Modulo makers3d desarrollado con codex V 0.0.1

This commit is contained in:
Ryuk Mike
2026-07-29 23:58:58 +02:00
commit 2bedf7cbb7
171 changed files with 29421 additions and 0 deletions
+637
View File
@@ -0,0 +1,637 @@
"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>
);
}