Files

638 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { browserFetch } from "../lib/api";
type User = {
id: string;
email: string;
role: string;
};
type MakerProfile = {
id: string;
slug: string;
business_name: string | null;
status: string;
};
type MessageAccess = "customer" | "maker" | "admin";
type InquiryStatus = "open" | "agreed" | "completed" | "rejected" | "incident";
type Conversation = {
id: string;
business_name: string;
maker_slug: string;
customer_name: string;
customer_id: string;
need_text: string;
size_text: string | null;
has_model: boolean;
material_preference: string | null;
quantity: number;
urgency: string;
delivery_type: string;
source_type: string;
status: InquiryStatus;
rejection_reason: string | null;
proposal_price_cents: number | null;
proposal_quantity: number | null;
proposal_lead_time_days: number | null;
proposal_note: string | null;
proposal_created_at: string | null;
agreed_at: string | null;
maker_completed_at: string | null;
customer_completed_at: string | null;
completed_at: string | null;
incident_reason: string | null;
incident_detail: string | null;
last_message: string | null;
last_message_at: string | null;
last_sender_user_id: string | null;
message_count: number;
created_at: string;
};
type Message = {
id: string;
sender_user_id: string;
full_name: string;
body: string;
created_at: string;
};
type ConversationDetail = {
inquiry: Conversation & {
maker_user_id: string;
customer_name: string;
};
messages: Message[];
};
type FilterKey = "all" | "consultas" | "active";
const incidentOptions = [
"El trabajo no fue entregado",
"El resultado no fue el acordado",
"Hubo un problema con el plazo",
"El maker no responde",
"Otro"
];
function formatTime(value?: string | null) {
if (!value) {
return "";
}
return new Intl.DateTimeFormat("es-AR", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
}
function formatMoney(value?: number | null) {
if (!value) {
return "";
}
return `$${Math.round(value / 100).toLocaleString("es-AR")}`;
}
function statusInfo(conversation: Conversation, userId?: string) {
if (conversation.status === "completed") {
return { label: "Finalizado", tone: "success", icon: "✓" };
}
if (conversation.status === "rejected") {
return { label: "Rechazado", tone: "danger", icon: "×" };
}
if (conversation.status === "incident") {
return { label: "Incidencia", tone: "danger", icon: "!" };
}
if (conversation.status === "agreed") {
return { label: "Trabajo acordado", tone: "info", icon: "↔" };
}
if (conversation.message_count <= 1) {
return { label: "Nueva consulta", tone: "info", icon: "✉" };
}
if (conversation.last_sender_user_id === userId) {
return { label: "Esperando respuesta", tone: "warning", icon: "○" };
}
return { label: "En conversacion", tone: "success", icon: "●" };
}
function deliveryLabel(value: string) {
return value === "local" ? "Retiro / local" : "Envio";
}
function urgencyLabel(value: string) {
if (value === "high") {
return "Urgente";
}
if (value === "low") {
return "Sin urgencia";
}
return "Esta semana";
}
export default function MessagesClient() {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [messageAccess, setMessageAccess] = useState<MessageAccess>("customer");
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<ConversationDetail | null>(null);
const [filter, setFilter] = useState<FilterKey>("all");
const [reply, setReply] = useState("");
const [review, setReview] = useState({ rating: 5, comment: "" });
const [incident, setIncident] = useState({ reason: incidentOptions[0], detail: "" });
const [showActions, setShowActions] = useState(false);
const [status, setStatus] = useState("");
async function loadConversations(preferredId?: string, access = messageAccess) {
if (access === "admin") {
setConversations([]);
setSelectedId("");
setDetail(null);
return;
}
const scope = access === "maker" ? "maker" : "customer";
const data = await browserFetch<{ conversations: Conversation[] }>(`/account/conversations?scope=${scope}`);
setConversations(data.conversations);
setSelectedId((current) => preferredId || current || data.conversations[0]?.id || "");
}
async function refreshDetail(id = selectedId) {
if (!id) {
return;
}
const data = await browserFetch<ConversationDetail>(`/inquiries/${id}`);
setDetail(data);
}
async function refreshAll(id = selectedId) {
await Promise.all([loadConversations(id), refreshDetail(id)]);
}
useEffect(() => {
let active = true;
async function load() {
setLoading(true);
try {
const me = await browserFetch<{ user: User | null; makerProfile: MakerProfile | null }>("/auth/me");
if (!active) {
return;
}
setUser(me.user);
const access: MessageAccess = me.user?.role === "admin"
? "admin"
: me.makerProfile && me.makerProfile.status !== "draft"
? "maker"
: "customer";
setMessageAccess(access);
if (me.user) {
await loadConversations(undefined, access);
}
} catch (error) {
if (active) {
setStatus(error instanceof Error ? error.message : "No se pudo cargar mensajes");
}
} finally {
if (active) {
setLoading(false);
}
}
}
void load();
return () => {
active = false;
};
}, []);
useEffect(() => {
if (!selectedId || !user) {
setDetail(null);
return;
}
let active = true;
async function loadDetail() {
try {
const data = await browserFetch<ConversationDetail>(`/inquiries/${selectedId}`);
if (active) {
setDetail(data);
setShowActions(false);
}
} catch (error) {
if (active) {
setStatus(error instanceof Error ? error.message : "No se pudo abrir la conversacion");
}
}
}
void loadDetail();
return () => {
active = false;
};
}, [selectedId, user]);
const filteredConversations = useMemo(() => {
if (filter === "consultas") {
return conversations.filter((conversation) => conversation.status === "open");
}
if (filter === "active") {
return conversations.filter((conversation) => ["open", "agreed", "incident"].includes(conversation.status));
}
return conversations;
}, [conversations, filter]);
const accessCopy = messageAccess === "maker"
? {
eyebrow: "Consultas maker",
title: "Bandeja de clientes",
empty: "No hay consultas recibidas para este filtro.",
auth: "Inicia sesion como maker para responder consultas de clientes."
}
: {
eyebrow: "Mensajes",
title: "Bandeja de consultas",
empty: "No hay conversaciones para este filtro.",
auth: "Inicia sesion para ver tus consultas y respuestas de los makers."
};
async function sendReply(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = reply.trim();
if (!selectedId || !body) {
return;
}
setStatus("Enviando...");
try {
await browserFetch(`/inquiries/${selectedId}/messages`, {
method: "POST",
body: JSON.stringify({ body })
});
setReply("");
setStatus("");
await refreshAll();
} catch (error) {
setStatus(error instanceof Error ? error.message : "No se pudo enviar el mensaje");
}
}
async function acceptProposal() {
if (!selectedId) {
return;
}
setStatus("Aceptando propuesta...");
try {
await browserFetch(`/inquiries/${selectedId}/accept-proposal`, { method: "POST" });
setStatus("");
await refreshAll();
} catch (error) {
setStatus(error instanceof Error ? error.message : "No se pudo aceptar la propuesta");
}
}
async function confirmCompleted() {
if (!selectedId) {
return;
}
setStatus("Confirmando finalizacion...");
try {
await browserFetch(`/inquiries/${selectedId}/complete`, { method: "POST" });
setStatus("");
await refreshAll();
} catch (error) {
setStatus(error instanceof Error ? error.message : "No se pudo confirmar");
}
}
async function submitReview(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!detail) {
return;
}
setStatus("Publicando resena...");
try {
await browserFetch("/reviews", {
method: "POST",
body: JSON.stringify({
inquiryId: detail.inquiry.id,
ratingOverall: review.rating,
ratingQuality: review.rating,
ratingCommunication: review.rating,
ratingValue: review.rating,
comment: review.comment
})
});
setReview({ rating: 5, comment: "" });
setStatus("Resena publicada.");
} catch (error) {
setStatus(error instanceof Error ? error.message : "No se pudo publicar la resena");
}
}
async function submitIncident(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedId) {
return;
}
setStatus("Reportando incidencia...");
try {
await browserFetch(`/inquiries/${selectedId}/incident`, {
method: "POST",
body: JSON.stringify(incident)
});
setIncident({ reason: incidentOptions[0], detail: "" });
setStatus("");
await refreshAll();
} catch (error) {
setStatus(error instanceof Error ? error.message : "No se pudo reportar la incidencia");
}
}
if (loading) {
return (
<section className="messages-shell">
<div className="messages-loading">
<span />
<span />
<span />
</div>
</section>
);
}
if (!user) {
return (
<section className="messages-shell">
<div className="messages-auth-card">
<h1>Mensajes</h1>
<p>{accessCopy.auth}</p>
<Link href="/login?returnTo=%2Fmessages" className="button button-primary">Entrar</Link>
</div>
</section>
);
}
if (messageAccess === "admin") {
return (
<section className="messages-shell">
<div className="messages-auth-card">
<span className="eyebrow">Mensajes administrador</span>
<h1>Bandeja administrativa</h1>
<p>Las consultas operativas, incidencias y reportes del administrador se gestionan desde el backoffice para mantener trazabilidad.</p>
<div className="home-actions">
<a href="/app/backoffice/cases" className="button button-primary">Abrir casos</a>
<a href="/app/backoffice" className="button button-secondary">Ir al backoffice</a>
</div>
</div>
</section>
);
}
const current = detail?.inquiry;
const currentInfo = current ? statusInfo(current, user.id) : null;
const currentDisplayName = current
? messageAccess === "maker" ? current.customer_name : current.business_name
: "";
const whatsappText = current
? encodeURIComponent(`Hola, soy ${detail.inquiry.customer_name}. Te contacto por la consulta "${current.need_text}".`)
: "";
return (
<section className="messages-shell messages-flow-shell">
<aside className="messages-list">
<div className="messages-head">
<div>
<span className="eyebrow">{accessCopy.eyebrow}</span>
<h1>{accessCopy.title}</h1>
</div>
<span className="badge">{conversations.length}</span>
</div>
<div className="messages-filter-row" aria-label="Filtros de mensajes">
{[
["all", "Todos"],
["consultas", "Consultas"],
["active", "En curso"]
].map(([key, label]) => (
<button
key={key}
type="button"
className={`messages-filter-chip ${filter === key ? "is-active" : ""}`}
onClick={() => setFilter(key as FilterKey)}
>
{label}
</button>
))}
</div>
<div className="messages-conversation-list">
{filteredConversations.length === 0 ? (
<div className="empty-state">{accessCopy.empty}</div>
) : null}
{filteredConversations.map((conversation) => {
const info = statusInfo(conversation, user.id);
const displayName = messageAccess === "maker" ? conversation.customer_name : conversation.business_name;
return (
<button
key={conversation.id}
type="button"
className={`messages-conversation ${conversation.id === selectedId ? "is-active" : ""}`}
onClick={() => setSelectedId(conversation.id)}
>
<span className="messages-avatar">{displayName.slice(0, 2).toUpperCase()}</span>
<span className="messages-conversation-copy">
<strong>{displayName}</strong>
<span>{conversation.last_message || conversation.need_text}</span>
<span className={`messages-state-text is-${info.tone}`}>{info.icon} {info.label}</span>
</span>
<span className="messages-time">{formatTime(conversation.last_message_at || conversation.created_at)}</span>
</button>
);
})}
</div>
</aside>
<section className="messages-thread">
{current && detail ? (
<>
<div className="messages-thread-head">
<div className="messages-maker-title">
<span className="messages-avatar small">{currentDisplayName.slice(0, 2).toUpperCase()}</span>
<div>
<h2>{currentDisplayName}</h2>
<span className={`messages-state-text is-${currentInfo?.tone}`}>{currentInfo?.icon} {currentInfo?.label}</span>
</div>
</div>
<button className="messages-icon-button" type="button" onClick={() => setShowActions((value) => !value)} aria-label="Mas acciones">
⋮
</button>
</div>
<article className="messages-context-card">
<div className="messages-context-main">
<span className="eyebrow">Consulta estructurada</span>
<strong>{current.need_text}</strong>
<Link href={`/makers/${current.maker_slug}`}>Ver perfil del maker</Link>
</div>
<div className="messages-context-grid">
<span>Cantidad <strong>{current.quantity || 1}</strong></span>
<span>Urgencia <strong>{urgencyLabel(current.urgency)}</strong></span>
<span>Entrega <strong>{deliveryLabel(current.delivery_type)}</strong></span>
<span>Material <strong>{current.material_preference || "A definir"}</strong></span>
<span>Medidas <strong>{current.size_text || "Sin medidas"}</strong></span>
<span>Adjuntos <strong>{current.has_model ? "Con archivo" : "Sin archivo"}</strong></span>
</div>
</article>
{showActions ? (
<div className="messages-actions-panel">
<button type="button" onClick={() => setReply((value) => value || "Te comparto las medidas actualizadas.")}>Compartir medidas</button>
<button type="button" onClick={() => setReply((value) => value || "Adjunto fotos de referencia para que lo revises.")}>Anadir fotos</button>
<a href={`https://wa.me/?text=${whatsappText}`} target="_blank" rel="noreferrer">Continuar por WhatsApp</a>
<a href={`mailto:?subject=Consulta Makers3D&body=${whatsappText}`}>Continuar por email</a>
</div>
) : null}
<div className="messages-bubbles">
{detail.messages.map((message) => {
const isMine = message.sender_user_id === user.id;
return (
<article key={message.id} className={`messages-bubble ${isMine ? "is-mine" : "is-maker"}`}>
<div className="messages-bubble-top">
<strong>{isMine ? "Tu" : currentDisplayName}</strong>
<span>{formatTime(message.created_at)}</span>
</div>
<p>{message.body}</p>
</article>
);
})}
{current.proposal_price_cents ? (
<article className="messages-system-card">
<span className="messages-system-icon">↔</span>
<div>
<strong>{current.status === "agreed" || current.status === "completed" ? "Trabajo acordado" : "Propuesta de trabajo"}</strong>
<dl>
<div><dt>Trabajo</dt><dd>{current.need_text}</dd></div>
<div><dt>Cantidad</dt><dd>{current.proposal_quantity || current.quantity || 1}</dd></div>
<div><dt>Precio</dt><dd>{formatMoney(current.proposal_price_cents)}</dd></div>
<div><dt>Plazo</dt><dd>{current.proposal_lead_time_days} dias</dd></div>
</dl>
{current.proposal_note ? <p>{current.proposal_note}</p> : null}
{current.status === "open" ? (
<button className="button button-primary" type="button" onClick={acceptProposal}>Aceptar propuesta</button>
) : null}
</div>
</article>
) : null}
{current.status === "completed" ? (
<article className="messages-system-card success">
<span className="messages-system-icon">✓</span>
<div>
<strong>Trabajo finalizado</strong>
<p>Ambas partes confirmaron que el trabajo fue completado.</p>
<small>Finalizado el {formatTime(current.completed_at)}</small>
</div>
</article>
) : null}
{current.status === "rejected" ? (
<article className="messages-system-card danger">
<span className="messages-system-icon">×</span>
<div>
<strong>Consulta rechazada</strong>
<p>{current.rejection_reason || "El maker no puede realizar este trabajo."}</p>
<Link href="/discover" className="button button-primary">Ver mas alternativas</Link>
</div>
</article>
) : null}
{current.status === "incident" ? (
<article className="messages-system-card danger">
<span className="messages-system-icon">!</span>
<div>
<strong>Incidencia abierta</strong>
<p>{current.incident_reason}: {current.incident_detail}</p>
</div>
</article>
) : null}
</div>
<div className="messages-work-actions">
{current.status === "agreed" || current.status === "open" ? (
<button className="button button-secondary" type="button" onClick={confirmCompleted}>
Marcar como finalizado
</button>
) : null}
{current.status !== "incident" && current.status !== "rejected" ? (
<button className="button button-secondary" type="button" onClick={() => setIncident((value) => ({ ...value, detail: value.detail || "Necesito ayuda con esta consulta." }))}>
Reportar problema
</button>
) : null}
</div>
{current.status === "completed" ? (
<form className="messages-review-box" onSubmit={submitReview}>
<strong>Dejar resena verificada</strong>
<div className="messages-stars" aria-label="Puntuacion">
{[1, 2, 3, 4, 5].map((star) => (
<button key={star} type="button" className={review.rating >= star ? "is-active" : ""} onClick={() => setReview((value) => ({ ...value, rating: star }))}>★</button>
))}
</div>
<textarea value={review.comment} onChange={(event) => setReview((value) => ({ ...value, comment: event.target.value }))} placeholder="Cuenta como fue tu experiencia..." required minLength={10} />
<button className="button button-primary" type="submit">Publicar resena</button>
</form>
) : null}
{incident.detail ? (
<form className="messages-incident-box" onSubmit={submitIncident}>
<strong>Cuentanos que ocurrio</strong>
<select value={incident.reason} onChange={(event) => setIncident((value) => ({ ...value, reason: event.target.value }))}>
{incidentOptions.map((option) => <option key={option}>{option}</option>)}
</select>
<textarea value={incident.detail} onChange={(event) => setIncident((value) => ({ ...value, detail: event.target.value }))} placeholder="Danos mas detalles..." required minLength={5} />
<button className="button button-primary" type="submit">Enviar reporte</button>
</form>
) : null}
<form className="messages-composer" onSubmit={sendReply}>
<button type="button" className="messages-plus-button" onClick={() => setShowActions((value) => !value)} aria-label="Acciones">+</button>
<input
value={reply}
onChange={(event) => setReply(event.target.value)}
placeholder="Escribe un mensaje..."
aria-label="Mensaje"
/>
<button className="button button-primary" type="submit">Enviar</button>
</form>
</>
) : (
<div className="empty-state">Selecciona una conversacion para ver el detalle.</div>
)}
{status ? <p className="messages-status">{status}</p> : null}
</section>
</section>
);
}