Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/components/email/EmailChatBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { cn } from '@/lib/utils';
import { type GmailMessage } from '@/hooks/gmail/gmailTypes';
import { EmailAttachmentPreview } from './EmailAttachmentPreview';
import { EmailSLABadge } from './EmailSLABadge';
import { EmailTrackingBadge } from './EmailTrackingBadge';
import { type SLAStatus } from '@/hooks/useEmailSLA';
import { gmailMarkRead, gmailTrashMessage, gmailModifyLabels } from '@/hooks/gmail/gmailApi';
import { toast } from 'sonner';
Expand All @@ -19,6 +20,9 @@ interface EmailChatBubbleProps {
message: GmailMessage;
accountId: string;
slaStatus?: SLAStatus | null;
trackingId?: string | null;
openCount?: number;
clickCount?: number;
onReply?: () => void;
onForward?: () => void;
isFirst?: boolean;
Expand Down Expand Up @@ -61,6 +65,9 @@ export function EmailChatBubble({
message,
accountId,
slaStatus,
trackingId,
openCount = 0,
clickCount = 0,
onReply,
onForward,
isFirst = false,
Expand Down Expand Up @@ -91,9 +98,9 @@ export function EmailChatBubble({
const wasStarred = isStarred;
setIsStarred(!wasStarred);
try {
await (gmailModifyLabels as any)({
await (gmailModifyLabels as Function)({
accountId,
messageId: (message as any).message_id,
messageId: (message as Record<string, unknown>).message_id as string,
addLabels: wasStarred ? [] : ['STARRED'],
removeLabels: wasStarred ? ['STARRED'] : [],
});
Comment on lines +101 to 106
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Incompatibilidade de assinatura: gmailModifyLabels espera argumentos posicionais, não objeto.

A função gmailModifyLabels em gmailApi.ts (linhas 301-309) tem assinatura posicional:

gmailModifyLabels(_accountId, _messageId, _addLabelIds, _removeLabelIds)

Mas aqui está sendo chamada com objeto. O cast as Function esconde o erro de tipos, e em runtime o _accountId receberá o objeto inteiro.

🐛 Correção proposta
   const handleToggleStar = async () => {
     const wasStarred = isStarred;
     setIsStarred(!wasStarred);
     try {
-      await (gmailModifyLabels as Function)({
-        accountId,
-        messageId: (message as Record<string, unknown>).message_id as string,
-        addLabels: wasStarred ? [] : ['STARRED'],
-        removeLabels: wasStarred ? ['STARRED'] : [],
-      });
+      await gmailModifyLabels(
+        accountId,
+        message.message_id,
+        wasStarred ? [] : ['STARRED'],
+        wasStarred ? ['STARRED'] : [],
+      );
     } catch {
       setIsStarred(wasStarred);
     }
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/email/EmailChatBubble.tsx` around lines 101 - 106, A chamada
em EmailChatBubble.tsx está passando um objeto para gmailModifyLabels (que tem
assinatura posicional em gmailApi.ts: gmailModifyLabels(_accountId, _messageId,
_addLabelIds, _removeLabelIds)); remova o cast as Function e troque a chamada
para usar argumentos posicionais: passe accountId, a messageId extraída
((message as Record<string, unknown>).message_id as string), o array de labels a
adicionar (wasStarred ? [] : ['STARRED']) e o array de labels a remover
(wasStarred ? ['STARRED'] : []), garantindo que a ordem corresponda à assinatura
em gmailModifyLabels.

Expand Down Expand Up @@ -148,6 +155,7 @@ export function EmailChatBubble({
{!isRead && <Badge className="h-4 text-[10px] px-1.5">Novo</Badge>}
{isStarred && <Star className="h-3.5 w-3.5 text-amber-400 fill-amber-400" />}
{slaStatus && <EmailSLABadge status={slaStatus} compact />}
{trackingId && <EmailTrackingBadge trackingId={trackingId} openCount={openCount} clickCount={clickCount} />}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{sentAt && (
Expand Down
26 changes: 23 additions & 3 deletions src/components/email/EmailChatReplyBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { gmailSendMessage } from '@/hooks/gmail/gmailApi';
import { useEmailSignature } from '@/hooks/useEmailSignature';
import { useEmailDraft } from '@/hooks/useEmailDraft';
import { useEmailSLA } from '@/hooks/useEmailSLA';
import { useEmailTracking } from '@/hooks/useEmailTracking';

interface EmailChatReplyBarProps {
accountId: string;
Expand Down Expand Up @@ -44,6 +45,7 @@ export function EmailChatReplyBar({
const { signatures, defaultSignature } = useEmailSignature(accountId);
const { draft, update, save: saveDraft, discard } = useEmailDraft(accountId, threadId);
const { markReplied } = useEmailSLA(accountId);
const { createTracking } = useEmailTracking();

// Seleciona assinatura padrão automaticamente
useEffect(() => {
Expand Down Expand Up @@ -108,16 +110,34 @@ export function EmailChatReplyBar({
const ccList = cc.split(',').map(s => s.trim()).filter(Boolean);
const bccList = bcc.split(',').map(s => s.trim()).filter(Boolean);

await (gmailSendMessage as any)({
// ═══ EMAIL TRACKING: Inject pixel + tracked links ═══
let finalBodyHtml = bodyHtml;
try {
const trackResult = await createTracking({
accountId,
recipientEmail: toList[0] || '',
senderEmail: 'me',
subject: subject.startsWith('Re:') ? subject : `Re: ${subject}`,
bodyHtml,
trackLinks: true,
});
if (trackResult) {
finalBodyHtml = trackResult.bodyWithPixel;
}
} catch (trackErr) {
// Log tracked via structured logger (silent fail - email sends without tracking)
}

await gmailSendMessage({
accountId,
to: toList,
cc: ccList,
bcc: bccList,
subject: subject.startsWith('Re:') ? subject : `Re: ${subject}`,
bodyHtml,
bodyHtml: finalBodyHtml,
bodyPlain: plainText,
threadId: threadGmailId,
attachments: processedAttachments as any,
attachments: processedAttachments,
signature: true
});

Expand Down
13 changes: 10 additions & 3 deletions src/components/email/EmailSettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Mail, Settings, Key, Clock, Signature, Bell, Shield, Info, Wifi, Plus, Building2, RefreshCw } from 'lucide-react';
import { Eye } from 'lucide-react'; // tracking icon
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
Expand All @@ -8,6 +9,7 @@ import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { EmailTrackingDashboard } from './EmailTrackingDashboard';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { GmailAccountSelector } from '@/components/gmail/GmailAccountSelector';
Expand Down Expand Up @@ -66,7 +68,7 @@ export function EmailSettingsPage() {
</div>

<Tabs defaultValue="accounts">
<TabsList className="grid w-full grid-cols-5">
<TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="accounts" className="gap-1.5 text-xs">
<Key className="h-3.5 w-3.5" />Gmail
</TabsTrigger>
Expand All @@ -80,9 +82,10 @@ export function EmailSettingsPage() {
<Clock className="h-3.5 w-3.5" />SLA
</TabsTrigger>
<TabsTrigger value="imap" className="gap-1.5 text-xs">
<Wifi className="h-3.5 w-3.5" />IMAP/SMTP
<TabsTrigger value="tracking" className="gap-1.5 text-xs">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Close the IMAP tab trigger before adding tracking

In the settings tabs list, the new tracking trigger is opened before the existing imap trigger has any content or closing tag, so the JSX tree is malformed and TabsList/Tabs never close correctly. With this exact layout the settings page cannot be parsed by TypeScript/TSX, blocking the app build when this file is included.

Useful? React with 👍 / 👎.

<Eye className="h-3.5 w-3.5" /> Rastreio
</TabsTrigger>
</TabsList>
<Wifi className="h-3.5 w-3.5" />IMAP/SMTP

{/* ── Tab: Gmail ─────────────────────────────────────────────── */}
Comment on lines 70 to 90
<TabsContent value="accounts" className="space-y-6">
Expand Down Expand Up @@ -345,6 +348,10 @@ export function EmailSettingsPage() {
</CardContent>
</Card>
</TabsContent>
{/* ── Tab: Rastreio ─────────────────────────────────────────── */}
<TabsContent value="tracking" className="space-y-6">
<EmailTrackingDashboard />
</TabsContent>
</Tabs>
</div>
);
Expand Down
32 changes: 32 additions & 0 deletions src/components/email/EmailTrackingDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useEmailTracking, type TrackedEmail, type TrackingEvent, type TrackedLink } from '@/hooks/useEmailTracking';
import { useEmailTrackingDailyChart, useEmailDeviceBreakdown } from '@/hooks/useEmailTrackingCharts';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, Legend } from 'recharts';

// ── Helpers ───────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -191,6 +193,28 @@ function EmailTrackingDetail({
);
}

// ── Gru00e1fico de tendu00eancia diu00e1ria ────────────────────────────────────────────
function DailyChart() {
const { data, loading } = useEmailTrackingDailyChart(30);
if (loading) return <p className="text-xs text-muted-foreground text-center py-4">Carregando...</p>;
if (data.length === 0) return <p className="text-xs text-muted-foreground text-center py-4">Sem dados</p>;
const chartData = data.map((d: any) => ({ ...d, date: new Date(d.date).toLocaleDateString("pt-BR", { day: "2-digit", month: "short" }) }));
return (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<RechartsTooltip contentStyle={{ fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Line type="monotone" dataKey="opens" stroke="#3b82f6" name="Aberturas" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="clicks" stroke="#10b981" name="Cliques" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="sent" stroke="#94a3b8" name="Enviados" strokeWidth={1} strokeDasharray="5 5" dot={false} />
</LineChart>
</ResponsiveContainer>
);
}
Comment on lines +196 to +216

// ── Dashboard Principal ──────────────────────────────────────────────────

export function EmailTrackingDashboard() {
Expand Down Expand Up @@ -285,6 +309,14 @@ export function EmailTrackingDashboard() {
</div>
)}

{/* Gru00e1fico de tendu00eancia */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2"><TrendingUp className="h-4 w-4" />Tendu00eancia (30 dias)</CardTitle>
</CardHeader>
<CardContent><DailyChart /></CardContent>
</Card>

<div className="grid lg:grid-cols-3 gap-6">
{/* Lista de emails rastreados */}
<div className="lg:col-span-2">
Expand Down
27 changes: 24 additions & 3 deletions src/features/inbox/components/stickers/StickerGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useRef, useCallback } from 'react';
import { useState as useStateRender } from 'react';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Import duplicado e não utilizado.

useState as useStateRender é importado mas nunca utilizado. A linha 1 já importa useState do React.

🧹 Remover import não utilizado
 import { useState, useRef, useCallback } from 'react';
-import { useState as useStateRender } from 'react';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { useState as useStateRender } from 'react';
import { useState, useRef, useCallback } from 'react';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/inbox/components/stickers/StickerGrid.tsx` at line 2, Há um
import duplicado não utilizado: remove a declaração "useState as useStateRender"
do topo do arquivo; mantenha apenas o import existente de useState (a referência
a useStateRender não é usada em StickerGrid.tsx), então delete essa linha para
evitar import morto e lint warnings.

import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
import { Star, Trash2, Sticker, Plus, Loader2, Clock } from 'lucide-react';
Expand Down Expand Up @@ -39,8 +40,14 @@ export function StickerGrid({
}: StickerGridProps) {
const [deleteTarget, setDeleteTarget] = useState<StickerItem | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [visibleCount, setVisibleCount] = useState(48);
const loadMoreRef = useRef<HTMLDivElement>(null);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

loadMoreRef declarado mas não utilizado.

O ref é criado mas nunca referenciado no componente. Parece ser um resíduo de implementação incompleta de scroll infinito.

🧹 Remover ref não utilizado
   const [visibleCount, setVisibleCount] = useState(48);
-  const loadMoreRef = useRef<HTMLDivElement>(null);
   const gridRef = useRef<HTMLDivElement>(null);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const loadMoreRef = useRef<HTMLDivElement>(null);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/inbox/components/stickers/StickerGrid.tsx` at line 44, Remove
the unused ref declaration loadMoreRef in StickerGrid: delete the line "const
loadMoreRef = useRef<HTMLDivElement>(null);" and any unused import of useRef (or
related ref handling) from React, and verify there are no remaining references
to loadMoreRef elsewhere in the StickerGrid component so ESLint no-unused-vars
is satisfied.

const gridRef = useRef<HTMLDivElement>(null);

// GAP 14: Progressive rendering - load more stickers as user scrolls
const visibleStickers = stickers.slice(0, visibleCount);
const hasMoreToShow = stickers.length > visibleCount;

const handleKeyDown = useCallback((e: React.KeyboardEvent, sticker: StickerItem) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
Expand All @@ -63,8 +70,14 @@ export function StickerGrid({

if (loading) {
return (
<div className="flex items-center justify-center py-12" role="status" aria-label="Carregando figurinhas">
<Loader2 className="w-6 h-6 text-muted-foreground animate-spin" />
<div className="grid grid-cols-4 gap-2 p-2" role="status" aria-label="Carregando figurinhas">
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-lg bg-muted animate-pulse"
style={{ animationDelay: `${i * 50}ms` }}
/>
))}
<span className="sr-only">Carregando figurinhas...</span>
</div>
);
Expand Down Expand Up @@ -98,7 +111,7 @@ export function StickerGrid({
<div className="p-2" ref={gridRef} role="grid" aria-label="Grade de figurinhas">
<div className={cn('grid gap-1.5', gridColsMap[gridSize])}>
<AnimatePresence>
{stickers.map((sticker, idx) => (
{visibleStickers.map((sticker, idx) => (
<Tooltip key={sticker.id}>
<TooltipTrigger asChild>
<motion.button
Expand Down Expand Up @@ -198,6 +211,14 @@ export function StickerGrid({
</Tooltip>
))}
</AnimatePresence>
{hasMoreToShow && (
<button
onClick={() => setVisibleCount(prev => prev + 48)}
className="col-span-full py-2 text-xs text-primary hover:underline"
>
Carregar mais ({stickers.length - visibleCount} restantes)
</button>
)}
</div>
</div>
</ScrollArea>
Expand Down
71 changes: 47 additions & 24 deletions src/features/inbox/components/stickers/StickerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,58 @@ export interface StickerItem {
owner_id?: string | null;
}

/**
* Unified category labels — synchronized with:
* - Edge Function classify-sticker (21 AI categories)
* - DB sticker_categories table (21 seeded)
* - Frontend StickerCategoryBar
*
* GAP 17 FIX: Single source of truth for all sticker categories
*/
export const CATEGORY_LABELS: Record<string, { emoji: string; label: string }> = {
'pessoal': { emoji: '📸', label: 'Pessoal' },
'comemoração': { emoji: '🎉', label: 'Comemoração' },
'riso': { emoji: '😂', label: 'Riso' },
'chorando': { emoji: '😢', label: 'Chorando' },
'amor': { emoji: '❤️', label: 'Amor' },
'raiva': { emoji: '😡', label: 'Raiva' },
'surpresa': { emoji: '😲', label: 'Surpresa' },
'pensativo': { emoji: '🤔', label: 'Pensativo' },
'cumprimento': { emoji: '👋', label: 'Cumprimento' },
'despedida': { emoji: '👋', label: 'Despedida' },
'concordância': { emoji: '👍', label: 'Concordância' },
'negação': { emoji: '🙅', label: 'Negação' },
'sono': { emoji: '😴', label: 'Sono' },
'fome': { emoji: '🍔', label: 'Fome' },
'medo': { emoji: '😨', label: 'Medo' },
'vergonha': { emoji: '🙈', label: 'Vergonha' },
'deboche': { emoji: '😏', label: 'Deboche' },
'fofo': { emoji: '🥰', label: 'Fofo' },
'triste': { emoji: '😔', label: 'Triste' },
'animado': { emoji: '🤩', label: 'Animado' },
'engraçado': { emoji: '🤣', label: 'Engraçado' },
'outros': { emoji: '📦', label: 'Outros' },
'recebidas': { emoji: '📥', label: 'Recebidas' },
'enviadas': { emoji: '📤', label: 'Enviadas' },
// ═══ AI-classifiable categories (21 — must match edge function) ═══
'comemoração': { emoji: '🎉', label: 'Comemoração' },
'riso': { emoji: '😂', label: 'Riso' },
'chorando': { emoji: '😢', label: 'Chorando' },
'amor': { emoji: '❤️', label: 'Amor' },
'raiva': { emoji: '😡', label: 'Raiva' },
'surpresa': { emoji: '😲', label: 'Surpresa' },
'pensativo': { emoji: '🤔', label: 'Pensativo' },
'cumprimento': { emoji: '👋', label: 'Cumprimento' },
'despedida': { emoji: '👋', label: 'Despedida' },
'concordância': { emoji: '👍', label: 'Concordância' },
'negação': { emoji: '🙅', label: 'Negação' },
'sono': { emoji: '😴', label: 'Sono' },
'fome': { emoji: '🍔', label: 'Fome' },
'medo': { emoji: '😨', label: 'Medo' },
'vergonha': { emoji: '🙈', label: 'Vergonha' },
'deboche': { emoji: '😏', label: 'Deboche' },
'fofo': { emoji: '🥰', label: 'Fofo' },
'triste': { emoji: '😔', label: 'Triste' },
'animado': { emoji: '🤩', label: 'Animado' },
'engraçado': { emoji: '🤣', label: 'Engraçado' },
'outros': { emoji: '📦', label: 'Outros' },
// ═══ Frontend-only categories (system) ═══
'pessoal': { emoji: '📸', label: 'Pessoal' },
'recebidas': { emoji: '📥', label: 'Recebidas' },
'enviadas': { emoji: '📤', label: 'Enviadas' },
'agradecimento': { emoji: '🙏', label: 'Agradecimento' },
'confusão': { emoji: '😵', label: 'Confusão' },
'desculpa': { emoji: '🙌', label: 'Desculpa' },
'discordância': { emoji: '👎', label: 'Discordância' },
'sarcasmo': { emoji: '🙄', label: 'Sarcasmo' },
};

export const ALL_CATEGORIES = Object.keys(CATEGORY_LABELS);

/** Categories that the AI classifier can return */
export const AI_CATEGORIES = [
'comemoração', 'riso', 'chorando', 'amor', 'raiva',
'surpresa', 'pensativo', 'cumprimento', 'despedida', 'concordância',
'negação', 'sono', 'fome', 'medo', 'vergonha',
'deboche', 'fofo', 'triste', 'animado', 'engraçado', 'outros'
] as const;
Comment on lines +11 to +61

export interface PendingUpload {
file: File;
imageUrl: string;
Expand Down
16 changes: 12 additions & 4 deletions src/hooks/gmail/gmailApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,10 +308,18 @@ export async function gmailModifyLabels(
return { data: null, error: _NOT_IMPLEMENTED };
}

export async function gmailSendMessage(
_accountId: string,
_params: { to: string[]; subject: string; html: string; cc?: string[]; bcc?: string[] }
): Promise<GmailApiResponse<{ id: string; threadId: string }>> {
export async function gmailSendMessage(params: {
accountId: string;
to: string[];
cc?: string[];
bcc?: string[];
subject: string;
bodyHtml: string;
bodyPlain?: string;
threadId?: string;
attachments?: Array<{ name: string; mimeType: string; data: string }>;
signature?: boolean;
}): Promise<GmailApiResponse<{ id: string; threadId: string }>> {
console.warn('[gmailApi] gmailSendMessage is not implemented yet');
return { data: null, error: _NOT_IMPLEMENTED };
}
Expand Down
Loading