fix(profile): corrige persistência do avatar e melhora fluxo de salvamento
- Corrige campo de avatar na API (avatarUrl ao invés de image) - Altera fluxo para salvar foto apenas ao clicar em "Salvar alterações" - Adiciona preview local antes do upload definitivo - Ajusta shader para preencher bordas arredondadas do card 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4e2dd7f77e
commit
2c21daee79
2 changed files with 92 additions and 73 deletions
|
|
@ -75,7 +75,7 @@ export async function POST(request: NextRequest) {
|
||||||
// Atualiza o usuário no banco
|
// Atualiza o usuário no banco
|
||||||
await prisma.authUser.update({
|
await prisma.authUser.update({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
data: { image: avatarUrl },
|
data: { avatarUrl },
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|
@ -99,7 +99,7 @@ export async function DELETE() {
|
||||||
// Remove a imagem do usuário (volta ao padrão)
|
// Remove a imagem do usuário (volta ao padrão)
|
||||||
await prisma.authUser.update({
|
await prisma.authUser.update({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
data: { image: null },
|
data: { avatarUrl: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|
|
||||||
|
|
@ -336,10 +336,15 @@ function ProfileEditCard({
|
||||||
const [confirmPassword, setConfirmPassword] = useState("")
|
const [confirmPassword, setConfirmPassword] = useState("")
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [localAvatarUrl, setLocalAvatarUrl] = useState(avatarUrl)
|
const [localAvatarUrl, setLocalAvatarUrl] = useState(avatarUrl)
|
||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
const [pendingAvatarFile, setPendingAvatarFile] = useState<File | null>(null)
|
||||||
|
const [pendingAvatarPreview, setPendingAvatarPreview] = useState<string | null>(null)
|
||||||
|
const [pendingRemoveAvatar, setPendingRemoveAvatar] = useState(false)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
async function handleAvatarUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
// URL de exibição: preview pendente > URL atual (se não marcado para remoção)
|
||||||
|
const displayAvatarUrl = pendingAvatarPreview ?? (pendingRemoveAvatar ? null : localAvatarUrl)
|
||||||
|
|
||||||
|
function handleAvatarSelect(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
|
|
@ -355,57 +360,28 @@ function ProfileEditCard({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsUploadingAvatar(true)
|
// Cria preview local
|
||||||
try {
|
const previewUrl = URL.createObjectURL(file)
|
||||||
const formData = new FormData()
|
setPendingAvatarFile(file)
|
||||||
formData.append("file", file)
|
setPendingAvatarPreview(previewUrl)
|
||||||
|
setPendingRemoveAvatar(false)
|
||||||
|
|
||||||
const res = await fetch("/api/profile/avatar", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({ error: "Erro ao fazer upload" }))
|
|
||||||
throw new Error(data.error || "Erro ao fazer upload")
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
setLocalAvatarUrl(data.avatarUrl)
|
|
||||||
toast.success("Foto atualizada com sucesso!")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Erro ao fazer upload:", error)
|
|
||||||
toast.error(error instanceof Error ? error.message : "Erro ao fazer upload da foto")
|
|
||||||
} finally {
|
|
||||||
setIsUploadingAvatar(false)
|
|
||||||
// Limpa o input para permitir reselecionar o mesmo arquivo
|
// Limpa o input para permitir reselecionar o mesmo arquivo
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ""
|
fileInputRef.current.value = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRemoveAvatarClick() {
|
||||||
|
// Limpa preview pendente se houver
|
||||||
|
if (pendingAvatarPreview) {
|
||||||
|
URL.revokeObjectURL(pendingAvatarPreview)
|
||||||
}
|
}
|
||||||
|
setPendingAvatarFile(null)
|
||||||
async function handleRemoveAvatar() {
|
setPendingAvatarPreview(null)
|
||||||
if (!localAvatarUrl) return
|
// Marca para remoção apenas se já tiver avatar salvo
|
||||||
|
if (localAvatarUrl) {
|
||||||
setIsUploadingAvatar(true)
|
setPendingRemoveAvatar(true)
|
||||||
try {
|
|
||||||
const res = await fetch("/api/profile/avatar", {
|
|
||||||
method: "DELETE",
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({ error: "Erro ao remover foto" }))
|
|
||||||
throw new Error(data.error || "Erro ao remover foto")
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocalAvatarUrl(null)
|
|
||||||
toast.success("Foto removida com sucesso!")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Erro ao remover foto:", error)
|
|
||||||
toast.error(error instanceof Error ? error.message : "Erro ao remover foto")
|
|
||||||
} finally {
|
|
||||||
setIsUploadingAvatar(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -413,8 +389,9 @@ function ProfileEditCard({
|
||||||
const nameChanged = editName.trim() !== name
|
const nameChanged = editName.trim() !== name
|
||||||
const emailChanged = editEmail.trim().toLowerCase() !== email.toLowerCase()
|
const emailChanged = editEmail.trim().toLowerCase() !== email.toLowerCase()
|
||||||
const passwordChanged = newPassword.length > 0 || confirmPassword.length > 0
|
const passwordChanged = newPassword.length > 0 || confirmPassword.length > 0
|
||||||
return nameChanged || emailChanged || passwordChanged
|
const avatarChanged = pendingAvatarFile !== null || pendingRemoveAvatar
|
||||||
}, [editName, name, editEmail, email, newPassword, confirmPassword])
|
return nameChanged || emailChanged || passwordChanged || avatarChanged
|
||||||
|
}, [editName, name, editEmail, email, newPassword, confirmPassword, pendingAvatarFile, pendingRemoveAvatar])
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
@ -442,6 +419,46 @@ function ProfileEditCard({
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
|
// Processa avatar primeiro
|
||||||
|
if (pendingAvatarFile) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", pendingAvatarFile)
|
||||||
|
|
||||||
|
const avatarRes = await fetch("/api/profile/avatar", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!avatarRes.ok) {
|
||||||
|
const data = await avatarRes.json().catch(() => ({ error: "Erro ao fazer upload" }))
|
||||||
|
throw new Error(data.error || "Erro ao fazer upload da foto")
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarData = await avatarRes.json()
|
||||||
|
setLocalAvatarUrl(avatarData.avatarUrl)
|
||||||
|
|
||||||
|
// Limpa preview
|
||||||
|
if (pendingAvatarPreview) {
|
||||||
|
URL.revokeObjectURL(pendingAvatarPreview)
|
||||||
|
}
|
||||||
|
setPendingAvatarFile(null)
|
||||||
|
setPendingAvatarPreview(null)
|
||||||
|
} else if (pendingRemoveAvatar) {
|
||||||
|
const avatarRes = await fetch("/api/profile/avatar", {
|
||||||
|
method: "DELETE",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!avatarRes.ok) {
|
||||||
|
const data = await avatarRes.json().catch(() => ({ error: "Erro ao remover foto" }))
|
||||||
|
throw new Error(data.error || "Erro ao remover foto")
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalAvatarUrl(null)
|
||||||
|
setPendingRemoveAvatar(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Processa outros dados do perfil se houver
|
||||||
|
if (Object.keys(payload).length > 0) {
|
||||||
const res = await fetch("/api/portal/profile", {
|
const res = await fetch("/api/portal/profile", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -457,12 +474,14 @@ function ProfileEditCard({
|
||||||
if (data?.email) {
|
if (data?.email) {
|
||||||
setEditEmail(data.email)
|
setEditEmail(data.email)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setNewPassword("")
|
setNewPassword("")
|
||||||
setConfirmPassword("")
|
setConfirmPassword("")
|
||||||
toast.success("Dados atualizados com sucesso!")
|
toast.success("Dados atualizados com sucesso!")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Falha ao atualizar perfil", error)
|
console.error("Falha ao atualizar perfil", error)
|
||||||
toast.error("Não foi possível atualizar o perfil.")
|
toast.error(error instanceof Error ? error.message : "Não foi possível atualizar o perfil.")
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
@ -471,14 +490,14 @@ function ProfileEditCard({
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm overflow-hidden">
|
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm overflow-hidden">
|
||||||
{/* Header com shader animado */}
|
{/* Header com shader animado */}
|
||||||
<div className="relative h-20 overflow-hidden">
|
<div className="relative h-20 overflow-hidden rounded-t-2xl">
|
||||||
<ShaderBackground className="absolute inset-0 h-full w-full" />
|
<ShaderBackground className="absolute inset-0 h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="pb-4 -mt-10">
|
<CardHeader className="pb-4 -mt-10">
|
||||||
<div className="flex items-end gap-4">
|
<div className="flex items-end gap-4">
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<Avatar className="size-20 border-4 border-white shadow-lg ring-2 ring-neutral-200">
|
<Avatar className="size-20 border-4 border-white shadow-lg ring-2 ring-neutral-200">
|
||||||
<AvatarImage src={localAvatarUrl ?? undefined} alt={name} />
|
<AvatarImage src={displayAvatarUrl ?? undefined} alt={name} />
|
||||||
<AvatarFallback className="bg-neutral-200 text-xl font-semibold text-neutral-700">
|
<AvatarFallback className="bg-neutral-200 text-xl font-semibold text-neutral-700">
|
||||||
{initials}
|
{initials}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
|
|
@ -487,11 +506,11 @@ function ProfileEditCard({
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
onChange={handleAvatarUpload}
|
onChange={handleAvatarSelect}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
{isUploadingAvatar ? (
|
{isSubmitting ? (
|
||||||
<Loader2 className="size-5 text-white animate-spin" />
|
<Loader2 className="size-5 text-white animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -503,11 +522,11 @@ function ProfileEditCard({
|
||||||
>
|
>
|
||||||
<Camera className="size-4 text-white" />
|
<Camera className="size-4 text-white" />
|
||||||
</button>
|
</button>
|
||||||
{localAvatarUrl && (
|
{(displayAvatarUrl || pendingAvatarFile) && !pendingRemoveAvatar && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center justify-center size-8 rounded-full bg-red-500/80 hover:bg-red-500 transition-colors"
|
className="flex items-center justify-center size-8 rounded-full bg-red-500/80 hover:bg-red-500 transition-colors"
|
||||||
onClick={handleRemoveAvatar}
|
onClick={handleRemoveAvatarClick}
|
||||||
title="Remover foto"
|
title="Remover foto"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-white" />
|
<Trash2 className="size-4 text-white" />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue