fix: corrige hydration, notificacoes e melhora visual

- Corrige hydration mismatch no Toaster (sonner) e ChatWidgetProvider
- Corrige API de preferencias de notificacao (typePreferences como string)
- Melhora visual do Switch (estado ativo em preto)
- Adiciona icones em circulos na pagina de notificacoes
- Corrige acentuacao em "Obrigatorio"
- Corrige centralizacao das estrelas de avaliacao nos e-mails
- Aplica Sentence case nos titulos dos templates de e-mail
- Adiciona script de teste de e-mail

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-15 19:52:46 -03:00
parent 7a3791117b
commit eedd446b36
7 changed files with 302 additions and 55 deletions

View file

@ -69,8 +69,8 @@ export async function GET(_request: NextRequest) {
tenantId: user.tenantId,
emailEnabled: true,
digestFrequency: "immediate",
typePreferences: {},
categoryPreferences: {},
typePreferences: "{}",
categoryPreferences: "{}",
},
})
}

View file

@ -1,5 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import dynamic from "next/dynamic"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
@ -17,12 +18,19 @@ function checkLiveChatApiExists() {
// Importacao dinamica para evitar problemas de SSR
const ChatWidget = dynamic(
() => import("./chat-widget").then((mod) => ({ default: mod.ChatWidget })),
{ ssr: false }
{ ssr: false, loading: () => null }
)
export function ChatWidgetProvider() {
const { role, isLoading } = useAuth()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Evita hydration mismatch - so renderiza apos montar no cliente
if (!mounted) return null
if (isLoading) return null
if (!isAgentOrAdmin(role)) return null

View file

@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Separator } from "@/components/ui/separator"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
interface NotificationType {
type: string
@ -304,37 +305,57 @@ export function NotificationPreferencesForm({ isPortal = false }: NotificationPr
<p className="text-sm text-muted-foreground">{group.description}</p>
</div>
<div className="space-y-3">
{types.map((notifType) => (
<div
key={notifType.type}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-3">
{notifType.required ? (
<Lock className="h-4 w-4 text-muted-foreground" />
) : localTypePrefs[notifType.type] ? (
<Bell className="h-4 w-4 text-primary" />
) : (
<BellOff className="h-4 w-4 text-muted-foreground" />
{types.map((notifType) => {
const isEnabled = localTypePrefs[notifType.type] ?? notifType.enabled
return (
<div
key={notifType.type}
className={cn(
"flex items-center justify-between rounded-lg border p-4 transition-colors",
isEnabled ? "border-foreground/20 bg-foreground/[0.02]" : "border-border"
)}
<div>
<Label className="text-sm font-medium">
{notifType.label}
</Label>
{notifType.required && (
<Badge variant="secondary" className="ml-2 text-xs">
Obrigatório
</Badge>
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full transition-colors",
notifType.required
? "bg-muted text-muted-foreground"
: isEnabled
? "bg-foreground text-background"
: "bg-muted text-muted-foreground"
)}
>
{notifType.required ? (
<Lock className="h-4 w-4" />
) : isEnabled ? (
<Bell className="h-4 w-4" />
) : (
<BellOff className="h-4 w-4" />
)}
</div>
<div>
<Label className={cn(
"text-sm font-medium transition-colors",
isEnabled ? "text-foreground" : "text-muted-foreground"
)}>
{notifType.label}
</Label>
{notifType.required && (
<Badge variant="secondary" className="ml-2 text-xs bg-foreground text-background">
Obrigatório
</Badge>
)}
</div>
</div>
<Switch
checked={isEnabled}
onCheckedChange={(checked) => toggleType(notifType.type, checked)}
disabled={!notifType.canDisable}
/>
</div>
<Switch
checked={localTypePrefs[notifType.type] ?? notifType.enabled}
onCheckedChange={(checked) => toggleType(notifType.type, checked)}
disabled={!notifType.canDisable}
/>
</div>
))}
)
})}
</div>
</div>
)

View file

@ -1,10 +1,19 @@
"use client"
import { useEffect, useState } from "react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Evita hydration mismatch - so renderiza apos montar no cliente
if (!mounted) return null
const baseClass =
"inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg"

View file

@ -12,14 +12,14 @@ const Switch = React.forwardRef<
<SwitchPrimitive.Root
ref={ref}
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-sidebar-accent data-[state=checked]:text-sidebar-accent-foreground",
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-foreground data-[state=checked]:border-foreground",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block h-5 w-5 translate-x-0 rounded-full bg-background shadow transition-transform duration-200 data-[state=checked]:translate-x-[20px]",
"pointer-events-none block h-5 w-5 translate-x-0 rounded-full bg-background shadow-sm transition-transform duration-200 data-[state=checked]:translate-x-[20px] data-[state=checked]:bg-background",
)}
/>
</SwitchPrimitive.Root>

View file

@ -236,12 +236,12 @@ function ratingStars(rateUrl: string): string {
}
return `
<table cellpadding="0" cellspacing="0" style="margin:16px 0;">
<table cellpadding="0" cellspacing="0" style="margin:16px auto;" align="center">
<tr>
${stars.join("")}
</tr>
</table>
<p style="color:${COLORS.textMuted};font-size:12px;margin:4px 0 0 0;">Clique em uma estrela para avaliar</p>
<p style="color:${COLORS.textMuted};font-size:12px;margin:4px 0 0 0;text-align:center;">Clique em uma estrela para avaliar</p>
`
}
@ -353,7 +353,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Chamado Aberto
Chamado aberto
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.
@ -368,7 +368,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
})}
<div style="text-align:center;margin-top:24px;">
${button("Ver Chamado", viewUrl)}
${button("Ver chamado", viewUrl)}
</div>
`,
data
@ -383,7 +383,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Chamado Resolvido
Chamado resolvido
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
Seu chamado foi marcado como resolvido. Esperamos que o atendimento tenha sido satisfatório!
@ -414,7 +414,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
</div>
<div style="text-align:center;margin-top:24px;">
${button("Ver Chamado", viewUrl)}
${button("Ver chamado", viewUrl)}
</div>
`,
data
@ -426,7 +426,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
const viewUrl = data.viewUrl as string
const isForRequester = data.isForRequester as boolean
const title = isForRequester ? "Agente Atribuído ao Chamado" : "Novo Chamado Atribuído"
const title = isForRequester ? "Agente atribuído ao chamado" : "Novo chamado atribuído"
const message = isForRequester
? `O agente ${escapeHtml(data.assigneeName)} foi atribuído ao seu chamado e em breve entrará em contato.`
: `Um novo chamado foi atribuído a você. Por favor, verifique os detalhes abaixo.`
@ -450,7 +450,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
})}
<div style="text-align:center;margin-top:24px;">
${button("Ver Chamado", viewUrl)}
${button("Ver chamado", viewUrl)}
</div>
`,
data
@ -466,7 +466,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Status Atualizado
Status atualizado
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
O status do seu chamado foi alterado.
@ -492,7 +492,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
</div>
<div style="text-align:center;margin-top:24px;">
${button("Ver Chamado", viewUrl)}
${button("Ver chamado", viewUrl)}
</div>
`,
data
@ -506,7 +506,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Nova Atualização no Chamado
Nova atualização no chamado
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
${escapeHtml(data.authorName)} adicionou um comentário ao seu chamado.
@ -527,7 +527,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
</div>
<div style="text-align:center;margin-top:24px;">
${button("Ver Chamado", viewUrl)}
${button("Ver chamado", viewUrl)}
</div>
`,
data
@ -541,14 +541,14 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Redefinição de Senha
Redefinição de senha
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
Recebemos uma solicitação para redefinir a senha da sua conta. Se você não fez essa solicitação, pode ignorar este e-mail.
</p>
<div style="text-align:center;margin:32px 0;">
${button("Redefinir Senha", resetUrl)}
${button("Redefinir senha", resetUrl)}
</div>
<p style="color:${COLORS.textMuted};font-size:12px;margin:24px 0 0 0;">
@ -567,14 +567,14 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Confirme seu E-mail
Confirme seu e-mail
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
Clique no botão abaixo para confirmar seu endereço de e-mail e ativar sua conta.
</p>
<div style="text-align:center;margin:32px 0;">
${button("Confirmar E-mail", verifyUrl)}
${button("Confirmar e-mail", verifyUrl)}
</div>
<p style="color:${COLORS.textMuted};font-size:12px;margin:24px 0 0 0;">
@ -618,7 +618,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
</div>
<div style="text-align:center;margin:32px 0;">
${button("Aceitar Convite", inviteUrl)}
${button("Aceitar convite", inviteUrl)}
</div>
<p style="color:${COLORS.textMuted};font-size:12px;margin:24px 0 0 0;">
@ -634,7 +634,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Novo Acesso Detectado
Novo acesso detectado
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
Detectamos um novo acesso à sua conta. Se foi você, pode ignorar este e-mail.
@ -672,7 +672,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
return baseTemplate(
`
<h1 style="color:${COLORS.statusPaused};font-size:24px;font-weight:600;margin:0 0 8px 0;">
SLA em Risco
SLA em risco
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
O chamado abaixo está próximo de violar o SLA. Ação necessária!
@ -697,7 +697,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
</div>
<div style="text-align:center;margin-top:24px;">
${button("Ver Chamado", viewUrl)}
${button("Ver chamado", viewUrl)}
</div>
`,
data
@ -711,7 +711,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
return baseTemplate(
`
<h1 style="color:${COLORS.priorityUrgent};font-size:24px;font-weight:600;margin:0 0 8px 0;">
SLA Violado
SLA violado
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
O chamado abaixo violou o SLA estabelecido. Atenção urgente necessária!
@ -736,7 +736,7 @@ const templates: Record<TemplateName, (data: TemplateData) => string> = {
</div>
<div style="text-align:center;margin-top:24px;">
${button("Ver Chamado", viewUrl)}
${button("Ver chamado", viewUrl)}
</div>
`,
data