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:
125
package-lock.json
generated
125
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
109
src/app/api/appointments/route.ts
Normal file
109
src/app/api/appointments/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
93
src/app/api/daily/room/route.ts
Normal file
93
src/app/api/daily/room/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
297
src/app/dashboard/agenda/page.tsx
Normal file
297
src/app/dashboard/agenda/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
src/app/dashboard/sessao/[id]/page.tsx
Normal file
194
src/app/dashboard/sessao/[id]/page.tsx
Normal 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
130
src/lib/daily.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user