Modulo makers3d desarrollado con codex V 0.0.1
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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="© OpenStreetMap contributors © 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='© OpenStreetMap contributors © 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"><</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user