feat: Fase 3 - Sistema de vídeo chamadas

- Integração Daily.co para videochamadas
- API para criar/gerenciar salas de vídeo
- API de agendamentos (CRUD)
- Página de sessão com controles de vídeo/áudio
- Página de agenda com calendário visual
- Lista de próximos agendamentos
This commit is contained in:
2026-02-07 00:25:08 -03:00
parent b8d47b8a97
commit 0b5b2c7ae6
7 changed files with 949 additions and 0 deletions

125
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@daily-co/daily-js": "^0.87.0",
"@prisma/client": "^6.19.2",
"@stripe/stripe-js": "^8.7.0",
"bcrypt": "^6.0.0",
@@ -281,6 +282,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -329,6 +339,22 @@
"node": ">=6.9.0"
}
},
"node_modules/@daily-co/daily-js": {
"version": "0.87.0",
"resolved": "https://registry.npmjs.org/@daily-co/daily-js/-/daily-js-0.87.0.tgz",
"integrity": "sha512-hWHdBDvJwDeg8unz+XG9hD7xamuFi5Jmsk89ASATKL4fdTDHplpxi4PG9aaPXzBZYYLTjuxUje1K7B5uUR+gzw==",
"license": "BSD-2-Clause",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@sentry/browser": "^8.33.1",
"bowser": "^2.8.1",
"dequal": "^2.0.3",
"events": "^3.1.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
@@ -1374,6 +1400,81 @@
"dev": true,
"license": "MIT"
},
"node_modules/@sentry-internal/browser-utils": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.55.0.tgz",
"integrity": "sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "8.55.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.55.0.tgz",
"integrity": "sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "8.55.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.55.0.tgz",
"integrity": "sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "8.55.0",
"@sentry/core": "8.55.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.55.0.tgz",
"integrity": "sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "8.55.0",
"@sentry/core": "8.55.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry/browser": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.55.0.tgz",
"integrity": "sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "8.55.0",
"@sentry-internal/feedback": "8.55.0",
"@sentry-internal/replay": "8.55.0",
"@sentry-internal/replay-canvas": "8.55.0",
"@sentry/core": "8.55.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry/core": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.55.0.tgz",
"integrity": "sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==",
"license": "MIT",
"engines": {
"node": ">=14.18"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2602,6 +2703,12 @@
"node": ">= 18"
}
},
"node_modules/bowser": {
"version": "2.13.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz",
"integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -3033,6 +3140,15 @@
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"license": "MIT"
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
@@ -3760,6 +3876,15 @@
"node": ">=0.10.0"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@daily-co/daily-js": "^0.87.0",
"@prisma/client": "^6.19.2",
"@stripe/stripe-js": "^8.7.0",
"bcrypt": "^6.0.0",

View File

@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// Listar agendamentos
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autenticado' }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: { children: true },
});
if (!user) {
return NextResponse.json({ error: 'Usuário não encontrado' }, { status: 404 });
}
let appointments;
if (user.role === 'therapist') {
// Terapeuta vê seus agendamentos
appointments = await prisma.appointment.findMany({
where: { therapistId: user.id },
include: {
child: { include: { parent: true } },
therapist: true,
},
orderBy: { scheduledAt: 'asc' },
});
} else {
// Pai vê agendamentos dos filhos
const childIds = user.children.map(c => c.id);
appointments = await prisma.appointment.findMany({
where: { childId: { in: childIds } },
include: {
child: true,
therapist: true,
},
orderBy: { scheduledAt: 'asc' },
});
}
return NextResponse.json(appointments);
} catch (error) {
console.error('Erro ao listar agendamentos:', error);
return NextResponse.json(
{ error: 'Erro ao buscar agendamentos' },
{ status: 500 }
);
}
}
// Criar agendamento
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autenticado' }, { status: 401 });
}
// Verificar se é terapeuta
const user = await prisma.user.findUnique({
where: { id: session.user.id },
});
if (!user || (user.role !== 'therapist' && user.role !== 'admin')) {
return NextResponse.json({ error: 'Sem permissão' }, { status: 403 });
}
const body = await request.json();
const { childId, scheduledAt, duration = 50, notes } = body;
if (!childId || !scheduledAt) {
return NextResponse.json(
{ error: 'childId e scheduledAt são obrigatórios' },
{ status: 400 }
);
}
const appointment = await prisma.appointment.create({
data: {
childId,
therapistId: user.id,
scheduledAt: new Date(scheduledAt),
duration,
notes,
status: 'scheduled',
},
include: {
child: true,
therapist: true,
},
});
return NextResponse.json(appointment);
} catch (error) {
console.error('Erro ao criar agendamento:', error);
return NextResponse.json(
{ error: 'Erro ao criar agendamento' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { createRoom, createMeetingToken, getRoom } from '@/lib/daily';
// Criar sala para um agendamento
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autenticado' }, { status: 401 });
}
const body = await request.json();
const { appointmentId } = body;
if (!appointmentId) {
return NextResponse.json({ error: 'appointmentId é obrigatório' }, { status: 400 });
}
// Buscar agendamento
const appointment = await prisma.appointment.findUnique({
where: { id: appointmentId },
include: {
child: { include: { parent: true } },
therapist: true,
},
});
if (!appointment) {
return NextResponse.json({ error: 'Agendamento não encontrado' }, { status: 404 });
}
// Verificar se o usuário é o pai ou terapeuta
const isParent = appointment.child.parentId === session.user.id;
const isTherapist = appointment.therapistId === session.user.id;
if (!isParent && !isTherapist) {
return NextResponse.json({ error: 'Sem permissão' }, { status: 403 });
}
// Se já tem sala, retornar
if (appointment.roomUrl && appointment.roomName) {
const existingRoom = await getRoom(appointment.roomName);
if (existingRoom) {
const token = await createMeetingToken(appointment.roomName, {
userName: session.user.name || 'Participante',
isOwner: isTherapist,
});
return NextResponse.json({
roomUrl: `${appointment.roomUrl}?t=${token.token}`,
roomName: appointment.roomName,
});
}
}
// Criar nova sala
const roomName = `iris-${appointmentId}`;
const room = await createRoom({
name: roomName,
expiryMinutes: 90, // Sessão de 50min + buffer
maxParticipants: 4,
});
// Atualizar agendamento
await prisma.appointment.update({
where: { id: appointmentId },
data: {
roomUrl: room.url,
roomName: room.name,
},
});
// Criar token
const token = await createMeetingToken(room.name, {
userName: session.user.name || 'Participante',
isOwner: isTherapist,
});
return NextResponse.json({
roomUrl: `${room.url}?t=${token.token}`,
roomName: room.name,
});
} catch (error) {
console.error('Erro ao criar sala:', error);
return NextResponse.json(
{ error: 'Erro ao criar sala de vídeo' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,297 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useSession } from 'next-auth/react';
import {
Sparkles, ArrowLeft, Calendar, Clock, Video, User,
ChevronLeft, ChevronRight, Plus, Loader2
} from 'lucide-react';
interface Appointment {
id: string;
scheduledAt: string;
duration: number;
status: string;
roomUrl: string | null;
child: {
id: string;
name: string;
};
therapist: {
id: string;
name: string;
};
}
export default function AgendaPage() {
const { data: session } = useSession();
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [loading, setLoading] = useState(true);
const [currentDate, setCurrentDate] = useState(new Date());
useEffect(() => {
fetchAppointments();
}, []);
const fetchAppointments = async () => {
try {
const response = await fetch('/api/appointments');
if (response.ok) {
const data = await response.json();
setAppointments(data);
}
} catch (error) {
console.error('Erro ao buscar agendamentos:', error);
} finally {
setLoading(false);
}
};
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear();
const month = date.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDay = firstDay.getDay();
const days = [];
// Dias do mês anterior
for (let i = 0; i < startingDay; i++) {
const prevDate = new Date(year, month, -startingDay + i + 1);
days.push({ date: prevDate, isCurrentMonth: false });
}
// Dias do mês atual
for (let i = 1; i <= daysInMonth; i++) {
days.push({ date: new Date(year, month, i), isCurrentMonth: true });
}
// Dias do próximo mês
const remainingDays = 42 - days.length;
for (let i = 1; i <= remainingDays; i++) {
days.push({ date: new Date(year, month + 1, i), isCurrentMonth: false });
}
return days;
};
const getAppointmentsForDate = (date: Date) => {
return appointments.filter(apt => {
const aptDate = new Date(apt.scheduledAt);
return aptDate.toDateString() === date.toDateString();
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
});
};
const isToday = (date: Date) => {
const today = new Date();
return date.toDateString() === today.toDateString();
};
const isFuture = (dateStr: string) => {
return new Date(dateStr) > new Date();
};
const prevMonth = () => {
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1));
};
const nextMonth = () => {
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1));
};
const monthYear = currentDate.toLocaleDateString('pt-BR', {
month: 'long',
year: 'numeric',
});
const days = getDaysInMonth(currentDate);
const weekDays = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'];
// Próximos agendamentos
const upcomingAppointments = appointments
.filter(apt => isFuture(apt.scheduledAt))
.slice(0, 5);
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200 px-4 lg:px-8 py-4">
<div className="max-w-7xl mx-auto flex items-center gap-4">
<Link href="/dashboard" className="text-gray-400 hover:text-gray-600">
<ArrowLeft className="w-6 h-6" />
</Link>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl gradient-rainbow flex items-center justify-center">
<Calendar className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">Agenda</h1>
<p className="text-sm text-gray-500">Seus agendamentos</p>
</div>
</div>
</div>
</header>
<div className="max-w-7xl mx-auto p-4 lg:p-8">
<div className="grid lg:grid-cols-3 gap-8">
{/* Calendar */}
<div className="lg:col-span-2">
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-gray-900 capitalize">
{monthYear}
</h2>
<div className="flex gap-2">
<button
onClick={prevMonth}
className="p-2 hover:bg-gray-100 rounded-lg transition"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
onClick={nextMonth}
className="p-2 hover:bg-gray-100 rounded-lg transition"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
{/* Week days header */}
<div className="grid grid-cols-7 mb-2">
{weekDays.map(day => (
<div key={day} className="text-center text-sm font-medium text-gray-500 py-2">
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{days.map((day, i) => {
const dayAppointments = getAppointmentsForDate(day.date);
const hasAppointments = dayAppointments.length > 0;
return (
<div
key={i}
className={`aspect-square p-1 ${
day.isCurrentMonth ? 'bg-white' : 'bg-gray-50'
}`}
>
<div
className={`h-full rounded-lg p-1 ${
isToday(day.date)
? 'bg-indigo-100 text-indigo-600'
: day.isCurrentMonth
? 'hover:bg-gray-50'
: 'text-gray-400'
}`}
>
<span className={`text-sm font-medium ${
isToday(day.date) ? 'bg-indigo-600 text-white rounded-full w-6 h-6 flex items-center justify-center' : ''
}`}>
{day.date.getDate()}
</span>
{hasAppointments && (
<div className="mt-1 space-y-0.5">
{dayAppointments.slice(0, 2).map(apt => (
<div
key={apt.id}
className="text-xs bg-indigo-500 text-white rounded px-1 py-0.5 truncate"
>
{formatTime(apt.scheduledAt)}
</div>
))}
{dayAppointments.length > 2 && (
<div className="text-xs text-gray-500">
+{dayAppointments.length - 2}
</div>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Upcoming appointments */}
<div className="lg:col-span-1">
<div className="bg-white rounded-xl shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Próximas Sessões
</h2>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 text-indigo-500 animate-spin" />
</div>
) : upcomingAppointments.length === 0 ? (
<div className="text-center py-8">
<Calendar className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">Nenhuma sessão agendada</p>
</div>
) : (
<div className="space-y-4">
{upcomingAppointments.map(apt => (
<div key={apt.id} className="p-4 bg-gray-50 rounded-xl">
<div className="flex items-start justify-between mb-2">
<div>
<p className="font-medium text-gray-900">
{apt.child.name}
</p>
<p className="text-sm text-gray-500">
com {apt.therapist.name}
</p>
</div>
<span className={`text-xs px-2 py-1 rounded-full ${
apt.status === 'scheduled'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600'
}`}>
{apt.status === 'scheduled' ? 'Agendado' : apt.status}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500 mb-3">
<span className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
{new Date(apt.scheduledAt).toLocaleDateString('pt-BR')}
</span>
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{formatTime(apt.scheduledAt)}
</span>
</div>
<Link
href={`/dashboard/sessao/${apt.id}`}
className="flex items-center justify-center gap-2 w-full bg-indigo-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-indigo-700 transition"
>
<Video className="w-4 h-4" />
Entrar na Sessão
</Link>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,194 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import {
Sparkles, ArrowLeft, Video, VideoOff, Mic, MicOff,
Phone, Settings, MessageCircle, Users, Loader2
} from 'lucide-react';
export default function SessaoPage() {
const params = useParams();
const router = useRouter();
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [roomUrl, setRoomUrl] = useState<string | null>(null);
const [callFrame, setCallFrame] = useState<any>(null);
const [videoEnabled, setVideoEnabled] = useState(true);
const [audioEnabled, setAudioEnabled] = useState(true);
const appointmentId = params.id as string;
const initializeCall = useCallback(async () => {
try {
setLoading(true);
// Buscar/criar sala
const response = await fetch('/api/daily/room', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appointmentId }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Erro ao conectar');
}
const { roomUrl } = await response.json();
setRoomUrl(roomUrl);
// Carregar Daily.co iframe
const DailyIframe = (await import('@daily-co/daily-js')).default;
const frame = DailyIframe.createFrame(
document.getElementById('call-container')!,
{
iframeStyle: {
width: '100%',
height: '100%',
border: '0',
borderRadius: '12px',
},
showLeaveButton: true,
showFullscreenButton: true,
}
);
await frame.join({ url: roomUrl });
setCallFrame(frame);
frame.on('left-meeting', () => {
router.push('/dashboard');
});
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}, [appointmentId, router]);
useEffect(() => {
initializeCall();
return () => {
if (callFrame) {
callFrame.destroy();
}
};
}, [initializeCall]);
const toggleVideo = () => {
if (callFrame) {
callFrame.setLocalVideo(!videoEnabled);
setVideoEnabled(!videoEnabled);
}
};
const toggleAudio = () => {
if (callFrame) {
callFrame.setLocalAudio(!audioEnabled);
setAudioEnabled(!audioEnabled);
}
};
const endCall = () => {
if (callFrame) {
callFrame.leave();
}
router.push('/dashboard');
};
if (loading) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-12 h-12 text-indigo-500 animate-spin mx-auto mb-4" />
<p className="text-white text-lg">Conectando à sessão...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-8">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Video className="w-8 h-8 text-red-500" />
</div>
<h1 className="text-xl font-semibold text-white mb-2">Erro ao conectar</h1>
<p className="text-gray-400 mb-6">{error}</p>
<Link
href="/dashboard"
className="inline-flex items-center gap-2 text-indigo-400 hover:text-indigo-300"
>
<ArrowLeft className="w-4 h-4" />
Voltar ao Dashboard
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-900 flex flex-col">
{/* Header */}
<header className="bg-gray-800 border-b border-gray-700 px-4 py-3">
<div className="flex items-center justify-between max-w-7xl mx-auto">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg gradient-rainbow flex items-center justify-center">
<Sparkles className="w-5 h-5 text-white" />
</div>
<span className="text-white font-semibold">Sessão de Terapia</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Conectado
</div>
</div>
</header>
{/* Video Container */}
<div className="flex-1 p-4">
<div id="call-container" className="w-full h-full min-h-[70vh] bg-gray-800 rounded-xl" />
</div>
{/* Controls */}
<div className="bg-gray-800 border-t border-gray-700 px-4 py-4">
<div className="flex items-center justify-center gap-4 max-w-md mx-auto">
<button
onClick={toggleAudio}
className={`w-14 h-14 rounded-full flex items-center justify-center transition ${
audioEnabled
? 'bg-gray-700 text-white hover:bg-gray-600'
: 'bg-red-500 text-white hover:bg-red-600'
}`}
>
{audioEnabled ? <Mic className="w-6 h-6" /> : <MicOff className="w-6 h-6" />}
</button>
<button
onClick={toggleVideo}
className={`w-14 h-14 rounded-full flex items-center justify-center transition ${
videoEnabled
? 'bg-gray-700 text-white hover:bg-gray-600'
: 'bg-red-500 text-white hover:bg-red-600'
}`}
>
{videoEnabled ? <Video className="w-6 h-6" /> : <VideoOff className="w-6 h-6" />}
</button>
<button
onClick={endCall}
className="w-14 h-14 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition"
>
<Phone className="w-6 h-6 rotate-135" />
</button>
</div>
</div>
</div>
);
}

130
src/lib/daily.ts Normal file
View File

@@ -0,0 +1,130 @@
const DAILY_API_KEY = process.env.DAILY_API_KEY;
const DAILY_API_URL = 'https://api.daily.co/v1';
interface DailyRoom {
id: string;
name: string;
url: string;
created_at: string;
config: {
exp?: number;
nbf?: number;
max_participants?: number;
enable_chat?: boolean;
enable_knocking?: boolean;
start_video_off?: boolean;
start_audio_off?: boolean;
};
}
interface CreateRoomOptions {
name?: string;
expiryMinutes?: number;
maxParticipants?: number;
}
export async function createRoom(options: CreateRoomOptions = {}): Promise<DailyRoom> {
const { name, expiryMinutes = 60, maxParticipants = 4 } = options;
const roomName = name || `sessao-${Date.now()}`;
const exp = Math.floor(Date.now() / 1000) + expiryMinutes * 60;
const response = await fetch(`${DAILY_API_URL}/rooms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${DAILY_API_KEY}`,
},
body: JSON.stringify({
name: roomName,
properties: {
exp,
max_participants: maxParticipants,
enable_chat: true,
enable_knocking: true,
start_video_off: false,
start_audio_off: false,
enable_screenshare: true,
enable_recording: 'cloud',
eject_at_room_exp: true,
},
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erro ao criar sala');
}
return response.json();
}
export async function getRoom(roomName: string): Promise<DailyRoom | null> {
const response = await fetch(`${DAILY_API_URL}/rooms/${roomName}`, {
headers: {
Authorization: `Bearer ${DAILY_API_KEY}`,
},
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error('Erro ao buscar sala');
}
return response.json();
}
export async function deleteRoom(roomName: string): Promise<void> {
const response = await fetch(`${DAILY_API_URL}/rooms/${roomName}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${DAILY_API_KEY}`,
},
});
if (!response.ok && response.status !== 404) {
throw new Error('Erro ao deletar sala');
}
}
interface MeetingToken {
token: string;
}
export async function createMeetingToken(
roomName: string,
options: {
userName: string;
isOwner?: boolean;
expiryMinutes?: number;
}
): Promise<MeetingToken> {
const { userName, isOwner = false, expiryMinutes = 60 } = options;
const exp = Math.floor(Date.now() / 1000) + expiryMinutes * 60;
const response = await fetch(`${DAILY_API_URL}/meeting-tokens`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${DAILY_API_KEY}`,
},
body: JSON.stringify({
properties: {
room_name: roomName,
user_name: userName,
is_owner: isOwner,
exp,
enable_screenshare: true,
start_video_off: false,
start_audio_off: false,
},
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erro ao criar token');
}
return response.json();
}