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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { browserFetch } from "../lib/api";
import { logoutByNavigation } from "../lib/logout";
type MeResponse = {
user: { id: string; email: string; role: string } | null;
makerProfile: { id: string; slug: string; business_name: string | null; status: string } | null;
};
export default function AuthNav() {
const [session, setSession] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
async function loadSession() {
try {
const response = await browserFetch<MeResponse>("/auth/me");
if (active) {
setSession(response);
}
} catch {
if (active) {
setSession({ user: null, makerProfile: null });
}
} finally {
if (active) {
setLoading(false);
}
}
}
void loadSession();
return () => {
active = false;
};
}, []);
function logout() {
logoutByNavigation();
}
const user = session?.user || null;
const makerProfile = session?.makerProfile || null;
const isAdmin = user?.role === "admin";
const isMaker = Boolean(makerProfile && makerProfile.status !== "draft");
return (
<nav className="nav-links">
<Link href="/" className="nav-link">Inicio</Link>
<Link href="/discover" className="nav-link">Descubrir</Link>
{loading ? <span className="nav-session-chip">Sesion...</span> : null}
{!loading && !user ? (
<Link href="/login" className="button button-primary">Entrar</Link>
) : null}
{!loading && user && isAdmin ? (
<>
<a href="/app/backoffice" className="nav-link nav-link-admin">Backoffice</a>
</>
) : null}
{!loading && user && !isAdmin && isMaker ? (
<>
<Link href="/account" className="nav-link">Mi espacio maker</Link>
<Link href="/account/inbox" className="nav-link">Consultas maker</Link>
</>
) : null}
{!loading && user && !isAdmin && !isMaker ? (
<>
<Link href="/messages" className="nav-link">Mensajes</Link>
<Link href="/favorites" className="nav-link">Favoritos</Link>
</>
) : null}
{!loading && user ? (
<button className="button button-secondary nav-logout-button" type="button" onClick={logout}>
Cerrar sesion
</button>
) : null}
</nav>
);
}
@@ -0,0 +1,13 @@
"use client";
import { useEffect } from "react";
import { listenAuthChanged } from "../lib/authEvents";
export default function AuthSessionSync() {
useEffect(() => listenAuthChanged(() => {
window.location.reload();
}), []);
return null;
}
@@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { browserFetch } from "../lib/api";
import AdminClient, { type AdminSection } from "./AdminClient";
export default function BackofficeEntryClient({ section }: { section: AdminSection }) {
const pathname = usePathname();
const [state, setState] = useState<"loading" | "ready" | "forbidden">("loading");
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
async function checkSession() {
try {
const response = await browserFetch<{ user: { id: string; role: string } | null }>("/auth/me");
if (cancelled) {
return;
}
if (!response.user) {
window.location.replace(`/login?returnTo=${encodeURIComponent(pathname || "/app/backoffice")}`);
return;
}
if (response.user.role !== "admin") {
setState("forbidden");
setError("Esta cuenta no tiene permisos de backoffice.");
return;
}
setState("ready");
} catch (sessionError) {
if (cancelled) {
return;
}
const message = sessionError instanceof Error ? sessionError.message : "No se pudo validar la sesion";
setError(message);
window.location.replace(`/login?returnTo=${encodeURIComponent(pathname || "/app/backoffice")}`);
}
}
void checkSession();
return () => {
cancelled = true;
};
}, [pathname]);
if (state === "loading") {
return (
<main className="page-shell ops-page">
<section className="panel stack">
<span className="eyebrow">Backoffice</span>
<h1 className="section-title">Validando acceso</h1>
<p className="muted">Comprobando la sesion antes de abrir el panel operativo.</p>
</section>
</main>
);
}
if (state === "forbidden") {
return (
<main className="page-shell ops-page">
<section className="panel stack">
<span className="eyebrow">Backoffice</span>
<h1 className="section-title">Acceso restringido</h1>
<p className="muted">{error}</p>
<div className="home-actions">
<Link href="/" className="button button-primary">Volver a la app</Link>
<Link href="/login?returnTo=%2Fapp%2Fbackoffice" className="button button-secondary">Entrar con otra cuenta</Link>
</div>
</section>
</main>
);
}
return (
<main className="page-shell ops-page">
<AdminClient section={section} />
</main>
);
}
@@ -0,0 +1,249 @@
"use client";
import { useEffect, useRef } from "react";
import Link from "next/link";
import FavoriteToggle from "./FavoriteToggle";
type PublicMaker = {
id: string;
slug: string;
business_name: string;
};
type PublicWork = {
id: string;
slug: string;
title: string;
summary: string;
technology: string;
material: string;
image_url: string | null;
gallery_urls: string[] | null;
maker_id: string;
maker_slug: string;
maker_name: string;
avg_rating: number | string;
review_count: number;
};
type DiscoverCategory = "all" | "fdm" | "resin" | "design-3d" | "repuestos" | "ingenieria";
const discoverReturnKey = "makers3d:discover:return-position";
function getRating(value: number | string) {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function workImages(work: PublicWork) {
const images = [work.image_url, ...(work.gallery_urls || [])]
.filter((image): image is string => Boolean(image));
return Array.from(new Set(images)).slice(0, 8);
}
function getWorkTags(work: PublicWork) {
return [work.technology, work.material].filter(Boolean).slice(0, 3);
}
function matchesCategory(work: PublicWork, category: DiscoverCategory) {
if (category === "all") {
return true;
}
const haystack = [work.title, work.summary, work.technology, work.material, work.maker_name]
.join(" ")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
if (category === "fdm") {
return haystack.includes("fdm");
}
if (category === "resin") {
return haystack.includes("resina") || haystack.includes("resin");
}
if (category === "design-3d") {
return haystack.includes("diseno") || haystack.includes("diseño") || haystack.includes("cad");
}
if (category === "repuestos") {
return haystack.includes("repuesto") || haystack.includes("restauracion") || haystack.includes("pieza");
}
return haystack.includes("ingenieria") || haystack.includes("industrial") || haystack.includes("tecnic");
}
function orderWorks(works: PublicWork[]) {
return [...works].sort((left, right) => {
if (getRating(right.avg_rating) !== getRating(left.avg_rating)) {
return getRating(right.avg_rating) - getRating(left.avg_rating);
}
return right.review_count - left.review_count;
});
}
function SearchIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="6.6" />
<path d="m20 20-3.6-3.6" />
</svg>
);
}
export default function DiscoverExperienceClient({
initialWorks,
initialQuery,
initialCategory
}: {
initialMakers: PublicMaker[];
initialWorks: PublicWork[];
initialQuery: string;
initialCategory: DiscoverCategory;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const works = orderWorks(initialWorks.filter((work) => matchesCategory(work, initialCategory)));
useEffect(() => {
const storedPosition = window.sessionStorage.getItem(discoverReturnKey);
if (!storedPosition) {
return;
}
try {
const position = JSON.parse(storedPosition) as {
scrollTop?: number;
query?: string;
category?: DiscoverCategory;
};
if (position.query !== initialQuery || position.category !== initialCategory) {
return;
}
const restore = () => {
scrollRef.current?.scrollTo({ top: position.scrollTop || 0, behavior: "auto" });
};
requestAnimationFrame(restore);
window.setTimeout(restore, 120);
} catch {
window.sessionStorage.removeItem(discoverReturnKey);
}
}, [initialCategory, initialQuery, works.length]);
function rememberReturnPosition(workId: string) {
window.sessionStorage.setItem(
discoverReturnKey,
JSON.stringify({
category: initialCategory,
query: initialQuery,
scrollTop: scrollRef.current?.scrollTop || 0,
workId
})
);
}
return (
<section className="discover-feed-shell">
<div className="discover-feed-topbar">
<form action="/discover" className="discover-feed-search">
{initialCategory !== "all" ? <input type="hidden" name="category" value={initialCategory} /> : null}
<span aria-hidden="true">
<SearchIcon />
</span>
<input name="q" placeholder="Buscar trabajos, piezas, materiales..." defaultValue={initialQuery} />
</form>
</div>
<div ref={scrollRef} className="discover-feed-scroll" aria-label="Feed de trabajos">
{works.map((work, index) => {
const images = workImages(work);
return (
<article key={work.id} className="discover-feed-item">
<section className="discover-feed-visual">
<div className="discover-feed-gallery" aria-label={`Galeria de ${work.title}`}>
{images.map((image, imageIndex) => (
<div key={`${work.id}-${image}`} className="discover-feed-slide">
<img src={image} alt={`${work.title} - imagen ${imageIndex + 1}`} />
</div>
))}
</div>
{images.length > 1 ? (
<div className="discover-feed-dots" aria-hidden="true">
{images.map((image) => (
<span key={`${work.id}-dot-${image}`} />
))}
</div>
) : null}
<span className="discover-feed-photo-count">{images.length === 1 ? "1 foto" : `${images.length} fotos | desliza`}</span>
<div className="discover-feed-actions" aria-label="Acciones del trabajo">
<FavoriteToggle targetType="work" targetId={work.id} compact />
<Link
href={`/works/${work.slug}`}
className="discover-feed-action-button"
onClick={() => rememberReturnPosition(work.id)}
aria-label="Ver trabajo completo"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 17 17 7" />
<path d="M8 7h9v9" />
</svg>
</Link>
</div>
</section>
<section className="discover-feed-copy">
<div className="discover-feed-maker-row">
<Link href={`/makers/${work.maker_slug}`} className="discover-feed-maker">
<span>{work.maker_name.slice(0, 2).toUpperCase()}</span>
<strong>{work.maker_name}</strong>
</Link>
<div className="discover-feed-rating">
<span className="map-home-star">*</span>
<span>{getRating(work.avg_rating).toFixed(1)}</span>
<span>({Math.max(work.review_count * 32, 12)})</span>
</div>
</div>
<div className="discover-feed-title-row">
<h1>{work.title}</h1>
<Link
href={`/works/${work.slug}`}
className="button button-primary discover-feed-cta"
onClick={() => rememberReturnPosition(work.id)}
>
Ver trabajo
</Link>
</div>
<p>{work.summary}</p>
<div className="discover-feed-tags">
{getWorkTags(work).map((tag) => (
<span key={`${work.id}-${tag}`} className="map-home-tag">{tag}</span>
))}
</div>
</section>
</article>
);
})}
{works.length === 0 ? (
<section className="discover-feed-empty">
<span className="eyebrow">Descubrir</span>
<h1>No encontramos trabajos</h1>
<p>Prueba otra busqueda para volver a explorar el portfolio de makers.</p>
<Link href="/discover" className="button button-primary">Ver todos</Link>
</section>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,96 @@
import { serverFetch } from "../lib/api";
import DiscoverExperienceClient from "./DiscoverExperienceClient";
type PublicMaker = {
id: string;
slug: string;
business_name: string;
description: string;
province: string;
city: string;
delivery_scope: string;
availability: string;
main_image_url: string | null;
avg_rating: number | string;
review_count: number;
service_count: number;
work_count: number;
public_latitude: number | null;
public_longitude: number | null;
categories?: string[];
};
type PublicWork = {
id: string;
slug: string;
title: string;
summary: string;
technology: string;
material: string;
image_url: string | null;
gallery_urls: string[] | null;
maker_id: string;
maker_slug: string;
maker_name: string;
avg_rating: number | string;
review_count: number;
};
type DiscoverCategory = "all" | "fdm" | "resin" | "design-3d" | "repuestos" | "ingenieria";
function readParam(value: string | string[] | undefined) {
return typeof value === "string" ? value : "";
}
function normalizeCategory(value: string): DiscoverCategory {
const normalized = value.trim().toLowerCase();
if (normalized === "fdm" || normalized === "impresion fdm") {
return "fdm";
}
if (normalized === "resina" || normalized === "impresion resina") {
return "resin";
}
if (normalized === "design-3d" || normalized === "diseno 3d") {
return "design-3d";
}
if (normalized === "repuestos") {
return "repuestos";
}
if (normalized === "ingenieria") {
return "ingenieria";
}
return "all";
}
export async function DiscoverResultsScreen({
searchParams
}: {
searchParams?: Record<string, string | string[] | undefined>;
}) {
const query = readParam(searchParams?.q);
const initialCategory = normalizeCategory(
readParam(searchParams?.category) || readParam(searchParams?.serviceCategory)
);
const requestQuery = new URLSearchParams();
if (query) {
requestQuery.set("q", query);
}
const serialized = requestQuery.toString();
const [makersData, worksData] = await Promise.all([
serverFetch<{ makers: PublicMaker[] }>(`/makers${serialized ? `?${serialized}` : ""}`),
serverFetch<{ works: PublicWork[] }>(`/works${serialized ? `?${serialized}` : ""}`)
]);
return (
<DiscoverExperienceClient
initialMakers={makersData.makers}
initialWorks={worksData.works}
initialQuery={query}
initialCategory={initialCategory}
/>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useState } from "react";
import { browserFetch } from "../lib/api";
type FavoriteToggleProps = {
targetId: string;
targetType: "maker" | "work";
targetSlug?: string;
compact?: boolean;
icon?: "heart" | "bookmark";
};
const favoriteSyncEvent = "makers3d:favorite-updated";
function loginForCurrentPage() {
const currentPath = `${window.location.pathname}${window.location.search}`;
window.location.assign(`/login?returnTo=${encodeURIComponent(currentPath)}`);
}
function BookmarkIcon({ active }: { active: boolean }) {
return (
<svg viewBox="0 0 24 24" fill={active ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M7 4.8A1.8 1.8 0 0 1 8.8 3h6.4A1.8 1.8 0 0 1 17 4.8V21l-5-3-5 3V4.8Z" />
</svg>
);
}
export default function FavoriteToggle({ targetId, targetType, targetSlug, compact = false, icon = "heart" }: FavoriteToggleProps) {
const [favorited, setFavorited] = useState(false);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState("");
const syncKey = `${targetType}:${targetSlug || targetId}`;
useEffect(() => {
let active = true;
async function loadFavorite() {
try {
const statusQuery = targetType === "maker" && targetSlug
? `/favorites/status?targetType=maker&targetSlug=${encodeURIComponent(targetSlug)}`
: `/favorites/status?targetType=${targetType}&targetId=${targetId}`;
const response = await browserFetch<{ favorited: boolean }>(
statusQuery
);
if (active) {
setFavorited(response.favorited);
}
} catch {
if (active) {
setFavorited(false);
}
} finally {
if (active) {
setLoading(false);
}
}
}
void loadFavorite();
return () => {
active = false;
};
}, [targetId, targetSlug, targetType]);
useEffect(() => {
function syncFavorite(event: Event) {
const detail = (event as CustomEvent<{ key: string; favorited: boolean }>).detail;
if (detail?.key === syncKey) {
setFavorited(detail.favorited);
}
}
window.addEventListener(favoriteSyncEvent, syncFavorite);
return () => window.removeEventListener(favoriteSyncEvent, syncFavorite);
}, [syncKey]);
async function toggleFavorite() {
setStatus("");
try {
const identifier = targetSlug || targetId || "current";
const endpoint = targetType === "maker"
? `/favorites/makers/${encodeURIComponent(identifier)}`
: `/favorites/works/${encodeURIComponent(identifier)}`;
const response = await browserFetch<{ favorited: boolean }>(endpoint, {
method: favorited ? "DELETE" : "POST"
});
setFavorited(response.favorited);
window.dispatchEvent(new CustomEvent(favoriteSyncEvent, {
detail: {
key: syncKey,
favorited: response.favorited
}
}));
} catch (error) {
const apiError = error as Error & { status?: number };
const message = apiError.message || "";
if (apiError.status === 401 || apiError.status === 403 || message.includes("Authentication")) {
loginForCurrentPage();
return;
}
setStatus(apiError.status ? `No se pudo guardar (${apiError.status}).` : "No se pudo actualizar favoritos.");
}
}
return (
<div className={`favorite-toggle-wrap ${compact ? "is-compact" : ""} favorite-icon-${icon}`}>
<button
className={`favorite-toggle ${favorited ? "is-active" : ""}`}
type="button"
onClick={toggleFavorite}
disabled={loading}
aria-pressed={favorited}
>
<span className="favorite-heart" aria-hidden="true">
{icon === "bookmark" ? <BookmarkIcon active={favorited} /> : (favorited ? "\u2764\uFE0F" : "\u2661")}
</span>
<span>{favorited ? "Guardado" : "Guardar"}</span>
</button>
{status ? <span className="mini-note">{status}</span> : null}
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { browserFetch } from "../lib/api";
type FavoriteMaker = {
id: string;
slug: string;
business_name: string;
city: string | null;
province: string | null;
main_image_url: string | null;
availability: string | null;
avg_rating: number | string;
review_count: number;
};
type FavoriteWork = {
id: string;
slug: string;
title: string;
summary: string;
technology: string;
material: string;
image_url: string;
maker_slug: string;
maker_name: string;
};
type FavoritesResponse = {
makers: FavoriteMaker[];
works: FavoriteWork[];
};
function rating(value: number | string) {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed.toFixed(1) : "0.0";
}
export default function FavoritesClient() {
const [activeTab, setActiveTab] = useState<"makers" | "works">("makers");
const [favorites, setFavorites] = useState<FavoritesResponse>({ makers: [], works: [] });
const [loading, setLoading] = useState(true);
const [needsLogin, setNeedsLogin] = useState(false);
const [status, setStatus] = useState("");
async function loadFavorites() {
setStatus("");
try {
const response = await browserFetch<FavoritesResponse>("/favorites");
setFavorites(response);
setNeedsLogin(false);
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (message.includes("Authentication")) {
setNeedsLogin(true);
return;
}
setStatus("No se pudieron cargar tus favoritos.");
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadFavorites();
}, []);
async function removeFavorite(type: "maker" | "work", id: string) {
const endpoint = type === "maker" ? `/favorites/makers/${id}` : `/favorites/works/${id}`;
await browserFetch(endpoint, { method: "DELETE" });
await loadFavorites();
}
if (loading) {
return (
<section className="favorites-shell">
<div className="profile-account-card stack">
<span className="eyebrow">Favoritos</span>
<h1 className="section-title">Cargando guardados</h1>
<p className="muted">Estamos preparando tus makers y trabajos favoritos.</p>
</div>
</section>
);
}
if (needsLogin) {
return (
<section className="favorites-shell">
<div className="profile-account-card stack">
<span className="eyebrow">Favoritos</span>
<h1 className="section-title">Inicia sesion para guardar favoritos</h1>
<p className="muted">Tus makers y trabajos guardados quedan asociados a tu cuenta para recuperarlos desde cualquier pestana.</p>
<Link href="/login?returnTo=%2Ffavorites" className="button button-primary">Entrar</Link>
</div>
</section>
);
}
const visibleMakers = activeTab === "makers";
const empty = visibleMakers ? favorites.makers.length === 0 : favorites.works.length === 0;
return (
<section className="favorites-shell">
<div className="favorites-header">
<div>
<span className="eyebrow">Favoritos</span>
<h1 className="section-title">Guardados para volver rapido</h1>
<p className="muted">Separados por makers y trabajos para que puedas comparar proveedores o ejemplos concretos.</p>
</div>
<div className="favorites-counts">
<span>{favorites.makers.length} makers</span>
<span>{favorites.works.length} trabajos</span>
</div>
</div>
<div className="favorites-tabs" role="tablist" aria-label="Secciones de favoritos">
<button className={activeTab === "makers" ? "is-active" : ""} type="button" onClick={() => setActiveTab("makers")}>
Makers
</button>
<button className={activeTab === "works" ? "is-active" : ""} type="button" onClick={() => setActiveTab("works")}>
Trabajos
</button>
</div>
{status ? <p className="messages-status">{status}</p> : null}
{empty ? (
<div className="profile-account-card stack">
<h2 className="section-title">{visibleMakers ? "Todavia no guardaste makers" : "Todavia no guardaste trabajos"}</h2>
<p className="muted">Usa el boton Guardar desde un perfil de maker o desde el detalle de un trabajo.</p>
<Link href="/discover" className="button button-primary">Descubrir makers</Link>
</div>
) : null}
{visibleMakers ? (
<div className="favorites-grid">
{favorites.makers.map((maker) => (
<article key={maker.id} className="favorite-card">
<img className="thumb" src={maker.main_image_url || "/demo/maker-hero-1.svg"} alt={maker.business_name} />
<div className="favorite-card-body">
<div>
<strong>{maker.business_name}</strong>
<span className="mini-note">{maker.city || "Cordoba"}, {maker.province || "Argentina"}</span>
</div>
<div className="badges">
<span className="badge">Rating {rating(maker.avg_rating)}</span>
<span className="badge">{maker.review_count} resenas</span>
<span className="badge">{maker.availability || "available"}</span>
</div>
<div className="favorite-card-actions">
<Link href={`/makers/${maker.slug}`} className="button button-primary">Ver perfil</Link>
<button className="button button-secondary" type="button" onClick={() => removeFavorite("maker", maker.id)}>Quitar</button>
</div>
</div>
</article>
))}
</div>
) : (
<div className="favorites-grid">
{favorites.works.map((work) => (
<article key={work.id} className="favorite-card">
<img className="thumb" src={work.image_url || "/demo/work-custom.svg"} alt={work.title} />
<div className="favorite-card-body">
<div>
<strong>{work.title}</strong>
<span className="mini-note">{work.maker_name}</span>
</div>
<p className="muted">{work.summary}</p>
<div className="badges">
<span className="badge">{work.technology}</span>
<span className="badge">{work.material}</span>
</div>
<div className="favorite-card-actions">
<Link href={`/works/${work.slug}`} className="button button-primary">Ver trabajo</Link>
<button className="button button-secondary" type="button" onClick={() => removeFavorite("work", work.id)}>Quitar</button>
</div>
</div>
</article>
))}
</div>
)}
</section>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import { browserFetch } from "../lib/api";
type Props = {
makerId: string;
sourceType: "profile" | "service" | "work";
sourceId?: string | null;
};
export function InquiryForm({ makerId, sourceType, sourceId }: Props) {
const [status, setStatus] = useState<string>("");
const [form, setForm] = useState({
needText: "",
sizeText: "",
hasModel: false,
materialPreference: "",
quantity: 1,
urgency: "medium",
deliveryType: "shipping"
});
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus("Enviando consulta...");
try {
await browserFetch("/inquiries", {
method: "POST",
body: JSON.stringify({
makerId,
sourceType,
sourceId,
...form
})
});
setStatus("Consulta enviada. Ya puedes seguirla desde tu cuenta.");
setForm({
needText: "",
sizeText: "",
hasModel: false,
materialPreference: "",
quantity: 1,
urgency: "medium",
deliveryType: "shipping"
});
} catch (error) {
setStatus(error instanceof Error ? error.message : "No se pudo enviar la consulta");
}
}
return (
<form className="inquiry-card" onSubmit={onSubmit}>
<div className="guide-steps">
<div className="guide-step">
<div className="step-index">1</div>
<div>
<strong>Que quieres hacer?</strong>
<div className="mini-note">Cuanto mejor expliques el uso, mejor se ajusta la respuesta del maker.</div>
</div>
</div>
</div>
<div className="inquiry-section">
<textarea
className="textarea"
placeholder="Quiero algo muy parecido, necesito modificar una pieza o tengo una idea similar..."
value={form.needText}
onChange={(event) => setForm((current) => ({ ...current, needText: event.target.value }))}
required
/>
</div>
<div className="inquiry-section">
<strong>Detalles que ayudan al maker</strong>
<div className="input-grid-compact">
<input
className="field"
placeholder="Tamano o medidas aproximadas"
value={form.sizeText}
onChange={(event) => setForm((current) => ({ ...current, sizeText: event.target.value }))}
/>
<input
className="field"
placeholder="Material preferido"
value={form.materialPreference}
onChange={(event) => setForm((current) => ({ ...current, materialPreference: event.target.value }))}
/>
<input
className="field"
type="number"
min={1}
value={form.quantity}
onChange={(event) => setForm((current) => ({ ...current, quantity: Number(event.target.value) }))}
/>
<select
className="select"
value={form.urgency}
onChange={(event) => setForm((current) => ({ ...current, urgency: event.target.value }))}
>
<option value="low">Sin urgencia</option>
<option value="medium">Esta semana</option>
<option value="high">Urgente</option>
</select>
</div>
</div>
<div className="input-grid-compact">
<label className="choice-chip">
<input
type="checkbox"
checked={form.hasModel}
onChange={(event) => setForm((current) => ({ ...current, hasModel: event.target.checked }))}
/>
<span>Ya tengo archivo o pieza de referencia</span>
</label>
<select
className="select"
value={form.deliveryType}
onChange={(event) => setForm((current) => ({ ...current, deliveryType: event.target.value }))}
>
<option value="shipping">Necesito envio</option>
<option value="local">Puedo retirar personalmente</option>
</select>
</div>
<button className="button button-primary" type="submit">Enviar consulta</button>
{status ? <p className="muted">{status}</p> : null}
</form>
);
}
@@ -0,0 +1,255 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { browserFetch } from "../lib/api";
type Props = {
makerId: string;
sourceType: "profile" | "service" | "work";
sourceId?: string;
makerName: string;
contextTitle: string;
returnTo: string;
};
type Draft = {
needText: string;
sizeText: string;
usageText: string;
materialPreference: string;
quantity: number;
urgency: string;
deliveryType: string;
hasModel: boolean;
referenceNotes: string;
};
const initialDraft: Draft = {
needText: "",
sizeText: "",
usageText: "",
materialPreference: "",
quantity: 1,
urgency: "medium",
deliveryType: "shipping",
hasModel: false,
referenceNotes: ""
};
export default function InquiryWizardClient({ makerId, sourceType, sourceId, makerName, contextTitle, returnTo }: Props) {
const router = useRouter();
const [step, setStep] = useState(1);
const [status, setStatus] = useState("");
const [me, setMe] = useState<{ id: string } | null>(null);
const [draft, setDraft] = useState<Draft>(initialDraft);
const storageKey = useMemo(
() => `makers3d_inquiry_draft:${makerId}:${sourceType}:${sourceId || "root"}`,
[makerId, sourceId, sourceType]
);
useEffect(() => {
const saved = window.sessionStorage.getItem(storageKey);
if (saved) {
try {
setDraft({ ...initialDraft, ...JSON.parse(saved) as Draft });
} catch {
window.sessionStorage.removeItem(storageKey);
}
}
void browserFetch<{ user: { id: string } | null }>("/auth/me")
.then((result) => setMe(result.user))
.catch(() => setMe(null));
}, [storageKey]);
useEffect(() => {
window.sessionStorage.setItem(storageKey, JSON.stringify(draft));
}, [draft, storageKey]);
function nextStep() {
setStep((current) => Math.min(5, current + 1));
}
function previousStep() {
setStep((current) => Math.max(1, current - 1));
}
async function submitInquiry() {
if (!me) {
const nextUrl = `/consultas/nueva?makerId=${encodeURIComponent(makerId)}&sourceType=${encodeURIComponent(sourceType)}&sourceId=${encodeURIComponent(sourceId || "")}&makerName=${encodeURIComponent(makerName)}&contextTitle=${encodeURIComponent(contextTitle)}`;
window.location.href = `/register?returnTo=${encodeURIComponent(nextUrl)}`;
return;
}
setStatus("Enviando consulta...");
try {
await browserFetch("/inquiries", {
method: "POST",
body: JSON.stringify({
makerId,
sourceType,
sourceId,
needText: `${draft.needText}\n\nUso final: ${draft.usageText}\nReferencia: ${draft.referenceNotes}`.trim(),
sizeText: draft.sizeText,
hasModel: draft.hasModel,
materialPreference: draft.materialPreference,
quantity: draft.quantity,
urgency: draft.urgency,
deliveryType: draft.deliveryType
})
});
window.sessionStorage.removeItem(storageKey);
setStatus("Consulta enviada.");
router.push("/account/inbox");
router.refresh();
} catch (error) {
setStatus(error instanceof Error ? error.message : "No se pudo enviar la consulta");
}
}
return (
<div className="wizard-shell">
<div className="panel wizard-context stack">
<span className="eyebrow">Consulta guiada</span>
<h1 className="section-title">Paso {step} de 5</h1>
<div className="list-item stack">
<strong>{contextTitle}</strong>
<span className="mini-note">Maker: {makerName}</span>
<span className="mini-note">Si vuelves tras registrarte, el borrador se conserva.</span>
</div>
<div className="wizard-progress">
{[1, 2, 3, 4, 5].map((value) => (
<span key={value} className={`wizard-dot ${value <= step ? "active" : ""}`} />
))}
</div>
</div>
<div className="panel stack">
{step === 1 && (
<>
<span className="eyebrow">Necesidad</span>
<h2 className="section-title">Que necesitas resolver?</h2>
<textarea
className="textarea"
placeholder="Quiero algo igual, modificar una pieza o fabricar algo parecido..."
value={draft.needText}
onChange={(event) => setDraft((current) => ({ ...current, needText: event.target.value }))}
/>
</>
)}
{step === 2 && (
<>
<span className="eyebrow">Detalles del trabajo</span>
<h2 className="section-title">Dimension, material y referencia</h2>
<div className="input-grid-compact">
<input
className="field"
placeholder="Tamano o medidas"
value={draft.sizeText}
onChange={(event) => setDraft((current) => ({ ...current, sizeText: event.target.value }))}
/>
<input
className="field"
placeholder="Material preferido"
value={draft.materialPreference}
onChange={(event) => setDraft((current) => ({ ...current, materialPreference: event.target.value }))}
/>
</div>
<label className="choice-chip">
<input
type="checkbox"
checked={draft.hasModel}
onChange={(event) => setDraft((current) => ({ ...current, hasModel: event.target.checked }))}
/>
<span>Ya tengo archivo o una pieza de referencia</span>
</label>
<textarea
className="textarea"
placeholder="Describe el archivo, la pieza o lo que puedes adjuntar despues en la conversacion."
value={draft.referenceNotes}
onChange={(event) => setDraft((current) => ({ ...current, referenceNotes: event.target.value }))}
/>
</>
)}
{step === 3 && (
<>
<span className="eyebrow">Uso final</span>
<h2 className="section-title">Para que se utilizara?</h2>
<textarea
className="textarea"
placeholder="Pieza funcional, exterior, calor, agua, decoracion, prototipo..."
value={draft.usageText}
onChange={(event) => setDraft((current) => ({ ...current, usageText: event.target.value }))}
/>
</>
)}
{step === 4 && (
<>
<span className="eyebrow">Urgencia y entrega</span>
<h2 className="section-title">Cantidad, plazo y modalidad</h2>
<div className="input-grid-compact">
<input
className="field"
type="number"
min={1}
value={draft.quantity}
onChange={(event) => setDraft((current) => ({ ...current, quantity: Number(event.target.value) }))}
/>
<select
className="select"
value={draft.urgency}
onChange={(event) => setDraft((current) => ({ ...current, urgency: event.target.value }))}
>
<option value="low">Sin urgencia</option>
<option value="medium">Esta semana</option>
<option value="high">Urgente</option>
</select>
<select
className="select"
value={draft.deliveryType}
onChange={(event) => setDraft((current) => ({ ...current, deliveryType: event.target.value }))}
>
<option value="shipping">Necesito envio</option>
<option value="local">Puedo retirar</option>
</select>
</div>
</>
)}
{step === 5 && (
<>
<span className="eyebrow">Resumen</span>
<h2 className="section-title">Revisa antes de enviar</h2>
<div className="checklist-list">
<div className="checklist-item"><strong>Necesidad</strong><span>{draft.needText || "Pendiente"}</span></div>
<div className="checklist-item"><strong>Detalles</strong><span>{draft.sizeText || "Sin medidas"} / {draft.materialPreference || "Sin material"}</span></div>
<div className="checklist-item"><strong>Uso final</strong><span>{draft.usageText || "No indicado"}</span></div>
<div className="checklist-item"><strong>Entrega</strong><span>{draft.quantity} unidad / {draft.deliveryType}</span></div>
</div>
{!me ? (
<p className="muted">Para enviar la consulta necesitas crear cuenta o entrar. El borrador no se pierde.</p>
) : null}
</>
)}
<div className="wizard-actions">
<a href={returnTo} className="button button-ghost">Volver</a>
<div className="wizard-actions-right">
{step > 1 ? <button className="button button-secondary" type="button" onClick={previousStep}>Anterior</button> : null}
{step < 5 ? (
<button className="button button-primary" type="button" onClick={nextStep}>Continuar</button>
) : (
<button className="button button-primary" type="button" onClick={submitInquiry}>Enviar consulta</button>
)}
</div>
</div>
{status ? <p className="muted">{status}</p> : null}
</div>
</div>
);
}
@@ -0,0 +1,437 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { divIcon, type DivIcon, type Map as LeafletMap } from "leaflet";
import { Circle, CircleMarker, MapContainer, Marker, Popup, TileLayer, useMap, useMapEvents } from "react-leaflet";
type DiscoverMapMarker = {
id: string;
slug: string;
businessName: string;
city: string;
province: string;
latitude: number;
longitude: number;
};
type DisplayMarker = DiscoverMapMarker & {
displayLatitude: number;
displayLongitude: number;
radius: number;
};
type ClusterGroup = {
key: string;
count: number;
latitude: number;
longitude: number;
markers: DisplayMarker[];
};
const cordobaCenter: [number, number] = [-31.4201, -64.1888];
const userApproximationRadius = 900;
function hashValue(input: string, seed: number) {
let value = seed;
for (let index = 0; index < input.length; index += 1) {
value = (value * 33 + input.charCodeAt(index) + index) % 1000003;
}
return value;
}
function toOffset(value: number, spread: number) {
return ((value % 1000) / 999 - 0.5) * spread;
}
function toDisplayMarkers(markers: DiscoverMapMarker[]): DisplayMarker[] {
return markers.map((marker) => {
const latSeed = hashValue(marker.id, 17);
const lngSeed = hashValue(marker.slug, 29);
const radiusSeed = hashValue(marker.businessName, 43);
return {
...marker,
displayLatitude: marker.latitude + toOffset(latSeed, 0.0072),
displayLongitude: marker.longitude + toOffset(lngSeed, 0.0094),
radius: 8 + (radiusSeed % 4)
};
});
}
function getClusterRadius(zoom: number) {
if (zoom >= 15) {
return 0;
}
if (zoom >= 14) {
return 34;
}
if (zoom >= 13) {
return 42;
}
if (zoom >= 12) {
return 50;
}
if (zoom >= 11) {
return 58;
}
return 66;
}
function buildClusterGroups(markers: DisplayMarker[], map: LeafletMap, zoom: number) {
const clusterRadius = getClusterRadius(zoom);
if (clusterRadius === 0) {
return markers.map((marker) => ({
key: marker.id,
count: 1,
latitude: marker.displayLatitude,
longitude: marker.displayLongitude,
markers: [marker]
}));
}
const projectedMarkers = markers.map((marker) => ({
marker,
point: map.project([marker.displayLatitude, marker.displayLongitude], zoom)
}));
const visited = new Set<number>();
const groups: ClusterGroup[] = [];
for (let index = 0; index < projectedMarkers.length; index += 1) {
if (visited.has(index)) {
continue;
}
const queue = [index];
const memberIndexes: number[] = [];
visited.add(index);
while (queue.length > 0) {
const currentIndex = queue.shift() as number;
const currentMarker = projectedMarkers[currentIndex];
memberIndexes.push(currentIndex);
for (let candidateIndex = 0; candidateIndex < projectedMarkers.length; candidateIndex += 1) {
if (visited.has(candidateIndex)) {
continue;
}
const candidateMarker = projectedMarkers[candidateIndex];
const deltaX = currentMarker.point.x - candidateMarker.point.x;
const deltaY = currentMarker.point.y - candidateMarker.point.y;
if (Math.hypot(deltaX, deltaY) <= clusterRadius) {
visited.add(candidateIndex);
queue.push(candidateIndex);
}
}
}
const bucket = memberIndexes.map((memberIndex) => projectedMarkers[memberIndex].marker);
const latitude = bucket.reduce((sum, marker) => sum + marker.displayLatitude, 0) / bucket.length;
const longitude = bucket.reduce((sum, marker) => sum + marker.displayLongitude, 0) / bucket.length;
groups.push({
key: bucket.map((marker) => marker.id).sort().join(":"),
count: bucket.length,
latitude,
longitude,
markers: bucket
});
}
return groups;
}
function buildClusterIcon(count: number): DivIcon {
const size = count >= 10 ? 60 : count >= 5 ? 54 : 48;
const toneClass = count >= 10 ? "is-large" : count >= 5 ? "is-medium" : "is-small";
return divIcon({
className: "map-home-cluster-icon-shell",
html: `<span class="map-home-cluster-badge ${toneClass}">${count}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2]
});
}
function FitMapToMarkers({ markers }: { markers: DisplayMarker[] }) {
const map = useMap();
useEffect(() => {
if (markers.length === 0) {
map.setView(cordobaCenter, 12);
return;
}
map.fitBounds(
markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
{
padding: [40, 40],
maxZoom: 14
}
);
}, [map, markers]);
return null;
}
function RegisterMapInstance({ mapRef }: { mapRef: MutableRefObject<LeafletMap | null> }) {
const map = useMap();
useEffect(() => {
mapRef.current = map;
return () => {
if (mapRef.current === map) {
mapRef.current = null;
}
};
}, [map, mapRef]);
return null;
}
function ClusteredMarkerLayer({
markers,
selectedMarkerId,
onSelectMarker
}: {
markers: DisplayMarker[];
selectedMarkerId?: string;
onSelectMarker: (makerId: string) => void;
}) {
const map = useMap();
const [zoom, setZoom] = useState(() => map.getZoom());
useMapEvents({
zoomend() {
setZoom(map.getZoom());
}
});
const groups = buildClusterGroups(markers, map, zoom);
return (
<>
{groups.map((group) => {
if (group.count === 1) {
const marker = group.markers[0];
const isSelected = marker.id === selectedMarkerId;
return (
<CircleMarker
key={marker.id}
center={[marker.displayLatitude, marker.displayLongitude]}
radius={isSelected ? marker.radius + 4 : marker.radius}
pathOptions={{
color: "#ffffff",
weight: isSelected ? 3 : 2,
fillColor: isSelected ? "#4f89ff" : "#10358d",
fillOpacity: 0.98
}}
eventHandlers={{
click: () => onSelectMarker(marker.id)
}}
>
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
<div className="map-home-popup-card">
<strong>{marker.businessName}</strong>
<span>{marker.city}, {marker.province}</span>
<a href={`/makers/${marker.slug}`}>Ver maker</a>
</div>
</Popup>
</CircleMarker>
);
}
return (
<Marker
key={group.key}
position={[group.latitude, group.longitude]}
icon={buildClusterIcon(group.count)}
eventHandlers={{
click: () => {
map.fitBounds(
group.markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
{
padding: [48, 48],
maxZoom: 15,
animate: true
}
);
}
}}
>
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
<div className="map-home-popup-card">
<strong>{group.count} makers en esta zona</strong>
<span>Toca el grupo para acercar y separarlos.</span>
</div>
</Popup>
</Marker>
);
})}
</>
);
}
export default function InteractiveDiscoverMap({
markers,
selectedMarkerId,
onSelectMarker,
onApplyVisibleArea,
focusRequest
}: {
markers: DiscoverMapMarker[];
selectedMarkerId?: string;
onSelectMarker: (makerId: string) => void;
onApplyVisibleArea: (makerIds: string[]) => void;
focusRequest: number;
}) {
const mapRef = useRef<LeafletMap | null>(null);
const displayMarkers = toDisplayMarkers(markers);
const focusMarkers = () => {
const map = mapRef.current;
if (!map) {
return;
}
if (markers.length === 0) {
map.setView(cordobaCenter, 12, { animate: true });
return;
}
map.fitBounds(
displayMarkers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
{
padding: [40, 40],
maxZoom: 14,
animate: true
}
);
};
const applyVisibleArea = () => {
const map = mapRef.current;
if (!map) {
onApplyVisibleArea(markers.map((marker) => marker.id));
return;
}
const bounds = map.getBounds();
const visibleIds = displayMarkers
.filter((marker) => bounds.contains([marker.displayLatitude, marker.displayLongitude]))
.map((marker) => marker.id);
onApplyVisibleArea(visibleIds);
};
useEffect(() => {
if (focusRequest > 0) {
focusMarkers();
}
}, [focusRequest]);
useEffect(() => {
const map = mapRef.current;
if (!map || !selectedMarkerId) {
return;
}
const selectedMarker = displayMarkers.find((marker) => marker.id === selectedMarkerId);
if (!selectedMarker) {
return;
}
map.flyTo([selectedMarker.displayLatitude, selectedMarker.displayLongitude], Math.max(map.getZoom(), 13), {
animate: true,
duration: 0.6
});
}, [selectedMarkerId, displayMarkers]);
return (
<section className="discover-map-stage">
<div className="discover-map-surface">
<MapContainer center={cordobaCenter} zoom={13} scrollWheelZoom className="discover-leaflet">
<RegisterMapInstance mapRef={mapRef} />
<TileLayer
attribution="&copy; OpenStreetMap contributors &copy; CARTO"
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
/>
<FitMapToMarkers markers={displayMarkers} />
<Circle
center={cordobaCenter}
radius={userApproximationRadius}
pathOptions={{
color: "#2d7cff",
weight: 2,
fillColor: "#2d7cff",
fillOpacity: 0.14
}}
/>
<CircleMarker
center={cordobaCenter}
radius={8}
pathOptions={{
color: "#ffffff",
weight: 2,
fillColor: "#2e7cff",
fillOpacity: 1
}}
/>
<ClusteredMarkerLayer
markers={displayMarkers}
selectedMarkerId={selectedMarkerId}
onSelectMarker={onSelectMarker}
/>
</MapContainer>
</div>
<div className="discover-map-overlay" aria-hidden="false">
<div className="discover-map-side-tools">
<button className="discover-map-control" type="button" aria-label="Recentrar mapa" onClick={focusMarkers}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5.4" />
<path d="M12 2v3" />
<path d="M12 19v3" />
<path d="M2 12h3" />
<path d="M19 12h3" />
</svg>
</button>
<button className="discover-map-control" type="button" aria-label="Capas del mapa">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
<path d="m12 4 8 4-8 4-8-4 8-4Z" />
<path d="m4 12 8 4 8-4" />
<path d="m4 16 8 4 8-4" />
</svg>
</button>
<button className="discover-map-radar" type="button" aria-label="Radar maker">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="2.2" />
<path d="M12 5a7 7 0 0 1 7 7" />
<path d="M12 2a10 10 0 0 1 10 10" />
<path d="M5 12a7 7 0 0 1 7-7" />
</svg>
<span>Radar Maker</span>
</button>
</div>
<button className="discover-map-cta" type="button" onClick={applyVisibleArea}>
Buscar en esta zona
</button>
</div>
</section>
);
}
@@ -0,0 +1,49 @@
"use client";
import dynamic from "next/dynamic";
export type DiscoverMapMarker = {
id: string;
slug: string;
businessName: string;
city: string;
province: string;
latitude: number;
longitude: number;
};
const InteractiveDiscoverMap = dynamic(
() => import("./InteractiveDiscoverMap"),
{
ssr: false,
loading: () => (
<section className="discover-map-stage">
<div className="discover-map-loading">Cargando mapa real...</div>
</section>
)
}
);
export default function InteractiveDiscoverMapClient({
markers,
selectedMarkerId,
onSelectMarker,
onApplyVisibleArea,
focusRequest
}: {
markers: DiscoverMapMarker[];
selectedMarkerId?: string;
onSelectMarker: (makerId: string) => void;
onApplyVisibleArea: (makerIds: string[]) => void;
focusRequest: number;
}) {
return (
<InteractiveDiscoverMap
markers={markers}
selectedMarkerId={selectedMarkerId}
onSelectMarker={onSelectMarker}
onApplyVisibleArea={onApplyVisibleArea}
focusRequest={focusRequest}
/>
);
}
@@ -0,0 +1,354 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { divIcon, type DivIcon, type Map as LeafletMap } from "leaflet";
import { Circle, CircleMarker, MapContainer, Marker, Popup, TileLayer, useMap, useMapEvents } from "react-leaflet";
type MakerMarker = {
id: string;
slug: string;
businessName: string;
city: string;
province: string;
latitude: number;
longitude: number;
};
type DisplayMarker = MakerMarker & {
displayLatitude: number;
displayLongitude: number;
radius: number;
};
type ClusterGroup = {
key: string;
count: number;
latitude: number;
longitude: number;
markers: DisplayMarker[];
};
const cordobaCenter: [number, number] = [-31.4201, -64.1888];
const userApproximationRadius = 900;
function hashValue(input: string, seed: number) {
let value = seed;
for (let index = 0; index < input.length; index += 1) {
value = (value * 33 + input.charCodeAt(index) + index) % 1000003;
}
return value;
}
function toOffset(value: number, spread: number) {
return ((value % 1000) / 999 - 0.5) * spread;
}
function toDisplayMarkers(markers: MakerMarker[]): DisplayMarker[] {
return markers.map((marker) => {
const latSeed = hashValue(marker.id, 17);
const lngSeed = hashValue(marker.slug, 29);
const radiusSeed = hashValue(marker.businessName, 43);
return {
...marker,
displayLatitude: marker.latitude + toOffset(latSeed, 0.0072),
displayLongitude: marker.longitude + toOffset(lngSeed, 0.0094),
radius: 8 + (radiusSeed % 4)
};
});
}
function getClusterRadius(zoom: number) {
if (zoom >= 15) {
return 0;
}
if (zoom >= 14) {
return 34;
}
if (zoom >= 13) {
return 42;
}
if (zoom >= 12) {
return 50;
}
if (zoom >= 11) {
return 58;
}
return 66;
}
function buildClusterGroups(markers: DisplayMarker[], map: LeafletMap, zoom: number) {
const clusterRadius = getClusterRadius(zoom);
if (clusterRadius === 0) {
return markers.map((marker) => ({
key: marker.id,
count: 1,
latitude: marker.displayLatitude,
longitude: marker.displayLongitude,
markers: [marker]
}));
}
const projectedMarkers = markers.map((marker) => ({
marker,
point: map.project([marker.displayLatitude, marker.displayLongitude], zoom)
}));
const visited = new Set<number>();
const groups: ClusterGroup[] = [];
for (let index = 0; index < projectedMarkers.length; index += 1) {
if (visited.has(index)) {
continue;
}
const queue = [index];
const memberIndexes: number[] = [];
visited.add(index);
while (queue.length > 0) {
const currentIndex = queue.shift() as number;
const currentMarker = projectedMarkers[currentIndex];
memberIndexes.push(currentIndex);
for (let candidateIndex = 0; candidateIndex < projectedMarkers.length; candidateIndex += 1) {
if (visited.has(candidateIndex)) {
continue;
}
const candidateMarker = projectedMarkers[candidateIndex];
const deltaX = currentMarker.point.x - candidateMarker.point.x;
const deltaY = currentMarker.point.y - candidateMarker.point.y;
if (Math.hypot(deltaX, deltaY) <= clusterRadius) {
visited.add(candidateIndex);
queue.push(candidateIndex);
}
}
}
const bucket = memberIndexes.map((memberIndex) => projectedMarkers[memberIndex].marker);
const latitude = bucket.reduce((sum, marker) => sum + marker.displayLatitude, 0) / bucket.length;
const longitude = bucket.reduce((sum, marker) => sum + marker.displayLongitude, 0) / bucket.length;
groups.push({
key: bucket.map((marker) => marker.id).sort().join(":"),
count: bucket.length,
latitude,
longitude,
markers: bucket
});
}
return groups;
}
function buildClusterIcon(count: number): DivIcon {
const size = count >= 10 ? 60 : count >= 5 ? 54 : 48;
const toneClass = count >= 10 ? "is-large" : count >= 5 ? "is-medium" : "is-small";
return divIcon({
className: "map-home-cluster-icon-shell",
html: `<span class="map-home-cluster-badge ${toneClass}">${count}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2]
});
}
function FitMapToMarkers({ markers }: { markers: DisplayMarker[] }) {
const map = useMap();
useEffect(() => {
if (markers.length === 0) {
map.setView(cordobaCenter, 12);
return;
}
const bounds = markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]);
map.fitBounds(bounds, {
padding: [40, 40],
maxZoom: 14
});
}, [map, markers]);
return null;
}
function ClusteredMarkerLayer({ markers }: { markers: DisplayMarker[] }) {
const map = useMap();
const [zoom, setZoom] = useState(() => map.getZoom());
useMapEvents({
zoomend() {
setZoom(map.getZoom());
}
});
const groups = buildClusterGroups(markers, map, zoom);
return (
<>
{groups.map((group) => {
if (group.count === 1) {
const marker = group.markers[0];
return (
<CircleMarker
key={marker.id}
center={[marker.displayLatitude, marker.displayLongitude]}
radius={marker.radius}
pathOptions={{
color: "#ffffff",
weight: 2,
fillColor: "#10358d",
fillOpacity: 0.95
}}
>
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
<div className="map-home-popup-card">
<strong>{marker.businessName}</strong>
<span>{marker.city}, {marker.province}</span>
<a href={`/makers/${marker.slug}`}>Ver maker</a>
</div>
</Popup>
</CircleMarker>
);
}
return (
<Marker
key={group.key}
position={[group.latitude, group.longitude]}
icon={buildClusterIcon(group.count)}
eventHandlers={{
click: () => {
map.fitBounds(
group.markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
{
padding: [48, 48],
maxZoom: 15,
animate: true
}
);
}
}}
>
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
<div className="map-home-popup-card">
<strong>{group.count} makers en esta zona</strong>
<span>Toca el grupo para acercar y separarlos.</span>
</div>
</Popup>
</Marker>
);
})}
</>
);
}
function RegisterMapInstance({ mapRef }: { mapRef: MutableRefObject<LeafletMap | null> }) {
const map = useMap();
useEffect(() => {
mapRef.current = map;
return () => {
if (mapRef.current === map) {
mapRef.current = null;
}
};
}, [map, mapRef]);
return null;
}
export default function InteractiveMakerMap({ markers }: { markers: MakerMarker[] }) {
const mapRef = useRef<LeafletMap | null>(null);
const displayMarkers = toDisplayMarkers(markers);
const focusMarkers = () => {
const map = mapRef.current;
if (!map) {
return;
}
if (markers.length === 0) {
map.setView(cordobaCenter, 12, { animate: true });
return;
}
map.fitBounds(
displayMarkers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
{
padding: [40, 40],
maxZoom: 14,
animate: true
}
);
};
return (
<section className="map-home-map">
<div className="map-home-realmap">
<MapContainer
center={cordobaCenter}
zoom={13}
scrollWheelZoom
className="map-home-leaflet"
>
<RegisterMapInstance mapRef={mapRef} />
<TileLayer
attribution='&copy; OpenStreetMap contributors &copy; CARTO'
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
/>
<FitMapToMarkers markers={displayMarkers} />
<Circle
center={cordobaCenter}
radius={userApproximationRadius}
pathOptions={{
color: "#1d67ff",
weight: 2,
fillColor: "#2e7cff",
fillOpacity: 0.1
}}
/>
<CircleMarker
center={cordobaCenter}
radius={8}
pathOptions={{
color: "#ffffff",
weight: 2,
fillColor: "#2e7cff",
fillOpacity: 1
}}
/>
<ClusteredMarkerLayer markers={displayMarkers} />
</MapContainer>
</div>
<div className="map-home-map-overlay" aria-hidden="false">
<button className="map-home-crosshair" type="button" aria-label="Recentrar mapa" onClick={focusMarkers}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5.4" />
<path d="M12 2v3" />
<path d="M12 19v3" />
<path d="M2 12h3" />
<path d="M19 12h3" />
</svg>
</button>
<button className="map-home-area-cta" type="button" onClick={focusMarkers}>
Buscar en esta area
</button>
</div>
</section>
);
}
@@ -0,0 +1,26 @@
"use client";
import dynamic from "next/dynamic";
type MakerMarker = {
id: string;
slug: string;
businessName: string;
city: string;
province: string;
latitude: number;
longitude: number;
};
const InteractiveMakerMap = dynamic(() => import("./InteractiveMakerMap"), {
ssr: false,
loading: () => (
<section className="map-home-map">
<div className="map-home-map-loading">Cargando mapa real...</div>
</section>
)
});
export default function InteractiveMakerMapClient({ markers }: { markers: MakerMarker[] }) {
return <InteractiveMakerMap markers={markers} />;
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
import { browserFetch } from "../lib/api";
import { notifyAuthChanged } from "../lib/authEvents";
export default function LoginForm({ redirectTo = "/account" }: { redirectTo?: string }) {
const [error, setError] = useState("");
const [form, setForm] = useState({ email: "", password: "" });
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
try {
const response = await browserFetch<{ user: { role: string } }>("/auth/login", {
method: "POST",
body: JSON.stringify(form)
});
notifyAuthChanged("login");
window.location.href = response.user.role === "admin" ? "/app/backoffice" : redirectTo;
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "No se pudo entrar");
}
}
return (
<form className="stack" onSubmit={onSubmit}>
<input className="field" type="email" placeholder="Correo" value={form.email} onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))} required />
<input className="field" type="password" placeholder="Contrasena" value={form.password} onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} required />
<button className="button button-primary" type="submit">Entrar</button>
{error ? <p className="muted">{error}</p> : null}
</form>
);
}
@@ -0,0 +1,174 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { browserFetch } from "../lib/api";
export type MapHomeMakerListItem = {
id: string;
slug: string;
name: string;
city: string;
province: string;
imageUrl: string;
distance: string;
rating: string;
reviewCount: number;
satisfaction: string;
tags: string[];
};
type FavoritesResponse = {
makers: Array<{ id: string; slug: string }>;
};
function loginForCurrentPage() {
const currentPath = `${window.location.pathname}${window.location.search}`;
window.location.assign(`/login?returnTo=${encodeURIComponent(currentPath)}`);
}
export function MapHomeMakerList({ makers }: { makers: MapHomeMakerListItem[] }) {
const [favoriteSlugs, setFavoriteSlugs] = useState<Set<string>>(new Set());
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
const [loadingSlug, setLoadingSlug] = useState("");
const [needsLogin, setNeedsLogin] = useState(false);
useEffect(() => {
let active = true;
async function loadFavorites() {
try {
const response = await browserFetch<FavoritesResponse>("/favorites");
if (!active) {
return;
}
setFavoriteSlugs(new Set(response.makers.map((maker) => maker.slug)));
setFavoriteIds(new Set(response.makers.map((maker) => maker.id)));
setNeedsLogin(false);
} catch (error) {
const apiError = error as Error & { status?: number };
if (active && (apiError.status === 401 || apiError.status === 403 || apiError.message.includes("Authentication"))) {
setNeedsLogin(true);
}
}
}
void loadFavorites();
return () => {
active = false;
};
}, []);
async function toggleFavorite(maker: MapHomeMakerListItem) {
if (needsLogin) {
loginForCurrentPage();
return;
}
const isFavorite = favoriteSlugs.has(maker.slug) || favoriteIds.has(maker.id);
setLoadingSlug(maker.slug);
try {
await browserFetch<{ favorited: boolean }>(`/favorites/makers/${encodeURIComponent(maker.slug)}`, {
method: isFavorite ? "DELETE" : "POST"
});
setFavoriteSlugs((current) => {
const next = new Set(current);
if (isFavorite) {
next.delete(maker.slug);
} else {
next.add(maker.slug);
}
return next;
});
setFavoriteIds((current) => {
const next = new Set(current);
if (isFavorite) {
next.delete(maker.id);
} else {
next.add(maker.id);
}
return next;
});
} catch (error) {
const apiError = error as Error & { status?: number };
if (apiError.status === 401 || apiError.status === 403 || apiError.message.includes("Authentication")) {
loginForCurrentPage();
}
} finally {
setLoadingSlug("");
}
}
if (makers.length === 0) {
return (
<div className="empty-state">
No encontramos makers con esa busqueda. Prueba otra categoria o abre la vista completa de descubrir.
</div>
);
}
return (
<div className="map-home-maker-list">
{makers.map((maker) => {
const isFavorite = favoriteSlugs.has(maker.slug) || favoriteIds.has(maker.id);
return (
<article key={maker.id} className="map-home-maker-card">
<Link
href={`/makers/${maker.slug}`}
className="map-home-maker-card-main map-home-maker-card-link"
aria-label={`Abrir perfil de ${maker.name}`}
>
<div className="map-home-maker-media">
<img className="thumb" src={maker.imageUrl} alt={maker.name} />
<span className="map-home-distance-pill">{maker.distance}</span>
</div>
<div className="map-home-maker-body">
<div className="map-home-maker-topline">
<div className="map-home-maker-brand">
<span className="map-home-maker-badge">{maker.name.slice(0, 2).toUpperCase()}</span>
<div className="map-home-maker-copy">
<div className="map-home-maker-heading">
<strong>{maker.name}</strong>
<span className="map-home-online-dot" />
</div>
<span className="map-home-maker-subline">{maker.city}, {maker.province}</span>
</div>
</div>
</div>
<div className="map-home-maker-score">
<span className="map-home-star">*</span>
<span>{maker.rating} ({maker.reviewCount})</span>
<span>|</span>
<span>{maker.satisfaction}</span>
</div>
<div className="map-home-tag-row">
{maker.tags.map((tag) => (
<span key={`${maker.id}-${tag}`} className="map-home-tag">{tag}</span>
))}
</div>
</div>
</Link>
<button
className={`map-home-bookmark ${isFavorite ? "is-active" : ""}`}
type="button"
aria-label={isFavorite ? `Quitar ${maker.name} de favoritos` : `Guardar ${maker.name} en favoritos`}
aria-pressed={isFavorite}
disabled={loadingSlug === maker.slug}
onClick={() => void toggleFavorite(maker)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 4.5h10a1 1 0 0 1 1 1V20l-6-3.2L6 20V5.5a1 1 0 0 1 1-1Z" />
</svg>
</button>
</article>
);
})}
</div>
);
}
+268
View File
@@ -0,0 +1,268 @@
import InteractiveMakerMapClient from "./InteractiveMakerMapClient";
import { MapHomeMakerList, type MapHomeMakerListItem } from "./MapHomeMakerList";
import { serverFetch } from "../lib/api";
type PublicMaker = {
id: string;
slug: string;
business_name: string;
description: string;
province: string;
city: string;
main_image_url: string | null;
avg_rating: number | string;
review_count: number;
service_count: number;
work_count: number;
public_latitude: number | null;
public_longitude: number | null;
public_locations?: Array<{
id: string;
label?: string | null;
city?: string | null;
province?: string | null;
latitude: number | null;
longitude: number | null;
}>;
categories?: string[];
};
type SearchParams = Record<string, string | string[] | undefined>;
type HomeCategory = "all" | "print-3d" | "design-3d" | "resin" | "fdm";
const quickCategories: Array<{ id: HomeCategory; label: string }> = [
{ id: "print-3d", label: "Impresion 3D" },
{ id: "design-3d", label: "Diseno 3D" },
{ id: "resin", label: "Resina" },
{ id: "fdm", label: "FDM" }
];
const preferredOrder = [
"MakerLab 3D",
"PrintCraft",
"3D Ideas",
"ImpresionAR",
"ProtoWorks",
"Resin Forge"
];
const distanceLabels = ["1,2 km", "1,8 km", "2,1 km", "2,4 km", "2,6 km", "2,9 km", "3,2 km"];
function getMakerImage(maker: PublicMaker) {
return maker.main_image_url || "/demo/maker-hero-1.svg";
}
function readParam(value: string | string[] | undefined) {
return typeof value === "string" ? value : "";
}
function getRating(value: number | string) {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function getSatisfaction(value: number | string) {
const rating = getRating(value);
return `${Math.max(92, Math.round(rating * 20))}% satisfaccion`;
}
function normalizeCategory(value: string): HomeCategory {
if (value === "print-3d" || value === "design-3d" || value === "resin" || value === "fdm") {
return value;
}
return "all";
}
function hasCategory(maker: PublicMaker, expected: string) {
return (maker.categories || []).some((category) => category.toLowerCase() === expected.toLowerCase());
}
function filterByCategory(makers: PublicMaker[], category: HomeCategory) {
if (category === "all") {
return makers;
}
if (category === "print-3d") {
return makers.filter((maker) => hasCategory(maker, "Impresion FDM") || hasCategory(maker, "Impresion Resina") || hasCategory(maker, "Prototipado"));
}
if (category === "design-3d") {
return makers.filter((maker) => hasCategory(maker, "Diseno 3D"));
}
if (category === "resin") {
return makers.filter((maker) => hasCategory(maker, "Impresion Resina"));
}
return makers.filter((maker) => hasCategory(maker, "Impresion FDM"));
}
function orderMakers(makers: PublicMaker[]) {
return [...makers].sort((left, right) => {
const leftIndex = preferredOrder.indexOf(left.business_name);
const rightIndex = preferredOrder.indexOf(right.business_name);
if (leftIndex !== -1 || rightIndex !== -1) {
return (leftIndex === -1 ? 999 : leftIndex) - (rightIndex === -1 ? 999 : rightIndex);
}
return getRating(right.avg_rating) - getRating(left.avg_rating);
});
}
function getMakerTags(maker: PublicMaker) {
const tags = new Set<string>();
for (const category of maker.categories || []) {
if (category === "Impresion FDM") {
tags.add("FDM");
}
if (category === "Impresion Resina") {
tags.add("Resina");
}
if (category === "Diseno 3D") {
tags.add("Diseno 3D");
}
if (category === "Prototipado") {
tags.add("Prototipos");
}
}
if (tags.size === 0) {
tags.add("Impresion 3D");
}
return Array.from(tags).slice(0, 3);
}
function buildHomeHref(query: string, category: HomeCategory) {
const params = new URLSearchParams();
if (query) {
params.set("q", query);
}
if (category !== "all") {
params.set("category", category);
}
const serialized = params.toString();
return serialized ? `/?${serialized}` : "/";
}
function buildDiscoverHref(query: string, category: HomeCategory) {
const params = new URLSearchParams();
if (query) {
params.set("q", query);
}
if (category === "design-3d") {
params.set("serviceCategory", "Diseno 3D");
}
if (category === "resin") {
params.set("serviceCategory", "Impresion Resina");
}
if (category === "fdm") {
params.set("serviceCategory", "Impresion FDM");
}
const serialized = params.toString();
return serialized ? `/discover?${serialized}` : "/discover";
}
export async function MapHomeScreen({ searchParams = {} }: { searchParams?: SearchParams }) {
const query = readParam(searchParams.q);
const activeCategory = normalizeCategory(readParam(searchParams.category));
const requestParams = new URLSearchParams();
if (query) {
requestParams.set("q", query);
}
const data = await serverFetch<{ makers: PublicMaker[] }>(`/makers?${requestParams.toString()}`);
const makers = orderMakers(filterByCategory(data.makers, activeCategory));
const visibleMakers = makers.slice(0, 10);
const makerListItems: MapHomeMakerListItem[] = visibleMakers.map((maker, index) => ({
id: maker.id,
slug: maker.slug,
name: maker.business_name,
city: maker.city,
province: maker.province,
imageUrl: getMakerImage(maker),
distance: distanceLabels[index % distanceLabels.length],
rating: getRating(maker.avg_rating).toFixed(1),
reviewCount: Math.max(maker.review_count * 32, 12),
satisfaction: getSatisfaction(maker.avg_rating),
tags: getMakerTags(maker)
}));
const discoverHref = buildDiscoverHref(query, activeCategory);
const mapMarkers = makers
.flatMap((maker) => {
const publicLocations = (maker.public_locations || []).filter((location) => location.latitude !== null && location.longitude !== null);
const locations = publicLocations.length
? publicLocations
: maker.public_latitude !== null && maker.public_longitude !== null
? [{ id: maker.id, city: maker.city, province: maker.province, latitude: maker.public_latitude, longitude: maker.public_longitude }]
: [];
return locations.map((location, index) => ({
id: `${maker.id}:${location.id || index}`,
slug: maker.slug,
businessName: maker.business_name,
city: location.city || maker.city,
province: location.province || maker.province,
latitude: location.latitude as number,
longitude: location.longitude as number
}));
});
return (
<section className="map-home-shell">
<div className="map-home-device">
<form action="/" className="map-home-search-stack">
<div className="map-home-search-row">
<label className="map-home-searchbar">
<span className="map-home-search-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="6.6" />
<path d="m20 20-3.6-3.6" />
</svg>
</span>
<input name="q" placeholder="Buscar makers, servicios, trabajos..." defaultValue={query} />
</label>
{activeCategory !== "all" ? <input type="hidden" name="category" value={activeCategory} /> : null}
<a href={discoverHref} className="map-home-filter-link" aria-label="Abrir filtros">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h16" />
<path d="M7 12h10" />
<path d="M10 17h4" />
</svg>
</a>
</div>
</form>
<div className="map-home-chip-row">
<a href={discoverHref} className="map-home-chip map-home-chip-primary">Filtros</a>
{quickCategories.map((category) => (
<a
key={category.id}
href={buildHomeHref(query, category.id)}
className={`map-home-chip ${activeCategory === category.id ? "active" : ""}`}
>
{category.label}
</a>
))}
</div>
<InteractiveMakerMapClient markers={mapMarkers} />
<section className="map-home-results">
<div className="map-home-results-head">
<div className="stack" style={{ gap: 4 }}>
<strong className="map-home-results-title">Makers cerca de ti</strong>
<span className="mini-note">Cordoba, Argentina | 1,2 km</span>
</div>
<a href={discoverHref} className="map-home-view-all">Ver todos</a>
</div>
<MapHomeMakerList makers={makerListItems} />
</section>
</div>
</section>
);
}
+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>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
type DockItem = {
href: string;
label: string;
isActive: (pathname: string) => boolean;
icon: ReactNode;
};
function DockIcon({ children }: { children: ReactNode }) {
return (
<span className="mobile-link-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
</span>
);
}
const dockItems: DockItem[] = [
{
href: "/",
label: "Mapa",
isActive: (pathname) => pathname === "/",
icon: <DockIcon><path d="M12 20s6-5.2 6-10a6 6 0 1 0-12 0c0 4.8 6 10 6 10Z" /><circle cx="12" cy="10" r="2.2" /></DockIcon>
},
{
href: "/discover",
label: "Descubrir",
isActive: (pathname) => pathname === "/discover" || pathname === "/results",
icon: <DockIcon><circle cx="11" cy="11" r="6.3" /><path d="m20 20-3.4-3.4" /></DockIcon>
},
{
href: "/messages",
label: "Mensajes",
isActive: (pathname) => pathname.startsWith("/messages") || pathname.startsWith("/account/inbox"),
icon: <DockIcon><path d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v7A2.5 2.5 0 0 1 17.5 16H10l-4.5 4v-4H6.5A2.5 2.5 0 0 1 4 13.5v-7Z" /></DockIcon>
},
{
href: "/favorites",
label: "Favoritos",
isActive: (pathname) => pathname.startsWith("/favorites"),
icon: <DockIcon><path d="m12 20-1.35-1.23C5.4 14 2 10.92 2 7.1 2 4.48 4.07 2.5 6.6 2.5c1.64 0 3.22.83 4.2 2.14A5.16 5.16 0 0 1 15 2.5c2.53 0 4.6 1.98 4.6 4.6 0 3.82-3.4 6.9-8.65 11.67L12 20Z" /></DockIcon>
},
{
href: "/profile",
label: "Perfil",
isActive: (pathname) => pathname.startsWith("/profile") || pathname === "/account",
icon: <DockIcon><path d="M19 20a7 7 0 0 0-14 0" /><circle cx="12" cy="8" r="4" /></DockIcon>
}
];
export default function MobileAppDock() {
const pathname = usePathname();
if (
pathname.startsWith("/app/backoffice")
|| pathname.startsWith("/admin")
) {
return null;
}
return (
<nav className="mobile-dock" aria-label="Navegacion principal movil">
{dockItems.map((item) => (
<a key={item.href} href={item.href} className={`mobile-link ${item.isActive(pathname) ? "active" : ""}`}>
{item.icon}
<span>{item.label}</span>
</a>
))}
</nav>
);
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { browserFetch } from "../lib/api";
import { logoutByNavigation } from "../lib/logout";
type MeResponse = {
user: { id: string; email: string; role: string } | null;
makerProfile: { id: string; slug: string; business_name: string | null; status: string } | null;
};
export default function ProfileClient() {
const [session, setSession] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState("");
useEffect(() => {
let active = true;
async function loadSession() {
try {
const response = await browserFetch<MeResponse>("/auth/me");
if (active) {
setSession(response);
}
} catch (error) {
if (active) {
setStatus(error instanceof Error ? error.message : "No se pudo cargar la sesion");
setSession({ user: null, makerProfile: null });
}
} finally {
if (active) {
setLoading(false);
}
}
}
void loadSession();
return () => {
active = false;
};
}, []);
useEffect(() => {
if (session?.user?.role === "admin") {
window.location.replace("/app/backoffice");
}
}, [session?.user?.role]);
function logout() {
logoutByNavigation();
}
if (loading) {
return (
<section className="profile-account-card stack">
<h1 className="section-title">Cargando perfil</h1>
<p className="muted">Validando tu sesion para mostrar el acceso correcto.</p>
</section>
);
}
if (!session?.user) {
return (
<section className="profile-account-card stack">
<h1 className="section-title">Necesitas iniciar sesion</h1>
<p className="muted">Entra con un usuario demo para probar cliente, maker o administrador.</p>
<Link href="/login?returnTo=%2Fprofile" className="button button-primary">Entrar</Link>
</section>
);
}
if (session.user.role === "admin") {
return (
<section className="workspace-main-card stack">
<span className="eyebrow">Backoffice</span>
<h1 className="section-title">Redirigiendo al panel administrador</h1>
<p className="muted">La cuenta superadmin no usa perfil maker ni perfil cliente.</p>
<button className="button button-secondary" type="button" onClick={logout}>Cerrar sesion</button>
{status ? <p className="muted">{status}</p> : null}
</section>
);
}
if (session.makerProfile && session.makerProfile.status !== "draft") {
const makerName = session.makerProfile.business_name || "Tu espacio Maker";
return (
<section className="profile-account-card stack">
<div className="profile-identity">
<span className="profile-avatar">{makerName.slice(0, 2).toUpperCase()}</span>
<div>
<span className="eyebrow">Perfil maker</span>
<h1 className="section-title">{makerName}</h1>
<p className="muted">{session.user.email}</p>
</div>
</div>
<div className="profile-status-grid">
<div className="stat-tile"><span className="stat-value">Activo</span><span className="mini-note">Estado de cuenta</span></div>
<div className="stat-tile"><span className="stat-value">{session.makerProfile.status}</span><span className="mini-note">Perfil publico</span></div>
</div>
<div className="profile-action-grid">
<Link href="/account" className="profile-action-tile">
<strong>Mi espacio maker</strong>
<span>Dashboard, trabajos, servicios y reputacion.</span>
</Link>
<Link href="/account/inbox" className="profile-action-tile">
<strong>Consultas recibidas</strong>
<span>Mensajes de clientes y propuestas de trabajo.</span>
</Link>
<Link href={`/makers/${session.makerProfile.slug}`} className="profile-action-tile">
<strong>Ver perfil publico</strong>
<span>Asi te ven clientes y proveedores.</span>
</Link>
<Link href="/favorites" className="profile-action-tile">
<strong>Favoritos</strong>
<span>Makers y trabajos que guardaste.</span>
</Link>
</div>
<button className="button button-secondary" type="button" onClick={logout}>Cerrar sesion</button>
{status ? <p className="muted">{status}</p> : null}
</section>
);
}
return (
<section className="profile-account-card stack client-profile-card">
<div className="profile-identity">
<span className="profile-avatar">CL</span>
<div>
<span className="eyebrow">Perfil cliente</span>
<h1 className="section-title">Tu cuenta de cliente</h1>
<p className="muted">{session.user.email}</p>
</div>
</div>
<div className="profile-action-grid">
<Link href="/messages" className="profile-action-tile">
<strong>Mensajes</strong>
<span>Tus consultas enviadas a makers y sus respuestas.</span>
</Link>
<Link href="/favorites" className="profile-action-tile">
<strong>Favoritos</strong>
<span>Makers y trabajos guardados para revisar despues.</span>
</Link>
<Link href="/account" className="profile-action-tile">
<strong>Crear mi espacio maker</strong>
<span>Solo si tambien queres publicar servicios como maker.</span>
</Link>
</div>
<button className="button button-secondary" type="button" onClick={logout}>Cerrar sesion</button>
{status ? <p className="muted">{status}</p> : null}
</section>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
import { browserFetch } from "../lib/api";
import { notifyAuthChanged } from "../lib/authEvents";
export default function RegisterForm({ redirectTo = "/account" }: { redirectTo?: string }) {
const [error, setError] = useState("");
const [form, setForm] = useState({ fullName: "", email: "", password: "" });
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
try {
await browserFetch("/auth/register", {
method: "POST",
body: JSON.stringify(form)
});
notifyAuthChanged("login");
window.location.href = redirectTo;
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "No se pudo crear la cuenta");
}
}
return (
<form className="stack" onSubmit={onSubmit}>
<input className="field" placeholder="Nombre completo" value={form.fullName} onChange={(event) => setForm((current) => ({ ...current, fullName: event.target.value }))} required />
<input className="field" type="email" placeholder="Correo" value={form.email} onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))} required />
<input className="field" type="password" placeholder="Contrasena" value={form.password} onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} required />
<button className="button button-primary" type="submit">Crear cuenta</button>
{error ? <p className="muted">{error}</p> : null}
</form>
);
}
@@ -0,0 +1,17 @@
"use client";
import { useEffect } from "react";
export function ServiceWorkerRegister() {
useEffect(() => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return;
}
navigator.serviceWorker.register("/sw.js").catch(() => {
// The app works without offline support, so registration failures stay silent in the demo MVP.
});
}, []);
return null;
}
@@ -0,0 +1,93 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import FavoriteToggle from "./FavoriteToggle";
type WorkHeroGalleryProps = {
gallery: string[];
title: string;
workId: string;
shareUrl?: string;
};
export default function WorkHeroGallery({ gallery, title, workId, shareUrl }: WorkHeroGalleryProps) {
const router = useRouter();
const galleryRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
function updateActiveSlide() {
const galleryNode = galleryRef.current;
if (!galleryNode) {
return;
}
const nextIndex = Math.round(galleryNode.scrollLeft / Math.max(galleryNode.clientWidth, 1));
setActiveIndex(Math.min(Math.max(nextIndex, 0), gallery.length - 1));
}
function goBack() {
if (typeof window !== "undefined" && window.history.length > 1) {
router.back();
return;
}
router.push("/discover");
}
async function shareWork() {
const url = shareUrl || (typeof window !== "undefined" ? window.location.href : "");
if (!url) {
return;
}
if (typeof navigator !== "undefined" && navigator.share) {
await navigator.share({
title,
text: `Mira este trabajo que encontre en Makers3D: ${title}`,
url
}).catch(() => undefined);
return;
}
await navigator.clipboard?.writeText(url).catch(() => undefined);
}
return (
<section className="work-detail-hero">
<div ref={galleryRef} className="work-detail-hero-gallery" onScroll={updateActiveSlide} aria-label={`Galeria de ${title}`}>
{gallery.map((image, index) => (
<div key={`${image}-${index}`} className="work-detail-hero-slide">
<img src={image} alt={`${title} - foto ${index + 1}`} />
</div>
))}
</div>
{gallery.length > 1 ? (
<div className="work-detail-hero-dots" aria-hidden="true">
{gallery.map((image, index) => (
<span key={`${image}-dot-${index}`} className={index === activeIndex ? "is-active" : ""} />
))}
</div>
) : null}
<div className="work-detail-top-actions">
<button className="work-detail-circle-action" type="button" onClick={goBack} aria-label="Volver">&lt;</button>
<div className="work-detail-action-pair">
<FavoriteToggle targetType="work" targetId={workId} compact icon="bookmark" />
<button className="work-detail-circle-action" type="button" onClick={() => void shareWork()} aria-label="Compartir trabajo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="m8.6 10.5 6.8-4" />
<path d="m8.6 13.5 6.8 4" />
</svg>
</button>
</div>
</div>
<span className="work-detail-gallery-count">{activeIndex + 1}/{gallery.length}</span>
</section>
);
}
@@ -0,0 +1,63 @@
"use client";
import { useMemo, useState } from "react";
type WorkShareActionsProps = {
title: string;
url: string;
};
export default function WorkShareActions({ title, url }: WorkShareActionsProps) {
const [status, setStatus] = useState("");
const shareText = useMemo(
() => `Mira esto que encontre en Makers3D: ${title}`,
[title]
);
const whatsappHref = `https://wa.me/?text=${encodeURIComponent(`${shareText}\n${url}`)}`;
async function copyLink() {
try {
await navigator.clipboard.writeText(url);
setStatus("Enlace copiado.");
} catch {
setStatus("No se pudo copiar. Manten presionado el enlace.");
}
}
async function shareWork() {
if (navigator.share) {
try {
await navigator.share({
title,
text: shareText,
url
});
setStatus("Listo para compartir.");
return;
} catch (error) {
const shareError = error as Error;
if (shareError.name === "AbortError") {
return;
}
}
}
await copyLink();
}
return (
<div className="work-detail-share-actions">
<button type="button" className="button button-primary" onClick={shareWork}>
Compartir
</button>
<a className="work-detail-share-option" href={whatsappHref} target="_blank" rel="noreferrer">
WhatsApp
</a>
<button type="button" className="work-detail-share-option" onClick={copyLink}>
Copiar enlace
</button>
{status ? <span className="work-detail-share-status">{status}</span> : null}
</div>
);
}