develop #83
14
susconecta/.vscode/tasks.json
vendored
Normal file
14
susconecta/.vscode/tasks.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
|||||||
@ -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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
499
susconecta/app/resultados/ResultadosClient.tsx
Normal file
499
susconecta/app/resultados/ResultadosClient.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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[]> {
|
||||||
|
|||||||
@ -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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
135
susconecta/package-lock.json
generated
135
susconecta/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
122
susconecta/pnpm-lock.yaml
generated
122
susconecta/pnpm-lock.yaml
generated
@ -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
|
||||||
|
|||||||
@ -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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user