From ab7dfa81ca3a5e1392124a59551e427d1ec94b2d Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 11:00:02 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20melhora=20p=C3=A1gina=20de=20perfil=20e?= =?UTF-8?q?=20integra=20prefer=C3=AAncias=20de=20notifica=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- convex/ticketNotifications.ts | 143 +++++++++++++-- convex/tickets.ts | 18 +- src/app/api/notifications/send/route.ts | 168 ++++++++++++++++++ src/app/api/profile/avatar/route.ts | 88 +++++++++ src/app/login/login-page-client.tsx | 29 ++- .../recuperar/forgot-password-page-client.tsx | 29 ++- .../reset-password-page-client.tsx | 29 ++- src/components/settings/settings-content.tsx | 98 ++++++++-- 8 files changed, 543 insertions(+), 59 deletions(-) create mode 100644 src/app/api/notifications/send/route.ts create mode 100644 src/app/api/profile/avatar/route.ts diff --git a/convex/ticketNotifications.ts b/convex/ticketNotifications.ts index 4e3b540..2116cfd 100644 --- a/convex/ticketNotifications.ts +++ b/convex/ticketNotifications.ts @@ -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 + 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, }) diff --git a/convex/tickets.ts b/convex/tickets.ts index 428fd74..f80342b 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -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, }) } } diff --git a/src/app/api/notifications/send/route.ts b/src/app/api/notifications/send/route.ts new file mode 100644 index 0000000..e97572c --- /dev/null +++ b/src/app/api/notifications/send/route.ts @@ -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 { + // 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 = { + 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 } + ) + } +} diff --git a/src/app/api/profile/avatar/route.ts b/src/app/api/profile/avatar/route.ts new file mode 100644 index 0000000..fce3604 --- /dev/null +++ b/src/app/api/profile/avatar/route.ts @@ -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 }) + } +} diff --git a/src/app/login/login-page-client.tsx b/src/app/login/login-page-client.tsx index f932c65..ee1c8f2 100644 --- a/src/app/login/login-page-client.tsx +++ b/src/app/login/login-page-client.tsx @@ -54,12 +54,19 @@ export function LoginPageClient() { return (
-
- -
- Sistema de chamados - Por Rever Tecnologia +
+ +
+ + Raven + + + Helpdesk +
+ + Por Rever Tecnologia +
@@ -81,8 +88,16 @@ export function LoginPageClient() { Desenvolvido por Esdras Renan
-
- +
+ +
+
+

Bem-vindo de volta

+

+ Gerencie seus chamados e tickets de forma simples +

+
+
) diff --git a/src/app/recuperar/forgot-password-page-client.tsx b/src/app/recuperar/forgot-password-page-client.tsx index 46562aa..2ea330d 100644 --- a/src/app/recuperar/forgot-password-page-client.tsx +++ b/src/app/recuperar/forgot-password-page-client.tsx @@ -63,12 +63,19 @@ export function ForgotPasswordPageClient() { return (
-
- -
- Sistema de chamados - Por Rever Tecnologia +
+ +
+ + Raven + + + Helpdesk +
+ + Por Rever Tecnologia +
@@ -99,8 +106,16 @@ export function ForgotPasswordPageClient() { Desenvolvido por Esdras Renan
-
- +
+ +
+
+

Recuperar acesso

+

+ Enviaremos as instruções para seu e-mail +

+
+
) diff --git a/src/app/redefinir-senha/reset-password-page-client.tsx b/src/app/redefinir-senha/reset-password-page-client.tsx index 96122d0..4a6b83c 100644 --- a/src/app/redefinir-senha/reset-password-page-client.tsx +++ b/src/app/redefinir-senha/reset-password-page-client.tsx @@ -96,12 +96,19 @@ export function ResetPasswordPageClient() { return (
-
- -
- Sistema de chamados - Por Rever Tecnologia +
+ +
+ + Raven + + + Helpdesk +
+ + Por Rever Tecnologia +
@@ -135,8 +142,16 @@ export function ResetPasswordPageClient() { Desenvolvido por Esdras Renan
-
- +
+ +
+
+

Nova senha

+

+ Crie uma senha segura para sua conta +

+
+
) diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 7f8edd2..5f0eca2 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -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 = { } const ROLE_COLORS: Record = { - 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(null) + + async function handleAvatarUpload(event: React.ChangeEvent) { + 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 ( - - -
+ + {/* Header com degradê */} +
+ +
- - - + + + {initials} +
-
+
{name || "Usuário"} {email}