├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── components.json ├── docs ├── ROADMAP.md ├── api.md ├── architecture.md ├── database-schema-changes.md └── next-api-patterns.md ├── jest.config.js ├── jest.setup.js ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── file.svg ├── globe.svg ├── next.svg ├── vercel.svg └── window.svg ├── src ├── app │ ├── api │ │ ├── cron │ │ │ └── route.ts │ │ ├── integrations │ │ │ └── vapi │ │ │ │ └── route.ts │ │ └── leads │ │ │ ├── [id] │ │ │ └── route.ts │ │ │ ├── import │ │ │ └── route.ts │ │ │ └── route.ts │ ├── favicon.ico │ ├── fonts │ │ ├── GeistMonoVF.woff │ │ └── GeistVF.woff │ ├── globals.css │ ├── layout.tsx │ ├── login │ │ └── page.tsx │ ├── page.tsx │ ├── providers.tsx │ └── settings │ │ └── page.tsx ├── components │ ├── automation-control.tsx │ ├── client-automation-control.tsx │ ├── client-header.tsx │ ├── client-lead-table.tsx │ ├── dialog.tsx │ ├── layouts │ │ ├── auth-aware-layout.tsx │ │ └── dashboard-layout.tsx │ ├── lead-table │ │ ├── cell-renderer.tsx │ │ ├── constants.ts │ │ ├── csv-preview-dialog.tsx │ │ ├── hooks │ │ │ ├── use-csv-import.ts │ │ │ ├── use-lead-sort.ts │ │ │ └── use-page-size.ts │ │ ├── index.tsx │ │ ├── lead-form-dialog.tsx │ │ ├── pagination.tsx │ │ ├── table-body.tsx │ │ ├── table-header.tsx │ │ └── types.ts │ ├── sidebar.tsx │ ├── theme-provider.tsx │ ├── theme-toggle.tsx │ └── ui │ │ ├── alert-dialog.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── loading-switch.tsx │ │ ├── popover.tsx │ │ ├── radio-group.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── spinner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ └── tooltip.tsx ├── env.d.ts ├── hooks │ ├── use-mobile.tsx │ ├── use-sidebar-state.ts │ └── use-toast.ts ├── lib │ ├── cal.ts │ ├── services │ │ ├── call-logs.ts │ │ ├── email.ts │ │ ├── leads.ts │ │ └── settings.ts │ ├── supabase │ │ ├── client.ts │ │ ├── server.ts │ │ ├── service.ts │ │ └── types.ts │ ├── types.ts │ └── utils.ts └── middleware.ts ├── supabase ├── init.sql └── test_data.csv ├── tailwind.config.ts ├── tsconfig.json ├── vapi ├── .gitignore ├── assistant_config.extracted.json ├── assistant_config.json ├── manage-prompts.js ├── package.json ├── pnpm-lock.yaml ├── prompt-manager.js ├── prompts │ ├── end-call-message.txt │ ├── first-message.txt │ ├── structured-data-prompt.md │ ├── structured-data-schema.json │ ├── success-evaluation.md │ ├── summary-prompt.md │ └── system-prompt.md ├── publish-vapi-config.js └── tools │ ├── book_appointment.json │ └── check_availability.json └── vercel.json /.env.example: -------------------------------------------------------------------------------- 1 | # Cal.com Configuration 2 | CALCOM_API_KEY=cal_xxxx_xxxxxxxxxxxxxxxx # Your Cal.com API key 3 | CALCOM_EVENT_TYPE_ID=123456 # The numeric ID of your event type 4 | CALCOM_EVENT_DURATION=30 # Duration of the event in minutes 5 | CALCOM_USERNAME=your-username # Your Cal.com username 6 | CALCOM_EVENT_SLUG=meeting # The slug of your event type (from cal.com/username/[slug]) 7 | CALCOM_BOOKING_LINK=https://cal.com/${CALCOM_USERNAME}/${CALCOM_EVENT_SLUG} # Your booking link for demos 8 | 9 | # Database Configuration 10 | NEXT_PUBLIC_SUPABASE_URL=your-supabase-url 11 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key # Used for client-side authentication 12 | SUPABASE_SERVICE_ROLE_KEY=your-service-role-key # Used for server-side operations (e.g., cron) 13 | 14 | # VAPI Configuration 15 | VAPI_API_KEY=vapi_xxxx_xxxxxxxxxxxxxxxx 16 | VAPI_ASSISTANT_ID=asst_xxxx_xxxxxxxxxxxxxxxx 17 | VAPI_PHONE_NUMBER_ID=phn_xxxx_xxxxxxxxxxxxxxxx 18 | 19 | # VAPI Integration Webhook 20 | VAPI_SECRET_KEY=your_generated_webhook_secret_here # Secret for authenticating VAPI webhooks to our endpoints 21 | AI_DIALER_URL=https://your-domain.com # Base URL for your AI dialer application 22 | 23 | # Cron Configuration 24 | CRON_SECRET=your-secret-here # Secret for authenticating cron job requests 25 | 26 | # Email Configuration (Resend) 27 | RESEND_API_KEY=re_xxxx_xxxxxxxxxxxxxxxx 28 | RESEND_FROM_EMAIL=team@example.com # The email address to send from 29 | RESEND_FROM_NAME=AI Dialer Team # The name to show in the from field 30 | 31 | # Node Environment 32 | NODE_ENV=development 33 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # env files 33 | .env* 34 | !.env.example 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🤖 AI Dialer ☎️ – Autonomous Voice Agent for Appointment Scheduling 🗓️ 2 | 3 | > ⚠️ **Proof of Concept**: This is a demonstration project showcasing AI-powered voice technology for automated calling. It is not intended for production use. 4 | 5 | A proof-of-concept system that demonstrates autonomous voice agent capabilities for automated outbound calling. Built with VAPI.ai for voice synthesis, this project explores the potential of AI agents in handling automated phone conversations. 6 | 7 | ## Demo 8 | 9 | [![Voice AI dialer agent built with #Windsurf and #Vapi](https://img.youtube.com/vi/Lws32HyvQq4/maxresdefault.jpg)](https://www.youtube.com/watch?v=Lws32HyvQq4) 10 | 11 | ## Credits 12 | 13 | Special thanks to Justin Hughes, founder of Firebringer AI, for providing valuable sales training methodology that has been incorporated into the system's conversational AI prompts. 14 | 15 | This project was developed as a submission for the Voice AI Accelerator's boot camp programme. Special thanks to Terrell Gentry and Lenny Cowans for specifying the functional requirements through their challenging project brief. 16 | 17 | ## Project Overview 18 | 19 | This demonstration system shows how AI can automate outbound calls to schedule appointments. The system manages leads, schedules calls, and handles appointment booking through an autonomous voice agent. It leverages: 20 | 21 | - VAPI.ai for voice synthesis and conversation 22 | - Cal.com for appointment scheduling 23 | - Resend for email communications 24 | - Supabase for database and authentication 25 | 26 | ### Key Features 27 | 28 | - 🤖 AI voice agent with natural conversation capabilities 29 | - 📊 Lead management dashboard with sorting and filtering 30 | - 📅 Automated appointment scheduling 31 | - 📧 Automated email follow-ups 32 | - 📈 Real-time call status tracking 33 | - 📁 Bulk lead import via CSV 34 | - 🎨 Theme support (light/dark mode) 35 | 36 | ## Project Structure 37 | 38 | ``` 39 | /src 40 | ├── app/ # Next.js app router pages 41 | ├── components/ # React components 42 | ├── hooks/ # React hooks 43 | ├── lib/ 44 | │ ├── services/ # Core business logic 45 | │ ├── supabase/ # Database clients and types 46 | │ ├── cal.ts # Cal.com integration 47 | │ ├── types.ts # Shared types 48 | │ └── utils.ts # Shared utilities 49 | └── middleware.ts # Auth middleware 50 | 51 | /docs # Project documentation 52 | /supabase # Database migrations and types 53 | /vapi # Voice assistant configuration 54 | ├── prompts/ # Assistant prompt templates 55 | ├── tools/ # Custom assistant tools 56 | ├── publish-vapi-config.js # Assistant deployment script 57 | └── prompt-manager.js # Prompt management utilities 58 | ``` 59 | 60 | ## Tech Stack 61 | 62 | - **Frontend**: 63 | - Next.js 15.0.3 with App Router 64 | - shadcn/ui component library 65 | - Tailwind CSS with class-variance-authority 66 | 67 | - **Backend**: 68 | - Next.js API Routes 69 | - Supabase Postgres with Row Level Security 70 | - Supabase Auth with Next.js middleware 71 | - Vercel Cron for automation 72 | 73 | - **External Services**: 74 | - VAPI.ai - Voice synthesis and call handling 75 | - Cal.com - Appointment scheduling 76 | - Resend - Email communications 77 | 78 | ## Getting Started 79 | 80 | ### Prerequisites 81 | 82 | - Node.js 18+ 83 | - pnpm (`npm install -g pnpm`) 84 | - Supabase account 85 | - VAPI.ai account 86 | - Cal.com account 87 | - Resend account 88 | 89 | ### Environment Setup 90 | 91 | 1. Copy the example environment file: 92 | ```bash 93 | cp .env.example .env.local 94 | ``` 95 | 96 | 2. Configure the following environment variables: 97 | 98 | ```env 99 | # Cal.com Configuration 100 | CALCOM_API_KEY=cal_xxxx_xxxxxxxxxxxxxxxx # Your Cal.com API key 101 | CALCOM_EVENT_TYPE_ID=123456 # The numeric ID of your event type 102 | CALCOM_EVENT_DURATION=30 # Duration of the event in minutes 103 | CALCOM_USERNAME=your-username # Your Cal.com username 104 | CALCOM_EVENT_SLUG=meeting # The slug of your event type 105 | 106 | # Database Configuration 107 | NEXT_PUBLIC_SUPABASE_URL=your-supabase-url 108 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key # For client-side auth 109 | SUPABASE_SERVICE_ROLE_KEY=your-service-role-key # For server-side operations 110 | 111 | # VAPI Configuration 112 | VAPI_API_KEY=vapi_xxxx_xxxxxxxxxxxxxxxx 113 | VAPI_ASSISTANT_ID=asst_xxxx_xxxxxxxxxxxxxxxx 114 | VAPI_PHONE_NUMBER_ID=phn_xxxx_xxxxxxxxxxxxxxxx 115 | 116 | # VAPI Integration Webhook 117 | VAPI_SECRET_KEY=your_generated_webhook_secret_here # For authenticating webhooks 118 | AI_DIALER_URL=https://your-domain.com # Your app's base URL 119 | 120 | # Cron Configuration 121 | CRON_SECRET=your-secret-here # For authenticating cron jobs 122 | 123 | # Email Configuration (Resend) 124 | RESEND_API_KEY=re_xxxx_xxxxxxxxxxxxxxxx 125 | RESEND_FROM_EMAIL=team@example.com # Sender email address 126 | RESEND_FROM_NAME=AI Dialer Team # Sender name 127 | ``` 128 | 129 | ### Installation 130 | 131 | ```bash 132 | # Install dependencies 133 | pnpm install 134 | 135 | # Run development server 136 | pnpm dev 137 | ``` 138 | 139 | ### Database Setup 140 | 141 | 1. Create a new Supabase project and get your project URL and API keys. 142 | 143 | 2. Initialize the database schema by running the SQL script in `supabase/init.sql`. This will: 144 | - Create all required tables (leads, call_logs, settings) 145 | - Set up indexes and relationships 146 | - Configure row-level security policies 147 | - Enable realtime subscriptions for the leads table 148 | 149 | 3. Verify the setup by: 150 | - Checking that all tables are created in the Supabase dashboard 151 | - Confirming the realtime functionality is working by checking the Network tab in your browser's developer tools for WebSocket connections 152 | 153 | ### Testing the Cron Job 154 | 155 | 1. Start the development server: 156 | ```bash 157 | pnpm dev 158 | ``` 159 | 160 | 2. Add a test lead to your database through Supabase: 161 | ```sql 162 | INSERT INTO leads (company_name, phone, email, status) 163 | VALUES ('Test Company', '+1234567890', 'test@example.com', 'pending'); 164 | ``` 165 | 166 | 3. Trigger the cron job: 167 | ```bash 168 | curl -H "Authorization: Bearer your_cron_secret" http://localhost:3000/api/cron 169 | ``` 170 | 171 | Open [http://localhost:3000](http://localhost:3000) to view the application. 172 | 173 | ## Documentation 174 | 175 | - [Architecture Overview](docs/architecture.md) - Detailed system design and components 176 | - [API Documentation](docs/api.md) - API endpoints and usage 177 | - [Development Roadmap](docs/ROADMAP.md) - Upcoming features and priorities 178 | 179 | ## License 180 | 181 | MIT 182 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /docs/ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Development Roadmap 2 | 3 | This document outlines the upcoming development priorities for the AI Dialer proof-of-concept. 4 | 5 | ## Priority Tasks 6 | 7 | ### 1. Call Status Management 8 | - Implement maximum call duration checker 9 | - Create process to reset stale "calling" states 10 | - Add logging for status changes 11 | - Implement error handling for edge cases 12 | 13 | ### 2. Dashboard Development 14 | - Create dashboard UI for call activity overview 15 | - Implement recent calls summary 16 | - Add key metrics and statistics 17 | - Include data visualization components 18 | 19 | ### 3. Lead Management Enhancement 20 | - Implement lead search functionality 21 | - Add filtering capabilities to lead table 22 | - Make visible columns selectable 23 | 24 | ### 4. Live Call Monitoring 25 | - Implement real-time call monitoring and control capabilities 26 | - Integrate Vapi's listenUrl parameter for call audio monitoring 27 | - Integrate Vapi's controlUrl parameter for call control features 28 | - Add UI elements for monitoring and controlling active calls 29 | - We can use the parameter values we cached in the call_logs table 30 | 31 | ### 5. Booking Information Enhancement 32 | - Implement hover popup for "scheduled" badges 33 | - Integrate Cal.com API for real-time booking information retrieval 34 | - Display comprehensive booking details using stored booking IDs 35 | - Ensure efficient caching and loading of booking data 36 | - We can use the unique booking ID we saved in the lead's record when the booking was confirmed 37 | 38 | ### 6. Cal.com Integration Endpoint 39 | - Create api/integrations/calcom/route.ts webhook endpoint 40 | - Implement real-time webhook handling for booking updates 41 | - Add notification system for booking amendments and cancellations 42 | - Update lead records based on Cal.com webhook events 43 | - Implement proper error handling and logging 44 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API Documentation 2 | 3 | ## Authentication 4 | 5 | All API endpoints require authentication via Supabase Auth except: 6 | - `/api/cron` (requires CRON_SECRET) 7 | - `/api/integrations/vapi` (requires VAPI_SECRET_KEY) 8 | 9 | Unauthorized requests will receive: 10 | ```typescript 11 | { 12 | error: "Unauthorized" 13 | } 14 | ``` 15 | 16 | ## Endpoints Overview 17 | 18 | ### Lead Management 19 | 20 | #### GET `/api/leads` 21 | Retrieves all leads from the system. 22 | 23 | **Response:** 24 | ```typescript 25 | [ 26 | { 27 | id: string; 28 | company_name: string; 29 | contact_name: string; 30 | phone: string; 31 | email: string; 32 | status: 'pending' | 'calling' | 'no_answer' | 'scheduled' | 'not_interested' | 'error'; 33 | call_attempts: number; 34 | timezone: string; 35 | last_called_at: string | null; 36 | cal_booking_uid: string | null; 37 | follow_up_email_sent: boolean; 38 | created_at: string; 39 | updated_at: string; 40 | } 41 | ] 42 | ``` 43 | 44 | #### POST `/api/leads` 45 | Add new leads to the system. Accepts single lead or array of leads. 46 | 47 | **Request Body:** 48 | ```typescript 49 | { 50 | company_name: string; 51 | contact_name: string; 52 | phone: string; 53 | email: string; 54 | timezone?: string; // defaults to 'America/Los_Angeles' 55 | } 56 | ``` 57 | or 58 | ```typescript 59 | Array<{ 60 | company_name: string; 61 | contact_name: string; 62 | phone: string; 63 | email: string; 64 | timezone?: string; 65 | }> 66 | ``` 67 | 68 | **Response:** 69 | ```typescript 70 | { 71 | data: Lead[] | null; 72 | error?: string; 73 | } 74 | ``` 75 | 76 | #### POST `/api/leads/import` 77 | Bulk import leads. 78 | 79 | **Request Body:** 80 | ```typescript 81 | Array<{ 82 | company_name: string; 83 | contact_name: string; 84 | phone: string; 85 | email: string; 86 | timezone?: string; 87 | }> 88 | ``` 89 | 90 | **Response:** 91 | ```typescript 92 | { 93 | data: Lead[] | null; 94 | error?: string; 95 | } 96 | ``` 97 | 98 | #### PUT `/api/leads/[id]` 99 | Update lead details. 100 | 101 | **Request Body:** 102 | ```typescript 103 | { 104 | company_name?: string; 105 | contact_name?: string; 106 | phone?: string; 107 | email?: string; 108 | status?: 'pending' | 'calling' | 'no_answer' | 'scheduled' | 'not_interested' | 'error'; 109 | call_attempts?: number; 110 | timezone?: string; 111 | last_called_at?: string | null; 112 | cal_booking_uid?: string | null; 113 | follow_up_email_sent?: boolean; 114 | } 115 | ``` 116 | 117 | #### DELETE `/api/leads/[id]` 118 | Remove a lead from the system. 119 | 120 | **Response:** 121 | ```typescript 122 | { 123 | message: string; 124 | error?: string; 125 | } 126 | ``` 127 | 128 | ### Automation Control 129 | 130 | #### GET `/api/automation/status` 131 | Get current automation system status. 132 | 133 | **Response:** 134 | ```typescript 135 | { 136 | automation_enabled: boolean; 137 | max_calls_batch: number; 138 | retry_interval: number; 139 | max_attempts: number; 140 | } 141 | ``` 142 | 143 | #### POST `/api/automation/toggle` 144 | Enable or disable the automation system. 145 | 146 | **Request Body:** 147 | ```typescript 148 | { 149 | automation_enabled: boolean; 150 | } 151 | ``` 152 | 153 | ### Cron Job 154 | 155 | #### GET `/api/cron` 156 | Trigger the automation system to process pending leads. 157 | 158 | **Authentication:** 159 | ```http 160 | Authorization: Bearer YOUR_CRON_SECRET 161 | ``` 162 | 163 | **Response:** 164 | ```typescript 165 | { 166 | message: string; 167 | summary?: { 168 | total: number; 169 | successful: number; 170 | failed: number; 171 | }; 172 | details?: Array<{ 173 | lead: Lead; 174 | success: boolean; 175 | callId?: string; 176 | error?: any; 177 | }>; 178 | error?: string; 179 | } 180 | ``` 181 | 182 | ### Integrations 183 | 184 | #### VAPI Integration 185 | 186 | ##### POST `/api/integrations/vapi` 187 | Endpoint for VAPI agent to interact with the system. 188 | 189 | **Authentication:** 190 | ```http 191 | x-vapi-secret: YOUR_VAPI_SECRET_KEY 192 | ``` 193 | 194 | **Request Body:** 195 | ```typescript 196 | { 197 | message: { 198 | type: 'tool-calls' | 'end-of-call-report'; 199 | toolCalls?: Array<{ 200 | id: string; 201 | type: 'function'; 202 | function: { 203 | name: 'check_availability' | 'book_appointment'; 204 | arguments: Record; 205 | }; 206 | }>; 207 | endedReason?: string; 208 | transcript?: string; 209 | summary?: string; 210 | messages?: any[]; 211 | call?: { 212 | id: string; 213 | }; 214 | }; 215 | } 216 | ``` 217 | 218 | For `check_availability` function: 219 | ```typescript 220 | { 221 | timezone: string; 222 | } 223 | ``` 224 | 225 | For `book_appointment` function: 226 | ```typescript 227 | { 228 | name: string; 229 | email: string; 230 | company: string; 231 | phone: string; 232 | timezone: string; 233 | notes?: string; 234 | startTime: string; // ISO string 235 | } 236 | ``` 237 | 238 | **Response:** 239 | ```typescript 240 | { 241 | results?: Array<{ 242 | toolCallId: string; 243 | result: string; 244 | }>; 245 | error?: string; 246 | } 247 | ``` 248 | 249 | ## Error Handling 250 | 251 | API errors follow this format: 252 | ```typescript 253 | { 254 | error: string; 255 | } 256 | ``` 257 | 258 | Common HTTP Status Codes: 259 | - 200: Success 260 | - 201: Created 261 | - 401: Unauthorized 262 | - 400: Bad Request 263 | - 500: Internal Server Error 264 | -------------------------------------------------------------------------------- /docs/database-schema-changes.md: -------------------------------------------------------------------------------- 1 | # Database Schema Changes 2 | 3 | This document outlines the process for making changes to the database schema in the AI Dialer project. 4 | 5 | ## Example: Adding Timezone Support 6 | 7 | Here's a detailed walkthrough of how we added timezone support to the leads table. This serves as a template for future schema changes. 8 | 9 | ### 1. Database Changes 10 | 11 | First, modify the database schema in `supabase/init.sql`: 12 | ```sql 13 | create table leads ( 14 | -- existing columns... 15 | timezone text default 'America/Los_Angeles', 16 | -- other columns... 17 | ); 18 | ``` 19 | 20 | ### 2. TypeScript Types 21 | 22 | Update the TypeScript types in `src/lib/supabase/types.ts`: 23 | ```typescript 24 | export interface Database { 25 | public: { 26 | Tables: { 27 | leads: { 28 | Row: { 29 | // existing fields... 30 | timezone: string 31 | // other fields... 32 | } 33 | // ... 34 | } 35 | } 36 | } 37 | } 38 | ``` 39 | 40 | ### 3. Test Data 41 | 42 | Update the test data in `supabase/test_data.csv` to include the new column: 43 | ```csv 44 | company_name,contact_name,phone,email,timezone 45 | "Company Name","Contact Name","+1-555-0123","email@example.com","America/New_York" 46 | ``` 47 | 48 | ### 4. UI Components 49 | 50 | 1. Add the field to table constants in `src/components/lead-table/constants.ts`: 51 | ```typescript 52 | export const FIELD_MAPPINGS = { 53 | // existing fields... 54 | timezone: "Timezone", 55 | // other fields... 56 | } as const; 57 | ``` 58 | 59 | 2. Update form types in `src/components/lead-table/types.ts`: 60 | ```typescript 61 | export interface LeadFormState { 62 | // existing fields... 63 | timezone?: string; 64 | } 65 | 66 | export interface CSVPreviewData { 67 | // existing fields... 68 | timezone?: string; 69 | } 70 | ``` 71 | 72 | 3. Add the field to form components in `src/components/lead-table/lead-form-dialog.tsx`: 73 | ```typescript 74 | 89 | ``` 90 | 91 | ### 5. System Integration 92 | 93 | 1. Update the system prompt in `vapi/prompts/system-prompt.md`: 94 | ```markdown 95 | # [Call Information] 96 | Current Date & Time: {{lead_datetime}} 97 | Contact Timezone: {{lead_timezone}} 98 | ... 99 | ``` 100 | 101 | 2. Add timezone support to VAPI route in `src/app/api/integrations/vapi/route.ts`: 102 | ```typescript 103 | // Add timezone conversion helpers 104 | function localToUTC(dateStr: string, timezone: string): string { 105 | const date = new Date(dateStr); 106 | const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' })); 107 | const localDate = new Date(date.toLocaleString('en-US', { timeZone: timezone })); 108 | const diff = utcDate.getTime() - localDate.getTime(); 109 | return new Date(date.getTime() + diff).toISOString(); 110 | } 111 | 112 | function utcToLocal(dateStr: string, timezone: string): string { 113 | const date = new Date(dateStr); 114 | return date.toLocaleString('en-US', { timeZone: timezone }); 115 | } 116 | ``` 117 | 118 | 3. Update VAPI tool definitions: 119 | - Add timezone parameter to `vapi/tools/check_availability.json` 120 | - Add timezone parameter to `vapi/tools/book_appointment.json` 121 | 122 | ### 6. Testing 123 | 124 | 1. Test the schema changes by running the Supabase initialization script 125 | 2. Test CSV imports with the new column 126 | 3. Test the UI for creating and editing leads with timezone 127 | 4. Test timezone conversion in calendar bookings 128 | 5. Test that the AI assistant correctly uses the timezone information 129 | 130 | ### Best Practices 131 | 132 | 1. Always provide a default value for new columns to ensure backward compatibility 133 | 2. Update all relevant TypeScript types to maintain type safety 134 | 3. Update test data to include the new field 135 | 4. Consider the impact on existing records and migrations 136 | 5. Test the changes thoroughly, especially date/time handling 137 | 6. Document any new environment variables or configuration changes 138 | 7. Update relevant documentation and API specifications 139 | 140 | ## Notes 141 | 142 | - When dealing with timezones, always store them in IANA timezone format (e.g., 'America/Los_Angeles') 143 | - For cal.com integration, convert times to UTC before sending and from UTC when receiving 144 | - Consider adding database migrations for production deployments 145 | - Always test timezone conversions across different timezone boundaries 146 | -------------------------------------------------------------------------------- /docs/next-api-patterns.md: -------------------------------------------------------------------------------- 1 | # Next.js 15 API Patterns and Best Practices 2 | 3 | ## Dynamic API Handling 4 | 5 | In Next.js 15, several APIs have been made asynchronous to improve performance and reliability. This document outlines the patterns we use to handle these changes. 6 | 7 | ### Async Parameters in Route Handlers 8 | 9 | When working with dynamic route parameters (e.g., `[id]`), you must await the params before accessing their properties: 10 | 11 | ```typescript 12 | // ❌ Wrong - Will trigger warning 13 | export async function PATCH(request: Request, { params }: { params: { id: string } }) { 14 | const { id } = params // Direct access is not allowed 15 | } 16 | 17 | // ✅ Correct - Properly awaited 18 | export async function PATCH(request: Request, { params }: { params: { id: string } }) { 19 | const { id } = await Promise.resolve(params) 20 | } 21 | ``` 22 | 23 | ### Cookie Handling 24 | 25 | Cookies must also be accessed asynchronously: 26 | 27 | ```typescript 28 | // ❌ Wrong - Will trigger warning 29 | const cookieStore = cookies() 30 | 31 | // ✅ Correct - Properly awaited 32 | const cookieStore = await cookies() 33 | ``` 34 | 35 | ### Complete Route Handler Example 36 | 37 | Here's a complete example of a route handler following Next.js 15 best practices: 38 | 39 | ```typescript 40 | import { NextResponse } from 'next/server' 41 | import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs' 42 | import { cookies } from 'next/headers' 43 | import { Database } from '@/lib/supabase/types' 44 | 45 | export async function PATCH(request: Request, { params }: { params: { id: string } }) { 46 | try { 47 | // 1. Handle cookies asynchronously 48 | const cookieStore = await cookies() 49 | const supabase = createRouteHandlerClient({ cookies: () => cookieStore }) 50 | 51 | // 2. Authentication check 52 | const { data: { user } } = await supabase.auth.getUser() 53 | if (!user) { 54 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 55 | } 56 | 57 | // 3. Access params asynchronously 58 | const { id } = await Promise.resolve(params) 59 | 60 | // 4. Process the request 61 | const updates = await request.json() 62 | const { data, error } = await supabase 63 | .from('leads') 64 | .update(updates) 65 | .eq('id', id) 66 | .select() 67 | 68 | // 5. Handle response 69 | if (error) { 70 | return NextResponse.json({ error: error.message }, { status: 500 }) 71 | } 72 | return NextResponse.json(data) 73 | } catch (error) { 74 | return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) 75 | } 76 | } 77 | ``` 78 | 79 | ## Common Patterns 80 | 81 | 1. **Always wrap route handlers in try-catch blocks** for comprehensive error handling 82 | 2. **Await all dynamic APIs** (params, cookies, headers) before using their properties 83 | 3. **Type your Supabase client** using the Database type for better type safety 84 | 4. **Return appropriate HTTP status codes** with error messages 85 | 5. **Use NextResponse.json()** for consistent response formatting 86 | 87 | ## Related Resources 88 | 89 | - [Next.js 15 Dynamic APIs Documentation](https://nextjs.org/docs/messages/sync-dynamic-apis) 90 | - [Next.js Route Handlers Guide](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) 91 | - [Supabase Auth Helpers for Next.js](https://supabase.com/docs/guides/auth/auth-helpers/nextjs) 92 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | moduleNameMapper: { 6 | '^@/(.*)$': '/src/$1', 7 | }, 8 | setupFiles: ['/jest.setup.js'], 9 | }; 10 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | // Mock the process.env values needed for tests 2 | process.env.VAPI_SECRET_KEY = 'test-key'; 3 | process.env.CALCOM_API_KEY = 'test-cal-key'; 4 | process.env.CALCOM_USER_ID = 'test-user-id'; 5 | process.env.CALCOM_EVENT_TYPE_ID = 'test-event-type-id'; 6 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-dialer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "jest", 11 | "test:watch": "jest --watch" 12 | }, 13 | "dependencies": { 14 | "@radix-ui/react-alert-dialog": "^1.1.2", 15 | "@radix-ui/react-checkbox": "^1.1.2", 16 | "@radix-ui/react-dialog": "^1.1.2", 17 | "@radix-ui/react-dropdown-menu": "^2.1.2", 18 | "@radix-ui/react-label": "^2.1.0", 19 | "@radix-ui/react-popover": "^1.1.2", 20 | "@radix-ui/react-radio-group": "^1.2.1", 21 | "@radix-ui/react-select": "^2.1.2", 22 | "@radix-ui/react-separator": "^1.1.0", 23 | "@radix-ui/react-slot": "^1.1.0", 24 | "@radix-ui/react-switch": "^1.1.1", 25 | "@radix-ui/react-tabs": "^1.1.1", 26 | "@radix-ui/react-toast": "^1.2.2", 27 | "@radix-ui/react-tooltip": "^1.1.4", 28 | "@supabase/ssr": "^0.5.2", 29 | "@supabase/supabase-js": "^2.46.1", 30 | "class-variance-authority": "^0.7.0", 31 | "clsx": "^2.1.1", 32 | "lodash": "^4.17.21", 33 | "lucide-react": "^0.460.0", 34 | "next": "15.0.3", 35 | "next-themes": "^0.4.3", 36 | "react": "18.3.1", 37 | "react-dom": "18.3.1", 38 | "resend": "^4.0.1", 39 | "tailwind-merge": "^2.5.4", 40 | "tailwindcss-animate": "^1.0.7", 41 | "zod": "^3.23.8" 42 | }, 43 | "devDependencies": { 44 | "@jest/globals": "^29.7.0", 45 | "@types/jest": "^29.5.14", 46 | "@types/lodash": "^4.17.13", 47 | "@types/node": "^20", 48 | "@types/react": "^18", 49 | "@types/react-dom": "^18", 50 | "eslint": "^8.57.1", 51 | "eslint-config-next": "15.0.3", 52 | "jest": "^29.7.0", 53 | "postcss": "^8", 54 | "tailwindcss": "^3.4.1", 55 | "ts-jest": "^29.2.5", 56 | "typescript": "^5" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/cron/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, NextRequest } from 'next/server' 2 | import { SettingsService, DEFAULT_SETTINGS } from '@/lib/services/settings' 3 | import { LeadsService } from '@/lib/services/leads' 4 | import { CallLogService } from '@/lib/services/call-logs' 5 | import type { Lead } from '@/lib/supabase/types' 6 | import { createServiceClient } from '@/lib/supabase/service' 7 | 8 | if (!process.env.VAPI_API_KEY) throw new Error('VAPI_API_KEY is required') 9 | if (!process.env.VAPI_ASSISTANT_ID) throw new Error('VAPI_ASSISTANT_ID is required') 10 | if (!process.env.VAPI_PHONE_NUMBER_ID) throw new Error('VAPI_PHONE_NUMBER_ID is required') 11 | if (!process.env.CRON_SECRET) throw new Error('CRON_SECRET is required') 12 | 13 | // Create service instances with the service role client 14 | const serviceClient = createServiceClient() 15 | const settingsService = new SettingsService(serviceClient) 16 | const leadsService = new LeadsService(serviceClient) 17 | const callLogService = new CallLogService(serviceClient) 18 | 19 | // Fetch automation settings from Supabase 20 | async function getAutomationSettings() { 21 | console.log('Fetching automation settings...'); 22 | try { 23 | const settings = await settingsService.getAutomationSettings(); 24 | // console.log('Settings retrieved:', settings); 25 | return settings; 26 | } catch (error) { 27 | console.error('Error fetching settings:', error); 28 | return { 29 | automation_enabled: false, 30 | max_calls_batch: DEFAULT_SETTINGS.max_calls_batch, 31 | retry_interval: DEFAULT_SETTINGS.retry_interval, 32 | max_attempts: DEFAULT_SETTINGS.max_attempts 33 | } 34 | } 35 | } 36 | 37 | // Initiate a VAPI call 38 | async function initiateVapiCall(lead: Lead) { 39 | console.log(`Initiating VAPI call for lead:`, lead); 40 | 41 | // Get current time in lead's timezone 42 | const leadDateTime = new Date().toLocaleString('en-US', { 43 | timeZone: lead.timezone || 'America/Los_Angeles', 44 | weekday: 'long', 45 | year: 'numeric', 46 | month: 'long', 47 | day: 'numeric', 48 | hour: 'numeric', 49 | minute: 'numeric', 50 | hour12: true 51 | }); 52 | 53 | const payload = { 54 | phoneNumberId: process.env.VAPI_PHONE_NUMBER_ID, 55 | assistantId: process.env.VAPI_ASSISTANT_ID, 56 | assistantOverrides: { 57 | variableValues: { 58 | lead_company_name: lead.company_name, 59 | lead_contact_name: lead.contact_name, 60 | lead_email: lead.email, 61 | lead_phone_number: lead.phone, 62 | lead_timezone: lead.timezone || 'America/Los_Angeles', 63 | lead_datetime: leadDateTime 64 | } 65 | }, 66 | customer: { 67 | number: lead.phone 68 | } 69 | }; 70 | console.log('VAPI request payload:', payload); 71 | 72 | const response = await fetch('https://api.vapi.ai/call', { 73 | method: 'POST', 74 | headers: { 75 | 'Authorization': `Bearer ${process.env.VAPI_API_KEY}`, 76 | 'Content-Type': 'application/json', 77 | }, 78 | body: JSON.stringify(payload) 79 | }) 80 | 81 | const responseData = await response.text(); 82 | console.log(`VAPI API response (${response.status}):`, responseData); 83 | 84 | if (!response.ok) { 85 | throw new Error(`Failed to initiate VAPI call: ${response.status} ${response.statusText} - ${responseData}`) 86 | } 87 | 88 | return JSON.parse(responseData); 89 | } 90 | 91 | export async function GET(request: NextRequest) { 92 | try { 93 | console.log('Cron job started'); 94 | 95 | // Verify cron authentication 96 | const authHeader = request.headers.get('authorization'); 97 | console.log('Auth header present:', !!authHeader); 98 | if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { 99 | return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) 100 | } 101 | 102 | // Get automation settings 103 | const settings = await getAutomationSettings() 104 | // console.log('Automation settings:', settings); 105 | 106 | if (!settings.automation_enabled) { 107 | console.log('Automation is disabled, exiting'); 108 | return NextResponse.json({ message: 'Automation is disabled' }) 109 | } 110 | 111 | // Use default values if settings properties are undefined 112 | const maxCallsBatch = settings.max_calls_batch ?? DEFAULT_SETTINGS.max_calls_batch; 113 | const retryInterval = settings.retry_interval ?? DEFAULT_SETTINGS.retry_interval; 114 | const maxAttempts = settings.max_attempts ?? DEFAULT_SETTINGS.max_attempts; 115 | 116 | // Fetch leads to process 117 | console.log('Fetching pending leads...'); 118 | const { success, leads, error: fetchError } = await leadsService.fetchPendingLeads(maxCallsBatch, retryInterval, maxAttempts) 119 | 120 | if (!success || !leads) { 121 | console.log('Error fetching leads:', fetchError); 122 | return NextResponse.json({ error: 'Failed to fetch leads' }, { status: 500 }) 123 | } 124 | 125 | console.log(`Found ${leads?.length || 0} leads to process`); 126 | if (leads.length === 0) { 127 | return NextResponse.json({ message: 'No leads to process' }) 128 | } 129 | 130 | // Initiate calls for each lead 131 | console.log('Processing leads...'); 132 | const results = await Promise.all( 133 | leads.map(async (lead) => { 134 | try { 135 | // Start VAPI call 136 | const callResult = await initiateVapiCall(lead) 137 | 138 | // Create call log 139 | const { error: logError } = await callLogService.createCallLog(lead.id, callResult) 140 | if (logError) { 141 | console.error('Error creating call log:', logError) 142 | } 143 | 144 | // Update lead with call attempt 145 | const { success, error: updateError } = await leadsService.updateLeadWithCallAttempt(lead.id, lead.call_attempts) 146 | 147 | if (!success) { 148 | console.log('Error updating lead:', updateError) 149 | return { lead, success: false, error: updateError } 150 | } 151 | 152 | return { lead, success: true, callId: callResult.id } 153 | } catch (error) { 154 | console.log(`Error processing lead ${lead.id}:`, error) 155 | return { lead, success: false, error } 156 | } 157 | }) 158 | ) 159 | 160 | // Prepare summary 161 | const summary = { 162 | total: results.length, 163 | successful: results.filter(r => r.success).length, 164 | failed: results.filter(r => !r.success).length 165 | } 166 | 167 | return NextResponse.json({ 168 | message: 'Calls initiated', 169 | summary, 170 | details: results 171 | }) 172 | } catch (error) { 173 | console.log('Cron job error:', error) 174 | return NextResponse.json( 175 | { error: 'Internal server error' }, 176 | { status: 500 } 177 | ) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/app/api/leads/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import { createRouteHandlerClient } from '@/lib/supabase/server' 3 | 4 | export async function PATCH(request: Request, { params }: { params: { id: string } }) { 5 | try { 6 | const supabase = await createRouteHandlerClient() 7 | 8 | // Get id from params - properly awaited in Next.js 15 9 | const { id } = await Promise.resolve(params) 10 | const updates = await request.json() 11 | 12 | const { data, error } = await supabase 13 | .from('leads') 14 | .update({ 15 | ...updates, 16 | updated_at: new Date().toISOString() 17 | }) 18 | .eq('id', id) 19 | .select() 20 | 21 | if (error) { 22 | return NextResponse.json({ error: error.message }, { status: 500 }) 23 | } 24 | return NextResponse.json(data) 25 | } catch { 26 | return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) 27 | } 28 | } 29 | 30 | export async function DELETE(request: Request, { params }: { params: { id: string } }) { 31 | try { 32 | const supabase = await createRouteHandlerClient() 33 | 34 | // Get id from params - properly awaited in Next.js 15 35 | const { id } = await Promise.resolve(params) 36 | 37 | const { error } = await supabase 38 | .from('leads') 39 | .delete() 40 | .eq('id', id) 41 | 42 | if (error) { 43 | return NextResponse.json({ error: error.message }, { status: 500 }) 44 | } 45 | return NextResponse.json({ message: 'Lead deleted successfully' }) 46 | } catch { 47 | return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/app/api/leads/import/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import { createClient } from '@supabase/supabase-js' 3 | 4 | const supabase = createClient( 5 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 6 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! 7 | ) 8 | 9 | export async function POST(request: Request) { 10 | const leads = await request.json() 11 | const { data, error } = await supabase.from('leads').insert(leads) 12 | if (error) { 13 | return NextResponse.json({ error: error.message }, { status: 500 }) 14 | } 15 | return NextResponse.json(data) 16 | } 17 | -------------------------------------------------------------------------------- /src/app/api/leads/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandlerClient } from '@/lib/supabase/server' 2 | import { NextResponse } from 'next/server' 3 | 4 | export async function GET() { 5 | const supabase = await createRouteHandlerClient() 6 | 7 | const { data, error } = await supabase 8 | .from('leads') 9 | .select('*') 10 | .order('created_at', { ascending: false }); 11 | 12 | if (error) { 13 | return NextResponse.json({ error: error.message }, { status: 500 }) 14 | } 15 | return NextResponse.json(data) 16 | } 17 | 18 | export async function POST(request: Request) { 19 | const supabase = await createRouteHandlerClient() 20 | 21 | const body = await request.json() 22 | const leads = Array.isArray(body) ? body : [body]; 23 | 24 | const { data, error } = await supabase 25 | .from('leads') 26 | .insert(leads) 27 | .select(); 28 | 29 | if (error) { 30 | return NextResponse.json({ error: error.message }, { status: 500 }) 31 | } 32 | return NextResponse.json(data, { status: 201 }) 33 | } 34 | 35 | export async function DELETE(request: Request) { 36 | const supabase = await createRouteHandlerClient() 37 | 38 | const { ids } = await request.json() 39 | 40 | const { error } = await supabase 41 | .from('leads') 42 | .delete() 43 | .in('id', ids); 44 | 45 | if (error) { 46 | return NextResponse.json({ error: error.message }, { status: 500 }) 47 | } 48 | return NextResponse.json({ success: true }, { status: 200 }) 49 | } 50 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/askjohngeorge/ai-dialer/13a0c206a69ddafd0a3a4db06a9c483d20b16cc8/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/askjohngeorge/ai-dialer/13a0c206a69ddafd0a3a4db06a9c483d20b16cc8/src/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /src/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/askjohngeorge/ai-dialer/13a0c206a69ddafd0a3a4db06a9c483d20b16cc8/src/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | @layer base { 10 | :root { 11 | --background: 0 0% 100%; 12 | --foreground: 222.2 84% 4.9%; 13 | --card: 0 0% 100%; 14 | --card-foreground: 222.2 84% 4.9%; 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 222.2 84% 4.9%; 17 | --primary: 222.2 47.4% 11.2%; 18 | --primary-foreground: 210 40% 98%; 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | --muted: 210 40% 96.1%; 22 | --muted-foreground: 215.4 16.3% 46.9%; 23 | --accent: 210 40% 96.1%; 24 | --accent-foreground: 222.2 47.4% 11.2%; 25 | --destructive: 0 84.2% 60.2%; 26 | --destructive-foreground: 210 40% 98%; 27 | --border: 214.3 31.8% 91.4%; 28 | --input: 214.3 31.8% 91.4%; 29 | --ring: 222.2 84% 4.9%; 30 | --radius: 0.5rem; 31 | --chart-1: 12 76% 61%; 32 | --chart-2: 173 58% 39%; 33 | --chart-3: 197 37% 24%; 34 | --chart-4: 43 74% 66%; 35 | --chart-5: 27 87% 67%; 36 | 37 | /* Sidebar specific variables */ 38 | --sidebar-width: 16rem; 39 | --sidebar-width-collapsed: 4rem; 40 | --sidebar-background: 0 0% 100%; 41 | --sidebar-foreground: 222.2 84% 4.9%; 42 | --sidebar-border: 214.3 31.8% 91.4%; 43 | --sidebar-icon: 215.4 16.3% 46.9%; 44 | } 45 | 46 | .dark { 47 | --background: 222.2 84% 4.9%; 48 | --foreground: 210 40% 98%; 49 | --card: 222.2 84% 4.9%; 50 | --card-foreground: 210 40% 98%; 51 | --popover: 222.2 84% 4.9%; 52 | --popover-foreground: 210 40% 98%; 53 | --primary: 210 40% 98%; 54 | --primary-foreground: 222.2 47.4% 11.2%; 55 | --secondary: 217.2 32.6% 17.5%; 56 | --secondary-foreground: 210 40% 98%; 57 | --muted: 217.2 32.6% 17.5%; 58 | --muted-foreground: 215 20.2% 65.1%; 59 | --accent: 217.2 32.6% 17.5%; 60 | --accent-foreground: 210 40% 98%; 61 | --destructive: 0 62.8% 30.6%; 62 | --destructive-foreground: 210 40% 98%; 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | --chart-1: 220 70% 50%; 67 | --chart-2: 160 60% 45%; 68 | --chart-3: 30 80% 55%; 69 | --chart-4: 280 65% 60%; 70 | --chart-5: 340 75% 55%; 71 | --sidebar-background: 222.2 84% 4.9%; 72 | --sidebar-foreground: 210 40% 98%; 73 | --sidebar-border: 217.2 32.6% 17.5%; 74 | --sidebar-icon: 215 20.2% 65.1%; 75 | --sidebar-primary: 240 5.9% 10%; 76 | --sidebar-primary-foreground: 0 0% 100%; 77 | --sidebar-accent: 240 4.8% 95.9%; 78 | --sidebar-accent-foreground: 240 4.8% 95.9%; 79 | --sidebar-ring: 217.2 91.2% 59.8%; 80 | } 81 | } 82 | 83 | @layer base { 84 | * { 85 | @apply border-border; 86 | } 87 | body { 88 | @apply bg-background text-foreground; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import { Inter } from "next/font/google"; 3 | import { Toaster } from "@/components/ui/toaster"; 4 | import { AuthAwareLayout } from "@/components/layouts/auth-aware-layout"; 5 | import { Providers } from './providers' 6 | 7 | const inter = Inter({ subsets: ["latin"] }); 8 | 9 | export default function RootLayout({ 10 | children, 11 | }: { 12 | children: React.ReactNode; 13 | }) { 14 | return ( 15 | 16 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | ); 24 | } -------------------------------------------------------------------------------- /src/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createBrowserClient, CookieOptions } from "@supabase/ssr"; 4 | import { useRouter } from "next/navigation"; 5 | import { useState } from "react"; 6 | import { Button } from "@/components/ui/button"; 7 | import { Input } from "@/components/ui/input"; 8 | import { useToast } from "@/hooks/use-toast"; 9 | 10 | export default function LoginPage() { 11 | const [email, setEmail] = useState(""); 12 | const [password, setPassword] = useState(""); 13 | const [loading, setLoading] = useState(false); 14 | const router = useRouter(); 15 | const { toast } = useToast(); 16 | const supabase = createBrowserClient( 17 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 18 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 19 | { 20 | cookies: { 21 | getAll: () => { 22 | const pairs = document.cookie.split("; ").map(pair => { 23 | const [name, value] = pair.split("="); 24 | return { name, value }; 25 | }); 26 | return pairs; 27 | }, 28 | setAll: (cookiesList: { name: string; value: string; options?: CookieOptions }[]) => { 29 | cookiesList.forEach(({ name, value, options }) => { 30 | document.cookie = `${name}=${value}; path=/; ${options?.sameSite ? `SameSite=${options.sameSite}; ` : ""}${options?.secure ? "Secure; " : ""}${options?.httpOnly ? "HttpOnly; " : ""}` 31 | }); 32 | } 33 | } 34 | } 35 | ); 36 | 37 | const handleLogin = async (e: React.FormEvent) => { 38 | e.preventDefault(); 39 | setLoading(true); 40 | 41 | try { 42 | const { error } = await supabase.auth.signInWithPassword({ 43 | email, 44 | password, 45 | }); 46 | 47 | if (error) { 48 | throw error; 49 | } 50 | 51 | router.push("/"); 52 | router.refresh(); 53 | } catch (error) { 54 | toast({ 55 | title: "Error", 56 | description: error instanceof Error ? error.message : "Failed to login", 57 | variant: "destructive", 58 | }); 59 | } finally { 60 | setLoading(false); 61 | } 62 | }; 63 | 64 | return ( 65 |
66 |
67 |
68 |

Sign in to your account

69 |

70 | Enter your email and password to access AI Dialer 71 |

72 |
73 |
74 |
75 |
76 | 79 | setEmail(e.target.value)} 84 | required 85 | className="mt-1" 86 | placeholder="Enter your email" 87 | /> 88 |
89 |
90 | 93 | setPassword(e.target.value)} 98 | required 99 | className="mt-1" 100 | placeholder="Enter your password" 101 | /> 102 |
103 |
104 | 111 |
112 |
113 |
114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { createRouteHandlerClient } from "@/lib/supabase/server"; 3 | import { SettingsService } from "@/lib/services/settings"; 4 | import { LeadsService } from "@/lib/services/leads"; 5 | import { ClientAutomationControl } from "@/components/client-automation-control"; 6 | import { ClientLeadTable } from "@/components/client-lead-table"; 7 | import { ClientHeader } from "@/components/client-header"; 8 | import { Skeleton } from "@/components/ui/skeleton"; 9 | 10 | // Loading skeletons for each component 11 | function HeaderSkeleton() { 12 | return ( 13 |
14 | {/* For "Lead Management" text */} 15 | {/* For "Sign Out" button */} 16 |
17 | ); 18 | } 19 | 20 | function AutomationControlSkeleton() { 21 | return ( 22 |
23 |
24 | {/* For "Outbound Calling" text */} 25 | {/* For status text */} 26 |
27 | {/* For the switch */} 28 |
29 | ); 30 | } 31 | 32 | function LeadTableSkeleton() { 33 | return ( 34 |
35 |
36 | {/* Table header */} 37 |
38 | {[...Array(5)].map((_, i) => ( 39 |
40 | {/* Table rows */} 41 |
42 | ))} 43 |
44 | ); 45 | } 46 | 47 | async function PageData() { 48 | const supabase = await createRouteHandlerClient(); 49 | const settingsService = new SettingsService(supabase); 50 | const leadsService = new LeadsService(supabase); 51 | 52 | const [leadsResult, settingsResult] = await Promise.all([ 53 | leadsService.getLeads(), 54 | settingsService.getAutomationSettings(), 55 | ]); 56 | 57 | if (leadsResult.error) { 58 | throw new Error(leadsResult.error.message); 59 | } 60 | 61 | return { 62 | leads: leadsResult.data || [], 63 | settings: settingsResult, 64 | }; 65 | } 66 | 67 | export default async function DashboardPage() { 68 | const { leads, settings } = await PageData(); 69 | 70 | return ( 71 |
72 | }> 73 | 74 | 75 | 76 | }> 77 | 78 | 79 | 80 | }> 81 | 82 | 83 |
84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ThemeProvider } from '@/components/theme-provider'; 4 | 5 | export function Providers({ children }: { children: React.ReactNode }) { 6 | return ( 7 | 8 | {children} 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/settings/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useState } from "react" 4 | import { Button } from "@/components/ui/button" 5 | import { Input } from "@/components/ui/input" 6 | import { Label } from "@/components/ui/label" 7 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 8 | import { useToast } from "@/hooks/use-toast" 9 | import { settingsService, type AutomationSettings } from "@/lib/services/settings" 10 | 11 | export default function SettingsPage() { 12 | const [settings, setSettings] = useState(null) 13 | const [loading, setLoading] = useState(false) 14 | const { toast } = useToast() 15 | 16 | useEffect(() => { 17 | loadSettings() 18 | }, []) 19 | 20 | const loadSettings = async () => { 21 | try { 22 | const settings = await settingsService.getAutomationSettings() 23 | setSettings(settings) 24 | } catch { 25 | toast({ 26 | title: "Error", 27 | description: "Failed to load settings", 28 | variant: "destructive", 29 | }) 30 | } 31 | } 32 | 33 | const updateSettings = async () => { 34 | if (!settings) return 35 | 36 | setLoading(true) 37 | const { success, error } = await settingsService.updateAllSettings({ 38 | ...settings, 39 | // Keep automation_enabled unchanged since it's managed elsewhere 40 | automation_enabled: settings.automation_enabled 41 | }) 42 | setLoading(false) 43 | 44 | if (!success) { 45 | toast({ 46 | title: "Error", 47 | description: error || "Failed to update settings", 48 | variant: "destructive", 49 | }) 50 | return 51 | } 52 | 53 | toast({ 54 | title: "Success", 55 | description: "Settings updated successfully", 56 | variant: "success", 57 | }) 58 | } 59 | 60 | const handleBlur = ( 61 | e: React.FocusEvent, 62 | field: keyof AutomationSettings, 63 | min: number = 0 64 | ) => { 65 | if (!settings) return 66 | 67 | const input = e.target 68 | const value = input.value.trim() 69 | 70 | // If empty, revert to the current setting value 71 | if (value === '') { 72 | input.value = settings[field].toString() 73 | return 74 | } 75 | 76 | // Otherwise validate and update if it's a valid number 77 | const numValue = parseInt(value) 78 | if (isNaN(numValue)) { 79 | input.value = settings[field].toString() 80 | return 81 | } 82 | 83 | // Apply minimum value constraint 84 | const finalValue = Math.max(min, numValue) 85 | setSettings({ ...settings, [field]: finalValue }) 86 | input.value = finalValue.toString() 87 | } 88 | 89 | if (!settings) return
Loading...
90 | 91 | return ( 92 |
93 |

Dialer Settings

94 | 95 | 96 | Call Settings 97 | Configure your automated calling system parameters. 98 | 99 | 100 |
101 | 102 | handleBlur(e, 'max_calls_batch', 1)} 108 | /> 109 |
110 | 111 |
112 | 113 | handleBlur(e, 'retry_interval', 0)} 119 | /> 120 |
121 | 122 |
123 | 124 | handleBlur(e, 'max_attempts', 1)} 130 | /> 131 |
132 | 133 | 140 |
141 |
142 |
143 | ) 144 | } 145 | -------------------------------------------------------------------------------- /src/components/automation-control.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useState } from 'react' 4 | import { settingsService } from '@/lib/services/settings' 5 | import { useToast } from '@/hooks/use-toast' 6 | import { Skeleton } from '@/components/ui/skeleton' 7 | import { LoadingSwitch } from '@/components/ui/loading-switch' 8 | 9 | // Define the type for AutomationSettings 10 | type AutomationSettings = { 11 | automation_enabled: boolean; 12 | } 13 | 14 | // Update the component to accept initialSettings and onSettingsUpdate props 15 | export function AutomationControl({ 16 | initialSettings, 17 | onSettingsUpdate 18 | }: { 19 | initialSettings: AutomationSettings | null; 20 | onSettingsUpdate?: (enabled: boolean) => void; 21 | }) { 22 | const [enabled, setEnabled] = useState(initialSettings?.automation_enabled ?? false) 23 | const [isUpdating, setIsUpdating] = useState(false) 24 | const { toast } = useToast() 25 | 26 | // Update local state when initialSettings changes 27 | useEffect(() => { 28 | if (initialSettings !== null) { 29 | setEnabled(initialSettings.automation_enabled) 30 | } 31 | }, [initialSettings]) 32 | 33 | useEffect(() => { 34 | const fetchSettings = async () => { 35 | const settings = await settingsService.getAutomationSettings() 36 | setEnabled(settings.automation_enabled) 37 | } 38 | 39 | if (!initialSettings) { 40 | fetchSettings() 41 | } 42 | }, [initialSettings]) 43 | 44 | const handleToggle = async (newState: boolean) => { 45 | setIsUpdating(true) 46 | 47 | try { 48 | const result = await settingsService.updateAutomationEnabled(newState) 49 | 50 | if (result.success) { 51 | setEnabled(newState) 52 | if (onSettingsUpdate) { 53 | onSettingsUpdate(newState) 54 | } 55 | toast({ 56 | title: newState ? "Outbound Calling Enabled" : "Outbound Calling Disabled", 57 | description: newState 58 | ? "System is now making calls to leads" 59 | : "Outbound calling has been paused", 60 | variant: "success", 61 | }) 62 | } else { 63 | toast({ 64 | title: "Error", 65 | description: `Failed to update settings: ${result.error}`, 66 | variant: "destructive", 67 | }) 68 | } 69 | } catch { 70 | toast({ 71 | title: "Error", 72 | description: "Failed to update settings. Please try again.", 73 | variant: "destructive", 74 | }) 75 | } finally { 76 | setIsUpdating(false) 77 | } 78 | } 79 | 80 | if (!initialSettings) { 81 | return ( 82 |
83 |
84 | 85 | 86 |
87 |
88 | 89 | 90 |
91 |
92 | ) 93 | } 94 | 95 | return ( 96 |
97 |
98 |
Outbound Call System
99 |

100 | {enabled ? 'System is actively making calls' : 'System is paused'} 101 |

102 |
103 |
104 | 111 | 112 | {enabled ? 'Active' : 'Paused'} 113 | 114 |
115 |
116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /src/components/client-automation-control.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCallback, useState, useEffect } from 'react'; 4 | import { AutomationControl } from './automation-control'; 5 | import { settingsService } from '@/lib/services/settings'; 6 | import type { AutomationSettings } from '@/lib/services/settings'; 7 | 8 | export function ClientAutomationControl({ 9 | initialSettings 10 | }: { 11 | initialSettings: AutomationSettings | null 12 | }) { 13 | const [settings, setSettings] = useState(initialSettings); 14 | 15 | // Fetch settings on mount 16 | useEffect(() => { 17 | const loadSettings = async () => { 18 | const newSettings = await settingsService.getAutomationSettings(); 19 | setSettings(newSettings); 20 | }; 21 | loadSettings(); 22 | }, []); 23 | 24 | // Handle settings updates 25 | const handleSettingsUpdate = useCallback(async (enabled: boolean) => { 26 | await settingsService.updateAutomationEnabled(enabled); 27 | const newSettings = await settingsService.getAutomationSettings(); 28 | setSettings(newSettings); 29 | }, []); 30 | 31 | return ( 32 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/client-header.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { useRouter } from "next/navigation"; 5 | import { useToast } from "@/hooks/use-toast"; 6 | import { supabase } from '@/lib/supabase/client'; 7 | 8 | export function ClientHeader() { 9 | const router = useRouter(); 10 | const { toast } = useToast(); 11 | 12 | const handleSignOut = async () => { 13 | const { error } = await supabase.auth.signOut(); 14 | if (error) { 15 | toast({ 16 | title: "Error signing out", 17 | description: error.message, 18 | variant: "destructive", 19 | }); 20 | return; 21 | } 22 | router.push('/login'); 23 | }; 24 | 25 | return ( 26 |
27 |

Lead Management

28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/client-lead-table.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { LeadTable } from './lead-table'; 4 | import type { Lead } from '@/lib/supabase/types'; 5 | 6 | export function ClientLeadTable({ initialLeads }: { initialLeads: Lead[] }) { 7 | return ; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from 'lucide-react' 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | 124 | -------------------------------------------------------------------------------- /src/components/layouts/auth-aware-layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { DashboardLayout } from "./dashboard-layout"; 4 | import { usePathname, useRouter } from "next/navigation"; 5 | import { useEffect, useState } from "react"; 6 | import { createBrowserClient } from "@supabase/ssr"; 7 | import { Spinner } from "@/components/ui/spinner"; 8 | 9 | function LoadingScreen() { 10 | return ( 11 |
12 | 13 |

Loading...

14 |
15 | ); 16 | } 17 | 18 | export function AuthAwareLayout({ children }: { children: React.ReactNode }) { 19 | const pathname = usePathname(); 20 | const router = useRouter(); 21 | const [loading, setLoading] = useState(true); 22 | const supabase = createBrowserClient( 23 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 24 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! 25 | ); 26 | 27 | useEffect(() => { 28 | // Only handle auth state changes for UI updates 29 | const { 30 | data: { subscription }, 31 | } = supabase.auth.onAuthStateChange(async (event, session) => { 32 | if (!session && pathname !== "/login") { 33 | router.push("/login"); 34 | } 35 | // Always update loading state after auth changes 36 | setLoading(false); 37 | }); 38 | 39 | // Initial loading state update 40 | setLoading(false); 41 | 42 | return () => { 43 | subscription.unsubscribe(); 44 | }; 45 | }, [pathname, router, supabase.auth]); 46 | 47 | if (loading) { 48 | return ; 49 | } 50 | 51 | // Login page doesn't need dashboard layout 52 | if (pathname === "/login") { 53 | return children; 54 | } 55 | 56 | // For all other routes, use dashboard layout 57 | // The middleware ensures these routes are authenticated 58 | return {children}; 59 | } 60 | -------------------------------------------------------------------------------- /src/components/layouts/dashboard-layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { useSidebarState } from "@/hooks/use-sidebar-state"; 5 | import { Sidebar } from "@/components/sidebar"; 6 | 7 | export const DashboardLayout = ({ children }: { children: React.ReactNode }) => { 8 | const { isSidebarOpen, toggleSidebar, isLoaded } = useSidebarState(); 9 | 10 | // Don't render until we've loaded the initial state from localStorage 11 | // This prevents a flash of the default state 12 | if (!isLoaded) { 13 | return null; 14 | } 15 | 16 | return ( 17 |
18 | 22 |
28 | {children} 29 |
30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/lead-table/cell-renderer.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from "react"; 2 | import { Badge } from "@/components/ui/badge"; 3 | import { Input } from "@/components/ui/input"; 4 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 5 | import { formatDateTime } from "@/lib/utils"; 6 | import type { Lead } from "@/lib/supabase/types"; 7 | import { STATUS_STYLES, NON_EDITABLE_FIELDS } from "./constants"; 8 | import { EditingCell } from "./types"; 9 | 10 | interface CellRendererProps { 11 | lead: Lead; 12 | field: keyof Lead; 13 | editingCell: EditingCell | null; 14 | onEdit: (id: string, field: keyof Lead, value: string) => void; 15 | onStartEdit: (id: string, field: keyof Lead) => void; 16 | onKeyDown: (e: React.KeyboardEvent, id: string, field: keyof Lead, value: string) => void; 17 | setEditingCell: (editingCell: EditingCell | null) => void; 18 | } 19 | 20 | export function CellRenderer({ 21 | lead, 22 | field, 23 | editingCell, 24 | onEdit, 25 | onStartEdit, 26 | onKeyDown, 27 | setEditingCell, 28 | }: CellRendererProps) { 29 | const [editValue, setEditValue] = useState(""); 30 | const inputRef = useRef(null); 31 | 32 | useEffect(() => { 33 | if (editingCell?.id === lead.id && editingCell?.field === field) { 34 | setEditValue(String(lead[field] || "")); 35 | // Add a small delay to ensure the input is rendered before focusing 36 | setTimeout(() => { 37 | inputRef.current?.focus(); 38 | }, 0); 39 | } 40 | }, [editingCell, lead, field]); 41 | 42 | if (editingCell?.id === lead.id && editingCell?.field === field && !NON_EDITABLE_FIELDS.includes(field)) { 43 | if (field === "status") { 44 | return ( 45 | 75 | ); 76 | } 77 | 78 | return ( 79 | setEditValue(e.target.value)} 83 | onKeyDown={async (e) => { 84 | if (e.key === "Escape") { 85 | e.preventDefault(); 86 | onKeyDown(e, lead.id, field, editValue); 87 | } else if (e.key === "Enter" || e.key === "Tab") { 88 | e.preventDefault(); 89 | // First save the value 90 | await onEdit(lead.id, field, editValue); 91 | // Only trigger navigation on Tab 92 | if (e.key === "Tab") { 93 | onKeyDown(e, lead.id, field, editValue); 94 | } else { 95 | // For Enter, just exit edit mode 96 | setEditingCell(null); 97 | } 98 | } 99 | }} 100 | onBlur={async () => { 101 | // Only save on blur if the value has changed 102 | if (editValue !== String(lead[field] || "")) { 103 | await onEdit(lead.id, field, editValue); 104 | } 105 | setEditingCell(null); 106 | }} 107 | className="h-8" 108 | /> 109 | ); 110 | } 111 | 112 | const value = lead[field]; 113 | 114 | if (field === "status" && typeof value === "string") { 115 | return ( 116 | onStartEdit(lead.id, field)} 119 | > 120 | {value.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} 121 | 122 | ); 123 | } 124 | 125 | if ( 126 | field === "last_called_at" || 127 | field === "created_at" || 128 | field === "updated_at" 129 | ) { 130 | return value ? formatDateTime(value as string) : "Never"; 131 | } 132 | 133 | return ( 134 | !NON_EDITABLE_FIELDS.includes(field) && onStartEdit(lead.id, field)} 137 | > 138 | {value ?? ""} 139 | 140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /src/components/lead-table/constants.ts: -------------------------------------------------------------------------------- 1 | export const FIELD_MAPPINGS = { 2 | company_name: "Company Name", 3 | contact_name: "Contact Name", 4 | phone: "Phone", 5 | email: "Email", 6 | status: "Status", 7 | timezone: "Timezone", 8 | call_attempts: "Call Attempts", 9 | last_called_at: "Last Called At", 10 | } as const; 11 | 12 | export const STATUS_STYLES = { 13 | pending: "bg-blue-100 text-blue-800 hover:bg-blue-200", 14 | calling: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200", 15 | no_answer: "bg-orange-100 text-orange-800 hover:bg-orange-200", 16 | scheduled: "bg-green-100 text-green-800 hover:bg-green-200", 17 | not_interested: "bg-red-100 text-red-800 hover:bg-red-200" 18 | } as const; 19 | 20 | export const NON_EDITABLE_FIELDS = ["last_called_at", "created_at", "updated_at"]; 21 | 22 | // Local Storage Keys 23 | export const STORAGE_KEYS = { 24 | PAGE_SIZE: 'leadTablePageSize', 25 | SORT_STATE: 'leadTableSort', 26 | SIDEBAR_COLLAPSED: 'sidebarCollapsed', 27 | } as const; 28 | 29 | // Default Values 30 | export const DEFAULTS = { 31 | PAGE_SIZE: 10, 32 | PAGE_SIZE_OPTIONS: [10, 20, 50, 100], 33 | } as const; 34 | -------------------------------------------------------------------------------- /src/components/lead-table/csv-preview-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertDialog, 3 | AlertDialogAction, 4 | AlertDialogCancel, 5 | AlertDialogContent, 6 | AlertDialogDescription, 7 | AlertDialogFooter, 8 | AlertDialogHeader, 9 | AlertDialogTitle, 10 | } from "@/components/ui/alert-dialog"; 11 | import { 12 | Table, 13 | TableBody, 14 | TableCell, 15 | TableHead, 16 | TableHeader, 17 | TableRow, 18 | } from "@/components/ui/table"; 19 | import { CSVDialogProps } from "./types"; 20 | 21 | export function CSVPreviewDialog({ 22 | previewData, 23 | onConfirm, 24 | onCancel, 25 | open, 26 | }: CSVDialogProps) { 27 | return ( 28 | !open && onCancel()}> 29 | 30 | 31 | Confirm CSV Import 32 | 33 | Please review the data before importing. The following{" "} 34 | {previewData.length} leads will be added: 35 | 36 | 37 |
38 | 39 | 40 | 41 | Company Name 42 | Contact Name 43 | Phone 44 | Email 45 | Timezone 46 | 47 | 48 | 49 | {previewData.map((row, index) => ( 50 | 51 | {row.company_name} 52 | {row.contact_name} 53 | {row.phone} 54 | {row.email} 55 | {row.timezone || 'America/Los_Angeles'} 56 | 57 | ))} 58 | 59 |
60 |
61 | 62 | Cancel 63 | onConfirm(previewData)}> 64 | Import 65 | 66 | 67 |
68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/components/lead-table/hooks/use-csv-import.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from "react"; 2 | import { useToast } from "@/hooks/use-toast"; 3 | import { leadsService } from "@/lib/services/leads"; 4 | import { CSVPreviewData } from "../types"; 5 | 6 | export function useCSVImport(onLeadsUpdate: () => void) { 7 | const [csvPreviewData, setCSVPreviewData] = useState([]); 8 | const [showCSVPreview, setShowCSVPreview] = useState(false); 9 | const fileInputRef = useRef(null); 10 | const { toast } = useToast(); 11 | 12 | const handleFileUpload = (event: React.ChangeEvent) => { 13 | const file = event.target.files?.[0]; 14 | if (!file) return; 15 | 16 | const reader = new FileReader(); 17 | reader.onload = async (e) => { 18 | try { 19 | const text = e.target?.result as string; 20 | const rows = text 21 | .split(/\r?\n/) 22 | .filter((row) => row.trim().length > 0); 23 | 24 | if (rows.length < 2) { 25 | throw new Error("CSV file must contain headers and at least one row"); 26 | } 27 | 28 | // Process headers 29 | const headers = rows[0] 30 | .split(",") 31 | .map((header) => 32 | header 33 | .trim() 34 | .toLowerCase() 35 | .replace(/['"]/g, "") // Remove quotes 36 | .replace(/\s+/g, "_") // Replace spaces with underscore 37 | ); 38 | 39 | // Find the indices of required columns 40 | const companyNameIndex = headers.findIndex( 41 | (h) => 42 | h.includes("company") || 43 | h.includes("business") 44 | ); 45 | const contactNameIndex = headers.findIndex( 46 | (h) => 47 | h.includes("contact_name") || 48 | h.includes("contact") && h.includes("name") 49 | ); 50 | const phoneIndex = headers.findIndex( 51 | (h) => 52 | h.includes("phone") || h.includes("tel") 53 | ); 54 | const emailIndex = headers.findIndex( 55 | (h) => 56 | h.includes("email") || h.includes("mail") || h.includes("e-mail") 57 | ); 58 | 59 | if ( 60 | companyNameIndex === -1 || 61 | contactNameIndex === -1 || 62 | phoneIndex === -1 || 63 | emailIndex === -1 64 | ) { 65 | throw new Error( 66 | "Could not find required columns (company name, contact name, phone, email)" 67 | ); 68 | } 69 | 70 | // Parse data rows 71 | const parsedData: CSVPreviewData[] = rows 72 | .slice(1) 73 | .map((row) => { 74 | // Handle both quoted and unquoted values 75 | const values = row.match(/(".*?"|[^",\s]+)(?=\s*,|\s*$)/g) || []; 76 | const cleanValues = values.map((val) => 77 | val.replace(/^"|"$/g, "").trim() 78 | ); 79 | 80 | return { 81 | company_name: cleanValues[companyNameIndex] || "", 82 | contact_name: cleanValues[contactNameIndex] || "", 83 | phone: cleanValues[phoneIndex] || "", 84 | email: cleanValues[emailIndex] || "", 85 | }; 86 | }) 87 | .filter((row) => row.company_name && row.contact_name && row.phone && row.email); 88 | 89 | if (parsedData.length === 0) { 90 | throw new Error("No valid data rows found in CSV"); 91 | } 92 | 93 | setCSVPreviewData(parsedData); 94 | setShowCSVPreview(true); 95 | } catch (error) { 96 | toast({ 97 | title: "Error parsing CSV", 98 | description: 99 | error instanceof Error ? error.message : "Invalid CSV format", 100 | variant: "destructive", 101 | }); 102 | } 103 | }; 104 | reader.readAsText(file); 105 | 106 | // Reset the file input 107 | if (event.target) { 108 | event.target.value = ""; 109 | } 110 | }; 111 | 112 | const handleCSVImport = async (data: CSVPreviewData[]) => { 113 | const newLeads = data.map((row) => ({ 114 | ...row, 115 | status: "pending" as const, 116 | call_attempts: 0, 117 | last_called_at: null, 118 | created_at: new Date().toISOString(), 119 | updated_at: new Date().toISOString(), 120 | })); 121 | 122 | try { 123 | const { success, error } = await leadsService.createLeads(newLeads); 124 | 125 | if (!success) { 126 | console.error("Error importing leads:", error); 127 | toast({ 128 | title: "Error", 129 | description: "Failed to import leads. Please try again.", 130 | variant: "destructive", 131 | }); 132 | } else { 133 | toast({ 134 | title: "CSV imported", 135 | description: `${data.length} lead(s) have been imported successfully.`, 136 | variant: "success", 137 | }); 138 | } 139 | 140 | onLeadsUpdate(); 141 | setShowCSVPreview(false); 142 | setCSVPreviewData([]); 143 | } catch (error) { 144 | toast({ 145 | title: "Error importing leads", 146 | description: error instanceof Error ? error.message : "An error occurred", 147 | variant: "destructive", 148 | }); 149 | } 150 | }; 151 | 152 | return { 153 | csvPreviewData, 154 | showCSVPreview, 155 | fileInputRef, 156 | handleFileUpload, 157 | handleCSVImport, 158 | setShowCSVPreview, 159 | }; 160 | } 161 | -------------------------------------------------------------------------------- /src/components/lead-table/hooks/use-lead-sort.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import type { Lead } from "@/lib/supabase/types"; 3 | import { SortState } from "../types"; 4 | 5 | export function useLeadSort(initialSortState?: SortState) { 6 | const [sortState, setSortState] = useState(() => { 7 | if (typeof window === 'undefined') return { column: null, direction: null }; 8 | const savedSort = localStorage.getItem('leadTableSort'); 9 | return savedSort ? JSON.parse(savedSort) : initialSortState || { column: null, direction: null }; 10 | }); 11 | 12 | const handleSort = (column: keyof Lead) => { 13 | setSortState(prev => { 14 | const newState = (() => { 15 | if (prev.column !== column) { 16 | return { column, direction: "asc" as const }; 17 | } 18 | if (prev.direction === "asc") { 19 | return { column, direction: "desc" as const }; 20 | } 21 | if (prev.direction === "desc") { 22 | return { column: null, direction: null }; 23 | } 24 | return { column, direction: "asc" as const }; 25 | })(); 26 | 27 | localStorage.setItem('leadTableSort', JSON.stringify(newState)); 28 | return newState; 29 | }); 30 | }; 31 | 32 | const getSortedLeads = (leadsToSort: Lead[]): Lead[] => { 33 | // Always create a new array to maintain referential integrity 34 | const leads = [...leadsToSort]; 35 | 36 | // Apply default sort by id to maintain consistent order when no explicit sort 37 | if (!sortState.column || !sortState.direction) { 38 | return leads.sort((a, b) => a.id.localeCompare(b.id)); 39 | } 40 | 41 | return leads.sort((a, b) => { 42 | const column = sortState.column!; 43 | const direction = sortState.direction!; 44 | const aValue = a[column]; 45 | const bValue = b[column]; 46 | 47 | // Handle null values 48 | if (aValue === null) return direction === "asc" ? 1 : -1; 49 | if (bValue === null) return direction === "asc" ? -1 : 1; 50 | 51 | // Compare dates 52 | if ( 53 | column === "last_called_at" || 54 | column === "created_at" || 55 | column === "updated_at" 56 | ) { 57 | const aDate = new Date(aValue as string).getTime(); 58 | const bDate = new Date(bValue as string).getTime(); 59 | return direction === "asc" ? aDate - bDate : bDate - aDate; 60 | } 61 | 62 | // Compare numbers 63 | if (typeof aValue === "number" && typeof bValue === "number") { 64 | return direction === "asc" ? aValue - bValue : bValue - aValue; 65 | } 66 | 67 | // Compare strings case-insensitively 68 | if (typeof aValue === "string" && typeof bValue === "string") { 69 | return direction === "asc" 70 | ? aValue.localeCompare(bValue, undefined, { sensitivity: "base" }) 71 | : bValue.localeCompare(aValue, undefined, { sensitivity: "base" }); 72 | } 73 | 74 | return 0; 75 | }); 76 | }; 77 | 78 | return { 79 | sortState, 80 | handleSort, 81 | getSortedLeads, 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /src/components/lead-table/hooks/use-page-size.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { STORAGE_KEYS, DEFAULTS } from '../constants'; 3 | 4 | export function usePageSize() { 5 | const [pageSize, setPageSize] = useState(() => { 6 | if (typeof window === 'undefined') return DEFAULTS.PAGE_SIZE; 7 | const savedPageSize = localStorage.getItem(STORAGE_KEYS.PAGE_SIZE); 8 | return savedPageSize ? parseInt(savedPageSize, 10) : DEFAULTS.PAGE_SIZE; 9 | }); 10 | 11 | const handlePageSizeChange = (newSize: number) => { 12 | setPageSize(newSize); 13 | localStorage.setItem(STORAGE_KEYS.PAGE_SIZE, newSize.toString()); 14 | }; 15 | 16 | return { 17 | pageSize, 18 | setPageSize: handlePageSizeChange 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/lead-table/lead-form-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { 3 | Dialog, 4 | DialogContent, 5 | DialogDescription, 6 | DialogFooter, 7 | DialogHeader, 8 | DialogTitle, 9 | } from "@/components/ui/dialog"; 10 | import { Input } from "@/components/ui/input"; 11 | import { Button } from "@/components/ui/button"; 12 | import { LeadFormState } from "./types"; 13 | import { 14 | Select, 15 | SelectContent, 16 | SelectItem, 17 | SelectTrigger, 18 | SelectValue, 19 | } from "@/components/ui/select"; 20 | 21 | interface LeadFormDialogProps { 22 | open: boolean; 23 | onOpenChange: (open: boolean) => void; 24 | onSubmit: (data: LeadFormState) => void; 25 | initialData?: LeadFormState; 26 | mode?: "add" | "edit"; 27 | } 28 | 29 | export function LeadFormDialog({ 30 | open, 31 | onOpenChange, 32 | onSubmit, 33 | initialData = {}, 34 | mode = "add" 35 | }: LeadFormDialogProps) { 36 | const [formData, setFormData] = useState(initialData); 37 | 38 | const handleSubmit = (e: React.FormEvent) => { 39 | e.preventDefault(); 40 | onSubmit(formData); 41 | onOpenChange(false); 42 | }; 43 | 44 | return ( 45 | 46 | 47 | 48 | {mode === "add" ? "Add New Lead" : "Edit Lead"} 49 | 50 | {mode === "add" 51 | ? "Enter the lead's information below." 52 | : "Edit the lead's information below." 53 | } 54 | 55 | 56 |
57 |
58 | 63 | setFormData({ ...formData, company_name: e.target.value }) 64 | } 65 | required 66 | /> 67 | 72 | setFormData({ ...formData, contact_name: e.target.value }) 73 | } 74 | required 75 | /> 76 |
77 |
78 | 83 | setFormData({ ...formData, phone: e.target.value }) 84 | } 85 | required 86 | /> 87 |
88 |
89 | 95 | setFormData({ ...formData, email: e.target.value }) 96 | } 97 | required 98 | /> 99 |
100 |
101 | 117 |
118 | 119 | 122 | 125 | 126 |
127 |
128 |
129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /src/components/lead-table/pagination.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | Select, 7 | SelectContent, 8 | SelectItem, 9 | SelectTrigger, 10 | SelectValue, 11 | } from "@/components/ui/select"; 12 | import { Input } from "@/components/ui/input"; 13 | import { DEFAULTS } from "./constants"; 14 | 15 | interface PaginationProps { 16 | currentPage: number; 17 | totalPages: number; 18 | pageSize: number; 19 | totalRecords: number; 20 | onPageChange: (page: number) => void; 21 | onPageSizeChange: (size: number) => void; 22 | } 23 | 24 | export function Pagination({ 25 | currentPage, 26 | totalPages, 27 | pageSize, 28 | totalRecords, 29 | onPageChange, 30 | onPageSizeChange, 31 | }: PaginationProps) { 32 | const handlePageInput = (e: React.ChangeEvent) => { 33 | const value = e.target.value; 34 | // Only allow numeric input 35 | if (!/^\d*$/.test(value)) return; 36 | 37 | const pageNum = parseInt(value); 38 | if (pageNum > 0 && pageNum <= totalPages) { 39 | onPageChange(pageNum); 40 | } 41 | }; 42 | 43 | const handleKeyDown = (e: React.KeyboardEvent) => { 44 | // Only allow numeric keys, backspace, delete, and arrow keys 45 | if ( 46 | !/^\d$/.test(e.key) && 47 | !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(e.key) 48 | ) { 49 | e.preventDefault(); 50 | } 51 | }; 52 | 53 | return ( 54 |
55 |
56 |

57 | Rows per page 58 |

59 | 74 |

75 | {`${((currentPage - 1) * pageSize) + 1}-${Math.min(currentPage * pageSize, totalRecords)} of ${totalRecords}`} 76 |

77 |
78 |
79 | 87 | 95 |
96 | 102 | of {totalPages} 103 |
104 | 112 | 120 |
121 |
122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /src/components/lead-table/table-body.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox } from "@/components/ui/checkbox"; 2 | import { 3 | TableBody, 4 | TableCell, 5 | TableRow, 6 | } from "@/components/ui/table"; 7 | import type { Lead } from "@/lib/supabase/types"; 8 | import { FIELD_MAPPINGS } from "./constants"; 9 | import { CellRenderer } from "./cell-renderer"; 10 | import { EditingCell } from "./types"; 11 | 12 | interface TableBodyProps { 13 | leads: Lead[]; 14 | selectedLeads: string[]; 15 | editingCell: EditingCell | null; 16 | onToggleLead: (id: string) => void; 17 | onEdit: (id: string, field: keyof Lead, value: string) => void; 18 | onStartEdit: (id: string | null, field: keyof Lead | null) => void; 19 | onKeyDown: ( 20 | e: React.KeyboardEvent, 21 | id: string, 22 | field: keyof Lead, 23 | value: string 24 | ) => void; 25 | setEditingCell: (editingCell: EditingCell | null) => void; 26 | } 27 | 28 | export function LeadTableBody({ 29 | leads, 30 | selectedLeads, 31 | editingCell, 32 | onToggleLead, 33 | onEdit, 34 | onStartEdit, 35 | onKeyDown, 36 | setEditingCell, 37 | }: TableBodyProps) { 38 | const handleKeyDown = ( 39 | e: React.KeyboardEvent, 40 | id: string, 41 | field: keyof Lead, 42 | value: string 43 | ) => { 44 | if (e.key === "Escape") { 45 | e.preventDefault(); 46 | onStartEdit(null, null); 47 | return; 48 | } 49 | 50 | // Let the parent component handle all other key navigation 51 | onKeyDown(e, id, field, value); 52 | }; 53 | 54 | if (leads.length === 0) { 55 | return ( 56 | 57 | 58 | 59 | No leads available. Add a new lead or import from CSV. 60 | 61 | 62 | 63 | ); 64 | } 65 | 66 | return ( 67 | 68 | {leads.map((lead) => ( 69 | 70 | 71 | onToggleLead(lead.id)} 74 | /> 75 | 76 | {Object.keys(FIELD_MAPPINGS).map((field) => ( 77 | 81 |
82 | handleKeyDown(e, lead.id, field as keyof Lead, String(lead[field as keyof Lead] ?? ''))} 90 | setEditingCell={setEditingCell} 91 | /> 92 |
93 |
94 | ))} 95 |
96 | ))} 97 |
98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /src/components/lead-table/table-header.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox } from "@/components/ui/checkbox"; 2 | import { 3 | TableHead, 4 | TableHeader, 5 | TableRow, 6 | } from "@/components/ui/table"; 7 | import { ArrowUp, ArrowDown } from "lucide-react"; 8 | import type { Lead } from "@/lib/supabase/types"; 9 | import { FIELD_MAPPINGS } from "./constants"; 10 | import { SortState } from "./types"; 11 | 12 | interface TableHeaderProps { 13 | onSelectAll: (checked: boolean) => void; 14 | allSelected: boolean; 15 | sortState: SortState; 16 | onSort: (column: keyof Lead) => void; 17 | hasLeads: boolean; 18 | } 19 | 20 | export function LeadTableHeader({ 21 | onSelectAll, 22 | allSelected, 23 | sortState, 24 | onSort, 25 | hasLeads, 26 | }: TableHeaderProps) { 27 | return ( 28 | 29 | 30 | 31 | 36 | 37 | {Object.entries(FIELD_MAPPINGS).map(([key, label]) => ( 38 | onSort(key as keyof Lead)} 42 | > 43 |
44 | {label} 45 | {sortState.column === key && ( 46 | 47 | {sortState.direction === "asc" ? ( 48 | 49 | ) : ( 50 | 51 | )} 52 | 53 | )} 54 |
55 |
56 | ))} 57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/components/lead-table/types.ts: -------------------------------------------------------------------------------- 1 | import type { Lead } from "@/lib/supabase/types"; 2 | 3 | export interface CSVPreviewData { 4 | company_name: string; 5 | contact_name: string; 6 | phone: string; 7 | email: string; 8 | timezone?: string; 9 | } 10 | 11 | export interface CSVDialogProps { 12 | previewData: CSVPreviewData[]; 13 | onConfirm: (data: CSVPreviewData[]) => void; 14 | onCancel: () => void; 15 | open: boolean; 16 | } 17 | 18 | export interface LeadTableProps { 19 | initialLeads: Lead[]; 20 | } 21 | 22 | export interface SortState { 23 | column: keyof Lead | null; 24 | direction: "asc" | "desc" | null; 25 | } 26 | 27 | export interface EditingCell { 28 | id: string; 29 | field: keyof Lead; 30 | } 31 | 32 | export interface LeadFormState { 33 | company_name?: string; 34 | contact_name?: string; 35 | phone?: string; 36 | email?: string; 37 | timezone?: string; 38 | } 39 | -------------------------------------------------------------------------------- /src/components/sidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Link from 'next/link' 4 | import { usePathname } from 'next/navigation' 5 | import { LayoutDashboard, Settings, ChevronLeft, ChevronRight } from 'lucide-react' 6 | import { cn } from "@/lib/utils" 7 | import { ThemeToggle } from './theme-toggle' 8 | import { Button } from './ui/button' 9 | 10 | interface SidebarProps { 11 | collapsed: boolean 12 | onToggleCollapse: () => void 13 | } 14 | 15 | export function Sidebar({ collapsed, onToggleCollapse }: SidebarProps) { 16 | const pathname = usePathname() 17 | 18 | return ( 19 |
25 |
26 | {!collapsed && ( 27 |

AI Dialer

28 | )} 29 | 44 |
45 | 69 |
70 | 74 |
75 |
76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from "next-themes" 5 | 6 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 7 | const [mounted, setMounted] = React.useState(false) 8 | 9 | React.useEffect(() => { 10 | setMounted(true) 11 | }, []) 12 | 13 | if (!mounted) { 14 | return <>{children} 15 | } 16 | 17 | return ( 18 | 25 | {children} 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Moon, Sun, Monitor, Check } from "lucide-react" 5 | import { useTheme } from "next-themes" 6 | 7 | import { Button } from "@/components/ui/button" 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu" 14 | 15 | interface ThemeToggleProps { 16 | className?: string 17 | collapsed?: boolean 18 | } 19 | 20 | export function ThemeToggle({ className, collapsed = false }: ThemeToggleProps) { 21 | const { theme, setTheme } = useTheme() 22 | const [mounted, setMounted] = React.useState(false) 23 | 24 | React.useEffect(() => { 25 | setMounted(true) 26 | }, []) 27 | 28 | if (!mounted) { 29 | return null 30 | } 31 | 32 | const Icon = theme === 'dark' ? Moon : theme === 'system' ? Monitor : Sun 33 | 34 | return ( 35 | 36 | 37 | {collapsed ? ( 38 | 41 | ) : ( 42 | 46 | )} 47 | 48 | 49 | setTheme("light")}> 50 | 51 | Light 52 | {theme === 'light' && } 53 | 54 | setTheme("dark")}> 55 | 56 | Dark 57 | {theme === 'dark' && } 58 | 59 | setTheme("system")}> 60 | 61 | System 62 | {theme === 'system' && } 63 | 64 | 65 | 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /src/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )) 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ) 60 | AlertDialogHeader.displayName = "AlertDialogHeader" 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ) 74 | AlertDialogFooter.displayName = "AlertDialogFooter" 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )) 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )) 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | } 142 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { Check } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/components/ui/loading-switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitives from "@radix-ui/react-switch" 5 | import { Loader2 } from "lucide-react" 6 | import { cn } from "@/lib/utils" 7 | 8 | interface LoadingSwitchProps extends React.ComponentPropsWithoutRef { 9 | isLoading?: boolean 10 | } 11 | 12 | const LoadingSwitch = React.forwardRef< 13 | React.ElementRef, 14 | LoadingSwitchProps 15 | >(({ className, isLoading, ...props }, ref) => ( 16 | 24 | 30 | {isLoading && ( 31 | 32 | )} 33 | 34 | 35 | )) 36 | LoadingSwitch.displayName = "LoadingSwitch" 37 | 38 | export { LoadingSwitch } 39 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverAnchor = PopoverPrimitive.Anchor 13 | 14 | const PopoverContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 18 | 19 | 29 | 30 | )) 31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 32 | 33 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 34 | -------------------------------------------------------------------------------- /src/components/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" 5 | import { Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const RadioGroup = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => { 13 | return ( 14 | 19 | ) 20 | }) 21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName 22 | 23 | const RadioGroupItem = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => { 27 | return ( 28 | 36 | 37 | 38 | 39 | 40 | ) 41 | }) 42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName 43 | 44 | export { RadioGroup, RadioGroupItem } 45 | -------------------------------------------------------------------------------- /src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { Check, ChevronDown, ChevronUp } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Select = SelectPrimitive.Root 10 | 11 | const SelectGroup = SelectPrimitive.Group 12 | 13 | const SelectValue = SelectPrimitive.Value 14 | 15 | const SelectTrigger = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, children, ...props }, ref) => ( 19 | span]:line-clamp-1", 23 | className 24 | )} 25 | {...props} 26 | > 27 | {children} 28 | 29 | 30 | 31 | 32 | )) 33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 34 | 35 | const SelectScrollUpButton = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | 48 | 49 | )) 50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 51 | 52 | const SelectScrollDownButton = React.forwardRef< 53 | React.ElementRef, 54 | React.ComponentPropsWithoutRef 55 | >(({ className, ...props }, ref) => ( 56 | 64 | 65 | 66 | )) 67 | SelectScrollDownButton.displayName = 68 | SelectPrimitive.ScrollDownButton.displayName 69 | 70 | const SelectContent = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >(({ className, children, position = "popper", ...props }, ref) => ( 74 | 75 | 86 | 87 | 94 | {children} 95 | 96 | 97 | 98 | 99 | )) 100 | SelectContent.displayName = SelectPrimitive.Content.displayName 101 | 102 | const SelectLabel = React.forwardRef< 103 | React.ElementRef, 104 | React.ComponentPropsWithoutRef 105 | >(({ className, ...props }, ref) => ( 106 | 111 | )) 112 | SelectLabel.displayName = SelectPrimitive.Label.displayName 113 | 114 | const SelectItem = React.forwardRef< 115 | React.ElementRef, 116 | React.ComponentPropsWithoutRef 117 | >(({ className, children, ...props }, ref) => ( 118 | 126 | 127 | 128 | 129 | 130 | 131 | {children} 132 | 133 | )) 134 | SelectItem.displayName = SelectPrimitive.Item.displayName 135 | 136 | const SelectSeparator = React.forwardRef< 137 | React.ElementRef, 138 | React.ComponentPropsWithoutRef 139 | >(({ className, ...props }, ref) => ( 140 | 145 | )) 146 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 147 | 148 | export { 149 | Select, 150 | SelectGroup, 151 | SelectValue, 152 | SelectTrigger, 153 | SelectContent, 154 | SelectLabel, 155 | SelectItem, 156 | SelectSeparator, 157 | SelectScrollUpButton, 158 | SelectScrollDownButton, 159 | } 160 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /src/components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SheetPrimitive from "@radix-ui/react-dialog" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | import { X } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const Sheet = SheetPrimitive.Root 11 | 12 | const SheetTrigger = SheetPrimitive.Trigger 13 | 14 | const SheetClose = SheetPrimitive.Close 15 | 16 | const SheetPortal = SheetPrimitive.Portal 17 | 18 | const SheetOverlay = React.forwardRef< 19 | React.ElementRef, 20 | React.ComponentPropsWithoutRef 21 | >(({ className, ...props }, ref) => ( 22 | 30 | )) 31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName 32 | 33 | const sheetVariants = cva( 34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", 35 | { 36 | variants: { 37 | side: { 38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 39 | bottom: 40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", 42 | right: 43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 44 | }, 45 | }, 46 | defaultVariants: { 47 | side: "right", 48 | }, 49 | } 50 | ) 51 | 52 | interface SheetContentProps 53 | extends React.ComponentPropsWithoutRef, 54 | VariantProps {} 55 | 56 | const SheetContent = React.forwardRef< 57 | React.ElementRef, 58 | SheetContentProps 59 | >(({ side = "right", className, children, ...props }, ref) => ( 60 | 61 | 62 | 67 | 68 | 69 | Close 70 | 71 | {children} 72 | 73 | 74 | )) 75 | SheetContent.displayName = SheetPrimitive.Content.displayName 76 | 77 | const SheetHeader = ({ 78 | className, 79 | ...props 80 | }: React.HTMLAttributes) => ( 81 |
88 | ) 89 | SheetHeader.displayName = "SheetHeader" 90 | 91 | const SheetFooter = ({ 92 | className, 93 | ...props 94 | }: React.HTMLAttributes) => ( 95 |
102 | ) 103 | SheetFooter.displayName = "SheetFooter" 104 | 105 | const SheetTitle = React.forwardRef< 106 | React.ElementRef, 107 | React.ComponentPropsWithoutRef 108 | >(({ className, ...props }, ref) => ( 109 | 114 | )) 115 | SheetTitle.displayName = SheetPrimitive.Title.displayName 116 | 117 | const SheetDescription = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, ...props }, ref) => ( 121 | 126 | )) 127 | SheetDescription.displayName = SheetPrimitive.Description.displayName 128 | 129 | export { 130 | Sheet, 131 | SheetPortal, 132 | SheetOverlay, 133 | SheetTrigger, 134 | SheetClose, 135 | SheetContent, 136 | SheetHeader, 137 | SheetFooter, 138 | SheetTitle, 139 | SheetDescription, 140 | } 141 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /src/components/ui/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | export function Spinner({ className }: { className?: string }) { 4 | return ( 5 | 17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitives from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )) 27 | Switch.displayName = SwitchPrimitives.Root.displayName 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:last:border-b-0", 47 | className 48 | )} 49 | {...props} 50 | /> 51 | )) 52 | TableFooter.displayName = "TableFooter" 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )) 67 | TableRow.displayName = "TableRow" 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
[role=checkbox]]:translate-y-[2px]", 77 | className 78 | )} 79 | {...props} 80 | /> 81 | )) 82 | TableHead.displayName = "TableHead" 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | [role=checkbox]]:translate-y-[2px]", 92 | className 93 | )} 94 | {...props} 95 | /> 96 | )) 97 | TableCell.displayName = "TableCell" 98 | 99 | const TableCaption = React.forwardRef< 100 | HTMLTableCaptionElement, 101 | React.HTMLAttributes 102 | >(({ className, ...props }, ref) => ( 103 |
108 | )) 109 | TableCaption.displayName = "TableCaption" 110 | 111 | export { 112 | Table, 113 | TableHeader, 114 | TableBody, 115 | TableFooter, 116 | TableHead, 117 | TableRow, 118 | TableCell, 119 | TableCaption, 120 | } 121 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Textarea = React.forwardRef< 6 | HTMLTextAreaElement, 7 | React.ComponentProps<"textarea"> 8 | >(({ className, ...props }, ref) => { 9 | return ( 10 |