From b538d84e17bbda617f2a5bd4feeb2224abfc07d3 Mon Sep 17 00:00:00 2001 From: Haitham Khalifa <55396005+HaithamEKhalifa@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:18:06 +0100 Subject: [PATCH] Initial commit --- ) | 0 .gitattributes | 2 + .gitignore | 41 + README.md | 36 + app/api/agent-settings/route.ts | 29 + app/api/appointments/route.ts | 14 + app/api/auth/login/route.ts | 46 + app/api/auth/me/route.ts | 13 + app/api/auth/register/route.ts | 48 + app/api/chat/route.ts | 38 + app/api/credits/limits/route.ts | 29 + app/api/credits/route.ts | 53 + app/api/cron/scheduled-calls/route.ts | 36 + app/api/notifications/route.ts | 32 + app/api/scheduled-calls/[id]/route.ts | 75 + app/api/scheduled-calls/route.ts | 73 + app/api/stripe/checkout/route.ts | 64 + app/api/stripe/webhook/route.ts | 76 + app/api/vapi/create-call/route.ts | 95 + app/api/vapi/webhook/route.ts | 63 + app/auth/page.tsx | 134 + app/chat/page.tsx | 177 + app/chat/route.ts.txt | 66 + app/context/AuthContext.tsx | 39 + app/dashboard/agent-settings/page.tsx | 257 + app/dashboard/chat/page.tsx | 477 ++ app/dashboard/credits/page.tsx | 440 ++ app/dashboard/layout.tsx | 306 + app/dashboard/notifications/page.tsx | 244 + app/dashboard/page.tsx | 979 +++ app/dashboard/scheduled-calls/page.tsx | 423 ++ app/dashboard/settings/page.tsx | 369 + app/favicon.ico | Bin 0 -> 25931 bytes app/globals.css | 11 + app/layout.tsx | 40 + app/login/page.tsx | 77 + app/page.tsx | 5 + app/settings/page.tsx | 210 + app/toaster.tsx | 18 + contexts/AuthContext.tsx | 103 + contexts/AuthContext.tsx.txt | 103 + dir | 0 eslint.config.mjs | 18 + hooks/useVapi.ts | 102 + lib/firebase.ts | 18 + lib/firebase.ts.txt | 12 + lib/firestore.ts | 287 + lib/types.ts | 132 + lib/vapi.ts | 11 + next.config.ts | 8 + notepad | 0 package-lock.json | 8950 ++++++++++++++++++++++++ package.json | 42 + postcss.config.mjs | 7 + public/file.svg | 1 + public/globe.svg | 1 + public/next.svg | 1 + public/vercel.svg | 1 + public/window.svg | 1 + tailwind.config.ts | 24 + tsconfig.json | 35 + type | 0 types/index.ts.txt | 67 + 63 files changed, 15059 insertions(+) create mode 100644 ) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/api/agent-settings/route.ts create mode 100644 app/api/appointments/route.ts create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/me/route.ts create mode 100644 app/api/auth/register/route.ts create mode 100644 app/api/chat/route.ts create mode 100644 app/api/credits/limits/route.ts create mode 100644 app/api/credits/route.ts create mode 100644 app/api/cron/scheduled-calls/route.ts create mode 100644 app/api/notifications/route.ts create mode 100644 app/api/scheduled-calls/[id]/route.ts create mode 100644 app/api/scheduled-calls/route.ts create mode 100644 app/api/stripe/checkout/route.ts create mode 100644 app/api/stripe/webhook/route.ts create mode 100644 app/api/vapi/create-call/route.ts create mode 100644 app/api/vapi/webhook/route.ts create mode 100644 app/auth/page.tsx create mode 100644 app/chat/page.tsx create mode 100644 app/chat/route.ts.txt create mode 100644 app/context/AuthContext.tsx create mode 100644 app/dashboard/agent-settings/page.tsx create mode 100644 app/dashboard/chat/page.tsx create mode 100644 app/dashboard/credits/page.tsx create mode 100644 app/dashboard/layout.tsx create mode 100644 app/dashboard/notifications/page.tsx create mode 100644 app/dashboard/page.tsx create mode 100644 app/dashboard/scheduled-calls/page.tsx create mode 100644 app/dashboard/settings/page.tsx create mode 100644 app/favicon.ico create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/login/page.tsx create mode 100644 app/page.tsx create mode 100644 app/settings/page.tsx create mode 100644 app/toaster.tsx create mode 100644 contexts/AuthContext.tsx create mode 100644 contexts/AuthContext.tsx.txt create mode 100644 dir create mode 100644 eslint.config.mjs create mode 100644 hooks/useVapi.ts create mode 100644 lib/firebase.ts create mode 100644 lib/firebase.ts.txt create mode 100644 lib/firestore.ts create mode 100644 lib/types.ts create mode 100644 lib/vapi.ts create mode 100644 next.config.ts create mode 100644 notepad create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/next.svg create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 type create mode 100644 types/index.ts.txt diff --git a/) b/) new file mode 100644 index 0000000..e69de29 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/app/api/agent-settings/route.ts b/app/api/agent-settings/route.ts new file mode 100644 index 0000000..9c2c060 --- /dev/null +++ b/app/api/agent-settings/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAgentSettings, updateAgentSettings } from '@/lib/firestore'; + +export async function GET(request: NextRequest) { + try { + // TODO: Get userId from auth token + const userId = 'test-user-id'; // Replace with actual auth + const settings = await getAgentSettings(userId); + return NextResponse.json({ settings }); + } catch (error) { + console.error('Error fetching agent settings:', error); + return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + // TODO: Get userId from auth token + const userId = 'test-user-id'; // Replace with actual auth + + await updateAgentSettings(userId, body); + const settings = await getAgentSettings(userId); + return NextResponse.json({ settings }); + } catch (error) { + console.error('Error updating agent settings:', error); + return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 }); + } +} diff --git a/app/api/appointments/route.ts b/app/api/appointments/route.ts new file mode 100644 index 0000000..86b2533 --- /dev/null +++ b/app/api/appointments/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getBookedAppointments } from '@/lib/firestore'; + +export async function GET(request: NextRequest) { + try { + // TODO: Get userId from auth token + const userId = 'test-user-id'; // Replace with actual auth + const appointments = await getBookedAppointments(userId); + return NextResponse.json({ appointments }); + } catch (error) { + console.error('Error fetching appointments:', error); + return NextResponse.json({ error: 'Failed to fetch appointments' }, { status: 500 }); + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..0685cd3 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuth, signInWithEmailAndPassword } from 'firebase/auth'; +import { app } from '@/lib/firebase'; + +export async function POST(request: NextRequest) { + try { + const { email, password } = await request.json(); + + if (!email || !password) { + return NextResponse.json( + { error: 'Missing email or password' }, + { status: 400 } + ); + } + + const auth = getAuth(app); + + const userCredential = await signInWithEmailAndPassword(auth, email, password); + const user = userCredential.user; + + return NextResponse.json( + { + user: { + uid: user.uid, + email: user.email, + name: user.displayName, + }, + }, + { status: 200 } + ); + } catch (error: any) { + console.error('Login error:', error); + + let message = 'Login failed'; + + if (error?.code === 'auth/user-not-found') { + message = 'No user found with that email'; + } else if (error?.code === 'auth/wrong-password') { + message = 'Incorrect password'; + } else if (error?.code === 'auth/invalid-credential') { + message = 'Invalid credentials'; + } + + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..eaf233f --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,13 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(_request: NextRequest) { + // MVP placeholder: always "not authenticated" + // Later we will read a token (cookie/header) and return the real user. + return NextResponse.json( + { + user: null, + isAuthenticated: false, + }, + { status: 200 } + ); +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..9e409c6 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuth, createUserWithEmailAndPassword, updateProfile } from 'firebase/auth'; +import { app } from '@/lib/firebase'; + +export async function POST(request: NextRequest) { + try { + const { email, password, name } = await request.json(); + + if (!email || !password || !name) { + return NextResponse.json( + { error: 'Missing email, password or name' }, + { status: 400 } + ); + } + + const auth = getAuth(app); + + const userCredential = await createUserWithEmailAndPassword(auth, email, password); + + if (auth.currentUser) { + await updateProfile(auth.currentUser, { displayName: name }); + } + + const user = userCredential.user; + + // Firebase client SDK already keeps the session via cookies/local storage on client. + // For MVP, we just return basic user info. + return NextResponse.json( + { + user: { + uid: user.uid, + email: user.email, + name: user.displayName, + }, + }, + { status: 201 } + ); + } catch (error: any) { + console.error('Register error:', error); + + const message = + error?.code === 'auth/email-already-in-use' + ? 'Email already in use' + : 'Registration failed'; + + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..2141e1d --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { GoogleGenerativeAI } from '@google/generative-ai'; + +export async function POST(req: NextRequest) { + try { + const { messages } = await req.json(); + + if (!process.env.GEMINI_API_KEY) { + return NextResponse.json({ error: 'Gemini API key not configured' }, { status: 500 }); + } + + const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash' }); + + const history = messages.slice(0, -1).map((msg: any) => ({ + role: msg.role === 'assistant' ? 'model' : 'user', + parts: [{ text: msg.content }], + })); + + const userMessage = messages[messages.length - 1].content; + + const chat = model.startChat({ + history: history, + generationConfig: { + temperature: 0.7, + maxOutputTokens: 1000, + }, + }); + + const result = await chat.sendMessage(userMessage); + const text = result.response.text(); + + return NextResponse.json({ text }); + } catch (error: any) { + console.error('Error in chat API:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/app/api/credits/limits/route.ts b/app/api/credits/limits/route.ts new file mode 100644 index 0000000..d4ce3af --- /dev/null +++ b/app/api/credits/limits/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDefaultLimits, updateDefaultLimits } from '@/lib/firestore'; + +export async function GET(request: NextRequest) { + try { + // TODO: Get userId from auth token + const userId = 'test-user-id'; // Replace with actual auth + const limits = await getDefaultLimits(userId); + return NextResponse.json({ limits }); + } catch (error) { + console.error('Error fetching credit limits:', error); + return NextResponse.json({ error: 'Failed to fetch limits' }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + // TODO: Get userId from auth token + const userId = 'test-user-id'; // Replace with actual auth + + await updateDefaultLimits(userId, body); + const limits = await getDefaultLimits(userId); + return NextResponse.json({ limits }); + } catch (error) { + console.error('Error updating credit limits:', error); + return NextResponse.json({ error: 'Failed to update limits' }, { status: 500 }); + } +} diff --git a/app/api/credits/route.ts b/app/api/credits/route.ts new file mode 100644 index 0000000..829f42c --- /dev/null +++ b/app/api/credits/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + createCreditTransaction, + getUser, + updateUserCredits, +} from '@/lib/firestore'; +import { Timestamp } from 'firebase/firestore'; + +export async function GET(request: NextRequest) { + try { + // TODO: Get userId from auth token + const userId = 'test-user-id'; // Replace with actual auth + const user = await getUser(userId); + return NextResponse.json({ balance: user?.creditBalance ?? 0 }); + } catch (error) { + console.error('Error fetching credit balance:', error); + return NextResponse.json({ error: 'Failed to fetch balance' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + // TODO: Get userId from auth token + const userId = 'test-user-id'; // Replace with actual auth + const amount = Number(body?.amount); + + if (!Number.isFinite(amount) || amount <= 0) { + return NextResponse.json({ error: 'Invalid amount' }, { status: 400 }); + } + + const user = await getUser(userId); + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + const newBalance = user.creditBalance + amount; + await updateUserCredits(userId, newBalance); + + await createCreditTransaction({ + userId, + amount, + type: 'purchase', + balanceAfter: newBalance, + createdAt: Timestamp.now(), + }); + + return NextResponse.json({ balance: newBalance }); + } catch (error) { + console.error('Error adding credits:', error); + return NextResponse.json({ error: 'Failed to add credits' }, { status: 500 }); + } +} diff --git a/app/api/cron/scheduled-calls/route.ts b/app/api/cron/scheduled-calls/route.ts new file mode 100644 index 0000000..95603a5 --- /dev/null +++ b/app/api/cron/scheduled-calls/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; +import { collection, getDocs, getFirestore, query, where, Timestamp } from 'firebase/firestore'; +import { app } from '@/lib/firebase'; + +export async function GET(request: Request) { + // TODO: Secure this route using a secret header for Vercel Cron. + const db = getFirestore(app); + const now = Timestamp.now(); + + // TODO: Add index for scheduledAt + status. + const tasksQuery = query( + collection(db, 'callTasks'), + where('status', '==', 'pending'), + where('scheduledAt', '<=', now) + ); + + const snapshot = await getDocs(tasksQuery); + const origin = request.headers.get('origin') ?? 'http://localhost:3000'; + + for (const docSnap of snapshot.docs) { + const task = docSnap.data() as any; + + // Trigger the call (TODO: include auth) + await fetch(`${origin}/api/vapi/create-call`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phoneNumber: task.phoneNumber, + userId: task.userId, + scheduledTaskId: docSnap.id, + }), + }); + } + + return NextResponse.json({ processed: snapshot.size }); +} diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 0000000..0518c74 --- /dev/null +++ b/app/api/notifications/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + getNotificationSettings, + updateNotificationSettings, +} from '@/lib/firestore'; + +export async function GET(request: NextRequest) { + try { + // TODO: Get userId from auth token + const userId = 'test-user-id'; // Replace with actual auth + const settings = await getNotificationSettings(userId); + return NextResponse.json({ settings }); + } catch (error) { + console.error('Error fetching notification settings:', error); + return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + // TODO: Get userId from auth token + const userId = 'test-user-id'; // Replace with actual auth + + await updateNotificationSettings(userId, body); + const settings = await getNotificationSettings(userId); + return NextResponse.json({ settings }); + } catch (error) { + console.error('Error updating notification settings:', error); + return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 }); + } +} diff --git a/app/api/scheduled-calls/[id]/route.ts b/app/api/scheduled-calls/[id]/route.ts new file mode 100644 index 0000000..3b1fed5 --- /dev/null +++ b/app/api/scheduled-calls/[id]/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + deleteCallTask, + getCallTask, + updateCallTask, +} from '@/lib/firestore'; +import { Timestamp } from 'firebase/firestore'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + // TODO: Get userId from auth token + const userId = 'test-user-id'; // Replace with actual auth + const task = await getCallTask(params.id); + + if (!task || task.userId !== userId) { + return NextResponse.json({ error: 'Task not found' }, { status: 404 }); + } + + return NextResponse.json({ task }); + } catch (error) { + console.error('Error fetching call task:', error); + return NextResponse.json({ error: 'Failed to fetch task' }, { status: 500 }); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const body = await request.json(); + // TODO: Get userId from auth token + const userId = 'test-user-id'; // Replace with actual auth + + const existing = await getCallTask(params.id); + if (!existing || existing.userId !== userId) { + return NextResponse.json({ error: 'Task not found' }, { status: 404 }); + } + + const updates: Record = { ...body }; + if (updates.scheduledAt) { + updates.scheduledAt = Timestamp.fromDate(new Date(updates.scheduledAt as string)); + } + + await updateCallTask(params.id, updates); + const task = await getCallTask(params.id); + return NextResponse.json({ task }); + } catch (error) { + console.error('Error updating call task:', error); + return NextResponse.json({ error: 'Failed to update task' }, { status: 500 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + // TODO: Get userId from auth token + const userId = 'test-user-id'; // Replace with actual auth + const existing = await getCallTask(params.id); + if (!existing || existing.userId !== userId) { + return NextResponse.json({ error: 'Task not found' }, { status: 404 }); + } + + await deleteCallTask(params.id); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting call task:', error); + return NextResponse.json({ error: 'Failed to delete task' }, { status: 500 }); + } +} diff --git a/app/api/scheduled-calls/route.ts b/app/api/scheduled-calls/route.ts new file mode 100644 index 0000000..b687dc7 --- /dev/null +++ b/app/api/scheduled-calls/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCallTasks, createCallTask, getUser } from '@/lib/firestore'; +import { Timestamp } from 'firebase/firestore'; + +export async function GET(request: NextRequest) { + try { + // TODO: Get userId from auth token + const userId = 'test-user-id'; // Replace with actual auth + const tasks = await getCallTasks(userId); + return NextResponse.json({ tasks }); + } catch (error) { + console.error('Error fetching call tasks:', error); + return NextResponse.json({ error: 'Failed to fetch tasks' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const userId = 'test-user-id'; // TODO: Get from auth token + + const { + businessName, + phoneNumber, + preferredLanguage, + callGoals, + scheduledAt, + timezone, + maxMinutes, + allowRecalls, + maxRecalls, + } = body; + + if (!businessName || !phoneNumber || !callGoals || !scheduledAt || !maxMinutes) { + return NextResponse.json( + { error: 'Missing required fields' }, + { status: 400 } + ); + } + + // Validate user has enough credits + const user = await getUser(userId); + if (!user || user.creditBalance < maxMinutes) { + return NextResponse.json( + { error: 'Insufficient credits' }, + { status: 400 } + ); + } + + const taskId = await createCallTask({ + userId, + businessName, + phoneNumber, + preferredLanguage: preferredLanguage || 'English', + callGoals, + scheduledAt: Timestamp.fromDate(new Date(scheduledAt)), + timezone: timezone || undefined, + maxMinutes, + maxCredits: maxMinutes, // 1:1 for v1 + allowRecalls: allowRecalls || false, + maxRecalls: maxRecalls || 0, + recallsUsed: 0, + status: 'pending', + createdAt: Timestamp.now(), + updatedAt: Timestamp.now(), + }); + + return NextResponse.json({ taskId }); + } catch (error) { + console.error('Error creating call task:', error); + return NextResponse.json({ error: 'Failed to create task' }, { status: 500 }); + } +} diff --git a/app/api/stripe/checkout/route.ts b/app/api/stripe/checkout/route.ts new file mode 100644 index 0000000..5d3e025 --- /dev/null +++ b/app/api/stripe/checkout/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server'; +import Stripe from 'stripe'; + +const stripeSecretKey = process.env.STRIPE_SECRET_KEY; + +const stripe = stripeSecretKey + ? new Stripe(stripeSecretKey, { apiVersion: '2024-06-20' }) + : null; + +export async function POST(request: Request) { + try { + if (!stripe) { + return NextResponse.json({ error: 'Stripe is not configured.' }, { status: 500 }); + } + + const body = await request.json(); + const credits = Number(body?.credits); + const amount = Number(body?.amount); + const userId = body?.userId as string | undefined; + + // TODO: Get userId from auth token + const resolvedUserId = userId ?? 'test-user-id'; + + if (!resolvedUserId || !Number.isFinite(credits) || credits <= 0) { + return NextResponse.json({ error: 'Invalid checkout payload.' }, { status: 400 }); + } + + if (!Number.isFinite(amount) || amount <= 0) { + return NextResponse.json({ error: 'Invalid amount.' }, { status: 400 }); + } + + const origin = request.headers.get('origin') ?? 'http://localhost:3000'; + + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + payment_method_types: ['card'], + line_items: [ + { + quantity: 1, + price_data: { + currency: 'eur', + unit_amount: amount * 100, + product_data: { + name: `${credits} HolaCompi Credits`, + description: 'Pay-as-you-go voice call credits', + }, + }, + }, + ], + success_url: `${origin}/dashboard/credits?success=true`, + cancel_url: `${origin}/dashboard/credits?canceled=true`, + metadata: { + userId: resolvedUserId, + credits: credits.toString(), + amount: amount.toString(), + }, + }); + + return NextResponse.json({ sessionId: session.id, url: session.url }); + } catch (error) { + console.error('Stripe checkout error:', error); + return NextResponse.json({ error: 'Failed to create checkout session.' }, { status: 500 }); + } +} diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..46117c8 --- /dev/null +++ b/app/api/stripe/webhook/route.ts @@ -0,0 +1,76 @@ +import Stripe from 'stripe'; +import { NextResponse } from 'next/server'; +import { Timestamp } from 'firebase/firestore'; +import { + createCreditTransaction, + createPurchase, + getUser, + updateUserCredits, +} from '@/lib/firestore'; + +const stripeSecretKey = process.env.STRIPE_SECRET_KEY; +const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + +const stripe = stripeSecretKey + ? new Stripe(stripeSecretKey, { apiVersion: '2024-06-20' }) + : null; + +export async function POST(request: Request) { + if (!stripe || !webhookSecret) { + return NextResponse.json({ error: 'Stripe webhook not configured.' }, { status: 500 }); + } + + const signature = request.headers.get('stripe-signature'); + if (!signature) { + return NextResponse.json({ error: 'Missing Stripe signature.' }, { status: 400 }); + } + + const payload = await request.text(); + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent(payload, signature, webhookSecret); + } catch (error) { + console.error('Stripe webhook signature error:', error); + return NextResponse.json({ error: 'Invalid signature.' }, { status: 400 }); + } + + if (event.type === 'checkout.session.completed') { + const session = event.data.object as Stripe.Checkout.Session; + const metadata = session.metadata ?? {}; + const userId = metadata.userId; + const credits = Number(metadata.credits); + const amount = Number(metadata.amount); + + if (!userId || !Number.isFinite(credits) || credits <= 0) { + return NextResponse.json({ error: 'Invalid checkout metadata.' }, { status: 400 }); + } + + const user = await getUser(userId); + if (!user) { + return NextResponse.json({ error: 'User not found.' }, { status: 404 }); + } + + const newBalance = user.creditBalance + credits; + await updateUserCredits(userId, newBalance); + + await createCreditTransaction({ + userId, + amount: credits, + type: 'purchase', + balanceAfter: newBalance, + createdAt: Timestamp.now(), + }); + + await createPurchase({ + userId, + credits, + amount: Number.isFinite(amount) ? amount : credits, + currency: session.currency ?? 'eur', + stripeSessionId: session.id, + createdAt: Timestamp.now(), + }); + } + + return NextResponse.json({ received: true }); +} diff --git a/app/api/vapi/create-call/route.ts b/app/api/vapi/create-call/route.ts new file mode 100644 index 0000000..d9085c9 --- /dev/null +++ b/app/api/vapi/create-call/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from 'next/server'; +import { createCallRecord, getAgentSettings, getUser } from '@/lib/firestore'; + +const VAPI_BASE_URL = 'https://api.vapi.ai'; +const vapiPrivateKey = process.env.VAPI_PRIVATE_KEY; + +export async function POST(request: Request) { + try { + if (!vapiPrivateKey) { + return NextResponse.json({ error: 'Vapi not configured.' }, { status: 500 }); + } + + const body = await request.json(); + const phoneNumber = body?.phoneNumber as string | undefined; + const scheduledTaskId = body?.scheduledTaskId as string | undefined; + + if (!phoneNumber) { + return NextResponse.json({ error: 'Phone number is required.' }, { status: 400 }); + } + + // TODO: Get userId from auth token + const userId = body?.userId ?? 'test-user-id'; + const user = await getUser(userId); + if (!user || user.creditBalance < 1) { + return NextResponse.json({ error: 'Insufficient credits.' }, { status: 400 }); + } + + const agentSettings = await getAgentSettings(userId); + + const assistantPayload = { + name: agentSettings?.agentName ?? 'HolaCompi', + instructions: + agentSettings?.agentInstructions ?? + 'You are HolaCompi, a helpful Spanish friend who makes phone calls on behalf of users.', + model: 'gpt-4o-mini', + voice: { + provider: 'openai', + voice: 'alloy', + }, + language: agentSettings?.primaryCallLanguage ?? 'English', + }; + + // Create assistant dynamically based on agent settings + const assistantResponse = await fetch(`${VAPI_BASE_URL}/assistant`, { + method: 'POST', + headers: { + Authorization: `Bearer ${vapiPrivateKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(assistantPayload), + }); + + if (!assistantResponse.ok) { + const errorText = await assistantResponse.text(); + return NextResponse.json({ error: errorText }, { status: 500 }); + } + + const assistant = await assistantResponse.json(); + + // Initiate outbound call via Vapi API + const callResponse = await fetch(`${VAPI_BASE_URL}/call`, { + method: 'POST', + headers: { + Authorization: `Bearer ${vapiPrivateKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + assistantId: assistant.id, + phoneNumber, + // TODO: Adjust payload based on Vapi call schema (customer/from numbers) + }), + }); + + if (!callResponse.ok) { + const errorText = await callResponse.text(); + return NextResponse.json({ error: errorText }, { status: 500 }); + } + + const callData = await callResponse.json(); + + await createCallRecord({ + userId, + phoneNumber, + status: 'initiated', + vapiCallId: callData.id, + assistantId: assistant.id, + scheduledTaskId, + }); + + return NextResponse.json({ status: callData.status ?? 'initiated', callId: callData.id }); + } catch (error) { + console.error('Vapi create call error:', error); + return NextResponse.json({ error: 'Failed to create call.' }, { status: 500 }); + } +} diff --git a/app/api/vapi/webhook/route.ts b/app/api/vapi/webhook/route.ts new file mode 100644 index 0000000..95b25b3 --- /dev/null +++ b/app/api/vapi/webhook/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server'; +import { Timestamp } from 'firebase/firestore'; +import { getCallByVapiCallId, getUser, updateCallRecord, updateUserCredits } from '@/lib/firestore'; + +export async function POST(request: Request) { + try { + const payload = await request.json(); + const eventType = payload?.event || payload?.type; + const callPayload = payload?.call || payload?.data?.call || payload; + const vapiCallId = callPayload?.id as string | undefined; + + if (!vapiCallId) { + return NextResponse.json({ error: 'Missing call id.' }, { status: 400 }); + } + + const callRecord = await getCallByVapiCallId(vapiCallId); + if (!callRecord) { + return NextResponse.json({ error: 'Call record not found.' }, { status: 404 }); + } + + if (eventType === 'call-started' || eventType === 'call.started') { + await updateCallRecord(callRecord.id, { + status: 'started', + startedAt: Timestamp.now(), + }); + } + + if (eventType === 'call-failed' || eventType === 'call.failed') { + await updateCallRecord(callRecord.id, { + status: 'failed', + endedAt: Timestamp.now(), + }); + } + + if (eventType === 'call-ended' || eventType === 'call.ended') { + const durationSeconds = + Number(callPayload?.durationSeconds) || + Number(callPayload?.duration) || + 0; + const creditsUsed = Math.ceil(durationSeconds / 60); + + await updateCallRecord(callRecord.id, { + status: 'ended', + endedAt: Timestamp.now(), + durationSeconds, + creditsUsed, + recordingUrl: callPayload?.recordingUrl || callPayload?.recording?.url, + transcript: callPayload?.transcript || callPayload?.summary, + }); + + const user = await getUser(callRecord.userId); + if (user) { + const newBalance = Math.max(0, user.creditBalance - creditsUsed); + await updateUserCredits(callRecord.userId, newBalance); + } + } + + return NextResponse.json({ received: true }); + } catch (error) { + console.error('Vapi webhook error:', error); + return NextResponse.json({ error: 'Webhook processing failed.' }, { status: 500 }); + } +} diff --git a/app/auth/page.tsx b/app/auth/page.tsx new file mode 100644 index 0000000..a983f76 --- /dev/null +++ b/app/auth/page.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { FormEvent, useState, useEffect } from 'react'; +import { useAuth } from '@/contexts/AuthContext'; +import { useRouter } from 'next/navigation'; + +export default function AuthPage() { + const { login, register, isLoading, user } = useAuth(); + const router = useRouter(); + const [mode, setMode] = useState<'login' | 'register'>('login'); + const [email, setEmail] = useState(''); + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + + // Redirect if already logged in + useEffect(() => { + if (user) { + router.push('/dashboard'); + } + }, [user, router]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + try { + if (mode === 'login') { + await login(email, password); + } else { + await register(email, password, name); + } + // Will auto-redirect via useEffect when user state updates + } catch (err: any) { + setError(err.message || 'Something went wrong'); + } + }; + + return ( +
+
+
+

HolaCompi

+

+ {mode === 'login' + ? 'Welcome back, sign in to continue' + : 'Create your HolaCompi account'} +

+
+ +
+ + +
+ + {error && ( +
+ {error} +
+ )} + + {user && ( +
+ Logged in as {user.email} +
+ )} + +
+ {mode === 'register' && ( +
+ + setName(e.target.value)} + required + /> +
+ )} + +
+ + setEmail(e.target.value)} + required + /> +
+ +
+ + setPassword(e.target.value)} + required + /> +
+ + +
+
+
+ ); +} diff --git a/app/chat/page.tsx b/app/chat/page.tsx new file mode 100644 index 0000000..cbfe2f9 --- /dev/null +++ b/app/chat/page.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { useAuth } from '@/contexts/AuthContext'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState, useRef, FormEvent } from 'react'; + +interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; + timestamp: Date; +} + +export default function ChatPage() { + const { user, isLoading } = useAuth(); + const router = useRouter(); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const messagesEndRef = useRef(null); + + useEffect(() => { + if (!isLoading && !user) { + router.push('/auth'); + } + }, [user, isLoading, router]); + + useEffect(() => { + // Scroll to bottom when messages change + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!user) return null; + + const handleSendMessage = async (e: FormEvent) => { + e.preventDefault(); + if (!input.trim() || sending) return; + + const userMessage: Message = { + id: Date.now().toString(), + role: 'user', + content: input.trim(), + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, userMessage]); + setInput(''); + setSending(true); + + // Simulate AI response (replace with real AI later) + setTimeout(() => { + const aiMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: `Hola! I received your message: "${userMessage.content}". I'm your AI companion, ready to help! (AI integration coming soon)`, + timestamp: new Date(), + }; + setMessages((prev) => [...prev, aiMessage]); + setSending(false); + }, 1000); + }; + + return ( +
+ {/* Navigation */} + + + {/* Chat Messages */} +
+
+ {messages.length === 0 ? ( +
+
👋
+

Welcome to HolaCompi!

+

+ Start a conversation with your AI companion +

+
+ ) : ( + messages.map((message) => ( +
+
+
+
+ {message.role === 'user' ? '👤' : '🤖'} +
+
+

+ {message.role === 'user' ? 'You' : 'HolaCompi'} +

+

{message.content}

+

+ {message.timestamp.toLocaleTimeString()} +

+
+
+
+
+ )) + )} + {sending && ( +
+
+
+
🤖
+
+
+
+
+
+
+
+
+ )} +
+
+
+ + {/* Input Area */} +
+
+
+ setInput(e.target.value)} + placeholder="Type your message..." + className="flex-1 rounded-xl px-6 py-4 bg-black/30 border border-white/10 text-white placeholder-gray-500 outline-none focus:border-purple-400 transition-colors" + disabled={sending} + /> + +
+
+
+
+ ); +} diff --git a/app/chat/route.ts.txt b/app/chat/route.ts.txt new file mode 100644 index 0000000..2c955ac --- /dev/null +++ b/app/chat/route.ts.txt @@ -0,0 +1,66 @@ +import { NextRequest } from 'next/server'; +import { GoogleGenerativeAI } from '@google/generative-ai'; + +export async function POST(req: NextRequest) { + try { + const { messages } = await req.json(); + + if (!process.env.GEMINI_API_KEY) { + return new Response(JSON.stringify({ error: 'Gemini API key not configured' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ model: 'gemini-pro' }); + + // Convert messages to Gemini format + const history = messages.slice(0, -1).map((msg: any) => ({ + role: msg.role === 'assistant' ? 'model' : 'user', + parts: [{ text: msg.content }], + })); + + const userMessage = messages[messages.length - 1].content; + + const chat = model.startChat({ + history: history, + generationConfig: { + temperature: 0.7, + maxOutputTokens: 1000, + }, + }); + + const result = await chat.sendMessageStream(userMessage); + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + try { + for await (const chunk of result.stream) { + const text = chunk.text(); + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text })}\n\n`)); + } + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + } catch (error) { + controller.error(error); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + } catch (error: any) { + console.error('Error in chat API:', error); + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/app/context/AuthContext.tsx b/app/context/AuthContext.tsx new file mode 100644 index 0000000..2340d33 --- /dev/null +++ b/app/context/AuthContext.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { createContext, useContext, useEffect, useState } from 'react'; +import { User, onAuthStateChanged } from 'firebase/auth'; +import { auth } from '@/lib/firebase'; + +interface AuthContextType { + user: User | null; + loading: boolean; +} + +const AuthContext = createContext({ + user: null, + loading: true, +}); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, (user) => { + setUser(user); + setLoading(false); + }); + + return () => unsubscribe(); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/app/dashboard/agent-settings/page.tsx b/app/dashboard/agent-settings/page.tsx new file mode 100644 index 0000000..ce51e18 --- /dev/null +++ b/app/dashboard/agent-settings/page.tsx @@ -0,0 +1,257 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/contexts/AuthContext'; +import type { AgentSettings } from '@/lib/types'; +import toast from 'react-hot-toast'; + +const SECONDARY_LANGUAGES = ['Spanish', 'Catalan', 'German', 'French']; + +const getFallbackSettings = (userId: string): AgentSettings => ({ + userId, + agentName: 'HolaCompi', + appLanguage: 'English', + primaryCallLanguage: 'English (UK)', + secondaryCallLanguages: [], + tone: 'Friendly', + agentInstructions: + 'You are HolaCompi, a helpful Spanish friend who makes phone calls on behalf of users.', + createdAt: null as unknown as AgentSettings['createdAt'], + updatedAt: null as unknown as AgentSettings['updatedAt'], +}); + +export default function AgentSettingsPage() { + const { user, isLoading } = useAuth(); + const router = useRouter(); + const [settings, setSettings] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (!isLoading && !user) { + router.push('/auth'); + } + }, [user, isLoading, router]); + + const fetchSettings = async () => { + try { + const response = await fetch('/api/agent-settings'); + if (!response.ok) { + toast.error('Unable to load agent settings.'); + setSettings(getFallbackSettings(user?.uid ?? 'test-user-id')); + return; + } + const data = await response.json(); + setSettings(data.settings ?? getFallbackSettings(user?.uid ?? 'test-user-id')); + } catch (error) { + toast.error('Unable to load agent settings.'); + setSettings(getFallbackSettings(user?.uid ?? 'test-user-id')); + } + }; + + useEffect(() => { + if (user) { + fetchSettings(); + } + }, [user]); + + const updateSettings = (patch: Partial) => { + setSettings((prev) => + prev + ? { + ...prev, + ...patch, + secondaryCallLanguages: + patch.secondaryCallLanguages ?? prev.secondaryCallLanguages ?? [], + } + : prev + ); + }; + + const toggleSecondaryLanguage = (language: string) => { + if (!settings) return; + const current = settings.secondaryCallLanguages || []; + const next = current.includes(language) + ? current.filter((item) => item !== language) + : [...current, language]; + updateSettings({ secondaryCallLanguages: next }); + }; + + const handleSave = async () => { + if (!settings || isSaving) return; + setIsSaving(true); + try { + const payload = { + agentName: settings.agentName, + appLanguage: settings.appLanguage, + primaryCallLanguage: settings.primaryCallLanguage, + secondaryCallLanguages: settings.secondaryCallLanguages ?? [], + tone: settings.tone, + agentInstructions: settings.agentInstructions, + }; + const response = await fetch('/api/agent-settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error('Failed to save'); + } + await fetchSettings(); + toast.success('Agent settings saved.'); + } catch (error) { + console.error('Error saving agent settings', error); + toast.error('Unable to save agent settings.'); + } finally { + setIsSaving(false); + } + }; + + if (isLoading || !settings) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+
+
+ +

Voice Agent Settings

+
+ +
+ +
+

+ Agent Identity +

+
+ + updateSettings({ agentName: event.target.value })} + className="mt-2 w-full rounded-xl bg-[#0f0b1a] border border-purple-500/20 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]" + placeholder="HolaCompi" + /> +
+
+ +
+

+ Linguistics +

+
+ +
+ English (fixed) +
+
+
+ + +
+
+ +
+ {SECONDARY_LANGUAGES.map((language) => { + const active = settings.secondaryCallLanguages?.includes(language); + return ( + + ); + })} +
+
+
+ +
+

+ Voice Persona +

+
+ {(['Professional', 'Friendly'] as const).map((tone) => { + const active = settings.tone === tone; + return ( + + ); + })} +
+
+ +