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:
parent
1bc08d3a5f
commit
ab7dfa81ca
8 changed files with 543 additions and 59 deletions
168
src/app/api/notifications/send/route.ts
Normal file
168
src/app/api/notifications/send/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
88
src/app/api/profile/avatar/route.ts
Normal file
88
src/app/api/profile/avatar/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue