Final version frontend: favicon, audio player, dist included
This commit is contained in:
+352
@@ -0,0 +1,352 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { FileText, Loader2, ShieldCheck, Sparkles, Workflow } from 'lucide-react';
|
||||
import { onAuthStateChanged, signInWithPopup, signOut, User } from 'firebase/auth';
|
||||
|
||||
import { Header } from './components/Header';
|
||||
import { InputArea } from './components/InputArea';
|
||||
import { MessageBubble } from './components/MessageBubble';
|
||||
import { LoginScreen } from './components/LoginScreen';
|
||||
|
||||
import { auth, googleProvider } from './lib/firebase';
|
||||
import { Message, Attachment, AudioRecording } from './types';
|
||||
|
||||
const webhookUrl = import.meta.env.VITE_WEBHOOK_URL || '';
|
||||
|
||||
export default function App() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(true);
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [authError, setAuthError] = useState('');
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, isProcessing]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
|
||||
setUser(currentUser);
|
||||
setAuthLoading(false);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
try {
|
||||
setLoginLoading(true);
|
||||
setAuthError('');
|
||||
|
||||
const result = await signInWithPopup(auth, googleProvider);
|
||||
const email = result.user.email || '';
|
||||
|
||||
const allowedDomain = '@gomezleemarketing.com';
|
||||
|
||||
if (!email.toLowerCase().endsWith(allowedDomain.toLowerCase())) {
|
||||
await signOut(auth);
|
||||
setAuthError('Solo se permite acceso con cuentas corporativas de la empresa.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(result.user);
|
||||
} catch (error) {
|
||||
console.error('Google login error:', error);
|
||||
setAuthError('No se pudo iniciar sesión con Google.');
|
||||
} finally {
|
||||
setLoginLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut(auth);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const handleSendMessage = async (
|
||||
text: string,
|
||||
attachments: Attachment[],
|
||||
audio?: AudioRecording
|
||||
) => {
|
||||
const userMessageId = Math.random().toString(36).substring(7);
|
||||
|
||||
const newMessage: Message = {
|
||||
id: userMessageId,
|
||||
sender: 'user',
|
||||
text,
|
||||
attachments,
|
||||
audio,
|
||||
timestamp: new Date(),
|
||||
status: 'sending'
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, newMessage]);
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
if (!webhookUrl) {
|
||||
throw new Error('No hay webhook configurado en VITE_WEBHOOK_URL.');
|
||||
}
|
||||
|
||||
const submittedAt = new Date().toISOString();
|
||||
const submittedByName = user?.displayName || user?.email?.split('@')[0] || 'No especificado';
|
||||
const submittedByEmail = user?.email || 'No especificado';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('text', text);
|
||||
formData.append('timestamp', submittedAt);
|
||||
formData.append('submittedAt', submittedAt);
|
||||
formData.append('submittedByName', submittedByName);
|
||||
formData.append('submittedByEmail', submittedByEmail);
|
||||
formData.append('submittedByUid', user?.uid || '');
|
||||
formData.append('submittedByPhotoUrl', user?.photoURL || '');
|
||||
|
||||
let generalFileIndex = 0;
|
||||
let campaignFileIndex = 0;
|
||||
|
||||
attachments.forEach((att) => {
|
||||
if (att.category === 'campaign') {
|
||||
formData.append(`campaign_file_${campaignFileIndex}`, att.file);
|
||||
formData.append(`campaign_file_${campaignFileIndex}_name`, att.file.name);
|
||||
campaignFileIndex += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
formData.append(`file_${generalFileIndex}`, att.file);
|
||||
formData.append(`file_${generalFileIndex}_name`, att.file.name);
|
||||
generalFileIndex += 1;
|
||||
});
|
||||
|
||||
formData.append('generalFilesCount', String(generalFileIndex));
|
||||
formData.append('campaignFilesCount', String(campaignFileIndex));
|
||||
|
||||
if (audio) {
|
||||
formData.append('audio', audio.blob, 'voice_note.webm');
|
||||
}
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
let responseData: unknown;
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
responseData = await response.json();
|
||||
} else {
|
||||
responseData = await response.text();
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === userMessageId ? { ...msg, status: 'sent' } : msg
|
||||
)
|
||||
);
|
||||
|
||||
const systemMessage: Message = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
sender: 'system',
|
||||
text:
|
||||
typeof responseData === 'object'
|
||||
? JSON.stringify(responseData, null, 2)
|
||||
: (responseData as string) || 'Solicitud recibida correctamente.',
|
||||
timestamp: new Date(),
|
||||
status: 'sent'
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, systemMessage]);
|
||||
setIsConnected(true);
|
||||
} catch (error) {
|
||||
console.error('Webhook error:', error);
|
||||
setIsConnected(false);
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === userMessageId ? { ...msg, status: 'error' } : msg
|
||||
)
|
||||
);
|
||||
|
||||
const errorMessage: Message = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
sender: 'system',
|
||||
text: `**Error de conexión:** No se pudo conectar con el webhook configurado.
|
||||
|
||||
Verifica que la URL esté correcta en el entorno y que el endpoint esté activo y acepte solicitudes POST con FormData.`,
|
||||
timestamp: new Date(),
|
||||
status: 'error'
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#060816] text-zinc-200">
|
||||
Cargando acceso...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<LoginScreen
|
||||
onLogin={handleGoogleLogin}
|
||||
isLoading={loginLoading}
|
||||
error={authError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col overflow-x-hidden bg-[#060816] text-zinc-100 font-sans selection:bg-indigo-500/30">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute left-[-10%] top-[-15%] h-[34rem] w-[34rem] rounded-full bg-indigo-600/18 blur-3xl" />
|
||||
<div className="absolute right-[-8%] top-[8%] h-[30rem] w-[30rem] rounded-full bg-cyan-400/12 blur-3xl" />
|
||||
<div className="absolute bottom-[-18%] left-[20%] h-[28rem] w-[28rem] rounded-full bg-fuchsia-500/10 blur-3xl" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:42px_42px] opacity-[0.06]" />
|
||||
</div>
|
||||
|
||||
<Header isConnected={isConnected} onLogout={handleLogout} />
|
||||
<main className="relative flex-1">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 pb-8 sm:px-6 lg:px-8">
|
||||
<section className="overflow-hidden rounded-[32px] border border-white/10 bg-white/5 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-xl">
|
||||
<div className="grid gap-0 lg:grid-cols-[1.5fr_1fr]">
|
||||
<div className="relative p-6 sm:p-7 lg:p-8">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.22),transparent_38%),radial-gradient(circle_at_bottom_right,rgba(34,211,238,0.14),transparent_36%)]" />
|
||||
<div className="relative">
|
||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-indigo-400/25 bg-indigo-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-indigo-200">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Portal inteligente de briefs
|
||||
</div>
|
||||
|
||||
<h2 className="max-w-3xl text-3xl font-semibold tracking-tight text-white sm:text-4xl">
|
||||
Carga el brief en un flujo claro, visual y mucho más profesional.
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-zinc-300 sm:text-base">
|
||||
La interfaz guía al usuario paso por paso, reduce errores de carga y organiza audio, imágenes, archivos, piezas gráficas y referencias para construir un brief listo para revisión interna.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{[
|
||||
{ icon: ShieldCheck, label: 'Validaciones obligatorias' },
|
||||
{ icon: Workflow, label: 'Proceso guiado por pasos' },
|
||||
{ icon: FileText, label: 'Entrega estructurada al flujo' },
|
||||
].map(({ icon: Icon, label }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-200"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-indigo-300" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 bg-black/20 p-6 sm:p-7 lg:border-l lg:border-t-0 lg:p-8">
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/25 p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">
|
||||
Resumen del sistema
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{[
|
||||
'La nota de voz, las imágenes y los PDFs sí se analizan automáticamente.',
|
||||
'Word, Excel, PowerPoint y otros adjuntos se guardan en la carpeta del proyecto.',
|
||||
'Los enlaces online se registran para alimentar el brief final.',
|
||||
'El usuario no necesita usar el portal como chat libre: todo queda guiado.',
|
||||
].map((item) => (
|
||||
<div key={item} className="flex items-start gap-3">
|
||||
<div className="mt-1 h-2.5 w-2.5 rounded-full bg-indigo-400 shadow-[0_0_18px_rgba(129,140,248,0.7)]" />
|
||||
<p className="text-sm leading-6 text-zinc-300">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-black/20 p-4 shadow-[0_24px_60px_rgba(0,0,0,0.28)] backdrop-blur-xl sm:p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">
|
||||
Carga guiada
|
||||
</p>
|
||||
<h3 className="mt-1 text-lg font-semibold text-white sm:text-xl">
|
||||
Completa el brief y envía la solicitud
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputArea
|
||||
onSendMessage={handleSendMessage}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-black/20 p-4 shadow-[0_24px_60px_rgba(0,0,0,0.28)] backdrop-blur-xl sm:p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">
|
||||
Respuesta del portal
|
||||
</p>
|
||||
<h3 className="mt-1 text-lg font-semibold text-white sm:text-xl">
|
||||
Resultado de la solicitud
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[220px] rounded-[24px] border border-white/10 bg-white/[0.03] p-4 sm:p-5 lg:p-6">
|
||||
{messages.length === 0 && !isProcessing && (
|
||||
<div className="flex min-h-[150px] items-center justify-center rounded-[22px] border border-dashed border-white/10 bg-black/10 px-5 text-center text-sm leading-6 text-zinc-500">
|
||||
Cuando envíes la solicitud, aquí aparecerá la respuesta del flujo y el enlace de Drive.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{isProcessing && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="mb-4 flex w-fit items-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3"
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-indigo-400" />
|
||||
<span className="text-sm font-medium text-zinc-300">
|
||||
Procesando la solicitud...
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} className="h-2" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { LogOut, Sparkles, Zap } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface HeaderProps {
|
||||
isConnected: boolean;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ isConnected, onLogout }) => {
|
||||
return (
|
||||
<header className="relative z-50 border-b border-white/10 bg-black/25 px-4 py-4 backdrop-blur-2xl sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-7xl items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3 sm:gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_50%,#06b6d4_100%)] shadow-[0_18px_40px_rgba(79,70,229,0.38)]">
|
||||
<Zap className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="truncate text-lg font-semibold tracking-tight text-white sm:text-xl">
|
||||
Portal Brief CDC
|
||||
</h1>
|
||||
<span className="hidden rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.24em] text-zinc-400 sm:inline-flex">
|
||||
Experiencia guiada
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-zinc-300">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
{isConnected && (
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-70" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex h-2.5 w-2.5 rounded-full',
|
||||
isConnected ? 'bg-emerald-400' : 'bg-red-400'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{isConnected ? 'Conectado al flujo' : 'Sin conexión'}
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-2 rounded-full border border-indigo-400/20 bg-indigo-500/10 px-2.5 py-1 text-xs text-indigo-200 sm:inline-flex">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Flujo validado
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-zinc-200 transition hover:border-white/20 hover:bg-white/10 hover:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
|
||||
aria-label="Cerrar sesión"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Cerrar sesión</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,912 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
File,
|
||||
Image as ImageIcon,
|
||||
Link2,
|
||||
Loader2,
|
||||
Mic,
|
||||
Paperclip,
|
||||
Pause,
|
||||
Play,
|
||||
Send,
|
||||
Square,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Attachment, AttachmentCategory, AttachmentType, AudioRecording } from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface InputAreaProps {
|
||||
onSendMessage: (text: string, attachments: Attachment[], audio?: AudioRecording) => void;
|
||||
isProcessing: boolean;
|
||||
}
|
||||
|
||||
type GuidedStep = 'audio' | 'images' | 'documents' | 'campaign' | 'links' | 'review';
|
||||
type CampaignAnswer = 'yes' | 'no' | null;
|
||||
|
||||
interface StepDefinition {
|
||||
id: GuidedStep;
|
||||
number: number;
|
||||
title: string;
|
||||
hint: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
const STEP_DEFINITIONS: StepDefinition[] = [
|
||||
{
|
||||
id: 'audio',
|
||||
number: 1,
|
||||
title: 'Nota de voz',
|
||||
hint: 'Obligatorio. Graba una nota de voz explicando el brief.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'images',
|
||||
number: 2,
|
||||
title: 'Imágenes de referencia',
|
||||
hint: 'Obligatorio. Adjunta una o varias imágenes de referencia.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'documents',
|
||||
number: 3,
|
||||
title: 'Documentos relacionados',
|
||||
hint: 'Opcional. Adjunta PDF, Excel, PowerPoint, Word u otros archivos de apoyo.',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'campaign',
|
||||
number: 4,
|
||||
title: 'Campaña o elementos gráficos',
|
||||
hint: 'Opcional. Si ya existen piezas gráficas o logos, adjúntalos aquí.',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'links',
|
||||
number: 5,
|
||||
title: 'Enlaces de referencia online',
|
||||
hint: 'Opcional. Pega enlaces, uno por línea.',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
number: 6,
|
||||
title: 'Revisión y envío',
|
||||
hint: 'Revisa la solicitud y envíala solo desde este paso final.',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const safeSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
|
||||
const minutes = Math.floor(safeSeconds / 60);
|
||||
const remainingSeconds = safeSeconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessing }) => {
|
||||
const [text, setText] = useState('');
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [audioRecording, setAudioRecording] = useState<AudioRecording | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState<GuidedStep>('audio');
|
||||
const [hasCampaignAssets, setHasCampaignAssets] = useState<CampaignAnswer>(null);
|
||||
const [validationMessage, setValidationMessage] = useState<string | null>(null);
|
||||
const [audioPreviewPlaying, setAudioPreviewPlaying] = useState(false);
|
||||
const [audioPreviewCurrentTime, setAudioPreviewCurrentTime] = useState(0);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const recordingDurationRef = useRef(0);
|
||||
const attachmentsRef = useRef<Attachment[]>([]);
|
||||
const audioRecordingRef = useRef<AudioRecording | null>(null);
|
||||
const audioPreviewRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const imageAttachments = useMemo(
|
||||
() => attachments.filter((att) => att.type === 'image' && att.category !== 'campaign'),
|
||||
[attachments],
|
||||
);
|
||||
|
||||
const documentAttachments = useMemo(
|
||||
() => attachments.filter((att) => att.category !== 'campaign' && att.type !== 'image'),
|
||||
[attachments],
|
||||
);
|
||||
|
||||
const campaignAttachments = useMemo(
|
||||
() => attachments.filter((att) => att.category === 'campaign'),
|
||||
[attachments],
|
||||
);
|
||||
|
||||
const linksCount = useMemo(
|
||||
() => text.split('\n').map((line) => line.trim()).filter(Boolean).length,
|
||||
[text],
|
||||
);
|
||||
|
||||
const requiredCompleted = Boolean(audioRecording) && imageAttachments.length > 0;
|
||||
const campaignStepCompleted = hasCampaignAssets !== 'yes' || campaignAttachments.length > 0;
|
||||
const canSend = requiredCompleted && campaignStepCompleted;
|
||||
const progressValue = ((STEP_DEFINITIONS.findIndex((step) => step.id === currentStep) + 1) / STEP_DEFINITIONS.length) * 100;
|
||||
|
||||
useEffect(() => {
|
||||
attachmentsRef.current = attachments;
|
||||
}, [attachments]);
|
||||
|
||||
useEffect(() => {
|
||||
audioRecordingRef.current = audioRecording;
|
||||
}, [audioRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
setAudioPreviewPlaying(false);
|
||||
setAudioPreviewCurrentTime(0);
|
||||
if (audioPreviewRef.current) {
|
||||
audioPreviewRef.current.pause();
|
||||
audioPreviewRef.current.currentTime = 0;
|
||||
}
|
||||
}, [audioRecording?.url]);
|
||||
|
||||
const toggleAudioPreview = async () => {
|
||||
if (!audioPreviewRef.current || !audioRecording) return;
|
||||
|
||||
if (audioPreviewPlaying) {
|
||||
audioPreviewRef.current.pause();
|
||||
setAudioPreviewPlaying(false);
|
||||
setAudioPreviewCurrentTime(Math.floor(audioPreviewRef.current.currentTime || 0));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await audioPreviewRef.current.play();
|
||||
setAudioPreviewPlaying(true);
|
||||
} catch (error) {
|
||||
console.error('No se pudo reproducir la nota de voz:', error);
|
||||
setAudioPreviewPlaying(false);
|
||||
setValidationMessage('No se pudo reproducir la nota de voz. Intenta grabarla nuevamente.');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
attachmentsRef.current.forEach((attachment) => URL.revokeObjectURL(attachment.url));
|
||||
if (audioRecordingRef.current?.url) {
|
||||
URL.revokeObjectURL(audioRecordingRef.current.url);
|
||||
}
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getStepStatus = (step: GuidedStep) => {
|
||||
switch (step) {
|
||||
case 'audio':
|
||||
return audioRecording ? 'done' : currentStep === 'audio' ? 'active' : 'pending';
|
||||
case 'images':
|
||||
return imageAttachments.length > 0 ? 'done' : currentStep === 'images' ? 'active' : 'pending';
|
||||
case 'documents':
|
||||
return documentAttachments.length > 0 ? 'done' : currentStep === 'documents' ? 'active' : 'pending';
|
||||
case 'campaign':
|
||||
if (hasCampaignAssets === 'no' || (hasCampaignAssets === 'yes' && campaignAttachments.length > 0)) return 'done';
|
||||
return currentStep === 'campaign' ? 'active' : 'pending';
|
||||
case 'links':
|
||||
return linksCount > 0 ? 'done' : currentStep === 'links' ? 'active' : 'pending';
|
||||
case 'review':
|
||||
return currentStep === 'review' ? 'active' : canSend ? 'done' : 'pending';
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
};
|
||||
|
||||
const inferAttachmentType = (file: File): AttachmentType => {
|
||||
if (file.type.startsWith('image/')) return 'image';
|
||||
if (file.type.startsWith('video/')) return 'video';
|
||||
if (file.type.startsWith('audio/')) return 'audio';
|
||||
if (
|
||||
file.type.includes('pdf') ||
|
||||
file.type.includes('document') ||
|
||||
file.type.includes('sheet') ||
|
||||
file.type.includes('excel') ||
|
||||
file.type.includes('powerpoint') ||
|
||||
file.type.startsWith('text/')
|
||||
) {
|
||||
return 'document';
|
||||
}
|
||||
return 'other';
|
||||
};
|
||||
|
||||
const getCurrentAttachmentCategory = (): AttachmentCategory => (
|
||||
currentStep === 'campaign' ? 'campaign' : 'general'
|
||||
);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
if (!selectedFiles.length) return;
|
||||
|
||||
let allowedFiles = selectedFiles;
|
||||
|
||||
if (currentStep === 'images') {
|
||||
allowedFiles = selectedFiles.filter((file) => file.type.startsWith('image/'));
|
||||
if (allowedFiles.length !== selectedFiles.length) {
|
||||
setValidationMessage('En este paso solo se permiten imágenes. Los demás archivos no se agregaron.');
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStep === 'documents') {
|
||||
allowedFiles = selectedFiles.filter((file) => !file.type.startsWith('image/') && !file.type.startsWith('audio/'));
|
||||
if (allowedFiles.length !== selectedFiles.length) {
|
||||
setValidationMessage('En este paso agrega documentos o archivos de apoyo. Las imágenes y audios se omiten aquí.');
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStep === 'campaign') {
|
||||
allowedFiles = selectedFiles.filter((file) => !file.type.startsWith('audio/'));
|
||||
if (allowedFiles.length !== selectedFiles.length) {
|
||||
setValidationMessage('En este paso adjunta piezas, documentos o referencias gráficas. Los audios se omiten aquí.');
|
||||
}
|
||||
}
|
||||
|
||||
const category = getCurrentAttachmentCategory();
|
||||
const newAttachments: Attachment[] = allowedFiles.map((file) => ({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
file,
|
||||
type: inferAttachmentType(file),
|
||||
url: URL.createObjectURL(file),
|
||||
category,
|
||||
}));
|
||||
|
||||
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||
if (allowedFiles.length > 0) {
|
||||
setValidationMessage(null);
|
||||
}
|
||||
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
setAttachments((prev) => {
|
||||
const removed = prev.find((attachment) => attachment.id === id);
|
||||
if (removed) URL.revokeObjectURL(removed.url);
|
||||
return prev.filter((attachment) => attachment.id !== id);
|
||||
});
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) audioChunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
setAudioRecording({
|
||||
blob: audioBlob,
|
||||
url: audioUrl,
|
||||
duration: recordingDurationRef.current,
|
||||
});
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
setValidationMessage(null);
|
||||
setIsRecording(true);
|
||||
recordingDurationRef.current = 0;
|
||||
setRecordingDuration(0);
|
||||
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setRecordingDuration((prev) => {
|
||||
const nextDuration = prev + 1;
|
||||
recordingDurationRef.current = nextDuration;
|
||||
return nextDuration;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Error accessing microphone:', error);
|
||||
setValidationMessage('No se pudo acceder al micrófono. Revisa los permisos del navegador.');
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRecording = () => {
|
||||
if (audioPreviewRef.current) {
|
||||
audioPreviewRef.current.pause();
|
||||
audioPreviewRef.current.currentTime = 0;
|
||||
}
|
||||
setAudioPreviewPlaying(false);
|
||||
setAudioPreviewCurrentTime(0);
|
||||
if (audioRecording?.url) {
|
||||
URL.revokeObjectURL(audioRecording.url);
|
||||
}
|
||||
setAudioRecording(null);
|
||||
recordingDurationRef.current = 0;
|
||||
setRecordingDuration(0);
|
||||
};
|
||||
|
||||
const advanceTo = (step: GuidedStep) => {
|
||||
setCurrentStep(step);
|
||||
setValidationMessage(null);
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (currentStep === 'audio') {
|
||||
if (!audioRecording) {
|
||||
setValidationMessage('Debes grabar una nota de voz para continuar.');
|
||||
return;
|
||||
}
|
||||
advanceTo('images');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep === 'images') {
|
||||
if (imageAttachments.length === 0) {
|
||||
setValidationMessage('Debes adjuntar al menos una imagen de referencia para continuar.');
|
||||
return;
|
||||
}
|
||||
advanceTo('documents');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep === 'documents') {
|
||||
advanceTo('campaign');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep === 'campaign') {
|
||||
if (hasCampaignAssets === 'yes' && campaignAttachments.length === 0) {
|
||||
setValidationMessage('Seleccionaste que sí existen elementos gráficos. Adjunta al menos un archivo para continuar.');
|
||||
return;
|
||||
}
|
||||
advanceTo('links');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep === 'links') {
|
||||
advanceTo('review');
|
||||
}
|
||||
};
|
||||
|
||||
const buildSubmissionText = () => {
|
||||
const linksText = text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
const summaryLines = [
|
||||
'Solicitud enviada desde el portal guiado del Brief CDC.',
|
||||
`Nota de voz: ${audioRecording ? 'sí' : 'no'}`,
|
||||
`Imágenes de referencia: ${imageAttachments.length}`,
|
||||
`Documentos relacionados: ${documentAttachments.length}`,
|
||||
`Campaña o elementos gráficos existentes: ${hasCampaignAssets === 'yes' ? 'sí' : hasCampaignAssets === 'no' ? 'no' : 'no indicado'}`,
|
||||
`Archivos de campaña / elementos gráficos: ${campaignAttachments.length}`,
|
||||
];
|
||||
|
||||
if (linksText) {
|
||||
summaryLines.push('', 'Enlaces de referencia:', linksText);
|
||||
}
|
||||
|
||||
return summaryLines.join('\n');
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
if (!requiredCompleted) {
|
||||
setValidationMessage('Completa primero la nota de voz y al menos una imagen de referencia.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!campaignStepCompleted) {
|
||||
setValidationMessage('Seleccionaste que sí existen elementos gráficos. Adjunta al menos un archivo antes de enviar.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep !== 'review') {
|
||||
setValidationMessage('El envío solo está disponible en el paso final de revisión.');
|
||||
advanceTo('review');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isProcessing) {
|
||||
onSendMessage(buildSubmissionText(), attachments, audioRecording || undefined);
|
||||
setText('');
|
||||
setAttachments([]);
|
||||
if (audioPreviewRef.current) {
|
||||
audioPreviewRef.current.pause();
|
||||
audioPreviewRef.current.currentTime = 0;
|
||||
}
|
||||
setAudioPreviewPlaying(false);
|
||||
setAudioPreviewCurrentTime(0);
|
||||
setAudioRecording(null);
|
||||
recordingDurationRef.current = 0;
|
||||
setRecordingDuration(0);
|
||||
setHasCampaignAssets(null);
|
||||
setCurrentStep('audio');
|
||||
setValidationMessage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const currentStepConfig = STEP_DEFINITIONS.find((step) => step.id === currentStep)!;
|
||||
const audioPreviewDuration = audioRecording?.duration || 0;
|
||||
const audioPreviewProgress = audioPreviewDuration
|
||||
? Math.min(100, Math.max(0, (audioPreviewCurrentTime / audioPreviewDuration) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(8,12,24,0.96),rgba(10,12,20,0.86))] p-4 shadow-[0_28px_80px_rgba(0,0,0,0.38)] backdrop-blur-2xl sm:p-5 lg:p-6">
|
||||
<div className="mb-5 overflow-hidden rounded-[28px] border border-white/10 bg-white/5 p-4">
|
||||
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-indigo-400/20 bg-indigo-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-indigo-200">
|
||||
<WandSparkles className="h-3.5 w-3.5" /> Flujo guiado
|
||||
</div>
|
||||
<h3 className="mt-3 text-xl font-semibold tracking-tight text-white">Construye la solicitud paso por paso</h3>
|
||||
<p className="mt-1 text-sm text-zinc-400">Completa primero los pasos obligatorios. Luego podrás adjuntar documentos, piezas gráficas y referencias online.</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
<span className="font-semibold text-white">Progreso:</span> {canSend ? 'listo para enviar' : `${Math.round(progressValue)}% del flujo`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 h-2 overflow-hidden rounded-full bg-white/5">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-[linear-gradient(90deg,#6366f1_0%,#3b82f6_55%,#22d3ee_100%)]"
|
||||
animate={{ width: `${progressValue}%` }}
|
||||
transition={{ type: 'spring', stiffness: 80, damping: 18 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-6">
|
||||
{STEP_DEFINITIONS.map((step) => {
|
||||
const status = getStepStatus(step.id);
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
type="button"
|
||||
onClick={() => advanceTo(step.id)}
|
||||
className={cn(
|
||||
'group relative flex min-h-[178px] flex-col justify-between overflow-hidden rounded-[24px] border p-4 text-left transition-all',
|
||||
status === 'done' && 'border-emerald-400/25 bg-emerald-500/10',
|
||||
status === 'active' && 'border-indigo-400/30 bg-[linear-gradient(180deg,rgba(99,102,241,0.18),rgba(59,130,246,0.08))] shadow-[0_18px_40px_rgba(79,70,229,0.18)]',
|
||||
status === 'pending' && 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/25'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.26em] text-zinc-400">
|
||||
Paso {step.number}
|
||||
</span>
|
||||
{status === 'done' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-300" />
|
||||
) : status === 'active' ? (
|
||||
<div className="h-3 w-3 rounded-full bg-indigo-300 shadow-[0_0_14px_rgba(129,140,248,0.7)]" />
|
||||
) : (
|
||||
<div className="h-3 w-3 rounded-full bg-zinc-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-5">
|
||||
<h4 className="text-sm font-semibold leading-5 text-white">{step.title}</h4>
|
||||
<p className="mt-2 text-xs leading-6 text-zinc-400">{step.hint}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[1.45fr_0.85fr]">
|
||||
<div>
|
||||
<AnimatePresence>
|
||||
{(attachments.length > 0 || audioRecording) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mb-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
{audioRecording && (
|
||||
<div className="relative overflow-hidden rounded-[24px] border border-indigo-400/25 bg-[linear-gradient(145deg,rgba(99,102,241,0.24),rgba(15,23,42,0.78))] p-4 shadow-[0_18px_45px_rgba(79,70,229,0.14)] sm:col-span-2 xl:col-span-3">
|
||||
<div className="pointer-events-none absolute -right-10 -top-10 h-28 w-28 rounded-full bg-indigo-400/20 blur-2xl" />
|
||||
|
||||
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3 pr-10">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-black/25">
|
||||
<Mic className="h-6 w-6 text-indigo-100" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-base font-semibold leading-6 text-white">Nota de voz</p>
|
||||
<p className="mt-0.5 text-sm leading-5 text-indigo-100/80">Lista para escuchar · Duración: {formatDuration(audioRecording.duration)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full rounded-2xl border border-white/10 bg-black/25 p-3 sm:max-w-[520px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAudioPreview}
|
||||
className="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-white text-slate-950 shadow-[0_12px_28px_rgba(0,0,0,0.26)] transition hover:scale-105 hover:bg-zinc-200"
|
||||
aria-label={audioPreviewPlaying ? 'Pausar nota de voz' : 'Reproducir nota de voz'}
|
||||
>
|
||||
{audioPreviewPlaying ? <Pause className="h-5 w-5 fill-current" /> : <Play className="ml-0.5 h-5 w-5 fill-current" />}
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
||||
<p className="text-sm font-semibold leading-5 text-white">
|
||||
{audioPreviewPlaying ? 'Reproduciendo' : 'Reproducir'}
|
||||
</p>
|
||||
<p className="font-mono text-xs leading-5 text-indigo-100/80">
|
||||
{formatDuration(audioPreviewCurrentTime)} / {formatDuration(audioPreviewDuration)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={Math.max(1, audioPreviewDuration)}
|
||||
step="1"
|
||||
value={Math.min(audioPreviewCurrentTime, audioPreviewDuration || 0)}
|
||||
onChange={(event) => {
|
||||
const nextTime = Number(event.target.value);
|
||||
setAudioPreviewCurrentTime(nextTime);
|
||||
if (audioPreviewRef.current) {
|
||||
audioPreviewRef.current.currentTime = nextTime;
|
||||
}
|
||||
}}
|
||||
className="audio-preview-range mt-3 w-full cursor-pointer"
|
||||
aria-label="Adelantar o atrasar la nota de voz"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
ref={audioPreviewRef}
|
||||
src={audioRecording.url}
|
||||
preload="metadata"
|
||||
onTimeUpdate={(event) => setAudioPreviewCurrentTime(Math.floor(event.currentTarget.currentTime || 0))}
|
||||
onEnded={() => {
|
||||
setAudioPreviewPlaying(false);
|
||||
setAudioPreviewCurrentTime(0);
|
||||
if (audioPreviewRef.current) audioPreviewRef.current.currentTime = 0;
|
||||
}}
|
||||
onPause={(event) => {
|
||||
setAudioPreviewPlaying(false);
|
||||
setAudioPreviewCurrentTime(Math.floor(event.currentTarget.currentTime || 0));
|
||||
}}
|
||||
onPlay={() => setAudioPreviewPlaying(true)}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={cancelRecording}
|
||||
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-black/35 text-white transition hover:bg-red-500/80"
|
||||
aria-label="Eliminar nota de voz"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.map((att) => (
|
||||
<div key={att.id} className="relative overflow-hidden rounded-[24px] border border-white/10 bg-white/5 p-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{att.type === 'image' ? (
|
||||
<div className="h-16 w-16 overflow-hidden rounded-2xl border border-white/10 bg-black/20">
|
||||
<img src={att.url} alt="preview" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-white/10 bg-black/20">
|
||||
<File className="h-6 w-6 text-zinc-300" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 pr-7">
|
||||
<p className="truncate text-sm font-medium text-white">{att.file.name}</p>
|
||||
<p className="mt-1 text-xs text-zinc-400">
|
||||
{att.category === 'campaign'
|
||||
? 'Campaña / elemento gráfico'
|
||||
: att.type === 'image'
|
||||
? 'Imagen de referencia'
|
||||
: 'Documento / archivo de apoyo'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => removeAttachment(att.id)}
|
||||
className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full bg-black/30 text-white transition hover:bg-red-500/80"
|
||||
aria-label="Eliminar archivo"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-4 sm:p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-400/20 bg-indigo-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-indigo-200">
|
||||
Paso {currentStepConfig.number}
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-white">{currentStepConfig.title}</h3>
|
||||
<span className="text-sm text-zinc-400">{currentStepConfig.hint}</span>
|
||||
</div>
|
||||
|
||||
{currentStep === 'audio' && (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
|
||||
<p className="mb-5 text-sm leading-7 text-zinc-300">
|
||||
Graba una nota de voz explicando el brief. Incluye país, cliente, marca, tipo de requerimiento, objetivos, entregables, presupuesto y fechas clave.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-all',
|
||||
isRecording
|
||||
? 'bg-red-500/20 text-red-200 hover:bg-red-500/30'
|
||||
: 'bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_100%)] text-white shadow-[0_18px_40px_rgba(79,70,229,0.25)] hover:brightness-110'
|
||||
)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isRecording ? <Square className="h-4 w-4 fill-current" /> : <Mic className="h-4 w-4" />}
|
||||
{isRecording ? `Detener grabación (${recordingDuration}s)` : 'Grabar nota de voz'}
|
||||
</button>
|
||||
{audioRecording && <span className="text-sm text-emerald-300">Nota de voz lista para enviar.</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 'images' && (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
|
||||
<p className="mb-5 text-sm leading-7 text-zinc-300">
|
||||
Adjunta una o varias imágenes de referencia. Este paso es obligatorio para continuar.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_100%)] px-5 py-3.5 text-sm font-semibold text-white shadow-[0_18px_40px_rgba(79,70,229,0.25)] transition hover:brightness-110 disabled:opacity-50"
|
||||
disabled={isProcessing || isRecording}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
Agregar imágenes
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 'documents' && (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
|
||||
<p className="mb-5 text-sm leading-7 text-zinc-300">
|
||||
Adjunta documentos relacionados, referencias y archivos de apoyo. Puedes subir PDF, Excel, PowerPoint, Word y otros materiales.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-black/25 px-5 py-3.5 text-sm font-semibold text-zinc-100 transition hover:border-white/20 hover:bg-black/35 disabled:opacity-50"
|
||||
disabled={isProcessing || isRecording}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
Agregar documentos relacionados
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 'campaign' && (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
|
||||
<p className="mb-5 text-sm leading-7 text-zinc-300">
|
||||
¿Ya existe campaña o elementos gráficos para esta solicitud? Si existen piezas, artes, logos, lineamientos o referencias aprobadas, adjúntalas aquí para evitar retrabajo.
|
||||
</p>
|
||||
|
||||
<div className="mb-5 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHasCampaignAssets('yes');
|
||||
setValidationMessage(null);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-2xl border px-5 py-3 text-sm font-semibold transition',
|
||||
hasCampaignAssets === 'yes'
|
||||
? 'border-indigo-300/40 bg-indigo-500/20 text-white'
|
||||
: 'border-white/10 bg-black/25 text-zinc-200 hover:border-white/20 hover:bg-black/35'
|
||||
)}
|
||||
disabled={isProcessing || isRecording}
|
||||
>
|
||||
Sí, adjuntar elementos
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHasCampaignAssets('no');
|
||||
setValidationMessage(null);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-2xl border px-5 py-3 text-sm font-semibold transition',
|
||||
hasCampaignAssets === 'no'
|
||||
? 'border-emerald-300/40 bg-emerald-500/15 text-white'
|
||||
: 'border-white/10 bg-black/25 text-zinc-200 hover:border-white/20 hover:bg-black/35'
|
||||
)}
|
||||
disabled={isProcessing || isRecording}
|
||||
>
|
||||
No, continuar sin piezas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasCampaignAssets === 'yes' && (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-black/25 px-5 py-3.5 text-sm font-semibold text-zinc-100 transition hover:border-white/20 hover:bg-black/35 disabled:opacity-50"
|
||||
disabled={isProcessing || isRecording}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
Adjuntar campaña o elementos gráficos
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 'links' && (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
|
||||
<p className="mb-5 text-sm leading-7 text-zinc-300">
|
||||
Pega enlaces de referencia online, uno por línea. Este paso es opcional.
|
||||
</p>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/25 px-4 py-3 transition-colors focus-within:border-indigo-400/30">
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-zinc-300">
|
||||
<Link2 className="h-3.5 w-3.5 text-indigo-300" /> Enlaces de referencia
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={'Pega aquí tus enlaces de referencia, uno por línea\nhttps://www.figma.com/...\nhttps://drive.google.com/...'}
|
||||
className="h-40 max-h-40 w-full resize-none overflow-y-auto bg-transparent pr-2 text-[15px] leading-7 text-zinc-100 placeholder:text-zinc-500 outline-none scrollbar-thin"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 'review' && (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
|
||||
<p className="mb-5 text-sm leading-7 text-zinc-300">
|
||||
Revisa el resumen antes de enviar. Este es el único paso donde se puede finalizar la solicitud.
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-zinc-500">Obligatorios</p>
|
||||
<div className="mt-3 space-y-2 text-sm text-zinc-300">
|
||||
<p>Nota de voz: <span className={audioRecording ? 'text-emerald-300' : 'text-amber-300'}>{audioRecording ? 'lista' : 'pendiente'}</span></p>
|
||||
<p>Imágenes: <span className={imageAttachments.length ? 'text-emerald-300' : 'text-amber-300'}>{imageAttachments.length}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-zinc-500">Soporte</p>
|
||||
<div className="mt-3 space-y-2 text-sm text-zinc-300">
|
||||
<p>Documentos: <span className="text-white">{documentAttachments.length}</span></p>
|
||||
<p>Campaña / gráficos: <span className="text-white">{hasCampaignAssets === 'yes' ? `${campaignAttachments.length} archivo(s)` : hasCampaignAssets === 'no' ? 'no aplica' : 'no indicado'}</span></p>
|
||||
<p>Enlaces: <span className="text-white">{linksCount}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept={currentStep === 'images' ? 'image/*' : undefined}
|
||||
/>
|
||||
|
||||
{validationMessage && (
|
||||
<div className="mt-4 flex items-start gap-3 rounded-2xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
|
||||
<span>{validationMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sticky bottom-0 mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-white/5 bg-[linear-gradient(180deg,rgba(8,12,24,0),rgba(8,12,24,0.96)_28%)] pt-4 pb-1">
|
||||
<div className="text-sm text-zinc-400">
|
||||
{canSend
|
||||
? 'Todo está listo. El envío solo se realiza desde el paso final.'
|
||||
: 'Completa primero la nota de voz y las imágenes. Si marcaste campaña, adjunta sus archivos.'}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{currentStep !== 'review' ? (
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={isProcessing}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm font-semibold text-zinc-100 transition hover:border-white/20 hover:bg-black/35 disabled:opacity-50"
|
||||
>
|
||||
Continuar
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{currentStep === 'review' && (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={isProcessing || !canSend}
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_100%)] px-5 py-3 text-sm font-semibold text-white shadow-[0_18px_40px_rgba(79,70,229,0.2)] transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isProcessing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
Finalizar y enviar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="rounded-[28px] border border-white/10 bg-white/5 p-4 sm:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">Panel de control</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-zinc-500">Obligatorios</p>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-zinc-300">Nota de voz</span>
|
||||
<span className={cn('font-semibold', audioRecording ? 'text-emerald-300' : 'text-zinc-500')}>{audioRecording ? 'Lista' : 'Pendiente'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-zinc-300">Imágenes</span>
|
||||
<span className={cn('font-semibold', imageAttachments.length > 0 ? 'text-emerald-300' : 'text-zinc-500')}>
|
||||
{imageAttachments.length > 0 ? `${imageAttachments.length} cargada(s)` : 'Pendiente'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-zinc-500">Opcionales</p>
|
||||
<div className="mt-3 space-y-3 text-sm text-zinc-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Documentos</span>
|
||||
<span className="font-semibold text-white">{documentAttachments.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Campaña / gráficos</span>
|
||||
<span className="font-semibold text-white">{hasCampaignAssets === 'yes' ? campaignAttachments.length : hasCampaignAssets === 'no' ? 'No aplica' : 'No indicado'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Enlaces</span>
|
||||
<span className="font-semibold text-white">{linksCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-indigo-400/20 bg-[linear-gradient(180deg,rgba(99,102,241,0.16),rgba(99,102,241,0.04))] p-4">
|
||||
<p className="text-sm font-semibold text-white">Cómo se procesará tu solicitud</p>
|
||||
<ul className="mt-3 space-y-3 text-sm leading-6 text-zinc-300">
|
||||
<li>• La voz, las imágenes y los PDFs alimentan la interpretación del brief.</li>
|
||||
<li>• Los documentos de apoyo se guardan completos en la carpeta del proyecto.</li>
|
||||
<li>• Los enlaces se agregan como referencias dentro del documento final.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Chrome, ShieldCheck, Sparkles } from 'lucide-react';
|
||||
|
||||
interface LoginScreenProps {
|
||||
onLogin: () => void;
|
||||
isLoading: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const LoginScreen: React.FC<LoginScreenProps> = ({ onLogin, isLoading, error }) => {
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#060816] px-4 text-zinc-100">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute left-[-10%] top-[-15%] h-[34rem] w-[34rem] rounded-full bg-indigo-600/18 blur-3xl" />
|
||||
<div className="absolute right-[-8%] top-[8%] h-[30rem] w-[30rem] rounded-full bg-cyan-400/12 blur-3xl" />
|
||||
<div className="absolute bottom-[-18%] left-[20%] h-[28rem] w-[28rem] rounded-full bg-fuchsia-500/10 blur-3xl" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:42px_42px] opacity-[0.06]" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
className="relative w-full max-w-xl rounded-[32px] border border-white/10 bg-white/5 p-8 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-xl"
|
||||
>
|
||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-indigo-400/25 bg-indigo-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-indigo-200">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Acceso corporativo
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl">
|
||||
Portal Brief CDC
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 text-sm leading-7 text-zinc-300 sm:text-base">
|
||||
Inicia sesión con tu cuenta corporativa de Google para acceder al portal.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldCheck className="mt-0.5 h-5 w-5 text-indigo-300" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Acceso restringido</p>
|
||||
<p className="mt-1 text-sm leading-6 text-zinc-400">
|
||||
Solo usuarios autorizados de la empresa podrán continuar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onLogin}
|
||||
disabled={isLoading}
|
||||
className="mt-6 inline-flex w-full items-center justify-center gap-3 rounded-2xl bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_50%,#06b6d4_100%)] px-5 py-4 text-sm font-semibold text-white shadow-[0_18px_40px_rgba(79,70,229,0.38)] transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Chrome className="h-4 w-4" />
|
||||
{isLoading ? 'Abriendo Google...' : 'Continuar con Google'}
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,363 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { format } from 'date-fns';
|
||||
import { AlertCircle, Check, CheckCheck, Copy, ExternalLink, FileText, Music, ShieldCheck, X } from 'lucide-react';
|
||||
import { Message, Attachment } from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
const DRIVE_FOLDER_REGEX = /https:\/\/drive\.google\.com\/(?:drive\/u\/\d+\/folders|drive\/folders)\/[^\s)\]}"']+/i;
|
||||
|
||||
const getDriveFolderLink = (text: string) => {
|
||||
const match = text.match(DRIVE_FOLDER_REGEX);
|
||||
return match?.[0] || null;
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const safeSeconds = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0;
|
||||
const minutes = Math.floor(safeSeconds / 60);
|
||||
const remainingSeconds = safeSeconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const AttachmentPreview = ({ attachment }: { attachment: Attachment }) => {
|
||||
if (attachment.type === 'image') {
|
||||
return (
|
||||
<div className="group relative max-w-[320px] overflow-hidden rounded-2xl border border-white/10 bg-black/20">
|
||||
<img
|
||||
src={attachment.url}
|
||||
alt={attachment.file.name}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-3 py-2 text-[11px] font-medium text-white/90">
|
||||
<div className="truncate">{attachment.file.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (attachment.type === 'audio') {
|
||||
return (
|
||||
<div className="flex w-full max-w-[420px] flex-col gap-3 rounded-2xl border border-white/10 bg-white/5 px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-black/25">
|
||||
<Music className="h-4 w-4 text-indigo-200" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-white/90">{attachment.file.name}</p>
|
||||
<p className="text-[11px] text-zinc-400">{(attachment.file.size / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
</div>
|
||||
<audio controls src={attachment.url} className="h-9 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-[360px] items-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-3 py-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-black/25">
|
||||
<FileText className="h-4 w-4 text-indigo-200" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-white/90">{attachment.file.name}</p>
|
||||
<p className="text-[11px] text-zinc-400">{(attachment.file.size / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DriveLinkCard = ({ url, onClose }: { url: string; onClose: () => void }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1800);
|
||||
} catch (error) {
|
||||
console.error('No se pudo copiar el enlace de Drive:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||
className="mb-5 overflow-hidden rounded-[28px] border border-indigo-300/25 bg-[linear-gradient(135deg,rgba(99,102,241,0.22),rgba(34,211,238,0.12))] p-4 shadow-[0_22px_60px_rgba(79,70,229,0.22)] sm:p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-indigo-100/80">
|
||||
Carpeta generada
|
||||
</p>
|
||||
<h4 className="mt-2 text-lg font-semibold text-white">Enlace de Drive listo</h4>
|
||||
<p className="mt-1 text-sm leading-6 text-zinc-300">
|
||||
Copia o abre la carpeta con el contenido del brief.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-zinc-200 transition hover:bg-white/20 hover:text-white"
|
||||
aria-label="Cerrar tarjeta del enlace"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 rounded-2xl border border-white/10 bg-black/25 p-3 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1 rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-zinc-200">
|
||||
<p className="truncate font-mono">{url}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-white px-4 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-zinc-200"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
{copied ? 'Copiado' : 'Copiar'}
|
||||
</button>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/10 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/15"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Abrir
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
|
||||
const isUser = message.sender === 'user';
|
||||
const [isPlaying, setIsPlaying] = React.useState(false);
|
||||
const [audioCurrentTime, setAudioCurrentTime] = React.useState(0);
|
||||
const [audioDuration, setAudioDuration] = React.useState(message.audio?.duration || 0);
|
||||
const [showDriveCard, setShowDriveCard] = React.useState(true);
|
||||
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
||||
const driveFolderLink = !isUser ? getDriveFolderLink(message.text) : null;
|
||||
|
||||
const effectiveAudioDuration = Math.max(0, audioDuration || message.audio?.duration || 0);
|
||||
const audioProgress = effectiveAudioDuration
|
||||
? Math.min(100, Math.max(0, (audioCurrentTime / effectiveAudioDuration) * 100))
|
||||
: 0;
|
||||
|
||||
const togglePlay = async () => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
try {
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
await audioRef.current.play();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('No se pudo reproducir la nota de voz:', error);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAudioSeek = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nextTime = Number(event.target.value);
|
||||
setAudioCurrentTime(nextTime);
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = nextTime;
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
setShowDriveCard(true);
|
||||
}, [message.id, message.text]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsPlaying(false);
|
||||
setAudioCurrentTime(0);
|
||||
setAudioDuration(message.audio?.duration || 0);
|
||||
|
||||
if (message.audio) {
|
||||
const audio = new Audio(message.audio.url);
|
||||
audio.preload = 'metadata';
|
||||
|
||||
const updateCurrentTime = () => {
|
||||
setAudioCurrentTime(audio.currentTime || 0);
|
||||
};
|
||||
|
||||
audio.onloadedmetadata = () => {
|
||||
const loadedDuration = Number.isFinite(audio.duration) ? audio.duration : 0;
|
||||
setAudioDuration(loadedDuration || message.audio?.duration || 0);
|
||||
};
|
||||
audio.ontimeupdate = updateCurrentTime;
|
||||
audio.onseeking = updateCurrentTime;
|
||||
audio.onseeked = updateCurrentTime;
|
||||
audio.onplay = () => setIsPlaying(true);
|
||||
audio.onpause = () => {
|
||||
setIsPlaying(false);
|
||||
updateCurrentTime();
|
||||
};
|
||||
audio.onended = () => {
|
||||
setIsPlaying(false);
|
||||
audio.currentTime = 0;
|
||||
setAudioCurrentTime(0);
|
||||
};
|
||||
|
||||
audioRef.current = audio;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
audioRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [message.audio]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 14, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.32, ease: [0.23, 1, 0.32, 1] }}
|
||||
className={cn('mb-5 flex w-full', isUser ? 'justify-end' : 'justify-start')}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-1',
|
||||
isUser ? 'max-w-3xl items-end' : 'w-full max-w-none items-start'
|
||||
)}
|
||||
>
|
||||
{!isUser && (
|
||||
<div className="mb-1 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-zinc-400">
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-indigo-300" />
|
||||
Respuesta del portal
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden border shadow-[0_16px_40px_rgba(0,0,0,0.25)]',
|
||||
isUser
|
||||
? 'max-w-[85%] rounded-[26px] rounded-br-md border-indigo-400/20 bg-[linear-gradient(135deg,rgba(79,70,229,0.96),rgba(37,99,235,0.92))] px-4 py-4 text-white sm:px-5'
|
||||
: 'w-full max-w-none rounded-[28px] rounded-bl-md border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.07),rgba(255,255,255,0.03))] px-5 py-5 text-zinc-100 backdrop-blur-xl sm:px-6 sm:py-6'
|
||||
)}
|
||||
>
|
||||
{!isUser && (
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(129,140,248,0.14),transparent_26%)]" />
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{driveFolderLink && showDriveCard && (
|
||||
<DriveLinkCard url={driveFolderLink} onClose={() => setShowDriveCard(false)} />
|
||||
)}
|
||||
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
{message.attachments.map((att) => (
|
||||
<AttachmentPreview key={att.id} attachment={att} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.audio && (
|
||||
<div className="mb-4 rounded-2xl bg-black/20 p-3 pr-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlay}
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-white/15 transition hover:bg-white/25"
|
||||
aria-label={isPlaying ? 'Pausar audio' : 'Reproducir audio'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<div className="flex h-3.5 w-3.5 gap-1">
|
||||
<div className="h-full w-1 rounded-sm bg-white" />
|
||||
<div className="h-full w-1 rounded-sm bg-white" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-0.5 h-0 w-0 border-b-[7px] border-l-[10px] border-t-[7px] border-b-transparent border-l-white border-t-transparent" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-semibold text-white/80">
|
||||
{isPlaying ? 'Reproduciendo' : 'Nota de voz'}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs font-mono opacity-80">
|
||||
{formatDuration(audioCurrentTime)} / {formatDuration(effectiveAudioDuration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative h-5">
|
||||
<div className="absolute left-0 right-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-white/15" />
|
||||
<div
|
||||
className="absolute left-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-white/70"
|
||||
style={{ width: `${audioProgress}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={Math.max(1, effectiveAudioDuration)}
|
||||
step="0.01"
|
||||
value={Math.min(audioCurrentTime, effectiveAudioDuration || 0)}
|
||||
onChange={handleAudioSeek}
|
||||
className="message-audio-range absolute inset-0 w-full cursor-pointer"
|
||||
aria-label="Adelantar o atrasar la nota de voz"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.text && (
|
||||
<div
|
||||
className={cn(
|
||||
'break-words text-[15px] leading-7 sm:text-base',
|
||||
isUser ? 'whitespace-pre-wrap' : ''
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
message.text
|
||||
) : (
|
||||
<div className="prose prose-invert max-w-none prose-p:my-3 prose-p:leading-8 prose-headings:mb-3 prose-headings:mt-1 prose-headings:text-white prose-strong:text-white prose-li:my-1 prose-ul:my-3 prose-ol:my-3 prose-pre:overflow-x-auto prose-pre:rounded-2xl prose-pre:border prose-pre:border-white/10 prose-pre:bg-black/35 prose-pre:p-4 prose-pre:text-[13px] prose-code:text-indigo-200">
|
||||
<ReactMarkdown>{message.text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-1.5 px-1">
|
||||
<span className="text-[11px] font-medium text-zinc-500">
|
||||
{format(message.timestamp, 'HH:mm')}
|
||||
</span>
|
||||
|
||||
{isUser && (
|
||||
<span className="text-zinc-500">
|
||||
{message.status === 'sending' && (
|
||||
<AlertCircle className="h-3.5 w-3.5 animate-pulse text-zinc-400" />
|
||||
)}
|
||||
{message.status === 'sent' && (
|
||||
<CheckCheck className="h-3.5 w-3.5 text-indigo-400" />
|
||||
)}
|
||||
{message.status === 'error' && (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { X, Save, Link as LinkIcon } from 'lucide-react';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
webhookUrl: string;
|
||||
onSave: (url: string) => void;
|
||||
}
|
||||
|
||||
export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, webhookUrl, onSave }) => {
|
||||
const [url, setUrl] = useState(webhookUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setUrl(webhookUrl);
|
||||
}
|
||||
}, [isOpen, webhookUrl]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(url);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md bg-zinc-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 border-b border-white/5">
|
||||
<h2 className="text-xl font-semibold text-white">Configuración de conexión</h2>
|
||||
<button onClick={onClose} className="p-2 rounded-full hover:bg-white/5 text-zinc-400 hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-300 flex items-center gap-2">
|
||||
<LinkIcon className="w-4 h-4 text-indigo-400" />
|
||||
Webhook URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://tu-endpoint.com/webhook"
|
||||
className="w-full bg-black/50 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-zinc-600 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500 mt-2">
|
||||
Endpoint donde se enviarán la nota de voz, imágenes, documentos y enlaces mediante una solicitud POST.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-white/5 bg-black/20 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 rounded-xl text-sm font-medium text-zinc-300 hover:text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-5 py-2.5 rounded-xl text-sm font-medium bg-indigo-600 text-white hover:bg-indigo-500 transition-colors flex items-center gap-2 shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #060816;
|
||||
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.audio-preview-range {
|
||||
height: 20px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.audio-preview-range::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.audio-preview-range::-webkit-slider-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: -5px;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: #67e8f9;
|
||||
box-shadow: 0 0 0 5px rgba(103, 232, 249, 0.16), 0 8px 18px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.audio-preview-range::-moz-range-track {
|
||||
height: 6px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.audio-preview-range::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
background: #67e8f9;
|
||||
box-shadow: 0 0 0 5px rgba(103, 232, 249, 0.16), 0 8px 18px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.message-audio-range {
|
||||
height: 20px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.message-audio-range::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.message-audio-range::-webkit-slider-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: -5px;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: #67e8f9;
|
||||
box-shadow: 0 0 0 5px rgba(103, 232, 249, 0.16), 0 8px 18px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.message-audio-range::-moz-range-track {
|
||||
height: 6px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.message-audio-range::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
background: #67e8f9;
|
||||
box-shadow: 0 0 0 5px rgba(103, 232, 249, 0.16), 0 8px 18px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
||||
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
||||
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: import.meta.env.VITE_FIREBASE_APP_ID,
|
||||
};
|
||||
|
||||
const app = initializeApp(firebaseConfig);
|
||||
|
||||
export const auth = getAuth(app);
|
||||
|
||||
export const googleProvider = new GoogleAuthProvider();
|
||||
googleProvider.setCustomParameters({
|
||||
prompt: 'select_account',
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
export type AttachmentType = 'image' | 'video' | 'audio' | 'document' | 'other';
|
||||
export type AttachmentCategory = 'general' | 'campaign';
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
file: File;
|
||||
type: AttachmentType;
|
||||
url: string; // Object URL for preview
|
||||
category?: AttachmentCategory;
|
||||
}
|
||||
|
||||
export interface AudioRecording {
|
||||
blob: Blob;
|
||||
url: string;
|
||||
duration: number; // in seconds
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
sender: 'user' | 'system';
|
||||
text: string;
|
||||
attachments?: Attachment[];
|
||||
audio?: AudioRecording;
|
||||
timestamp: Date;
|
||||
status: 'sending' | 'sent' | 'error';
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user