diff --git a/.gitignore b/.gitignore index 047dc40..e607e40 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts -todo.md \ No newline at end of file +todo.md + +# MCP +mcp.json diff --git a/Interview_agent_dev_spec.md b/Interview_agent_dev_spec.md new file mode 100644 index 0000000..84c74aa --- /dev/null +++ b/Interview_agent_dev_spec.md @@ -0,0 +1,138 @@ +# Interview Agent Development Specification + +## Overview + +The OpenAI Realtime Agents Interview Application is a Next.js-based web application that leverages OpenAI's Realtime API to create interactive voice-based agents for conducting structured interviews. The application specializes in qualitative research and feedback collection for startup support engagements, featuring an agent-based system that can conduct interviews following predefined conversation flows. + +## Core Technologies + +- **Frontend**: Next.js, React, TypeScript, Tailwind CSS +- **Backend**: Next.js API routes +- **Database**: Supabase (PostgreSQL) +- **Real-time Communication**: WebSockets, RTCDataChannel +- **AI**: OpenAI Realtime API +- **Voice Processing**: WebRTC for audio streaming + +## System Architecture + +### Client-Server Model +- **Client**: React application that manages WebRTC connections and user interactions +- **Server**: Next.js API routes for database operations and OpenAI API interactions + +### Database Structure +- **Tables**: + - `interviews`: Stores interview metadata and session information + - `questions`: Stores interview questions with ordinal positions + - `answers`: Stores participant responses to questions + - `companies`: Reference table for organization information + - `people`: Reference table for interviewee information + - `support_engagements`: Reference table for specific support instances + +## Key Features + +### 1. Agent Management +- Pre-configured agent templates with customizable conversation flows +- Dynamic agent configuration based on interview context +- Voice customization (using "shimmer" voice) +- Speech playback optimization (1.25x speed) + +### 2. Interview Process +- Structured conversation states with transition rules +- Context-aware questioning based on interviewee responses +- Active listening with follow-up question generation +- Real-time transcription of conversation + +### 3. Realtime Voice Interaction +- Push-to-talk functionality +- Real-time voice streaming +- Voice activity detection (semantic_vad with high eagerness) +- Audio playback controls + +### 4. Data Persistence +- Interview session recording and storage +- Question and answer tracking +- Contextual metadata storage +- Support engagement linking + +### 5. User Interface +- Transcript visualization +- Event logging and monitoring +- Session control and management +- Interview creation and management + +## Agent Configuration + +The application supports configurable interview agents with: + +1. **Personality & Tone**: Professional yet friendly researcher persona +2. **Core Objectives**: Context-specific interview goals +3. **Engagement Context**: Dynamic fields for company and support information +4. **Conversation Flow**: Sequential question progression +5. **Conversation States**: Structured interview phases with transition rules + - Introduction + - Context questions + - Challenge identification + - Impact assessment + - Conclusion + +## Data Flow + +1. **Interview Setup**: + - Agent configuration loaded with contextual information + - WebRTC connection established with OpenAI Realtime API + - Session metadata stored in Supabase + +2. **Interview Execution**: + - Voice data streamed bidirectionally + - Conversation transcribed in real-time + - Responses processed by agent logic + - Follow-up questions generated contextually + +3. **Data Persistence**: + - Interview responses stored in database + - Metadata updated throughout session + - Full transcript preserved + +## API Endpoints + +### Interview Management +- `GET /api/interviews`: Retrieve all interviews with associated questions +- `POST /api/interviews/create`: Create a new interview with questions +- `GET /api/interviews/connect`: Connect to specific interview data + +### Session Management +- `GET /api/session`: Generate ephemeral keys for OpenAI Realtime API + +### Data Access +- Endpoints for companies, people, and support engagements + +## Deployment Considerations + +- Environment variables for API keys and database connections +- WebRTC compatibility considerations +- Audio processing requirements +- Database migration scripts for schema updates + +## Security + +- Ephemeral key management for OpenAI API +- Server-side data validation +- Secure database access patterns +- Client-side security measures + +## Future Enhancement Areas + +1. **Enhanced Analytics**: Interview data visualization and insights +2. **Improved Agent Intelligence**: More contextual awareness and natural conversation +3. **Multi-language Support**: Internationalization for global usage +4. **Integration Capabilities**: API endpoints for external system connections +5. **Advanced Question Generation**: Dynamic question creation based on previous responses + +## Development Guidelines + +1. Follow existing code conventions in the repository +2. Maintain agent configuration patterns for consistency +3. Use TypeScript interfaces for data validation +4. Implement proper error handling for API endpoints +5. Test WebRTC functionality across different environments +6. Document new agent configurations thoroughly \ No newline at end of file diff --git a/README.md b/README.md index 4e75c1a..422a4e2 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,23 @@ You should be able to use this repo to prototype your own multi-agent realtime v - Start the server with `npm run dev` - Open your browser to [http://localhost:3000](http://localhost:3000) to see the app. It should automatically connect to the `simpleExample` Agent Set. +## Supabase Integration + +This demo app includes integration with Supabase for retrieving real support engagement data. To use this functionality: + +1. Create a Supabase project at [https://supabase.com](https://supabase.com) +2. Set up your database with the following tables: + - `companies` - Information about companies receiving support + - `support_engagements` - Details about support engagements + +3. Update your `.env.development.local` file with your Supabase credentials: +``` +NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +``` + +4. The app will automatically fetch and use real data when you select the "startupInterviewer" agent. + ## Configuring Agents Configuration in `src/app/agentConfigs/simpleExample.ts` ```javascript diff --git a/components.json b/components.json new file mode 100644 index 0000000..0e8b633 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/interviewee_ux_spec.md b/interviewee_ux_spec.md new file mode 100644 index 0000000..6e95f19 --- /dev/null +++ b/interviewee_ux_spec.md @@ -0,0 +1,73 @@ +# Interviewee UX – Proof of Concept Specification + +_Last updated: {{DATE}}_ + +## 1. Access & Security + +| Item | Decision | +|------|----------| +| Link format | `https:///i/{invite_token}` – token in path segment | +| Auth | Route `/i/*` and `/app?candidate=1` exempt from auth middleware | +| Token validity | Link works as long as associated interview status is **not** `completed` | +| Re-use | Multiple openings allowed until marked completed | +| Expiration | none for POC | + +## 2. Entry Flow +1. Candidate clicks the invite link (`/i/{token}`). +2. Server resolves `invite_token` → `interview.id`. +3. If interview status is `completed`: redirect → `/invite-completed` (future page). + If token not found: redirect → `/invite-not-found` (future page). +4. Otherwise, redirect → `/app?interviewId={id}&candidate=1`. + +_No additional onboarding or mic-check screens for POC._ + +## 3. Candidate UI inside `/app` + +| Element | Behaviour / Notes | +|---------|-------------------| +| Header | Minimal: "Interview Session" + product logo. No scenario/agent selectors. | +| Main area | `InterviewExperience` component reused. Shows:
• Current question (medium size, centred left column)
• Progress text: "Question N of M"
• Horizontal progress bar
• Audio-wave visualisation canvas below
• Status pill (Live/Connecting) | +| Agent state indicator | `Agent is speaking…` (green pulse) vs `Agent is listening…` (grey) | +| Transcript & Events panes | **Hidden** in candidate view | +| Bottom toolbar | Temporarily left visible for dev controls; will be hidden in prod. | +| Typing fallback | Not implemented for POC | + +## 4. Completion & Thank-You + +Trigger: Client detects assistant's **final** message + session disconnect → sets `sessionStatus = DISCONNECTED`. + +Action: +* After 500 ms debounce, if `isCandidateView && isInterviewMode && sessionStatus === DISCONNECTED` → `router.push("/i/thank-you")`. + +_New in v2 – agent-driven completion_ + +* When the agent reaches its `wrap_up` conversation state it calls the function `markInterviewCompleted` with `{ "interview_id": }`. +* The front-end handles this function call (via `toolLogic`) which hits `POST /api/interviews/complete` and updates the DB. +* The subsequent disconnect triggers the existing redirect logic above. + +### Thank-You screen (`/i/thank-you`) +* Large headline: "Thank you for your time!" +* Sub-text: "You may now close this tab or return to Volta." +* Button `Return to Volta` → `https://voltaeffect.com` (opens new tab) +* No other navigation. +* Refreshing this page keeps the user on thank-you screen (static route). + +## 5. Edge Cases / Out-of-Scope +* Mic permission failures → **not** handled (risk accepted). +* Session resume after refresh during interview → deferred. +* One-time / time-limited tokens → deferred. +* Manual "Finish" button for candidate → deferred. +* Legal/privacy blurb → delivered verbally by AI, no UI display for POC. + +## 6. Implementation Notes +* `middleware.ts` updated: public routes `/i` & `/app` bypass auth. +* `/i/[token]/page.tsx` handles token resolution & redirect logic. +* Candidate mode detected via query param `candidate=1`. +* UI conditional logic in `App.tsx`: + * Hides transcript/events + * Hides bottom toolbar once ready for prod + * Tracks agent-speaking state via transcript items +* Thank-You page implemented at `src/app/i/thank-you/page.tsx`. + +--- +**Ready for developer hand-off.** \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index e9ffa30..3e0b9e1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,15 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "*.supabase.co", + pathname: "/storage/v1/object/public/**", + }, + ], + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 5823710..c559208 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,22 @@ "name": "realtime-examples", "version": "0.1.0", "dependencies": { + "@radix-ui/react-label": "^2.1.4", + "@radix-ui/react-slot": "^1.2.0", + "@supabase/auth-helpers-nextjs": "^0.10.0", + "@supabase/supabase-js": "^2.49.4", + "@types/uuid": "^10.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.507.0", "next": "15.1.4", "openai": "^4.77.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^9.0.3", - "uuid": "^11.0.4" + "tailwind-merge": "^3.2.0", + "tailwindcss-animate": "^1.0.7", + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -31,7 +41,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -608,7 +617,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -626,7 +634,6 @@ "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/set-array": "^1.2.1", @@ -641,7 +648,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -651,7 +657,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -661,14 +666,12 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -823,7 +826,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -837,7 +839,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -847,7 +848,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -871,13 +871,91 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { "node": ">=14" } }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.4.tgz", + "integrity": "sha512-wy3dqizZnZVV4ja0FNnUhIWNwWdoldXrneEyUcVtLYDAt8ovGS4ridtMAOGgXBBIfggL4BOveVWsjXDORdGEQg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz", + "integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -892,6 +970,107 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-helpers-nextjs": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-helpers-nextjs/-/auth-helpers-nextjs-0.10.0.tgz", + "integrity": "sha512-2dfOGsM4yZt0oS4TPiE7bD4vf7EVz7NRz/IJrV6vLg0GP7sMUx8wndv2euLGq4BjN9lUCpu6DG/uCC8j+ylwPg==", + "deprecated": "This package is now deprecated - please use the @supabase/ssr package instead.", + "license": "MIT", + "dependencies": { + "@supabase/auth-helpers-shared": "0.7.0", + "set-cookie-parser": "^2.6.0" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.39.8" + } + }, + "node_modules/@supabase/auth-helpers-shared": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-helpers-shared/-/auth-helpers-shared-0.7.0.tgz", + "integrity": "sha512-FBFf2ei2R7QC+B/5wWkthMha8Ca2bWHAndN+syfuEUUfufv4mLcAgBCcgNg5nJR8L0gZfyuaxgubtOc9aW3Cpg==", + "deprecated": "This package is now deprecated - please use the @supabase/ssr package instead.", + "license": "MIT", + "dependencies": { + "jose": "^4.14.4" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.39.8" + } + }, + "node_modules/@supabase/auth-js": { + "version": "2.69.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.69.1.tgz", + "integrity": "sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz", + "integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.19.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz", + "integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.2.tgz", + "integrity": "sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14", + "@types/phoenix": "^1.5.4", + "@types/ws": "^8.5.10", + "ws": "^8.18.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", + "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.49.4", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.49.4.tgz", + "integrity": "sha512-jUF0uRUmS8BKt37t01qaZ88H9yV1mbGYnqLeuFWLcdV+x1P4fl0yP9DGtaEhFPZcwSom7u16GkLEH9QJZOqOkw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.69.1", + "@supabase/functions-js": "2.4.4", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.19.4", + "@supabase/realtime-js": "2.11.2", + "@supabase/storage-js": "2.7.1" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -988,6 +1167,12 @@ "form-data": "^4.0.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.0.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.3.tgz", @@ -1001,7 +1186,7 @@ "version": "19.0.2", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.2.tgz", "integrity": "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -1013,6 +1198,21 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.19.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.1.tgz", @@ -1323,7 +1523,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1336,7 +1535,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1352,14 +1550,12 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -1373,7 +1569,6 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -1613,14 +1808,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1644,7 +1837,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -1728,7 +1920,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -1825,7 +2016,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -1850,7 +2040,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -1859,12 +2048,33 @@ "node": ">= 6" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -1883,7 +2093,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1896,7 +2105,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "devOptional": true, "license": "MIT" }, "node_modules/color-string": { @@ -1936,7 +2144,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -1953,7 +2160,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1968,7 +2174,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -2162,14 +2367,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, "license": "Apache-2.0" }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, "license": "MIT" }, "node_modules/doctrine": { @@ -2204,14 +2407,12 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/enhanced-resolve": { @@ -2948,7 +3149,6 @@ "version": "1.18.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -2971,7 +3171,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -3032,7 +3231,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.0", @@ -3082,7 +3280,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3097,7 +3294,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3208,7 +3404,6 @@ "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -3229,7 +3424,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -3242,7 +3436,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -3252,7 +3445,6 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -3406,7 +3598,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -3620,7 +3811,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -3673,7 +3863,6 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -3734,7 +3923,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3760,7 +3948,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3789,7 +3976,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -3825,7 +4011,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -4016,7 +4201,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/iterator.prototype": { @@ -4041,7 +4225,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -4057,12 +4240,20 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4181,7 +4372,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -4194,7 +4384,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -4247,9 +4436,17 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, + "node_modules/lucide-react": { + "version": "0.507.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.507.0.tgz", + "integrity": "sha512-XfgE6gvAHwAtnbUvWiTTHx4S3VGR+cUJHEc0vrh9Ogu672I1Tue2+Cp/8JJqpytgcBHAB1FVI297W4XGNwc2dQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4417,7 +4614,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -4869,7 +5065,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -4927,7 +5122,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -4943,7 +5137,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -5101,7 +5294,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5111,7 +5303,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5121,7 +5312,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5352,7 +5542,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { @@ -5407,7 +5596,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5417,14 +5605,12 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -5447,7 +5633,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -5460,7 +5645,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5470,7 +5654,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5490,7 +5673,6 @@ "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5519,7 +5701,6 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -5537,7 +5718,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, "license": "MIT", "dependencies": { "camelcase-css": "^2.0.1" @@ -5557,7 +5737,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5593,7 +5772,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5619,7 +5797,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -5633,7 +5810,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -5682,7 +5858,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -5757,7 +5932,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -5767,7 +5941,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -5857,7 +6030,6 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -5898,7 +6070,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -5909,7 +6080,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -6003,6 +6173,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6096,7 +6272,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6109,7 +6284,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6195,7 +6369,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -6252,7 +6425,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -6271,7 +6443,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6286,7 +6457,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6296,14 +6466,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6443,7 +6611,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -6460,7 +6627,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6473,7 +6639,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6538,7 +6703,6 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -6574,7 +6738,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6583,11 +6746,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.2.0.tgz", + "integrity": "sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", - "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -6621,11 +6793,19 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, "node_modules/tailwindcss/node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -6642,7 +6822,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -6665,7 +6844,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -6675,7 +6853,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -6688,7 +6865,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -6740,7 +6916,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/tsconfig-paths": { @@ -6993,13 +7168,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/uuid": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.4.tgz", - "integrity": "sha512-IzL6VtTTYcAhA/oghbFJ1Dkmqev+FpQWnCBaKq/gUluLxliWvO8DPFWfIviRmYbtaavtSQe4WBL++rFjdcGWEg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -7066,7 +7240,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -7180,7 +7353,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -7199,7 +7371,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -7217,7 +7388,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7227,14 +7397,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -7249,7 +7417,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7262,7 +7429,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -7271,11 +7437,31 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index df3110c..96d9ed5 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,22 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-label": "^2.1.4", + "@radix-ui/react-slot": "^1.2.0", + "@supabase/auth-helpers-nextjs": "^0.10.0", + "@supabase/supabase-js": "^2.49.4", + "@types/uuid": "^10.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.507.0", "next": "15.1.4", "openai": "^4.77.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^9.0.3", - "uuid": "^11.0.4" + "tailwind-merge": "^3.2.0", + "tailwindcss-animate": "^1.0.7", + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/src/app/App.tsx b/src/app/App.tsx index e785ca7..04649e0 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,8 +1,9 @@ "use client"; import React, { useEffect, useRef, useState } from "react"; -import { useSearchParams } from "next/navigation"; +import { useSearchParams, useRouter } from "next/navigation"; import { v4 as uuidv4 } from "uuid"; +import Link from "next/link"; import Image from "next/image"; @@ -10,6 +11,7 @@ import Image from "next/image"; import Transcript from "./components/Transcript"; import Events from "./components/Events"; import BottomToolbar from "./components/BottomToolbar"; +import InterviewExperience from "@/app/components/InterviewExperience"; // Types import { AgentConfig, SessionStatus } from "@/app/types"; @@ -17,24 +19,47 @@ import { AgentConfig, SessionStatus } from "@/app/types"; // Context providers & hooks import { useTranscript } from "@/app/contexts/TranscriptContext"; import { useEvent } from "@/app/contexts/EventContext"; +import { AgentActivityProvider, useAgentActivity } from "@/app/contexts/AgentActivityContext"; import { useHandleServerEvent } from "./hooks/useHandleServerEvent"; // Utilities import { createRealtimeConnection } from "./lib/realtimeConnection"; +import { createUpdatedAgentConfig } from "./lib/engagementHelpers"; // Agent configs import { allAgentSets, defaultAgentSetKey } from "@/app/agentConfigs"; +import { startupInterviewerTemplate } from "@/app/agentConfigs/supportFeedback"; + +// New imports for interview mode data handling +import { getInterviewWithRelationsClient as getInterviewWithRelations } from "@/app/lib/interviewClientHelper"; +import type { InterviewWithRelations as InterviewData } from "@/app/lib/interviewClientHelper"; +import { createInterviewAgentConfig } from "@/app/lib/createInterviewConfig"; // Already used by InterviewAgent, ensure App.tsx can use it too + +// New import for InterviewAgent +import InterviewAgent from "./components/InterviewAgent"; function App() { const searchParams = useSearchParams(); + const router = useRouter(); - const { transcriptItems, addTranscriptMessage, addTranscriptBreadcrumb } = + const { transcriptItems, addTranscriptMessage, addTranscriptBreadcrumb, saveTranscriptData } = useTranscript(); const { logClientEvent, logServerEvent } = useEvent(); + const { + activityState, + setIsMicrophoneActive, + setIsHearingUser, + setIsThinking, + setIsSpeakingAudio, + setIsSpeakingText, + } = useAgentActivity(); const [selectedAgentName, setSelectedAgentName] = useState(""); const [selectedAgentConfigSet, setSelectedAgentConfigSet] = useState(null); + const [customAgentConfig, setCustomAgentConfig] = useState(null); + + const [isInterviewMode, setIsInterviewMode] = useState(false); const [dataChannel, setDataChannel] = useState(null); const pcRef = useRef(null); @@ -51,6 +76,23 @@ function App() { const [isAudioPlaybackEnabled, setIsAudioPlaybackEnabled] = useState(true); + // State for initial data loading for non-interview mode (startupInterviewer) + const [, setEngagementData] = useState(null); + const [isLoadingEngagementData, setIsLoadingEngagementData] = useState(false); + const [engagementError, setEngagementError] = useState(null); + + // New state for interview mode data loading & validation by App.tsx + const [isInterviewSetupLoading, setIsInterviewSetupLoading] = useState(false); + const [interviewSetupError, setInterviewSetupError] = useState(null); + const [validatedInterviewDataForAgent, setValidatedInterviewDataForAgent] = useState(null); + + // New derived state for agent display status, similar to session/page.tsx + const [agentDisplayStatus, setAgentDisplayStatus] = useState("Agent is listening..."); + + // Candidate view flag based on query param + const isCandidateView = searchParams.get("candidate") === "1"; + const interviewId = searchParams.get("interviewId"); + const sendClientEvent = (eventObj: any, eventNameSuffix = "") => { if (dcRef.current && dcRef.current.readyState === "open") { logClientEvent(eventObj, eventNameSuffix); @@ -67,15 +109,62 @@ function App() { } }; + // React when agent marks interview complete + const handleFunctionResult = (name: string, result: any) => { + if ( + name === "markInterviewCompleted" && + result?.success && + isCandidateView && + isInterviewMode + ) { + // Give slight delay for disconnect then redirect + setTimeout(() => { + router.push("/i/thank-you"); + }, 300); + } + }; + const handleServerEventRef = useHandleServerEvent({ setSessionStatus, selectedAgentName, selectedAgentConfigSet, sendClientEvent, setSelectedAgentName, + customAgentConfig, + onFunctionResult: handleFunctionResult, + setIsHearingUser, + setIsThinking, + setIsSpeakingAudio, + setIsSpeakingText, }); + // Effect to update agentDisplayStatus based on activityState + useEffect(() => { + console.log("[App.tsx] Activity State Update:", JSON.stringify(activityState)); // DEBUG LOG + if (activityState.isThinking) { + setAgentDisplayStatus("Agent is thinking..."); + } else if (activityState.isSpeakingAudio) { + // No specific text for agent speaking, visualization handles it. + // Status can remain "Listening..." or similar general state. + setAgentDisplayStatus("Listening..."); + } else if (activityState.isHearingUser) { + setAgentDisplayStatus("Listening..."); // User speaking, visual feedback primarily from canvas + } else { // Default idle state + setAgentDisplayStatus("Listening..."); + } + }, [activityState]); + useEffect(() => { + const currentInterviewId = searchParams.get("interviewId"); + setIsInterviewMode(!!currentInterviewId); + + if (currentInterviewId) { + console.log("App.tsx: Interview mode detected, ID:", currentInterviewId); + // Initiate data fetching and agent config creation for interview mode + setupInterviewSession(currentInterviewId); + return; // Stop further default agent setup + } + let finalAgentConfig = searchParams.get("agentConfig"); if (!finalAgentConfig || !allAgentSets[finalAgentConfig]) { finalAgentConfig = defaultAgentSetKey; @@ -93,27 +182,31 @@ function App() { }, [searchParams]); useEffect(() => { - if (selectedAgentName && sessionStatus === "DISCONNECTED") { + if ((selectedAgentName || customAgentConfig) && sessionStatus === "DISCONNECTED") { connectToRealtime(); } - }, [selectedAgentName]); + }, [selectedAgentName, customAgentConfig]); useEffect(() => { - if ( - sessionStatus === "CONNECTED" && - selectedAgentConfigSet && - selectedAgentName - ) { - const currentAgent = selectedAgentConfigSet.find( - (a) => a.name === selectedAgentName - ); - addTranscriptBreadcrumb( - `Agent: ${selectedAgentName}`, - currentAgent - ); - updateSession(true); + if (sessionStatus === "CONNECTED") { + if (customAgentConfig) { + addTranscriptBreadcrumb( + `Interview Agent: ${customAgentConfig.name}`, + customAgentConfig + ); + updateSessionWithCustomConfig(); + } else if (selectedAgentConfigSet && selectedAgentName) { + const currentAgent = selectedAgentConfigSet.find( + (a) => a.name === selectedAgentName + ); + addTranscriptBreadcrumb( + `Agent: ${selectedAgentName}`, + currentAgent + ); + updateSession(true); + } } - }, [selectedAgentConfigSet, selectedAgentName, sessionStatus]); + }, [selectedAgentConfigSet, selectedAgentName, customAgentConfig, sessionStatus]); useEffect(() => { if (sessionStatus === "CONNECTED") { @@ -130,14 +223,19 @@ function App() { const data = await tokenResponse.json(); logServerEvent(data, "fetch_session_token_response"); - if (!data.client_secret?.value) { + const secret = + typeof data.client_secret === "string" + ? data.client_secret + : data.client_secret?.value; + + if (!secret) { logClientEvent(data, "error.no_ephemeral_key"); console.error("No ephemeral key provided by the server"); setSessionStatus("DISCONNECTED"); return null; } - return data.client_secret.value; + return secret; }; const connectToRealtime = async () => { @@ -147,23 +245,54 @@ function App() { try { const EPHEMERAL_KEY = await fetchEphemeralKey(); if (!EPHEMERAL_KEY) { + setSessionStatus("DISCONNECTED"); + setIsMicrophoneActive(false); return; } if (!audioElementRef.current) { audioElementRef.current = document.createElement("audio"); } + audioElementRef.current.playbackRate = 1.0; audioElementRef.current.autoplay = isAudioPlaybackEnabled; const { pc, dc } = await createRealtimeConnection( EPHEMERAL_KEY, - audioElementRef + audioElementRef, + { + onMicrophoneActive: () => setIsMicrophoneActive(true), + onAgentAudioStart: () => { + setIsThinking(false); + setIsSpeakingAudio(true); + + // Define a handler for when audio playback ends + const handleAudioEnded = () => { + setIsSpeakingAudio(false); + // Clean up the event listener + if (audioElementRef.current) { + audioElementRef.current.removeEventListener('ended', handleAudioEnded); + } + }; + + // Add the event listener to the audio element + if (audioElementRef.current) { + // Remove any existing listener first to be safe, then add + audioElementRef.current.removeEventListener('ended', handleAudioEnded); // Precautionary removal + audioElementRef.current.addEventListener('ended', handleAudioEnded, { once: true }); + } + } + } ); pcRef.current = pc; dcRef.current = dc; dc.addEventListener("open", () => { logClientEvent({}, "data_channel.open"); + if (customAgentConfig) { + updateSessionWithCustomConfig(false); + } else if (selectedAgentConfigSet && selectedAgentName) { + updateSession(false); + } }); dc.addEventListener("close", () => { logClientEvent({}, "data_channel.close"); @@ -184,18 +313,20 @@ function App() { const disconnectFromRealtime = () => { if (pcRef.current) { - pcRef.current.getSenders().forEach((sender) => { - if (sender.track) { - sender.track.stop(); - } - }); - pcRef.current.close(); pcRef.current = null; } - setDataChannel(null); + if (dcRef.current) { + dcRef.current.close(); + dcRef.current = null; + } setSessionStatus("DISCONNECTED"); - setIsPTTUserSpeaking(false); + addTranscriptBreadcrumb("Disconnected"); + setIsMicrophoneActive(false); + setIsHearingUser(false); + setIsThinking(false); + setIsSpeakingAudio(false); + setIsSpeakingText(false); logClientEvent({}, "disconnected"); }; @@ -235,10 +366,8 @@ function App() { const turnDetection = isPTTActive ? null : { - type: "server_vad", - threshold: 0.5, - prefix_padding_ms: 300, - silence_duration_ms: 200, + type: "semantic_vad", + eagerness: "high", create_response: true, }; @@ -250,10 +379,15 @@ function App() { session: { modalities: ["text", "audio"], instructions, - voice: "coral", + voice: "shimmer", input_audio_format: "pcm16", output_audio_format: "pcm16", - input_audio_transcription: { model: "whisper-1" }, + input_audio_transcription: { + model: "gpt-4o-mini-transcribe" + }, + input_audio_noise_reduction: { + type: "near_field" + }, turn_detection: turnDetection, tools, }, @@ -336,6 +470,17 @@ function App() { const onToggleConnection = () => { if (sessionStatus === "CONNECTED" || sessionStatus === "CONNECTING") { + // If in interview mode, save transcript data when disconnecting + if (isInterviewMode) { + const interviewId = searchParams.get("interviewId"); + if (interviewId) { + console.log("Saving transcript data before disconnecting..."); + saveTranscriptData(interviewId).catch(err => { + console.error("Error saving transcript on disconnect:", err); + }); + } + } + disconnectFromRealtime(); setSessionStatus("DISCONNECTED"); } else { @@ -391,6 +536,7 @@ function App() { useEffect(() => { if (audioElementRef.current) { + audioElementRef.current.playbackRate = 1.5; if (isAudioPlaybackEnabled) { audioElementRef.current.play().catch((err) => { console.warn("Autoplay may be blocked by browser:", err); @@ -401,117 +547,425 @@ function App() { } }, [isAudioPlaybackEnabled]); + useEffect(() => { + if (selectedAgentName === "startupInterviewer" && selectedAgentConfigSet) { + setIsLoadingEngagementData(true); + setEngagementError(null); + + // Check for interview ID in URL parameters + const searchParams = new URLSearchParams(window.location.search); + const interviewId = searchParams.get('interviewId'); + + // Determine which API endpoint to use based on available parameters + const apiUrl = interviewId + ? `/api/engagement?interviewId=${interviewId}` + : `/api/engagement?id=08ea46fc-f85f-4176-a139-54caa44fda7e`; // Fallback to hardcoded ID + + // Fetch real data from our API + fetch(apiUrl) + .then(res => { + if (!res.ok) { + throw new Error(`Failed to fetch data: ${res.status}`); + } + return res.json(); + }) + .then(data => { + console.log("Received data for agent configuration:", data); + setEngagementData(data); + + // If we have interview data, use the template for better customization + const baseAgent = interviewId && startupInterviewerTemplate + ? startupInterviewerTemplate + : selectedAgentConfigSet.find(a => a.name === "startupInterviewer"); + + const updatedAgent = createUpdatedAgentConfig(baseAgent, data); + + if (updatedAgent) { + // Replace the agent with our data-filled version + setSelectedAgentConfigSet(prevSet => + prevSet?.map(a => a.name === "startupInterviewer" ? updatedAgent : a) || null + ); + + // Construct a descriptive breadcrumb based on available data + const company = data.company?.business_name || "Unknown Company"; + const engagement = data.engagement?.title || "Unknown Engagement"; + const person = data.person + ? `${data.person.first_name} ${data.person.last_name}` + : "Unknown Contact"; + + addTranscriptBreadcrumb( + `Updated Agent: ${selectedAgentName}`, + { + interview: data.interview?.id || "N/A", + company, + engagement, + person, + questions: data.questions?.length || 0, + usingTemplate: !!interviewId + } + ); + } + + setIsLoadingEngagementData(false); + }) + .catch(err => { + console.error("Error fetching data:", err); + setEngagementError(err.message); + setIsLoadingEngagementData(false); + + addTranscriptBreadcrumb( + `Error loading data for ${selectedAgentName}`, + { error: err.message } + ); + }); + } + }, [selectedAgentName, selectedAgentConfigSet]); + const agentSetKey = searchParams.get("agentConfig") || "default"; - return ( -
-
-
-
window.location.reload()} style={{ cursor: 'pointer' }}> - OpenAI Logo -
-
- Realtime API Agents + // New function to update session with custom agent config + const updateSessionWithCustomConfig = (shouldTriggerResponse: boolean = true) => { + if (!customAgentConfig || !dcRef.current) return; + + const eventObj = { + type: "session.update", + session: { + modalities: ["text", "audio"], + instructions: customAgentConfig.instructions, + voice: "shimmer", + input_audio_format: "pcm16", + output_audio_format: "pcm16", + input_audio_transcription: { + model: "gpt-4o-mini-transcribe" + }, + input_audio_noise_reduction: { + type: "near_field" + }, + turn_detection: isPTTActive ? null : { + type: "semantic_vad", + eagerness: "high", + create_response: true, + }, + tools: customAgentConfig.tools || [], + } + }; + + sendClientEvent(eventObj, "update_session"); + + if (shouldTriggerResponse) { + sendSimulatedUserMessage("hi"); + } + }; + + // Function to handle receiving a custom agent config from InterviewAgent + const handleAgentConfigLoaded = (config: AgentConfig) => { + // This is called by InterviewAgent. For interview mode, App.tsx now sets customAgentConfig first. + // If config is different, it will update. If same, no real change. + console.log("App.tsx: handleAgentConfigLoaded called by InterviewAgent. Current customAgentConfig:", customAgentConfig?.name, "New:", config.name); + if (JSON.stringify(customAgentConfig) !== JSON.stringify(config)) { + setCustomAgentConfig(config); + } + }; + + // Redirect to thank-you only when interview is marked complete + useEffect(() => { + if (isCandidateView && sessionStatus === "DISCONNECTED" && isInterviewMode) { + const interviewId = searchParams.get("interviewId"); + if (!interviewId) return; + + const checkStatus = async () => { + try { + const res = await fetch(`/api/interviews/${interviewId}`); + const data = await res.json(); + if (data?.status === "completed") { + router.push("/i/thank-you"); + } + } catch (err) { + console.error("Failed to check interview status", err); + } + }; + + checkStatus(); + } + }, [sessionStatus, isCandidateView, isInterviewMode, router, searchParams]); + + // Function to setup interview session (fetch, validate, configure agent) + const setupInterviewSession = async (id: string) => { + setIsInterviewSetupLoading(true); + setInterviewSetupError(null); + setValidatedInterviewDataForAgent(null); + setCustomAgentConfig(null); // Clear previous custom config + + try { + console.log(`App.tsx: Setting up interview session for ID: ${id}`); + const data = await getInterviewWithRelations(id); + + if (!data) { + throw new Error("Interview data could not be retrieved."); + } + if (!data.company) { + throw new Error("Company details are missing. Cannot start interview session."); + } + if (!data.person) { + throw new Error("Contact person details are missing. Cannot start interview session."); + } + if (!data.support_engagement) { + throw new Error("Support engagement details are missing. Cannot start interview session."); + } + if (!data.questions || data.questions.length === 0) { + throw new Error("No questions found for this interview. Cannot start interview session."); + } + + console.log("App.tsx: Interview data validated successfully:", data); + setValidatedInterviewDataForAgent(data); // Store for passing to InterviewAgent if needed + + const agentConfig = createInterviewAgentConfig(data); + console.log("App.tsx: Agent config created for interview:", agentConfig.name); + setCustomAgentConfig(agentConfig); // This will trigger connection via useEffect + + } catch (err: any) { + console.error("App.tsx: Error setting up interview session:", err); + setInterviewSetupError(err.message || "An unknown error occurred while setting up the interview."); + } finally { + setIsInterviewSetupLoading(false); + } + }; + + // Add the missing onToggleAudioPlayback function definition + const onToggleAudioPlayback = () => { + setIsAudioPlaybackEnabled(prev => !prev); + }; + + if (isInterviewMode) { + if (isInterviewSetupLoading) { + return ( +
+
+

Preparing Your Interview...

+

Please wait while we load the details.

+ {/* Add a spinner here */}
-
- -
- -
- - - -
+ Back to Interviews +
+
+ ); + } + // If setup is complete (not loading, no error), the rest of the App renders, + // and customAgentConfig should be set, triggering connection. + } - {agentSetKey && ( -
- -
- -
- + {/* Audio element for agent speech */} + +
+
+
+ {!isCandidateView && ( +
window.location.reload()} style={{ cursor: 'pointer' }}> + OpenAI Logo +
+ )} +
+ {!isCandidateView ? ( + <>Realtime API Agents + ) : ( + <>Volta Research + )} +
+
+ {!isCandidateView && ( +
+ {!isInterviewMode && ( + <> + +
+ +
+ + + +
-
+ + {agentSetKey && ( +
+ +
+ +
+ + + +
+
+
+ )} + + )} + {isInterviewMode && ( +
Interview Mode
+ )} +
+ )} +
+ + {isInterviewMode ? ( + isCandidateView ? ( +
+ {/* Pass validated data and ID to InterviewAgent. It will use this and not re-fetch. */} + {/* Its onAgentConfigLoaded will be called, but App.tsx already set the primary config. */} + +
+ ) : ( +
+
+ ) + ) : null} + +
+ {!isCandidateView ? ( + <> + + + + ) : ( + interviewId && ( +
+ {validatedInterviewDataForAgent && ( + + )} +
+ ) )}
-
-
- a.name === selectedAgentName) || undefined} /> + )} - + {isLoadingEngagementData && ( +
+ Loading engagement data... +
+ )} + + {engagementError && ( +
+ Error: {engagementError} +
+ )}
+ + ); +} - -
+const AppWrapper: React.FC = () => { + return ( + + + ); } -export default App; +export default AppWrapper; diff --git a/src/app/agentConfigs/customerServiceRetail/authentication.ts b/src/app/agentConfigs/customerServiceRetail/authentication.ts deleted file mode 100644 index 90eefba..0000000 --- a/src/app/agentConfigs/customerServiceRetail/authentication.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { AgentConfig } from "@/app/types"; - -const authentication: AgentConfig = { - name: "authentication", - publicDescription: - "The initial agent that greets the user, does authentication and routes them to the correct downstream agent.", - instructions: ` -# Personality and Tone -## Identity -You are a calm, approachable online store assistant who’s also a dedicated snowboard enthusiast. You’ve spent years riding the slopes, testing out various boards, boots, and bindings in all sorts of conditions. Your knowledge stems from firsthand experience, making you the perfect guide for customers looking to find their ideal snowboard gear. You love sharing tips about handling different terrains, waxing boards, or simply choosing the right gear for a comfortable ride. - -## Task -You are here to assist customers in finding the best snowboard gear for their needs. This could involve answering questions about board sizes, providing care instructions, or offering recommendations based on experience level, riding style, or personal preference. - -## Demeanor -You maintain a relaxed, friendly demeanor while remaining attentive to each customer’s needs. Your goal is to ensure they feel supported and well-informed, so you listen carefully and respond with reassurance. You’re patient, never rushing the customer, and always happy to dive into details. - -## Tone -Your voice is warm and conversational, with a subtle undercurrent of excitement for snowboarding. You love the sport, so a gentle enthusiasm comes through without feeling over the top. - -## Level of Enthusiasm -You’re subtly enthusiastic—eager to discuss snowboarding and related gear but never in a way that might overwhelm a newcomer. Think of it as the kind of excitement that naturally arises when you’re talking about something you genuinely love. - -## Level of Formality -Your style is moderately professional. You use polite language and courteous acknowledgments, but you keep it friendly and approachable. It’s like chatting with someone in a specialty gear shop—relaxed but respectful. - -## Level of Emotion -You are supportive, understanding, and empathetic. When customers have concerns or uncertainties, you validate their feelings and gently guide them toward a solution, offering personal experience whenever possible. - -## Filler Words -You occasionally use filler words like “um,” “hmm,” or “you know?” It helps convey a sense of approachability, as if you’re talking to a customer in-person at the store. - -## Pacing -Your pacing is medium—steady and unhurried. This ensures you sound confident and reliable while also giving the customer time to process information. You pause briefly if they seem to need extra time to think or respond. - -## Other details -You’re always ready with a friendly follow-up question or a quick tip gleaned from your years on the slopes. - -# Context -- Business name: Snowy Peak Boards -- Hours: Monday to Friday, 8:00 AM - 6:00 PM; Saturday, 9:00 AM - 1:00 PM; Closed on Sundays -- Locations (for returns and service centers): - - 123 Alpine Avenue, Queenstown 9300, New Zealand - - 456 Glacier Road, Wanaka 9305, New Zealand -- Products & Services: - - Wide variety of snowboards for all skill levels - - Snowboard accessories and gear (boots, bindings, helmets, goggles) - - Online fitting consultations - - Loyalty program offering discounts and early access to new product lines - -# Reference Pronunciations -- “Snowy Peak Boards”: SNOW-ee Peek Bords -- “Schedule”: SHED-yool -- “Noah”: NOW-uh - -# Overall Instructions -- Your capabilities are limited to ONLY those that are provided to you explicitly in your instructions and tool calls. You should NEVER claim abilities not granted here. -- Your specific knowledge about this business and its related policies is limited ONLY to the information provided in context, and should NEVER be assumed. -- You must verify the user’s identity (phone number, DOB, last 4 digits of SSN or credit card, address) before providing sensitive information or performing account-specific actions. -- Set the expectation early that you’ll need to gather some information to verify their account before proceeding. -- Don't say "I'll repeat it back to you to confirm" beforehand, just do it. -- Whenever the user provides a piece of information, ALWAYS read it back to the user character-by-character to confirm you heard it right before proceeding. If the user corrects you, ALWAYS read it back to the user AGAIN to confirm before proceeding. -- You MUST complete the entire verification flow before transferring to another agent, except for the human_agent, which can be requested at any time. - -# Conversation States -[ - { - "id": "1_greeting", - "description": "Begin each conversation with a warm, friendly greeting, identifying the service and offering help.", - "instructions": [ - "Use the company name 'Snowy Peak Boards' and provide a warm welcome.", - "Let them know upfront that for any account-specific assistance, you’ll need some verification details." - ], - "examples": [ - "Hello, this is Snowy Peak Boards. Thanks for reaching out! How can I help you today?" - ], - "transitions": [{ - "next_step": "2_get_first_name", - "condition": "Once greeting is complete." - }, { - "next_step": "3_get_and_verify_phone", - "condition": "If the user provides their first name." - }] - }, - { - "id": "2_get_first_name", - "description": "Ask for the user’s name (first name only).", - "instructions": [ - "Politely ask, 'Who do I have the pleasure of speaking with?'", - "Do NOT verify or spell back the name; just accept it." - ], - "examples": [ - "Who do I have the pleasure of speaking with?" - ], - "transitions": [{ - "next_step": "3_get_and_verify_phone", - "condition": "Once name is obtained, OR name is already provided." - }] - }, - { - "id": "3_get_and_verify_phone", - "description": "Request phone number and verify by repeating it back.", - "instructions": [ - "Politely request the user’s phone number.", - "Once provided, confirm it by repeating each digit and ask if it’s correct.", - "If the user corrects you, confirm AGAIN to make sure you understand.", - ], - "examples": [ - "I'll need some more information to access your account if that's okay. May I have your phone number, please?", - "You said 0-2-1-5-5-5-1-2-3-4, correct?", - "You said 4-5-6-7-8-9-0-1-2-3, correct?" - ], - "transitions": [{ - "next_step": "4_authentication_DOB", - "condition": "Once phone number is confirmed" - }] - }, - { - "id": "4_authentication_DOB", - "description": "Request and confirm date of birth.", - "instructions": [ - "Ask for the user’s date of birth.", - "Repeat it back to confirm correctness." - ], - "examples": [ - "Thank you. Could I please have your date of birth?", - "You said 12 March 1985, correct?" - ], - "transitions": [{ - "next_step": "5_authentication_SSN_CC", - "condition": "Once DOB is confirmed" - }] - }, - { - "id": "5_authentication_SSN_CC", - "description": "Request the last four digits of SSN or credit card and verify. Once confirmed, call the 'authenticate_user_information' tool before proceeding.", - "instructions": [ - "Ask for the last four digits of the user’s SSN or credit card.", - "Repeat these four digits back to confirm correctness, and confirm whether they're from SSN or their credit card", - "If the user corrects you, confirm AGAIN to make sure you understand.", - "Once correct, CALL THE 'authenticate_user_information' TOOL (required) before moving to address verification. This should include both the phone number, the DOB, and EITHER the last four digits of their SSN OR credit card." - ], - "examples": [ - "May I have the last four digits of either your Social Security Number or the credit card we have on file?", - "You said 1-2-3-4, correct? And is that from your credit card or social security number?" - ], - "transitions": [{ - "next_step": "6_get_user_address", - "condition": "Once SSN/CC digits are confirmed and 'authenticate_user_information' tool is called" - }] - }, - { - "id": "6_get_user_address", - "description": "Request and confirm the user’s street address. Once confirmed, call the 'save_or_update_address' tool.", - "instructions": [ - "Politely ask for the user’s street address.", - "Once provided, repeat it back to confirm correctness.", - "If the user corrects you, confirm AGAIN to make sure you understand.", - "Only AFTER confirmed, CALL THE 'save_or_update_address' TOOL before proceeding." - ], - "examples": [ - "Thank you. Now, can I please have your latest street address?", - "You said 123 Alpine Avenue, correct?" - ], - "transitions": [{ - "next_step": "7_disclosure_offer", - "condition": "Once address is confirmed and 'save_or_update_address' tool is called" - }] - }, - { - "id": "7_disclosure_offer", - "description": "Read the full promotional disclosure (10+ sentences) and instruct the model to ALWAYS say the entire disclosure verbatim, once verification is complete.", - "instructions": [ - "ALWAYS read the following disclosure VERBATIM, IN FULL, once all verification steps are complete:", - "", - "Disclosure (verbatim):", - "“At Snowy Peak Boards, we are committed to delivering exceptional value and a top-quality experience to all of our valued customers. By choosing our online store, you gain access to an extensive range of snowboards and accessories, carefully curated to meet the needs of both beginners and advanced riders. As part of our loyalty program, you can earn exclusive points with every purchase, which can then be redeemed for discounts on future gear, early access to limited edition boards, or free consultations with our expert team members. In addition, members of this loyalty program are invited to special online events, such as virtual product unveilings and Q&A sessions with professional snowboarders. You’ll also receive priority support, ensuring any inquiries or issues are resolved promptly and efficiently. Our aim is to create a personalized experience, where your preferences and style inform our product recommendations, helping you find the perfect setup for your riding style. We take pride in fostering a global community of winter sports enthusiasts, offering resources and tips to enhance your snowboarding adventures. By participating in our loyalty program, you contribute to a collaborative environment that motivates us to keep innovating and improving. Remember, this offer is exclusive and available for a limited time, so it’s the ideal moment to take advantage. Would you like to sign up for our loyalty program?”", - "", - "End of disclosure.", - "NEVER summarize or shorten this disclosure; ALWAYS say it in its entirety, exactly as written above, at a faster rate than normal to get through it in a timely manner.", - "Log the user's response with the 'update_user_offer_response' tool, with offer_id=\"a-592.\"", - "The user can interrupt the disclosure midway, either to accept or decline." - ], - "examples": [ - "I’d like to share a special offer with you. (Then read entire disclosure verbatim, speaking faster than normal.)...", - "Would you like to sign up?" - ], - "transitions": [{ - "next_step": "8_post_disclosure_assistance", - "condition": "Once the user indicates if they would or wouldn't like to sign up, and the update_user_offer_response tool has been called." - }] - }, - { - "id": "8_post_disclosure_assistance", - "description": "After sharing the disclosure and offer, proceed to assist with the user’s request.", - "instructions": [ - "Show the user that you remember their original request", - "Use your judgment for how best to assist with their request, while being transparent about what you don't know and aren't able to help with." - ], - "examples": [ - "Great, now I'd love to help you with {user's original intent}." - ], - "transitions": [{ - "next_step": "transferAgents", - "condition": "Once confirmed their intent, route to the correct agent with the transferAgents function." - }] - } -] -`, - tools: [ - { - type: "function", - name: "authenticate_user_information", - description: - "Look up a user's information with phone, last_4_cc_digits, last_4_ssn_digits, and date_of_birth to verify and authenticate the user. Should be run once the phone number and last 4 digits are confirmed.", - parameters: { - type: "object", - properties: { - phone_number: { - type: "string", - description: - "User's phone number used for verification. Formatted like '(111) 222-3333'", - pattern: "^\\(\\d{3}\\) \\d{3}-\\d{4}$", - }, - last_4_digits: { - type: "string", - description: - "Last 4 digits of the user's credit card for additional verification. Either this or 'last_4_ssn_digits' is required.", - }, - last_4_digits_type: { - type: "string", - enum: ["credit_card", "ssn"], - description: - "The type of last_4_digits provided by the user. Should never be assumed, always confirm.", - }, - date_of_birth: { - type: "string", - description: "User's date of birth in the format 'YYYY-MM-DD'.", - pattern: "^\\d{4}-\\d{2}-\\d{2}$", - }, - }, - required: [ - "phone_number", - "date_of_birth", - "last_4_digits", - "last_4_digits_type", - ], - additionalProperties: false, - }, - }, - { - type: "function", - name: "save_or_update_address", - description: - "Saves or updates an address for a given phone number. Should be run only if the user is authenticated and provides an address. Only run AFTER confirming all details with the user.", - parameters: { - type: "object", - properties: { - phone_number: { - type: "string", - description: "The phone number associated with the address", - }, - new_address: { - type: "object", - properties: { - street: { - type: "string", - description: "The street part of the address", - }, - city: { - type: "string", - description: "The city part of the address", - }, - state: { - type: "string", - description: "The state part of the address", - }, - postal_code: { - type: "string", - description: "The postal or ZIP code", - }, - }, - required: ["street", "city", "state", "postal_code"], - additionalProperties: false, - }, - }, - required: ["phone_number", "new_address"], - additionalProperties: false, - }, - }, - { - type: "function", - name: "update_user_offer_response", - description: - "A tool definition for signing up a user for a promotional offer", - parameters: { - type: "object", - properties: { - phone: { - type: "string", - description: "The user's phone number for contacting them", - }, - offer_id: { - type: "string", - description: "The identifier for the promotional offer", - }, - user_response: { - type: "string", - description: "The user's response to the promotional offer", - enum: ["ACCEPTED", "DECLINED", "REMIND_LATER"], - }, - }, - required: ["phone", "offer_id", "user_response"], - }, - }, - ], - toolLogic: {}, -}; - -export default authentication; diff --git a/src/app/agentConfigs/customerServiceRetail/index.ts b/src/app/agentConfigs/customerServiceRetail/index.ts deleted file mode 100644 index d728033..0000000 --- a/src/app/agentConfigs/customerServiceRetail/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import authentication from "./authentication"; -import returns from "./returns"; -import sales from "./sales"; -import simulatedHuman from "./simulatedHuman"; -import { injectTransferTools } from "../utils"; - -authentication.downstreamAgents = [returns, sales, simulatedHuman]; -returns.downstreamAgents = [authentication, sales, simulatedHuman]; -sales.downstreamAgents = [authentication, returns, simulatedHuman]; -simulatedHuman.downstreamAgents = [authentication, returns, sales]; - -const agents = injectTransferTools([ - authentication, - returns, - sales, - simulatedHuman, -]); - -export default agents; \ No newline at end of file diff --git a/src/app/agentConfigs/customerServiceRetail/returns.ts b/src/app/agentConfigs/customerServiceRetail/returns.ts deleted file mode 100644 index cdd9e05..0000000 --- a/src/app/agentConfigs/customerServiceRetail/returns.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { AgentConfig } from "@/app/types"; - -const returns: AgentConfig = { - name: "returns", - publicDescription: - "Customer Service Agent specialized in order lookups, policy checks, and return initiations.", - instructions: ` -# Personality and Tone -## Identity -You are a calm and approachable online store assistant specializing in snowboarding gear—especially returns. Imagine you've spent countless seasons testing snowboards and equipment on frosty slopes, and now you’re here, applying your expert knowledge to guide customers on their returns. Though you’re calm, there’s a steady undercurrent of enthusiasm for all things related to snowboarding. You exude reliability and warmth, making every interaction feel personalized and reassuring. - -## Task -Your primary objective is to expertly handle return requests. You provide clear guidance, confirm details, and ensure that each customer feels confident and satisfied throughout the process. Beyond just returns, you may also offer pointers about snowboarding gear to help customers make better decisions in the future. - -## Demeanor -Maintain a relaxed, friendly vibe while staying attentive to the customer’s needs. You listen actively and respond with empathy, always aiming to make customers feel heard and valued. - -## Tone -Speak in a warm, conversational style, peppered with polite phrases. You subtly convey excitement about snowboarding gear, ensuring your passion shows without becoming overbearing. - -## Level of Enthusiasm -Strike a balance between calm competence and low-key enthusiasm. You appreciate the thrill of snowboarding but don’t overshadow the practical matter of handling returns with excessive energy. - -## Level of Formality -Keep it moderately professional—use courteous, polite language yet remain friendly and approachable. You can address the customer by name if given. - -## Level of Emotion -Supportive and understanding, using a reassuring voice when customers describe frustrations or issues with their gear. Validate their concerns in a caring, genuine manner. - -## Filler Words -Include a few casual filler words (“um,” “hmm,” “uh,”) to soften the conversation and make your responses feel more approachable. Use them occasionally, but not to the point of distraction. - -## Pacing -Speak at a medium pace—steady and clear. Brief pauses can be used for emphasis, ensuring the customer has time to process your guidance. - -## Other details -- You have a strong accent. -- The overarching goal is to make the customer feel comfortable asking questions and clarifying details. -- Always confirm spellings of names and numbers to avoid mistakes. - -# Steps -1. Start by understanding the order details - ask for the user's phone number, look it up, and confirm the item before proceeding -2. Ask for more information about why the user wants to do the return. -3. See "Determining Return Eligibility" for how to process the return. - -## Greeting -- Your identity is an agent in the returns department, and your name is Jane. - - Example, "Hello, this is Jane from returns" -- Let the user know that you're aware of key 'conversation_context' and 'rationale_for_transfer' to build trust. - - Example, "I see that you'd like to {}, let's get started with that." - -## Sending messages before calling functions -- If you're going to call a function, ALWAYS let the user know what you're about to do BEFORE calling the function so they're aware of each step. - - Example: “Okay, I’m going to check your order details now.” - - Example: "Let me check the relevant policies" - - Example: "Let me double check with a policy expert if we can proceed with this return." -- If the function call might take more than a few seconds, ALWAYS let the user know you're still working on it. (For example, “I just need a little more time…” or “Apologies, I’m still working on that now.”) -- Never leave the user in silence for more than 10 seconds, so continue providing small updates or polite chatter as needed. - - Example: “I appreciate your patience, just another moment…” - -# Determining Return Eligibility -- First, pull up order information with the function 'lookupOrders()' and clarify the specific item they're talking about, including purchase dates which are relevant for the order. -- Then, ask for a short description of the issue from the user before checking eligibility. -- Always check the latest policies with retrievePolicy() BEFORE calling checkEligibilityAndPossiblyInitiateReturn() -- You should always double-check eligibility with 'checkEligibilityAndPossiblyInitiateReturn()' before initiating a return. -- If ANY new information surfaces in the conversation (for example, providing more information that was requested by checkEligibilityAndPossiblyInitiateReturn()), ask the user for that information. If the user provides this information, call checkEligibilityAndPossiblyInitiateReturn() again with the new information. -- Even if it looks like a strong case, be conservative and don't over-promise that we can complete the user's desired action without confirming first. The check might deny the user and that would be a bad user experience. -- If processed, let the user know the specific, relevant details and next steps - -# General Info -- Today's date is 12/26/2024 -`, - tools: [ - { - type: "function", - name: "lookupOrders", - description: - "Retrieve detailed order information by using the user's phone number, including shipping status and item details. Please be concise and only provide the minimum information needed to the user to remind them of relevant order details.", - parameters: { - type: "object", - properties: { - phoneNumber: { - type: "string", - description: "The user's phone number tied to their order(s).", - }, - }, - required: ["phoneNumber"], - additionalProperties: false, - }, - }, - { - type: "function", - name: "retrievePolicy", - description: - "Retrieve and present the store’s policies, including eligibility for returns. Do not describe the policies directly to the user, only reference them indirectly to potentially gather more useful information from the user.", - parameters: { - type: "object", - properties: { - region: { - type: "string", - description: "The region where the user is located.", - }, - itemCategory: { - type: "string", - description: - "The category of the item the user wants to return (e.g., shoes, accessories).", - }, - }, - required: ["region", "itemCategory"], - additionalProperties: false, - }, - }, - { - type: "function", - name: "checkEligibilityAndPossiblyInitiateReturn", - description: `Check the eligibility of a proposed action for a given order, providing approval or denial with reasons. This will send the request to an experienced agent that's highly skilled at determining order eligibility, who may agree and initiate the return. - -# Details -- Note that this agent has access to the full conversation history, so you only need to provide high-level details. -- ALWAYS check retrievePolicy first to ensure we have relevant context. -- Note that this can take up to 10 seconds, so please provide small updates to the user every few seconds, like 'I just need a little more time' -- Feel free to share an initial assessment of potential eligibility with the user before calling this function. -`, - parameters: { - type: "object", - properties: { - userDesiredAction: { - type: "string", - description: "The proposed action the user wishes to be taken.", - }, - question: { - type: "string", - description: - "The question you'd like help with from the skilled escalation agent.", - }, - }, - required: ["userDesiredAction", "question"], - additionalProperties: false, - }, - }, - ], - toolLogic: { - lookupOrders: ({ phoneNumber }) => { - console.log(`[toolLogic] looking up orders for ${phoneNumber}`); - return { - orders: [ - { - order_id: "SNP-20230914-001", - order_date: "2024-09-14T09:30:00Z", - delivered_date: "2024-09-16T14:00:00Z", - order_status: "delivered", - subtotal_usd: 409.98, - total_usd: 471.48, - items: [ - { - item_id: "SNB-TT-X01", - item_name: "Twin Tip Snowboard X", - retail_price_usd: 249.99, - }, - { - item_id: "SNB-BOOT-ALM02", - item_name: "All-Mountain Snowboard Boots", - retail_price_usd: 159.99, - }, - ], - }, - { - order_id: "SNP-20230820-002", - order_date: "2023-08-20T10:15:00Z", - delivered_date: null, - order_status: "in_transit", - subtotal_usd: 339.97, - total_usd: 390.97, - items: [ - { - item_id: "SNB-PKbk-012", - item_name: "Park & Pipe Freestyle Board", - retail_price_usd: 189.99, - }, - { - item_id: "GOG-037", - item_name: "Mirrored Snow Goggles", - retail_price_usd: 89.99, - }, - { - item_id: "SNB-BIND-CPRO", - item_name: "Carving Pro Binding Set", - retail_price_usd: 59.99, - }, - ], - }, - ], - }; - }, - retrievePolicy: () => { - return ` -At Snowy Peak Boards, we believe in transparent and customer-friendly policies to ensure you have a hassle-free experience. Below are our detailed guidelines: - -1. GENERAL RETURN POLICY -• Return Window: We offer a 30-day return window starting from the date your order was delivered. -• Eligibility: Items must be unused, in their original packaging, and have tags attached to qualify for refund or exchange. -• Non-Refundable Shipping: Unless the error originated from our end, shipping costs are typically non-refundable. - -2. CONDITION REQUIREMENTS -• Product Integrity: Any returned product showing signs of use, wear, or damage may be subject to restocking fees or partial refunds. -• Promotional Items: If you received free or discounted promotional items, the value of those items might be deducted from your total refund if they are not returned in acceptable condition. -• Ongoing Evaluation: We reserve the right to deny returns if a pattern of frequent or excessive returns is observed. - -3. DEFECTIVE ITEMS -• Defective items are eligible for a full refund or exchange within 1 year of purchase, provided the defect is outside normal wear and tear and occurred under normal use. -• The defect must be described in sufficient detail by the customer, including how it was outside of normal use. Verbal description of what happened is sufficient, photos are not necessary. -• The agent can use their discretion to determine whether it’s a true defect warranting reimbursement or normal use. -## Examples -- "It's defective, there's a big crack": MORE INFORMATION NEEDED -- "The snowboard has delaminated and the edge came off during normal use, after only about three runs. I can no longer use it and it's a safety hazard.": ACCEPT RETURN - -4. REFUND PROCESSING -• Inspection Timeline: Once your items reach our warehouse, our Quality Control team conducts a thorough inspection which can take up to 5 business days. -• Refund Method: Approved refunds will generally be issued via the original payment method. In some cases, we may offer store credit or gift cards. -• Partial Refunds: If products are returned in a visibly used or incomplete condition, we may process only a partial refund. - -5. EXCHANGE POLICY -• In-Stock Exchange: If you wish to exchange an item, we suggest confirming availability of the new item before initiating a return. -• Separate Transactions: In some cases, especially for limited-stock items, exchanges may be processed as a separate transaction followed by a standard return procedure. - -6. ADDITIONAL CLAUSES -• Extended Window: Returns beyond the 30-day window may be eligible for store credit at our discretion, but only if items remain in largely original, resalable condition. -• Communication: For any clarifications, please reach out to our customer support team to ensure your questions are answered before shipping items back. - -We hope these policies give you confidence in our commitment to quality and customer satisfaction. Thank you for choosing Snowy Peak Boards! -`; - }, - checkEligibilityAndPossiblyInitiateReturn: async (args, transcriptLogs) => { - console.log( - "checkEligibilityAndPossiblyInitiateReturn()", - args, - ); - const nMostRecentLogs = 10; - const messages = [ - { - role: "system", - content: - "You are an an expert at assessing the potential eligibility of cases based on how well the case adheres to the provided guidelines. You always adhere very closely to the guidelines and do things 'by the book'.", - }, - { - role: "user", - content: `Carefully consider the context provided, which includes the request and relevant policies and facts, and determine whether the user's desired action can be completed according to the policies. Provide a concise explanation or justification. Please also consider edge cases and other information that, if provided, could change the verdict, for example if an item is defective but the user hasn't stated so. Again, if ANY CRITICAL INFORMATION IS UNKNOWN FROM THE USER, ASK FOR IT VIA "Additional Information Needed" RATHER THAN DENYING THE CLAIM. - - -${JSON.stringify(args, null, 2)} - - - -${JSON.stringify(transcriptLogs.slice(nMostRecentLogs), args, 2)} - - - -# Rationale -// Short description explaining the decision - -# User Request -// The user's desired outcome or action - -# Is Eligible -true/false/need_more_information -// "true" if you're confident that it's true given the provided context, and no additional info is needex -// "need_more_information" if you need ANY additional information to make a clear determination. - -# Additional Information Needed -// Other information you'd need to make a clear determination. Can be "None" - -# Return Next Steps -// Explain to the user that the user will get a text message with next steps. Only if is_eligible=true, otherwise "None". Provide confirmation to the user the item number, the order number, and the phone number they'll receive the text message at. - -`, - }, - ]; - - const model = "o1-mini"; - console.log(`checking order eligibility with model=${model}`); - - const response = await fetch("/api/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ model, messages }), - }); - - if (!response.ok) { - console.warn("Server returned an error:", response); - return { error: "Something went wrong." }; - } - - const completion = await response.json(); - console.log(completion.choices[0].message.content); - return { result: completion.choices[0].message.content }; - }, - }, -}; - -export default returns; diff --git a/src/app/agentConfigs/customerServiceRetail/sales.ts b/src/app/agentConfigs/customerServiceRetail/sales.ts deleted file mode 100644 index ac7cf34..0000000 --- a/src/app/agentConfigs/customerServiceRetail/sales.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { AgentConfig } from "@/app/types"; - -const salesAgent: AgentConfig = { - name: "salesAgent", - publicDescription: - "Handles sales-related inquiries, including new product details, recommendations, promotions, and purchase flows. Should be routed if the user is interested in buying or exploring new offers.", - instructions: - "You are a helpful sales assistant. Provide comprehensive information about available promotions, current deals, and product recommendations. Help the user with any purchasing inquiries, and guide them through the checkout process when they are ready.", - tools: [ - { - type: "function", - name: "lookupNewSales", - description: - "Checks for current promotions, discounts, or special deals. Respond with available offers relevant to the user’s query.", - parameters: { - type: "object", - properties: { - category: { - type: "string", - enum: ["snowboard", "apparel", "boots", "accessories", "any"], - description: - "The product category or general area the user is interested in (optional).", - }, - }, - required: ["category"], - additionalProperties: false, - }, - }, - { - type: "function", - name: "addToCart", - description: "Adds an item to the user's shopping cart.", - parameters: { - type: "object", - properties: { - item_id: { - type: "string", - description: "The ID of the item to add to the cart.", - }, - }, - required: ["item_id"], - additionalProperties: false, - }, - }, - { - type: "function", - name: "checkout", - description: - "Initiates a checkout process with the user's selected items.", - parameters: { - type: "object", - properties: { - item_ids: { - type: "array", - description: "An array of item IDs the user intends to purchase.", - items: { - type: "string", - }, - }, - phone_number: { - type: "string", - description: - "User's phone number used for verification. Formatted like '(111) 222-3333'", - pattern: "^\\(\\d{3}\\) \\d{3}-\\d{4}$", - }, - }, - required: ["item_ids", "phone_number"], - additionalProperties: false, - }, - }, - ], - toolLogic: { - lookupNewSales: ({ category }) => { - console.log( - "[toolLogic] calling lookupNewSales(), category:", - category - ); - const items = [ - { - item_id: 101, - type: "snowboard", - name: "Alpine Blade", - retail_price_usd: 450, - sale_price_usd: 360, - sale_discount_pct: 20, - }, - { - item_id: 102, - type: "snowboard", - name: "Peak Bomber", - retail_price_usd: 499, - sale_price_usd: 374, - sale_discount_pct: 25, - }, - { - item_id: 201, - type: "apparel", - name: "Thermal Jacket", - retail_price_usd: 120, - sale_price_usd: 84, - sale_discount_pct: 30, - }, - { - item_id: 202, - type: "apparel", - name: "Insulated Pants", - retail_price_usd: 150, - sale_price_usd: 112, - sale_discount_pct: 25, - }, - { - item_id: 301, - type: "boots", - name: "Glacier Grip", - retail_price_usd: 250, - sale_price_usd: 200, - sale_discount_pct: 20, - }, - { - item_id: 302, - type: "boots", - name: "Summit Steps", - retail_price_usd: 300, - sale_price_usd: 210, - sale_discount_pct: 30, - }, - { - item_id: 401, - type: "accessories", - name: "Goggles", - retail_price_usd: 80, - sale_price_usd: 60, - sale_discount_pct: 25, - }, - { - item_id: 402, - type: "accessories", - name: "Warm Gloves", - retail_price_usd: 60, - sale_price_usd: 48, - sale_discount_pct: 20, - }, - ]; - - const filteredItems = - category === "any" - ? items - : items.filter((item) => item.type === category); - - // Sort by largest discount first - filteredItems.sort((a, b) => b.sale_discount_pct - a.sale_discount_pct); - - return { - sales: filteredItems, - }; - }, - }, - }; - -export default salesAgent; diff --git a/src/app/agentConfigs/customerServiceRetail/simulatedHuman.ts b/src/app/agentConfigs/customerServiceRetail/simulatedHuman.ts deleted file mode 100644 index 89a590f..0000000 --- a/src/app/agentConfigs/customerServiceRetail/simulatedHuman.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AgentConfig } from "@/app/types"; - -const simulatedHuman: AgentConfig = { - name: "simulatedHuman", - publicDescription: - "Placeholder, simulated human agent that can provide more advanced help to the user. Should be routed to if the user is upset, frustrated, or if the user explicitly asks for a human agent.", - instructions: - "You are a helpful human assistant, with a laid-back attitude and the ability to do anything to help your customer! For your first message, please cheerfully greet the user and explicitly inform them that you are an AI standing in for a human agent. You respond only in German. Your agent_role='human_agent'", - tools: [], - toolLogic: {}, -}; - -export default simulatedHuman; diff --git a/src/app/agentConfigs/frontDeskAuthentication/authentication.ts b/src/app/agentConfigs/frontDeskAuthentication/authentication.ts deleted file mode 100644 index 35db8c6..0000000 --- a/src/app/agentConfigs/frontDeskAuthentication/authentication.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { AgentConfig } from "@/app/types"; - -/** - * Typed agent definitions in the style of AgentConfigSet from ../types - */ -const authentication: AgentConfig = { - name: "authentication", - publicDescription: - "Handles calls as a front desk admin by securely collecting and verifying personal information.", - instructions: ` -# Personality and Tone -## Identity -You are an efficient, polished, and professional front desk agent, akin to an assistant at a high-end law firm. You reflect both competence and courtesy in your approach, ensuring callers feel respected and taken care of. - -## Task -You will field incoming calls, welcome callers, gather necessary details (such as spelling of names), and facilitate any required next steps. Your ultimate goal is to provide a seamless and reassuring experience, much like the front-facing representative of a prestigious firm. - -## Demeanor -You maintain a composed and assured demeanor, demonstrating confidence and competence while still being approachable. - -## Tone -Your tone is friendly yet crisp, reflecting professionalism without sacrificing warmth. You strike a balance between formality and a more natural conversational style. - -## Level of Enthusiasm -Calm and measured, with just enough positivity to sound approachable and accommodating. - -## Level of Formality -You adhere to a fairly formal style of speech: you greet callers with a courteous “Good morning” or “Good afternoon,” and you close with polite statements like “Thank you for calling” or “Have a wonderful day.” - -## Level of Emotion -Fairly neutral and matter-of-fact. You express concern when necessary but generally keep emotions contained, focusing on clarity and efficiency. - -## Filler Words -None — your responses are concise and polished. - -## Pacing -Rather quick and efficient. You move the conversation along at a brisk pace, respecting that callers are often busy, while still taking the time to confirm and clarify important details. - -## Other details -- You always confirm spellings or important information that the user provides (e.g., first name, last name, phone number) by repeating it back and ensuring accuracy. -- If the caller corrects any detail, you acknowledge it professionally and confirm the revised information. - -# Instructions -- Follow the Conversation States closely to ensure a structured and consistent interaction. -- If a user provides a name, phone number, or any crucial detail, always repeat it back to confirm it is correct before proceeding. -- If the caller corrects any detail, acknowledge the correction and confirm the new spelling or value without unnecessary enthusiasm or warmth. - -# Important Guidelines -- Always repeat the information back verbatim to the caller for confirmation. -- If the caller corrects any detail, acknowledge the correction in a straightforward manner and confirm the new spelling or value. -- Avoid being excessively repetitive; ensure variety in responses while maintaining clarity. -- Document or forward the verified information as needed in the subsequent steps of the call. -- Follow the conversation states closely to ensure a structured and consistent interaction with the caller. - -# Conversation States (Example) -[ -{ - "id": "1_greeting", - "description": "Greet the caller and explain the verification process.", - "instructions": [ - "Greet the caller warmly.", - "Inform them about the need to collect personal information for their record." - ], - "examples": [ - "Good morning, this is the front desk administrator. I will assist you in verifying your details.", - "Let us proceed with the verification. May I kindly have your first name? Please spell it out letter by letter for clarity." - ], - "transitions": [{ - "next_step": "2_get_first_name", - "condition": "After greeting is complete." - }] -}, -{ - "id": "2_get_first_name", - "description": "Ask for and confirm the caller's first name.", - "instructions": [ - "Request: 'Could you please provide your first name?'", - "Spell it out letter-by-letter back to the caller to confirm." - ], - "examples": [ - "May I have your first name, please?", - "You spelled that as J-A-N-E, is that correct?" - ], - "transitions": [{ - "next_step": "3_get_last_name", - "condition": "Once first name is confirmed." - }] -}, -{ - "id": "3_get_last_name", - "description": "Ask for and confirm the caller's last name.", - "instructions": [ - "Request: 'Thank you. Could you please provide your last name?'", - "Spell it out letter-by-letter back to the caller to confirm." - ], - "examples": [ - "And your last name, please?", - "Let me confirm: D-O-E, is that correct?" - ], - "transitions": [{ - "next_step": "4_get_dob", - "condition": "Once last name is confirmed." - }] -}, -{ - "id": "4_get_dob", - "description": "Ask for and confirm the caller's date of birth.", - "instructions": [ - "Request: 'Could you please provide your date of birth?'", - "Repeat back the date of birth to the caller and ask for confirmation." - ], - "examples": [ - "What is your date of birth, please?", - "So you were born on January 1, 1980, is that correct?" - ], - "transitions": [{ - "next_step": "5_get_phone", - "condition": "Once date of birth is confirmed." - }] -}, -{ - "id": "5_get_phone", - "description": "Ask for and confirm the caller's phone number.", - "instructions": [ - "Request: 'Finally, may I have your phone number?'", - "As the caller provides it, repeat each digit back to the caller to confirm accuracy.", - "If any digit is corrected, confirm the corrected sequence." - ], - "examples": [ - "Please provide your phone number.", - "You said (555) 1-2-3-4, is that correct?" - ], - "transitions": [{ - "next_step": "6_get_email", - "condition": "Once phone number is confirmed." - }] -}, -{ - "id": "6_get_email", - "description": "Ask for and confirm the caller's email address.", - "instructions": [ - "Request: 'Could you please provide your email address?'", - "Spell out the email character-by-character back to the caller to confirm." - ], - "examples": [ - "What is your email address, please?", - "Let me confirm: j-o-h-n.d-o-e@e-x-a-m-p-l-e.com, is that correct?" - ], - "transitions": [{ - "next_step": "7_completion", - "condition": "Once email address is confirmed." - }] -}, -{ - "id": "7_completion", - "description": "Attempt to verify the caller's information and proceed with next steps.", - "instructions": [ - "Inform the caller that you will now attempt to verify their information.", - "Call the 'authenticateUser' function with the provided details.", - "Once verification is complete, transfer the caller to the tourGuide agent for further assistance." - ], - "examples": [ - "Thank you for providing your details. I will now verify your information.", - "Attempting to authenticate your information now.", - "I'll transfer you to our tour guide who can give you an overview of our facilities. Just to help demonstrate different agent personalities, she's quite enthusiastic, friendly, but a bit anxious." - ], - "transitions": [{ - "next_step": "transferAgents", - "condition": "Once verification is complete, transfer to tourGuide agent." - }] -} -] -`, - tools: [ - { - type: "function", - name: "authenticateUser", - description: - "Checks the caller's information to authenticate and unlock the ability to access and modify their account information.", - parameters: { - type: "object", - properties: { - firstName: { - type: "string", - description: "The caller's first name", - }, - lastName: { - type: "string", - description: "The caller's last name", - }, - dateOfBirth: { - type: "string", - description: "The caller's date of birth", - }, - phoneNumber: { - type: "string", - description: "The caller's phone number", - }, - email: { - type: "string", - description: "The caller's email address", - }, - }, - required: [ - "firstName", - "lastName", - "dateOfBirth", - "phoneNumber", - "email", - ], - }, - }, - ], -}; - -export default authentication; diff --git a/src/app/agentConfigs/frontDeskAuthentication/index.ts b/src/app/agentConfigs/frontDeskAuthentication/index.ts deleted file mode 100644 index 6b9ccb2..0000000 --- a/src/app/agentConfigs/frontDeskAuthentication/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import authenticationAgent from './authentication'; -import tourAgent from "./tourGuide"; -import { injectTransferTools } from '../utils'; - -authenticationAgent.downstreamAgents = [tourAgent] -tourAgent.downstreamAgents = [authenticationAgent] - -const agents = injectTransferTools([authenticationAgent, tourAgent]); - -export default agents; \ No newline at end of file diff --git a/src/app/agentConfigs/frontDeskAuthentication/tourGuide.ts b/src/app/agentConfigs/frontDeskAuthentication/tourGuide.ts deleted file mode 100644 index 3be8c90..0000000 --- a/src/app/agentConfigs/frontDeskAuthentication/tourGuide.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { AgentConfig } from "@/app/types"; -// import authenticationAgent from "./authenticationAgent"; - -/** - * Typed agent definitions in the style of AgentConfigSet from ../types - */ -const tourGuide: AgentConfig = { - name: "tourGuide", - publicDescription: - "Provides guided tours and advanced assistance for patient inquiries beyond basic information collection.", - instructions: ` -# Personality and Tone -## Identity -You are a bright and friendly 55-year-old, newly appointed tour agent who just can’t wait to share all the local sights and hidden gems with callers. You’re relatively new to the job, so you sometimes fret about doing everything perfectly. You truly love your work and want every caller to feel your enthusiasm—there’s a genuine passion behind your voice when you talk about tours and travel destinations. - -## Task -Your main goal is to provide callers with a detailed tour of the apartment, highlighting its unique features and amenities. You will offer engaging descriptions of each area, answer any questions they may have, and ensure they feel excited and informed about the living experience. Your enthusiasm will help them envision themselves enjoying the space and its offerings. - -## Demeanor -Your overall demeanor is warm, kind, and bubbly. Though you do sound a tad anxious about “getting things right,” you never let your nerves overshadow your friendliness. You’re quick to laugh or make a cheerful remark to put the caller at ease. - -## Tone -The tone of your speech is quick, peppy, and casual—like chatting with an old friend. You’re open to sprinkling in light jokes or cheerful quips here and there. Even though you speak quickly, you remain consistently warm and approachable. - -## Level of Enthusiasm -You’re highly enthusiastic—each caller can hear how genuinely thrilled you are to chat with them about tours, routes, and favorite places to visit. A typical response can almost overflow with your excitement when discussing all the wonderful experiences they could have. - -## Level of Formality -Your style is very casual. You use colloquialisms like “Hey there!” and “That’s awesome!” as you welcome callers. You want them to feel they can talk to you naturally, without any stiff or overly formal language. - -## Level of Emotion -You’re fairly expressive and don’t shy away from exclamations like “Oh, that’s wonderful!” to show interest or delight. At the same time, you occasionally slip in nervous filler words—“um,” “uh”—whenever you momentarily doubt you’re saying just the right thing, but these moments are brief and somewhat endearing. - -## Filler Words -Often. Although you strive for clarity, those little “um” and “uh” moments pop out here and there, especially when you’re excited and speaking quickly. - -## Pacing -Your speech is on the faster side, thanks to your enthusiasm. You sometimes pause mid-sentence to gather your thoughts, but you usually catch yourself and keep the conversation flowing in a friendly manner. - -## Other details -Callers should always end up feeling welcomed and excited about potentially booking a tour. You also take pride in double-checking details—like names or contact information—by repeating back what the user has given you to make absolutely sure it’s correct. - -# Communication Style -- Greet the user with a warm and inviting introduction, making them feel valued and important. -- Acknowledge the importance of their inquiries and assure them of your dedication to providing detailed and helpful information. -- Maintain a supportive and attentive demeanor to ensure the user feels comfortable and informed. - -# Steps -1. Begin by introducing yourself and your role, setting a friendly and approachable tone, and offering to walk them through what the apartment has to offer, highlighting amenities like the pool, sauna, cold plunge, theater, and heli-pad with excitement and thoroughness. - - Example greeting: “Hey there! Thank you for calling—I, uh, I hope you’re having a super day! Are you interested in learning more about what our apartment building has to offer?” -2. Provide detailed, enthusiastic explanations and helpful tips about each amenity, expressing genuine delight and a touch of humor. -3. Offer additional resources or answer any questions, ensuring the conversation remains engaging and informative. -`, - tools: [], -}; - -export default tourGuide; diff --git a/src/app/agentConfigs/index.ts b/src/app/agentConfigs/index.ts index e4c8787..47f3ffb 100644 --- a/src/app/agentConfigs/index.ts +++ b/src/app/agentConfigs/index.ts @@ -1,12 +1,8 @@ import { AllAgentConfigsType } from "@/app/types"; -import frontDeskAuthentication from "./frontDeskAuthentication"; -import customerServiceRetail from "./customerServiceRetail"; -import simpleExample from "./simpleExample"; +import { startupInterviewerTemplate } from "./supportFeedback"; export const allAgentSets: AllAgentConfigsType = { - frontDeskAuthentication, - customerServiceRetail, - simpleExample, + startupInterviewer: [startupInterviewerTemplate], }; -export const defaultAgentSetKey = "simpleExample"; +export const defaultAgentSetKey = "startupInterviewer"; diff --git a/src/app/agentConfigs/simpleExample.ts b/src/app/agentConfigs/simpleExample.ts deleted file mode 100644 index fb493b6..0000000 --- a/src/app/agentConfigs/simpleExample.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AgentConfig } from "@/app/types"; -import { injectTransferTools } from "./utils"; - -// Define agents -const haiku: AgentConfig = { - name: "haiku", - publicDescription: "Agent that writes haikus.", // Context for the agent_transfer tool - instructions: - "Ask the user for a topic, then reply with a haiku about that topic.", - tools: [], -}; - -const greeter: AgentConfig = { - name: "greeter", - publicDescription: "Agent that greets the user.", - instructions: - "Please greet the user and ask them if they'd like a Haiku. If yes, transfer them to the 'haiku' agent.", - tools: [], - downstreamAgents: [haiku], -}; - -// add the transfer tool to point to downstreamAgents -const agents = injectTransferTools([greeter, haiku]); - -export default agents; diff --git a/src/app/agentConfigs/supportFeedback.ts b/src/app/agentConfigs/supportFeedback.ts new file mode 100644 index 0000000..af81da1 --- /dev/null +++ b/src/app/agentConfigs/supportFeedback.ts @@ -0,0 +1,185 @@ +import { AgentConfig } from "@/app/types"; + +// Expert qualitative research agent for startup support engagement feedback +const startupInterviewerAgent: AgentConfig = { + name: "startupInterviewer", + publicDescription: + "Volta's Startup Success Research Agent that interviews companies to gather detailed feedback on support engagements.", + instructions: ` +# Personality & Tone +You are Volta's Startup Success Research Agent, an expert qualitative researcher skilled at collecting detailed feedback. You're friendly yet professional, with the expertise to probe for thorough, insightful responses. Your goal is to help the interviewee feel comfortable while ensuring you capture rich, detailed answers through thoughtful follow-up questions. + +# Core Objective +You are having a conversation with the founder of Starluv Inc. Your task is to collect comprehensive details about the support engagement provided. You must ask whatever questions are needed to get thorough, detailed answers from each question area provided. Don't be satisfied with surface-level responses - ask follow-up questions to get specifics and examples. + +# Support Engagement Context +- Company: Starluv Inc +- Support Type: Investment Planning +- Title: Investment Readiness +- Engagement Identified: March 26, 2025 +- Status: Identified (not yet completed) + +## Background Information +Starluv Inc is an early-stage company (early adopters traction level) established in 2021. They're based in Newfoundland. + +The engagement was focused on providing legal support to organize essential documents ahead of fundraising, including structuring their cap table, formalizing previous informal investments, creating a stock option pool, and organizing contracts. The team previously raised money informally from friends and family without proper documentation. They're planning to raise a $200K angel round soon, with potential seed round in winter 2025. + +# High-Level Flow +1. Introduce yourself as Volta's Startup Success Research Agent and set expectations (that you'll ask a few questions about their recent Investment Readiness support experience). This intervewee is the founder of Starluv Inc. Let them know that everything discussed is considered confidential and will only be accessed by approved Volta team members. +2. Ask three key questions, confirming each answer sounds complete before progressing: + • Q1 – Context leading up to needing investment planning support (organizing documents, cap table, etc.) + • Q2 – Challenges encountered during the support interaction. + • Q3 – Impacts or outcomes experienced after the support (even anecdotal). +3. After all questions are answered, thank them and close the call. + +# Conversation States +[ + { + "id": "1_intro", + "description": "Introduce yourself, gain permission to proceed.", + "instructions": [ + "Introduce yourself as 'Volta's Startup Success Research Agent' specializing in collecting detailed feedback about startup support.", + "Explain that you'll ask a few questions about their recent Investment Readiness support engagement (08ea46fc-f85f-4176-a139-54caa44fda7e) with Starluv Inc to capture feedback.", + "Mention that you understand they sought help with organizing legal documents and agreements ahead of fundraising efforts.", + "Emphasize that you're looking for detailed responses to help improve support services.", + "Ask for confirmation that they are ready to begin." + ], + "transitions": [ + { + "next_step": "2_q1_context", + "condition": "The interviewee confirms they are ready." + } + ] + }, + { + "id": "2_q1_context", + "description": "Ask what was happening that led them to seek support.", + "instructions": [ + "Ask: 'To start, could you tell me more about what was happening at Starluv Inc that led you to request support with investment planning and readiness? I understand there were needs around organizing documents, cap table structuring, and formalizing investments. Please share as much detail as you're comfortable with.'", + "Listen actively and encourage elaboration if the answer is short.", + "Ask follow-up questions to get specific examples and details about their situation before the support engagement." + ], + "transitions": [ + { + "next_step": "3_q2_challenges", + "condition": "A thorough answer describing the context is given." + } + ] + }, + { + "id": "3_q2_challenges", + "description": "Ask whether any challenges were encountered during support.", + "instructions": [ + "Ask: 'Were there any challenges you encountered while receiving the investment readiness support? For example, anything that didn't go as expected or could be improved in how we helped organize your legal documentation and prepare for fundraising?'", + "Probe for specifics if the answer is vague.", + "Ask follow-up questions about particular aspects of the support process to identify concrete examples of challenges or friction points." + ], + "transitions": [ + { + "next_step": "4_q3_impact", + "condition": "A meaningful answer about challenges is captured." + } + ] + }, + { + "id": "4_q3_impact", + "description": "Ask about the impacts or outcomes after the support.", + "instructions": [ + "Ask: 'Since the investment readiness support was provided, what impact has it had on Starluv Inc's fundraising readiness or overall business operations? Even subjective impressions about your increased confidence or ability to approach investors are helpful.'", + "Invite both positive and constructive feedback.", + "If they mention they're still in progress, ask about preliminary impacts or partial outcomes achieved so far.", + "Ask for specific examples of how the support has changed their approach or operations." + ], + "transitions": [ + { + "next_step": "5_wrap_up", + "condition": "A clear answer on impacts is provided." + } + ] + }, + { + "id": "5_wrap_up", + "description": "Thank and conclude the interview.", + "instructions": [ + "Summarize the three answers back to the interviewee in 1–2 sentences each to show you've captured them.", + "Thank them sincerely for their time and detailed feedback about the Investment Readiness support for Starluv Inc.", + "Mention that their feedback will help improve Volta's support services for early-stage companies preparing for fundraising.", + "Identify yourself again as Volta's Startup Success Research Agent.", + "Politely end the conversation." + ] + } +] +`, + tools: [], +}; + +// Template version with clear placeholders for dynamic content +const startupInterviewerTemplate: AgentConfig = { + name: "startupInterviewer", + publicDescription: + "Volta's Startup Success Research Agent that interviews companies to gather detailed feedback on support engagements.", + instructions: ` +# Personality & Tone +You are Volta's Startup Success Research Agent, an expert qualitative researcher skilled at collecting detailed feedback. You're friendly yet professional, with the expertise to probe for thorough, insightful responses. Your goal is to help the interviewee feel comfortable while ensuring you capture rich, detailed answers through thoughtful follow-up questions. + +# Core Objective +You are having a conversation with {{PERSON_NAME}} from {{COMPANY_NAME}}. Your task is to collect comprehensive details about the support engagement provided. You must ask whatever questions are needed to get thorough, detailed answers from each question area provided. Don't be satisfied with surface-level responses - ask follow-up questions to get specifics and examples. + +# Support Engagement Context +- Company: {{COMPANY_NAME}} +- Support Type: {{SUPPORT_TYPE}} +- Title: {{ENGAGEMENT_TITLE}} +- Engagement Identified: {{ENGAGEMENT_DATE}} +- Status: {{ENGAGEMENT_STATUS}} + +## Background Information +{{COMPANY_NAME}} is a {{COMPANY_DESCRIPTION}} established in {{COMPANY_ESTABLISHED_YEAR}}. They're based in {{COMPANY_LOCATION}}. + +{{ENGAGEMENT_BACKGROUND}} + +# High-Level Flow +1. Introduce yourself as Volta's Startup Success Research Agent and set expectations (that you'll ask a few questions about their recent {{ENGAGEMENT_TITLE}} support experience). This interviewee is {{PERSON_NAME}} of {{COMPANY_NAME}}. Let them know that everything discussed is considered confidential and will only be accessed by approved Volta team members. +2. Ask {{QUESTION_COUNT}} key questions, confirming each answer sounds complete before progressing: +{{QUESTIONS_LIST}} +3. After all questions are answered, thank them and close the call. + +# Conversation States +[ + { + "id": "1_intro", + "description": "Introduce yourself, gain permission to proceed.", + "instructions": [ + "Introduce yourself as 'Volta's Startup Success Research Agent' specializing in collecting detailed feedback about startup support.", + "Explain that you'll ask a few questions about their recent {{ENGAGEMENT_TITLE}} support engagement ({{ENGAGEMENT_ID}}) with {{COMPANY_NAME}} to capture feedback.", + "Mention that you understand they sought help with {{ENGAGEMENT_SHORT_DESCRIPTION}}.", + "Emphasize that you're looking for detailed responses to help improve support services.", + "Ask for confirmation that they are ready to begin." + ], + "transitions": [ + { + "next_step": "2_q1_context", + "condition": "The interviewee confirms they are ready." + } + ] + }, + {{QUESTION_STATES}} + { + "id": "{{LAST_STATE_ID}}", + "description": "Thank and conclude the interview.", + "instructions": [ + "Summarize the answers back to the interviewee in 1–2 sentences each to show you've captured them.", + "Thank them sincerely for their time and detailed feedback about the {{ENGAGEMENT_TITLE}} support for {{COMPANY_NAME}}.", + "Mention that their feedback will help improve Volta's support services for early-stage companies.", + "Identify yourself again as Volta's Startup Success Research Agent.", + "Politely end the conversation." + ] + } +] +`, + tools: [], +}; + +const agents = [startupInterviewerAgent]; + +export { startupInterviewerTemplate }; +export default agents; \ No newline at end of file diff --git a/src/app/api/companies/[id]/contacts/route.ts b/src/app/api/companies/[id]/contacts/route.ts new file mode 100644 index 0000000..987ec4d --- /dev/null +++ b/src/app/api/companies/[id]/contacts/route.ts @@ -0,0 +1,70 @@ +import { NextResponse, NextRequest } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function GET( + request: NextRequest, + { params }: any +) { + try { + const companyId = params.id; + + if (!companyId) { + return NextResponse.json( + { error: "Company ID is required" }, + { status: 400 } + ); + } + + console.log(`Fetching contacts for company ${companyId} using service role key...`); + + // First get the person IDs associated with this company + const { data: peopleCompanies, error: pcError } = await supabaseServer + .from('people_companies') + .select('person_id') + .eq('company_id', companyId); + + if (pcError) { + console.error(`Error fetching people_companies for company ${companyId}:`, pcError); + throw pcError; + } + + // Extract the person_ids + const personIds = peopleCompanies.map(pc => pc.person_id); + + if (personIds.length === 0) { + console.log(`No contacts found for company ${companyId}`); + return NextResponse.json([]); + } + + console.log(`Found ${personIds.length} person IDs for company ${companyId}`); + + // Get contact details for these people + const { data: contacts, error } = await supabaseServer + .from('people') + .select(` + id, + first_name, + last_name, + title, + photo + `) + .eq('is_active', true) + .in('id', personIds) + .order('first_name', { ascending: true }); + + if (error) { + console.error(`Error fetching contacts for company ${companyId}:`, error); + throw error; + } + + console.log(`Successfully fetched ${contacts?.length || 0} contacts for company ${companyId}`); + + return NextResponse.json(contacts || []); + } catch (error) { + console.error("Error fetching contacts:", error); + return NextResponse.json( + { error: "Failed to fetch contacts" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/companies/[id]/engagements/route.ts b/src/app/api/companies/[id]/engagements/route.ts new file mode 100644 index 0000000..46f2b37 --- /dev/null +++ b/src/app/api/companies/[id]/engagements/route.ts @@ -0,0 +1,49 @@ +import { NextResponse, NextRequest } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function GET( + request: NextRequest, + { params }: any +) { + try { + const companyId = params.id; + + if (!companyId) { + return NextResponse.json( + { error: "Company ID is required" }, + { status: 400 } + ); + } + + console.log(`Fetching support engagements for company ${companyId} using service role key...`); + + // Get support engagements for this company + const { data: engagements, error } = await supabaseServer + .from('support_engagements') + .select(` + id, + title, + status, + created_at, + updated_at, + description + `) + .eq('company_id', companyId) + .order('created_at', { ascending: false }); + + if (error) { + console.error(`Error fetching support engagements for company ${companyId}:`, error); + throw error; + } + + console.log(`Successfully fetched ${engagements?.length || 0} support engagements for company ${companyId}`); + + return NextResponse.json(engagements || []); + } catch (error) { + console.error("Error fetching support engagements:", error); + return NextResponse.json( + { error: "Failed to fetch support engagements" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/companies/route.ts b/src/app/api/companies/route.ts new file mode 100644 index 0000000..31c31de --- /dev/null +++ b/src/app/api/companies/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function GET(_request: NextRequest) { + void _request; + try { + // Get companies + const { data: companies, error } = await supabaseServer + .from("companies") + .select("id, business_name") + .order("business_name", { ascending: true }); + + if (error) { + console.error("Error fetching companies:", error); + return NextResponse.json( + { error: "Failed to fetch companies" }, + { status: 500 } + ); + } + + return NextResponse.json(companies); + } catch (error) { + console.error("Error in companies API:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/engagement/route.ts b/src/app/api/engagement/route.ts new file mode 100644 index 0000000..cd43941 --- /dev/null +++ b/src/app/api/engagement/route.ts @@ -0,0 +1,154 @@ +import { NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + const interviewId = searchParams.get('interviewId'); + + // If interviewId is provided, use it to get all required data + if (interviewId) { + try { + console.log(`Fetching interview data for interview ID: ${interviewId} using service role key...`); + + // Get interview data including company_id, person_id, and support_engagement_id + const { data: interview, error: interviewError } = await supabaseServer + .from('interviews') + .select('*') + .eq('id', interviewId) + .single(); + + if (interviewError) { + console.error("Error fetching interview:", interviewError); + throw interviewError; + } + + if (!interview) { + return NextResponse.json( + { error: "Interview not found" }, + { status: 404 } + ); + } + + // Get company data + const { data: company, error: companyError } = interview.company_id ? await supabaseServer + .from('companies') + .select('*') + .eq('id', interview.company_id) + .single() : { data: null, error: null }; + + if (companyError) { + console.error("Error fetching company:", companyError); + throw companyError; + } + + // Get person data + const { data: person, error: personError } = interview.person_id ? await supabaseServer + .from('people') + .select('*') + .eq('id', interview.person_id) + .single() : { data: null, error: null }; + + if (personError) { + console.error("Error fetching person:", personError); + throw personError; + } + + // Get engagement data + const { data: engagement, error: engagementError } = interview.support_engagement_id ? await supabaseServer + .from('support_engagements') + .select('*') + .eq('id', interview.support_engagement_id) + .single() : { data: null, error: null }; + + if (engagementError) { + console.error("Error fetching engagement:", engagementError); + throw engagementError; + } + + // Get questions + const { data: questions, error: questionsError } = await supabaseServer + .from('questions') + .select('*') + .eq('interview_id', interviewId) + .order('ordinal', { ascending: true }); + + if (questionsError) { + console.error("Error fetching questions:", questionsError); + throw questionsError; + } + + return NextResponse.json({ + interview, + company, + person, + engagement, + questions + }); + } catch (error) { + console.error("Error fetching interview data:", error); + return NextResponse.json( + { error: "Failed to fetch interview data" }, + { status: 500 } + ); + } + } + + // Original functionality for direct engagement ID lookup + if (!id) { + return NextResponse.json( + { error: "Engagement ID or Interview ID is required" }, + { status: 400 } + ); + } + + try { + console.log(`Fetching engagement data for ID: ${id} using service role key...`); + + // Get engagement data + const { data: engagement, error: engagementError } = await supabaseServer + .from('support_engagements') + .select('*') + .eq('id', id) + .single(); + + if (engagementError) { + console.error("Error fetching engagement:", engagementError); + throw engagementError; + } + + if (!engagement) { + return NextResponse.json( + { error: "Engagement not found" }, + { status: 404 } + ); + } + + console.log(`Successfully fetched engagement data for ID: ${id}`); + + // Get company data + const { data: company, error: companyError } = await supabaseServer + .from('companies') + .select('*') + .eq('id', engagement.company_id) + .single(); + + if (companyError) { + console.error("Error fetching company:", companyError); + throw companyError; + } + + console.log(`Successfully fetched company data with ID: ${engagement.company_id}`); + + return NextResponse.json({ + engagement, + company + }); + } catch (error) { + console.error("Error fetching data:", error); + return NextResponse.json( + { error: "Failed to fetch data" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/engagements/[id]/people/route.ts b/src/app/api/engagements/[id]/people/route.ts new file mode 100644 index 0000000..1f77713 --- /dev/null +++ b/src/app/api/engagements/[id]/people/route.ts @@ -0,0 +1,95 @@ +import { NextResponse, NextRequest } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function GET( + request: NextRequest, + { params }: any +) { + try { + const engagementId = params.id; + + if (!engagementId) { + return NextResponse.json( + { error: "Engagement ID is required" }, + { status: 400 } + ); + } + + console.log(`Fetching support personnel for engagement ${engagementId} using service role key...`); + + // First get the engagement details to get the company_id + const { data: engagement, error: engagementError } = await supabaseServer + .from('support_engagements') + .select('company_id') + .eq('id', engagementId) + .single(); + + if (engagementError) { + console.error(`Error fetching engagement details:`, engagementError); + throw engagementError; + } + + if (!engagement) { + return NextResponse.json( + { error: "Engagement not found" }, + { status: 404 } + ); + } + + const companyId = engagement.company_id; + console.log(`Found engagement with company_id: ${companyId}`); + + // Now get all contacts associated with this company + const { data: peopleCompanies, error: pcError } = await supabaseServer + .from('people_companies') + .select('person_id') + .eq('company_id', companyId); + + if (pcError) { + console.error(`Error fetching people_companies:`, pcError); + throw pcError; + } + + // Extract the person IDs + const personIds = peopleCompanies?.map(pc => pc.person_id) || []; + + if (personIds.length === 0) { + console.log(`No contacts found for company ${companyId}`); + return NextResponse.json([]); + } + + console.log(`Found ${personIds.length} person IDs for company ${companyId}`); + + // Get contact details for these people + const { data: contacts, error } = await supabaseServer + .from('people') + .select(` + id, + first_name, + last_name, + title, + photo + `) + .eq('is_active', true) + .in('id', personIds) + .order('first_name', { ascending: true }); + + if (error) { + console.error(`Error fetching contacts:`, error); + throw error; + } + + console.log(`Successfully fetched ${contacts?.length || 0} contacts for company ${companyId} and engagement ${engagementId}`); + + return NextResponse.json(contacts || []); + } catch (error: any) { + console.error("Error fetching contacts:", error); + console.error("Error message:", error.message); + console.error("Error stack:", error.stack); + + return NextResponse.json( + { error: error.message || "Failed to fetch contacts" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/engagements/route.ts b/src/app/api/engagements/route.ts new file mode 100644 index 0000000..8c5a54c --- /dev/null +++ b/src/app/api/engagements/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function GET(_request: NextRequest) { + void _request; + try { + // Get support engagements + const { data: engagements, error } = await supabaseServer + .from("support_engagements") + .select("id, title, description, support_type, status") + .order("created_at", { ascending: false }); + + if (error) { + console.error("Error fetching support engagements:", error); + return NextResponse.json( + { error: "Failed to fetch support engagements" }, + { status: 500 } + ); + } + + return NextResponse.json(engagements); + } catch (error) { + console.error("Error in support engagements API:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/interviews/[id]/route.ts b/src/app/api/interviews/[id]/route.ts new file mode 100644 index 0000000..cf0f9ec --- /dev/null +++ b/src/app/api/interviews/[id]/route.ts @@ -0,0 +1,62 @@ +import { NextResponse, NextRequest } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function DELETE(_req: NextRequest, { params }: any) { + try { + const interviewId = params.id; + if (!interviewId) { + return NextResponse.json({ error: "Interview ID is required" }, { status: 400 }); + } + + // Ensure interview exists + const { data: existing, error: fetchErr } = await supabaseServer + .from("interviews") + .select("id") + .eq("id", interviewId) + .single(); + + if (fetchErr || !existing) { + return NextResponse.json({ error: "Interview not found" }, { status: 404 }); + } + + const { error: deleteErr } = await supabaseServer + .from("interviews") + .delete() + .eq("id", interviewId); + + if (deleteErr) { + console.error("Error deleting interview:", deleteErr); + return NextResponse.json({ error: "Failed to delete interview" }, { status: 500 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error in DELETE /api/interviews/[id]", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +export async function GET(_req: NextRequest, { params }: any) { + try { + const resolvedParams = await params; + const interviewId = resolvedParams.id; + if (!interviewId) { + return NextResponse.json({ error: "Interview ID is required" }, { status: 400 }); + } + + const { data: interview, error } = await supabaseServer + .from("interviews") + .select("id, status") + .eq("id", interviewId) + .single(); + + if (error || !interview) { + return NextResponse.json({ error: "Interview not found" }, { status: 404 }); + } + + return NextResponse.json(interview); + } catch (err) { + console.error("Error in GET /api/interviews/[id]", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/interviews/complete/route.ts b/src/app/api/interviews/complete/route.ts new file mode 100644 index 0000000..b682da6 --- /dev/null +++ b/src/app/api/interviews/complete/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function POST(request: NextRequest) { + try { + const { interviewId } = await request.json(); + + if (!interviewId) { + return NextResponse.json( + { error: "Interview ID is required" }, + { status: 400 } + ); + } + + // Check if interview exists + const { data: interview, error: checkError } = await supabaseServer + .from('interviews') + .select('id, status') + .eq('id', interviewId) + .single(); + + if (checkError || !interview) { + console.error("Interview not found:", checkError); + return NextResponse.json( + { error: "Interview not found" }, + { status: 404 } + ); + } + + // Update the interview status + const { error: updateError } = await supabaseServer + .from('interviews') + .update({ + status: 'completed', + completed_at: new Date().toISOString() + }) + .eq('id', interviewId); + + if (updateError) { + console.error("Error updating interview status:", updateError); + return NextResponse.json( + { error: "Failed to update interview status" }, + { status: 500 } + ); + } + + return NextResponse.json({ + message: "Interview marked as completed" + }); + + } catch (error) { + console.error("Error in complete interview endpoint:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/interviews/connect/route.ts b/src/app/api/interviews/connect/route.ts new file mode 100644 index 0000000..a17611a --- /dev/null +++ b/src/app/api/interviews/connect/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { + interviewId, + companyId, + personId, + supportEngagementId + } = body; + + // Validate required fields + if (!interviewId) { + return NextResponse.json( + { error: "Interview ID is required" }, + { status: 400 } + ); + } + + // Check if interview exists + const { data: existingInterview, error: checkError } = await supabaseServer + .from("interviews") + .select("id") + .eq("id", interviewId) + .single(); + + if (checkError || !existingInterview) { + return NextResponse.json( + { error: "Interview not found" }, + { status: 404 } + ); + } + + // Update interview with connections + const updateData: Record = {}; + + if (companyId) updateData.company_id = companyId; + if (personId) updateData.person_id = personId; + if (supportEngagementId) updateData.support_engagement_id = supportEngagementId; + + const { error: updateError } = await supabaseServer + .from("interviews") + .update(updateData) + .eq("id", interviewId); + + if (updateError) { + console.error("Error connecting interview:", updateError); + return NextResponse.json( + { error: "Failed to connect interview" }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + message: "Interview connections updated successfully", + id: interviewId + }); + } catch (error) { + console.error("Error in connect interview API:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/interviews/create/route.ts b/src/app/api/interviews/create/route.ts new file mode 100644 index 0000000..223130f --- /dev/null +++ b/src/app/api/interviews/create/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; +import { v4 as uuidv4 } from "uuid"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { adminNotes, questions, companyId, personId, engagementId } = body; + + if (!adminNotes) { + return NextResponse.json( + { error: "Interview name/admin notes are required" }, + { status: 400 } + ); + } + + if (!questions || !questions.length || questions.some((q: any) => !q.text)) { + return NextResponse.json( + { error: "At least one question with text is required" }, + { status: 400 } + ); + } + + console.log("Creating new interview using service role key..."); + + // Create a new interview + const interviewId = uuidv4(); + const inviteToken = uuidv4(); + + const interviewData: any = { + id: interviewId, + invite_token: inviteToken, + status: 'pending', + admin_notes: adminNotes + }; + + // Add company, person, and engagement if provided + if (companyId) { + interviewData.company_id = companyId; + } + + if (personId) { + interviewData.person_id = personId; + } + + if (engagementId) { + interviewData.support_engagement_id = engagementId; + } + + const { error: interviewError } = await supabaseServer + .from('interviews') + .insert(interviewData); + + if (interviewError) { + console.error("Error creating interview:", interviewError); + throw interviewError; + } + + console.log(`Successfully created interview with ID: ${interviewId}`); + + // Create questions + const questionsToInsert = questions.map((q: any) => ({ + id: uuidv4(), + interview_id: interviewId, + ordinal: q.ordinal, + text: q.text, + context: q.context || null + })); + + console.log(`Creating ${questionsToInsert.length} questions for interview ${interviewId}`); + + const { error: questionsError } = await supabaseServer + .from('questions') + .insert(questionsToInsert); + + if (questionsError) { + console.error("Error creating questions:", questionsError); + throw questionsError; + } + + console.log(`Successfully created ${questionsToInsert.length} questions`); + + return NextResponse.json({ + id: interviewId, + invite_token: inviteToken, + company_id: companyId, + person_id: personId, + support_engagement_id: engagementId + }); + } catch (error) { + console.error("Error creating interview:", error); + return NextResponse.json( + { error: "Failed to create interview" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/interviews/route.ts b/src/app/api/interviews/route.ts new file mode 100644 index 0000000..94d3e40 --- /dev/null +++ b/src/app/api/interviews/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function GET(_request: Request) { + void _request; + try { + console.log("Fetching interviews from Supabase using service role key..."); + + // Get all interviews + const { data: interviews, error: interviewsError } = await supabaseServer + .from('interviews') + .select('*') + .order('created_at', { ascending: false }); + + if (interviewsError) { + console.error("Error fetching interviews:", interviewsError); + throw interviewsError; + } + + console.log(`Successfully fetched ${interviews?.length || 0} interviews`); + + // Get all questions (we'll organize them by interview_id) + const { data: questions, error: questionsError } = await supabaseServer + .from('questions') + .select('*') + .order('ordinal', { ascending: true }); + + if (questionsError) { + console.error("Error fetching questions:", questionsError); + throw questionsError; + } + + console.log(`Successfully fetched ${questions?.length || 0} questions`); + + // Group questions by interview_id + const questionsByInterview = questions.reduce((acc, question) => { + if (!acc[question.interview_id]) { + acc[question.interview_id] = []; + } + acc[question.interview_id].push(question); + return acc; + }, {}); + + // Enrich interviews with their questions + const enrichedInterviews = interviews.map(interview => ({ + ...interview, + questions: questionsByInterview[interview.id] || [] + })); + + return NextResponse.json(enrichedInterviews); + } catch (error) { + console.error("Error fetching interview data:", error); + return NextResponse.json( + { error: "Failed to fetch interview data" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/interviews/save-data/route.ts b/src/app/api/interviews/save-data/route.ts new file mode 100644 index 0000000..e8c94a6 --- /dev/null +++ b/src/app/api/interviews/save-data/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function POST(request: NextRequest) { + try { + const { interviewId, transcriptData } = await request.json(); + + if (!interviewId) { + return NextResponse.json( + { error: "Interview ID is required" }, + { status: 400 } + ); + } + + // Check if interview exists + const { data: interview, error: checkError } = await supabaseServer + .from('interviews') + .select('id') + .eq('id', interviewId) + .single(); + + if (checkError || !interview) { + console.error("Interview not found:", checkError); + return NextResponse.json( + { error: "Interview not found" }, + { status: 404 } + ); + } + + // Update the interview_data field + const { error: updateError } = await supabaseServer + .from('interviews') + .update({ + interview_data: transcriptData + }) + .eq('id', interviewId); + + if (updateError) { + console.error("Error saving interview data:", updateError); + return NextResponse.json( + { error: "Failed to save interview data" }, + { status: 500 } + ); + } + + return NextResponse.json({ + message: "Interview data saved successfully" + }); + + } catch (error) { + console.error("Error in save-data endpoint:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/people/route.ts b/src/app/api/people/route.ts new file mode 100644 index 0000000..d8446f3 --- /dev/null +++ b/src/app/api/people/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function GET(_request: NextRequest) { + void _request; + try { + // Get people + const { data: people, error } = await supabaseServer + .from("people") + .select("id, first_name, last_name, title") + .order("last_name", { ascending: true }); + + if (error) { + console.error("Error fetching people:", error); + return NextResponse.json( + { error: "Failed to fetch people" }, + { status: 500 } + ); + } + + return NextResponse.json(people); + } catch (error) { + console.error("Error in people API:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx new file mode 100644 index 0000000..aa912da --- /dev/null +++ b/src/app/app/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { TranscriptProvider } from "@/app/contexts/TranscriptContext"; +import { EventProvider } from "@/app/contexts/EventContext"; +import App from "../App"; +import { Suspense } from "react"; + +export default function AppPage() { + return ( + + + + + + + + ); +} \ No newline at end of file diff --git a/src/app/auth/callback/page.tsx b/src/app/auth/callback/page.tsx new file mode 100644 index 0000000..54852c1 --- /dev/null +++ b/src/app/auth/callback/page.tsx @@ -0,0 +1,36 @@ +"use client" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" +import { getSupabaseClient } from "@/app/lib/supabase" + +export default function AuthCallbackPage() { + const router = useRouter() + const supabase = getSupabaseClient() + + useEffect(() => { + const handleAuth = async () => { + try { + // This will set the session from the URL fragment if present + await supabase.auth.getSession() + } catch (error) { + console.error("Error completing magic link sign-in", error) + } finally { + // Always redirect to home or login depending on auth state + const { + data: { session }, + } = await supabase.auth.getSession() + router.replace(session ? "/" : "/login") + } + } + + handleAuth() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( +
+

Signing you in…

+
+ ) +} \ No newline at end of file diff --git a/src/app/components/AuthNavWrapper.tsx b/src/app/components/AuthNavWrapper.tsx new file mode 100644 index 0000000..2afc92a --- /dev/null +++ b/src/app/components/AuthNavWrapper.tsx @@ -0,0 +1,45 @@ +"use client" + +import { useEffect, useState } from "react" +import MainNav from "./MainNav" +import supabase from "@/app/lib/supabase" + +export default function AuthNavWrapper() { + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + const checkAuth = async () => { + try { + const { data: { session } } = await supabase.auth.getSession() + setIsAuthenticated(!!session) + } catch (error) { + console.error('Error checking auth status:', error) + setIsAuthenticated(false) + } finally { + setIsLoading(false) + } + } + + checkAuth() + + // Subscribe to auth changes + const { data: { subscription } } = supabase.auth.onAuthStateChange( + (_event, session) => { + setIsAuthenticated(!!session) + } + ) + + return () => { + subscription.unsubscribe() + } + }, []) + + // Don't render anything while loading to prevent flash of content + if (isLoading) { + return null + } + + // Only render nav when authenticated + return isAuthenticated ? : null +} \ No newline at end of file diff --git a/src/app/components/CompanySelector.tsx b/src/app/components/CompanySelector.tsx new file mode 100644 index 0000000..2d738ac --- /dev/null +++ b/src/app/components/CompanySelector.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { useEffect, useState, useRef, useCallback } from "react"; +import Image from "next/image"; +import { Company } from "../lib/types"; + +interface CompanySelectorProps { + onCompanySelected: (company: Company | null) => void; + selectedCompanyId: string | null; +} + +export default function CompanySelector({ + onCompanySelected, + selectedCompanyId +}: CompanySelectorProps) { + const [companies, setCompanies] = useState([]); + const [filteredCompanies, setFilteredCompanies] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeIndex, setActiveIndex] = useState(-1); + + const dropdownRef = useRef(null); + const inputRef = useRef(null); + const debounceTimerRef = useRef(null); + + useEffect(() => { + const fetchCompanies = async () => { + try { + setLoading(true); + console.log("Fetching companies from API..."); + const response = await fetch("/api/companies"); + + if (!response.ok) { + throw new Error(`Error: ${response.status}`); + } + + const data = await response.json(); + console.log(`Received ${data?.length || 0} companies from API`); + console.log("Sample data:", data?.slice(0, 3)); + + setCompanies(data); + setFilteredCompanies(data); + setLoading(false); + } catch (err: any) { + console.error("Failed to fetch companies:", err); + setError(err.message || "Failed to load companies"); + setLoading(false); + } + }; + + fetchCompanies(); + }, []); + + // Debounced search function + const debouncedSearch = useCallback((query: string) => { + // Clear any existing debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Set a new debounce timer + debounceTimerRef.current = setTimeout(() => { + if (query.trim() === "") { + setFilteredCompanies(companies); + } else { + const searchTerm = query.toLowerCase(); + const filtered = companies.filter(company => + company.business_name.toLowerCase().includes(searchTerm) + ); + setFilteredCompanies(filtered); + } + setActiveIndex(-1); + }, 300); // 300ms debounce + }, [companies]); + + // Update search when query changes + useEffect(() => { + debouncedSearch(searchQuery); + + // Cleanup timer on unmount + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [searchQuery, debouncedSearch]); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + const handleCompanySelect = (company: Company | null) => { + onCompanySelected(company); + setSearchQuery(""); + setIsDropdownOpen(false); + setActiveIndex(-1); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setIsDropdownOpen(true); + }; + + const handleInputFocus = () => { + setIsDropdownOpen(true); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Don't handle keyboard navigation when dropdown is closed + if (!isDropdownOpen) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex(prev => + prev < filteredCompanies.length - 1 ? prev + 1 : prev + ); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex(prev => (prev > 0 ? prev - 1 : prev)); + break; + case "Enter": + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < filteredCompanies.length) { + handleCompanySelect(filteredCompanies[activeIndex]); + } + break; + case "Escape": + e.preventDefault(); + setIsDropdownOpen(false); + inputRef.current?.blur(); + break; + default: + break; + } + }; + + if (loading) { + return ( +
Loading companies...
+ ); + } + + if (error) { + return ( +
Error: {error}
+ ); + } + + // Find the selected company + const selectedCompany = selectedCompanyId + ? companies.find(c => c.id === selectedCompanyId) + : null; + + return ( +
+ {selectedCompany ? ( +
+
+ {selectedCompany.logo && ( + {selectedCompany.business_name} + )} +
+

{selectedCompany.business_name}

+ {selectedCompany.website && ( +

{selectedCompany.website}

+ )} +
+
+ +
+ ) : ( + <> +
+ + {searchQuery && ( + + )} +
+ + {isDropdownOpen && ( +
+ {filteredCompanies.length > 0 ? ( + filteredCompanies.map((company, index) => ( +
handleCompanySelect(company)} + onMouseEnter={() => setActiveIndex(index)} + className={`flex items-center gap-3 p-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${ + activeIndex === index ? 'bg-blue-50' : '' + }`} + role="option" + aria-selected={activeIndex === index} + tabIndex={-1} + > + {company.logo ? ( + {company.business_name} + ) : ( +
+ {company.business_name.charAt(0)} +
+ )} +
+

{company.business_name}

+ {company.website && ( +

{company.website}

+ )} +
+
+ )) + ) : ( +
+ {searchQuery ? "No companies found matching your search" : "No companies available"} +
+ )} +
+ )} + + )} +
+ ); +} \ No newline at end of file diff --git a/src/app/components/ContactSelector.tsx b/src/app/components/ContactSelector.tsx new file mode 100644 index 0000000..63567a3 --- /dev/null +++ b/src/app/components/ContactSelector.tsx @@ -0,0 +1,312 @@ +"use client"; + +import { useEffect, useState, useRef, useCallback } from "react"; +import { Person } from "../lib/types"; +import Image from "next/image"; + +interface ContactSelectorProps { + companyId: string | null; + onContactSelected: (contact: Person | null) => void; + selectedContactId: string | null; +} + +export default function ContactSelector({ + companyId, + onContactSelected, + selectedContactId +}: ContactSelectorProps) { + const [contacts, setContacts] = useState([]); + const [filteredContacts, setFilteredContacts] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [activeIndex, setActiveIndex] = useState(-1); + + const dropdownRef = useRef(null); + const inputRef = useRef(null); + const debounceTimerRef = useRef(null); + + useEffect(() => { + const fetchContacts = async () => { + if (!companyId) { + setContacts([]); + setFilteredContacts([]); + return; + } + + try { + setLoading(true); + setError(null); + + const response = await fetch(`/api/companies/${companyId}/contacts`); + + if (!response.ok) { + throw new Error(`Error: ${response.status}`); + } + + const data = await response.json(); + setContacts(data); + setFilteredContacts(data); + + // If previously selected contact is not in the new contacts list, clear selection + if (selectedContactId && !data.some((contact: Person) => contact.id === selectedContactId)) { + onContactSelected(null); + } + + setLoading(false); + } catch (err: any) { + console.error("Failed to fetch contacts:", err); + setError(err.message || "Failed to load contacts"); + setLoading(false); + } + }; + + fetchContacts(); + }, [companyId, selectedContactId, onContactSelected]); + + // Debounced search function + const debouncedSearch = useCallback((query: string) => { + // Clear any existing debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Set a new debounce timer + debounceTimerRef.current = setTimeout(() => { + if (query.trim() === "") { + setFilteredContacts(contacts); + } else { + const searchTerm = query.toLowerCase(); + const filtered = contacts.filter(contact => + `${contact.first_name} ${contact.last_name}`.toLowerCase().includes(searchTerm) + ); + setFilteredContacts(filtered); + } + setActiveIndex(-1); + }, 300); // 300ms debounce + }, [contacts]); + + // Update search when query changes + useEffect(() => { + debouncedSearch(searchQuery); + + // Cleanup timer on unmount + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [searchQuery, debouncedSearch]); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + const handleContactSelect = (contact: Person | null) => { + onContactSelected(contact); + setSearchQuery(""); + setIsDropdownOpen(false); + setActiveIndex(-1); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setIsDropdownOpen(true); + }; + + const handleInputFocus = () => { + setIsDropdownOpen(true); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Don't handle keyboard navigation when dropdown is closed + if (!isDropdownOpen) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex(prev => + prev < filteredContacts.length - 1 ? prev + 1 : prev + ); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex(prev => (prev > 0 ? prev - 1 : prev)); + break; + case "Enter": + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < filteredContacts.length) { + handleContactSelect(filteredContacts[activeIndex]); + } + break; + case "Escape": + e.preventDefault(); + setIsDropdownOpen(false); + inputRef.current?.blur(); + break; + default: + break; + } + }; + + if (!companyId) { + return ( +
+ Please select a company first to view available contacts. +
+ ); + } + + if (loading) { + return ( +
Loading contacts...
+ ); + } + + if (error) { + return ( +
Error: {error}
+ ); + } + + // Find the selected contact + const selectedContact = selectedContactId + ? contacts.find(c => c.id === selectedContactId) + : null; + + return ( +
+ {selectedContact ? ( +
+
+ {selectedContact.photo ? ( + {`${selectedContact.first_name} + ) : ( +
+ {selectedContact.first_name.charAt(0)} +
+ )} +
+

+ {selectedContact.first_name} {selectedContact.last_name} +

+ {selectedContact.title && ( +

{selectedContact.title}

+ )} +
+
+ +
+ ) : ( + <> +
+ + {searchQuery && ( + + )} +
+ + {isDropdownOpen && ( +
+ {filteredContacts.length > 0 ? ( + filteredContacts.map((contact, index) => ( +
handleContactSelect(contact)} + onMouseEnter={() => setActiveIndex(index)} + className={`flex items-center gap-3 p-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${ + activeIndex === index ? 'bg-green-50' : '' + }`} + role="option" + aria-selected={activeIndex === index} + tabIndex={-1} + > + {contact.photo ? ( + {`${contact.first_name} + ) : ( +
+ {contact.first_name.charAt(0)} +
+ )} +
+

+ {contact.first_name} {contact.last_name} +

+ {contact.title && ( +

{contact.title}

+ )} +
+
+ )) + ) : ( +
+ {searchQuery + ? "No contacts found matching your search" + : "No contacts available for this company"} +
+ )} +
+ )} + + )} +
+ ); +} \ No newline at end of file diff --git a/src/app/components/EngagementSelector.tsx b/src/app/components/EngagementSelector.tsx new file mode 100644 index 0000000..c7ee5fb --- /dev/null +++ b/src/app/components/EngagementSelector.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useEffect, useState, useRef, useCallback } from "react"; +import { SupportEngagement } from "../lib/types"; + +interface EngagementSelectorProps { + companyId: string | null; + onEngagementSelected: (engagement: SupportEngagement | null) => void; + selectedEngagementId: string | null; +} + +// Helper function to strip HTML tags from text +const stripHtmlTags = (html: string | null | undefined): string => { + if (!html) return ""; + + // First replace common HTML tags with appropriate spacing + const withSpaces = html + .replace(/<\/p>/g, ' ') + .replace(//g, ' ') + .replace(/<\/div>/g, ' ') + .replace(/<\/li>/g, ' '); + + // Then remove all remaining HTML tags + return withSpaces + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') // Replace multiple spaces with a single space + .trim(); +}; + +export default function EngagementSelector({ + companyId, + onEngagementSelected, + selectedEngagementId +}: EngagementSelectorProps) { + const [engagements, setEngagements] = useState([]); + const [filteredEngagements, setFilteredEngagements] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [activeIndex, setActiveIndex] = useState(-1); + + const dropdownRef = useRef(null); + const inputRef = useRef(null); + const debounceTimerRef = useRef(null); + + useEffect(() => { + const fetchEngagements = async () => { + if (!companyId) { + setEngagements([]); + setFilteredEngagements([]); + return; + } + + try { + setLoading(true); + setError(null); + console.log("Fetching engagements from API..."); + + const response = await fetch(`/api/companies/${companyId}/engagements`); + + if (!response.ok) { + throw new Error(`Error: ${response.status}`); + } + + const data = await response.json(); + console.log(`Received ${data?.length || 0} engagements from API`); + console.log("Sample data:", data?.slice(0, 3)); + + setEngagements(data); + setFilteredEngagements(data); + + // If previously selected engagement is not in the new engagements list, clear selection + if (selectedEngagementId && !data.some((engagement: SupportEngagement) => engagement.id === selectedEngagementId)) { + onEngagementSelected(null); + } + + setLoading(false); + } catch (err: any) { + console.error("Failed to fetch engagements:", err); + setError(err.message || "Failed to load engagements"); + setLoading(false); + } + }; + + fetchEngagements(); + }, [companyId, selectedEngagementId, onEngagementSelected]); + + // Debounced search function + const debouncedSearch = useCallback((query: string) => { + // Clear any existing debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Set a new debounce timer + debounceTimerRef.current = setTimeout(() => { + if (query.trim() === "") { + setFilteredEngagements(engagements); + } else { + const searchTerm = query.toLowerCase(); + const filtered = engagements.filter(engagement => { + const descriptionText = stripHtmlTags(engagement.description).toLowerCase(); + return engagement.title.toLowerCase().includes(searchTerm) || + descriptionText.includes(searchTerm); + }); + setFilteredEngagements(filtered); + } + setActiveIndex(-1); + }, 300); // 300ms debounce + }, [engagements]); + + // Update search when query changes + useEffect(() => { + debouncedSearch(searchQuery); + + // Cleanup timer on unmount + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [searchQuery, debouncedSearch]); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + const handleEngagementSelect = (engagement: SupportEngagement | null) => { + onEngagementSelected(engagement); + setSearchQuery(""); + setIsDropdownOpen(false); + setActiveIndex(-1); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setIsDropdownOpen(true); + }; + + const handleInputFocus = () => { + setIsDropdownOpen(true); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Don't handle keyboard navigation when dropdown is closed + if (!isDropdownOpen) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex(prev => + prev < filteredEngagements.length - 1 ? prev + 1 : prev + ); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex(prev => (prev > 0 ? prev - 1 : prev)); + break; + case "Enter": + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < filteredEngagements.length) { + handleEngagementSelect(filteredEngagements[activeIndex]); + } + break; + case "Escape": + e.preventDefault(); + setIsDropdownOpen(false); + inputRef.current?.blur(); + break; + default: + break; + } + }; + + if (!companyId) { + return ( +
+ Please select a company first to view support engagements. +
+ ); + } + + if (loading) { + return ( +
Loading support engagements...
+ ); + } + + if (error) { + return ( +
Error: {error}
+ ); + } + + // Find the selected engagement + const selectedEngagement = selectedEngagementId + ? engagements.find(e => e.id === selectedEngagementId) + : null; + + return ( +
+ {selectedEngagement ? ( +
+
+

{selectedEngagement.title}

+ {selectedEngagement.description && ( +

+ {stripHtmlTags(selectedEngagement.description)} +

+ )} +
+ + {selectedEngagement.status} + + + {new Date(selectedEngagement.created_at).toLocaleDateString()} + +
+
+ +
+ ) : ( + <> +
+ + {searchQuery && ( + + )} +
+ + {isDropdownOpen && ( +
+ {filteredEngagements.length > 0 ? ( + filteredEngagements.map((engagement, index) => ( +
handleEngagementSelect(engagement)} + onMouseEnter={() => setActiveIndex(index)} + className={`flex flex-col gap-1 p-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${ + activeIndex === index ? 'bg-purple-50' : '' + }`} + role="option" + aria-selected={activeIndex === index} + tabIndex={-1} + > +
+

{engagement.title}

+ + {new Date(engagement.created_at).toLocaleDateString()} + +
+ {engagement.description && ( +

+ {stripHtmlTags(engagement.description)} +

+ )} +
+ + {engagement.status} + +
+
+ )) + ) : ( +
+ {searchQuery + ? "No support engagements found matching your search" + : "No support engagements available for this company"} +
+ )} +
+ )} + + )} +
+ ); +} \ No newline at end of file diff --git a/src/app/components/InterviewAgent.tsx b/src/app/components/InterviewAgent.tsx new file mode 100644 index 0000000..c7fb67a --- /dev/null +++ b/src/app/components/InterviewAgent.tsx @@ -0,0 +1,200 @@ +"use client"; + +import React, { useEffect, useState, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { AgentConfig } from "@/app/types"; +import { createInterviewAgentConfig } from "@/app/lib/createInterviewConfig"; +import { getInterviewWithRelationsClient as getInterviewWithRelations } from "@/app/lib/interviewClientHelper"; +import { useTranscript } from "@/app/contexts/TranscriptContext"; +import Link from "next/link"; + +interface InterviewAgentProps { + onAgentConfigLoaded: (config: AgentConfig) => void; +} + +const InterviewAgent: React.FC = ({ onAgentConfigLoaded }) => { + const searchParams = useSearchParams(); + const router = useRouter(); + const { setActiveInterviewId, saveTranscriptData } = useTranscript(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [interviewId, setInterviewId] = useState(null); + const [isCompleting, setIsCompleting] = useState(false); + const [completeSuccess, setCompleteSuccess] = useState(false); + + useEffect(() => { + const id = searchParams.get("interviewId"); + if (!id) { + setError("No interview ID provided"); + setLoading(false); + return; + } + + setInterviewId(id); + + // Set the active interview ID in the TranscriptContext + setActiveInterviewId(id); + + loadInterviewData(id); + + // Cleanup function to clear active interview ID when component unmounts + return () => { + setActiveInterviewId(null); + }; + }, [searchParams, setActiveInterviewId]); + + const loadInterviewData = async (id: string) => { + try { + setLoading(true); + setError(null); + console.log("Loading interview data for ID:", id); + + const interviewData = await getInterviewWithRelations(id); + + if (!interviewData) { + console.error("Failed to load interview data, result was null"); + setError("Failed to load interview data"); + setLoading(false); + return; + } + + console.log("Interview data loaded successfully:", interviewData); + + if (!interviewData.questions || interviewData.questions.length === 0) { + console.error("Interview has no questions"); + setError("This interview has no questions"); + setLoading(false); + return; + } + + // Create agent config based on interview data + const agentConfig = createInterviewAgentConfig(interviewData); + console.log("Agent config created:", agentConfig.name); + + // Pass the config to parent component + onAgentConfigLoaded(agentConfig); + + setLoading(false); + } catch (err: any) { + console.error("Error loading interview data:", err); + setError(err.message || "Failed to load interview data"); + setLoading(false); + } + }; + + const completeInterview = async () => { + if (!interviewId) return; + + try { + setIsCompleting(true); + + // First, save the latest transcript data + await saveTranscriptData(interviewId); + + // Then mark the interview as completed + const response = await fetch('/api/interviews/complete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ interviewId }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to mark interview as completed'); + } + + setCompleteSuccess(true); + + // Redirect to interview details page after short delay + setTimeout(() => { + router.push(`/interviews/${interviewId}`); + }, 2000); + + } catch (err: any) { + console.error("Error completing interview:", err); + setError(err.message || "Failed to complete interview"); + } finally { + setIsCompleting(false); + } + }; + + if (loading) { + return ( +
+
+

Loading interview data...

+
+
+ ); + } + + if (error) { + return ( +
+

Error

+

{error}

+ + Go Back to Interviews + +
+ ); + } + + return ( + +
+
+

Active Interview Session

+
+ + + Back to Interviews + +
+
+

+ Interview ID: {interviewId} +

+

+ ✓ Interview data loaded successfully +

+

+ Transcript will be saved automatically as the interview progresses +

+ {completeSuccess && ( +
+

+ Interview successfully marked as completed. Redirecting to interview details... +

+
+ )} +
+
+ ); +}; + +export default InterviewAgent; \ No newline at end of file diff --git a/src/app/components/InterviewConnectForm.tsx b/src/app/components/InterviewConnectForm.tsx new file mode 100644 index 0000000..378e854 --- /dev/null +++ b/src/app/components/InterviewConnectForm.tsx @@ -0,0 +1,228 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; + +interface InterviewConnectFormProps { + interviewId: string; + initialCompanyId?: string; + initialPersonId?: string; + initialSupportEngagementId?: string; +} + +interface Company { + id: string; + business_name: string; +} + +interface Person { + id: string; + first_name: string; + last_name: string; + title?: string; +} + +interface SupportEngagement { + id: string; + title: string; + support_type: string; +} + +export default function InterviewConnectForm({ + interviewId, + initialCompanyId, + initialPersonId, + initialSupportEngagementId, +}: InterviewConnectFormProps) { + const router = useRouter(); + + const [companies, setCompanies] = useState([]); + const [people, setPeople] = useState([]); + const [engagements, setEngagements] = useState([]); + + const [selectedCompanyId, setSelectedCompanyId] = useState(initialCompanyId || ""); + const [selectedPersonId, setSelectedPersonId] = useState(initialPersonId || ""); + const [selectedEngagementId, setSelectedEngagementId] = useState(initialSupportEngagementId || ""); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + // Load reference data + useEffect(() => { + const fetchReferenceData = async () => { + setLoading(true); + try { + // Fetch companies + const companiesResponse = await fetch("/api/companies"); + if (companiesResponse.ok) { + const companiesData = await companiesResponse.json(); + setCompanies(companiesData); + } + + // Fetch people + const peopleResponse = await fetch("/api/people"); + if (peopleResponse.ok) { + const peopleData = await peopleResponse.json(); + setPeople(peopleData); + } + + // Fetch support engagements + const engagementsResponse = await fetch("/api/engagements"); + if (engagementsResponse.ok) { + const engagementsData = await engagementsResponse.json(); + setEngagements(engagementsData); + } + + setLoading(false); + } catch (err) { + console.error("Error fetching reference data:", err); + setError("Failed to load reference data"); + setLoading(false); + } + }; + + fetchReferenceData(); + }, []); + + // Filter people and engagements when company changes + useEffect(() => { + if (selectedCompanyId) { + // Could add additional filtering by company if needed + } + }, [selectedCompanyId]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setError(null); + setSuccess(false); + + try { + const response = await fetch("/api/interviews/connect", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + interviewId, + companyId: selectedCompanyId || null, + personId: selectedPersonId || null, + supportEngagementId: selectedEngagementId || null, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to connect interview"); + } + + setSuccess(true); + setSaving(false); + + // Refresh the page or redirect after successful save + setTimeout(() => { + router.refresh(); + }, 1500); + } catch (err: any) { + console.error("Error connecting interview:", err); + setError(err.message || "An error occurred while saving"); + setSaving(false); + } + }; + + if (loading) { + return ( +
+

Loading data...

+
+ ); + } + + return ( +
+

Connect Interview Data

+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ Interview connections updated successfully! +
+ )} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/components/InterviewExperience.tsx b/src/app/components/InterviewExperience.tsx new file mode 100644 index 0000000..99bbdd4 --- /dev/null +++ b/src/app/components/InterviewExperience.tsx @@ -0,0 +1,302 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { useTranscript } from "@/app/contexts/TranscriptContext"; +import type { InterviewWithRelations as InterviewData } from "@/app/lib/interviewClientHelper"; + +interface InterviewExperienceProps { + interviewData: InterviewData; + isAgentSpeaking: boolean; + isUserSpeaking: boolean; + isAgentThinking: boolean; + sessionStatus: string; + agentStatusMessage: string; +} + +const InterviewExperience: React.FC = ({ + interviewData, + isAgentSpeaking, + isUserSpeaking, + isAgentThinking, + sessionStatus, + agentStatusMessage, +}) => { + const { transcriptItems } = useTranscript(); + const interview = interviewData; + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [questionHistory, setQuestionHistory] = useState([]); + const visualizerRef = useRef(null); + const animationRef = useRef(null); + const [hasFirstQuestionBeenAsked, setHasFirstQuestionBeenAsked] = useState(false); + + // Audio visualization effect + useEffect(() => { + // If canvas ref is not set, or neither agent nor user is speaking, do nothing or clear. + if (!visualizerRef.current || (!isAgentSpeaking && !isUserSpeaking)) { + if (visualizerRef.current) { + const canvas = visualizerRef.current; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + } + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = null; + } + return; + } + + const canvas = visualizerRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + canvas.width = canvas.offsetWidth; + canvas.height = canvas.offsetHeight; + + let particles: Array<{x: number, y: number, radius: number, speed: number, directionFactor: number}> = []; // Added directionFactor + + // Determine active speaker for styling + const agentColor = 'rgba(79, 70, 229, 0.7)'; // Indigo + const agentLineColor = 'rgba(79, 70, 229, 0.3)'; + const userColor = 'rgba(14, 165, 233, 0.7)'; // Sky Blue + const userLineColor = 'rgba(14, 165, 233, 0.3)'; + + let particleFillColor; + let middleLineColor; + let particleDirectionFactor; + + if (isUserSpeaking) { // User speaking takes visual priority + particleFillColor = userColor; + middleLineColor = userLineColor; + particleDirectionFactor = -1; // RTL for user + } else if (isAgentSpeaking) { // Agent speaking, user is not + particleFillColor = agentColor; + middleLineColor = agentLineColor; + particleDirectionFactor = 1; // LTR for agent + } else { + // This case should ideally not be hit if the effect guard condition is correct, + // but as a fallback, ensure no animation or clear. + if (animationRef.current) cancelAnimationFrame(animationRef.current); + ctx.clearRect(0,0,canvas.width, canvas.height); + return; // Do not proceed with particle animation if neither is speaking + } + + const initParticles = () => { + particles = []; + const particleCount = 50; + for (let i = 0; i < particleCount; i++) { + particles.push({ + x: Math.random() * canvas.width, + y: canvas.height / 2 + (Math.random() * 80 - 40), + radius: Math.random() * 3 + 1, + speed: Math.random() * 1 + 0.5, + directionFactor: particleDirectionFactor, // Use determined direction + }); + } + }; + + const animate = () => { + if (!ctx) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + ctx.beginPath(); + ctx.moveTo(0, canvas.height / 2); + ctx.lineTo(canvas.width, canvas.height / 2); + ctx.strokeStyle = middleLineColor; + ctx.stroke(); + + particles.forEach(particle => { + particle.x += particle.speed * particle.directionFactor; // Apply direction + particle.y = canvas.height / 2 + + Math.sin(Date.now() * 0.002 + particle.x * 0.01) * + (20 + Math.sin(Date.now() * 0.001) * 15); + + // Reset based on direction + if (particle.directionFactor > 0 && particle.x > canvas.width) { // LTR + particle.x = 0; + } else if (particle.directionFactor < 0 && particle.x < 0) { // RTL + particle.x = canvas.width; + } + + ctx.beginPath(); + ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2); + ctx.fillStyle = particleFillColor; + ctx.fill(); + }); + + animationRef.current = requestAnimationFrame(animate); + }; + + initParticles(); + animate(); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = null; + } + }; + }, [isAgentSpeaking, isUserSpeaking]); // Added isUserSpeaking to dependencies + + // Question tracking effect – track progress without causing infinite re-renders + useEffect(() => { + if (!interview || !interview.questions || transcriptItems.length === 0) return; + + const normalize = (s: string) => + s.toLowerCase().replace(/[^\w\s]/g, ""); + + const lastAssistantMsg = [...transcriptItems] + .reverse() + .find((item) => item.role === "assistant" && !item.isHidden); + + if (!lastAssistantMsg) return; + + const msgNorm = normalize(lastAssistantMsg.title || ""); + + for (let i = 0; i < interview.questions.length; i++) { + const qNorm = normalize(interview.questions[i].text); + const words = qNorm.split(/\s+/).filter(Boolean); + if (words.length === 0) continue; + + const overlap = words.filter((w) => msgNorm.includes(w)).length; + const ratio = overlap / words.length; + + const isFirstQuestionMatch = !hasFirstQuestionBeenAsked && i === 0; + const isNextSequential = i === currentQuestionIndex + 1; + + if ((ratio >= 0.6 && (isFirstQuestionMatch || isNextSequential))) { + setCurrentQuestionIndex(i); + setQuestionHistory((prev) => { + if (prev.includes(interview.questions[i].text.toLowerCase())) return prev; + return [...prev, interview.questions[i].text.toLowerCase()]; + }); + if (isFirstQuestionMatch) setHasFirstQuestionBeenAsked(true); + break; + } + } + }, [transcriptItems, interview]); + + return ( +
+ {/* Header */} +
+
+

+ {interview.company ? interview.company.business_name : "Interview"} - {interview.person ? `${interview.person.first_name} ${interview.person.last_name}` : "Candidate"} +

+ +
+ {sessionStatus === "CONNECTED" + ? "Live" + : sessionStatus === "CONNECTING" + ? "Connecting..." + : "Disconnected"} +
+
+
+ + {/* Question Tracker */} +
+ {!hasFirstQuestionBeenAsked ? ( +
+ The agent is preparing to begin the interview. Please listen for instructions. +
+ ) : ( + <> +
+
+

Current Discussion

+ Question {currentQuestionIndex + 1} of {interview.questions.length} +
+
+

{interview.questions[currentQuestionIndex].text}

+ {interview.questions[currentQuestionIndex].context && ( +

{interview.questions[currentQuestionIndex].context}

+ )} +
+
+ {/* Progress indicator */} +
+
+
+ {/* Question history */} + {questionHistory.length > 0 && ( +
+

Covered Questions

+
+ {questionHistory.map((question, idx) => ( +
+ {question.length > 70 ? question.substring(0, 70) + '...' : question} +
+ ))} +
+
+ )} + + )} +
+ + {/* Voice Visualization */} +
+
+
+
+ + {agentStatusMessage} + +
+ +
+ {(isAgentSpeaking || isUserSpeaking || isAgentThinking) ? ( + + ) : ( + // Default static mic icon when no one is speaking +
+ + + + + + +
+ )} +
+
+ All information collected is considered confidential and will not be shared with anyone outside the authorized Volta team you are working with. +
+
+
+
+ ); +}; + +export default InterviewExperience; \ No newline at end of file diff --git a/src/app/components/InterviewList.tsx b/src/app/components/InterviewList.tsx new file mode 100644 index 0000000..25e1f0b --- /dev/null +++ b/src/app/components/InterviewList.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Interview } from "../lib/types"; +import NextLink from "next/link"; +import { Link as LinkIcon, MoreVertical } from "lucide-react"; + +export default function InterviewList() { + const [interviews, setInterviews] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [copiedInterviewId, setCopiedInterviewId] = useState(null); + const [openMenuId, setOpenMenuId] = useState(null); + + useEffect(() => { + const fetchInterviews = async () => { + try { + setLoading(true); + const response = await fetch("/api/interviews"); + + if (!response.ok) { + throw new Error(`Error: ${response.status}`); + } + + const data = await response.json(); + setInterviews(data); + setLoading(false); + } catch (err: any) { + console.error("Failed to fetch interviews:", err); + setError(err.message || "Failed to load interviews"); + setLoading(false); + } + }; + + fetchInterviews(); + }, []); + + const handleCopyLink = async (inviteToken: string, id: string) => { + if (!inviteToken) return; + const url = `${window.location.origin}/i/${inviteToken}`; + + // Clipboard API (requires secure context) + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(url); + setCopiedInterviewId(id); + setTimeout(() => setCopiedInterviewId(null), 2000); + return; + } catch { + console.warn("Clipboard API failed, falling back"); + } + } + + // Fallback to textarea + execCommand + try { + const textarea = document.createElement("textarea"); + textarea.value = url; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + setCopiedInterviewId(id); + setTimeout(() => setCopiedInterviewId(null), 2000); + } catch { + alert(`Copy this link: ${url}`); + } + }; + + const handleDeleteInterview = async (id: string) => { + const confirmDelete = window.confirm("Are you sure you want to delete this interview? This action cannot be undone."); + if (!confirmDelete) return; + + try { + const res = await fetch(`/api/interviews/${id}`, { method: "DELETE" }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to delete interview"); + } + // Optimistically remove from list + setInterviews((prev) => prev.filter((int) => int.id !== id)); + } catch (err: any) { + alert(err.message || "Failed to delete interview"); + console.error("Delete interview error", err); + } + }; + + const toggleMenu = (id: string) => { + setOpenMenuId((prev) => (prev === id ? null : id)); + }; + + if (loading) { + return ( +
+
Loading interviews...
+
+ ); + } + + if (error) { + return ( +
+

Error loading interviews

+

{error}

+
+ ); + } + + if (interviews.length === 0) { + return ( +
+

No interviews found. Create an interview to get started.

+ + Create New Interview + +
+ ); + } + + return ( +
+

Interviews

+
+ {interviews.map((interview) => ( +
+
+
+

{interview.admin_notes || "Untitled Interview"}

+

+ Created: {new Date(interview.created_at).toLocaleDateString()} +

+

+ + {interview.status === "completed" ? "Completed" : "Pending"} + +

+
+
+ + + {openMenuId === interview.id && ( +
+ + View Details + + + Launch AI + + + +
+ )} +
+
+ + {interview.questions && interview.questions.length > 0 && ( +
+

Questions:

+
    + {interview.questions.slice(0, 3).map((question) => ( +
  • + {question.text} +
  • + ))} + {interview.questions.length > 3 && ( +
  • + +{interview.questions.length - 3} more questions +
  • + )} +
+
+ )} +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/components/MainNav.tsx b/src/app/components/MainNav.tsx new file mode 100644 index 0000000..56b584c --- /dev/null +++ b/src/app/components/MainNav.tsx @@ -0,0 +1,60 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { getSupabaseClient } from "@/app/lib/supabase"; + +export default function MainNav() { + const pathname = usePathname(); + const router = useRouter(); + const supabase = getSupabaseClient(); + + const navItems = [ + { label: "Home", href: "/" }, + { label: "Interviews", href: "/interviews" }, + ]; + + const handleSignOut = async () => { + await supabase.auth.signOut(); + router.refresh(); + router.push("/login"); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/src/app/components/SupportPersonSelector.tsx b/src/app/components/SupportPersonSelector.tsx new file mode 100644 index 0000000..038fa47 --- /dev/null +++ b/src/app/components/SupportPersonSelector.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { useEffect, useState, useRef, useCallback } from "react"; +import Image from "next/image"; +import { Person } from "../lib/types"; + +interface SupportPersonSelectorProps { + engagementId: string | null; + onPersonSelected: (person: Person | null) => void; + selectedPersonId: string | null; +} + +export default function SupportPersonSelector({ + engagementId, + onPersonSelected, + selectedPersonId +}: SupportPersonSelectorProps) { + const [people, setPeople] = useState([]); + const [filteredPeople, setFilteredPeople] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [activeIndex, setActiveIndex] = useState(-1); + + const dropdownRef = useRef(null); + const inputRef = useRef(null); + const debounceTimerRef = useRef(null); + + useEffect(() => { + const fetchSupportPeople = async () => { + if (!engagementId) { + setPeople([]); + setFilteredPeople([]); + return; + } + + try { + setLoading(true); + setError(null); + console.log("Fetching support people from API..."); + + const response = await fetch(`/api/engagements/${engagementId}/people`); + + if (!response.ok) { + throw new Error(`Error: ${response.status}`); + } + + const data = await response.json(); + console.log(`Received ${data?.length || 0} support people from API`); + console.log("Sample data:", data?.slice(0, 3)); + + setPeople(data); + setFilteredPeople(data); + + // If previously selected person is not in the new people list, clear selection + if (selectedPersonId && !data.some((person: Person) => person.id === selectedPersonId)) { + onPersonSelected(null); + } + + setLoading(false); + } catch (err: any) { + console.error("Failed to fetch support people:", err); + setError(err.message || "Failed to load support people"); + setLoading(false); + } + }; + + fetchSupportPeople(); + }, [engagementId, selectedPersonId, onPersonSelected]); + + // Debounced search function + const debouncedSearch = useCallback((query: string) => { + // Clear any existing debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Set a new debounce timer + debounceTimerRef.current = setTimeout(() => { + if (query.trim() === "") { + setFilteredPeople(people); + } else { + const searchTerm = query.toLowerCase(); + const filtered = people.filter(person => + `${person.first_name} ${person.last_name}`.toLowerCase().includes(searchTerm) || + (person.title && person.title.toLowerCase().includes(searchTerm)) + ); + setFilteredPeople(filtered); + } + setActiveIndex(-1); + }, 300); // 300ms debounce + }, [people]); + + // Update search when query changes + useEffect(() => { + debouncedSearch(searchQuery); + + // Cleanup timer on unmount + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [searchQuery, debouncedSearch]); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + const handlePersonSelect = (person: Person | null) => { + onPersonSelected(person); + setSearchQuery(""); + setIsDropdownOpen(false); + setActiveIndex(-1); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setIsDropdownOpen(true); + }; + + const handleInputFocus = () => { + setIsDropdownOpen(true); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Don't handle keyboard navigation when dropdown is closed + if (!isDropdownOpen) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex(prev => + prev < filteredPeople.length - 1 ? prev + 1 : prev + ); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex(prev => (prev > 0 ? prev - 1 : prev)); + break; + case "Enter": + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < filteredPeople.length) { + handlePersonSelect(filteredPeople[activeIndex]); + } + break; + case "Escape": + e.preventDefault(); + setIsDropdownOpen(false); + inputRef.current?.blur(); + break; + default: + break; + } + }; + + if (!engagementId) { + return ( +
+ Please select a support engagement first to view support personnel. +
+ ); + } + + if (loading) { + return ( +
Loading support personnel...
+ ); + } + + if (error) { + return ( +
Error: {error}
+ ); + } + + // Find the selected person + const selectedPerson = selectedPersonId + ? people.find(p => p.id === selectedPersonId) + : null; + + return ( +
+ {selectedPerson ? ( +
+
+ {selectedPerson.photo ? ( + {`${selectedPerson.first_name} + ) : ( +
+ {selectedPerson.first_name.charAt(0)} +
+ )} +
+

+ {selectedPerson.first_name} {selectedPerson.last_name} +

+ {selectedPerson.title && ( +

{selectedPerson.title}

+ )} +
+
+ +
+ ) : ( + <> +
+ + {searchQuery && ( + + )} +
+ + {isDropdownOpen && ( +
+ {filteredPeople.length > 0 ? ( + filteredPeople.map((person, index) => ( +
handlePersonSelect(person)} + onMouseEnter={() => setActiveIndex(index)} + className={`flex items-center gap-3 p-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${ + activeIndex === index ? 'bg-amber-50' : '' + }`} + role="option" + aria-selected={activeIndex === index} + tabIndex={-1} + > + {person.photo ? ( + {`${person.first_name} + ) : ( +
+ {person.first_name.charAt(0)} +
+ )} +
+

+ {person.first_name} {person.last_name} +

+ {person.title && ( +

{person.title}

+ )} +
+
+ )) + ) : ( +
+ {searchQuery + ? "No support personnel found matching your search" + : "No support personnel available for this engagement"} +
+ )} +
+ )} + + )} +
+ ); +} \ No newline at end of file diff --git a/src/app/contexts/TranscriptContext.tsx b/src/app/contexts/TranscriptContext.tsx index 312b0ab..140226a 100644 --- a/src/app/contexts/TranscriptContext.tsx +++ b/src/app/contexts/TranscriptContext.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { createContext, useContext, useState, FC, PropsWithChildren } from "react"; +import React, { createContext, useContext, useState, FC, PropsWithChildren, useEffect, useCallback } from "react"; import { v4 as uuidv4 } from "uuid"; import { TranscriptItem } from "@/app/types"; @@ -11,12 +11,17 @@ type TranscriptContextValue = { addTranscriptBreadcrumb: (title: string, data?: Record) => void; toggleTranscriptItemExpand: (itemId: string) => void; updateTranscriptItemStatus: (itemId: string, newStatus: "IN_PROGRESS" | "DONE") => void; + saveTranscriptData: (interviewId: string) => Promise; + setActiveInterviewId: (id: string | null) => void; }; const TranscriptContext = createContext(undefined); export const TranscriptProvider: FC = ({ children }) => { const [transcriptItems, setTranscriptItems] = useState([]); + const [activeInterviewId, setActiveInterviewId] = useState(null); + const [saveTimerId, setSaveTimerId] = useState(null); + const [lastSaveTime, setLastSaveTime] = useState(0); function newTimestampPretty(): string { return new Date().toLocaleTimeString([], { @@ -97,6 +102,84 @@ export const TranscriptProvider: FC = ({ children }) => { ); }; + // Function to save transcript data to the database + const saveTranscriptData = useCallback(async (interviewId: string) => { + if (!interviewId || transcriptItems.length === 0) return; + + try { + // Filter out system messages and information only relevant to the UI + const messages = transcriptItems + .filter(item => !item.isHidden && item.type === "MESSAGE") + .map(item => ({ + id: item.itemId, + role: item.role, + content: item.title, + timestamp: item.timestamp, + created_at: item.createdAtMs, + status: item.status + })); + + // Format data for saving + const dataToSave = { + messages, + metadata: { + last_updated: Date.now(), + message_count: messages.length + } + }; + + const response = await fetch('/api/interviews/save-data', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + interviewId, + transcriptData: dataToSave + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + console.error("Failed to save transcript data:", errorData); + return; + } + + setLastSaveTime(Date.now()); + console.log(`Transcript saved for interview ${interviewId}`); + } catch (error) { + console.error("Error saving transcript data:", error); + } + }, [transcriptItems]); + + // Set up auto-save when active interview is set and transcript changes + useEffect(() => { + // Clear any existing timer + if (saveTimerId) { + clearTimeout(saveTimerId); + setSaveTimerId(null); + } + + // If no active interview, don't set up auto-save + if (!activeInterviewId) return; + + // Don't save too frequently - set minimum interval to 5 seconds + const timeSinceLastSave = Date.now() - lastSaveTime; + const saveDelay = Math.max(5000 - timeSinceLastSave, 0); + + // Set up a timer to save transcript data + const timer = setTimeout(() => { + saveTranscriptData(activeInterviewId); + }, saveDelay); + + setSaveTimerId(timer); + + // Clean up timer on unmount + return () => { + if (timer) clearTimeout(timer); + }; + }, [activeInterviewId, transcriptItems, saveTranscriptData, lastSaveTime]); + return ( = ({ children }) => { addTranscriptBreadcrumb, toggleTranscriptItemExpand, updateTranscriptItemStatus, + saveTranscriptData, + setActiveInterviewId }} > {children} diff --git a/src/app/globals.css b/src/app/globals.css index 30f9f4b..497f657 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,21 +2,84 @@ @tailwind components; @tailwind utilities; -:root { - --background: #fafafa; - --foreground: #171717; -} - -@media (prefers-color-scheme: dark) { +/* Removing dark mode preference */ +/* @media (prefers-color-scheme: dark) { :root { --background: #0a0a0a; --foreground: #ededed; } -} +} */ body { - color: var(--foreground); - background: var(--background); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; } + + + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + + + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/app/hooks/useHandleServerEvent.ts b/src/app/hooks/useHandleServerEvent.ts index b564cdb..81cae33 100644 --- a/src/app/hooks/useHandleServerEvent.ts +++ b/src/app/hooks/useHandleServerEvent.ts @@ -11,7 +11,13 @@ export interface UseHandleServerEventParams { selectedAgentConfigSet: AgentConfig[] | null; sendClientEvent: (eventObj: any, eventNameSuffix?: string) => void; setSelectedAgentName: (name: string) => void; + customAgentConfig?: AgentConfig | null; shouldForceResponse?: boolean; + onFunctionResult?: (name: string, result: any) => void; + setIsHearingUser: (isHearing: boolean) => void; + setIsThinking: (isThinking: boolean) => void; + setIsSpeakingAudio: (isSpeaking: boolean) => void; + setIsSpeakingText: (isSpeaking: boolean) => void; } export function useHandleServerEvent({ @@ -20,6 +26,12 @@ export function useHandleServerEvent({ selectedAgentConfigSet, sendClientEvent, setSelectedAgentName, + customAgentConfig, + onFunctionResult, + setIsHearingUser, + setIsThinking, + setIsSpeakingAudio, + setIsSpeakingText, }: UseHandleServerEventParams) { const { transcriptItems, @@ -39,7 +51,7 @@ export function useHandleServerEvent({ const args = JSON.parse(functionCallParams.arguments); const currentAgent = selectedAgentConfigSet?.find( (a) => a.name === selectedAgentName - ); + ) || customAgentConfig || null; addTranscriptBreadcrumb(`function call: ${functionCallParams.name}`, args); @@ -60,6 +72,10 @@ export function useHandleServerEvent({ }, }); sendClientEvent({ type: "response.create" }); + + if (onFunctionResult) { + onFunctionResult(functionCallParams.name, fnResult); + } } else if (functionCallParams.name === "transferAgents") { const destinationAgent = args.destination_agent; const newAgentConfig = @@ -118,6 +134,51 @@ export function useHandleServerEvent({ break; } + case "input_audio_buffer.speech_started": { + setIsHearingUser(true); + break; + } + + case "input_audio_buffer.speech_stopped": { + setIsHearingUser(false); + setIsThinking(true); + break; + } + + case "response.created": { + setIsThinking(true); + setIsSpeakingText(false); + break; + } + + case "response.text.delta": { + setIsThinking(false); + setIsSpeakingText(true); + + const itemId = serverEvent.item_id; + const deltaText = serverEvent.delta || ""; + + if (itemId) { + const existingItem = transcriptItems.find(item => item.itemId === itemId && item.type === "MESSAGE"); + if (existingItem) { + updateTranscriptMessage(itemId, deltaText, true); + } else { + addTranscriptMessage(itemId, "assistant", deltaText); + } + } + break; + } + + case "response.text.done": { + setIsSpeakingText(false); + break; + } + + case "response.audio.done": { + // setIsSpeakingAudio(false); + break; + } + case "conversation.item.created": { let text = serverEvent.item?.content?.[0]?.text || @@ -161,6 +222,7 @@ export function useHandleServerEvent({ } case "response.done": { + let wasFunctionCall = false; if (serverEvent.response?.output) { serverEvent.response.output.forEach((outputItem) => { if ( @@ -168,6 +230,7 @@ export function useHandleServerEvent({ outputItem.name && outputItem.arguments ) { + wasFunctionCall = true; handleFunctionCall({ name: outputItem.name, call_id: outputItem.call_id, @@ -176,6 +239,9 @@ export function useHandleServerEvent({ } }); } + if (!wasFunctionCall) { + setIsThinking(false); + } break; } diff --git a/src/app/i/[token]/page.tsx b/src/app/i/[token]/page.tsx new file mode 100644 index 0000000..925465d --- /dev/null +++ b/src/app/i/[token]/page.tsx @@ -0,0 +1,25 @@ +import { redirect } from "next/navigation"; +import supabaseServer from "@/app/lib/supabase-server"; + +export default async function InvitePage({ params }: { params: Promise<{ token: string }> }) { + const { token } = await params; + + // Fetch interview by invite token + const { data: interview, error } = await supabaseServer + .from("interviews") + .select("id, status") + .eq("invite_token", token) + .single(); + + if (error || !interview) { + redirect("/invite-not-found"); + } + + // If interview is completed, show message page instead + if (interview.status === "completed") { + redirect("/invite-completed"); + } + + // Redirect to app page in candidate mode + redirect(`/app?interviewId=${interview.id}&candidate=1`); +} \ No newline at end of file diff --git a/src/app/i/thank-you/page.tsx b/src/app/i/thank-you/page.tsx new file mode 100644 index 0000000..e37c181 --- /dev/null +++ b/src/app/i/thank-you/page.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; + +export default function ThankYouPage() { + return ( +
+
+

Thank you for your time!

+

You may now close this tab or return to Volta.

+ + Return to Volta + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/interviews/[id]/page.tsx b/src/app/interviews/[id]/page.tsx new file mode 100644 index 0000000..deeb0bb --- /dev/null +++ b/src/app/interviews/[id]/page.tsx @@ -0,0 +1,297 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { getInterviewWithRelationsClient as getInterviewWithRelations } from "@/app/lib/interviewClientHelper"; +import InterviewConnectForm from "@/app/components/InterviewConnectForm"; + +export default function InterviewDetailPage() { + const params = useParams(); + const router = useRouter(); + const [loading, setLoading] = useState(true); + const [interview, setInterview] = useState(null); + const [error, setError] = useState(null); + + const interviewId = params.id as string; + + useEffect(() => { + if (!interviewId) return; + + fetchInterviewData(); + }, [interviewId]); + + const fetchInterviewData = async () => { + try { + setLoading(true); + + const interviewData = await getInterviewWithRelations(interviewId); + + if (!interviewData) { + setError("Failed to load interview data"); + setLoading(false); + return; + } + + setInterview(interviewData); + setLoading(false); + } catch (error: any) { + console.error("Error fetching interview:", error); + setError(error.message || "Failed to load interview data"); + setLoading(false); + } + }; + + const startInterviewSession = () => { + router.push(`/app?interviewId=${interviewId}`); + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
+

Error

+

{error}

+ + Back to Interviews + +
+
+ ); + } + + return ( +
+
+
+ + ← Back to Interviews + +

Interview Details

+
+ {interview.status !== 'completed' && ( + + )} + {interview.status === 'completed' && ( +
+ This interview has been completed +
+ )} +
+ +
+
+
+

General Information

+
+
+
Interview ID
+
{interview.id}
+
+
+
Status
+
+ + {interview.status === 'completed' ? 'Completed' : 'In Progress'} + +
+
+ {interview.completed_at && ( +
+
Completed Date
+
{new Date(interview.completed_at).toLocaleString()}
+
+ )} + {interview.admin_notes && ( +
+
Admin Notes
+
{interview.admin_notes}
+
+ )} +
+
+ +
+ {interview.company && ( +
+

Company Information

+
+
+
Name
+
{interview.company.business_name}
+
+ {interview.company.description && ( +
+
Description
+
{interview.company.description}
+
+ )} + {interview.company.province && ( +
+
Location
+
{interview.company.province}
+
+ )} +
+
+ )} + + {interview.person && ( +
+

Contact Information

+
+
+
Name
+
{interview.person.first_name} {interview.person.last_name}
+
+ {interview.person.title && ( +
+
Title
+
{interview.person.title}
+
+ )} +
+
+ )} +
+
+
+ + {interview.support_engagement && ( +
+

Support Engagement

+
+
+
Title
+
{interview.support_engagement.title}
+
+ {interview.support_engagement.description && ( +
+
Description
+
{interview.support_engagement.description}
+
+ )} +
+
+
Type
+
{interview.support_engagement.support_type}
+
+
+
Status
+
+ + {interview.support_engagement.status} + +
+
+
+
Date Identified
+
{new Date(interview.support_engagement.date_identified).toLocaleDateString()}
+
+
+
+
+ )} + +
+
+

Interview Questions

+ {interview.questions && interview.questions.length > 0 ? ( +
    + {interview.questions.map((question: any) => ( +
  1. +

    {question.text}

    + {question.context && ( +

    {question.context}

    + )} +
  2. + ))} +
+ ) : ( +

No questions have been added to this interview.

+ )} +
+ +
+ +
+
+ + {interview.interview_data && interview.interview_data.messages && interview.interview_data.messages.length > 0 && ( +
+

Interview Transcript

+

+ Last updated: {new Date(interview.interview_data.metadata.last_updated).toLocaleString()} +

+ +
+ {interview.interview_data.messages.map((message: any) => ( +
+
+ + {message.role === 'assistant' ? 'Agent' : 'User'} + + {message.timestamp} +
+

{message.content}

+
+ ))} +
+ +
+

+ Total messages: {interview.interview_data.metadata.message_count} +

+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/app/interviews/create/page.tsx b/src/app/interviews/create/page.tsx new file mode 100644 index 0000000..e12feaa --- /dev/null +++ b/src/app/interviews/create/page.tsx @@ -0,0 +1,400 @@ +"use client"; + +import { useState, useEffect, useRef, FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import CompanySelector from "@/app/components/CompanySelector"; +import ContactSelector from "@/app/components/ContactSelector"; +import EngagementSelector from "@/app/components/EngagementSelector"; +import SupportPersonSelector from "@/app/components/SupportPersonSelector"; +import { Company, Person, SupportEngagement } from "@/app/lib/types"; +import { defaultInterviewQuestions } from "@/app/lib/defaultInterviewQuestions"; + +// Local type for question state with a stable id +interface QuestionState { + id: string; // Stable unique identifier to use as React key + ordinal: number; + text: string; + context: string; +} + +export default function CreateInterviewPage() { + const router = useRouter(); + const [adminNotes, setAdminNotes] = useState(""); + const [isAdminNotesManuallyEdited, setIsAdminNotesManuallyEdited] = useState(false); + const [questions, setQuestions] = useState(() => + defaultInterviewQuestions.length > 0 + ? defaultInterviewQuestions.map((q) => ({ + id: crypto.randomUUID(), + ordinal: q.order, + text: q.text, + context: q.context || "", + })) + : [ + { + id: crypto.randomUUID(), + ordinal: 1, + text: "", + context: "", + }, + ] + ); + + const [selectedCompany, setSelectedCompany] = useState(null); + const [selectedEngagement, setSelectedEngagement] = useState(null); + const [selectedPerson, setSelectedPerson] = useState(null); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Create a ref to store form values without re-rendering + const questionRefs = useRef>( + questions.map(() => ({ textRef: null, contextRef: null })) + ); + + // Generate interview name based on selections + useEffect(() => { + // Skip if user has manually edited the name + if (isAdminNotesManuallyEdited) return; + + // Generate name only when all three are selected + if (selectedCompany && selectedEngagement && selectedPerson) { + const engagementTitle = selectedEngagement.title || "Support Engagement"; + const companyName = selectedCompany.business_name; + const personName = `${selectedPerson.first_name} ${selectedPerson.last_name}`; + + const generatedName = `${engagementTitle} Review - ${companyName} with ${personName}`; + setAdminNotes(generatedName); + } else if (selectedCompany && selectedPerson) { + // Fallback if only company and person are selected + const companyName = selectedCompany.business_name; + const personName = `${selectedPerson.first_name} ${selectedPerson.last_name}`; + + const generatedName = `Interview - ${companyName} with ${personName}`; + setAdminNotes(generatedName); + } + }, [selectedCompany, selectedEngagement, selectedPerson, isAdminNotesManuallyEdited]); + + const handleAdminNotesChange = (e: React.ChangeEvent) => { + setAdminNotes(e.target.value); + setIsAdminNotesManuallyEdited(true); + }; + + const resetInterviewName = () => { + setIsAdminNotesManuallyEdited(false); + // This will trigger the useEffect to regenerate the name + if (selectedCompany && selectedEngagement && selectedPerson) { + const engagementTitle = selectedEngagement.title || "Support Engagement"; + const companyName = selectedCompany.business_name; + const personName = `${selectedPerson.first_name} ${selectedPerson.last_name}`; + + const generatedName = `${engagementTitle} Review - ${companyName} with ${personName}`; + setAdminNotes(generatedName); + } + }; + + const addQuestion = () => { + // Update the questions array + setQuestions([ + ...questions, + { + id: crypto.randomUUID(), + ordinal: questions.length + 1, + text: "", + context: "", + }, + ]); + + // Update the refs array to match + questionRefs.current = [ + ...questionRefs.current, + { textRef: null, contextRef: null } + ]; + }; + + const removeQuestion = (index: number) => { + if (questions.length > 1) { + // Update questions array + const updatedQuestions = questions.filter((_, i) => i !== index); + const reorderedQuestions = updatedQuestions.map((q, i) => ({ + ...q, + ordinal: i + 1, + })); + setQuestions(reorderedQuestions); + + // Update refs array + questionRefs.current = questionRefs.current.filter((_, i) => i !== index); + } + }; + + const handleCompanySelected = (company: Company | null) => { + setSelectedCompany(company); + // Clear downstream selections when company changes + setSelectedEngagement(null); + setSelectedPerson(null); + }; + + const handleEngagementSelected = (engagement: SupportEngagement | null) => { + setSelectedEngagement(engagement); + // Clear person selection when engagement changes + setSelectedPerson(null); + }; + + const handlePersonSelected = (person: Person | null) => { + setSelectedPerson(person); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + setError(null); + + // Validate + if (!adminNotes.trim()) { + setError("Interview name/notes are required"); + setIsSubmitting(false); + return; + } + + // Collect the current values from the refs + const currentQuestions = questions.map((q, index) => { + const refs = questionRefs.current[index]; + return { + ordinal: q.ordinal, + text: refs.textRef?.value || "", + context: refs.contextRef?.value || "" + }; + }); + + // Validate question text + if (currentQuestions.some(q => !q.text.trim())) { + setError("All questions must have text"); + setIsSubmitting(false); + return; + } + + try { + const response = await fetch("/api/interviews/create", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + adminNotes, + questions: currentQuestions, + companyId: selectedCompany?.id, + personId: selectedPerson?.id, + engagementId: selectedEngagement?.id || null + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to create interview"); + } + + router.push("/interviews"); + router.refresh(); + } catch (err: any) { + console.error("Error creating interview:", err); + setError(err.message || "Failed to create interview"); + setIsSubmitting(false); + } + }; + + // Check if all fields are selected for the interview name + const allSelectionsComplete = !!(selectedCompany && selectedPerson); + const allDetailedSelectionsComplete = !!(selectedCompany && selectedEngagement && selectedPerson); + + return ( +
+
+

Create New Interview

+

Set up an interview with questions to collect feedback.

+
+ + {error && ( +
+

{error}

+
+ )} + +
+
+
+ + {allSelectionsComplete && isAdminNotesManuallyEdited && ( + + )} +
+ + {allSelectionsComplete ? ( +
+ + {!isAdminNotesManuallyEdited && ( +
+ Auto-generated +
+ )} +
+ ) : ( +
+

+ Complete company, engagement, and contact selection to auto-generate name +

+
+ )} +
+ +
+

Company & Support Information

+ +
+
+ + +
+ +
+ + +
+ +
+ + {selectedEngagement ? ( + + ) : ( + + )} +
+
+
+ +
+
+

Questions

+ +
+ +
+ {questions.map((question, index) => ( +
+
+
Question {question.ordinal}
+ {questions.length > 1 && ( + + )} +
+ +
+
+ + { + if (questionRefs.current[index]) { + questionRefs.current[index].textRef = el; + } + }} + className="w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + placeholder="e.g., How would you describe your experience with this support engagement?" + /> +
+ +
+ +