feat: melhora página de perfil e integra preferências de notificação

- Atualiza cores das badges para padrão cyan do projeto
- Adiciona degradê no header do card de perfil
- Implementa upload de foto de perfil via API Convex
- Integra notificações do Convex com preferências do usuário
- Cria API /api/notifications/send para verificar preferências
- Melhora layout das páginas de login/recuperação com degradê
- Adiciona badge "Helpdesk" e título "Raven" consistente

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rever-tecnologia 2025-12-15 11:00:02 -03:00
parent 1bc08d3a5f
commit ab7dfa81ca
8 changed files with 543 additions and 59 deletions

View file

@ -8,6 +8,45 @@ import { v } from "convex/values"
import { renderSimpleNotificationEmailHtml } from "./reactEmail"
import { buildBaseUrl } from "./url"
// API do Next.js para verificar preferências
async function sendViaNextApi(params: {
type: string
to: { email: string; name?: string; userId?: string }
subject: string
data: Record<string, unknown>
tenantId?: string
}): Promise<{ success: boolean; skipped?: boolean; reason?: string }> {
const baseUrl = buildBaseUrl()
const token = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET
if (!token) {
console.warn("[ticketNotifications] Token interno não configurado, enviando diretamente")
return { success: false, reason: "no_token" }
}
try {
const response = await fetch(`${baseUrl}/api/notifications/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(params),
})
if (!response.ok) {
const error = await response.text()
console.error("[ticketNotifications] Erro na API:", error)
return { success: false, reason: "api_error" }
}
return await response.json()
} catch (error) {
console.error("[ticketNotifications] Erro ao chamar API:", error)
return { success: false, reason: "fetch_error" }
}
}
function b64(input: string) {
return Buffer.from(input, "utf8").toString("base64")
}
@ -284,17 +323,15 @@ async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html:
export const sendTicketCreatedEmail = action({
args: {
to: v.string(),
userId: v.optional(v.string()),
userName: v.optional(v.string()),
ticketId: v.string(),
reference: v.number(),
subject: v.string(),
priority: v.string(),
tenantId: v.optional(v.string()),
},
handler: async (_ctx, { to, ticketId, reference, subject, priority }) => {
const smtp = buildSmtpConfig()
if (!smtp) {
console.warn("SMTP not configured; skipping ticket created email")
return { skipped: true }
}
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, priority, tenantId }) => {
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
@ -305,8 +342,34 @@ export const sendTicketCreatedEmail = action({
URGENT: "Urgente",
}
const priorityLabel = priorityLabels[priority] ?? priority
const mailSubject = `Novo chamado #${reference} aberto`
// Tenta usar a API do Next.js para verificar preferências
const apiResult = await sendViaNextApi({
type: "ticket_created",
to: { email: to, name: userName, userId },
subject: mailSubject,
data: {
reference,
subject,
status: "Pendente",
priority: priorityLabel,
viewUrl: url,
},
tenantId,
})
if (apiResult.success || apiResult.skipped) {
return apiResult
}
// Fallback: envia diretamente se a API falhar
const smtp = buildSmtpConfig()
if (!smtp) {
console.warn("SMTP not configured; skipping ticket created email")
return { skipped: true }
}
const html = await renderSimpleNotificationEmailHtml({
title: `Novo chamado #${reference} aberto`,
message: `Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: ${subject}\nPrioridade: ${priorityLabel}\nStatus: Pendente`,
@ -321,22 +384,45 @@ export const sendTicketCreatedEmail = action({
export const sendPublicCommentEmail = action({
args: {
to: v.string(),
userId: v.optional(v.string()),
userName: v.optional(v.string()),
ticketId: v.string(),
reference: v.number(),
subject: v.string(),
tenantId: v.optional(v.string()),
},
handler: async (_ctx, { to, ticketId, reference, subject }) => {
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
// Tenta usar a API do Next.js para verificar preferências
const apiResult = await sendViaNextApi({
type: "comment_public",
to: { email: to, name: userName, userId },
subject: mailSubject,
data: {
reference,
subject,
viewUrl: url,
},
tenantId,
})
if (apiResult.success || apiResult.skipped) {
return apiResult
}
// Fallback: envia diretamente se a API falhar
const smtp = buildSmtpConfig()
if (!smtp) {
console.warn("SMTP not configured; skipping ticket comment email")
return { skipped: true }
}
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
const html = await renderSimpleNotificationEmailHtml({
title: `Nova atualização no seu chamado #${reference}`,
message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`,
message: `Um novo comentário foi adicionado ao chamado "${subject}". Clique abaixo para visualizar e responder pelo portal.`,
ctaLabel: "Abrir e responder",
ctaUrl: url,
})
@ -348,22 +434,45 @@ export const sendPublicCommentEmail = action({
export const sendResolvedEmail = action({
args: {
to: v.string(),
userId: v.optional(v.string()),
userName: v.optional(v.string()),
ticketId: v.string(),
reference: v.number(),
subject: v.string(),
tenantId: v.optional(v.string()),
},
handler: async (_ctx, { to, ticketId, reference, subject }) => {
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Seu chamado #${reference} foi encerrado`
// Tenta usar a API do Next.js para verificar preferências
const apiResult = await sendViaNextApi({
type: "ticket_resolved",
to: { email: to, name: userName, userId },
subject: mailSubject,
data: {
reference,
subject,
viewUrl: url,
},
tenantId,
})
if (apiResult.success || apiResult.skipped) {
return apiResult
}
// Fallback: envia diretamente se a API falhar
const smtp = buildSmtpConfig()
if (!smtp) {
console.warn("SMTP not configured; skipping ticket resolution email")
return { skipped: true }
}
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Seu chamado #${reference} foi encerrado`
const html = await renderSimpleNotificationEmailHtml({
title: `Chamado #${reference} encerrado`,
message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
message: `O chamado "${subject}" foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
ctaLabel: "Ver detalhes",
ctaUrl: url,
})

View file

@ -2464,10 +2464,13 @@ export const create = mutation({
if (typeof schedulerRunAfter === "function") {
await schedulerRunAfter(0, api.ticketNotifications.sendTicketCreatedEmail, {
to: requesterEmail,
userId: String(requester._id),
userName: requester.name ?? undefined,
ticketId: String(id),
reference: nextRef,
subject,
priority: args.priority,
tenantId: args.tenantId,
})
}
}
@ -2870,15 +2873,19 @@ export const addComment = mutation({
await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch });
// Notificação por e-mail: comentário público para o solicitante
try {
const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email
const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
const snapshotEmail = requesterSnapshot?.email
if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
const schedulerRunAfter = ctx.scheduler?.runAfter
if (typeof schedulerRunAfter === "function") {
await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
to: snapshotEmail,
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
userName: requesterSnapshot?.name ?? undefined,
ticketId: String(ticketDoc._id),
reference: ticketDoc.reference ?? 0,
subject: ticketDoc.subject ?? "",
tenantId: ticketDoc.tenantId,
})
}
}
@ -3146,16 +3153,21 @@ export async function resolveTicketHandler(
// Notificação por e-mail: encerramento do chamado
try {
const requesterDoc = await ctx.db.get(ticketDoc.requesterId)
const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null
const requesterDoc = await ctx.db.get(ticketDoc.requesterId) as Doc<"users"> | null
const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
const email = requesterDoc?.email || requesterSnapshot?.email || null
const userName = requesterDoc?.name || requesterSnapshot?.name || undefined
if (email) {
const schedulerRunAfter = ctx.scheduler?.runAfter
if (typeof schedulerRunAfter === "function") {
await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, {
to: email,
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
userName,
ticketId: String(ticketId),
reference: ticketDoc.reference ?? 0,
subject: ticketDoc.subject ?? "",
tenantId: ticketDoc.tenantId,
})
}
}

View file

@ -0,0 +1,168 @@
/**
* API de Envio de Notificações
* Chamada pelo Convex para enviar e-mails respeitando preferências do usuário
*/
import { NextRequest, NextResponse } from "next/server"
import { z } from "zod"
import { prisma } from "@/lib/prisma"
import { sendEmail, type NotificationType, type TemplateName, NOTIFICATION_TYPES } from "@/server/email"
// Token de autenticação interna (deve ser o mesmo usado no Convex)
const INTERNAL_TOKEN = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET
const sendNotificationSchema = z.object({
type: z.enum([
"ticket_created",
"ticket_assigned",
"ticket_resolved",
"ticket_reopened",
"ticket_status_changed",
"ticket_priority_changed",
"comment_public",
"comment_response",
"sla_at_risk",
"sla_breached",
"automation",
]),
to: z.object({
email: z.string().email(),
name: z.string().optional(),
userId: z.string().optional(),
}),
subject: z.string(),
data: z.record(z.any()),
tenantId: z.string().optional(),
skipPreferenceCheck: z.boolean().optional(),
})
async function shouldSendNotification(
userId: string | undefined,
notificationType: NotificationType | "automation",
tenantId?: string
): Promise<boolean> {
// Automações sempre passam (são configuradas separadamente)
if (notificationType === "automation") return true
// Se não tem userId, não pode verificar preferências
if (!userId) return true
try {
const prefs = await prisma.notificationPreferences.findUnique({
where: { userId },
})
// Se não tem preferências, usa os defaults
if (!prefs) return true
// Se e-mail está desabilitado globalmente
if (!prefs.emailEnabled) return false
// Verifica se é um tipo obrigatório
const config = NOTIFICATION_TYPES[notificationType as NotificationType]
if (config?.required) return true
// Verifica preferências por tipo
const typePrefs = prefs.typePreferences
? JSON.parse(prefs.typePreferences as string)
: {}
if (notificationType in typePrefs) {
return typePrefs[notificationType] !== false
}
// Usa o default do tipo
return config?.defaultEnabled ?? true
} catch (error) {
console.error("[notifications/send] Erro ao verificar preferências:", error)
// Em caso de erro, envia a notificação
return true
}
}
function getTemplateForType(type: string): string {
const templateMap: Record<string, string> = {
ticket_created: "ticket_created",
ticket_assigned: "ticket_assigned",
ticket_resolved: "ticket_resolved",
ticket_reopened: "ticket_status",
ticket_status_changed: "ticket_status",
ticket_priority_changed: "ticket_status",
comment_public: "ticket_comment",
comment_response: "ticket_comment",
sla_at_risk: "sla_warning",
sla_breached: "sla_breached",
automation: "automation",
}
return templateMap[type] ?? "simple_notification"
}
export async function POST(request: NextRequest) {
try {
// Verifica autenticação
const authHeader = request.headers.get("authorization")
const token = authHeader?.replace("Bearer ", "")
if (!INTERNAL_TOKEN || token !== INTERNAL_TOKEN) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const body = await request.json()
const parsed = sendNotificationSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Dados inválidos", details: parsed.error.flatten() },
{ status: 400 }
)
}
const { type, to, subject, data, tenantId, skipPreferenceCheck } = parsed.data
// Verifica preferências do usuário
if (!skipPreferenceCheck) {
const shouldSend = await shouldSendNotification(
to.userId,
type as NotificationType | "automation",
tenantId
)
if (!shouldSend) {
return NextResponse.json({
success: true,
skipped: true,
reason: "user_preference_disabled",
})
}
}
// Envia o e-mail
const template = getTemplateForType(type)
const result = await sendEmail({
to: {
email: to.email,
name: to.name,
userId: to.userId,
},
subject,
template,
data,
notificationType: type === "automation" ? undefined : (type as NotificationType),
tenantId,
skipPreferenceCheck: true, // Já verificamos acima
})
return NextResponse.json({
success: result.success,
skipped: result.skipped,
reason: result.reason,
})
} catch (error) {
console.error("[notifications/send] Erro:", error)
return NextResponse.json(
{ error: "Erro interno do servidor" },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,88 @@
/**
* API de Upload de Avatar
* POST - Faz upload de uma nova foto de perfil
*/
import { NextRequest, NextResponse } from "next/server"
import { getServerSession } from "@/lib/auth-server"
import { prisma } from "@/lib/prisma"
import { createConvexClient } from "@/server/convex-client"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]
export async function POST(request: NextRequest) {
try {
const session = await getServerSession()
if (!session?.user?.id) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const formData = await request.formData()
const file = formData.get("file") as File | null
if (!file) {
return NextResponse.json({ error: "Nenhum arquivo enviado" }, { status: 400 })
}
// Valida tipo
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: "Tipo de arquivo não permitido. Use JPG, PNG, WebP ou GIF." },
{ status: 400 }
)
}
// Valida tamanho
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: "Arquivo muito grande. Máximo 5MB." },
{ status: 400 }
)
}
const convex = createConvexClient()
// Gera URL de upload
const uploadUrl = await convex.action(api.files.generateUploadUrl, {})
// Faz upload do arquivo
const uploadResponse = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
})
if (!uploadResponse.ok) {
console.error("[profile/avatar] Erro no upload:", await uploadResponse.text())
return NextResponse.json({ error: "Erro ao fazer upload" }, { status: 500 })
}
const { storageId } = (await uploadResponse.json()) as { storageId: Id<"_storage"> }
// Obtém URL pública do arquivo
const avatarUrl = await convex.action(api.files.getUrl, { storageId })
if (!avatarUrl) {
return NextResponse.json({ error: "Erro ao obter URL do avatar" }, { status: 500 })
}
// Atualiza o usuário no banco
await prisma.authUser.update({
where: { id: session.user.id },
data: { image: avatarUrl },
})
return NextResponse.json({
success: true,
avatarUrl,
})
} catch (error) {
console.error("[profile/avatar] Erro:", error)
return NextResponse.json({ error: "Erro interno do servidor" }, { status: 500 })
}
}

View file

@ -54,12 +54,19 @@ export function LoginPageClient() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-6 p-6 md:p-10">
<div className="flex flex-col items-center gap-1.5 text-center">
<Link href="/" className="text-xl font-semibold text-neutral-900">
<div className="flex flex-col leading-none items-center">
<span>Sistema de chamados</span>
<span className="mt-0.5 text-xs text-muted-foreground">Por Rever Tecnologia</span>
<div className="flex flex-col items-center gap-2 text-center">
<Link href="/" className="group flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<span className="text-2xl font-bold tracking-tight text-neutral-900">
Raven
</span>
<span className="rounded-full bg-cyan-100 px-2.5 py-0.5 text-xs font-medium text-cyan-700 border border-cyan-200">
Helpdesk
</span>
</div>
<span className="text-sm text-neutral-500">
Por Rever Tecnologia
</span>
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
@ -81,8 +88,16 @@ export function LoginPageClient() {
Desenvolvido por Esdras Renan
</footer>
</div>
<div className="relative hidden overflow-hidden lg:flex">
<ShaderBackground className="h-full w-full" />
<div className="relative hidden overflow-hidden bg-gradient-to-br from-neutral-800 via-neutral-700 to-neutral-600 lg:flex">
<ShaderBackground className="h-full w-full opacity-50" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-white">
<h2 className="text-3xl font-bold tracking-tight">Bem-vindo de volta</h2>
<p className="mt-2 text-neutral-300">
Gerencie seus chamados e tickets de forma simples
</p>
</div>
</div>
</div>
</div>
)

View file

@ -63,12 +63,19 @@ export function ForgotPasswordPageClient() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-6 p-6 md:p-10">
<div className="flex flex-col items-center gap-1.5 text-center">
<Link href="/" className="text-xl font-semibold text-neutral-900">
<div className="flex flex-col leading-none items-center">
<span>Sistema de chamados</span>
<span className="mt-0.5 text-xs text-muted-foreground">Por Rever Tecnologia</span>
<div className="flex flex-col items-center gap-2 text-center">
<Link href="/" className="group flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<span className="text-2xl font-bold tracking-tight text-neutral-900">
Raven
</span>
<span className="rounded-full bg-cyan-100 px-2.5 py-0.5 text-xs font-medium text-cyan-700 border border-cyan-200">
Helpdesk
</span>
</div>
<span className="text-sm text-neutral-500">
Por Rever Tecnologia
</span>
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
@ -99,8 +106,16 @@ export function ForgotPasswordPageClient() {
Desenvolvido por Esdras Renan
</footer>
</div>
<div className="relative hidden overflow-hidden lg:flex">
<ShaderBackground className="h-full w-full" />
<div className="relative hidden overflow-hidden bg-gradient-to-br from-neutral-800 via-neutral-700 to-neutral-600 lg:flex">
<ShaderBackground className="h-full w-full opacity-50" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-white">
<h2 className="text-3xl font-bold tracking-tight">Recuperar acesso</h2>
<p className="mt-2 text-neutral-300">
Enviaremos as instruções para seu e-mail
</p>
</div>
</div>
</div>
</div>
)

View file

@ -96,12 +96,19 @@ export function ResetPasswordPageClient() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-6 p-6 md:p-10">
<div className="flex flex-col items-center gap-1.5 text-center">
<Link href="/" className="text-xl font-semibold text-neutral-900">
<div className="flex flex-col leading-none items-center">
<span>Sistema de chamados</span>
<span className="mt-0.5 text-xs text-muted-foreground">Por Rever Tecnologia</span>
<div className="flex flex-col items-center gap-2 text-center">
<Link href="/" className="group flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<span className="text-2xl font-bold tracking-tight text-neutral-900">
Raven
</span>
<span className="rounded-full bg-cyan-100 px-2.5 py-0.5 text-xs font-medium text-cyan-700 border border-cyan-200">
Helpdesk
</span>
</div>
<span className="text-sm text-neutral-500">
Por Rever Tecnologia
</span>
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
@ -135,8 +142,16 @@ export function ResetPasswordPageClient() {
Desenvolvido por Esdras Renan
</footer>
</div>
<div className="relative hidden overflow-hidden lg:flex">
<ShaderBackground className="h-full w-full" />
<div className="relative hidden overflow-hidden bg-gradient-to-br from-neutral-800 via-neutral-700 to-neutral-600 lg:flex">
<ShaderBackground className="h-full w-full opacity-50" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-white">
<h2 className="text-3xl font-bold tracking-tight">Nova senha</h2>
<p className="mt-2 text-neutral-300">
Crie uma senha segura para sua conta
</p>
</div>
</div>
</div>
</div>
)

View file

@ -1,11 +1,10 @@
"use client"
import { FormEvent, useMemo, useState } from "react"
import { FormEvent, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import {
Settings2,
Share2,
ShieldCheck,
UserPlus,
@ -18,10 +17,10 @@ import {
Mail,
Key,
User,
Building2,
Shield,
Clock,
Camera,
Loader2,
} from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
@ -55,11 +54,11 @@ const ROLE_LABELS: Record<string, string> = {
}
const ROLE_COLORS: Record<string, string> = {
admin: "bg-violet-100 text-violet-700 border-violet-200",
manager: "bg-blue-100 text-blue-700 border-blue-200",
agent: "bg-cyan-100 text-cyan-700 border-cyan-200",
collaborator: "bg-slate-100 text-slate-700 border-slate-200",
customer: "bg-slate-100 text-slate-700 border-slate-200",
admin: "bg-cyan-100 text-cyan-800 border-cyan-300",
manager: "bg-cyan-50 text-cyan-700 border-cyan-200",
agent: "bg-cyan-50 text-cyan-600 border-cyan-200",
collaborator: "bg-neutral-100 text-neutral-600 border-neutral-200",
customer: "bg-neutral-100 text-neutral-600 border-neutral-200",
}
const SETTINGS_ACTIONS: SettingsAction[] = [
@ -326,6 +325,55 @@ function ProfileEditCard({
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [localAvatarUrl, setLocalAvatarUrl] = useState(avatarUrl)
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
async function handleAvatarUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
if (!file) return
// Valida tamanho (5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error("Arquivo muito grande. Máximo 5MB.")
return
}
// Valida tipo
if (!["image/jpeg", "image/png", "image/webp", "image/gif"].includes(file.type)) {
toast.error("Tipo de arquivo não permitido. Use JPG, PNG, WebP ou GIF.")
return
}
setIsUploadingAvatar(true)
try {
const formData = new FormData()
formData.append("file", file)
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
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
}
const hasChanges = useMemo(() => {
const nameChanged = editName.trim() !== name
@ -387,25 +435,39 @@ function ProfileEditCard({
}
return (
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm">
<CardHeader className="pb-4">
<div className="flex items-start gap-4">
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm overflow-hidden">
{/* Header com degradê */}
<div className="h-20 bg-gradient-to-r from-neutral-800 via-neutral-700 to-neutral-600" />
<CardHeader className="pb-4 -mt-10">
<div className="flex items-end gap-4">
<div className="relative group">
<Avatar className="size-16 border-2 border-white shadow-md">
<AvatarImage src={avatarUrl ?? undefined} alt={name} />
<AvatarFallback className="bg-gradient-to-br from-cyan-500 to-blue-600 text-lg font-semibold text-white">
<Avatar className="size-20 border-4 border-white shadow-lg ring-2 ring-neutral-200">
<AvatarImage src={localAvatarUrl ?? undefined} alt={name} />
<AvatarFallback className="bg-gradient-to-br from-cyan-500 to-cyan-600 text-xl font-semibold text-white">
{initials}
</AvatarFallback>
</Avatar>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={handleAvatarUpload}
className="hidden"
/>
<button
type="button"
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => toast.info("Upload de foto em breve!")}
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100 disabled:cursor-not-allowed"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingAvatar}
>
{isUploadingAvatar ? (
<Loader2 className="size-5 text-white animate-spin" />
) : (
<Camera className="size-5 text-white" />
)}
</button>
</div>
<div className="flex-1">
<div className="flex-1 pb-1">
<CardTitle className="text-lg font-semibold text-neutral-900">{name || "Usuário"}</CardTitle>
<CardDescription className="text-sm text-neutral-500">{email}</CardDescription>
</div>