Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
+41
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = { ...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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#050316] text-white">
|
||||||
|
<div className="w-full max-w-md space-y-6 p-8 bg-white/5 rounded-3xl border border-white/10">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold">HolaCompi</h1>
|
||||||
|
<p className="text-sm text-gray-300">
|
||||||
|
{mode === 'login'
|
||||||
|
? 'Welcome back, sign in to continue'
|
||||||
|
: 'Create your HolaCompi account'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center space-x-2 bg-white/5 rounded-full p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('login')}
|
||||||
|
className={`flex-1 py-2 text-sm rounded-full ${
|
||||||
|
mode === 'login' ? 'bg-white text-black' : 'text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('register')}
|
||||||
|
className={`flex-1 py-2 text-sm rounded-full ${
|
||||||
|
mode === 'register' ? 'bg-white text-black' : 'text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-red-400 bg-red-400/10 border border-red-400/40 rounded-lg px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="text-xs text-green-400 bg-green-400/10 border border-green-400/40 rounded-lg px-3 py-2">
|
||||||
|
Logged in as {user.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{mode === 'register' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full rounded-lg px-3 py-2 bg-black/30 border border-white/10 text-sm outline-none focus:border-purple-400"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="w-full rounded-lg px-3 py-2 bg-black/30 border border-white/10 text-sm outline-none focus:border-purple-400"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full rounded-lg px-3 py-2 bg-black/30 border border-white/10 text-sm outline-none focus:border-purple-400"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full py-2.5 rounded-lg bg-purple-500 hover:bg-purple-600 disabled:opacity-70 text-sm font-medium"
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? 'Please wait...'
|
||||||
|
: mode === 'login'
|
||||||
|
? 'Login'
|
||||||
|
: 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Message[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#050316] text-white">
|
||||||
|
<div className="animate-pulse text-2xl">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="h-screen flex flex-col bg-[#050316] text-white">
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="border-b border-white/10 bg-white/5 backdrop-blur-sm flex-shrink-0">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<div className="flex items-center space-x-8">
|
||||||
|
<h1 className="text-2xl font-bold">HolaCompi Chat</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Chatting as {user.name || user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Chat Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-6">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-6xl mb-4">👋</div>
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Welcome to HolaCompi!</h2>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Start a conversation with your AI companion
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[70%] rounded-2xl px-6 py-4 ${
|
||||||
|
message.role === 'user'
|
||||||
|
? 'bg-purple-500 text-white'
|
||||||
|
: 'bg-white/10 text-white border border-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="flex-shrink-0 text-2xl">
|
||||||
|
{message.role === 'user' ? '👤' : '🤖'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm mb-1 opacity-70">
|
||||||
|
{message.role === 'user' ? 'You' : 'HolaCompi'}
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||||
|
<p className="text-xs opacity-50 mt-2">
|
||||||
|
{message.timestamp.toLocaleTimeString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{sending && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="max-w-[70%] rounded-2xl px-6 py-4 bg-white/10 border border-white/10">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="text-2xl">🤖</div>
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
<div className="w-2 h-2 bg-purple-400 rounded-full animate-bounce"></div>
|
||||||
|
<div className="w-2 h-2 bg-purple-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||||
|
<div className="w-2 h-2 bg-purple-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<div className="border-t border-white/10 bg-white/5 backdrop-blur-sm flex-shrink-0">
|
||||||
|
<div className="max-w-4xl mx-auto p-4">
|
||||||
|
<form onSubmit={handleSendMessage} className="flex space-x-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!input.trim() || sending}
|
||||||
|
className="px-8 py-4 rounded-xl bg-purple-500 hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AuthContextType>({
|
||||||
|
user: null,
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
||||||
|
setUser(user);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
@@ -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<AgentSettings | null>(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<AgentSettings>) => {
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#0f0b1a] text-white">
|
||||||
|
<div className="animate-pulse text-lg">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0f0b1a] text-white px-6 py-10">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
className="text-[#9ca3af] hover:text-white transition"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-semibold">Voice Agent Settings</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="text-sm px-4 py-2 rounded-full bg-[#8b5cf6]/20 text-white border border-[#8b5cf6]/40 hover:bg-[#8b5cf6]/30 transition"
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="bg-[#1a1625] border border-purple-500/20 rounded-xl p-6 shadow-lg space-y-4">
|
||||||
|
<h2 className="text-sm uppercase text-[#9ca3af] flex items-center gap-2">
|
||||||
|
<span className="text-[#8b5cf6]">◆</span> Agent Identity
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[#9ca3af] uppercase">Agent Name</label>
|
||||||
|
<input
|
||||||
|
value={settings.agentName}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#1a1625] border border-purple-500/20 rounded-xl p-6 shadow-lg space-y-5">
|
||||||
|
<h2 className="text-sm uppercase text-[#9ca3af] flex items-center gap-2">
|
||||||
|
<span className="text-[#8b5cf6]">◆</span> Linguistics
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[#9ca3af] uppercase">App Language</label>
|
||||||
|
<div className="mt-2 rounded-xl bg-[#0f0b1a] border border-purple-500/20 px-4 py-3 text-sm text-white/80">
|
||||||
|
English (fixed)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[#9ca3af] uppercase">Primary Language</label>
|
||||||
|
<select
|
||||||
|
value={settings.primaryCallLanguage}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSettings({ primaryCallLanguage: 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]"
|
||||||
|
>
|
||||||
|
<option>English (UK)</option>
|
||||||
|
<option>English (US)</option>
|
||||||
|
<option>Spanish</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[#9ca3af] uppercase">
|
||||||
|
Secondary Languages
|
||||||
|
</label>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{SECONDARY_LANGUAGES.map((language) => {
|
||||||
|
const active = settings.secondaryCallLanguages?.includes(language);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={language}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSecondaryLanguage(language)}
|
||||||
|
className={`px-4 py-2 rounded-full border text-sm transition ${
|
||||||
|
active
|
||||||
|
? 'bg-[#8b5cf6]/30 border-[#8b5cf6]/60 text-white'
|
||||||
|
: 'bg-white/5 border-white/10 text-[#9ca3af] hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{language}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#1a1625] border border-purple-500/20 rounded-xl p-6 shadow-lg space-y-5">
|
||||||
|
<h2 className="text-sm uppercase text-[#9ca3af] flex items-center gap-2">
|
||||||
|
<span className="text-[#8b5cf6]">◆</span> Voice Persona
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{(['Professional', 'Friendly'] as const).map((tone) => {
|
||||||
|
const active = settings.tone === tone;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tone}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateSettings({ tone })}
|
||||||
|
className={`flex items-center justify-between rounded-2xl border px-4 py-4 text-left transition ${
|
||||||
|
active
|
||||||
|
? 'border-[#8b5cf6]/70 bg-[#8b5cf6]/20 text-white'
|
||||||
|
: 'border-white/10 bg-white/5 text-[#9ca3af] hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{tone}</p>
|
||||||
|
<p className="text-xs text-[#9ca3af] mt-1">
|
||||||
|
{tone === 'Professional'
|
||||||
|
? 'Clear, polite, and authoritative'
|
||||||
|
: 'Warm, friendly, and supportive'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`h-4 w-4 rounded-full border ${
|
||||||
|
active ? 'border-[#8b5cf6] bg-[#8b5cf6]' : 'border-white/20'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[#9ca3af] uppercase">
|
||||||
|
Agent Instructions
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={settings.agentInstructions}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSettings({ agentInstructions: event.target.value })
|
||||||
|
}
|
||||||
|
className="mt-2 w-full rounded-2xl bg-[#0f0b1a] border border-purple-500/20 px-4 py-4 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6] min-h-[160px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
MessageSquare,
|
||||||
|
Mic,
|
||||||
|
MicOff,
|
||||||
|
PhoneCall,
|
||||||
|
Send,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useVapi } from '@/hooks/useVapi';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Chat() {
|
||||||
|
const { user, isLoading: authLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [permissionError, setPermissionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Voice state
|
||||||
|
const [isListening, setIsListening] = useState(false);
|
||||||
|
const recognitionRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { status, error: callError, durationSeconds, callStats, startCall, endCall } =
|
||||||
|
useVapi();
|
||||||
|
|
||||||
|
// Redirect if not logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !user) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [user, authLoading, router]);
|
||||||
|
|
||||||
|
// Auto-scroll
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Init speech recognition (client only)
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const SpeechRecognition =
|
||||||
|
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||||
|
|
||||||
|
if (!SpeechRecognition) {
|
||||||
|
console.warn('Speech recognition not supported in this browser');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recognition = new SpeechRecognition();
|
||||||
|
recognition.lang = 'en-US'; // change to 'es-ES' etc if you want
|
||||||
|
recognition.continuous = false;
|
||||||
|
recognition.interimResults = true;
|
||||||
|
|
||||||
|
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||||
|
let finalText = '';
|
||||||
|
let interimText = '';
|
||||||
|
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
const result = event.results[i];
|
||||||
|
if (result.isFinal) {
|
||||||
|
finalText += result[0].transcript;
|
||||||
|
} else {
|
||||||
|
interimText += result[0].transcript;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// show interim in input while speaking
|
||||||
|
if (interimText) {
|
||||||
|
setInput(interimText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalText) {
|
||||||
|
const cleaned = finalText.trim();
|
||||||
|
setInput(cleaned);
|
||||||
|
// If you want auto-send after speech, uncomment:
|
||||||
|
// if (cleaned) {
|
||||||
|
// handleVoiceSend(cleaned);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = (event: any) => {
|
||||||
|
console.error('Speech recognition error', event.error);
|
||||||
|
setIsListening(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onend = () => {
|
||||||
|
setIsListening(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognitionRef.current = recognition;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
recognition.stop();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
recognitionRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendMessage = async (
|
||||||
|
e?: React.FormEvent,
|
||||||
|
overrideText?: string,
|
||||||
|
) => {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
|
||||||
|
const textToSend = overrideText ?? input;
|
||||||
|
if (!textToSend.trim() || isSending) return;
|
||||||
|
|
||||||
|
const userMessage: Message = { role: 'user', content: textToSend };
|
||||||
|
const updatedMessages = [...messages, userMessage];
|
||||||
|
setMessages(updatedMessages);
|
||||||
|
setInput('');
|
||||||
|
setIsSending(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
messages: updatedMessages,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to get response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages([
|
||||||
|
...updatedMessages,
|
||||||
|
{ role: 'assistant', content: data.text },
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending message:', error);
|
||||||
|
setMessages([
|
||||||
|
...updatedMessages,
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content:
|
||||||
|
'Sorry, I encountered an error. Please try again.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
toast.error('Unable to send your message.');
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Manual send from voice text (if you want to click after dictation)
|
||||||
|
const handleVoiceSend = (text: string) => {
|
||||||
|
sendMessage(undefined, text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleListening = () => {
|
||||||
|
const recognition = recognitionRef.current;
|
||||||
|
if (!recognition) {
|
||||||
|
console.warn('No recognition instance; browser may not support it');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isListening) {
|
||||||
|
setInput('');
|
||||||
|
setIsListening(true);
|
||||||
|
try {
|
||||||
|
recognition.start();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error starting recognition', err);
|
||||||
|
setIsListening(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
recognition.stop();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error stopping recognition', err);
|
||||||
|
}
|
||||||
|
setIsListening(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}m ${secs.toString().padStart(2, '0')}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const callStatusLabel = useMemo(() => {
|
||||||
|
if (status === 'connecting') return 'Connecting...';
|
||||||
|
if (status === 'active') return 'On Call';
|
||||||
|
if (status === 'ended') return 'Call Ended';
|
||||||
|
if (status === 'error') return 'Error';
|
||||||
|
return 'Ready to Call';
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const handleVoiceCall = async () => {
|
||||||
|
setPermissionError(null);
|
||||||
|
|
||||||
|
if (status === 'active' || status === 'connecting') {
|
||||||
|
await endCall();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Microphone permission denied', error);
|
||||||
|
setPermissionError(
|
||||||
|
'Microphone access is required. Please allow permission and use a modern browser.'
|
||||||
|
);
|
||||||
|
toast.error('Microphone access is required to start a call.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startCall();
|
||||||
|
toast.success('Call started.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unable to start call', error);
|
||||||
|
toast.error('Unable to start the call.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] flex items-center justify-center">
|
||||||
|
<div className="text-white text-xl animate-pulse">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ label: 'Dashboard', href: '/dashboard' },
|
||||||
|
{ label: 'Scheduled Calls', href: '/dashboard/scheduled-calls' },
|
||||||
|
{ label: 'Voice Agent', href: '/dashboard/agent-settings' },
|
||||||
|
{ label: 'Credits', href: '/dashboard/credits' },
|
||||||
|
{ label: 'Notifications', href: '/dashboard/notifications' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] text-white">
|
||||||
|
<nav className="border-b border-white/10 bg-white/5 backdrop-blur-sm">
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-4 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-2xl bg-[#8b5cf6]/20 border border-[#8b5cf6]/40 flex items-center justify-center">
|
||||||
|
<MessageSquare className="h-5 w-5 text-[#8b5cf6]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Chat</h1>
|
||||||
|
<p className="text-xs text-[#9ca3af]">Talk to HolaCompi in real time</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{navLinks.map((link) => {
|
||||||
|
const isActive = pathname === link.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className={`px-3 py-1.5 rounded-full border text-xs transition ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[#8b5cf6]/20 border-[#8b5cf6]/40 text-white'
|
||||||
|
: 'bg-white/5 border-white/10 text-[#9ca3af] hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<div className="bg-[#1a1625] rounded-3xl border border-[#8b5cf6]/20 p-6 shadow-2xl h-[70vh] flex flex-col">
|
||||||
|
<div className="mb-4 rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-4 flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`h-10 w-10 rounded-2xl flex items-center justify-center border ${
|
||||||
|
status === 'active'
|
||||||
|
? 'bg-red-500/20 border-red-400/40'
|
||||||
|
: 'bg-[#8b5cf6]/20 border-[#8b5cf6]/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<PhoneCall
|
||||||
|
className={`h-5 w-5 ${
|
||||||
|
status === 'active' ? 'text-red-300' : 'text-[#8b5cf6]'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">{callStatusLabel}</p>
|
||||||
|
<div className="text-xs text-[#9ca3af] flex items-center gap-2">
|
||||||
|
{status === 'connecting' && (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
Connecting to Vapi...
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'active' && (
|
||||||
|
<>
|
||||||
|
<span className="h-2 w-2 rounded-full bg-red-400 animate-pulse" />
|
||||||
|
Duration: {formatDuration(durationSeconds)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'idle' && 'Tap start to begin a voice call'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleVoiceCall}
|
||||||
|
disabled={status === 'connecting'}
|
||||||
|
className={`px-5 py-3 rounded-2xl font-semibold shadow-2xl transition flex items-center gap-2 ${
|
||||||
|
status === 'active'
|
||||||
|
? 'bg-red-500 text-white hover:bg-red-600'
|
||||||
|
: 'bg-gradient-to-r from-[#8b5cf6] to-[#4f46e5] text-white hover:from-[#7c3aed] hover:to-[#4338ca]'
|
||||||
|
} ${status === 'connecting' ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
{status === 'active'
|
||||||
|
? 'End Call'
|
||||||
|
: status === 'connecting'
|
||||||
|
? 'Connecting...'
|
||||||
|
: 'Start Call'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(permissionError || callError) && (
|
||||||
|
<div className="text-xs text-red-300">
|
||||||
|
{permissionError || callError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'ended' && callStats && (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-[#0f0b1a]/60 p-4">
|
||||||
|
<p className="text-sm font-semibold">Call Summary</p>
|
||||||
|
<div className="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-[#9ca3af]">
|
||||||
|
<div>
|
||||||
|
<p className="uppercase">Duration</p>
|
||||||
|
<p className="text-white">{formatDuration(callStats.durationSeconds)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="uppercase">Credits Used</p>
|
||||||
|
<p className="text-white">{callStats.creditsUsed}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="uppercase">Transcript</p>
|
||||||
|
<button className="text-[#8b5cf6] hover:text-white transition">
|
||||||
|
View Transcript
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto mb-4 space-y-4">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="text-[#9ca3af] text-center mt-20">
|
||||||
|
Start a conversation with your AI companion
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`flex ${
|
||||||
|
message.role === 'user'
|
||||||
|
? 'justify-end'
|
||||||
|
: 'justify-start'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[70%] rounded-2xl p-4 shadow ${
|
||||||
|
message.role === 'user'
|
||||||
|
? 'bg-[#8b5cf6] text-white'
|
||||||
|
: 'bg-[#0f0b1a] border border-white/10 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="whitespace-pre-wrap">
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isSending && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="bg-[#0f0b1a] border border-white/10 rounded-2xl p-4">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<div className="w-2 h-2 bg-[#9ca3af] rounded-full animate-bounce"></div>
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 bg-[#9ca3af] rounded-full animate-bounce"
|
||||||
|
style={{ animationDelay: '0.1s' }}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 bg-[#9ca3af] rounded-full animate-bounce"
|
||||||
|
style={{ animationDelay: '0.2s' }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={(e) => sendMessage(e)} className="flex gap-2 items-center">
|
||||||
|
<div className="flex-1 flex items-center gap-2 bg-[#0f0b1a] border border-white/10 rounded-2xl px-4 py-3 focus-within:ring-2 focus-within:ring-[#8b5cf6] transition">
|
||||||
|
<Sparkles className="h-4 w-4 text-[#8b5cf6]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder={isListening ? 'Listening...' : 'Type your message...'}
|
||||||
|
className="flex-1 bg-transparent text-white placeholder:text-[#9ca3af] focus:outline-none"
|
||||||
|
disabled={isSending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleListening}
|
||||||
|
className={`px-4 py-3 rounded-2xl font-semibold transition flex items-center gap-2 ${
|
||||||
|
isListening
|
||||||
|
? 'bg-red-500/80 hover:bg-red-500 text-white'
|
||||||
|
: 'bg-white/5 border border-white/10 text-[#9ca3af] hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isListening ? <MicOff className="h-4 w-4" /> : <Mic className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSending || !input.trim()}
|
||||||
|
className="bg-[#8b5cf6] hover:bg-[#7c3aed] disabled:bg-white/10 disabled:text-[#9ca3af] disabled:cursor-not-allowed px-6 py-3 rounded-2xl font-semibold transition flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import type { DefaultLimits, Purchase } from '@/lib/types';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { CreditCard, Flame, SlidersHorizontal, Wallet } from 'lucide-react';
|
||||||
|
import { getPurchasesByUser } from '@/lib/firestore';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function CreditsPage() {
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [balance, setBalance] = useState(0);
|
||||||
|
const [limits, setLimits] = useState<DefaultLimits | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [purchaseHistory, setPurchaseHistory] = useState<Purchase[]>([]);
|
||||||
|
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
||||||
|
const [isCreatingCheckout, setIsCreatingCheckout] = useState<string | null>(null);
|
||||||
|
const getFallbackLimits = (userId: string): DefaultLimits => ({
|
||||||
|
userId,
|
||||||
|
defaultMaxMinutes: 15,
|
||||||
|
defaultMaxCredits: 15,
|
||||||
|
allowAutoExtension: false,
|
||||||
|
maxExtraMinutes: 0,
|
||||||
|
defaultMaxRecalls: 3,
|
||||||
|
minDelayBetweenRecalls: 30,
|
||||||
|
updatedAt: null as unknown as DefaultLimits['updatedAt'],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !user) {
|
||||||
|
router.push('/auth');
|
||||||
|
}
|
||||||
|
}, [user, isLoading, router]);
|
||||||
|
|
||||||
|
const fetchCredits = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/credits');
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error('Unable to load credits right now.');
|
||||||
|
setBalance(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setBalance(data.balance ?? 0);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Unable to load credits. Please try again.');
|
||||||
|
setBalance(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchLimits = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/credits/limits');
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error('Unable to load limits right now.');
|
||||||
|
setLimits(getFallbackLimits(user?.uid ?? 'test-user-id'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setLimits(data.limits ?? getFallbackLimits(user?.uid ?? 'test-user-id'));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Unable to load limits. Please try again.');
|
||||||
|
setLimits(getFallbackLimits(user?.uid ?? 'test-user-id'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
fetchCredits();
|
||||||
|
fetchLimits();
|
||||||
|
getPurchasesByUser(user.uid)
|
||||||
|
.then(setPurchaseHistory)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error loading purchases', error);
|
||||||
|
toast.error('Unable to load purchases right now.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const estimatedValue = useMemo(() => {
|
||||||
|
return balance.toFixed(2);
|
||||||
|
}, [balance]);
|
||||||
|
|
||||||
|
const updateLimits = (patch: Partial<DefaultLimits>) => {
|
||||||
|
setLimits((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveLimits = async () => {
|
||||||
|
if (!limits || isSaving) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
defaultMaxMinutes: Number(limits.defaultMaxMinutes),
|
||||||
|
defaultMaxCredits: Number(limits.defaultMaxCredits),
|
||||||
|
allowAutoExtension: limits.allowAutoExtension,
|
||||||
|
maxExtraMinutes: Number(limits.maxExtraMinutes),
|
||||||
|
defaultMaxRecalls: Number(limits.defaultMaxRecalls),
|
||||||
|
minDelayBetweenRecalls: Number(limits.minDelayBetweenRecalls),
|
||||||
|
};
|
||||||
|
const response = await fetch('/api/credits/limits', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed');
|
||||||
|
await fetchLimits();
|
||||||
|
toast.success('Limits updated.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving limits', error);
|
||||||
|
toast.error('Failed to save limits.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCredits = async () => {
|
||||||
|
const raw = window.prompt('Enter credits to add', '10');
|
||||||
|
if (!raw) return;
|
||||||
|
const amount = Number(raw);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/credits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ amount }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed');
|
||||||
|
const data = await response.json();
|
||||||
|
setBalance(data.balance ?? balance);
|
||||||
|
toast.success('Credits added successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding credits', error);
|
||||||
|
toast.error('Unable to add credits right now.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const creditPackages = [
|
||||||
|
{ id: 'credits-10', label: '10 Credits', price: '€10', value: 10, tag: '€1/credit' },
|
||||||
|
{ id: 'credits-50', label: '50 Credits', price: '€45', value: 50, tag: 'Best Value' },
|
||||||
|
{ id: 'credits-100', label: '100 Credits', price: '€85', value: 100, tag: 'Enterprise' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleCheckout = async (packageId: string) => {
|
||||||
|
setCheckoutError(null);
|
||||||
|
setIsCreatingCheckout(packageId);
|
||||||
|
const selected = creditPackages.find((pack) => pack.id === packageId);
|
||||||
|
if (!selected) {
|
||||||
|
setCheckoutError('Invalid credit package.');
|
||||||
|
setIsCreatingCheckout(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/stripe/checkout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
credits: selected.value,
|
||||||
|
amount: Number(selected.price.replace('€', '')),
|
||||||
|
userId: user?.uid,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Unable to create a checkout session.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.url) {
|
||||||
|
toast.success('Redirecting to checkout...');
|
||||||
|
window.location.href = data.url;
|
||||||
|
} else {
|
||||||
|
throw new Error('Missing Stripe checkout URL.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Checkout error', error);
|
||||||
|
setCheckoutError('Unable to start checkout. Please try again.');
|
||||||
|
toast.error('Checkout failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsCreatingCheckout(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading || !limits) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#0f0b1a] text-white">
|
||||||
|
<div className="animate-pulse text-lg">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ label: 'Dashboard', href: '/dashboard' },
|
||||||
|
{ label: 'Scheduled Calls', href: '/dashboard/scheduled-calls' },
|
||||||
|
{ label: 'Voice Agent', href: '/dashboard/agent-settings' },
|
||||||
|
{ label: 'Notifications', href: '/dashboard/notifications' },
|
||||||
|
{ label: 'Settings', href: '/dashboard/settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] text-white px-6 py-10">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
className="text-[#9ca3af] hover:text-white transition"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-semibold">Credits & Limits</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{navLinks.map((link) => {
|
||||||
|
const isActive = pathname === link.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className={`px-3 py-1.5 rounded-full border text-xs transition ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[#8b5cf6]/20 border-[#8b5cf6]/40 text-white'
|
||||||
|
: 'bg-white/5 border-white/10 text-[#9ca3af] hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="bg-[#1a1625] border border-[#8b5cf6]/40 rounded-3xl p-6 shadow-2xl space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-[#9ca3af]">Current Balance</p>
|
||||||
|
<h2 className="text-4xl font-semibold mt-2">{balance} credits</h2>
|
||||||
|
<div className="text-sm text-[#9ca3af] mt-2 space-y-1">
|
||||||
|
<p>≈ {balance} minutes of calls</p>
|
||||||
|
<p>Estimated value: €{estimatedValue}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-14 w-14 rounded-2xl bg-[#8b5cf6]/20 border border-[#8b5cf6]/40 flex items-center justify-center">
|
||||||
|
<Wallet className="h-6 w-6 text-[#8b5cf6]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-white/5 border border-white/10 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-[#8b5cf6] to-[#4f46e5]"
|
||||||
|
style={{ width: `${Math.min(balance, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAddCredits}
|
||||||
|
className="w-full md:w-auto px-6 py-3 rounded-2xl bg-[#8b5cf6] text-white font-semibold shadow-2xl hover:bg-[#7c3aed] transition flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<CreditCard className="h-4 w-4" />
|
||||||
|
Add Credits
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(searchParams.get('success') || searchParams.get('canceled')) && (
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl border p-4 text-sm ${
|
||||||
|
searchParams.get('success')
|
||||||
|
? 'border-emerald-400/40 bg-emerald-500/10 text-emerald-200'
|
||||||
|
: 'border-red-400/40 bg-red-500/10 text-red-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{searchParams.get('success')
|
||||||
|
? 'Payment successful! Credits will appear shortly.'
|
||||||
|
: 'Payment canceled. You can try again anytime.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{creditPackages.map((pack) => (
|
||||||
|
<div
|
||||||
|
key={pack.id}
|
||||||
|
className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 shadow-lg space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-[#9ca3af]">Package</p>
|
||||||
|
<h3 className="text-xl font-semibold">{pack.label}</h3>
|
||||||
|
<p className="text-3xl font-semibold mt-2">{pack.price}</p>
|
||||||
|
<p className="text-xs text-[#9ca3af] mt-2">{pack.tag}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCheckout(pack.id)}
|
||||||
|
disabled={isCreatingCheckout === pack.id}
|
||||||
|
className="w-full px-4 py-2 rounded-2xl bg-[#8b5cf6] text-white font-semibold shadow-2xl hover:bg-[#7c3aed] transition disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isCreatingCheckout === pack.id ? 'Redirecting...' : 'Buy Now'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checkoutError && (
|
||||||
|
<div className="text-sm text-red-300">{checkoutError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Monthly Usage</h3>
|
||||||
|
<p className="text-xs text-[#9ca3af]">Last 30 days</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-emerald-400 flex items-center gap-1">
|
||||||
|
<Flame className="h-4 w-4" />
|
||||||
|
+12%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 h-32 rounded-2xl bg-gradient-to-b from-[#8b5cf6]/40 to-transparent border border-[#8b5cf6]/30 flex items-end gap-3 px-4 pb-4">
|
||||||
|
{[30, 40, 20, 55].map((value, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="w-6 rounded-full bg-[#8b5cf6]/70"
|
||||||
|
style={{ height: `${value}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 shadow-lg space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<SlidersHorizontal className="h-5 w-5 text-[#8b5cf6]" />
|
||||||
|
Default limits per task
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase text-[#9ca3af]">Max Minutes</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={limits.defaultMaxMinutes}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateLimits({ defaultMaxMinutes: Number(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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase text-[#9ca3af]">Max Credits</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={limits.defaultMaxCredits}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateLimits({ defaultMaxCredits: Number(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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase text-[#9ca3af]">Max Recalls</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={limits.defaultMaxRecalls}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateLimits({ defaultMaxRecalls: Number(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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase text-[#9ca3af]">
|
||||||
|
Min Delay (sec)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={limits.minDelayBetweenRecalls}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateLimits({ minDelayBetweenRecalls: Number(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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveLimits}
|
||||||
|
className="w-full md:w-auto px-6 py-2 rounded-full bg-white/5 border border-[#8b5cf6]/40 text-white hover:bg-[#8b5cf6]/20 transition"
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Limits'}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-[#9ca3af]">
|
||||||
|
Credits are consumed per task. One credit is approximately equal to 1 minute of
|
||||||
|
processing time or 1 voice call minute. Rates vary by destination.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 shadow-lg space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">Purchase History</h3>
|
||||||
|
{purchaseHistory.length === 0 ? (
|
||||||
|
<p className="text-sm text-[#9ca3af]">No purchases yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-[#9ca3af]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left pb-2 font-medium">Date</th>
|
||||||
|
<th className="text-left pb-2 font-medium">Credits</th>
|
||||||
|
<th className="text-left pb-2 font-medium">Amount</th>
|
||||||
|
<th className="text-left pb-2 font-medium">Session</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-white/90">
|
||||||
|
{purchaseHistory.map((purchase) => (
|
||||||
|
<tr key={purchase.id} className="border-t border-white/5">
|
||||||
|
<td className="py-3">
|
||||||
|
{purchase.createdAt?.toDate
|
||||||
|
? purchase.createdAt.toDate().toLocaleDateString()
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3">{purchase.credits}</td>
|
||||||
|
<td className="py-3">€{purchase.amount}</td>
|
||||||
|
<td className="py-3 text-xs text-[#9ca3af]">
|
||||||
|
{purchase.stripeSessionId.slice(0, 8)}...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
LogOut,
|
||||||
|
Settings,
|
||||||
|
UserCircle,
|
||||||
|
CreditCard,
|
||||||
|
Twitter,
|
||||||
|
Linkedin,
|
||||||
|
Github,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getAuth, signOut } from 'firebase/auth';
|
||||||
|
import { app, db } from '@/lib/firebase';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { doc, onSnapshot } from 'firebase/firestore';
|
||||||
|
|
||||||
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const auth = getAuth(app);
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [credits, setCredits] = useState<number | null>(null);
|
||||||
|
const [showCreditsPulse, setShowCreditsPulse] = useState(false);
|
||||||
|
const [isQuickBuyOpen, setIsQuickBuyOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
setCredits(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const docRef = doc(db, 'users', user.uid);
|
||||||
|
const unsubscribe = onSnapshot(
|
||||||
|
docRef,
|
||||||
|
(snapshot) => {
|
||||||
|
const balance = snapshot.data()?.creditBalance ?? 0;
|
||||||
|
setCredits((prev) => {
|
||||||
|
if (prev !== null && prev !== balance) {
|
||||||
|
setShowCreditsPulse(true);
|
||||||
|
window.setTimeout(() => setShowCreditsPulse(false), 900);
|
||||||
|
}
|
||||||
|
return balance;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('Failed to subscribe to credits', error);
|
||||||
|
setCredits(0);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
try {
|
||||||
|
await signOut(auth);
|
||||||
|
toast.success('Signed out.');
|
||||||
|
router.push('/auth');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sign out failed', error);
|
||||||
|
toast.error('Unable to sign out. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initial = user?.email?.[0]?.toUpperCase() ?? '?';
|
||||||
|
|
||||||
|
const pricingTiers = [
|
||||||
|
{ label: '10 Credits', price: '€10', value: 10 },
|
||||||
|
{ label: '50 Credits', price: '€45', value: 50 },
|
||||||
|
{ label: '100 Credits', price: '€85', value: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleQuickBuy = async (value: number, price: string) => {
|
||||||
|
if (!user) {
|
||||||
|
toast.error('Please sign in to purchase credits.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/stripe/checkout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
credits: value,
|
||||||
|
amount: Number(price.replace('€', '')),
|
||||||
|
userId: user.uid,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Checkout failed');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.url) {
|
||||||
|
window.location.href = data.url;
|
||||||
|
} else {
|
||||||
|
throw new Error('Missing checkout URL');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Quick buy failed', error);
|
||||||
|
toast.error('Unable to start checkout.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] text-white flex flex-col">
|
||||||
|
<header className="border-b border-white/10 bg-white/5 backdrop-blur-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-[#9ca3af]">Dashboard</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<motion.button
|
||||||
|
onClick={() => setIsQuickBuyOpen(true)}
|
||||||
|
className="relative rounded-full bg-[#8b5cf6]/20 border border-[#8b5cf6]/40 px-3 py-1.5 text-xs font-semibold text-white hover:bg-[#8b5cf6]/30 transition"
|
||||||
|
animate={showCreditsPulse ? { scale: [1, 1.08, 1] } : { scale: 1 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
💎 {credits ?? '—'} Credits
|
||||||
|
{showCreditsPulse && (
|
||||||
|
<span className="absolute inset-0 rounded-full bg-[#8b5cf6]/20 blur-md" />
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
className="flex items-center gap-3 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 hover:bg-white/10 transition"
|
||||||
|
>
|
||||||
|
<span className="h-9 w-9 rounded-full bg-[#8b5cf6] text-white flex items-center justify-center font-semibold">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className={`h-4 w-4 text-[#9ca3af] transition ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 6, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="absolute right-0 mt-3 w-64 rounded-2xl border border-[#8b5cf6]/30 bg-[#1a1625] shadow-2xl overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="px-4 py-4 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="h-10 w-10 rounded-full bg-[#8b5cf6]/30 border border-[#8b5cf6]/50 flex items-center justify-center font-semibold">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">Account</p>
|
||||||
|
<p className="text-xs text-[#9ca3af]">{user?.email ?? 'Unknown user'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-[#9ca3af]">
|
||||||
|
Credits: <span className="text-white">{credits ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
router.push('/dashboard/settings');
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-xl text-sm text-[#9ca3af] hover:text-white hover:bg-white/5 transition"
|
||||||
|
>
|
||||||
|
<UserCircle className="h-4 w-4" />
|
||||||
|
Profile
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
router.push('/dashboard/settings');
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-xl text-sm text-[#9ca3af] hover:text-white hover:bg-white/5 transition"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
router.push('/dashboard/credits');
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-xl text-sm text-[#9ca3af] hover:text-white hover:bg-white/5 transition"
|
||||||
|
>
|
||||||
|
<CreditCard className="h-4 w-4" />
|
||||||
|
Billing
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
handleSignOut();
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-xl text-sm text-red-300 hover:text-red-200 hover:bg-red-500/10 transition"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1">{children}</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isQuickBuyOpen && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setIsQuickBuyOpen(false)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.96, opacity: 0, y: 20 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.96, opacity: 0, y: 10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="w-full max-w-md rounded-3xl border border-[#8b5cf6]/30 bg-[#1a1625] p-6 shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold">Quick Buy Credits</h3>
|
||||||
|
<p className="text-xs text-[#9ca3af] mt-1">Choose a package to top up.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsQuickBuyOpen(false)}
|
||||||
|
className="text-[#9ca3af] hover:text-white transition"
|
||||||
|
aria-label="Close quick buy"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{pricingTiers.map((tier) => (
|
||||||
|
<button
|
||||||
|
key={tier.value}
|
||||||
|
onClick={() => handleQuickBuy(tier.value, tier.price)}
|
||||||
|
className="w-full flex items-center justify-between rounded-2xl border border-white/10 bg-[#0f0b1a] px-4 py-3 text-sm text-white hover:border-[#8b5cf6]/40 hover:bg-[#8b5cf6]/10 transition"
|
||||||
|
>
|
||||||
|
<span>{tier.label}</span>
|
||||||
|
<span className="text-[#9ca3af]">{tier.price}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsQuickBuyOpen(false);
|
||||||
|
router.push('/dashboard/credits');
|
||||||
|
}}
|
||||||
|
className="mt-4 w-full px-4 py-2 rounded-2xl bg-[#8b5cf6] text-white font-semibold hover:bg-[#7c3aed] transition"
|
||||||
|
>
|
||||||
|
Go to Billing & Credits
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<footer className="mt-auto border-t border-white/10 bg-[#120f1f]/80 backdrop-blur-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 text-center space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-[#9ca3af]">
|
||||||
|
<a href="#" className="hover:text-white transition">Privacy</a>
|
||||||
|
<span className="text-[#2f2544]">|</span>
|
||||||
|
<a href="#" className="hover:text-white transition">Terms</a>
|
||||||
|
<span className="text-[#2f2544]">|</span>
|
||||||
|
<a href="#" className="hover:text-white transition">Support</a>
|
||||||
|
<span className="text-[#2f2544]">|</span>
|
||||||
|
<a href="#" className="hover:text-white transition">Docs</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-4 text-[#9ca3af]">
|
||||||
|
<a href="#" aria-label="Twitter" className="hover:text-white transition">
|
||||||
|
<Twitter className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
<a href="#" aria-label="LinkedIn" className="hover:text-white transition">
|
||||||
|
<Linkedin className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
<a href="#" aria-label="GitHub" className="hover:text-white transition">
|
||||||
|
<Github className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#9ca3af]">© 2026 HolaCompi. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import type { BookedAppointment, NotificationSettings } from '@/lib/types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const formatTimestamp = (value: any) => {
|
||||||
|
if (!value) return '';
|
||||||
|
if (typeof value === 'string') return new Date(value).toLocaleString();
|
||||||
|
if (value?.seconds) return new Date(value.seconds * 1000).toLocaleString();
|
||||||
|
return value.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFallbackSettings = (userId: string): NotificationSettings => ({
|
||||||
|
userId,
|
||||||
|
notifyOnSuccess: true,
|
||||||
|
notifyOnFailure: true,
|
||||||
|
emailSummary: false,
|
||||||
|
autoAddToCalendar: false,
|
||||||
|
googleCalendarConnected: false,
|
||||||
|
iCloudCalendarConnected: false,
|
||||||
|
createdAt: null as unknown as NotificationSettings['createdAt'],
|
||||||
|
updatedAt: null as unknown as NotificationSettings['updatedAt'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [settings, setSettings] = useState<NotificationSettings | null>(null);
|
||||||
|
const [appointments, setAppointments] = useState<BookedAppointment[]>([]);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !user) {
|
||||||
|
router.push('/auth');
|
||||||
|
}
|
||||||
|
}, [user, isLoading, router]);
|
||||||
|
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/notifications');
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error('Unable to load notification 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 notification settings.');
|
||||||
|
setSettings(getFallbackSettings(user?.uid ?? 'test-user-id'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAppointments = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/appointments');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load appointments');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setAppointments(data.appointments || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading appointments', error);
|
||||||
|
toast.error('Unable to load appointments.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
fetchSettings();
|
||||||
|
fetchAppointments();
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const toggleSetting = (key: 'notifyOnSuccess' | 'notifyOnFailure' | 'emailSummary') => {
|
||||||
|
if (!settings) return;
|
||||||
|
setSettings({ ...settings, [key]: !settings[key] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!settings || isSaving) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
notifyOnSuccess: settings.notifyOnSuccess,
|
||||||
|
notifyOnFailure: settings.notifyOnFailure,
|
||||||
|
emailSummary: settings.emailSummary,
|
||||||
|
autoAddToCalendar: settings.autoAddToCalendar,
|
||||||
|
googleCalendarConnected: settings.googleCalendarConnected,
|
||||||
|
iCloudCalendarConnected: settings.iCloudCalendarConnected,
|
||||||
|
};
|
||||||
|
const response = await fetch('/api/notifications', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed');
|
||||||
|
await fetchSettings();
|
||||||
|
toast.success('Notification settings saved.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving settings', error);
|
||||||
|
toast.error('Unable to save settings.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading || !settings) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#0f0b1a] text-white">
|
||||||
|
<div className="animate-pulse text-lg">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0f0b1a] text-white px-6 py-10 pb-24">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
className="text-[#9ca3af] hover:text-white transition"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-semibold">Notifications & Calendar</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="text-sm px-4 py-2 rounded-full bg-[#8b5cf6]/20 text-white border border-[#8b5cf6]/40 hover:bg-[#8b5cf6]/30 transition"
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 space-y-4 shadow-lg">
|
||||||
|
<h2 className="text-sm uppercase text-[#9ca3af]">Preferences</h2>
|
||||||
|
{[
|
||||||
|
{ key: 'notifyOnSuccess', label: 'Successful booking' },
|
||||||
|
{ key: 'notifyOnFailure', label: 'Call failure' },
|
||||||
|
{ key: 'emailSummary', label: 'Email summary' },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.key} className="flex items-center justify-between">
|
||||||
|
<p className="text-sm">{item.label}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSetting(item.key as keyof NotificationSettings)}
|
||||||
|
className={`h-6 w-12 rounded-full transition flex items-center ${
|
||||||
|
settings[item.key as keyof NotificationSettings]
|
||||||
|
? 'bg-[#8b5cf6]'
|
||||||
|
: 'bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-5 w-5 rounded-full bg-white shadow transform transition ${
|
||||||
|
settings[item.key as keyof NotificationSettings]
|
||||||
|
? 'translate-x-6'
|
||||||
|
: 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 space-y-4 shadow-lg">
|
||||||
|
<h2 className="text-sm uppercase text-[#9ca3af]">Integrations</h2>
|
||||||
|
<button className="w-full rounded-2xl bg-[#8b5cf6] text-white py-3 font-semibold shadow-2xl hover:bg-[#7c3aed] transition">
|
||||||
|
Connect Google Calendar
|
||||||
|
</button>
|
||||||
|
<button className="w-full rounded-2xl bg-[#8b5cf6] text-white py-3 font-semibold shadow-2xl hover:bg-[#7c3aed] transition">
|
||||||
|
Connect iCloud Calendar
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 space-y-4 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm uppercase text-[#9ca3af]">Upcoming</h2>
|
||||||
|
<button className="text-xs text-[#9ca3af] hover:text-white">
|
||||||
|
View All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{appointments.length === 0 && (
|
||||||
|
<div className="rounded-2xl border border-purple-500/20 bg-white/5 p-4 text-sm text-[#9ca3af]">
|
||||||
|
No upcoming appointments yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{appointments.map((appointment) => (
|
||||||
|
<div
|
||||||
|
key={appointment.id}
|
||||||
|
className="rounded-2xl border border-purple-500/20 bg-[#1a1625] p-4 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[#9ca3af]">
|
||||||
|
{formatTimestamp(appointment.dateTime)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold">{appointment.title}</p>
|
||||||
|
{appointment.location && (
|
||||||
|
<p className="text-xs text-[#9ca3af] mt-1">{appointment.location}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-3 py-1 rounded-full border ${
|
||||||
|
appointment.calendarEventId
|
||||||
|
? 'bg-blue-500/20 text-blue-200 border-blue-400/30'
|
||||||
|
: 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{appointment.calendarEventId ? 'GOOGLE' : 'ICLOUD'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="fixed bottom-0 left-0 right-0 bg-[#0f0b1a] border-t border-purple-500/20 px-6 py-3">
|
||||||
|
<div className="max-w-4xl mx-auto flex items-center justify-between text-xs text-[#9ca3af]">
|
||||||
|
{[
|
||||||
|
{ label: 'Home', path: '/dashboard' },
|
||||||
|
{ label: 'Assistant', path: '/dashboard/agent-settings' },
|
||||||
|
{ label: 'Schedule', path: '/dashboard/scheduled-calls' },
|
||||||
|
{ label: 'Settings', path: '/dashboard/notifications' },
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
onClick={() => router.push(item.path)}
|
||||||
|
className="flex flex-col items-center gap-1 text-[#9ca3af] hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="text-base">●</span>
|
||||||
|
{item.label.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,979 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Joyride, { CallBackProps, STATUS, EVENTS } from 'react-joyride';
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
CalendarClock,
|
||||||
|
CreditCard,
|
||||||
|
Gauge,
|
||||||
|
MessageSquare,
|
||||||
|
Phone,
|
||||||
|
Clock,
|
||||||
|
TrendingUp,
|
||||||
|
Coins,
|
||||||
|
Settings,
|
||||||
|
Sparkles,
|
||||||
|
Timer,
|
||||||
|
X,
|
||||||
|
Search,
|
||||||
|
Download,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
CartesianGrid,
|
||||||
|
} from 'recharts';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { getUser, updateUserOnboardingCompleted } from '@/lib/firestore';
|
||||||
|
|
||||||
|
interface Call {
|
||||||
|
id: number;
|
||||||
|
contactName: string;
|
||||||
|
date: string;
|
||||||
|
duration: string;
|
||||||
|
status: 'Completed' | 'Failed';
|
||||||
|
credits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalyticsData {
|
||||||
|
totalCalls: number;
|
||||||
|
totalMinutes: number;
|
||||||
|
avgDuration: number;
|
||||||
|
creditsRemaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CallAnalyticsPoint {
|
||||||
|
day: string;
|
||||||
|
calls: number;
|
||||||
|
minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { user, isLoading, logout } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [creditsRemaining, setCreditsRemaining] = useState<number | null>(null);
|
||||||
|
const [isStatsLoading, setIsStatsLoading] = useState(true);
|
||||||
|
const [isLowCreditsDismissed, setIsLowCreditsDismissed] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [dateRange, setDateRange] = useState<'7' | '30' | 'custom'>('7');
|
||||||
|
const [customStart, setCustomStart] = useState('');
|
||||||
|
const [customEnd, setCustomEnd] = useState('');
|
||||||
|
const [sortKey, setSortKey] = useState<'date' | 'duration' | 'status'>('date');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [selectedCall, setSelectedCall] = useState<Call | null>(null);
|
||||||
|
const [runTour, setRunTour] = useState(false);
|
||||||
|
const [tourStepIndex, setTourStepIndex] = useState(0);
|
||||||
|
|
||||||
|
const tourSteps = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
target: '[data-tour="welcome"]',
|
||||||
|
content: 'Welcome to HolaCompi! Here is your dashboard overview.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="buy-credits"]',
|
||||||
|
content: 'Buy credits to power your calls whenever you need.',
|
||||||
|
placement: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="make-call"]',
|
||||||
|
content: 'Schedule a call to reach clients instantly.',
|
||||||
|
placement: 'top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="view-settings"]',
|
||||||
|
content: 'Adjust preferences and manage your account settings here.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockChartData: CallAnalyticsPoint[] = [
|
||||||
|
{ day: 'Mon', calls: 8, minutes: 24 },
|
||||||
|
{ day: 'Tue', calls: 12, minutes: 38 },
|
||||||
|
{ day: 'Wed', calls: 6, minutes: 19 },
|
||||||
|
{ day: 'Thu', calls: 10, minutes: 31 },
|
||||||
|
{ day: 'Fri', calls: 7, minutes: 22 },
|
||||||
|
{ day: 'Sat', calls: 2, minutes: 12 },
|
||||||
|
{ day: 'Sun', calls: 2, minutes: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockRecentCalls: Call[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
contactName: 'Lucia Morales',
|
||||||
|
date: '2026-01-21 18:30',
|
||||||
|
duration: '4m 15s',
|
||||||
|
status: 'Completed',
|
||||||
|
credits: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
contactName: 'Clinic Valencia',
|
||||||
|
date: '2026-01-21 14:20',
|
||||||
|
duration: '2m 45s',
|
||||||
|
status: 'Completed',
|
||||||
|
credits: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
contactName: 'Miguel Ramos',
|
||||||
|
date: '2026-01-20 16:10',
|
||||||
|
duration: '5m 32s',
|
||||||
|
status: 'Completed',
|
||||||
|
credits: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
contactName: 'Barcelona Dental',
|
||||||
|
date: '2026-01-20 11:45',
|
||||||
|
duration: '1m 23s',
|
||||||
|
status: 'Failed',
|
||||||
|
credits: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
contactName: 'Sofia Ortega',
|
||||||
|
date: '2026-01-19 09:15',
|
||||||
|
duration: '3m 58s',
|
||||||
|
status: 'Completed',
|
||||||
|
credits: 4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const analyticsData: AnalyticsData = useMemo(
|
||||||
|
() => ({
|
||||||
|
totalCalls: 47,
|
||||||
|
totalMinutes: 156,
|
||||||
|
avgDuration: 3.3,
|
||||||
|
creditsRemaining: creditsRemaining ?? 0,
|
||||||
|
}),
|
||||||
|
[creditsRemaining]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fadeInUp = {
|
||||||
|
hidden: { opacity: 0, y: 16 },
|
||||||
|
visible: { opacity: 1, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const slideUp = {
|
||||||
|
hidden: { opacity: 0, y: 24 },
|
||||||
|
visible: { opacity: 1, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseCallDate = (value: string) => new Date(value.replace(' ', 'T'));
|
||||||
|
|
||||||
|
const durationToSeconds = (value: string) => {
|
||||||
|
const match = value.match(/(\d+)m\s*(\d+)s/);
|
||||||
|
if (!match) return 0;
|
||||||
|
return Number(match[1]) * 60 + Number(match[2]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !user) {
|
||||||
|
router.push('/auth');
|
||||||
|
}
|
||||||
|
}, [user, isLoading, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCredits = async () => {
|
||||||
|
setIsStatsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/credits');
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error('Unable to load credits. Please try again.');
|
||||||
|
setCreditsRemaining(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setCreditsRemaining(data.balance ?? 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load credits', error);
|
||||||
|
toast.error('Unable to load credits. Please check your connection.');
|
||||||
|
setCreditsRemaining(0);
|
||||||
|
} finally {
|
||||||
|
setIsStatsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
fetchCredits();
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const dismissed = window.localStorage.getItem('lowCreditsBannerDismissed');
|
||||||
|
setIsLowCreditsDismissed(dismissed === 'true');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isActive = true;
|
||||||
|
const loadOnboardingStatus = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
const userDoc = await getUser(user.uid);
|
||||||
|
const completed = userDoc?.onboardingCompleted ?? false;
|
||||||
|
if (!completed && isActive) {
|
||||||
|
setRunTour(true);
|
||||||
|
setTourStepIndex(0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load onboarding status', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadOnboardingStatus();
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
};
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const handleDismissLowCredits = () => {
|
||||||
|
setIsLowCreditsDismissed(true);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem('lowCreditsBannerDismissed', 'true');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchTerm, dateRange, customStart, customEnd, sortKey, sortDirection]);
|
||||||
|
|
||||||
|
const filteredCalls = useMemo(() => {
|
||||||
|
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||||
|
const now = new Date();
|
||||||
|
let startDate: Date | null = null;
|
||||||
|
let endDate: Date | null = null;
|
||||||
|
|
||||||
|
if (dateRange === '7') {
|
||||||
|
startDate = new Date(now);
|
||||||
|
startDate.setDate(now.getDate() - 7);
|
||||||
|
} else if (dateRange === '30') {
|
||||||
|
startDate = new Date(now);
|
||||||
|
startDate.setDate(now.getDate() - 30);
|
||||||
|
} else if (dateRange === 'custom') {
|
||||||
|
startDate = customStart ? new Date(customStart) : null;
|
||||||
|
endDate = customEnd ? new Date(customEnd) : null;
|
||||||
|
if (endDate) {
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mockRecentCalls.filter((call) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!normalizedSearch ||
|
||||||
|
call.contactName.toLowerCase().includes(normalizedSearch);
|
||||||
|
const callDate = parseCallDate(call.date);
|
||||||
|
const afterStart = !startDate || callDate >= startDate;
|
||||||
|
const beforeEnd = !endDate || callDate <= endDate;
|
||||||
|
return matchesSearch && afterStart && beforeEnd;
|
||||||
|
});
|
||||||
|
}, [searchTerm, dateRange, customStart, customEnd, mockRecentCalls]);
|
||||||
|
|
||||||
|
const sortedCalls = useMemo(() => {
|
||||||
|
const sorted = [...filteredCalls];
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
let comparison = 0;
|
||||||
|
if (sortKey === 'date') {
|
||||||
|
comparison = parseCallDate(a.date).getTime() - parseCallDate(b.date).getTime();
|
||||||
|
} else if (sortKey === 'duration') {
|
||||||
|
comparison = durationToSeconds(a.duration) - durationToSeconds(b.duration);
|
||||||
|
} else {
|
||||||
|
comparison = a.status.localeCompare(b.status);
|
||||||
|
}
|
||||||
|
return sortDirection === 'asc' ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}, [filteredCalls, sortKey, sortDirection]);
|
||||||
|
|
||||||
|
const callsPerPage = 10;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(sortedCalls.length / callsPerPage));
|
||||||
|
const paginatedCalls = sortedCalls.slice(
|
||||||
|
(currentPage - 1) * callsPerPage,
|
||||||
|
currentPage * callsPerPage
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSort = (key: 'date' | 'duration' | 'status') => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDirection('desc');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
if (sortedCalls.length === 0) {
|
||||||
|
toast.error('No calls available to export.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = [
|
||||||
|
['Contact', 'Date', 'Duration', 'Status', 'Credits'],
|
||||||
|
...sortedCalls.map((call) => [
|
||||||
|
call.contactName,
|
||||||
|
call.date,
|
||||||
|
call.duration,
|
||||||
|
call.status,
|
||||||
|
call.credits.toString(),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
const csvContent = rows
|
||||||
|
.map((row) => row.map((value) => `"${value.replace(/"/g, '""')}"`).join(','))
|
||||||
|
.join('\n');
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', 'recent-calls.csv');
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoyride = async (data: CallBackProps) => {
|
||||||
|
const { status, index, type } = data;
|
||||||
|
if (type === EVENTS.STEP_AFTER) {
|
||||||
|
setTourStepIndex(index + 1);
|
||||||
|
}
|
||||||
|
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
|
||||||
|
setRunTour(false);
|
||||||
|
setTourStepIndex(0);
|
||||||
|
if (user) {
|
||||||
|
try {
|
||||||
|
await updateUserOnboardingCompleted(user.uid, true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save onboarding status', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] text-white">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-pulse text-2xl">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
router.push('/auth');
|
||||||
|
toast.success('Signed out successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout failed', error);
|
||||||
|
toast.error('Unable to log out. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ label: 'Scheduled Calls', href: '/dashboard/scheduled-calls', icon: CalendarClock },
|
||||||
|
{ label: 'Voice Agent', href: '/dashboard/agent-settings', icon: Sparkles },
|
||||||
|
{ label: 'Credits & Limits', href: '/dashboard/credits', icon: CreditCard },
|
||||||
|
{ label: 'Notifications', href: '/dashboard/notifications', icon: Bell },
|
||||||
|
{ label: 'Chat', href: '/dashboard/chat', icon: MessageSquare },
|
||||||
|
{ label: 'Settings', href: '/dashboard/settings', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] text-white">
|
||||||
|
<Joyride
|
||||||
|
steps={tourSteps}
|
||||||
|
run={runTour}
|
||||||
|
stepIndex={tourStepIndex}
|
||||||
|
continuous
|
||||||
|
showSkipButton
|
||||||
|
scrollToFirstStep
|
||||||
|
disableOverlayClose
|
||||||
|
callback={handleJoyride}
|
||||||
|
locale={{ skip: 'Skip Tour', next: 'Next', last: 'Done' }}
|
||||||
|
styles={{
|
||||||
|
options: {
|
||||||
|
primaryColor: '#8b5cf6',
|
||||||
|
backgroundColor: '#1a1625',
|
||||||
|
textColor: '#ffffff',
|
||||||
|
overlayColor: 'rgba(15, 11, 26, 0.65)',
|
||||||
|
arrowColor: '#1a1625',
|
||||||
|
zIndex: 60,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
borderRadius: 16,
|
||||||
|
border: '1px solid rgba(139,92,246,0.35)',
|
||||||
|
},
|
||||||
|
buttonNext: {
|
||||||
|
backgroundColor: '#8b5cf6',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
buttonSkip: {
|
||||||
|
color: '#c7c5d1',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<nav className="border-b border-white/10 bg-white/5 backdrop-blur-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-2xl bg-[#8b5cf6]/20 border border-[#8b5cf6]/40 flex items-center justify-center">
|
||||||
|
<Sparkles className="h-5 w-5 text-[#8b5cf6]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">HolaCompi</h1>
|
||||||
|
<p className="text-xs text-[#9ca3af]">
|
||||||
|
Your AI voice assistant dashboard
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="px-4 py-2 rounded-xl bg-red-500/10 hover:bg-red-500/20 text-red-300 border border-red-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3 pb-4">
|
||||||
|
{navLinks.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-full border text-sm transition ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[#8b5cf6]/20 border-[#8b5cf6]/50 text-white shadow-lg'
|
||||||
|
: 'bg-white/5 border-white/10 text-[#9ca3af] hover:text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
data-tour={item.label === 'Settings' ? 'view-settings' : undefined}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{!isLowCreditsDismissed && creditsRemaining !== null && creditsRemaining < 10 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="sticky top-[72px] z-40"
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="mt-4 rounded-2xl border border-yellow-400/40 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-100 shadow-lg backdrop-blur-xl flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
⚠️ Running low on credits! You have {creditsRemaining} credits remaining.
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard/credits')}
|
||||||
|
className="px-3 py-1.5 rounded-full bg-[#8b5cf6] text-white text-xs font-semibold hover:bg-[#7c3aed] transition"
|
||||||
|
>
|
||||||
|
Buy More Credits
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDismissLowCredits}
|
||||||
|
className="text-yellow-100 hover:text-white transition"
|
||||||
|
aria-label="Dismiss low credits warning"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<div className="space-y-10">
|
||||||
|
<motion.div
|
||||||
|
className="bg-[#1a1625] rounded-3xl border border-[#8b5cf6]/20 p-8 shadow-lg transition-transform hover:scale-[1.01]"
|
||||||
|
variants={fadeInUp}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
data-tour="welcome"
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-semibold mb-3">
|
||||||
|
Welcome back, {user.name || user.email}! 👋
|
||||||
|
</h2>
|
||||||
|
<p className="text-[#9ca3af]">
|
||||||
|
Schedule calls, monitor credits, and tune your voice agent.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||||
|
<Gauge className="h-5 w-5 text-[#8b5cf6]" />
|
||||||
|
Usage Overview
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard/credits')}
|
||||||
|
className="text-xs text-[#9ca3af] hover:text-white transition animate-pulse"
|
||||||
|
data-tour="buy-credits"
|
||||||
|
>
|
||||||
|
View credits →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{(isStatsLoading ? new Array(4).fill(null) : [
|
||||||
|
{
|
||||||
|
label: 'Total Calls Made',
|
||||||
|
value: `${analyticsData.totalCalls} calls`,
|
||||||
|
icon: Phone,
|
||||||
|
gradient: 'from-[#8b5cf6]/30 to-[#4f46e5]/30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Minutes Used',
|
||||||
|
value: `${analyticsData.totalMinutes} minutes`,
|
||||||
|
icon: Clock,
|
||||||
|
gradient: 'from-[#4f46e5]/30 to-[#3b82f6]/30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Average Call Duration',
|
||||||
|
value: `${analyticsData.avgDuration} minutes`,
|
||||||
|
icon: TrendingUp,
|
||||||
|
gradient: 'from-[#8b5cf6]/30 to-[#ec4899]/30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Credits Remaining',
|
||||||
|
value:
|
||||||
|
creditsRemaining === null
|
||||||
|
? 'Loading...'
|
||||||
|
: `${analyticsData.creditsRemaining} credits`,
|
||||||
|
icon: Coins,
|
||||||
|
gradient: 'from-[#10b981]/20 to-[#8b5cf6]/30',
|
||||||
|
},
|
||||||
|
]).map((card, index) => {
|
||||||
|
if (isStatsLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`skeleton-${index}`}
|
||||||
|
className="bg-white/5 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-xl animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="h-12 w-12 rounded-2xl bg-white/10 border border-white/10" />
|
||||||
|
<div className="mt-4 h-3 w-24 rounded-full bg-white/10" />
|
||||||
|
<div className="mt-3 h-6 w-32 rounded-full bg-white/10" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = card.icon;
|
||||||
|
const action =
|
||||||
|
card.label === 'Total Calls Made'
|
||||||
|
? () => {
|
||||||
|
const section = document.getElementById('call-history');
|
||||||
|
section?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
: card.label === 'Credits Remaining'
|
||||||
|
? () => router.push('/dashboard/credits')
|
||||||
|
: null;
|
||||||
|
const isClickable = Boolean(action);
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={card.label}
|
||||||
|
className={`group relative bg-white/5 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-xl transition hover:shadow-2xl ${
|
||||||
|
isClickable ? 'cursor-pointer' : ''
|
||||||
|
}`}
|
||||||
|
variants={fadeInUp}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
transition={{ delay: index * 0.08, duration: 0.4, ease: 'easeOut' }}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
onClick={action ?? undefined}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-0 group-hover:opacity-100 transition">
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-[#8b5cf6]/40 to-[#4f46e5]/30 blur-lg" />
|
||||||
|
<div className="absolute inset-0 rounded-2xl border border-[#8b5cf6]/50" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`relative rounded-2xl p-3 bg-gradient-to-r ${card.gradient} border border-white/10`}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs uppercase text-[#9ca3af] mt-4">{card.label}</p>
|
||||||
|
<p className="text-2xl font-semibold mt-2">{card.value}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
action?.();
|
||||||
|
}}
|
||||||
|
disabled={!isClickable}
|
||||||
|
className={`mt-4 text-xs font-semibold text-[#c4b5fd] transition-all ${
|
||||||
|
isClickable ? 'group-hover:text-white' : 'text-[#6b7280]'
|
||||||
|
} opacity-0 group-hover:opacity-100 translate-y-1 group-hover:translate-y-0`}
|
||||||
|
>
|
||||||
|
View Details →
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
<motion.div
|
||||||
|
className="lg:col-span-3 bg-[#1a1625] border border-white/10 rounded-3xl p-6 shadow-lg backdrop-blur-xl transition-transform hover:scale-[1.01]"
|
||||||
|
variants={slideUp}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
transition={{ duration: 0.45, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Weekly Usage</h3>
|
||||||
|
<p className="text-xs text-[#9ca3af]">Last 7 days</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-[#9ca3af]">
|
||||||
|
<Phone className="h-4 w-4 text-[#8b5cf6]" />
|
||||||
|
Calls & Minutes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={mockChartData}>
|
||||||
|
<CartesianGrid stroke="rgba(255,255,255,0.08)" strokeDasharray="4 4" />
|
||||||
|
<XAxis dataKey="day" stroke="#9ca3af" />
|
||||||
|
<YAxis stroke="#9ca3af" />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: '#1a1625',
|
||||||
|
border: '1px solid rgba(139,92,246,0.3)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
formatter={(value: number, name: string) => [
|
||||||
|
value,
|
||||||
|
name === 'calls' ? 'Calls' : 'Minutes',
|
||||||
|
]}
|
||||||
|
labelFormatter={(label) => `Day: ${label}`}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="calls" stroke="#8b5cf6" strokeWidth={3} />
|
||||||
|
<Line type="monotone" dataKey="minutes" stroke="#4f46e5" strokeWidth={3} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
id="call-history"
|
||||||
|
className="lg:col-span-2 bg-[#1a1625] border border-white/10 rounded-3xl p-6 shadow-lg backdrop-blur-xl transition-transform hover:scale-[1.01]"
|
||||||
|
variants={fadeInUp}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
transition={{ duration: 0.4, delay: 0.1, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-lg font-semibold">Recent Activity</h3>
|
||||||
|
<Timer className="h-5 w-5 text-[#8b5cf6]" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-full bg-white/5 border border-white/10 text-[#9ca3af] hover:text-white hover:bg-white/10 transition flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 mb-4">
|
||||||
|
<div className="flex items-center gap-2 rounded-2xl bg-[#0f0b1a] border border-white/10 px-4 py-2">
|
||||||
|
<Search className="h-4 w-4 text-[#9ca3af]" />
|
||||||
|
<input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
placeholder="Search by contact name"
|
||||||
|
className="w-full bg-transparent text-sm text-white placeholder:text-[#9ca3af] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={dateRange}
|
||||||
|
onChange={(event) => setDateRange(event.target.value as '7' | '30' | 'custom')}
|
||||||
|
className="rounded-full bg-[#0f0b1a] border border-white/10 px-4 py-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
|
||||||
|
>
|
||||||
|
<option value="7">Last 7 days</option>
|
||||||
|
<option value="30">Last 30 days</option>
|
||||||
|
<option value="custom">Custom range</option>
|
||||||
|
</select>
|
||||||
|
{dateRange === 'custom' && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={customStart}
|
||||||
|
onChange={(event) => setCustomStart(event.target.value)}
|
||||||
|
className="rounded-full bg-[#0f0b1a] border border-white/10 px-4 py-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-[#9ca3af]">to</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={customEnd}
|
||||||
|
onChange={(event) => setCustomEnd(event.target.value)}
|
||||||
|
className="rounded-full bg-[#0f0b1a] border border-white/10 px-4 py-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sortedCalls.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center text-center py-12">
|
||||||
|
<svg
|
||||||
|
width="160"
|
||||||
|
height="120"
|
||||||
|
viewBox="0 0 160 120"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect x="12" y="18" width="136" height="84" rx="16" fill="#1f1a2e" />
|
||||||
|
<rect x="28" y="34" width="104" height="12" rx="6" fill="#2f2544" />
|
||||||
|
<rect x="28" y="54" width="72" height="10" rx="5" fill="#2f2544" />
|
||||||
|
<rect x="28" y="72" width="88" height="10" rx="5" fill="#2f2544" />
|
||||||
|
<circle cx="126" cy="72" r="18" fill="#2f2544" />
|
||||||
|
<path
|
||||||
|
d="M120 72l6 6 10-12"
|
||||||
|
stroke="#8b5cf6"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-[#9ca3af] mt-4">
|
||||||
|
No calls match your filters yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-[#9ca3af]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left pb-2 font-medium">Contact</th>
|
||||||
|
<th className="text-left pb-2 font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('date')}
|
||||||
|
className="text-[#9ca3af] hover:text-white transition"
|
||||||
|
>
|
||||||
|
Date/Time {sortKey === 'date' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-left pb-2 font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('duration')}
|
||||||
|
className="text-[#9ca3af] hover:text-white transition"
|
||||||
|
>
|
||||||
|
Duration {sortKey === 'duration' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-left pb-2 font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('status')}
|
||||||
|
className="text-[#9ca3af] hover:text-white transition"
|
||||||
|
>
|
||||||
|
Status {sortKey === 'status' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-right pb-2 font-medium">Credits</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<motion.tbody
|
||||||
|
className="text-white/90"
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
variants={{ hidden: {}, visible: {} }}
|
||||||
|
>
|
||||||
|
{paginatedCalls.map((call, index) => (
|
||||||
|
<motion.tr
|
||||||
|
key={call.id}
|
||||||
|
className="border-t border-white/5 cursor-pointer hover:bg-white/5 transition"
|
||||||
|
variants={fadeInUp}
|
||||||
|
transition={{ delay: 0.1 + index * 0.08, duration: 0.35 }}
|
||||||
|
onClick={() => setSelectedCall(call)}
|
||||||
|
>
|
||||||
|
<td className="py-3">{call.contactName}</td>
|
||||||
|
<td className="py-3">{call.date}</td>
|
||||||
|
<td className="py-3">{call.duration}</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-1 rounded-full text-xs border ${
|
||||||
|
call.status === 'Completed'
|
||||||
|
? 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30'
|
||||||
|
: 'bg-red-500/20 text-red-200 border-red-400/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{call.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right">{call.credits}</td>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</motion.tbody>
|
||||||
|
</table>
|
||||||
|
<div className="flex items-center justify-between pt-4 text-xs text-[#9ca3af]">
|
||||||
|
<span>
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="px-2 py-1 rounded-full border border-white/10 text-[#9ca3af] hover:text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="px-2 py-1 rounded-full border border-white/10 text-[#9ca3af] hover:text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard/chat')}
|
||||||
|
className="bg-[#1a1625] rounded-2xl border border-white/10 p-6 shadow-lg hover:-translate-y-1 hover:border-[#8b5cf6]/40 hover:shadow-2xl transition text-left w-full hover:scale-[1.01]"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-10 w-10 text-[#8b5cf6] mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold mb-2">Chat</h3>
|
||||||
|
<p className="text-[#9ca3af] text-sm">
|
||||||
|
Talk to your AI companion in real-time.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard/scheduled-calls')}
|
||||||
|
className="bg-[#1a1625] rounded-2xl border border-white/10 p-6 shadow-lg hover:-translate-y-1 hover:border-[#8b5cf6]/40 hover:shadow-2xl transition text-left w-full hover:scale-[1.01]"
|
||||||
|
data-tour="make-call"
|
||||||
|
>
|
||||||
|
<CalendarClock className="h-10 w-10 text-[#8b5cf6] mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold mb-2">Scheduled Calls</h3>
|
||||||
|
<p className="text-[#9ca3af] text-sm">
|
||||||
|
Plan new calls and manage your queue.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard/credits')}
|
||||||
|
className="bg-[#1a1625] rounded-2xl border border-white/10 p-6 shadow-lg hover:-translate-y-1 hover:border-[#8b5cf6]/40 hover:shadow-2xl transition text-left w-full hover:scale-[1.01]"
|
||||||
|
>
|
||||||
|
<CreditCard className="h-10 w-10 text-[#8b5cf6] mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold mb-2">Credits & Limits</h3>
|
||||||
|
<p className="text-[#9ca3af] text-sm">
|
||||||
|
Track balance and adjust default call limits.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-r from-[#8b5cf6]/10 to-[#4f46e5]/10 rounded-2xl border border-[#8b5cf6]/20 p-6 shadow-lg transition-transform hover:scale-[1.01]">
|
||||||
|
<h3 className="text-lg font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-[#8b5cf6]" />
|
||||||
|
Account Status
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[#9ca3af] mb-3">
|
||||||
|
Email: <span className="text-white">{user.email}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[#9ca3af]">
|
||||||
|
UID: <span className="font-mono text-xs text-white/80">{user.uid}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedCall && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setSelectedCall(null)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.96, opacity: 0, y: 20 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.96, opacity: 0, y: 10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="w-full max-w-md rounded-3xl border border-[#8b5cf6]/30 bg-[#1a1625] p-6 shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold">{selectedCall.contactName}</h3>
|
||||||
|
<p className="text-xs text-[#9ca3af] mt-1">{selectedCall.date}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCall(null)}
|
||||||
|
className="text-[#9ca3af] hover:text-white transition"
|
||||||
|
aria-label="Close call details"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 space-y-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[#9ca3af]">Duration</span>
|
||||||
|
<span className="text-white">{selectedCall.duration}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[#9ca3af]">Status</span>
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-1 rounded-full text-xs border ${
|
||||||
|
selectedCall.status === 'Completed'
|
||||||
|
? 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30'
|
||||||
|
: 'bg-red-500/20 text-red-200 border-red-400/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedCall.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[#9ca3af]">Credits Used</span>
|
||||||
|
<span className="text-white">{selectedCall.credits}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCall(null)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-white hover:bg-white/10 transition"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import type { CallTask } from '@/lib/types';
|
||||||
|
import { CalendarDays, PhoneCall } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<CallTask['status'], string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
in_progress: 'In Progress',
|
||||||
|
completed: 'Completed',
|
||||||
|
failed: 'Failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<CallTask['status'], string> = {
|
||||||
|
pending: 'bg-yellow-500/20 text-yellow-200 border-yellow-400/30',
|
||||||
|
in_progress: 'bg-blue-500/20 text-blue-200 border-blue-400/30',
|
||||||
|
completed: 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30',
|
||||||
|
failed: 'bg-red-500/20 text-red-200 border-red-400/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILTERS: Array<{ key: 'all' | 'pending' | 'completed' | 'failed'; label: string }> = [
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'pending', label: 'Pending' },
|
||||||
|
{ key: 'completed', label: 'Completed' },
|
||||||
|
{ key: 'failed', label: 'Failed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatTimestamp = (value: any) => {
|
||||||
|
if (!value) return '';
|
||||||
|
if (typeof value === 'string') return new Date(value).toLocaleString();
|
||||||
|
if (value?.seconds) return new Date(value.seconds * 1000).toLocaleString();
|
||||||
|
return value.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScheduledCallsPage() {
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [tasks, setTasks] = useState<CallTask[]>([]);
|
||||||
|
const [activeFilter, setActiveFilter] = useState<'all' | 'pending' | 'completed' | 'failed'>(
|
||||||
|
'all'
|
||||||
|
);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
businessName: '',
|
||||||
|
phoneNumber: '',
|
||||||
|
preferredLanguage: 'English',
|
||||||
|
callGoals: '',
|
||||||
|
scheduledAt: '',
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
maxMinutes: 10,
|
||||||
|
allowRecalls: false,
|
||||||
|
maxRecalls: 0,
|
||||||
|
});
|
||||||
|
const [callNowNumber, setCallNowNumber] = useState('');
|
||||||
|
const [callNowError, setCallNowError] = useState<string | null>(null);
|
||||||
|
const [callNowStatus, setCallNowStatus] = useState<'idle' | 'connecting' | 'started'>('idle');
|
||||||
|
const [creditsBalance, setCreditsBalance] = useState<number | null>(null);
|
||||||
|
const [calendarMonth, setCalendarMonth] = useState(() => {
|
||||||
|
const date = new Date();
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), 1);
|
||||||
|
});
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date>(() => new Date());
|
||||||
|
const [selectedTime, setSelectedTime] = useState('10:00');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !user) {
|
||||||
|
router.push('/auth');
|
||||||
|
}
|
||||||
|
}, [user, isLoading, router]);
|
||||||
|
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/scheduled-calls');
|
||||||
|
const data = await response.json();
|
||||||
|
setTasks(data.tasks || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading tasks', error);
|
||||||
|
toast.error('Unable to load scheduled calls.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
fetchTasks();
|
||||||
|
fetch('/api/credits')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => setCreditsBalance(data.balance ?? 0))
|
||||||
|
.catch(() => {
|
||||||
|
setCreditsBalance(0);
|
||||||
|
toast.error('Unable to load credits.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const filteredTasks = useMemo(() => {
|
||||||
|
if (activeFilter === 'all') return tasks;
|
||||||
|
return tasks.filter((task) => task.status === activeFilter);
|
||||||
|
}, [tasks, activeFilter]);
|
||||||
|
|
||||||
|
const updateField = (key: keyof typeof formData, value: string | number | boolean) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isSubmitting) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/scheduled-calls', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...formData,
|
||||||
|
scheduledAt: new Date(
|
||||||
|
`${selectedDate.toDateString()} ${selectedTime}`
|
||||||
|
).toISOString(),
|
||||||
|
maxMinutes: Number(formData.maxMinutes),
|
||||||
|
maxRecalls: Number(formData.maxRecalls),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to schedule call');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchTasks();
|
||||||
|
toast.success('Call scheduled successfully.');
|
||||||
|
setFormData({
|
||||||
|
businessName: '',
|
||||||
|
phoneNumber: '',
|
||||||
|
preferredLanguage: 'English',
|
||||||
|
callGoals: '',
|
||||||
|
scheduledAt: '',
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
maxMinutes: 10,
|
||||||
|
allowRecalls: false,
|
||||||
|
maxRecalls: 0,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scheduling call', error);
|
||||||
|
toast.error('Unable to schedule the call.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCallNow = async () => {
|
||||||
|
setCallNowError(null);
|
||||||
|
if (!callNowNumber) {
|
||||||
|
setCallNowError('Please enter a phone number.');
|
||||||
|
toast.error('Please enter a phone number.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (creditsBalance !== null && creditsBalance < 1) {
|
||||||
|
setCallNowError('Insufficient credits to start a call.');
|
||||||
|
toast.error('Insufficient credits to start a call.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCallNowStatus('connecting');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/vapi/create-call', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
phoneNumber: callNowNumber,
|
||||||
|
userId: user?.uid,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data?.error || 'Failed to start call.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setCallNowStatus('started');
|
||||||
|
toast.success('Call started.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Call now error', error);
|
||||||
|
setCallNowError(
|
||||||
|
error instanceof Error ? error.message : 'Failed to start call.'
|
||||||
|
);
|
||||||
|
toast.error('Unable to start the call.');
|
||||||
|
setCallNowStatus('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelTask = async (taskId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/scheduled-calls/${taskId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to cancel task.');
|
||||||
|
}
|
||||||
|
await fetchTasks();
|
||||||
|
toast.success('Scheduled call canceled.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cancel task error', error);
|
||||||
|
toast.error('Unable to cancel the scheduled call.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const daysInMonth = new Date(
|
||||||
|
calendarMonth.getFullYear(),
|
||||||
|
calendarMonth.getMonth() + 1,
|
||||||
|
0
|
||||||
|
).getDate();
|
||||||
|
const firstWeekday = new Date(
|
||||||
|
calendarMonth.getFullYear(),
|
||||||
|
calendarMonth.getMonth(),
|
||||||
|
1
|
||||||
|
).getDay();
|
||||||
|
const calendarDays = Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#0f0b1a] text-white">
|
||||||
|
<div className="animate-pulse text-lg">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0f0b1a] text-white px-6 py-10">
|
||||||
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold">Scheduled Calls</h1>
|
||||||
|
<p className="text-sm text-[#9ca3af] mt-1">Agent online · 42 mins left</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
className="text-sm text-[#9ca3af] hover:text-white transition"
|
||||||
|
>
|
||||||
|
← Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 rounded-full bg-[#1a1625] p-1 border border-purple-500/20 w-fit shadow-lg">
|
||||||
|
{FILTERS.map((filter) => (
|
||||||
|
<button
|
||||||
|
key={filter.key}
|
||||||
|
onClick={() => setActiveFilter(filter.key)}
|
||||||
|
className={`px-4 py-2 text-sm rounded-full transition ${
|
||||||
|
activeFilter === filter.key
|
||||||
|
? 'bg-[#8b5cf6] text-white shadow-[0_0_16px_rgba(139,92,246,0.6)]'
|
||||||
|
: 'text-[#9ca3af] hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 shadow-lg space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PhoneCall className="h-5 w-5 text-[#8b5cf6]" />
|
||||||
|
<h2 className="text-lg font-semibold">Make Call Now</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#9ca3af]">
|
||||||
|
Start an immediate outbound call for quick tests.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
value={callNowNumber}
|
||||||
|
onChange={(event) => setCallNowNumber(event.target.value)}
|
||||||
|
className="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="+34 600 000 000"
|
||||||
|
/>
|
||||||
|
{creditsBalance !== null && (
|
||||||
|
<p className="text-xs text-[#9ca3af]">
|
||||||
|
Credits available: {creditsBalance}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{callNowError && <p className="text-xs text-red-300">{callNowError}</p>}
|
||||||
|
<button
|
||||||
|
onClick={handleCallNow}
|
||||||
|
className="w-full px-4 py-2 rounded-2xl bg-[#8b5cf6] text-white font-semibold shadow-2xl hover:bg-[#7c3aed] transition"
|
||||||
|
disabled={callNowStatus === 'connecting'}
|
||||||
|
>
|
||||||
|
{callNowStatus === 'connecting' ? 'Connecting...' : 'Test Call'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 shadow-lg space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarDays className="h-5 w-5 text-[#8b5cf6]" />
|
||||||
|
<h2 className="text-lg font-semibold">Schedule a Call</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-2 text-center text-xs text-[#9ca3af]">
|
||||||
|
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => (
|
||||||
|
<span key={day}>{day}</span>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: firstWeekday }).map((_, index) => (
|
||||||
|
<span key={`empty-${index}`} />
|
||||||
|
))}
|
||||||
|
{calendarDays.map((day) => {
|
||||||
|
const current = new Date(
|
||||||
|
calendarMonth.getFullYear(),
|
||||||
|
calendarMonth.getMonth(),
|
||||||
|
day
|
||||||
|
);
|
||||||
|
const isSelected =
|
||||||
|
current.toDateString() === selectedDate.toDateString();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedDate(current)}
|
||||||
|
className={`h-9 w-9 rounded-lg border text-sm transition ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-[#8b5cf6] text-white border-[#8b5cf6]'
|
||||||
|
: 'border-white/10 text-[#9ca3af] hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={selectedTime}
|
||||||
|
onChange={(event) => setSelectedTime(event.target.value)}
|
||||||
|
className="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]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={formData.timezone}
|
||||||
|
onChange={(event) => updateField('timezone', event.target.value)}
|
||||||
|
className="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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={formData.businessName}
|
||||||
|
onChange={(event) => updateField('businessName', event.target.value)}
|
||||||
|
className="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="Business name"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={formData.phoneNumber}
|
||||||
|
onChange={(event) => updateField('phoneNumber', event.target.value)}
|
||||||
|
className="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="+34 600 000 000"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={formData.preferredLanguage}
|
||||||
|
onChange={(event) => updateField('preferredLanguage', event.target.value)}
|
||||||
|
className="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]"
|
||||||
|
>
|
||||||
|
<option>English</option>
|
||||||
|
<option>Spanish</option>
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
value={formData.callGoals}
|
||||||
|
onChange={(event) => updateField('callGoals', event.target.value)}
|
||||||
|
className="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] min-h-[100px]"
|
||||||
|
placeholder="Call goals"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full bg-[#8b5cf6] hover:bg-[#7c3aed] transition text-white font-semibold py-3 rounded-2xl shadow-2xl"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Scheduling...' : 'Schedule Call'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredTasks.length === 0 && (
|
||||||
|
<div className="bg-[#1a1625] border border-purple-500/20 rounded-xl p-8 text-center text-[#9ca3af] shadow-lg">
|
||||||
|
No scheduled calls yet. Click the + button to add your first call.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredTasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="bg-[#1a1625] border border-purple-500/20 rounded-xl p-5 shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`text-xs uppercase tracking-wide px-3 py-1 rounded-full border ${STATUS_STYLES[task.status]}`}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[task.status]}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[#9ca3af]">
|
||||||
|
{formatTimestamp(task.scheduledAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">{task.businessName}</h3>
|
||||||
|
<p className="text-sm text-[#9ca3af]">{task.phoneNumber}</p>
|
||||||
|
<p className="text-sm text-white/80 line-clamp-2">{task.callGoals}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<span className="text-xs px-3 py-1 rounded-full bg-[#8b5cf6]/20 text-white border border-[#8b5cf6]/40">
|
||||||
|
Max {task.maxMinutes} min · UP TO €{task.maxCredits}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCancelTask(task.id)}
|
||||||
|
className="text-xs px-3 py-1 rounded-full bg-white/5 text-[#9ca3af] hover:text-white transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
CreditCard,
|
||||||
|
Lock,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
ShieldCheck,
|
||||||
|
SlidersHorizontal,
|
||||||
|
User,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getAuth, sendPasswordResetEmail, updateProfile } from 'firebase/auth';
|
||||||
|
import { app } from '@/lib/firebase';
|
||||||
|
import { getPurchasesByUser, updateUserOnboardingCompleted } from '@/lib/firestore';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const profileSchema = z.object({
|
||||||
|
displayName: z.string().trim().min(2, 'Display name must be at least 2 characters.'),
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => (value ? value : undefined))
|
||||||
|
.refine(
|
||||||
|
(value) => !value || /^\+?[0-9\s\-()]{7,20}$/.test(value),
|
||||||
|
'Enter a valid phone number.'
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: { opacity: 1, transition: { staggerChildren: 0.08 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardVariants = {
|
||||||
|
hidden: { opacity: 0, y: 14 },
|
||||||
|
show: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const auth = getAuth(app);
|
||||||
|
const firebaseUser = auth.currentUser;
|
||||||
|
|
||||||
|
const [profileForm, setProfileForm] = useState({
|
||||||
|
displayName: user?.name || '',
|
||||||
|
phone: user?.phoneNumber || '',
|
||||||
|
});
|
||||||
|
const [profileErrors, setProfileErrors] = useState<{ displayName?: string; phone?: string }>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||||
|
const [resetStatus, setResetStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||||
|
const [callAlerts, setCallAlerts] = useState(true);
|
||||||
|
const [weeklyReports, setWeeklyReports] = useState(false);
|
||||||
|
|
||||||
|
const [totalCreditsPurchased, setTotalCreditsPurchased] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !user) {
|
||||||
|
router.push('/auth');
|
||||||
|
}
|
||||||
|
}, [user, isLoading, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
getPurchasesByUser(user.uid)
|
||||||
|
.then((purchases) => {
|
||||||
|
const total = purchases.reduce((sum, purchase) => sum + (purchase.credits || 0), 0);
|
||||||
|
setTotalCreditsPurchased(total);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load purchases', error);
|
||||||
|
toast.error('Unable to load purchases.');
|
||||||
|
setTotalCreditsPurchased(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const creationDate = useMemo(() => {
|
||||||
|
const createdAt = firebaseUser?.metadata?.creationTime;
|
||||||
|
if (!createdAt) return 'Unknown';
|
||||||
|
return new Date(createdAt).toLocaleDateString();
|
||||||
|
}, [firebaseUser]);
|
||||||
|
|
||||||
|
const handleProfileChange = (key: 'displayName' | 'phone', value: string) => {
|
||||||
|
setProfileForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setProfileErrors((prev) => ({ ...prev, [key]: undefined }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveProfile = async () => {
|
||||||
|
if (!firebaseUser) return;
|
||||||
|
const result = profileSchema.safeParse(profileForm);
|
||||||
|
if (!result.success) {
|
||||||
|
const errors: { displayName?: string; phone?: string } = {};
|
||||||
|
result.error.issues.forEach((issue) => {
|
||||||
|
const field = issue.path[0] as 'displayName' | 'phone';
|
||||||
|
errors[field] = issue.message;
|
||||||
|
});
|
||||||
|
setProfileErrors(errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingProfile(true);
|
||||||
|
try {
|
||||||
|
await updateProfile(firebaseUser, { displayName: result.data.displayName.trim() });
|
||||||
|
setProfileForm((prev) => ({ ...prev, displayName: result.data.displayName.trim() }));
|
||||||
|
toast.success('Profile updated.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating profile', error);
|
||||||
|
setProfileErrors({ displayName: 'Failed to update profile. Try again.' });
|
||||||
|
toast.error('Unable to update profile.');
|
||||||
|
} finally {
|
||||||
|
setIsSavingProfile(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordReset = async () => {
|
||||||
|
if (!firebaseUser?.email) return;
|
||||||
|
setResetStatus(null);
|
||||||
|
try {
|
||||||
|
await sendPasswordResetEmail(auth, firebaseUser.email);
|
||||||
|
setResetStatus('Password reset email sent.');
|
||||||
|
toast.success('Password reset email sent.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Password reset failed', error);
|
||||||
|
setResetStatus('Unable to send reset email. Try again.');
|
||||||
|
toast.error('Unable to send reset email.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestartTour = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
await updateUserOnboardingCompleted(user.uid, false);
|
||||||
|
toast.success('Tour restarted. Redirecting...');
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to restart tour', error);
|
||||||
|
toast.error('Unable to restart tour.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] text-white">
|
||||||
|
<div className="animate-pulse text-lg">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ label: 'Dashboard', href: '/dashboard' },
|
||||||
|
{ label: 'Scheduled Calls', href: '/dashboard/scheduled-calls' },
|
||||||
|
{ label: 'Voice Agent', href: '/dashboard/agent-settings' },
|
||||||
|
{ label: 'Credits', href: '/dashboard/credits' },
|
||||||
|
{ label: 'Notifications', href: '/dashboard/notifications' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] text-white px-6 py-10">
|
||||||
|
<motion.div
|
||||||
|
className="max-w-5xl mx-auto space-y-6"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
>
|
||||||
|
<motion.header
|
||||||
|
variants={cardVariants}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-2xl bg-[#8b5cf6]/20 border border-[#8b5cf6]/40 flex items-center justify-center">
|
||||||
|
<SlidersHorizontal className="h-5 w-5 text-[#8b5cf6]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||||
|
<p className="text-xs text-[#9ca3af]">Manage preferences and account details</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{navLinks.map((link) => {
|
||||||
|
const isActive = pathname === link.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className={`px-3 py-1.5 rounded-full border text-xs transition ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[#8b5cf6]/20 border-[#8b5cf6]/40 text-white'
|
||||||
|
: 'bg-white/5 border-white/10 text-[#9ca3af] hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<motion.div variants={cardVariants} className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="bg-[#1a1625] border border-white/10 rounded-3xl p-6 shadow-2xl space-y-5">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<User className="h-5 w-5 text-[#8b5cf6]" />
|
||||||
|
User Profile
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="bg-[#0f0b1a] border border-white/10 rounded-2xl p-4">
|
||||||
|
<p className="text-xs uppercase text-[#9ca3af] flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
Email
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-2">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#0f0b1a] border border-white/10 rounded-2xl p-4">
|
||||||
|
<p className="text-xs uppercase text-[#9ca3af] flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
Display Name
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
value={profileForm.displayName}
|
||||||
|
onChange={(event) => handleProfileChange('displayName', event.target.value)}
|
||||||
|
className="mt-2 w-full rounded-xl bg-transparent border border-white/10 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
|
||||||
|
placeholder="Your name"
|
||||||
|
/>
|
||||||
|
{profileErrors.displayName && (
|
||||||
|
<p className="text-xs text-red-300 mt-2">{profileErrors.displayName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#0f0b1a] border border-white/10 rounded-2xl p-4 md:col-span-2">
|
||||||
|
<p className="text-xs uppercase text-[#9ca3af] flex items-center gap-2">
|
||||||
|
<Phone className="h-4 w-4" />
|
||||||
|
Phone
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
value={profileForm.phone}
|
||||||
|
onChange={(event) => handleProfileChange('phone', event.target.value)}
|
||||||
|
className="mt-2 w-full rounded-xl bg-transparent border border-white/10 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
|
||||||
|
placeholder="+34 600 000 000"
|
||||||
|
/>
|
||||||
|
{profileErrors.phone && (
|
||||||
|
<p className="text-xs text-red-300 mt-2">{profileErrors.phone}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveProfile}
|
||||||
|
disabled={isSavingProfile}
|
||||||
|
className="px-4 py-2 rounded-2xl bg-[#8b5cf6] text-white font-semibold shadow-2xl hover:bg-[#7c3aed] transition disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSavingProfile ? 'Saving...' : 'Save Profile'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#1a1625] border border-white/10 rounded-3xl p-6 shadow-2xl space-y-5">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-[#8b5cf6]" />
|
||||||
|
Security
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[#9ca3af]">
|
||||||
|
Reset your password to keep your account secure.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handlePasswordReset}
|
||||||
|
className="w-full md:w-auto px-4 py-2 rounded-2xl bg-white/5 border border-white/10 text-white hover:bg-white/10 transition flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
{resetStatus && <p className="text-xs text-[#9ca3af]">{resetStatus}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#1a1625] border border-white/10 rounded-3xl p-6 shadow-2xl space-y-5">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Bell className="h-5 w-5 text-[#8b5cf6]" />
|
||||||
|
Notification Preferences
|
||||||
|
</h2>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
key: 'email',
|
||||||
|
label: 'Email notifications',
|
||||||
|
value: emailNotifications,
|
||||||
|
setter: setEmailNotifications,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'calls',
|
||||||
|
label: 'Call alerts',
|
||||||
|
value: callAlerts,
|
||||||
|
setter: setCallAlerts,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reports',
|
||||||
|
label: 'Weekly usage reports',
|
||||||
|
value: weeklyReports,
|
||||||
|
setter: setWeeklyReports,
|
||||||
|
},
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.key} className="flex items-center justify-between">
|
||||||
|
<p className="text-sm">{item.label}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => item.setter(!item.value)}
|
||||||
|
className={`h-6 w-12 rounded-full transition flex items-center ${
|
||||||
|
item.value ? 'bg-[#8b5cf6]' : 'bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-5 w-5 rounded-full bg-white shadow transform transition ${
|
||||||
|
item.value ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-xs text-[#9ca3af]">
|
||||||
|
Preferences save locally for now. TODO: persist in Firestore.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={cardVariants} className="space-y-6">
|
||||||
|
<div className="bg-[#1a1625] border border-white/10 rounded-3xl p-6 shadow-2xl space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<CreditCard className="h-5 w-5 text-[#8b5cf6]" />
|
||||||
|
Account Info
|
||||||
|
</h2>
|
||||||
|
<div className="bg-[#0f0b1a] border border-white/10 rounded-2xl p-4 space-y-2">
|
||||||
|
<p className="text-xs uppercase text-[#9ca3af]">Member Since</p>
|
||||||
|
<p className="text-sm">{creationDate}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#0f0b1a] border border-white/10 rounded-2xl p-4 space-y-2">
|
||||||
|
<p className="text-xs uppercase text-[#9ca3af]">Total Credits Purchased</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{totalCreditsPurchased === null ? 'Loading…' : totalCreditsPurchased}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard/credits')}
|
||||||
|
className="w-full px-4 py-2 rounded-2xl bg-[#8b5cf6] text-white font-semibold shadow-2xl hover:bg-[#7c3aed] transition"
|
||||||
|
>
|
||||||
|
Go to Billing & Credits
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRestartTour}
|
||||||
|
className="w-full px-4 py-2 rounded-2xl bg-white/5 border border-white/10 text-white hover:bg-white/10 transition"
|
||||||
|
>
|
||||||
|
Restart Tour
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #2B1B47;
|
||||||
|
color: #D4C5F9;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { AuthProvider } from "@/contexts/AuthContext";
|
||||||
|
import ToasterProvider from "@/app/toaster";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "HolaCompi",
|
||||||
|
description: "Your AI buddy in Spain",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
suppressHydrationWarning
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
<AuthProvider>
|
||||||
|
{children}
|
||||||
|
<ToasterProvider />
|
||||||
|
</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { signInWithEmailAndPassword } from 'firebase/auth';
|
||||||
|
import { auth } from '@/lib/firebase';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signInWithEmailAndPassword(auth, email, password);
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-gray-800 p-8 rounded-lg shadow-lg w-full max-w-md">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-6">Login to HolaCompi</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500 text-red-500 p-3 rounded mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-300 mb-2">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full bg-gray-700 text-white rounded px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-300 mb-2">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full bg-gray-700 text-white rounded px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 rounded transition"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-gray-400 mt-4 text-center">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<a href="/register" className="text-blue-500 hover:underline">
|
||||||
|
Register
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/dashboard/chat');
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState, FormEvent } from 'react';
|
||||||
|
import { getAuth, updateProfile, updatePassword } from 'firebase/auth';
|
||||||
|
import { app } from '@/lib/firebase';
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !user) {
|
||||||
|
router.push('/auth');
|
||||||
|
}
|
||||||
|
if (user) {
|
||||||
|
setName(user.name || '');
|
||||||
|
}
|
||||||
|
}, [user, isLoading, router]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#050316] text-white">
|
||||||
|
<div className="animate-pulse text-2xl">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const handleUpdateProfile = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = getAuth(app);
|
||||||
|
if (auth.currentUser && name !== user.name) {
|
||||||
|
await updateProfile(auth.currentUser, { displayName: name });
|
||||||
|
setMessage({ type: 'success', text: 'Profile updated successfully!' });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setMessage({ type: 'error', text: error.message || 'Failed to update profile' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setMessage({ type: 'error', text: 'Passwords do not match' });
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setMessage({ type: 'error', text: 'Password must be at least 6 characters' });
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = getAuth(app);
|
||||||
|
if (auth.currentUser) {
|
||||||
|
await updatePassword(auth.currentUser, newPassword);
|
||||||
|
setMessage({ type: 'success', text: 'Password changed successfully!' });
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'auth/requires-recent-login') {
|
||||||
|
setMessage({ type: 'error', text: 'Please log out and log back in to change your password' });
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: error.message || 'Failed to change password' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#050316] text-white">
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="border-b border-white/10 bg-white/5 backdrop-blur-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<div className="flex items-center space-x-8">
|
||||||
|
<h1 className="text-2xl font-bold">HolaCompi</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold mb-2">Account Settings</h2>
|
||||||
|
<p className="text-gray-400">Manage your profile and account preferences</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success/Error Message */}
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg px-4 py-3 ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'bg-green-500/10 border border-green-500/20 text-green-400'
|
||||||
|
: 'bg-red-500/10 border border-red-500/20 text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Account Info */}
|
||||||
|
<div className="bg-white/5 rounded-2xl border border-white/10 p-6">
|
||||||
|
<h3 className="text-xl font-semibold mb-4">Account Information</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-400">Email</label>
|
||||||
|
<p className="text-white">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-400">User ID</label>
|
||||||
|
<p className="text-white font-mono text-xs">{user.uid}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Settings */}
|
||||||
|
<div className="bg-white/5 rounded-2xl border border-white/10 p-6">
|
||||||
|
<h3 className="text-xl font-semibold mb-4">Profile Settings</h3>
|
||||||
|
<form onSubmit={handleUpdateProfile} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-2">Display Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full rounded-lg px-4 py-3 bg-black/30 border border-white/10 text-white outline-none focus:border-purple-400 transition-colors"
|
||||||
|
placeholder="Enter your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving || name === user.name}
|
||||||
|
className="px-6 py-2.5 rounded-lg bg-purple-500 hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Change Password */}
|
||||||
|
<div className="bg-white/5 rounded-2xl border border-white/10 p-6">
|
||||||
|
<h3 className="text-xl font-semibold mb-4">Change Password</h3>
|
||||||
|
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-2">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className="w-full rounded-lg px-4 py-3 bg-black/30 border border-white/10 text-white outline-none focus:border-purple-400 transition-colors"
|
||||||
|
placeholder="Enter new password (min 6 characters)"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-2">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="w-full rounded-lg px-4 py-3 bg-black/30 border border-white/10 text-white outline-none focus:border-purple-400 transition-colors"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="px-6 py-2.5 rounded-lg bg-red-500 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? 'Changing...' : 'Change Password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function ToasterProvider() {
|
||||||
|
return (
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
background: '#1a1625',
|
||||||
|
color: '#fff',
|
||||||
|
border: '1px solid rgba(139,92,246,0.35)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { getAuth, createUserWithEmailAndPassword, signInWithEmailAndPassword, updateProfile, signOut } from 'firebase/auth';
|
||||||
|
import { app } from '@/lib/firebase';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
uid: string;
|
||||||
|
email: string | null;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (email: string, password: string, name: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const auth = getAuth(app);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('Setting up auth listener...');
|
||||||
|
const unsubscribe = auth.onAuthStateChanged((firebaseUser) => {
|
||||||
|
console.log('Auth state changed:', firebaseUser?.email || 'null');
|
||||||
|
if (firebaseUser) {
|
||||||
|
setUser({
|
||||||
|
uid: firebaseUser.uid,
|
||||||
|
email: firebaseUser.email,
|
||||||
|
name: firebaseUser.displayName,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [auth]);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await signInWithEmailAndPassword(auth, email, password);
|
||||||
|
} catch (error: any) {
|
||||||
|
setIsLoading(false);
|
||||||
|
throw new Error(error.message || 'Login failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (email: string, password: string, name: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
|
||||||
|
await updateProfile(userCredential.user, { displayName: name });
|
||||||
|
} catch (error: any) {
|
||||||
|
setIsLoading(false);
|
||||||
|
throw new Error(error.message || 'Registration failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await signOut(auth);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { getAuth, createUserWithEmailAndPassword, signInWithEmailAndPassword, updateProfile, signOut } from 'firebase/auth';
|
||||||
|
import { app } from '@/lib/firebase';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
uid: string;
|
||||||
|
email: string | null;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (email: string, password: string, name: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const auth = getAuth(app);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen to auth state changes
|
||||||
|
const unsubscribe = auth.onAuthStateChanged((firebaseUser) => {
|
||||||
|
if (firebaseUser) {
|
||||||
|
setUser({
|
||||||
|
uid: firebaseUser.uid,
|
||||||
|
email: firebaseUser.email,
|
||||||
|
name: firebaseUser.displayName,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [auth]);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await signInWithEmailAndPassword(auth, email, password);
|
||||||
|
} catch (error: any) {
|
||||||
|
setIsLoading(false);
|
||||||
|
throw new Error(error.message || 'Login failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (email: string, password: string, name: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
|
||||||
|
await updateProfile(userCredential.user, { displayName: name });
|
||||||
|
// User state will be updated by onAuthStateChanged listener
|
||||||
|
} catch (error: any) {
|
||||||
|
setIsLoading(false);
|
||||||
|
throw new Error(error.message || 'Registration failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await signOut(auth);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { initializeVapi, vapiConfig } from '@/lib/vapi';
|
||||||
|
|
||||||
|
export type VapiCallStatus = 'idle' | 'connecting' | 'active' | 'ended' | 'error';
|
||||||
|
|
||||||
|
interface CallStats {
|
||||||
|
durationSeconds: number;
|
||||||
|
creditsUsed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatError = (error: unknown) => {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (typeof error === 'string') return error;
|
||||||
|
return 'Something went wrong starting the call.';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useVapi = () => {
|
||||||
|
const clientRef = useRef<any>(null);
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [status, setStatus] = useState<VapiCallStatus>('idle');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [durationSeconds, setDurationSeconds] = useState(0);
|
||||||
|
const [callStats, setCallStats] = useState<CallStats | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'active') {
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setDurationSeconds((prev) => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const startCall = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
setCallStats(null);
|
||||||
|
setDurationSeconds(0);
|
||||||
|
setStatus('connecting');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
clientRef.current = initializeVapi();
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantId = vapiConfig.assistantId;
|
||||||
|
if (!assistantId) {
|
||||||
|
throw new Error('Missing NEXT_PUBLIC_VAPI_ASSISTANT_ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = clientRef.current;
|
||||||
|
const response =
|
||||||
|
(await client?.start?.({ assistantId })) ??
|
||||||
|
(await client?.startCall?.({ assistantId })) ??
|
||||||
|
(await client?.call?.({ assistantId }));
|
||||||
|
|
||||||
|
if (response?.id) {
|
||||||
|
// Future: store call id for status lookup
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('active');
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatError(err));
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const endCall = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const client = clientRef.current;
|
||||||
|
await client?.stop?.();
|
||||||
|
await client?.end?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatError(err));
|
||||||
|
} finally {
|
||||||
|
setStatus('ended');
|
||||||
|
setCallStats({
|
||||||
|
durationSeconds,
|
||||||
|
creditsUsed: Math.max(1, Math.round(durationSeconds / 60)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [durationSeconds]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
durationSeconds,
|
||||||
|
callStats,
|
||||||
|
startCall,
|
||||||
|
endCall,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { initializeApp, getApps, getApp } from 'firebase/app';
|
||||||
|
import { getAuth } from 'firebase/auth';
|
||||||
|
import { getFirestore } from 'firebase/firestore';
|
||||||
|
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
|
||||||
|
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,
|
||||||
|
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
|
||||||
|
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET!,
|
||||||
|
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID!,
|
||||||
|
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID!,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const app =
|
||||||
|
getApps().length === 0 ? initializeApp(firebaseConfig) : getApp();
|
||||||
|
|
||||||
|
export const auth = getAuth(app);
|
||||||
|
export const db = getFirestore(app);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { initializeApp, getApps, getApp } from 'firebase/app';
|
||||||
|
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
|
||||||
|
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,
|
||||||
|
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
|
||||||
|
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET!,
|
||||||
|
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID!,
|
||||||
|
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID!,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const app = !getApps().length ? initializeApp(firebaseConfig) : getApp();
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import {
|
||||||
|
collection,
|
||||||
|
doc,
|
||||||
|
getDoc,
|
||||||
|
getDocs,
|
||||||
|
setDoc,
|
||||||
|
updateDoc,
|
||||||
|
query,
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
Timestamp,
|
||||||
|
addDoc,
|
||||||
|
deleteDoc,
|
||||||
|
} from 'firebase/firestore';
|
||||||
|
import { db } from './firebase';
|
||||||
|
import type {
|
||||||
|
User,
|
||||||
|
AgentSettings,
|
||||||
|
CallTask,
|
||||||
|
CallAttempt,
|
||||||
|
CallRecord,
|
||||||
|
CreditTransaction,
|
||||||
|
NotificationSettings,
|
||||||
|
BookedAppointment,
|
||||||
|
DefaultLimits,
|
||||||
|
Purchase,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// Users
|
||||||
|
export const getUser = async (uid: string): Promise<User | null> => {
|
||||||
|
const docRef = doc(db, 'users', uid);
|
||||||
|
const docSnap = await getDoc(docRef);
|
||||||
|
return docSnap.exists() ? (docSnap.data() as User) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUserCredits = async (uid: string, newBalance: number) => {
|
||||||
|
const docRef = doc(db, 'users', uid);
|
||||||
|
await updateDoc(docRef, {
|
||||||
|
creditBalance: newBalance,
|
||||||
|
updatedAt: Timestamp.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUserOnboardingCompleted = async (uid: string, completed: boolean) => {
|
||||||
|
const docRef = doc(db, 'users', uid);
|
||||||
|
await updateDoc(docRef, {
|
||||||
|
onboardingCompleted: completed,
|
||||||
|
updatedAt: Timestamp.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agent Settings
|
||||||
|
export const getAgentSettings = async (userId: string): Promise<AgentSettings | null> => {
|
||||||
|
const docRef = doc(db, 'agentSettings', userId);
|
||||||
|
const docSnap = await getDoc(docRef);
|
||||||
|
if (docSnap.exists()) {
|
||||||
|
return docSnap.data() as AgentSettings;
|
||||||
|
}
|
||||||
|
// Create default if not exists
|
||||||
|
const defaults: AgentSettings = {
|
||||||
|
userId,
|
||||||
|
agentName: 'HolaCompi',
|
||||||
|
appLanguage: 'English',
|
||||||
|
primaryCallLanguage: 'English',
|
||||||
|
secondaryCallLanguages: [],
|
||||||
|
tone: 'Friendly',
|
||||||
|
agentInstructions: 'You are HolaCompi, a helpful Spanish friend who makes phone calls on behalf of users.',
|
||||||
|
createdAt: Timestamp.now(),
|
||||||
|
updatedAt: Timestamp.now(),
|
||||||
|
};
|
||||||
|
await setDoc(docRef, defaults);
|
||||||
|
return defaults;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateAgentSettings = async (userId: string, updates: Partial<AgentSettings>) => {
|
||||||
|
const docRef = doc(db, 'agentSettings', userId);
|
||||||
|
await updateDoc(docRef, {
|
||||||
|
...updates,
|
||||||
|
updatedAt: Timestamp.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call Tasks
|
||||||
|
export const createCallTask = async (task: Omit<CallTask, 'id'>): Promise<string> => {
|
||||||
|
const docRef = await addDoc(collection(db, 'callTasks'), task);
|
||||||
|
return docRef.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCallTasks = async (userId: string): Promise<CallTask[]> => {
|
||||||
|
const q = query(
|
||||||
|
collection(db, 'callTasks'),
|
||||||
|
where('userId', '==', userId),
|
||||||
|
orderBy('scheduledAt', 'desc')
|
||||||
|
);
|
||||||
|
const snapshot = await getDocs(q);
|
||||||
|
return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() } as CallTask));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCallTask = async (taskId: string): Promise<CallTask | null> => {
|
||||||
|
const docRef = doc(db, 'callTasks', taskId);
|
||||||
|
const docSnap = await getDoc(docRef);
|
||||||
|
return docSnap.exists() ? ({ id: docSnap.id, ...docSnap.data() } as CallTask) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCallTask = async (taskId: string, updates: Partial<CallTask>) => {
|
||||||
|
const docRef = doc(db, 'callTasks', taskId);
|
||||||
|
await updateDoc(docRef, {
|
||||||
|
...updates,
|
||||||
|
updatedAt: Timestamp.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteCallTask = async (taskId: string) => {
|
||||||
|
await deleteDoc(doc(db, 'callTasks', taskId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call Attempts
|
||||||
|
export const createCallAttempt = async (
|
||||||
|
taskId: string,
|
||||||
|
attempt: Omit<CallAttempt, 'id'>
|
||||||
|
): Promise<string> => {
|
||||||
|
const docRef = await addDoc(
|
||||||
|
collection(db, 'callTasks', taskId, 'attempts'),
|
||||||
|
attempt
|
||||||
|
);
|
||||||
|
return docRef.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCallAttempts = async (taskId: string): Promise<CallAttempt[]> => {
|
||||||
|
const q = query(
|
||||||
|
collection(db, 'callTasks', taskId, 'attempts'),
|
||||||
|
orderBy('startTime', 'desc')
|
||||||
|
);
|
||||||
|
const snapshot = await getDocs(q);
|
||||||
|
return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() } as CallAttempt));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCallAttempt = async (
|
||||||
|
taskId: string,
|
||||||
|
attemptId: string,
|
||||||
|
updates: Partial<CallAttempt>
|
||||||
|
) => {
|
||||||
|
const docRef = doc(db, 'callTasks', taskId, 'attempts', attemptId);
|
||||||
|
await updateDoc(docRef, updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calls
|
||||||
|
export const createCallRecord = async (
|
||||||
|
record: Omit<CallRecord, 'id' | 'createdAt' | 'updatedAt'>
|
||||||
|
): Promise<string> => {
|
||||||
|
const docRef = await addDoc(collection(db, 'calls'), {
|
||||||
|
...record,
|
||||||
|
createdAt: Timestamp.now(),
|
||||||
|
updatedAt: Timestamp.now(),
|
||||||
|
});
|
||||||
|
return docRef.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCallRecord = async (
|
||||||
|
callId: string,
|
||||||
|
updates: Partial<CallRecord>
|
||||||
|
) => {
|
||||||
|
const docRef = doc(db, 'calls', callId);
|
||||||
|
await updateDoc(docRef, { ...updates, updatedAt: Timestamp.now() });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCallByVapiCallId = async (vapiCallId: string): Promise<CallRecord | null> => {
|
||||||
|
const q = query(collection(db, 'calls'), where('vapiCallId', '==', vapiCallId));
|
||||||
|
const snapshot = await getDocs(q);
|
||||||
|
if (snapshot.empty) return null;
|
||||||
|
const docSnap = snapshot.docs[0];
|
||||||
|
return { id: docSnap.id, ...(docSnap.data() as CallRecord) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Credit Transactions
|
||||||
|
export const createCreditTransaction = async (
|
||||||
|
transaction: Omit<CreditTransaction, 'id'>
|
||||||
|
): Promise<string> => {
|
||||||
|
const docRef = await addDoc(collection(db, 'creditLedger'), transaction);
|
||||||
|
return docRef.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCreditTransactions = async (userId: string): Promise<CreditTransaction[]> => {
|
||||||
|
const q = query(
|
||||||
|
collection(db, 'creditLedger'),
|
||||||
|
where('userId', '==', userId),
|
||||||
|
orderBy('createdAt', 'desc')
|
||||||
|
);
|
||||||
|
const snapshot = await getDocs(q);
|
||||||
|
return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() } as CreditTransaction));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Purchases
|
||||||
|
export const createPurchase = async (purchase: Omit<Purchase, 'id'>): Promise<string> => {
|
||||||
|
const docRef = await addDoc(collection(db, 'purchases'), purchase);
|
||||||
|
return docRef.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPurchasesByUser = async (userId: string): Promise<Purchase[]> => {
|
||||||
|
const q = query(
|
||||||
|
collection(db, 'purchases'),
|
||||||
|
where('userId', '==', userId),
|
||||||
|
orderBy('createdAt', 'desc')
|
||||||
|
);
|
||||||
|
const snapshot = await getDocs(q);
|
||||||
|
return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() } as Purchase));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Notification Settings
|
||||||
|
export const getNotificationSettings = async (userId: string): Promise<NotificationSettings> => {
|
||||||
|
const docRef = doc(db, 'notificationSettings', userId);
|
||||||
|
const docSnap = await getDoc(docRef);
|
||||||
|
if (docSnap.exists()) {
|
||||||
|
return docSnap.data() as NotificationSettings;
|
||||||
|
}
|
||||||
|
const defaults: NotificationSettings = {
|
||||||
|
userId,
|
||||||
|
notifyOnSuccess: true,
|
||||||
|
notifyOnFailure: true,
|
||||||
|
emailSummary: false,
|
||||||
|
autoAddToCalendar: false,
|
||||||
|
googleCalendarConnected: false,
|
||||||
|
iCloudCalendarConnected: false,
|
||||||
|
createdAt: Timestamp.now(),
|
||||||
|
updatedAt: Timestamp.now(),
|
||||||
|
};
|
||||||
|
await setDoc(docRef, defaults);
|
||||||
|
return defaults;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateNotificationSettings = async (
|
||||||
|
userId: string,
|
||||||
|
updates: Partial<NotificationSettings>
|
||||||
|
) => {
|
||||||
|
const docRef = doc(db, 'notificationSettings', userId);
|
||||||
|
await updateDoc(docRef, {
|
||||||
|
...updates,
|
||||||
|
updatedAt: Timestamp.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Booked Appointments
|
||||||
|
export const createBookedAppointment = async (
|
||||||
|
appointment: Omit<BookedAppointment, 'id'>
|
||||||
|
): Promise<string> => {
|
||||||
|
const docRef = await addDoc(collection(db, 'bookedAppointments'), appointment);
|
||||||
|
return docRef.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBookedAppointments = async (userId: string): Promise<BookedAppointment[]> => {
|
||||||
|
const q = query(
|
||||||
|
collection(db, 'bookedAppointments'),
|
||||||
|
where('userId', '==', userId),
|
||||||
|
orderBy('dateTime', 'asc')
|
||||||
|
);
|
||||||
|
const snapshot = await getDocs(q);
|
||||||
|
return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() } as BookedAppointment));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default Limits
|
||||||
|
export const getDefaultLimits = async (userId: string): Promise<DefaultLimits> => {
|
||||||
|
const docRef = doc(db, 'defaultLimits', userId);
|
||||||
|
const docSnap = await getDoc(docRef);
|
||||||
|
if (docSnap.exists()) {
|
||||||
|
return docSnap.data() as DefaultLimits;
|
||||||
|
}
|
||||||
|
const defaults: DefaultLimits = {
|
||||||
|
userId,
|
||||||
|
defaultMaxMinutes: 10,
|
||||||
|
defaultMaxCredits: 10,
|
||||||
|
allowAutoExtension: false,
|
||||||
|
maxExtraMinutes: 0,
|
||||||
|
defaultMaxRecalls: 2,
|
||||||
|
minDelayBetweenRecalls: 15,
|
||||||
|
updatedAt: Timestamp.now(),
|
||||||
|
};
|
||||||
|
await setDoc(docRef, defaults);
|
||||||
|
return defaults;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateDefaultLimits = async (userId: string, updates: Partial<DefaultLimits>) => {
|
||||||
|
const docRef = doc(db, 'defaultLimits', userId);
|
||||||
|
await updateDoc(docRef, {
|
||||||
|
...updates,
|
||||||
|
updatedAt: Timestamp.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
import { Timestamp } from 'firebase/firestore';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
uid: string;
|
||||||
|
email: string;
|
||||||
|
displayName?: string;
|
||||||
|
onboardingCompleted?: boolean;
|
||||||
|
creditBalance: number; // in EUR/credits
|
||||||
|
createdAt: Timestamp;
|
||||||
|
updatedAt: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSettings {
|
||||||
|
userId: string;
|
||||||
|
agentName: string; // default "HolaCompi"
|
||||||
|
appLanguage: string; // fixed "English" for v1
|
||||||
|
primaryCallLanguage: string; // default "English"
|
||||||
|
secondaryCallLanguages: string[]; // e.g., ["Spanish"]
|
||||||
|
tone: 'Friendly' | 'Professional' | 'Assertive';
|
||||||
|
agentInstructions: string; // system prompt
|
||||||
|
createdAt: Timestamp;
|
||||||
|
updatedAt: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallTask {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
businessName: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
preferredLanguage: string; // "English" or "Spanish"
|
||||||
|
callGoals: string; // multi-line text
|
||||||
|
scheduledAt: Timestamp;
|
||||||
|
timezone?: string;
|
||||||
|
maxMinutes: number;
|
||||||
|
maxCredits: number; // same as maxMinutes for v1
|
||||||
|
allowRecalls: boolean;
|
||||||
|
maxRecalls: number;
|
||||||
|
recallsUsed: number;
|
||||||
|
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||||
|
lastResult?: string;
|
||||||
|
createdAt: Timestamp;
|
||||||
|
updatedAt: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallAttempt {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
startTime: Timestamp;
|
||||||
|
endTime?: Timestamp;
|
||||||
|
duration: number; // minutes
|
||||||
|
creditsUsed: number;
|
||||||
|
status: 'in_progress' | 'completed' | 'hangup_other_party' | 'failed' | 'busy';
|
||||||
|
transcript?: string;
|
||||||
|
summary?: string;
|
||||||
|
vapiCallId?: string;
|
||||||
|
createdAt: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
status: 'initiated' | 'started' | 'ended' | 'failed';
|
||||||
|
vapiCallId?: string;
|
||||||
|
assistantId?: string;
|
||||||
|
scheduledTaskId?: string;
|
||||||
|
startedAt?: Timestamp;
|
||||||
|
endedAt?: Timestamp;
|
||||||
|
durationSeconds?: number;
|
||||||
|
creditsUsed?: number;
|
||||||
|
recordingUrl?: string;
|
||||||
|
transcript?: string;
|
||||||
|
createdAt: Timestamp;
|
||||||
|
updatedAt: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreditTransaction {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
amount: number; // positive for add, negative for debit
|
||||||
|
type: 'purchase' | 'call_debit' | 'refund';
|
||||||
|
callTaskId?: string;
|
||||||
|
callAttemptId?: string;
|
||||||
|
balanceAfter: number;
|
||||||
|
createdAt: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationSettings {
|
||||||
|
userId: string;
|
||||||
|
notifyOnSuccess: boolean;
|
||||||
|
notifyOnFailure: boolean;
|
||||||
|
emailSummary: boolean;
|
||||||
|
autoAddToCalendar: boolean;
|
||||||
|
googleCalendarConnected: boolean;
|
||||||
|
iCloudCalendarConnected: boolean;
|
||||||
|
createdAt: Timestamp;
|
||||||
|
updatedAt: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookedAppointment {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
callTaskId: string;
|
||||||
|
title: string;
|
||||||
|
dateTime: Timestamp;
|
||||||
|
location?: string;
|
||||||
|
phone?: string;
|
||||||
|
notes?: string;
|
||||||
|
calendarEventId?: string;
|
||||||
|
createdAt: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DefaultLimits {
|
||||||
|
userId: string;
|
||||||
|
defaultMaxMinutes: number;
|
||||||
|
defaultMaxCredits: number;
|
||||||
|
allowAutoExtension: boolean;
|
||||||
|
maxExtraMinutes: number;
|
||||||
|
defaultMaxRecalls: number;
|
||||||
|
minDelayBetweenRecalls: number; // minutes
|
||||||
|
updatedAt: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Purchase {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
credits: number;
|
||||||
|
amount: number; // EUR amount
|
||||||
|
currency: string;
|
||||||
|
stripeSessionId: string;
|
||||||
|
createdAt: Timestamp;
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import Vapi from '@vapi-ai/web';
|
||||||
|
|
||||||
|
export const vapiConfig = {
|
||||||
|
publicKey: process.env.NEXT_PUBLIC_VAPI_PUBLIC_KEY!,
|
||||||
|
// Vapi Assistant ID from dashboard
|
||||||
|
assistantId: process.env.NEXT_PUBLIC_VAPI_ASSISTANT_ID,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function initializeVapi() {
|
||||||
|
return new Vapi(vapiConfig.publicKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// Disable experimental React Compiler for now to keep build stable
|
||||||
|
reactCompiler: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+8950
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "holacompi",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"dev:classic": "next dev --bundler webpack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/generative-ai": "^0.24.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
|
"@vapi-ai/web": "^2.5.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"firebase": "^12.8.0",
|
||||||
|
"framer-motion": "^12.28.1",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
|
"next": "16.1.3",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-joyride": "^2.9.3",
|
||||||
|
"recharts": "^3.6.0",
|
||||||
|
"stripe": "^20.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.3",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'compi-purple': {
|
||||||
|
DEFAULT: '#A855F7',
|
||||||
|
dark: '#2B1B47',
|
||||||
|
light: '#D4C5F9',
|
||||||
|
},
|
||||||
|
'compi-bg': '#2B1B47',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
export interface User {
|
||||||
|
uid: string;
|
||||||
|
email: string;
|
||||||
|
displayName?: string;
|
||||||
|
credits: number;
|
||||||
|
createdAt: Date;
|
||||||
|
referralCode: string;
|
||||||
|
referredBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceRequest {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
serviceType: 'nie' | 'rental' | 'plumber' | 'restaurant' | 'other';
|
||||||
|
serviceLevel: 1 | 2 | 3 | 4 | 5;
|
||||||
|
status: 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
|
description: string;
|
||||||
|
createdAt: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
creditsUsed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceLevel {
|
||||||
|
level: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
credits: number;
|
||||||
|
features: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SERVICE_LEVELS: ServiceLevel[] = [
|
||||||
|
{
|
||||||
|
level: 1,
|
||||||
|
name: 'Find Options',
|
||||||
|
description: 'Get recommendations',
|
||||||
|
credits: 1,
|
||||||
|
features: ['AI-powered search', 'Top 3 recommendations', 'Basic info']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: 2,
|
||||||
|
name: 'Call For You',
|
||||||
|
description: 'We make the call',
|
||||||
|
credits: 2,
|
||||||
|
features: ['Everything in Level 1', 'AI calls on your behalf', 'Get availability']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: 3,
|
||||||
|
name: 'Book It',
|
||||||
|
description: 'Complete booking',
|
||||||
|
credits: 3,
|
||||||
|
features: ['Everything in Level 2', 'Complete booking', 'Confirmation details']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: 4,
|
||||||
|
name: 'Book It Premium',
|
||||||
|
description: 'Premium booking service',
|
||||||
|
credits: 4,
|
||||||
|
features: ['Everything in Level 3', 'Priority support', 'Cancellation handling']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: 5,
|
||||||
|
name: 'Full Follow-up',
|
||||||
|
description: 'Complete concierge',
|
||||||
|
credits: 5,
|
||||||
|
features: ['Everything in Level 4', 'Full follow-up', 'Issue resolution', '24/7 support']
|
||||||
|
}
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user