638 lines
22 KiB
TypeScript
638 lines
22 KiB
TypeScript
"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>
|
||
);
|
||
}
|