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

@ -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}
>
<Camera className="size-5 text-white" />
{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>