develop #83

Merged
M-Gabrielly merged 426 commits from develop into main 2025-12-04 04:13:15 +00:00
18 changed files with 1524 additions and 1388 deletions
Showing only changes of commit 84cc56b017 - Show all commits

14
susconecta/.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,14 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Build Next.js susconecta",
"type": "shell",
"command": "npm run build",
"problemMatcher": [
"$tsc"
],
"group": "build"
}
]
}

View File

@ -53,7 +53,7 @@ export default function PacientesPage() {
async function loadAll() { async function loadAll() {
try { try {
setLoading(true); setLoading(true);
const data = await listarPacientes({ page: 1, limit: 20 }); const data = await listarPacientes({ page: 1, limit: 50 });
if (Array.isArray(data)) { if (Array.isArray(data)) {
setPatients(data.map(normalizePaciente)); setPatients(data.map(normalizePaciente));

View File

@ -1,42 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { ENV_CONFIG } from '@/lib/env-config'
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => ({}))
const target = `${ENV_CONFIG.SUPABASE_URL}/functions/v1/create-user`
const headers: Record<string,string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'apikey': ENV_CONFIG.SUPABASE_ANON_KEY,
}
const auth = req.headers.get('authorization')
if (auth) headers.Authorization = auth
const r = await fetch(target, { method: 'POST', headers, body: JSON.stringify(body) })
if (r.status === 404 || r.status >= 500) {
// fallback to signup
const email = body.email
let password = body.password
const full_name = body.full_name
const phone = body.phone
const role = body.role || (Array.isArray(body.roles) ? body.roles[0] : undefined)
if (!password) password = `senha${Math.floor(Math.random()*900)+100}!`
const userType = (role && String(role).toLowerCase() === 'paciente') ? 'paciente' : 'profissional'
const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup`
const signupRes = await fetch(signupUrl, {
method: 'POST',
headers: { 'Content-Type':'application/json', 'Accept':'application/json', 'apikey': ENV_CONFIG.SUPABASE_ANON_KEY },
body: JSON.stringify({ email, password, data: { userType, full_name, phone } })
})
const text = await signupRes.text()
try { return NextResponse.json({ fallback: true, from: 'signup', result: JSON.parse(text) }, { status: signupRes.status }) } catch { return new NextResponse(text, { status: signupRes.status }) }
}
const text = await r.text()
try { return NextResponse.json(JSON.parse(text), { status: r.status }) } catch { return new NextResponse(text, { status: r.status }) }
} catch (err:any) {
console.error('[app/api/create-user] error', err)
return NextResponse.json({ error: 'Bad gateway', details: String(err) }, { status: 502 })
}
}

View File

@ -3,6 +3,7 @@ import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
import { sendMagicLink } from '@/lib/api'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -12,6 +13,9 @@ import { AuthenticationError } from '@/lib/auth'
export default function LoginAdminPage() { export default function LoginAdminPage() {
const [credentials, setCredentials] = useState({ email: '', password: '' }) const [credentials, setCredentials] = useState({ email: '', password: '' })
const [error, setError] = useState('') const [error, setError] = useState('')
const [magicMessage, setMagicMessage] = useState('')
const [magicError, setMagicError] = useState('')
const [magicLoading, setMagicLoading] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const router = useRouter() const router = useRouter()
const { login } = useAuth() const { login } = useAuth()
@ -44,6 +48,27 @@ export default function LoginAdminPage() {
} }
} }
const handleSendMagicLink = async () => {
if (!credentials.email) {
setMagicError('Por favor, preencha o email antes de solicitar o magic link.')
return
}
setMagicLoading(true)
setMagicError('')
setMagicMessage('')
try {
const res = await sendMagicLink(credentials.email, { target: 'admin' })
setMagicMessage(res?.message ?? 'Magic link enviado. Verifique seu email.')
} catch (err: any) {
console.error('[MAGIC-LINK ADMIN] erro ao enviar:', err)
setMagicError(err?.message ?? String(err))
} finally {
setMagicLoading(false)
}
}
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8"> <div className="max-w-md w-full space-y-8">
@ -108,6 +133,25 @@ export default function LoginAdminPage() {
{loading ? 'Entrando...' : 'Entrar no Sistema Administrativo'} {loading ? 'Entrando...' : 'Entrar no Sistema Administrativo'}
</Button> </Button>
</form> </form>
<div className="mt-4 space-y-2">
<div className="text-sm text-muted-foreground mb-2">Ou entre usando um magic link (sem senha)</div>
{magicError && (
<Alert variant="destructive">
<AlertDescription>{magicError}</AlertDescription>
</Alert>
)}
{magicMessage && (
<Alert>
<AlertDescription>{magicMessage}</AlertDescription>
</Alert>
)}
<Button className="w-full" onClick={handleSendMagicLink} disabled={magicLoading}>
{magicLoading ? 'Enviando magic link...' : 'Enviar magic link'}
</Button>
</div>
<div className="mt-4 text-center"> <div className="mt-4 text-center">
<Button variant="outline" asChild className="w-full hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200"> <Button variant="outline" asChild className="w-full hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200">

View File

@ -3,6 +3,7 @@ import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
import { sendMagicLink } from '@/lib/api'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -12,6 +13,9 @@ import { AuthenticationError } from '@/lib/auth'
export default function LoginPacientePage() { export default function LoginPacientePage() {
const [credentials, setCredentials] = useState({ email: '', password: '' }) const [credentials, setCredentials] = useState({ email: '', password: '' })
const [error, setError] = useState('') const [error, setError] = useState('')
const [magicMessage, setMagicMessage] = useState('')
const [magicError, setMagicError] = useState('')
const [magicLoading, setMagicLoading] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const router = useRouter() const router = useRouter()
const { login } = useAuth() const { login } = useAuth()
@ -51,6 +55,27 @@ export default function LoginPacientePage() {
} }
} }
const handleSendMagicLink = async () => {
if (!credentials.email) {
setMagicError('Por favor, preencha o email antes de solicitar o magic link.')
return
}
setMagicLoading(true)
setMagicError('')
setMagicMessage('')
try {
const res = await sendMagicLink(credentials.email, { target: 'paciente' })
setMagicMessage(res?.message ?? 'Magic link enviado. Verifique seu email.')
} catch (err: any) {
console.error('[MAGIC-LINK PACIENTE] erro ao enviar:', err)
setMagicError(err?.message ?? String(err))
} finally {
setMagicLoading(false)
}
}
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8"> <div className="max-w-md w-full space-y-8">
@ -115,6 +140,25 @@ export default function LoginPacientePage() {
{loading ? 'Entrando...' : 'Entrar na Minha Área'} {loading ? 'Entrando...' : 'Entrar na Minha Área'}
</Button> </Button>
</form> </form>
<div className="mt-4 space-y-2">
<div className="text-sm text-muted-foreground mb-2">Ou entre usando um magic link (sem senha)</div>
{magicError && (
<Alert variant="destructive">
<AlertDescription>{magicError}</AlertDescription>
</Alert>
)}
{magicMessage && (
<Alert>
<AlertDescription>{magicMessage}</AlertDescription>
</Alert>
)}
<Button className="w-full" onClick={handleSendMagicLink} disabled={magicLoading}>
{magicLoading ? 'Enviando magic link...' : 'Enviar magic link'}
</Button>
</div>
<div className="mt-4 text-center"> <div className="mt-4 text-center">
<Button variant="outline" asChild className="w-full hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200"> <Button variant="outline" asChild className="w-full hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200">

View File

@ -3,6 +3,7 @@ import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
import { sendMagicLink } from '@/lib/api'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -12,6 +13,9 @@ import { AuthenticationError } from '@/lib/auth'
export default function LoginPage() { export default function LoginPage() {
const [credentials, setCredentials] = useState({ email: '', password: '' }) const [credentials, setCredentials] = useState({ email: '', password: '' })
const [error, setError] = useState('') const [error, setError] = useState('')
const [magicMessage, setMagicMessage] = useState('')
const [magicError, setMagicError] = useState('')
const [magicLoading, setMagicLoading] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const router = useRouter() const router = useRouter()
const { login } = useAuth() const { login } = useAuth()
@ -53,6 +57,28 @@ export default function LoginPage() {
} }
} }
const handleSendMagicLink = async () => {
// basic client-side validation
if (!credentials.email) {
setMagicError('Por favor, preencha o email antes de solicitar o magic link.')
return
}
setMagicLoading(true)
setMagicError('')
setMagicMessage('')
try {
const res = await sendMagicLink(credentials.email, { target: 'medico' })
setMagicMessage(res?.message ?? 'Magic link enviado. Verifique seu email.')
} catch (err: any) {
console.error('[MAGIC-LINK] erro ao enviar:', err)
setMagicError(err?.message ?? String(err))
} finally {
setMagicLoading(false)
}
}
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8"> <div className="max-w-md w-full space-y-8">
@ -117,6 +143,25 @@ export default function LoginPage() {
{loading ? 'Entrando...' : 'Entrar'} {loading ? 'Entrando...' : 'Entrar'}
</Button> </Button>
</form> </form>
<div className="mt-4 space-y-2">
<div className="text-sm text-muted-foreground mb-2">Ou entre usando um magic link (sem senha)</div>
{magicError && (
<Alert variant="destructive">
<AlertDescription>{magicError}</AlertDescription>
</Alert>
)}
{magicMessage && (
<Alert>
<AlertDescription>{magicMessage}</AlertDescription>
</Alert>
)}
<Button className="w-full" onClick={handleSendMagicLink} disabled={magicLoading}>
{magicLoading ? 'Enviando magic link...' : 'Enviar magic link'}
</Button>
</div>
<div className="mt-4 text-center"> <div className="mt-4 text-center">
<Button variant="outline" asChild className="w-full hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200"> <Button variant="outline" asChild className="w-full hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200">

View File

@ -0,0 +1,499 @@
"use client"
import React, { useMemo, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Toggle } from '@/components/ui/toggle'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Building2,
Filter,
Globe,
HeartPulse,
Languages,
MapPin,
ShieldCheck,
Star,
Stethoscope,
ChevronRight,
UserRound
} from 'lucide-react'
import { cn } from '@/lib/utils'
type TipoConsulta = 'teleconsulta' | 'local'
type Medico = {
id: number
nome: string
especialidade: string
crm: string
categoriaHero: string
avaliacao: number
avaliacaoQtd: number
convenios: string[]
endereco?: string
bairro?: string
cidade?: string
precoLocal?: string
precoTeleconsulta?: string
atendeLocal: boolean
atendeTele: boolean
agenda: {
label: string
data: string
horarios: string[]
}[]
experiencia: string[]
planosSaude: string[]
consultorios: { nome: string; endereco: string; telefone: string }[]
servicos: { nome: string; preco: string }[]
opinioes: { id: number; paciente: string; data: string; nota: number; comentario: string }[]
}
type MedicoBase = Omit<Medico, 'experiencia' | 'planosSaude' | 'consultorios' | 'servicos' | 'opinioes'> &
Partial<Pick<Medico, 'experiencia' | 'planosSaude' | 'consultorios' | 'servicos' | 'opinioes'>>;
const especialidadesHero = ['Psicólogo', 'Médico clínico geral', 'Pediatra', 'Dentista', 'Ginecologista', 'Veja mais']
// NOTE: keep this mock local to component to avoid cross-file references
const medicosMock: Medico[] = [
{
id: 1,
nome: 'Paula Pontes',
especialidade: 'Psicóloga clínica',
crm: 'CRP SE 19/4244',
categoriaHero: 'Psicólogo',
avaliacao: 4.9,
avaliacaoQtd: 23,
convenios: ['Amil', 'Unimed'],
endereco: 'Av. Doutor José Machado de Souza, 200 - Jardins',
bairro: 'Jardins',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 180',
precoTeleconsulta: 'R$ 160',
atendeLocal: true,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: [] },
{ label: 'Amanhã', data: '10 Out', horarios: ['09:00', '10:00', '11:00', '12:00', '13:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['11:00', '12:00', '13:00', '14:00'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
],
experiencia: ['Atendimento clínico há 8 anos'],
planosSaude: ['Amil'],
consultorios: [],
servicos: [],
opinioes: []
}
]
export default function ResultadosClient() {
const params = useSearchParams()
const router = useRouter()
const [tipoConsulta, setTipoConsulta] = useState<TipoConsulta>(
params?.get('tipo') === 'presencial' ? 'local' : 'teleconsulta'
)
const [especialidadeHero, setEspecialidadeHero] = useState<string>(params?.get('especialidade') || 'Psicólogo')
const [convenio, setConvenio] = useState<string>('Todos')
const [bairro, setBairro] = useState<string>('Todos')
const [agendasExpandida, setAgendasExpandida] = useState<Record<number, boolean>>({})
const [medicoSelecionado, setMedicoSelecionado] = useState<Medico | null>(null)
const [abaDetalhe, setAbaDetalhe] = useState('experiencia')
const profissionais = useMemo(() => {
return medicosMock.filter(medico => {
if (tipoConsulta === 'local' && !medico.atendeLocal) return false
if (tipoConsulta === 'teleconsulta' && !medico.atendeTele) return false
if (convenio !== 'Todos' && !medico.convenios.includes(convenio)) return false
if (bairro !== 'Todos' && medico.bairro !== bairro) return false
if (especialidadeHero !== 'Veja mais' && medico.categoriaHero !== especialidadeHero) return false
if (especialidadeHero === 'Veja mais' && medico.categoriaHero !== 'Veja mais') return false
return true
})
}, [bairro, convenio, especialidadeHero, tipoConsulta])
const toggleBase =
'rounded-full px-4 py-[10px] text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]'
return (
<div className="min-h-screen bg-background">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-10 md:px-8">
<section className="rounded-3xl bg-primary p-6 text-primary-foreground shadow-lg">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold md:text-3xl">Resultados da procura</h1>
<p className="text-sm text-primary-foreground/80">Qual especialização você deseja?</p>
</div>
<Button
variant="outline"
className="rounded-full border-primary-foreground/30 bg-primary-foreground/10 text-primary-foreground hover:bg-primary-foreground hover:text-primary"
>
Ajustar filtros
</Button>
</div>
<div className="mt-6 flex flex-wrap gap-3">
{especialidadesHero.map(item => (
<button
key={item}
type="button"
onClick={() => setEspecialidadeHero(item)}
className={cn(
'rounded-full px-5 py-2 text-sm font-medium transition focus-visible:ring-2 focus-visible:ring-primary-foreground/80',
especialidadeHero === item ? 'bg-primary-foreground text-primary' : 'bg-primary-foreground/10'
)}
>
{item}
</button>
))}
</div>
</section>
<section className="sticky top-0 z-30 flex flex-wrap gap-3 rounded-2xl border border-border bg-card/90 p-4 shadow-lg backdrop-blur">
<Toggle
pressed={tipoConsulta === 'teleconsulta'}
onPressedChange={() => setTipoConsulta('teleconsulta')}
className={cn(toggleBase, tipoConsulta === 'teleconsulta' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')}
>
<Globe className="mr-2 h-4 w-4" />
Teleconsulta
</Toggle>
<Toggle
pressed={tipoConsulta === 'local'}
onPressedChange={() => setTipoConsulta('local')}
className={cn(toggleBase, tipoConsulta === 'local' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')}
>
<Building2 className="mr-2 h-4 w-4" />
Consulta no local
</Toggle>
<Select value={convenio} onValueChange={setConvenio}>
<SelectTrigger className="h-10 min-w-[180px] rounded-full border border-primary/40 bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground">
<SelectValue placeholder="Convênio" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Todos">Todos os convênios</SelectItem>
<SelectItem value="Amil">Amil</SelectItem>
<SelectItem value="Unimed">Unimed</SelectItem>
<SelectItem value="SulAmérica">SulAmérica</SelectItem>
<SelectItem value="Bradesco Saúde">Bradesco Saúde</SelectItem>
<SelectItem value="Particular">Particular</SelectItem>
</SelectContent>
</Select>
<Select value={bairro} onValueChange={setBairro}>
<SelectTrigger className="h-10 min-w-[160px] rounded-full border border-primary/40 bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground">
<SelectValue placeholder="Bairro" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Todos">Todos os bairros</SelectItem>
<SelectItem value="Centro">Centro</SelectItem>
<SelectItem value="Jardins">Jardins</SelectItem>
<SelectItem value="Farolândia">Farolândia</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
className="rounded-full border border-primary/40 bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground"
>
<Filter className="mr-2 h-4 w-4" />
Mais filtros
</Button>
<Button
variant="ghost"
className="ml-auto rounded-full text-primary hover:bg-primary/10"
onClick={() => router.back()}
>
Voltar
<ChevronRight className="ml-1 h-4 w-4 rotate-180" />
</Button>
</section>
<section className="space-y-4">
{profissionais.map(medico => (
<Card
key={medico.id}
className="flex flex-col gap-4 border border-border bg-card/80 p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-lg"
>
<div className="flex flex-wrap items-start gap-4">
<Avatar className="h-14 w-14 border border-primary/20 bg-primary/5">
<AvatarFallback className="bg-primary/10 text-primary">
<UserRound className="h-6 w-6" />
</AvatarFallback>
</Avatar>
<div className="flex flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-foreground">{medico.nome}</h2>
<Badge className="rounded-full bg-primary/10 text-primary">{medico.especialidade}</Badge>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-3 py-1 text-primary">
<Star className="h-4 w-4 fill-primary text-primary" />
{medico.avaliacao.toFixed(1)} {medico.avaliacaoQtd} avaliações
</span>
<span>{medico.crm}</span>
<span>{medico.convenios.join(', ')}</span>
</div>
</div>
<Button
variant="ghost"
className="ml-auto h-fit rounded-full text-primary hover:bg-primary/10"
onClick={() => {
setMedicoSelecionado(medico)
setAbaDetalhe('experiencia')
}}
>
Ver perfil completo
</Button>
</div>
{tipoConsulta === 'local' && medico.atendeLocal && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-muted/40 p-4 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-2 text-foreground">
<MapPin className="h-4 w-4 text-primary" />
{medico.endereco}
</span>
<div className="flex flex-col text-right">
<span className="text-xs text-muted-foreground">{medico.cidade}</span>
<span className="text-sm font-semibold text-primary">{medico.precoLocal}</span>
</div>
</div>
)}
{tipoConsulta === 'teleconsulta' && medico.atendeTele && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 text-primary">
<span className="inline-flex items-center gap-2 font-medium">
<Globe className="h-4 w-4" />
Teleconsulta
</span>
<span className="text-sm font-semibold">{medico.precoTeleconsulta}</span>
</div>
)}
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-3 py-1">
<Languages className="h-3.5 w-3.5 text-primary" />
Idiomas: Português, Inglês
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-3 py-1">
<HeartPulse className="h-3.5 w-3.5 text-primary" />
Acolhimento em cada consulta
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-3 py-1">
<ShieldCheck className="h-3.5 w-3.5 text-primary" />
Pagamento seguro
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-3 py-1">
<Stethoscope className="h-3.5 w-3.5 text-primary" />
Especialista recomendado
</span>
</div>
<div className="flex flex-wrap gap-3 pt-2">
<Button className="h-11 rounded-full bg-primary text-primary-foreground hover:bg-primary/90">Agendar consulta</Button>
<Button variant="outline" className="h-11 rounded-full border-primary/40 bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground">
Enviar mensagem
</Button>
<Button
variant="ghost"
className="h-11 rounded-full text-primary hover:bg-primary/10"
onClick={() =>
setAgendasExpandida(prev => ({
...prev,
[medico.id]: !prev[medico.id]
}))
}
>
{agendasExpandida[medico.id] ? 'Ocultar horários' : 'Mostrar mais horários'}
</Button>
</div>
<div className="mt-4 overflow-x-auto">
<div className="grid min-w-[360px] grid-cols-4 gap-3">
{medico.agenda.map(coluna => {
const horarios = agendasExpandida[medico.id] ? coluna.horarios : coluna.horarios.slice(0, 3)
return (
<div key={`${medico.id}-${coluna.label}`} className="rounded-2xl border border-border p-3 text-center">
<p className="text-xs font-semibold uppercase text-muted-foreground">{coluna.label}</p>
<p className="text-[10px] text-muted-foreground">{coluna.data}</p>
<div className="mt-3 flex flex-col gap-2">
{horarios.length ? (
horarios.map(horario => (
<button
key={horario}
type="button"
className="rounded-lg bg-primary/10 px-2 py-1 text-xs font-medium text-primary transition hover:bg-primary hover:text-primary-foreground"
>
{horario}
</button>
))
) : (
<span className="rounded-lg border border-dashed border-border px-2 py-3 text-[11px] text-muted-foreground">
Sem horários
</span>
)}
{!agendasExpandida[medico.id] && coluna.horarios.length > 3 && (
<span className="text-[10px] text-muted-foreground">+{coluna.horarios.length - 3} horários</span>
)}
</div>
</div>
)
})}
</div>
</div>
</Card>
))}
{!profissionais.length && (
<Card className="flex flex-col items-center justify-center gap-3 border border-dashed border-border bg-card/60 p-12 text-center text-muted-foreground">
Nenhum profissional encontrado. Ajuste os filtros para ver outras opções.
</Card>
)}
</section>
<Dialog open={!!medicoSelecionado} onOpenChange={open => !open && setMedicoSelecionado(null)}>
<DialogContent className="max-h-[90vh] w-full max-w-5xl overflow-y-auto border border-border bg-card p-0">
{medicoSelecionado && (
<>
<DialogHeader className="border-b border-border px-6 py-4">
<DialogTitle className="text-2xl font-semibold text-foreground">
{medicoSelecionado.nome}
</DialogTitle>
<p className="text-sm text-muted-foreground">
{medicoSelecionado.especialidade} {medicoSelecionado.crm}
</p>
</DialogHeader>
<div className="flex flex-col gap-6 px-6 py-5">
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-3 py-1 text-primary">
<Star className="h-4 w-4 fill-primary text-primary" />
{medicoSelecionado.avaliacao.toFixed(1)} ({medicoSelecionado.avaliacaoQtd} avaliações)
</span>
<span>{medicoSelecionado.planosSaude.join(' • ')}</span>
</div>
<Tabs value={abaDetalhe} onValueChange={setAbaDetalhe} className="space-y-6">
<TabsList className="w-full justify-start rounded-full bg-muted/50 p-1 text-sm">
<TabsTrigger value="experiencia" className="rounded-full px-4 py-2 data-[state=active]:bg-card data-[state=active]:text-primary">
Experiência
</TabsTrigger>
<TabsTrigger value="planos" className="rounded-full px-4 py-2 data-[state=active]:bg-card data-[state=active]:text-primary">
Planos de saúde
</TabsTrigger>
<TabsTrigger value="consultorios" className="rounded-full px-4 py-2 data-[state=active]:bg-card data-[state=active]:text-primary">
Consultórios
</TabsTrigger>
<TabsTrigger value="servicos" className="rounded-full px-4 py-2 data-[state=active]:bg-card data-[state=active]:text-primary">
Serviços
</TabsTrigger>
<TabsTrigger value="opinioes" className="rounded-full px-4 py-2 data-[state=active]:bg-card data-[state=active]:text-primary">
Opiniões ({medicoSelecionado.opinioes.length})
</TabsTrigger>
<TabsTrigger value="agenda" className="rounded-full px-4 py-2 data-[state=active]:bg-card data-[state=active]:text-primary">
Agenda
</TabsTrigger>
</TabsList>
<TabsContent value="experiencia" className="space-y-3 text-sm text-muted-foreground">
{medicoSelecionado.experiencia.map((linha, index) => (
<p key={index}>{linha}</p>
))}
</TabsContent>
<TabsContent value="planos" className="flex flex-wrap gap-2">
{medicoSelecionado.planosSaude.map(plano => (
<span key={plano} className="rounded-full border border-primary/30 bg-primary/5 px-4 py-1 text-xs font-medium text-primary">
{plano}
</span>
))}
</TabsContent>
<TabsContent value="consultorios" className="space-y-3 text-sm text-muted-foreground">
{medicoSelecionado.consultorios.length ? (
medicoSelecionado.consultorios.map((consultorio, index) => (
<div key={index} className="rounded-xl border border-border bg-muted/40 p-4">
<p className="font-medium text-foreground">{consultorio.nome}</p>
<p>{consultorio.endereco}</p>
<p className="text-xs text-muted-foreground">Telefone: {consultorio.telefone}</p>
</div>
))
) : (
<p>Atendimento exclusivamente por teleconsulta.</p>
)}
</TabsContent>
<TabsContent value="servicos" className="space-y-3 text-sm text-muted-foreground">
{medicoSelecionado.servicos.map(servico => (
<div key={servico.nome} className="flex items-center justify-between rounded-xl border border-border bg-card/70 px-4 py-3">
<span>{servico.nome}</span>
<span className="font-semibold text-primary">{servico.preco}</span>
</div>
))}
</TabsContent>
<TabsContent value="opinioes" className="space-y-3">
{medicoSelecionado.opinioes.map(opiniao => (
<div key={opiniao.id} className="rounded-xl border border-border bg-muted/40 p-4 text-sm text-muted-foreground">
<div className="flex items-center justify-between text-foreground">
<span className="font-semibold">{opiniao.paciente}</span>
<span className="text-xs text-muted-foreground">{opiniao.data}</span>
</div>
<div className="mt-2 flex items-center gap-1 text-primary">
{Array.from({ length: opiniao.nota }).map((_, index) => (
<Star key={index} className="h-4 w-4 fill-primary text-primary" />
))}
</div>
<p className="mt-2 text-muted-foreground">{opiniao.comentario}</p>
</div>
))}
</TabsContent>
<TabsContent value="agenda" className="space-y-4">
<p className="text-sm text-muted-foreground">
Escolha o melhor horário disponível para sua consulta.
</p>
<div className="overflow-x-auto">
<div className="grid min-w-[420px] grid-cols-4 gap-3">
{medicoSelecionado.agenda.map(coluna => (
<div key={coluna.label} className="rounded-2xl border border-border bg-muted/30 p-3 text-center text-sm">
<p className="font-semibold text-foreground">{coluna.label}</p>
<p className="text-xs text-muted-foreground">{coluna.data}</p>
<div className="mt-3 flex flex-col gap-2">
{coluna.horarios.length ? (
coluna.horarios.map(horario => (
<button
key={horario}
type="button"
className="rounded-lg bg-primary/10 px-2 py-1 text-xs font-medium text-primary transition hover:bg-primary hover:text-primary-foreground"
>
{horario}
</button>
))
) : (
<span className="rounded-lg border border-dashed border-border px-2 py-3 text-[11px] text-muted-foreground">
Sem horários
</span>
)}
</div>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
</div>
</>
)}
</DialogContent>
</Dialog>
</div>
</div>
)
}

View File

@ -1,979 +1,10 @@
'use client' import React, { Suspense } from 'react'
import ResultadosClient from './ResultadosClient'
import { useMemo, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Toggle } from '@/components/ui/toggle'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Building2,
Filter,
Globe,
HeartPulse,
Languages,
MapPin,
ShieldCheck,
Star,
Stethoscope,
ChevronRight,
UserRound
} from 'lucide-react'
import { cn } from '@/lib/utils'
type TipoConsulta = 'teleconsulta' | 'local'
type Medico = {
id: number
nome: string
especialidade: string
crm: string
categoriaHero: string
avaliacao: number
avaliacaoQtd: number
convenios: string[]
endereco?: string
bairro?: string
cidade?: string
precoLocal?: string
precoTeleconsulta?: string
atendeLocal: boolean
atendeTele: boolean
agenda: {
label: string
data: string
horarios: string[]
}[]
experiencia: string[]
planosSaude: string[]
consultorios: { nome: string; endereco: string; telefone: string }[]
servicos: { nome: string; preco: string }[]
opinioes: { id: number; paciente: string; data: string; nota: number; comentario: string }[]
}
type MedicoBase = Omit<Medico, 'experiencia' | 'planosSaude' | 'consultorios' | 'servicos' | 'opinioes'> &
Partial<Pick<Medico, 'experiencia' | 'planosSaude' | 'consultorios' | 'servicos' | 'opinioes'>>;
const especialidadesHero = ['Psicólogo', 'Médico clínico geral', 'Pediatra', 'Dentista', 'Ginecologista', 'Veja mais']
const medicosBase: MedicoBase[] = [
{
id: 1,
nome: 'Paula Pontes',
especialidade: 'Psicóloga clínica',
crm: 'CRP SE 19/4244',
categoriaHero: 'Psicólogo',
avaliacao: 4.9,
avaliacaoQtd: 23,
convenios: ['Amil', 'Unimed'],
endereco: 'Av. Doutor José Machado de Souza, 200 - Jardins',
bairro: 'Jardins',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 180',
precoTeleconsulta: 'R$ 160',
atendeLocal: true,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: [] },
{ label: 'Amanhã', data: '10 Out', horarios: ['09:00', '10:00', '11:00', '12:00', '13:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['11:00', '12:00', '13:00', '14:00'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 2,
nome: 'Marcos Vieira',
especialidade: 'Psicólogo comportamental',
crm: 'CRP SE 24/1198',
categoriaHero: 'Psicólogo',
avaliacao: 4.7,
avaliacaoQtd: 31,
convenios: ['SulAmérica', 'Bradesco Saúde'],
endereco: 'Rua Juarez Távora, 155 - São José',
bairro: 'São José',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 190',
precoTeleconsulta: 'R$ 150',
atendeLocal: true,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['14:00', '16:00'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['10:00', '11:00', '12:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['09:00', '10:30'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 3,
nome: 'Julia Azevedo',
especialidade: 'Psicóloga infantil',
crm: 'CRP SE 23/4476',
categoriaHero: 'Psicólogo',
avaliacao: 4.95,
avaliacaoQtd: 45,
convenios: ['NotreDame Intermédica', 'Particular'],
precoTeleconsulta: 'R$ 140',
atendeLocal: false,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['09:00'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['09:30', '11:30'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['08:30', '10:00', '11:00'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 4,
nome: 'Rafael Sousa',
especialidade: 'Neuropsicólogo',
crm: 'CRP BA 03/8874',
categoriaHero: 'Psicólogo',
avaliacao: 4.82,
avaliacaoQtd: 52,
convenios: ['Amil', 'Particular'],
endereco: 'Rua Riachão, 77 - Centro',
bairro: 'Centro',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 210',
atendeLocal: true,
atendeTele: false,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: [] },
{ label: 'Amanhã', data: '10 Out', horarios: ['09:00', '13:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['10:00', '12:00'] },
{ label: 'Dom.', data: '12 Out', horarios: ['09:30'] }
]
},
{
id: 5,
nome: 'Lucas Amorim',
especialidade: 'Clínico geral',
crm: 'CRM SE 5122',
categoriaHero: 'Médico clínico geral',
avaliacao: 4.88,
avaliacaoQtd: 98,
convenios: ['Amil', 'Bradesco Saúde'],
endereco: 'Av. Beira Mar, 402 - Coroa do Meio',
bairro: 'Coroa do Meio',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 220',
atendeLocal: true,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['09:00', '11:00'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['08:00', '09:30', '14:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['10:30', '12:00'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 6,
nome: 'Dr. João Silva',
especialidade: 'Ortopedista',
crm: 'CRM RJ 90876',
categoriaHero: 'Veja mais',
avaliacao: 4.7,
avaliacaoQtd: 96,
convenios: ['Unimed', 'Bradesco Saúde'],
endereco: 'Av. Beira Mar, 1450 - Farolândia',
bairro: 'Farolândia',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 310',
atendeLocal: true,
atendeTele: false,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: [] },
{ label: 'Amanhã', data: '10 Out', horarios: ['08:00', '09:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['10:00'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 7,
nome: 'Dra. Beatriz Moura',
especialidade: 'Ginecologista',
crm: 'CRM BA 52110',
categoriaHero: 'Veja mais',
avaliacao: 4.95,
avaliacaoQtd: 186,
convenios: ['NotreDame Intermédica', 'Particular', 'Amil'],
endereco: 'Rua Tobias Barreto, 512 - Bairro São José',
bairro: 'São José',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 280',
precoTeleconsulta: 'R$ 240',
atendeLocal: true,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['14:00', '15:00'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['09:00', '11:00', '16:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['10:30', '12:30'] },
{ label: 'Dom.', data: '12 Out', horarios: ['11:30'] }
]
},
{
id: 8,
nome: 'Dr. André Lemos',
especialidade: 'Gastroenterologista',
crm: 'CRM SE 9033',
categoriaHero: 'Veja mais',
avaliacao: 4.75,
avaliacaoQtd: 105,
convenios: ['SulAmérica', 'Unimed'],
endereco: 'Rua Arauá, 22 - Centro',
bairro: 'Centro',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 340',
atendeLocal: true,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['13:00'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['08:00', '09:00', '11:00', '15:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['09:30', '10:15'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 9,
nome: 'Dra. Fernanda Lima',
especialidade: 'Médico clínico geral',
crm: 'CRM SE 7890',
categoriaHero: 'Médico clínico geral',
avaliacao: 4.9,
avaliacaoQtd: 110,
convenios: ['Amil', 'Unimed', 'Bradesco Saúde'],
endereco: 'Av. Rio de Janeiro, 300 - São José',
bairro: 'São José',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 250',
atendeLocal: true,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['09:00', '11:00'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['08:00', '09:30', '14:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['10:30', '12:00'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 10,
nome: 'Dra. Helena Castro',
especialidade: 'Pediatra geral',
crm: 'CRM SE 7812',
categoriaHero: 'Pediatra',
avaliacao: 4.92,
avaliacaoQtd: 134,
convenios: ['Amil', 'Unimed', 'SulAmérica'],
endereco: 'Rua José Hipólito, 98 - Suíssa',
bairro: 'Suíssa',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 260',
precoTeleconsulta: 'R$ 220',
atendeLocal: true,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['09:00', '11:30'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['08:30', '10:00', '14:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['09:30', '11:00'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 11,
nome: 'Dr. Vinícius Prado',
especialidade: 'Pediatra neonatologista',
crm: 'CRM SE 6331',
categoriaHero: 'Pediatra',
avaliacao: 4.85,
avaliacaoQtd: 89,
convenios: ['Bradesco Saúde', 'NotreDame Intermédica'],
endereco: 'Av. Augusto Franco, 2220 - Siqueira Campos',
bairro: 'Siqueira Campos',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 280',
atendeLocal: true,
atendeTele: false,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: [] },
{ label: 'Amanhã', data: '10 Out', horarios: ['08:00', '09:00', '11:30'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['10:00'] },
{ label: 'Dom.', data: '12 Out', horarios: ['09:30'] }
]
},
{
id: 12,
nome: 'Dra. Marina Salles',
especialidade: 'Pediatra emergencista',
crm: 'CRM BA 85660',
categoriaHero: 'Pediatra',
avaliacao: 4.78,
avaliacaoQtd: 57,
convenios: ['Particular', 'Amil'],
precoTeleconsulta: 'R$ 210',
atendeLocal: false,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['13:00', '15:00'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['09:30', '12:30'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['09:00'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 13,
nome: 'Dr. Caio Moura',
especialidade: 'Pediatra pneumologista',
crm: 'CRM SE 7345',
categoriaHero: 'Pediatra',
avaliacao: 4.91,
avaliacaoQtd: 102,
convenios: ['SulAmérica', 'Unimed'],
endereco: 'Av. Hermes Fontes, 445 - Salgado Filho',
bairro: 'Salgado Filho',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 270',
precoTeleconsulta: 'R$ 230',
atendeLocal: true,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['10:00'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['09:00', '11:00', '16:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['09:30', '11:30'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 14,
nome: 'Dra. Patrícia Freire',
especialidade: 'Cirurgiã-dentista',
crm: 'CRO SE 2133',
categoriaHero: 'Dentista',
avaliacao: 4.9,
avaliacaoQtd: 176,
convenios: ['OdontoPrev', 'Amil Dental'],
endereco: 'Rua Itabaiana, 410 - Centro',
bairro: 'Centro',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 200',
precoTeleconsulta: 'R$ 160',
atendeLocal: true,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['09:00', '13:30'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['08:30', '10:00', '14:30'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['09:30', '11:00'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 15,
nome: 'Dr. Henrique Assis',
especialidade: 'Implantodontista',
crm: 'CRO SE 1450',
categoriaHero: 'Dentista',
avaliacao: 4.83,
avaliacaoQtd: 94,
convenios: ['SulAmérica Odonto', 'Particular'],
endereco: 'Av. Jorge Amado, 321 - Atalaia',
bairro: 'Atalaia',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 350',
atendeLocal: true,
atendeTele: false,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: [] },
{ label: 'Amanhã', data: '10 Out', horarios: ['09:00', '11:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['10:30'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 16,
nome: 'Dra. Lívia Teles',
especialidade: 'Ortodontista',
crm: 'CRO BA 11567',
categoriaHero: 'Dentista',
avaliacao: 4.88,
avaliacaoQtd: 140,
convenios: ['Uniodonto', 'Amil Dental', 'Particular'],
precoTeleconsulta: 'R$ 120',
atendeLocal: false,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['17:00'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['09:00', '10:30', '15:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['08:30', '09:30'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 17,
nome: 'Dr. Pablo Menezes',
especialidade: 'Endodontista',
crm: 'CRO SE 2099',
categoriaHero: 'Dentista',
avaliacao: 4.76,
avaliacaoQtd: 83,
convenios: ['OdontoPrev', 'SulAmérica Odonto'],
endereco: 'Rua Cedro, 70 - Grageru',
bairro: 'Grageru',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 230',
precoTeleconsulta: 'R$ 190',
atendeLocal: true,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['09:00'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['08:00', '09:00', '13:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['09:30'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 18,
nome: 'Dra. Beatriz Moura',
especialidade: 'Ginecologista obstetra',
crm: 'CRM BA 52110',
categoriaHero: 'Ginecologista',
avaliacao: 4.95,
avaliacaoQtd: 186,
convenios: ['NotreDame Intermédica', 'Particular', 'Amil'],
endereco: 'Rua Tobias Barreto, 512 - São José',
bairro: 'São José',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 280',
precoTeleconsulta: 'R$ 240',
atendeLocal: true,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['14:00', '15:00'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['09:00', '11:00', '16:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['10:30', '12:30'] },
{ label: 'Dom.', data: '12 Out', horarios: ['11:30'] }
]
},
{
id: 19,
nome: 'Dra. Camila Albuquerque',
especialidade: 'Ginecologista endocrinologista',
crm: 'CRM SE 6774',
categoriaHero: 'Ginecologista',
avaliacao: 4.89,
avaliacaoQtd: 122,
convenios: ['SulAmérica', 'Unimed'],
endereco: 'Av. Gonçalo Prado Rollemberg, 167 - São José',
bairro: 'São José',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 300',
atendeLocal: true,
atendeTele: false,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: [] },
{ label: 'Amanhã', data: '10 Out', horarios: ['08:00', '09:30', '15:00'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['09:00'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 20,
nome: 'Dra. Renata Figueiredo',
especialidade: 'Ginecologista minimamente invasiva',
crm: 'CRM PE 112233',
categoriaHero: 'Ginecologista',
avaliacao: 4.94,
avaliacaoQtd: 208,
convenios: ['Amil', 'Bradesco Saúde', 'Particular'],
precoTeleconsulta: 'R$ 260',
atendeLocal: false,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['09:00', '10:30'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['08:30', '11:00', '14:30'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['09:45'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
},
{
id: 21,
nome: 'Dr. Eduardo Fontes',
especialidade: 'Ginecologista mastologista',
crm: 'CRM SE 7012',
categoriaHero: 'Ginecologista',
avaliacao: 4.8,
avaliacaoQtd: 95,
convenios: ['NotreDame Intermédica', 'SulAmérica'],
endereco: 'Rua Teófilo Dantas, 55 - Centro',
bairro: 'Centro',
cidade: 'Aracaju • SE',
precoLocal: 'R$ 310',
atendeLocal: true,
atendeTele: true,
agenda: [
{ label: 'Hoje', data: '9 Out', horarios: ['08:30'] },
{ label: 'Amanhã', data: '10 Out', horarios: ['09:00', '11:00', '16:30'] },
{ label: 'Sáb.', data: '11 Out', horarios: ['10:00', '12:00'] },
{ label: 'Dom.', data: '12 Out', horarios: [] }
]
}
]
const medicosMock: Medico[] = medicosBase.map((medico, index) => ({
...medico,
experiencia:
medico.experiencia ??
[
'Especialista com atuação reconhecida pelo respectivo conselho profissional.',
'Formação continuada em instituições nacionais e internacionais.',
'Atendimento humanizado com foco em resultados sustentáveis.'
],
planosSaude:
medico.planosSaude ?? medico.convenios ?? ['Amil', 'Unimed', 'SulAmérica'],
consultorios:
medico.consultorios ??
(medico.endereco
? [
{
nome: 'Clínica principal',
endereco: `${medico.endereco}${medico.cidade ? `${medico.cidade}` : ''}`,
telefone: '(79) 4002-8922'
}
]
: []),
servicos:
medico.servicos ??
[
{
nome: 'Consulta inicial',
preco: medico.precoLocal ?? medico.precoTeleconsulta ?? 'Sob consulta'
},
{ nome: 'Retorno em até 30 dias', preco: 'R$ 150' }
],
opinioes:
medico.opinioes ??
[
{
id: index * 2 + 1,
paciente: 'Ana P.',
data: '01/09/2025',
nota: 5,
comentario: 'Profissional muito atencioso e detalhista.'
},
{
id: index * 2 + 2,
paciente: 'Marcos L.',
data: '18/08/2025',
nota: 4,
comentario: 'Explicações claras e ambiente acolhedor.'
}
]
}))
export default function ResultadosPage() {
const params = useSearchParams()
const router = useRouter()
const [tipoConsulta, setTipoConsulta] = useState<TipoConsulta>(
params.get('tipo') === 'presencial' ? 'local' : 'teleconsulta'
)
const [especialidadeHero, setEspecialidadeHero] = useState<string>(params.get('especialidade') || 'Psicólogo')
const [convenio, setConvenio] = useState<string>('Todos')
const [bairro, setBairro] = useState<string>('Todos')
const [agendasExpandida, setAgendasExpandida] = useState<Record<number, boolean>>({})
const [medicoSelecionado, setMedicoSelecionado] = useState<Medico | null>(null)
const [abaDetalhe, setAbaDetalhe] = useState('experiencia')
const profissionais = useMemo(() => {
return medicosMock.filter(medico => {
if (tipoConsulta === 'local' && !medico.atendeLocal) return false
if (tipoConsulta === 'teleconsulta' && !medico.atendeTele) return false
if (convenio !== 'Todos' && !medico.convenios.includes(convenio)) return false
if (bairro !== 'Todos' && medico.bairro !== bairro) return false
if (especialidadeHero !== 'Veja mais' && medico.categoriaHero !== especialidadeHero) return false
if (especialidadeHero === 'Veja mais' && medico.categoriaHero !== 'Veja mais') return false
return true
})
}, [bairro, convenio, especialidadeHero, tipoConsulta])
const toggleBase =
'rounded-full px-4 py-[10px] text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]'
export default function Page() {
return ( return (
<div className="min-h-screen bg-background"> <Suspense fallback={<div className="min-h-screen">Carregando...</div>}>
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-10 md:px-8"> <ResultadosClient />
<section className="rounded-3xl bg-primary p-6 text-primary-foreground shadow-lg"> </Suspense>
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold md:text-3xl">Resultados da procura</h1>
<p className="text-sm text-primary-foreground/80">Qual especialização você deseja?</p>
</div>
<Button
variant="outline"
className="rounded-full border-primary-foreground/30 bg-primary-foreground/10 text-primary-foreground hover:bg-primary-foreground hover:text-primary"
>
Ajustar filtros
</Button>
</div>
<div className="mt-6 flex flex-wrap gap-3">
{especialidadesHero.map(item => (
<button
key={item}
type="button"
onClick={() => setEspecialidadeHero(item)}
className={cn(
'rounded-full px-5 py-2 text-sm font-medium transition focus-visible:ring-2 focus-visible:ring-primary-foreground/80',
especialidadeHero === item ? 'bg-primary-foreground text-primary' : 'bg-primary-foreground/10'
)}
>
{item}
</button>
))}
</div>
</section>
<section className="sticky top-0 z-30 flex flex-wrap gap-3 rounded-2xl border border-border bg-card/90 p-4 shadow-lg backdrop-blur">
<Toggle
pressed={tipoConsulta === 'teleconsulta'}
onPressedChange={() => setTipoConsulta('teleconsulta')}
className={cn(toggleBase, tipoConsulta === 'teleconsulta' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')}
>
<Globe className="mr-2 h-4 w-4" />
Teleconsulta
</Toggle>
<Toggle
pressed={tipoConsulta === 'local'}
onPressedChange={() => setTipoConsulta('local')}
className={cn(toggleBase, tipoConsulta === 'local' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')}
>
<Building2 className="mr-2 h-4 w-4" />
Consulta no local
</Toggle>
<Select value={convenio} onValueChange={setConvenio}>
<SelectTrigger className="h-10 min-w-[180px] rounded-full border border-primary/40 bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground">
<SelectValue placeholder="Convênio" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Todos">Todos os convênios</SelectItem>
<SelectItem value="Amil">Amil</SelectItem>
<SelectItem value="Unimed">Unimed</SelectItem>
<SelectItem value="SulAmérica">SulAmérica</SelectItem>
<SelectItem value="Bradesco Saúde">Bradesco Saúde</SelectItem>
<SelectItem value="Particular">Particular</SelectItem>
</SelectContent>
</Select>
<Select value={bairro} onValueChange={setBairro}>
<SelectTrigger className="h-10 min-w-[160px] rounded-full border border-primary/40 bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground">
<SelectValue placeholder="Bairro" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Todos">Todos os bairros</SelectItem>
<SelectItem value="Centro">Centro</SelectItem>
<SelectItem value="Jardins">Jardins</SelectItem>
<SelectItem value="Farolândia">Farolândia</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
className="rounded-full border border-primary/40 bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground"
>
<Filter className="mr-2 h-4 w-4" />
Mais filtros
</Button>
<Button
variant="ghost"
className="ml-auto rounded-full text-primary hover:bg-primary/10"
onClick={() => router.back()}
>
Voltar
<ChevronRight className="ml-1 h-4 w-4 rotate-180" />
</Button>
</section>
<section className="space-y-4">
{profissionais.map(medico => (
<Card
key={medico.id}
className="flex flex-col gap-4 border border-border bg-card/80 p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-lg"
>
<div className="flex flex-wrap items-start gap-4">
<Avatar className="h-14 w-14 border border-primary/20 bg-primary/5">
<AvatarFallback className="bg-primary/10 text-primary">
<UserRound className="h-6 w-6" />
</AvatarFallback>
</Avatar>
<div className="flex flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-foreground">{medico.nome}</h2>
<Badge className="rounded-full bg-primary/10 text-primary">{medico.especialidade}</Badge>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-3 py-1 text-primary">
<Star className="h-4 w-4 fill-primary text-primary" />
{medico.avaliacao.toFixed(1)} {medico.avaliacaoQtd} avaliações
</span>
<span>{medico.crm}</span>
<span>{medico.convenios.join(', ')}</span>
</div>
</div>
<Button
variant="ghost"
className="ml-auto h-fit rounded-full text-primary hover:bg-primary/10"
onClick={() => {
setMedicoSelecionado(medico)
setAbaDetalhe('experiencia')
}}
>
Ver perfil completo
</Button>
</div>
{tipoConsulta === 'local' && medico.atendeLocal && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-muted/40 p-4 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-2 text-foreground">
<MapPin className="h-4 w-4 text-primary" />
{medico.endereco}
</span>
<div className="flex flex-col text-right">
<span className="text-xs text-muted-foreground">{medico.cidade}</span>
<span className="text-sm font-semibold text-primary">{medico.precoLocal}</span>
</div>
</div>
)}
{tipoConsulta === 'teleconsulta' && medico.atendeTele && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 text-primary">
<span className="inline-flex items-center gap-2 font-medium">
<Globe className="h-4 w-4" />
Teleconsulta
</span>
<span className="text-sm font-semibold">{medico.precoTeleconsulta}</span>
</div>
)}
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-3 py-1">
<Languages className="h-3.5 w-3.5 text-primary" />
Idiomas: Português, Inglês
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-3 py-1">
<HeartPulse className="h-3.5 w-3.5 text-primary" />
Acolhimento em cada consulta
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-3 py-1">
<ShieldCheck className="h-3.5 w-3.5 text-primary" />
Pagamento seguro
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-3 py-1">
<Stethoscope className="h-3.5 w-3.5 text-primary" />
Especialista recomendado
</span>
</div>
<div className="flex flex-wrap gap-3 pt-2">
<Button className="h-11 rounded-full bg-primary text-primary-foreground hover:bg-primary/90">Agendar consulta</Button>
<Button variant="outline" className="h-11 rounded-full border-primary/40 bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground">
Enviar mensagem
</Button>
<Button
variant="ghost"
className="h-11 rounded-full text-primary hover:bg-primary/10"
onClick={() =>
setAgendasExpandida(prev => ({
...prev,
[medico.id]: !prev[medico.id]
}))
}
>
{agendasExpandida[medico.id] ? 'Ocultar horários' : 'Mostrar mais horários'}
</Button>
</div>
<div className="mt-4 overflow-x-auto">
<div className="grid min-w-[360px] grid-cols-4 gap-3">
{medico.agenda.map(coluna => {
const horarios = agendasExpandida[medico.id] ? coluna.horarios : coluna.horarios.slice(0, 3)
return (
<div key={`${medico.id}-${coluna.label}`} className="rounded-2xl border border-border p-3 text-center">
<p className="text-xs font-semibold uppercase text-muted-foreground">{coluna.label}</p>
<p className="text-[10px] text-muted-foreground">{coluna.data}</p>
<div className="mt-3 flex flex-col gap-2">
{horarios.length ? (
horarios.map(horario => (
<button
key={horario}
type="button"
className="rounded-lg bg-primary/10 px-2 py-1 text-xs font-medium text-primary transition hover:bg-primary hover:text-primary-foreground"
>
{horario}
</button>
))
) : (
<span className="rounded-lg border border-dashed border-border px-2 py-3 text-[11px] text-muted-foreground">
Sem horários
</span>
)}
{!agendasExpandida[medico.id] && coluna.horarios.length > 3 && (
<span className="text-[10px] text-muted-foreground">+{coluna.horarios.length - 3} horários</span>
)}
</div>
</div>
)
})}
</div>
</div>
</Card>
))}
{!profissionais.length && (
<Card className="flex flex-col items-center justify-center gap-3 border border-dashed border-border bg-card/60 p-12 text-center text-muted-foreground">
Nenhum profissional encontrado. Ajuste os filtros para ver outras opções.
</Card>
)}
</section>
<Dialog open={!!medicoSelecionado} onOpenChange={open => !open && setMedicoSelecionado(null)}>
<DialogContent className="max-h-[90vh] w-full max-w-5xl overflow-y-auto border border-border bg-card p-0">
{medicoSelecionado && (
<>
<DialogHeader className="border-b border-border px-6 py-4">
<DialogTitle className="text-2xl font-semibold text-foreground">
{medicoSelecionado.nome}
</DialogTitle>
<p className="text-sm text-muted-foreground">
{medicoSelecionado.especialidade} {medicoSelecionado.crm}
</p>
</DialogHeader>
<div className="flex flex-col gap-6 px-6 py-5">
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-3 py-1 text-primary">
<Star className="h-4 w-4 fill-primary text-primary" />
{medicoSelecionado.avaliacao.toFixed(1)} ({medicoSelecionado.avaliacaoQtd} avaliações)
</span>
<span>{medicoSelecionado.planosSaude.join(' • ')}</span>
</div>
<Tabs value={abaDetalhe} onValueChange={setAbaDetalhe} className="space-y-6">
<TabsList className="w-full justify-start rounded-full bg-muted/50 p-1 text-sm">
<TabsTrigger value="experiencia" className="rounded-full px-4 py-2 data-[state=active]:bg-card data-[state=active]:text-primary">
Experiência
</TabsTrigger>
<TabsTrigger value="planos" className="rounded-full px-4 py-2 data-[state=active]:bg-card data-[state=active]:text-primary">
Planos de saúde
</TabsTrigger>
<TabsTrigger value="consultorios" className="rounded-full px-4 py-2 data-[state=active]:bg-card data-[state=active]:text-primary">
Consultórios
</TabsTrigger>
<TabsTrigger value="servicos" className="rounded-full px-4 py-2 data-[state=active]:bg-card data-[state=active]:text-primary">
Serviços
</TabsTrigger>
<TabsTrigger value="opinioes" className="rounded-full px-4 py-2 data-[state=active]:bg-card data-[state=active]:text-primary">
Opiniões ({medicoSelecionado.opinioes.length})
</TabsTrigger>
<TabsTrigger value="agenda" className="rounded-full px-4 py-2 data-[state=active]:bg-card data-[state=active]:text-primary">
Agenda
</TabsTrigger>
</TabsList>
<TabsContent value="experiencia" className="space-y-3 text-sm text-muted-foreground">
{medicoSelecionado.experiencia.map((linha, index) => (
<p key={index}>{linha}</p>
))}
</TabsContent>
<TabsContent value="planos" className="flex flex-wrap gap-2">
{medicoSelecionado.planosSaude.map(plano => (
<span key={plano} className="rounded-full border border-primary/30 bg-primary/5 px-4 py-1 text-xs font-medium text-primary">
{plano}
</span>
))}
</TabsContent>
<TabsContent value="consultorios" className="space-y-3 text-sm text-muted-foreground">
{medicoSelecionado.consultorios.length ? (
medicoSelecionado.consultorios.map((consultorio, index) => (
<div key={index} className="rounded-xl border border-border bg-muted/40 p-4">
<p className="font-medium text-foreground">{consultorio.nome}</p>
<p>{consultorio.endereco}</p>
<p className="text-xs text-muted-foreground">Telefone: {consultorio.telefone}</p>
</div>
))
) : (
<p>Atendimento exclusivamente por teleconsulta.</p>
)}
</TabsContent>
<TabsContent value="servicos" className="space-y-3 text-sm text-muted-foreground">
{medicoSelecionado.servicos.map(servico => (
<div key={servico.nome} className="flex items-center justify-between rounded-xl border border-border bg-card/70 px-4 py-3">
<span>{servico.nome}</span>
<span className="font-semibold text-primary">{servico.preco}</span>
</div>
))}
</TabsContent>
<TabsContent value="opinioes" className="space-y-3">
{medicoSelecionado.opinioes.map(opiniao => (
<div key={opiniao.id} className="rounded-xl border border-border bg-muted/40 p-4 text-sm text-muted-foreground">
<div className="flex items-center justify-between text-foreground">
<span className="font-semibold">{opiniao.paciente}</span>
<span className="text-xs text-muted-foreground">{opiniao.data}</span>
</div>
<div className="mt-2 flex items-center gap-1 text-primary">
{Array.from({ length: opiniao.nota }).map((_, index) => (
<Star key={index} className="h-4 w-4 fill-primary text-primary" />
))}
</div>
<p className="mt-2 text-muted-foreground">{opiniao.comentario}</p>
</div>
))}
</TabsContent>
<TabsContent value="agenda" className="space-y-4">
<p className="text-sm text-muted-foreground">
Escolha o melhor horário disponível para sua consulta.
</p>
<div className="overflow-x-auto">
<div className="grid min-w-[420px] grid-cols-4 gap-3">
{medicoSelecionado.agenda.map(coluna => (
<div key={coluna.label} className="rounded-2xl border border-border bg-muted/30 p-3 text-center text-sm">
<p className="font-semibold text-foreground">{coluna.label}</p>
<p className="text-xs text-muted-foreground">{coluna.data}</p>
<div className="mt-3 flex flex-col gap-2">
{coluna.horarios.length ? (
coluna.horarios.map(horario => (
<button
key={horario}
type="button"
className="rounded-lg bg-primary/10 px-2 py-1 text-xs font-medium text-primary transition hover:bg-primary hover:text-primary-foreground"
>
{horario}
</button>
))
) : (
<span className="rounded-lg border border-dashed border-border px-2 py-3 text-[11px] text-muted-foreground">
Sem horários
</span>
)}
</div>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
</div>
</>
)}
</DialogContent>
</Dialog>
</div>
</div>
) )
} }

View File

@ -22,11 +22,13 @@ import {
listarAnexosMedico, listarAnexosMedico,
adicionarAnexoMedico, adicionarAnexoMedico,
removerAnexoMedico, removerAnexoMedico,
removerFotoMedico,
MedicoInput, MedicoInput,
Medico, Medico,
criarUsuarioMedico, criarUsuarioMedico,
gerarSenhaAleatoria, gerarSenhaAleatoria,
} from "@/lib/api"; } from "@/lib/api";
import { getAvatarPublicUrl } from '@/lib/api';
; ;
import { buscarCepAPI } from "@/lib/api"; import { buscarCepAPI } from "@/lib/api";
@ -150,6 +152,7 @@ export function DoctorRegistrationForm({
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [expanded, setExpanded] = useState({ dados: true, contato: false, endereco: false, obs: false, formacao: false, admin: false }); const [expanded, setExpanded] = useState({ dados: true, contato: false, endereco: false, obs: false, formacao: false, admin: false });
const [isSubmitting, setSubmitting] = useState(false); const [isSubmitting, setSubmitting] = useState(false);
const [isUploadingPhoto, setUploadingPhoto] = useState(false);
const [isSearchingCEP, setSearchingCEP] = useState(false); const [isSearchingCEP, setSearchingCEP] = useState(false);
const [photoPreview, setPhotoPreview] = useState<string | null>(null); const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [serverAnexos, setServerAnexos] = useState<any[]>([]); const [serverAnexos, setServerAnexos] = useState<any[]>([]);
@ -242,6 +245,22 @@ export function DoctorRegistrationForm({
} catch (err) { } catch (err) {
console.error("[DoctorForm] Erro ao carregar anexos:", err); console.error("[DoctorForm] Erro ao carregar anexos:", err);
} }
// Try to detect existing public avatar (no file extension) and set preview
try {
const url = getAvatarPublicUrl(String(doctorId));
try {
const head = await fetch(url, { method: 'HEAD' });
if (head.ok) { setPhotoPreview(url); }
else {
const get = await fetch(url, { method: 'GET' });
if (get.ok) { setPhotoPreview(url); }
}
} catch (inner) {
// ignore network/CORS errors while detecting
}
} catch (detectErr) {
// ignore detection errors
}
} catch (err) { } catch (err) {
console.error("[DoctorForm] Erro ao carregar médico:", err); console.error("[DoctorForm] Erro ao carregar médico:", err);
} }
@ -345,6 +364,27 @@ function setField<T extends keyof FormData>(k: T, v: FormData[T]) {
return Object.keys(e).length === 0; return Object.keys(e).length === 0;
} }
async function handleRemoverFotoServidor() {
if (mode !== 'edit' || !doctorId) return;
try {
setUploadingPhoto(true);
await removerFotoMedico(String(doctorId));
setPhotoPreview(null);
alert('Foto removida com sucesso.');
} catch (e: any) {
console.warn('[DoctorForm] erro ao remover foto do servidor', e);
if (String(e?.message || '').includes('401')) {
alert('Falha ao remover a foto: não autenticado. Faça login novamente e tente novamente.\nDetalhe: ' + (e?.message || ''));
} else if (String(e?.message || '').includes('403')) {
alert('Falha ao remover a foto: sem permissão. Verifique as permissões do token e se o storage aceita esse usuário.\nDetalhe: ' + (e?.message || ''));
} else {
alert(e?.message || 'Não foi possível remover a foto do storage. Veja console para detalhes.');
}
} finally {
setUploadingPhoto(false);
}
}
function toPayload(): MedicoInput { function toPayload(): MedicoInput {
// Converte dd/MM/yyyy para ISO (yyyy-MM-dd) se possível // Converte dd/MM/yyyy para ISO (yyyy-MM-dd) se possível
let isoDate: string | null = null; let isoDate: string | null = null;
@ -396,6 +436,18 @@ async function handleSubmit(ev: React.FormEvent) {
if (!doctorId) throw new Error("ID do médico não fornecido para edição"); if (!doctorId) throw new Error("ID do médico não fornecido para edição");
const payload = toPayload(); const payload = toPayload();
const saved = await atualizarMedico(String(doctorId), payload); const saved = await atualizarMedico(String(doctorId), payload);
// If user selected a new photo, upload it
if (form.photo) {
try {
setUploadingPhoto(true);
await uploadFotoMedico(String(doctorId), form.photo);
} catch (upErr) {
console.warn('[DoctorForm] Falha ao enviar foto do médico:', upErr);
alert('Médico atualizado, mas falha ao enviar a foto. Tente novamente.');
} finally {
setUploadingPhoto(false);
}
}
onSaved?.(saved); onSaved?.(saved);
alert("Médico atualizado com sucesso!"); alert("Médico atualizado com sucesso!");
if (inline) onClose?.(); if (inline) onClose?.();
@ -458,6 +510,20 @@ async function handleSubmit(ev: React.FormEvent) {
setPhotoPreview(null); setPhotoPreview(null);
setServerAnexos([]); setServerAnexos([]);
// If a photo was selected during creation, upload it now
if (form.photo) {
try {
setUploadingPhoto(true);
const docId = (savedDoctorProfile && (savedDoctorProfile.id || (Array.isArray(savedDoctorProfile) ? savedDoctorProfile[0]?.id : undefined))) || null;
if (docId) await uploadFotoMedico(String(docId), form.photo);
} catch (upErr) {
console.warn('[DoctorForm] Falha ao enviar foto do médico após criação:', upErr);
alert('Médico criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.');
} finally {
setUploadingPhoto(false);
}
}
// 5. Notifica componente pai // 5. Notifica componente pai
onSaved?.(savedDoctorProfile); onSaved?.(savedDoctorProfile);
} else { } else {
@ -582,6 +648,11 @@ async function handleSubmit(ev: React.FormEvent) {
</Button> </Button>
</Label> </Label>
<Input id="photo" type="file" accept="image/*" className="hidden" onChange={handlePhoto} /> <Input id="photo" type="file" accept="image/*" className="hidden" onChange={handlePhoto} />
{mode === "edit" && (
<Button type="button" variant="ghost" onClick={handleRemoverFotoServidor}>
<Trash2 className="mr-2 h-4 w-4" /> Remover foto
</Button>
)}
{errors.photo && <p className="text-sm text-destructive">{errors.photo}</p>} {errors.photo && <p className="text-sm text-destructive">{errors.photo}</p>}
<p className="text-xs text-muted-foreground">Máximo 5MB</p> <p className="text-xs text-muted-foreground">Máximo 5MB</p>
</div> </div>

View File

@ -27,6 +27,7 @@ import {
criarUsuarioPaciente, criarUsuarioPaciente,
criarPaciente, criarPaciente,
} from "@/lib/api"; } from "@/lib/api";
import { getAvatarPublicUrl } from '@/lib/api';
import { validarCPFLocal } from "@/lib/utils"; import { validarCPFLocal } from "@/lib/utils";
import { verificarCpfDuplicado } from "@/lib/api"; import { verificarCpfDuplicado } from "@/lib/api";
@ -99,6 +100,7 @@ export function PatientRegistrationForm({
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [expanded, setExpanded] = useState({ dados: true, contato: false, endereco: false, obs: false }); const [expanded, setExpanded] = useState({ dados: true, contato: false, endereco: false, obs: false });
const [isSubmitting, setSubmitting] = useState(false); const [isSubmitting, setSubmitting] = useState(false);
const [isUploadingPhoto, setUploadingPhoto] = useState(false);
const [isSearchingCEP, setSearchingCEP] = useState(false); const [isSearchingCEP, setSearchingCEP] = useState(false);
const [photoPreview, setPhotoPreview] = useState<string | null>(null); const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [serverAnexos, setServerAnexos] = useState<any[]>([]); const [serverAnexos, setServerAnexos] = useState<any[]>([]);
@ -145,6 +147,22 @@ export function PatientRegistrationForm({
const ax = await listarAnexos(String(patientId)).catch(() => []); const ax = await listarAnexos(String(patientId)).catch(() => []);
setServerAnexos(Array.isArray(ax) ? ax : []); setServerAnexos(Array.isArray(ax) ? ax : []);
// Try to detect existing public avatar (no file extension) and set preview
try {
const url = getAvatarPublicUrl(String(patientId));
try {
const head = await fetch(url, { method: 'HEAD' });
if (head.ok) { setPhotoPreview(url); }
else {
const get = await fetch(url, { method: 'GET' });
if (get.ok) { setPhotoPreview(url); }
}
} catch (inner) {
// ignore network/CORS errors while detecting
}
} catch (detectErr) {
// ignore detection errors
}
} catch (err) { } catch (err) {
console.error("[PatientForm] Erro ao carregar paciente:", err); console.error("[PatientForm] Erro ao carregar paciente:", err);
} }
@ -260,6 +278,28 @@ export function PatientRegistrationForm({
if (patientId == null) throw new Error("Paciente inexistente para edição"); if (patientId == null) throw new Error("Paciente inexistente para edição");
const payload = toPayload(); const payload = toPayload();
const saved = await atualizarPaciente(String(patientId), payload); const saved = await atualizarPaciente(String(patientId), payload);
// If a new photo was selected locally, remove existing public avatar (if any) then upload the new one
if (form.photo) {
try {
setUploadingPhoto(true);
// Attempt to remove existing avatar first (no-op if none)
try {
await removerFotoPaciente(String(patientId));
// clear any cached preview so upload result will repopulate it
setPhotoPreview(null);
} catch (remErr) {
// If removal fails (permissions/CORS), continue to attempt upload — we don't want to block the user
console.warn('[PatientForm] aviso: falha ao remover avatar antes do upload:', remErr);
}
await uploadFotoPaciente(String(patientId), form.photo);
} catch (upErr) {
console.warn('[PatientForm] Falha ao enviar foto do paciente:', upErr);
// don't block the main update — show a warning
alert('Paciente atualizado, mas falha ao enviar a foto. Tente novamente.');
} finally {
setUploadingPhoto(false);
}
}
onSaved?.(saved); onSaved?.(saved);
alert("Paciente atualizado com sucesso!"); alert("Paciente atualizado com sucesso!");
@ -301,7 +341,24 @@ export function PatientRegistrationForm({
const apiMod = await import('@/lib/api'); const apiMod = await import('@/lib/api');
const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id); const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id);
const userId = (userResponse.user as any)?.id || (userResponse.user as any)?.user_id || (userResponse.user as any)?.id; const userId = (userResponse.user as any)?.id || (userResponse.user as any)?.user_id || (userResponse.user as any)?.id;
if (pacienteId && userId && typeof apiMod.vincularUserIdPaciente === 'function') {
// Guard: verify userId is present and looks plausible before attempting to PATCH
const isPlausibleUserId = (id: any) => {
if (!id) return false;
const s = String(id).trim();
if (!s) return false;
// quick UUID v4-ish check (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx) or numeric id fallback
const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const numeric = /^\d+$/;
return uuidV4.test(s) || numeric.test(s) || s.length >= 8;
};
if (!pacienteId) {
console.warn('[PatientForm] pacienteId ausente; pulando vinculação de user_id');
} else if (!isPlausibleUserId(userId)) {
// Do not attempt to PATCH when userId is missing/invalid to avoid 400s
console.warn('[PatientForm] userId inválido ou ausente; não será feita a vinculação. userResponse:', userResponse);
} else if (typeof apiMod.vincularUserIdPaciente === 'function') {
console.log('[PatientForm] Vinculando user_id ao paciente:', pacienteId, userId); console.log('[PatientForm] Vinculando user_id ao paciente:', pacienteId, userId);
try { try {
await apiMod.vincularUserIdPaciente(pacienteId, String(userId)); await apiMod.vincularUserIdPaciente(pacienteId, String(userId));
@ -318,6 +375,22 @@ export function PatientRegistrationForm({
setForm(initial); setForm(initial);
setPhotoPreview(null); setPhotoPreview(null);
setServerAnexos([]); setServerAnexos([]);
// If a photo was selected during creation, upload it now using the created patient id
if (form.photo) {
try {
setUploadingPhoto(true);
const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id);
if (pacienteId) {
await uploadFotoPaciente(String(pacienteId), form.photo);
}
} catch (upErr) {
console.warn('[PatientForm] Falha ao enviar foto do paciente após criação:', upErr);
// Non-blocking: inform user
alert('Paciente criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.');
} finally {
setUploadingPhoto(false);
}
}
onSaved?.(savedPatientProfile); onSaved?.(savedPatientProfile);
return; return;
} else { } else {
@ -402,10 +475,23 @@ export function PatientRegistrationForm({
async function handleRemoverFotoServidor() { async function handleRemoverFotoServidor() {
if (mode !== "edit" || !patientId) return; if (mode !== "edit" || !patientId) return;
try { try {
setUploadingPhoto(true);
await removerFotoPaciente(String(patientId)); await removerFotoPaciente(String(patientId));
alert("Foto removida."); // clear preview and inform user
setPhotoPreview(null);
alert('Foto removida com sucesso.');
} catch (e: any) { } catch (e: any) {
alert(e?.message || "Não foi possível remover a foto."); console.warn('[PatientForm] erro ao remover foto do servidor', e);
// Show detailed guidance for common cases
if (String(e?.message || '').includes('401')) {
alert('Falha ao remover a foto: não autenticado. Faça login novamente e tente novamente.\nDetalhe: ' + (e?.message || ''));
} else if (String(e?.message || '').includes('403')) {
alert('Falha ao remover a foto: sem permissão. Verifique as permissões do token e se o storage aceita esse usuário.\nDetalhe: ' + (e?.message || ''));
} else {
alert(e?.message || 'Não foi possível remover a foto do storage. Veja console para detalhes.');
}
} finally {
setUploadingPhoto(false);
} }
} }

View File

@ -599,10 +599,35 @@ export async function deletarExcecao(id: string): Promise<void> {
// ===== CONFIG =====
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? ENV_CONFIG.SUPABASE_URL; const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? ENV_CONFIG.SUPABASE_URL;
const REST = `${API_BASE}/rest/v1`; const REST = `${API_BASE}/rest/v1`;
const DEFAULT_AUTH_CALLBACK = 'https://mediconecta-app-liart.vercel.app/auth/callback';
const DEFAULT_LANDING = 'https://mediconecta-app-liart.vercel.app';
// Helper to build/normalize redirect URLs
function buildRedirectUrl(target?: 'paciente' | 'medico' | 'admin' | 'default', explicit?: string, redirectBase?: string) {
const DEFAULT_REDIRECT_BASE = redirectBase ?? DEFAULT_LANDING;
if (explicit) {
try {
const u = new URL(explicit);
return u.toString().replace(/\/$/, '');
} catch (e) {
}
}
const base = DEFAULT_REDIRECT_BASE.replace(/\/$/, '');
let path = '/';
if (target === 'paciente') path = '/paciente';
else if (target === 'medico') path = '/profissional';
else if (target === 'admin') path = '/dashboard';
return `${base}${path}`;
}
// Token salvo no browser (aceita auth_token ou token) // Token salvo no browser (aceita auth_token ou token)
function getAuthToken(): string | null { function getAuthToken(): string | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
@ -671,21 +696,35 @@ async function fetchWithFallback<T = any>(url: string, headers: Record<string, s
// Parse genérico // Parse genérico
async function parse<T>(res: Response): Promise<T> { async function parse<T>(res: Response): Promise<T> {
let json: any = null; let json: any = null;
let rawText = '';
try { try {
// Attempt to parse JSON; many endpoints may return empty bodies (204/204) or plain text
// so guard against unexpected EOF during json parsing
json = await res.json(); json = await res.json();
} catch (err) { } catch (err) {
console.error("Erro ao parsear a resposta como JSON:", err); // Try to capture raw text for better diagnostics
}
if (!res.ok) {
// Tenta também ler o body como texto cru para obter mensagens detalhadas
let rawText = '';
try { try {
rawText = await res.clone().text(); rawText = await res.clone().text();
} catch (tErr) { } catch (tErr) {
// ignore rawText = '';
} }
console.error("[API ERROR]", res.url, res.status, json, "raw:", rawText); if (rawText) {
console.warn('Resposta não-JSON recebida do servidor. raw text:', rawText);
} else {
console.warn('Resposta vazia ou inválida recebida do servidor; não foi possível parsear JSON:', err);
}
}
if (!res.ok) {
// If we didn't already collect rawText above, try to get it now for error messaging
if (!rawText) {
try {
rawText = await res.clone().text();
} catch (tErr) {
rawText = '';
}
}
console.error('[API ERROR]', res.url, res.status, json, 'raw:', rawText);
const code = (json && (json.error?.code || json.code)) ?? res.status; const code = (json && (json.error?.code || json.code)) ?? res.status;
const msg = (json && (json.error?.message || json.message || json.error)) ?? res.statusText; const msg = (json && (json.error?.message || json.message || json.error)) ?? res.statusText;
@ -1081,17 +1120,17 @@ export async function excluirPaciente(id: string | number): Promise<void> {
* Este endpoint usa a service role key e valida se o requisitante é administrador. * Este endpoint usa a service role key e valida se o requisitante é administrador.
*/ */
export async function assignRoleServerSide(userId: string, role: string): Promise<any> { export async function assignRoleServerSide(userId: string, role: string): Promise<any> {
const url = `/api/assign-role`; // Atribuição de roles é uma operação privilegiada que requer a
const token = getAuthToken(); // service_role key do Supabase (ou equivalente) e validação de permissões
const res = await fetch(url, { // server-side. Não execute isso do cliente.
method: 'POST', //
headers: { // Antes este helper chamava `/api/assign-role` (um proxy server-side).
'Content-Type': 'application/json', // Agora que o projeto deve usar apenas o endpoint público seguro de
...(token ? { Authorization: `Bearer ${token}` } : {}), // criação de usuários (OpenAPI `/create-user`), a atribuição deve ocorrer
}, // dentro desse endpoint no backend. Portanto este helper foi descontinuado
body: JSON.stringify({ user_id: userId, role }), // no cliente para evitar qualquer tentativa de realizar operação
}); // privilegiada no navegador.
return await parse<any>(res); throw new Error('assignRoleServerSide is not available in the client. Use the backend /create-user endpoint which performs role assignment server-side.');
} }
// ===== PACIENTES (Extra: verificação de CPF duplicado) ===== // ===== PACIENTES (Extra: verificação de CPF duplicado) =====
export async function verificarCpfDuplicado(cpf: string): Promise<boolean> { export async function verificarCpfDuplicado(cpf: string): Promise<boolean> {
@ -1402,15 +1441,41 @@ export async function vincularUserIdMedico(medicoId: string | number, userId: st
* Retorna o paciente atualizado. * Retorna o paciente atualizado.
*/ */
export async function vincularUserIdPaciente(pacienteId: string | number, userId: string): Promise<Paciente> { export async function vincularUserIdPaciente(pacienteId: string | number, userId: string): Promise<Paciente> {
const url = `${REST}/patients?id=eq.${encodeURIComponent(String(pacienteId))}`; // Validate pacienteId looks like a UUID (basic check) or at least a non-empty string/number
const idStr = String(pacienteId || '').trim();
if (!idStr) throw new Error('ID do paciente inválido ao tentar vincular user_id.');
const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
const looksLikeUuid = uuidRegex.test(idStr);
// Allow non-UUID ids (legacy) but log a debug warning when it's not UUID
if (!looksLikeUuid) console.warn('[vincularUserIdPaciente] pacienteId does not look like a UUID:', idStr);
const url = `${REST}/patients?id=eq.${encodeURIComponent(idStr)}`;
const payload = { user_id: String(userId) }; const payload = { user_id: String(userId) };
// Debug-friendly masked headers
const headers = withPrefer({ ...baseHeaders(), 'Content-Type': 'application/json' }, 'return=representation');
const maskedHeaders = { ...headers } as Record<string, string>;
if (maskedHeaders.Authorization) {
const a = maskedHeaders.Authorization as string;
maskedHeaders.Authorization = a.slice(0,6) + '...' + a.slice(-6);
}
console.debug('[vincularUserIdPaciente] PATCH', url, 'payload:', { ...payload }, 'headers(masked):', maskedHeaders);
const res = await fetch(url, { const res = await fetch(url, {
method: 'PATCH', method: 'PATCH',
headers: withPrefer({ ...baseHeaders(), 'Content-Type': 'application/json' }, 'return=representation'), headers,
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
// If parse throws, the existing parse() will log response details; ensure we also surface helpful context
try {
const arr = await parse<Paciente[] | Paciente>(res); const arr = await parse<Paciente[] | Paciente>(res);
return Array.isArray(arr) ? arr[0] : (arr as Paciente); return Array.isArray(arr) ? arr[0] : (arr as Paciente);
} catch (err) {
console.error('[vincularUserIdPaciente] erro ao vincular:', { pacienteId: idStr, userId, url });
throw err;
}
} }
@ -1587,6 +1652,13 @@ export type CreateUserInput = {
full_name: string; full_name: string;
phone?: string | null; phone?: string | null;
role: UserRoleEnum; role: UserRoleEnum;
// Optional: when provided, backend can use this to send magic links that redirect
// to the given URL or interpret `target` to build a role-specific redirect.
emailRedirectTo?: string;
// Compatibility: some integrations expect `redirect_url` as the parameter name
// for the post-auth redirect. Include it so backend/functions receive it.
redirect_url?: string;
target?: 'paciente' | 'medico' | 'admin' | 'default';
}; };
export type CreatedUser = { export type CreatedUser = {
@ -1618,26 +1690,56 @@ export function gerarSenhaAleatoria(): string {
} }
export async function criarUsuario(input: CreateUserInput): Promise<CreateUserResponse> { export async function criarUsuario(input: CreateUserInput): Promise<CreateUserResponse> {
// When running in the browser, call our Next.js proxy to avoid CORS/preflight // Prefer calling the Functions path first in environments where /create-user
// issues that some Edge Functions may have. On server-side, call the function // is not mapped at the API root (this avoids expected 404 noise). Keep the
// directly. // root /create-user as a fallback for deployments that expose it.
if (typeof window !== 'undefined') { const functionsUrl = `${API_BASE}/functions/v1/create-user`;
const proxyUrl = '/api/create-user' const url = `${API_BASE}/create-user`;
const res = await fetch(proxyUrl, {
let res: Response | null = null;
try {
res = await fetch(functionsUrl, {
method: 'POST', method: 'POST',
headers: { ...baseHeaders(), 'Content-Type': 'application/json' }, headers: { ...baseHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(input), body: JSON.stringify(input),
}) });
return await parse<CreateUserResponse>(res as Response) } catch (err: any) {
} console.error('[criarUsuario] fetch error for', functionsUrl, err);
// Attempt root /create-user fallback when functions path can't be reached
const url = `${API_BASE}/functions/v1/create-user`; try {
const res = await fetch(url, { console.warn('[criarUsuario] tentando fallback para', url);
method: "POST", const res2 = await fetch(url, {
headers: { ...baseHeaders(), "Content-Type": "application/json" }, method: 'POST',
headers: { ...baseHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
return await parse<CreateUserResponse>(res); return await parse<CreateUserResponse>(res2 as Response);
} catch (err2: any) {
console.error('[criarUsuario] fallback /create-user also failed', err2);
throw new Error(
'Falha ao contatar o endpoint /functions/v1/create-user e o fallback /create-user também falhou. Verifique disponibilidade e CORS. Detalhes: ' +
(err?.message ?? String(err)) + ' | fallback: ' + (err2?.message ?? String(err2))
);
}
}
// If we got a response but it's 404 (route not found), try the root path too
if (res && !res.ok && res.status === 404) {
try {
console.warn('[criarUsuario] /functions/v1/create-user returned 404; trying root path', url);
const res2 = await fetch(url, {
method: 'POST',
headers: { ...baseHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return await parse<CreateUserResponse>(res2 as Response);
} catch (err2: any) {
console.error('[criarUsuario] fallback /create-user failed after 404', err2);
// Fall through to parse original response to provide friendly error
}
}
return await parse<CreateUserResponse>(res as Response);
} }
// ===== ALTERNATIVA: Criar usuário diretamente via Supabase Auth ===== // ===== ALTERNATIVA: Criar usuário diretamente via Supabase Auth =====
@ -1689,7 +1791,15 @@ export async function criarUsuarioDirectAuth(input: {
} }
const responseData = await response.json(); const responseData = await response.json();
const userId = responseData.user?.id || responseData.id; // Try several common locations for the returned user id depending on Supabase configuration
const userId = responseData?.user?.id || responseData?.id || responseData?.data?.user?.id || responseData?.data?.id;
// If no user id was returned, treat this as a failure. Some Supabase setups (e.g. magic link / invite)
// may not return the user id immediately. In that case we cannot safely link the profile to a user.
if (!userId) {
console.warn('[DIRECT AUTH] signup response did not include a user id; response:', responseData);
throw new Error('Signup did not return a user id (provider may be configured for magic links or pending confirmation). Fallback cannot determine created user id.');
}
console.log('[DIRECT AUTH] Usuário criado:', userId); console.log('[DIRECT AUTH] Usuário criado:', userId);
@ -1723,93 +1833,72 @@ export async function criarUsuarioDirectAuth(input: {
// Criar usuário para MÉDICO no Supabase Auth (sistema de autenticação) // Criar usuário para MÉDICO no Supabase Auth (sistema de autenticação)
export async function criarUsuarioMedico(medico: { email: string; full_name: string; phone_mobile: string; }): Promise<any> { export async function criarUsuarioMedico(medico: { email: string; full_name: string; phone_mobile: string; }): Promise<any> {
// Prefer server-side creation (new OpenAPI create-user) so roles are assigned const redirectBase = DEFAULT_LANDING;
// correctly (and magic link is sent). Fallback to direct Supabase signup if const emailRedirectTo = `${redirectBase.replace(/\/$/, '')}/profissional`;
// the server function is unavailable. // Use the role-specific landing as the redirect_url so the magic link
try { // redirects users directly to the app path (e.g. /profissional).
const res = await criarUsuario({ email: medico.email, password: '', full_name: medico.full_name, phone: medico.phone_mobile, role: 'medico' as any }); const redirect_url = emailRedirectTo;
return res; // generate a secure-ish random password on the client so the caller can receive it
} catch (err) { const password = gerarSenhaAleatoria();
console.warn('[CRIAR MÉDICO] Falha no endpoint server-side create-user, tentando fallback direto no Supabase Auth:', err); const resp = await criarUsuario({ email: medico.email, password, full_name: medico.full_name, phone: medico.phone_mobile, role: 'medico' as any, emailRedirectTo, redirect_url, target: 'medico' });
// Fallback: create directly in Supabase Auth (old behavior) // Return backend response plus the generated password so the UI can show/save it
} return { ...(resp as any), password };
// --- Fallback to previous direct signup ---
const senha = gerarSenhaAleatoria();
const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup`;
const payload = {
email: medico.email,
password: senha,
data: {
userType: 'profissional',
full_name: medico.full_name,
phone: medico.phone_mobile,
}
};
const response = await fetch(signupUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"apikey": ENV_CONFIG.SUPABASE_ANON_KEY,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
let errorMsg = `Erro ao criar usuário (${response.status})`;
try { const errorData = JSON.parse(errorText); errorMsg = errorData.msg || errorData.message || errorData.error_description || errorMsg; } catch {}
throw new Error(errorMsg);
}
const responseData = await response.json();
return { success: true, user: responseData.user || responseData, email: medico.email, password: senha };
} }
// Criar usuário para PACIENTE no Supabase Auth (sistema de autenticação) // Criar usuário para PACIENTE no Supabase Auth (sistema de autenticação)
export async function criarUsuarioPaciente(paciente: { email: string; full_name: string; phone_mobile: string; }): Promise<any> { export async function criarUsuarioPaciente(paciente: { email: string; full_name: string; phone_mobile: string; }): Promise<any> {
// Prefer server-side creation (OpenAPI create-user) to assign role 'paciente'. const redirectBase = DEFAULT_LANDING;
const emailRedirectTo = `${redirectBase.replace(/\/$/, '')}/paciente`;
// Use the role-specific landing as the redirect_url so the magic link
// redirects users directly to the app path (e.g. /paciente).
const redirect_url = emailRedirectTo;
// generate a secure-ish random password on the client so the caller can receive it
const password = gerarSenhaAleatoria();
const resp = await criarUsuario({ email: paciente.email, password, full_name: paciente.full_name, phone: paciente.phone_mobile, role: 'paciente' as any, emailRedirectTo, redirect_url, target: 'paciente' });
// Return backend response plus the generated password so the UI can show/save it
return { ...(resp as any), password };
}
export async function sendMagicLink(
email: string,
options?: { emailRedirectTo?: string; target?: 'paciente' | 'medico' | 'admin' | 'default'; redirectBase?: string }
): Promise<{ success: boolean; message?: string }> {
if (!email) throw new Error('Email obrigatório para enviar magic link');
const url = `${API_BASE}/auth/v1/otp`;
const payload: any = { email };
const redirectUrl = buildRedirectUrl(options?.target, options?.emailRedirectTo, options?.redirectBase);
if (redirectUrl) {
// include both keys for broader compatibility across different Supabase setups
payload.options = { emailRedirectTo: redirectUrl, redirect_to: redirectUrl, redirect_url: redirectUrl };
}
try { try {
const res = await criarUsuario({ email: paciente.email, password: '', full_name: paciente.full_name, phone: paciente.phone_mobile, role: 'paciente' as any }); const res = await fetch(url, {
return res; method: 'POST',
} catch (err) {
console.warn('[CRIAR PACIENTE] Falha no endpoint server-side create-user, tentando fallback direto no Supabase Auth:', err);
}
// Fallback to previous direct signup behavior
const senha = gerarSenhaAleatoria();
const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup`;
const payload = {
email: paciente.email,
password: senha,
data: {
userType: 'paciente',
full_name: paciente.full_name,
phone: paciente.phone_mobile,
}
};
const response = await fetch(signupUrl, {
method: "POST",
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
"Accept": "application/json", Accept: 'application/json',
"apikey": ENV_CONFIG.SUPABASE_ANON_KEY, apikey: ENV_CONFIG.SUPABASE_ANON_KEY,
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!response.ok) { const text = await res.text();
const errorText = await response.text(); let json: any = null;
let errorMsg = `Erro ao criar usuário (${response.status})`; try { json = text ? JSON.parse(text) : null; } catch { json = null; }
try { const errorData = JSON.parse(errorText); errorMsg = errorData.msg || errorData.message || errorData.error_description || errorMsg; } catch {}
throw new Error(errorMsg); if (!res.ok) {
const msg = (json && (json.error || json.msg || json.message)) ?? text ?? res.statusText;
throw new Error(String(msg));
} }
const responseData = await response.json(); return { success: true, message: (json && (json.message || json.msg)) ?? 'Magic link enviado. Verifique seu email.' };
return { success: true, user: responseData.user || responseData, email: paciente.email, password: senha }; } catch (err: any) {
console.error('[sendMagicLink] erro ao enviar magic link', err);
throw new Error(err?.message ?? 'Falha ao enviar magic link');
}
} }
// ===== CEP (usado nos formulários) ===== // ===== CEP (usado nos formulários) =====
@ -1841,13 +1930,135 @@ export async function buscarCepAPI(cep: string): Promise<{
export async function listarAnexos(_id: string | number): Promise<any[]> { return []; } export async function listarAnexos(_id: string | number): Promise<any[]> { return []; }
export async function adicionarAnexo(_id: string | number, _file: File): Promise<any> { return {}; } export async function adicionarAnexo(_id: string | number, _file: File): Promise<any> { return {}; }
export async function removerAnexo(_id: string | number, _anexoId: string | number): Promise<void> {} export async function removerAnexo(_id: string | number, _anexoId: string | number): Promise<void> {}
export async function uploadFotoPaciente(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> { return {}; } /**
export async function removerFotoPaciente(_id: string | number): Promise<void> {} * Envia uma foto de avatar do paciente ao Supabase Storage.
* - Valida tipo (jpeg/png/webp) e tamanho (<= 2MB)
* - Faz POST multipart/form-data para /storage/v1/object/avatars/{userId}/avatar
* - Retorna o objeto { Key } quando upload for bem-sucedido
*/
export async function uploadFotoPaciente(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string; Key?: string }> {
const userId = String(_id);
if (!userId) throw new Error('ID do paciente é obrigatório para upload de foto');
if (!_file) throw new Error('Arquivo ausente');
// validações de formato e tamanho
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowed.includes(_file.type)) {
throw new Error('Formato inválido. Aceitamos JPG, PNG ou WebP.');
}
const maxBytes = 2 * 1024 * 1024; // 2MB
if (_file.size > maxBytes) {
throw new Error('Arquivo muito grande. Máx 2MB.');
}
const extMap: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
};
const ext = extMap[_file.type] || 'jpg';
const objectPath = `avatars/${userId}/avatar.${ext}`;
const uploadUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/avatars/${encodeURIComponent(userId)}/avatar`;
// Build multipart form data
const form = new FormData();
form.append('file', _file, `avatar.${ext}`);
const headers: Record<string, string> = {
// Supabase requires the anon key in 'apikey' header for client-side uploads
apikey: ENV_CONFIG.SUPABASE_ANON_KEY,
// Accept json
Accept: 'application/json',
};
// if user is logged in, include Authorization header
const jwt = getAuthToken();
if (jwt) headers.Authorization = `Bearer ${jwt}`;
const res = await fetch(uploadUrl, {
method: 'POST',
headers,
body: form as any,
});
// Supabase storage returns 200/201 with object info or error
if (!res.ok) {
const raw = await res.text().catch(() => '');
console.error('[uploadFotoPaciente] upload falhou', { status: res.status, raw });
if (res.status === 401) throw new Error('Não autenticado');
if (res.status === 403) throw new Error('Sem permissão para fazer upload');
throw new Error('Falha no upload da imagem');
}
// Try to parse JSON response
let json: any = null;
try { json = await res.json(); } catch { json = null; }
// The API may not return a structured body; return the Key we constructed
const key = (json && (json.Key || json.key)) ?? objectPath;
const publicUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/public/${encodeURIComponent('avatars')}/${encodeURIComponent(userId)}/avatar.${ext}`;
return { foto_url: publicUrl, Key: key };
}
/**
* Retorna a URL pública do avatar do usuário (acesso público)
* Path conforme OpenAPI: /storage/v1/object/public/avatars/{userId}/avatar.{ext}
* @param userId - ID do usuário (UUID)
* @param ext - extensão do arquivo: 'jpg' | 'png' | 'webp' (default 'jpg')
*/
export function getAvatarPublicUrl(userId: string | number): string {
// Build the public avatar URL without file extension.
// Example: https://<project>.supabase.co/storage/v1/object/public/avatars/{userId}/avatar
const id = String(userId || '').trim();
if (!id) throw new Error('userId é obrigatório para obter URL pública do avatar');
const base = String(ENV_CONFIG.SUPABASE_URL).replace(/\/$/, '');
// Note: Supabase public object path does not require an extension in some setups
return `${base}/storage/v1/object/public/${encodeURIComponent('avatars')}/${encodeURIComponent(id)}/avatar`;
}
export async function removerFotoPaciente(_id: string | number): Promise<void> {
const userId = String(_id || '').trim();
if (!userId) throw new Error('ID do paciente é obrigatório para remover foto');
const deleteUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/avatars/${encodeURIComponent(userId)}/avatar`;
const headers: Record<string,string> = {
apikey: ENV_CONFIG.SUPABASE_ANON_KEY,
Accept: 'application/json',
};
const jwt = getAuthToken();
if (jwt) headers.Authorization = `Bearer ${jwt}`;
try {
console.debug('[removerFotoPaciente] Deleting avatar for user:', userId, 'url:', deleteUrl);
const res = await fetch(deleteUrl, { method: 'DELETE', headers });
if (!res.ok) {
const raw = await res.text().catch(() => '');
console.warn('[removerFotoPaciente] remoção falhou', { status: res.status, raw });
// Treat 404 as success (object already absent)
if (res.status === 404) return;
// Include status and server body in the error message to aid debugging
const bodySnippet = raw && raw.length > 0 ? raw : '<sem corpo na resposta>';
if (res.status === 401) throw new Error(`Não autenticado (401). Resposta: ${bodySnippet}`);
if (res.status === 403) throw new Error(`Sem permissão para remover a foto (403). Resposta: ${bodySnippet}`);
throw new Error(`Falha ao remover a foto do storage (status ${res.status}). Resposta: ${bodySnippet}`);
}
// success
return;
} catch (err) {
// bubble up for the caller to handle
throw err;
}
}
export async function listarAnexosMedico(_id: string | number): Promise<any[]> { return []; } export async function listarAnexosMedico(_id: string | number): Promise<any[]> { return []; }
export async function adicionarAnexoMedico(_id: string | number, _file: File): Promise<any> { return {}; } export async function adicionarAnexoMedico(_id: string | number, _file: File): Promise<any> { return {}; }
export async function removerAnexoMedico(_id: string | number, _anexoId: string | number): Promise<void> {} export async function removerAnexoMedico(_id: string | number, _anexoId: string | number): Promise<void> {}
export async function uploadFotoMedico(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> { return {}; } export async function uploadFotoMedico(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string; Key?: string }> {
export async function removerFotoMedico(_id: string | number): Promise<void> {} // reuse same implementation as paciente but place under avatars/{userId}/avatar
return await uploadFotoPaciente(_id, _file);
}
export async function removerFotoMedico(_id: string | number): Promise<void> {
// reuse samme implementation
return await removerFotoPaciente(_id);
}
// ===== PERFIS DE USUÁRIOS ===== // ===== PERFIS DE USUÁRIOS =====
export async function listarPerfis(params?: { page?: number; limit?: number; q?: string; }): Promise<Profile[]> { export async function listarPerfis(params?: { page?: number; limit?: number; q?: string; }): Promise<Profile[]> {

View File

@ -1,7 +1,6 @@
import type { import type {
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
RefreshTokenResponse,
AuthError, AuthError,
UserData UserData
} from '@/types/auth'; } from '@/types/auth';
@ -68,10 +67,15 @@ async function processResponse<T>(response: Response): Promise<T> {
const errorMessage = data?.message || data?.error || response.statusText || 'Erro na autenticação'; const errorMessage = data?.message || data?.error || response.statusText || 'Erro na autenticação';
const errorCode = data?.code || String(response.status); const errorCode = data?.code || String(response.status);
// Log raw text as well to help debug cases where JSON is empty or {}
let rawText = '';
try { rawText = await response.clone().text(); } catch (_) { rawText = '<unable to read raw text>'; }
console.error('[AUTH ERROR]', { console.error('[AUTH ERROR]', {
url: response.url, url: response.url,
status: response.status, status: response.status,
data, data,
rawText,
}); });
throw new AuthenticationError(errorMessage, errorCode, data); throw new AuthenticationError(errorMessage, errorCode, data);
@ -89,40 +93,34 @@ export async function loginUser(
password: string, password: string,
userType: 'profissional' | 'paciente' | 'administrador' userType: 'profissional' | 'paciente' | 'administrador'
): Promise<LoginResponse> { ): Promise<LoginResponse> {
let url = AUTH_ENDPOINTS.LOGIN; try {
// Use the canonical Supabase token endpoint for password grant as configured in ENV_CONFIG.
const url = AUTH_ENDPOINTS.LOGIN;
const payload = { const payload = { email, password };
email,
password,
};
console.log('[AUTH-API] Iniciando login...', { console.log('[AUTH-API] Iniciando login (using AUTH_ENDPOINTS.LOGIN)...', {
email, email,
userType, userType,
url, url,
payload,
timestamp: new Date().toLocaleTimeString() timestamp: new Date().toLocaleTimeString()
}); });
console.log('🔑 [AUTH-API] Credenciais sendo usadas no login:'); // Do not log passwords. Log only non-sensitive info.
console.log('📧 Email:', email); debugRequest('POST', url, getLoginHeaders(), { email });
console.log('🔐 Senha:', password);
console.log('👤 UserType:', userType);
// Delay para visualizar na aba Network
await new Promise(resolve => setTimeout(resolve, 50));
// Perform single, explicit request to the configured token endpoint.
let response: Response;
try { try {
console.log('[AUTH-API] Enviando requisição de login...'); response = await fetch(url, {
// Debug: Log request sem credenciais sensíveis
debugRequest('POST', url, getLoginHeaders(), payload);
const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: getLoginHeaders(), headers: getLoginHeaders(),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} catch (networkError) {
console.error('[AUTH-API] Network error when calling', url, networkError);
throw new AuthenticationError('Não foi possível contatar o serviço de autenticação', 'AUTH_NETWORK_ERROR', networkError);
}
console.log(`[AUTH-API] Login response: ${response.status} ${response.statusText}`, { console.log(`[AUTH-API] Login response: ${response.status} ${response.statusText}`, {
url: response.url, url: response.url,
@ -130,23 +128,11 @@ export async function loginUser(
timestamp: new Date().toLocaleTimeString() timestamp: new Date().toLocaleTimeString()
}); });
// Se falhar, mostrar detalhes do erro // If endpoint is missing, make the error explicit
if (!response.ok) { if (response.status === 404) {
try { console.error('[AUTH-API] Final response was 404 (Not Found) for', url);
const errorText = await response.text(); throw new AuthenticationError('Signin endpoint not found (404) at configured AUTH_ENDPOINTS.LOGIN', 'SIGNIN_NOT_FOUND', { url });
console.error('[AUTH-API] Erro detalhado:', {
status: response.status,
statusText: response.statusText,
body: errorText,
headers: Object.fromEntries(response.headers.entries())
});
} catch (e) {
console.error('[AUTH-API] Não foi possível ler erro da resposta');
} }
}
// Delay adicional para ver status code
await new Promise(resolve => setTimeout(resolve, 50));
const data = await processResponse<any>(response); const data = await processResponse<any>(response);
@ -256,10 +242,10 @@ export async function logoutUser(token: string): Promise<void> {
/** /**
* Serviço para renovar token JWT * Serviço para renovar token JWT
*/ */
export async function refreshAuthToken(refreshToken: string): Promise<RefreshTokenResponse> { export async function refreshAuthToken(refreshToken: string): Promise<LoginResponse> {
const url = AUTH_ENDPOINTS.REFRESH; const url = AUTH_ENDPOINTS.REFRESH;
console.log('[AUTH] Renovando token'); console.log('[AUTH] Renovando token via REFRESH endpoint');
try { try {
const response = await fetch(url, { const response = await fetch(url, {
@ -272,10 +258,35 @@ export async function refreshAuthToken(refreshToken: string): Promise<RefreshTok
body: JSON.stringify({ refresh_token: refreshToken }), body: JSON.stringify({ refresh_token: refreshToken }),
}); });
const data = await processResponse<RefreshTokenResponse>(response); const data = await processResponse<any>(response);
console.log('[AUTH] Token renovado com sucesso'); console.log('[AUTH] Dados recebidos no refresh:', data);
return data;
if (!data || !data.access_token) {
console.error('[AUTH] Refresh não retornou access_token:', data);
throw new AuthenticationError('Refresh não retornou access_token', 'NO_TOKEN_RECEIVED', data);
}
// Adaptar para o mesmo formato usado no login
const adapted: LoginResponse = {
access_token: data.access_token || data.token,
refresh_token: data.refresh_token || null,
token_type: data.token_type || 'Bearer',
expires_in: data.expires_in || 3600,
user: {
id: data.user?.id || data.id || '',
email: data.user?.email || data.email || '',
name: data.user?.name || data.name || '',
userType: (data.user?.userType as any) || 'paciente',
profile: data.user?.profile || data.profile || {}
}
};
console.log('[AUTH] Token renovado com sucesso (adapted)', {
tokenSnippet: adapted.access_token?.substring(0, 20) + '...'
});
return adapted;
} catch (error) { } catch (error) {
console.error('[AUTH] Erro ao renovar token:', error); console.error('[AUTH] Erro ao renovar token:', error);
@ -283,11 +294,7 @@ export async function refreshAuthToken(refreshToken: string): Promise<RefreshTok
throw error; throw error;
} }
throw new AuthenticationError( throw new AuthenticationError('Não foi possível renovar a sessão', 'REFRESH_ERROR', error);
'Não foi possível renovar a sessão',
'REFRESH_ERROR',
error
);
} }
} }

View File

@ -9,7 +9,6 @@ export function debugRequest(
body?: any body?: any
) { ) {
if (process.env.NODE_ENV !== 'development') return; if (process.env.NODE_ENV !== 'development') return;
const headersWithoutSensitive = Object.keys(headers).reduce((acc, key) => { const headersWithoutSensitive = Object.keys(headers).reduce((acc, key) => {
// Não logar valores sensíveis, apenas nomes // Não logar valores sensíveis, apenas nomes
if (key.toLowerCase().includes('apikey') || key.toLowerCase().includes('authorization')) { if (key.toLowerCase().includes('apikey') || key.toLowerCase().includes('authorization')) {
@ -20,15 +19,35 @@ export function debugRequest(
return acc; return acc;
}, {} as Record<string, string>); }, {} as Record<string, string>);
const bodyShape = body ? Object.keys(typeof body === 'string' ? JSON.parse(body) : body) : []; const bodyShape = body ? Object.keys(typeof body === 'string' ? (() => {
try { return JSON.parse(body); } catch { return {}; }
})() : body) : [];
// Support relative URLs (e.g. '/api/signin-user') by providing a base when needed.
try {
let urlObj: URL;
try {
urlObj = new URL(url);
} catch (e) {
// Fallbacks: browser origin or localhost for server-side dev
const base = (typeof window !== 'undefined' && window.location && window.location.origin)
? window.location.origin
: 'http://localhost';
urlObj = new URL(url, base);
}
console.log('[DEBUG] Request Preview:', { console.log('[DEBUG] Request Preview:', {
method, method,
path: new URL(url).pathname, path: urlObj.pathname,
query: new URL(url).search, query: urlObj.search,
headerNames: Object.keys(headers), headerNames: Object.keys(headers),
headers: headersWithoutSensitive, headers: headersWithoutSensitive,
bodyShape, bodyShape,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} catch (err) {
// Never throw from debug tooling; keep best-effort logging
// eslint-disable-next-line no-console
console.warn('[DEBUG] debugRequest failed to parse URL or body', { url, error: err });
}
} }

View File

@ -74,16 +74,25 @@ class HttpClient {
const data = await response.json() const data = await response.json()
// Data pode ser um LoginResponse completo ou apenas { access_token }
const newAccessToken = data.access_token || data.token || null
const newRefreshToken = data.refresh_token || null
if (!newAccessToken) {
console.error('[HTTP] Refresh não retornou access_token', data)
throw new Error('Refresh did not return access_token')
}
// Atualizar tokens de forma atômica // Atualizar tokens de forma atômica
localStorage.setItem(AUTH_STORAGE_KEYS.TOKEN, data.access_token) localStorage.setItem(AUTH_STORAGE_KEYS.TOKEN, newAccessToken)
if (data.refresh_token) { if (newRefreshToken) {
localStorage.setItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN, data.refresh_token) localStorage.setItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN, newRefreshToken)
} }
console.log('[HTTP] Token renovado com sucesso!', { console.log('[HTTP] Token renovado com sucesso!', {
timestamp: new Date().toLocaleTimeString() timestamp: new Date().toLocaleTimeString()
}) })
return data.access_token return newAccessToken
} }
/** /**

View File

@ -41,6 +41,7 @@
"@radix-ui/react-toggle": "latest", "@radix-ui/react-toggle": "latest",
"@radix-ui/react-toggle-group": "latest", "@radix-ui/react-toggle-group": "latest",
"@radix-ui/react-tooltip": "latest", "@radix-ui/react-tooltip": "latest",
"@supabase/supabase-js": "^2.75.0",
"@vercel/analytics": "1.3.1", "@vercel/analytics": "1.3.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -2627,6 +2628,80 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@supabase/auth-js": {
"version": "2.75.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.75.0.tgz",
"integrity": "sha512-J8TkeqCOMCV4KwGKVoxmEBuDdHRwoInML2vJilthOo7awVCro2SM+tOcpljORwuBQ1vHUtV62Leit+5wlxrNtw==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.75.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.75.0.tgz",
"integrity": "sha512-18yk07Moj/xtQ28zkqswxDavXC3vbOwt1hDuYM3/7xPnwwpKnsmPyZ7bQ5th4uqiJzQ135t74La9tuaxBR6e7w==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15"
}
},
"node_modules/@supabase/node-fetch": {
"version": "2.6.15",
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "2.75.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.75.0.tgz",
"integrity": "sha512-YfBz4W/z7eYCFyuvHhfjOTTzRrQIvsMG2bVwJAKEVVUqGdzqfvyidXssLBG0Fqlql1zJFgtsPpK1n4meHrI7tg==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.75.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.75.0.tgz",
"integrity": "sha512-B4Xxsf2NHd5cEnM6MGswOSPSsZKljkYXpvzKKmNxoUmNQOfB7D8HOa6NwHcUBSlxcjV+vIrYKcYXtavGJqeGrw==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15",
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"ws": "^8.18.2"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.75.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.75.0.tgz",
"integrity": "sha512-wpJMYdfFDckDiHQaTpK+Ib14N/O2o0AAWWhguKvmmMurB6Unx17GGmYp5rrrqCTf8S1qq4IfIxTXxS4hzrUySg==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.75.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.75.0.tgz",
"integrity": "sha512-8UN/vATSgS2JFuJlMVr51L3eUDz+j1m7Ww63wlvHLKULzCDaVWYzvacCjBTLW/lX/vedI2LBI4Vg+01G9ufsJQ==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.75.0",
"@supabase/functions-js": "2.75.0",
"@supabase/node-fetch": "2.6.15",
"@supabase/postgrest-js": "2.75.0",
"@supabase/realtime-js": "2.75.0",
"@supabase/storage-js": "2.75.0"
}
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -3250,7 +3325,6 @@
"version": "22.18.8", "version": "22.18.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.8.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.8.tgz",
"integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==", "integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@ -3262,6 +3336,12 @@
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -3325,6 +3405,15 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.45.0", "version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz",
@ -9144,6 +9233,12 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/trim-canvas": { "node_modules/trim-canvas": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/trim-canvas/-/trim-canvas-0.1.2.tgz", "resolved": "https://registry.npmjs.org/trim-canvas/-/trim-canvas-0.1.2.tgz",
@ -9344,7 +9439,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
@ -9519,6 +9613,22 @@
"d3-timer": "^3.0.1" "d3-timer": "^3.0.1"
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -9634,6 +9744,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",

View File

@ -1,85 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { ENV_CONFIG } from '@/lib/env-config'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })
try {
const target = `${ENV_CONFIG.SUPABASE_URL}/functions/v1/create-user`
const headers: Record<string,string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'apikey': ENV_CONFIG.SUPABASE_ANON_KEY,
}
// forward authorization header if present (keeps the caller's identity)
if (req.headers.authorization) headers.Authorization = String(req.headers.authorization)
const r = await fetch(target, {
method: 'POST',
headers,
body: JSON.stringify(req.body),
})
// If the function is not available (404) or returns server error (5xx),
// perform a fallback: create the user via Supabase Auth signup so the
// client doesn't experience a hard 404.
if (r.status === 404 || r.status >= 500) {
console.warn('[proxy/create-user] function returned', r.status, 'falling back to signup')
// attempt signup
try {
const body = req.body || {}
const email = body.email
let password = body.password
const full_name = body.full_name
const phone = body.phone
const role = body.role || (Array.isArray(body.roles) ? body.roles[0] : undefined)
// generate a password if none provided
if (!password) {
const rand = Math.floor(Math.random() * 900) + 100
password = `senha${rand}!`
}
const userType = (role && String(role).toLowerCase() === 'paciente') ? 'paciente' : 'profissional'
const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup`
const signupRes = await fetch(signupUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'apikey': ENV_CONFIG.SUPABASE_ANON_KEY,
},
body: JSON.stringify({
email,
password,
data: { userType, full_name, phone }
}),
})
const signupText = await signupRes.text()
try {
const signupJson = JSON.parse(signupText)
return res.status(signupRes.status).json({ fallback: true, from: 'signup', result: signupJson })
} catch {
return res.status(signupRes.status).send(signupText)
}
} catch (err2: any) {
console.error('[proxy/create-user] fallback signup failed', err2)
return res.status(502).json({ error: 'Bad gateway (fallback failed)', details: String(err2) })
}
}
const text = await r.text()
try {
const json = JSON.parse(text)
return res.status(r.status).json(json)
} catch {
// not JSON, return raw text
res.status(r.status).send(text)
}
} catch (err: any) {
console.error('[proxy/create-user] error', err)
return res.status(502).json({ error: 'Bad gateway', details: String(err) })
}
}

View File

@ -222,6 +222,9 @@ importers:
eslint-plugin-unicorn: eslint-plugin-unicorn:
specifier: ^61.0.2 specifier: ^61.0.2
version: 61.0.2(eslint@9.36.0(jiti@2.5.1)) version: 61.0.2(eslint@9.36.0(jiti@2.5.1))
lightningcss:
specifier: ^1.30.2
version: 1.30.2
next: next:
specifier: ^15.5.4 specifier: ^15.5.4
version: 15.5.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 15.5.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -2500,70 +2503,140 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.30.1: lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
lightningcss-darwin-arm64@1.30.2:
resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.30.1: lightningcss-darwin-x64@1.30.1:
resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
lightningcss-darwin-x64@1.30.2:
resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.30.1: lightningcss-freebsd-x64@1.30.1:
resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
lightningcss-freebsd-x64@1.30.2:
resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.30.1: lightningcss-linux-arm-gnueabihf@1.30.1:
resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
lightningcss-linux-arm-gnueabihf@1.30.2:
resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.30.1: lightningcss-linux-arm64-gnu@1.30.1:
resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
lightningcss-linux-arm64-gnu@1.30.2:
resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-arm64-musl@1.30.1: lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-x64-gnu@1.30.1: lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-linux-x64-musl@1.30.1: lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-win32-arm64-msvc@1.30.1: lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.30.1: lightningcss-win32-x64-msvc@1.30.1:
resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
lightningcss-win32-x64-msvc@1.30.2:
resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.30.1: lightningcss@1.30.1:
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
lightningcss@1.30.2:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
locate-path@6.0.0: locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -5743,36 +5816,69 @@ snapshots:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-check: 0.4.0 type-check: 0.4.0
lightningcss-android-arm64@1.30.2:
optional: true
lightningcss-darwin-arm64@1.30.1: lightningcss-darwin-arm64@1.30.1:
optional: true optional: true
lightningcss-darwin-arm64@1.30.2:
optional: true
lightningcss-darwin-x64@1.30.1: lightningcss-darwin-x64@1.30.1:
optional: true optional: true
lightningcss-darwin-x64@1.30.2:
optional: true
lightningcss-freebsd-x64@1.30.1: lightningcss-freebsd-x64@1.30.1:
optional: true optional: true
lightningcss-freebsd-x64@1.30.2:
optional: true
lightningcss-linux-arm-gnueabihf@1.30.1: lightningcss-linux-arm-gnueabihf@1.30.1:
optional: true optional: true
lightningcss-linux-arm-gnueabihf@1.30.2:
optional: true
lightningcss-linux-arm64-gnu@1.30.1: lightningcss-linux-arm64-gnu@1.30.1:
optional: true optional: true
lightningcss-linux-arm64-gnu@1.30.2:
optional: true
lightningcss-linux-arm64-musl@1.30.1: lightningcss-linux-arm64-musl@1.30.1:
optional: true optional: true
lightningcss-linux-arm64-musl@1.30.2:
optional: true
lightningcss-linux-x64-gnu@1.30.1: lightningcss-linux-x64-gnu@1.30.1:
optional: true optional: true
lightningcss-linux-x64-gnu@1.30.2:
optional: true
lightningcss-linux-x64-musl@1.30.1: lightningcss-linux-x64-musl@1.30.1:
optional: true optional: true
lightningcss-linux-x64-musl@1.30.2:
optional: true
lightningcss-win32-arm64-msvc@1.30.1: lightningcss-win32-arm64-msvc@1.30.1:
optional: true optional: true
lightningcss-win32-arm64-msvc@1.30.2:
optional: true
lightningcss-win32-x64-msvc@1.30.1: lightningcss-win32-x64-msvc@1.30.1:
optional: true optional: true
lightningcss-win32-x64-msvc@1.30.2:
optional: true
lightningcss@1.30.1: lightningcss@1.30.1:
dependencies: dependencies:
detect-libc: 2.1.0 detect-libc: 2.1.0
@ -5788,6 +5894,22 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-arm64-msvc: 1.30.1
lightningcss-win32-x64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1
lightningcss@1.30.2:
dependencies:
detect-libc: 2.1.0
optionalDependencies:
lightningcss-android-arm64: 1.30.2
lightningcss-darwin-arm64: 1.30.2
lightningcss-darwin-x64: 1.30.2
lightningcss-freebsd-x64: 1.30.2
lightningcss-linux-arm-gnueabihf: 1.30.2
lightningcss-linux-arm64-gnu: 1.30.2
lightningcss-linux-arm64-musl: 1.30.2
lightningcss-linux-x64-gnu: 1.30.2
lightningcss-linux-x64-musl: 1.30.2
lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2
locate-path@6.0.0: locate-path@6.0.0:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0

View File

@ -1,70 +0,0 @@
import { NextResponse } from 'next/server'
import { ENV_CONFIG } from '@/lib/env-config'
type Body = {
user_id: string
role: string
}
async function getRequesterIdFromToken(token: string | null): Promise<string | null> {
if (!token) return null
try {
const url = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/user`
const res = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'apikey': ENV_CONFIG.SUPABASE_ANON_KEY, Authorization: `Bearer ${token}` } })
if (!res.ok) return null
const data = await res.json().catch(() => null)
return data?.id ?? null
} catch (err) {
console.error('[assign-role] erro ao obter requester id', err)
return null
}
}
export async function POST(req: Request) {
try {
const body = (await req.json()) as Body
if (!body || !body.user_id || !body.role) return NextResponse.json({ error: 'user_id and role required' }, { status: 400 })
// Business rule: there is no separate 'paciente' role — patients are any user.
// Prevent creation/assignment of a 'paciente' role to avoid confusion.
if (body.role === 'paciente') {
return NextResponse.json({ error: "role 'paciente' must not be created or assigned; patients are regular users" }, { status: 400 })
}
const authHeader = req.headers.get('authorization')
const token = authHeader?.startsWith('Bearer ') ? authHeader.split(' ')[1] : null
const requesterId = await getRequesterIdFromToken(token)
if (!requesterId) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 })
// Check if requester is administrador
const checkUrl = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/user_roles?user_id=eq.${requesterId}&role=eq.administrador`
const checkRes = await fetch(checkUrl, { method: 'GET', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Authorization: `Bearer ${token}` } })
if (!checkRes.ok) return NextResponse.json({ error: 'forbidden' }, { status: 403 })
const arr = await checkRes.json().catch(() => [])
if (!Array.isArray(arr) || arr.length === 0) return NextResponse.json({ error: 'forbidden' }, { status: 403 })
// Insert role using service role key from environment (must be set on the server)
const svcKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!svcKey) return NextResponse.json({ error: 'server misconfigured' }, { status: 500 })
const insertUrl = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/user_roles`
const insertRes = await fetch(insertUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', apikey: svcKey, Authorization: `Bearer ${svcKey}` },
body: JSON.stringify({ user_id: body.user_id, role: body.role }),
})
if (!insertRes.ok) {
const errBody = await insertRes.text().catch(() => null)
console.error('[assign-role] insert failed', insertRes.status, errBody)
return NextResponse.json({ error: 'failed to assign role', details: errBody }, { status: insertRes.status })
}
const result = await insertRes.json().catch(() => null)
return NextResponse.json({ ok: true, data: result })
} catch (err) {
console.error('[assign-role] unexpected error', err)
return NextResponse.json({ error: 'internal error' }, { status: 500 })
}
}