Modulo makers3d desarrollado con codex V 0.0.1

This commit is contained in:
Ryuk Mike
2026-07-29 23:58:58 +02:00
commit 2bedf7cbb7
171 changed files with 29421 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
import AccountClient from "../../../components/AccountClient";
export default async function AccountInboxDetailPage({
params
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<main className="page-shell workspace-page">
<AccountClient section="inbox" conversationId={id} />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountInboxPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="inbox" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountLocationsPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="locations" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../components/AccountClient";
export default function AccountPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="summary" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountProfilePage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="profile" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountReviewsPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="reviews" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountServicesPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="services" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountSettingsPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="settings" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountShowcasesPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="showcases" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountStatsPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="stats" />
</main>
);
}
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountSubscriptionPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="subscription" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountWorksPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="works" />
</main>
);
}
@@ -0,0 +1,56 @@
import BackofficeEntryClient from "../../../components/BackofficeEntryClient";
import type { AdminSection } from "../../../components/AdminClient";
const sectionMap: Record<string, AdminSection> = {
queue: "queue",
clients: "clients",
makers: "makers",
cases: "incidents",
"internal-users": "internal-users",
reviews: "reviews",
incidents: "incidents",
reports: "reports",
fraud: "fraud",
appeals: "appeals",
subscriptions: "subscriptions",
payments: "payments",
billing: "billing",
promotions: "promotions",
founders: "founders",
works: "works",
showcases: "showcases",
services: "services",
"reported-media": "reported-media",
"catalog-services": "catalog-services",
categories: "categories",
materials: "materials",
technologies: "technologies",
specialties: "specialties",
locations: "locations",
tickets: "tickets",
conversations: "conversations",
"help-center": "help-center",
"analytics-product": "analytics-product",
"analytics-makers": "analytics-makers",
"analytics-clients": "analytics-clients",
"analytics-trust": "analytics-trust",
"analytics-business": "analytics-business",
roles: "roles",
settings: "settings",
integrations: "integrations",
templates: "templates",
audit: "audit",
system: "system"
};
export default async function BackofficePage({
params
}: {
params: Promise<{ section?: string[] }>;
}) {
const resolvedParams = await params;
const currentSection = resolvedParams.section?.[0];
const section = currentSection ? sectionMap[currentSection] || "overview" : "overview";
return <BackofficeEntryClient section={section} />;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { browserFetch } from "../lib/api";
import { logoutByNavigation } from "../lib/logout";
type MeResponse = {
user: { id: string; email: string; role: string } | null;
makerProfile: { id: string; slug: string; business_name: string | null; status: string } | null;
};
export default function AuthNav() {
const [session, setSession] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
async function loadSession() {
try {
const response = await browserFetch<MeResponse>("/auth/me");
if (active) {
setSession(response);
}
} catch {
if (active) {
setSession({ user: null, makerProfile: null });
}
} finally {
if (active) {
setLoading(false);
}
}
}
void loadSession();
return () => {
active = false;
};
}, []);
function logout() {
logoutByNavigation();
}
const user = session?.user || null;
const makerProfile = session?.makerProfile || null;
const isAdmin = user?.role === "admin";
const isMaker = Boolean(makerProfile && makerProfile.status !== "draft");
return (
<nav className="nav-links">
<Link href="/" className="nav-link">Inicio</Link>
<Link href="/discover" className="nav-link">Descubrir</Link>
{loading ? <span className="nav-session-chip">Sesion...</span> : null}
{!loading && !user ? (
<Link href="/login" className="button button-primary">Entrar</Link>
) : null}
{!loading && user && isAdmin ? (
<>
<a href="/app/backoffice" className="nav-link nav-link-admin">Backoffice</a>
</>
) : null}
{!loading && user && !isAdmin && isMaker ? (
<>
<Link href="/account" className="nav-link">Mi espacio maker</Link>
<Link href="/account/inbox" className="nav-link">Consultas maker</Link>
</>
) : null}
{!loading && user && !isAdmin && !isMaker ? (
<>
<Link href="/messages" className="nav-link">Mensajes</Link>
<Link href="/favorites" className="nav-link">Favoritos</Link>
</>
) : null}
{!loading && user ? (
<button className="button button-secondary nav-logout-button" type="button" onClick={logout}>
Cerrar sesion
</button>
) : null}
</nav>
);
}
@@ -0,0 +1,13 @@
"use client";
import { useEffect } from "react";
import { listenAuthChanged } from "../lib/authEvents";
export default function AuthSessionSync() {
useEffect(() => listenAuthChanged(() => {
window.location.reload();
}), []);
return null;
}
@@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { browserFetch } from "../lib/api";
import AdminClient, { type AdminSection } from "./AdminClient";
export default function BackofficeEntryClient({ section }: { section: AdminSection }) {
const pathname = usePathname();
const [state, setState] = useState<"loading" | "ready" | "forbidden">("loading");
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
async function checkSession() {
try {
const response = await browserFetch<{ user: { id: string; role: string } | null }>("/auth/me");
if (cancelled) {
return;
}
if (!response.user) {
window.location.replace(`/login?returnTo=${encodeURIComponent(pathname || "/app/backoffice")}`);
return;
}
if (response.user.role !== "admin") {
setState("forbidden");
setError("Esta cuenta no tiene permisos de backoffice.");
return;
}
setState("ready");
} catch (sessionError) {
if (cancelled) {
return;
}
const message = sessionError instanceof Error ? sessionError.message : "No se pudo validar la sesion";
setError(message);
window.location.replace(`/login?returnTo=${encodeURIComponent(pathname || "/app/backoffice")}`);
}
}
void checkSession();
return () => {
cancelled = true;
};
}, [pathname]);
if (state === "loading") {
return (
<main className="page-shell ops-page">
<section className="panel stack">
<span className="eyebrow">Backoffice</span>
<h1 className="section-title">Validando acceso</h1>
<p className="muted">Comprobando la sesion antes de abrir el panel operativo.</p>
</section>
</main>
);
}
if (state === "forbidden") {
return (
<main className="page-shell ops-page">
<section className="panel stack">
<span className="eyebrow">Backoffice</span>
<h1 className="section-title">Acceso restringido</h1>
<p className="muted">{error}</p>
<div className="home-actions">
<Link href="/" className="button button-primary">Volver a la app</Link>
<Link href="/login?returnTo=%2Fapp%2Fbackoffice" className="button button-secondary">Entrar con otra cuenta</Link>
</div>
</section>
</main>
);
}
return (
<main className="page-shell ops-page">
<AdminClient section={section} />
</main>
);
}
@@ -0,0 +1,249 @@
"use client";
import { useEffect, useRef } from "react";
import Link from "next/link";
import FavoriteToggle from "./FavoriteToggle";
type PublicMaker = {
id: string;
slug: string;
business_name: string;
};
type PublicWork = {
id: string;
slug: string;
title: string;
summary: string;
technology: string;
material: string;
image_url: string | null;
gallery_urls: string[] | null;
maker_id: string;
maker_slug: string;
maker_name: string;
avg_rating: number | string;
review_count: number;
};
type DiscoverCategory = "all" | "fdm" | "resin" | "design-3d" | "repuestos" | "ingenieria";
const discoverReturnKey = "makers3d:discover:return-position";
function getRating(value: number | string) {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function workImages(work: PublicWork) {
const images = [work.image_url, ...(work.gallery_urls || [])]
.filter((image): image is string => Boolean(image));
return Array.from(new Set(images)).slice(0, 8);
}
function getWorkTags(work: PublicWork) {
return [work.technology, work.material].filter(Boolean).slice(0, 3);
}
function matchesCategory(work: PublicWork, category: DiscoverCategory) {
if (category === "all") {
return true;
}
const haystack = [work.title, work.summary, work.technology, work.material, work.maker_name]
.join(" ")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
if (category === "fdm") {
return haystack.includes("fdm");
}
if (category === "resin") {
return haystack.includes("resina") || haystack.includes("resin");
}
if (category === "design-3d") {
return haystack.includes("diseno") || haystack.includes("diseño") || haystack.includes("cad");
}
if (category === "repuestos") {
return haystack.includes("repuesto") || haystack.includes("restauracion") || haystack.includes("pieza");
}
return haystack.includes("ingenieria") || haystack.includes("industrial") || haystack.includes("tecnic");
}
function orderWorks(works: PublicWork[]) {
return [...works].sort((left, right) => {
if (getRating(right.avg_rating) !== getRating(left.avg_rating)) {
return getRating(right.avg_rating) - getRating(left.avg_rating);
}
return right.review_count - left.review_count;
});
}
function SearchIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="6.6" />
<path d="m20 20-3.6-3.6" />
</svg>
);
}
export default function DiscoverExperienceClient({
initialWorks,
initialQuery,
initialCategory
}: {
initialMakers: PublicMaker[];
initialWorks: PublicWork[];
initialQuery: string;
initialCategory: DiscoverCategory;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const works = orderWorks(initialWorks.filter((work) => matchesCategory(work, initialCategory)));
useEffect(() => {
const storedPosition = window.sessionStorage.getItem(discoverReturnKey);
if (!storedPosition) {
return;
}
try {
const position = JSON.parse(storedPosition) as {
scrollTop?: number;
query?: string;
category?: DiscoverCategory;
};
if (position.query !== initialQuery || position.category !== initialCategory) {
return;
}
const restore = () => {
scrollRef.current?.scrollTo({ top: position.scrollTop || 0, behavior: "auto" });
};
requestAnimationFrame(restore);
window.setTimeout(restore, 120);
} catch {
window.sessionStorage.removeItem(discoverReturnKey);
}
}, [initialCategory, initialQuery, works.length]);
function rememberReturnPosition(workId: string) {
window.sessionStorage.setItem(
discoverReturnKey,
JSON.stringify({
category: initialCategory,
query: initialQuery,
scrollTop: scrollRef.current?.scrollTop || 0,
workId
})
);
}
return (
<section className="discover-feed-shell">
<div className="discover-feed-topbar">
<form action="/discover" className="discover-feed-search">
{initialCategory !== "all" ? <input type="hidden" name="category" value={initialCategory} /> : null}
<span aria-hidden="true">
<SearchIcon />
</span>
<input name="q" placeholder="Buscar trabajos, piezas, materiales..." defaultValue={initialQuery} />
</form>
</div>
<div ref={scrollRef} className="discover-feed-scroll" aria-label="Feed de trabajos">
{works.map((work, index) => {
const images = workImages(work);
return (
<article key={work.id} className="discover-feed-item">
<section className="discover-feed-visual">
<div className="discover-feed-gallery" aria-label={`Galeria de ${work.title}`}>
{images.map((image, imageIndex) => (
<div key={`${work.id}-${image}`} className="discover-feed-slide">
<img src={image} alt={`${work.title} - imagen ${imageIndex + 1}`} />
</div>
))}
</div>
{images.length > 1 ? (
<div className="discover-feed-dots" aria-hidden="true">
{images.map((image) => (
<span key={`${work.id}-dot-${image}`} />
))}
</div>
) : null}
<span className="discover-feed-photo-count">{images.length === 1 ? "1 foto" : `${images.length} fotos | desliza`}</span>
<div className="discover-feed-actions" aria-label="Acciones del trabajo">
<FavoriteToggle targetType="work" targetId={work.id} compact />
<Link
href={`/works/${work.slug}`}
className="discover-feed-action-button"
onClick={() => rememberReturnPosition(work.id)}
aria-label="Ver trabajo completo"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 17 17 7" />
<path d="M8 7h9v9" />
</svg>
</Link>
</div>
</section>
<section className="discover-feed-copy">
<div className="discover-feed-maker-row">
<Link href={`/makers/${work.maker_slug}`} className="discover-feed-maker">
<span>{work.maker_name.slice(0, 2).toUpperCase()}</span>
<strong>{work.maker_name}</strong>
</Link>
<div className="discover-feed-rating">
<span className="map-home-star">*</span>
<span>{getRating(work.avg_rating).toFixed(1)}</span>
<span>({Math.max(work.review_count * 32, 12)})</span>
</div>
</div>
<div className="discover-feed-title-row">
<h1>{work.title}</h1>
<Link
href={`/works/${work.slug}`}
className="button button-primary discover-feed-cta"
onClick={() => rememberReturnPosition(work.id)}
>
Ver trabajo
</Link>
</div>
<p>{work.summary}</p>
<div className="discover-feed-tags">
{getWorkTags(work).map((tag) => (
<span key={`${work.id}-${tag}`} className="map-home-tag">{tag}</span>
))}
</div>
</section>
</article>
);
})}
{works.length === 0 ? (
<section className="discover-feed-empty">
<span className="eyebrow">Descubrir</span>
<h1>No encontramos trabajos</h1>
<p>Prueba otra busqueda para volver a explorar el portfolio de makers.</p>
<Link href="/discover" className="button button-primary">Ver todos</Link>
</section>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,96 @@
import { serverFetch } from "../lib/api";
import DiscoverExperienceClient from "./DiscoverExperienceClient";
type PublicMaker = {
id: string;
slug: string;
business_name: string;
description: string;
province: string;
city: string;
delivery_scope: string;
availability: string;
main_image_url: string | null;
avg_rating: number | string;
review_count: number;
service_count: number;
work_count: number;
public_latitude: number | null;
public_longitude: number | null;
categories?: string[];
};
type PublicWork = {
id: string;
slug: string;
title: string;
summary: string;
technology: string;
material: string;
image_url: string | null;
gallery_urls: string[] | null;
maker_id: string;
maker_slug: string;
maker_name: string;
avg_rating: number | string;
review_count: number;
};
type DiscoverCategory = "all" | "fdm" | "resin" | "design-3d" | "repuestos" | "ingenieria";
function readParam(value: string | string[] | undefined) {
return typeof value === "string" ? value : "";
}
function normalizeCategory(value: string): DiscoverCategory {
const normalized = value.trim().toLowerCase();
if (normalized === "fdm" || normalized === "impresion fdm") {
return "fdm";
}
if (normalized === "resina" || normalized === "impresion resina") {
return "resin";
}
if (normalized === "design-3d" || normalized === "diseno 3d") {
return "design-3d";
}
if (normalized === "repuestos") {
return "repuestos";
}
if (normalized === "ingenieria") {
return "ingenieria";
}
return "all";
}
export async function DiscoverResultsScreen({
searchParams
}: {
searchParams?: Record<string, string | string[] | undefined>;
}) {
const query = readParam(searchParams?.q);
const initialCategory = normalizeCategory(
readParam(searchParams?.category) || readParam(searchParams?.serviceCategory)
);
const requestQuery = new URLSearchParams();
if (query) {
requestQuery.set("q", query);
}
const serialized = requestQuery.toString();
const [makersData, worksData] = await Promise.all([
serverFetch<{ makers: PublicMaker[] }>(`/makers${serialized ? `?${serialized}` : ""}`),
serverFetch<{ works: PublicWork[] }>(`/works${serialized ? `?${serialized}` : ""}`)
]);
return (
<DiscoverExperienceClient
initialMakers={makersData.makers}
initialWorks={worksData.works}
initialQuery={query}
initialCategory={initialCategory}
/>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useState } from "react";
import { browserFetch } from "../lib/api";
type FavoriteToggleProps = {
targetId: string;
targetType: "maker" | "work";
targetSlug?: string;
compact?: boolean;
icon?: "heart" | "bookmark";
};
const favoriteSyncEvent = "makers3d:favorite-updated";
function loginForCurrentPage() {
const currentPath = `${window.location.pathname}${window.location.search}`;
window.location.assign(`/login?returnTo=${encodeURIComponent(currentPath)}`);
}
function BookmarkIcon({ active }: { active: boolean }) {
return (
<svg viewBox="0 0 24 24" fill={active ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M7 4.8A1.8 1.8 0 0 1 8.8 3h6.4A1.8 1.8 0 0 1 17 4.8V21l-5-3-5 3V4.8Z" />
</svg>
);
}
export default function FavoriteToggle({ targetId, targetType, targetSlug, compact = false, icon = "heart" }: FavoriteToggleProps) {
const [favorited, setFavorited] = useState(false);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState("");
const syncKey = `${targetType}:${targetSlug || targetId}`;
useEffect(() => {
let active = true;
async function loadFavorite() {
try {
const statusQuery = targetType === "maker" && targetSlug
? `/favorites/status?targetType=maker&targetSlug=${encodeURIComponent(targetSlug)}`
: `/favorites/status?targetType=${targetType}&targetId=${targetId}`;
const response = await browserFetch<{ favorited: boolean }>(
statusQuery
);
if (active) {
setFavorited(response.favorited);
}
} catch {
if (active) {
setFavorited(false);
}
} finally {
if (active) {
setLoading(false);
}
}
}
void loadFavorite();
return () => {
active = false;
};
}, [targetId, targetSlug, targetType]);
useEffect(() => {
function syncFavorite(event: Event) {
const detail = (event as CustomEvent<{ key: string; favorited: boolean }>).detail;
if (detail?.key === syncKey) {
setFavorited(detail.favorited);
}
}
window.addEventListener(favoriteSyncEvent, syncFavorite);
return () => window.removeEventListener(favoriteSyncEvent, syncFavorite);
}, [syncKey]);
async function toggleFavorite() {
setStatus("");
try {
const identifier = targetSlug || targetId || "current";
const endpoint = targetType === "maker"
? `/favorites/makers/${encodeURIComponent(identifier)}`
: `/favorites/works/${encodeURIComponent(identifier)}`;
const response = await browserFetch<{ favorited: boolean }>(endpoint, {
method: favorited ? "DELETE" : "POST"
});
setFavorited(response.favorited);
window.dispatchEvent(new CustomEvent(favoriteSyncEvent, {
detail: {
key: syncKey,
favorited: response.favorited
}
}));
} catch (error) {
const apiError = error as Error & { status?: number };
const message = apiError.message || "";
if (apiError.status === 401 || apiError.status === 403 || message.includes("Authentication")) {
loginForCurrentPage();
return;
}
setStatus(apiError.status ? `No se pudo guardar (${apiError.status}).` : "No se pudo actualizar favoritos.");
}
}
return (
<div className={`favorite-toggle-wrap ${compact ? "is-compact" : ""} favorite-icon-${icon}`}>
<button
className={`favorite-toggle ${favorited ? "is-active" : ""}`}
type="button"
onClick={toggleFavorite}
disabled={loading}
aria-pressed={favorited}
>
<span className="favorite-heart" aria-hidden="true">
{icon === "bookmark" ? <BookmarkIcon active={favorited} /> : (favorited ? "\u2764\uFE0F" : "\u2661")}
</span>
<span>{favorited ? "Guardado" : "Guardar"}</span>
</button>
{status ? <span className="mini-note">{status}</span> : null}
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { browserFetch } from "../lib/api";
type FavoriteMaker = {
id: string;
slug: string;
business_name: string;
city: string | null;
province: string | null;
main_image_url: string | null;
availability: string | null;
avg_rating: number | string;
review_count: number;
};
type FavoriteWork = {
id: string;
slug: string;
title: string;
summary: string;
technology: string;
material: string;
image_url: string;
maker_slug: string;
maker_name: string;
};
type FavoritesResponse = {
makers: FavoriteMaker[];
works: FavoriteWork[];
};
function rating(value: number | string) {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed.toFixed(1) : "0.0";
}
export default function FavoritesClient() {
const [activeTab, setActiveTab] = useState<"makers" | "works">("makers");
const [favorites, setFavorites] = useState<FavoritesResponse>({ makers: [], works: [] });
const [loading, setLoading] = useState(true);
const [needsLogin, setNeedsLogin] = useState(false);
const [status, setStatus] = useState("");
async function loadFavorites() {
setStatus("");
try {
const response = await browserFetch<FavoritesResponse>("/favorites");
setFavorites(response);
setNeedsLogin(false);
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (message.includes("Authentication")) {
setNeedsLogin(true);
return;
}
setStatus("No se pudieron cargar tus favoritos.");
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadFavorites();
}, []);
async function removeFavorite(type: "maker" | "work", id: string) {
const endpoint = type === "maker" ? `/favorites/makers/${id}` : `/favorites/works/${id}`;
await browserFetch(endpoint, { method: "DELETE" });
await loadFavorites();
}
if (loading) {
return (
<section className="favorites-shell">
<div className="profile-account-card stack">
<span className="eyebrow">Favoritos</span>
<h1 className="section-title">Cargando guardados</h1>
<p className="muted">Estamos preparando tus makers y trabajos favoritos.</p>
</div>
</section>
);
}
if (needsLogin) {
return (
<section className="favorites-shell">
<div className="profile-account-card stack">
<span className="eyebrow">Favoritos</span>
<h1 className="section-title">Inicia sesion para guardar favoritos</h1>
<p className="muted">Tus makers y trabajos guardados quedan asociados a tu cuenta para recuperarlos desde cualquier pestana.</p>
<Link href="/login?returnTo=%2Ffavorites" className="button button-primary">Entrar</Link>
</div>
</section>
);
}
const visibleMakers = activeTab === "makers";
const empty = visibleMakers ? favorites.makers.length === 0 : favorites.works.length === 0;
return (
<section className="favorites-shell">
<div className="favorites-header">
<div>
<span className="eyebrow">Favoritos</span>
<h1 className="section-title">Guardados para volver rapido</h1>
<p className="muted">Separados por makers y trabajos para que puedas comparar proveedores o ejemplos concretos.</p>
</div>
<div className="favorites-counts">
<span>{favorites.makers.length} makers</span>
<span>{favorites.works.length} trabajos</span>
</div>
</div>
<div className="favorites-tabs" role="tablist" aria-label="Secciones de favoritos">
<button className={activeTab === "makers" ? "is-active" : ""} type="button" onClick={() => setActiveTab("makers")}>
Makers
</button>
<button className={activeTab === "works" ? "is-active" : ""} type="button" onClick={() => setActiveTab("works")}>
Trabajos
</button>
</div>
{status ? <p className="messages-status">{status}</p> : null}
{empty ? (
<div className="profile-account-card stack">
<h2 className="section-title">{visibleMakers ? "Todavia no guardaste makers" : "Todavia no guardaste trabajos"}</h2>
<p className="muted">Usa el boton Guardar desde un perfil de maker o desde el detalle de un trabajo.</p>
<Link href="/discover" className="button button-primary">Descubrir makers</Link>
</div>
) : null}
{visibleMakers ? (
<div className="favorites-grid">
{favorites.makers.map((maker) => (
<article key={maker.id} className="favorite-card">
<img className="thumb" src={maker.main_image_url || "/demo/maker-hero-1.svg"} alt={maker.business_name} />
<div className="favorite-card-body">
<div>
<strong>{maker.business_name}</strong>
<span className="mini-note">{maker.city || "Cordoba"}, {maker.province || "Argentina"}</span>
</div>
<div className="badges">
<span className="badge">Rating {rating(maker.avg_rating)}</span>
<span className="badge">{maker.review_count} resenas</span>
<span className="badge">{maker.availability || "available"}</span>
</div>
<div className="favorite-card-actions">
<Link href={`/makers/${maker.slug}`} className="button button-primary">Ver perfil</Link>
<button className="button button-secondary" type="button" onClick={() => removeFavorite("maker", maker.id)}>Quitar</button>
</div>
</div>
</article>
))}
</div>
) : (
<div className="favorites-grid">
{favorites.works.map((work) => (
<article key={work.id} className="favorite-card">
<img className="thumb" src={work.image_url || "/demo/work-custom.svg"} alt={work.title} />
<div className="favorite-card-body">
<div>
<strong>{work.title}</strong>
<span className="mini-note">{work.maker_name}</span>
</div>
<p className="muted">{work.summary}</p>
<div className="badges">
<span className="badge">{work.technology}</span>
<span className="badge">{work.material}</span>
</div>
<div className="favorite-card-actions">
<Link href={`/works/${work.slug}`} className="button button-primary">Ver trabajo</Link>
<button className="button button-secondary" type="button" onClick={() => removeFavorite("work", work.id)}>Quitar</button>
</div>
</div>
</article>
))}
</div>
)}
</section>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import { browserFetch } from "../lib/api";
type Props = {
makerId: string;
sourceType: "profile" | "service" | "work";
sourceId?: string | null;
};
export function InquiryForm({ makerId, sourceType, sourceId }: Props) {
const [status, setStatus] = useState<string>("");
const [form, setForm] = useState({
needText: "",
sizeText: "",
hasModel: false,
materialPreference: "",
quantity: 1,
urgency: "medium",
deliveryType: "shipping"
});
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus("Enviando consulta...");
try {
await browserFetch("/inquiries", {
method: "POST",
body: JSON.stringify({
makerId,
sourceType,
sourceId,
...form
})
});
setStatus("Consulta enviada. Ya puedes seguirla desde tu cuenta.");
setForm({
needText: "",
sizeText: "",
hasModel: false,
materialPreference: "",
quantity: 1,
urgency: "medium",
deliveryType: "shipping"
});
} catch (error) {
setStatus(error instanceof Error ? error.message : "No se pudo enviar la consulta");
}
}
return (
<form className="inquiry-card" onSubmit={onSubmit}>
<div className="guide-steps">
<div className="guide-step">
<div className="step-index">1</div>
<div>
<strong>Que quieres hacer?</strong>
<div className="mini-note">Cuanto mejor expliques el uso, mejor se ajusta la respuesta del maker.</div>
</div>
</div>
</div>
<div className="inquiry-section">
<textarea
className="textarea"
placeholder="Quiero algo muy parecido, necesito modificar una pieza o tengo una idea similar..."
value={form.needText}
onChange={(event) => setForm((current) => ({ ...current, needText: event.target.value }))}
required
/>
</div>
<div className="inquiry-section">
<strong>Detalles que ayudan al maker</strong>
<div className="input-grid-compact">
<input
className="field"
placeholder="Tamano o medidas aproximadas"
value={form.sizeText}
onChange={(event) => setForm((current) => ({ ...current, sizeText: event.target.value }))}
/>
<input
className="field"
placeholder="Material preferido"
value={form.materialPreference}
onChange={(event) => setForm((current) => ({ ...current, materialPreference: event.target.value }))}
/>
<input
className="field"
type="number"
min={1}
value={form.quantity}
onChange={(event) => setForm((current) => ({ ...current, quantity: Number(event.target.value) }))}
/>
<select
className="select"
value={form.urgency}
onChange={(event) => setForm((current) => ({ ...current, urgency: event.target.value }))}
>
<option value="low">Sin urgencia</option>
<option value="medium">Esta semana</option>
<option value="high">Urgente</option>
</select>
</div>
</div>
<div className="input-grid-compact">
<label className="choice-chip">
<input
type="checkbox"
checked={form.hasModel}
onChange={(event) => setForm((current) => ({ ...current, hasModel: event.target.checked }))}
/>
<span>Ya tengo archivo o pieza de referencia</span>
</label>
<select
className="select"
value={form.deliveryType}
onChange={(event) => setForm((current) => ({ ...current, deliveryType: event.target.value }))}
>
<option value="shipping">Necesito envio</option>
<option value="local">Puedo retirar personalmente</option>
</select>
</div>
<button className="button button-primary" type="submit">Enviar consulta</button>
{status ? <p className="muted">{status}</p> : null}
</form>
);
}
@@ -0,0 +1,255 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { browserFetch } from "../lib/api";
type Props = {
makerId: string;
sourceType: "profile" | "service" | "work";
sourceId?: string;
makerName: string;
contextTitle: string;
returnTo: string;
};
type Draft = {
needText: string;
sizeText: string;
usageText: string;
materialPreference: string;
quantity: number;
urgency: string;
deliveryType: string;
hasModel: boolean;
referenceNotes: string;
};
const initialDraft: Draft = {
needText: "",
sizeText: "",
usageText: "",
materialPreference: "",
quantity: 1,
urgency: "medium",
deliveryType: "shipping",
hasModel: false,
referenceNotes: ""
};
export default function InquiryWizardClient({ makerId, sourceType, sourceId, makerName, contextTitle, returnTo }: Props) {
const router = useRouter();
const [step, setStep] = useState(1);
const [status, setStatus] = useState("");
const [me, setMe] = useState<{ id: string } | null>(null);
const [draft, setDraft] = useState<Draft>(initialDraft);
const storageKey = useMemo(
() => `makers3d_inquiry_draft:${makerId}:${sourceType}:${sourceId || "root"}`,
[makerId, sourceId, sourceType]
);
useEffect(() => {
const saved = window.sessionStorage.getItem(storageKey);
if (saved) {
try {
setDraft({ ...initialDraft, ...JSON.parse(saved) as Draft });
} catch {
window.sessionStorage.removeItem(storageKey);
}
}
void browserFetch<{ user: { id: string } | null }>("/auth/me")
.then((result) => setMe(result.user))
.catch(() => setMe(null));
}, [storageKey]);
useEffect(() => {
window.sessionStorage.setItem(storageKey, JSON.stringify(draft));
}, [draft, storageKey]);
function nextStep() {
setStep((current) => Math.min(5, current + 1));
}
function previousStep() {
setStep((current) => Math.max(1, current - 1));
}
async function submitInquiry() {
if (!me) {
const nextUrl = `/consultas/nueva?makerId=${encodeURIComponent(makerId)}&sourceType=${encodeURIComponent(sourceType)}&sourceId=${encodeURIComponent(sourceId || "")}&makerName=${encodeURIComponent(makerName)}&contextTitle=${encodeURIComponent(contextTitle)}`;
window.location.href = `/register?returnTo=${encodeURIComponent(nextUrl)}`;
return;
}
setStatus("Enviando consulta...");
try {
await browserFetch("/inquiries", {
method: "POST",
body: JSON.stringify({
makerId,
sourceType,
sourceId,
needText: `${draft.needText}\n\nUso final: ${draft.usageText}\nReferencia: ${draft.referenceNotes}`.trim(),
sizeText: draft.sizeText,
hasModel: draft.hasModel,
materialPreference: draft.materialPreference,
quantity: draft.quantity,
urgency: draft.urgency,
deliveryType: draft.deliveryType
})
});
window.sessionStorage.removeItem(storageKey);
setStatus("Consulta enviada.");
router.push("/account/inbox");
router.refresh();
} catch (error) {
setStatus(error instanceof Error ? error.message : "No se pudo enviar la consulta");
}
}
return (
<div className="wizard-shell">
<div className="panel wizard-context stack">
<span className="eyebrow">Consulta guiada</span>
<h1 className="section-title">Paso {step} de 5</h1>
<div className="list-item stack">
<strong>{contextTitle}</strong>
<span className="mini-note">Maker: {makerName}</span>
<span className="mini-note">Si vuelves tras registrarte, el borrador se conserva.</span>
</div>
<div className="wizard-progress">
{[1, 2, 3, 4, 5].map((value) => (
<span key={value} className={`wizard-dot ${value <= step ? "active" : ""}`} />
))}
</div>
</div>
<div className="panel stack">
{step === 1 && (
<>
<span className="eyebrow">Necesidad</span>
<h2 className="section-title">Que necesitas resolver?</h2>
<textarea
className="textarea"
placeholder="Quiero algo igual, modificar una pieza o fabricar algo parecido..."
value={draft.needText}
onChange={(event) => setDraft((current) => ({ ...current, needText: event.target.value }))}
/>
</>
)}
{step === 2 && (
<>
<span className="eyebrow">Detalles del trabajo</span>
<h2 className="section-title">Dimension, material y referencia</h2>
<div className="input-grid-compact">
<input
className="field"
placeholder="Tamano o medidas"
value={draft.sizeText}
onChange={(event) => setDraft((current) => ({ ...current, sizeText: event.target.value }))}
/>
<input
className="field"
placeholder="Material preferido"
value={draft.materialPreference}
onChange={(event) => setDraft((current) => ({ ...current, materialPreference: event.target.value }))}
/>
</div>
<label className="choice-chip">
<input
type="checkbox"
checked={draft.hasModel}
onChange={(event) => setDraft((current) => ({ ...current, hasModel: event.target.checked }))}
/>
<span>Ya tengo archivo o una pieza de referencia</span>
</label>
<textarea
className="textarea"
placeholder="Describe el archivo, la pieza o lo que puedes adjuntar despues en la conversacion."
value={draft.referenceNotes}
onChange={(event) => setDraft((current) => ({ ...current, referenceNotes: event.target.value }))}
/>
</>
)}
{step === 3 && (
<>
<span className="eyebrow">Uso final</span>
<h2 className="section-title">Para que se utilizara?</h2>
<textarea
className="textarea"
placeholder="Pieza funcional, exterior, calor, agua, decoracion, prototipo..."
value={draft.usageText}
onChange={(event) => setDraft((current) => ({ ...current, usageText: event.target.value }))}
/>
</>
)}
{step === 4 && (
<>
<span className="eyebrow">Urgencia y entrega</span>
<h2 className="section-title">Cantidad, plazo y modalidad</h2>
<div className="input-grid-compact">
<input
className="field"
type="number"
min={1}
value={draft.quantity}
onChange={(event) => setDraft((current) => ({ ...current, quantity: Number(event.target.value) }))}
/>
<select
className="select"
value={draft.urgency}
onChange={(event) => setDraft((current) => ({ ...current, urgency: event.target.value }))}
>
<option value="low">Sin urgencia</option>
<option value="medium">Esta semana</option>
<option value="high">Urgente</option>
</select>
<select
className="select"
value={draft.deliveryType}
onChange={(event) => setDraft((current) => ({ ...current, deliveryType: event.target.value }))}
>
<option value="shipping">Necesito envio</option>
<option value="local">Puedo retirar</option>
</select>
</div>
</>
)}
{step === 5 && (
<>
<span className="eyebrow">Resumen</span>
<h2 className="section-title">Revisa antes de enviar</h2>
<div className="checklist-list">
<div className="checklist-item"><strong>Necesidad</strong><span>{draft.needText || "Pendiente"}</span></div>
<div className="checklist-item"><strong>Detalles</strong><span>{draft.sizeText || "Sin medidas"} / {draft.materialPreference || "Sin material"}</span></div>
<div className="checklist-item"><strong>Uso final</strong><span>{draft.usageText || "No indicado"}</span></div>
<div className="checklist-item"><strong>Entrega</strong><span>{draft.quantity} unidad / {draft.deliveryType}</span></div>
</div>
{!me ? (
<p className="muted">Para enviar la consulta necesitas crear cuenta o entrar. El borrador no se pierde.</p>
) : null}
</>
)}
<div className="wizard-actions">
<a href={returnTo} className="button button-ghost">Volver</a>
<div className="wizard-actions-right">
{step > 1 ? <button className="button button-secondary" type="button" onClick={previousStep}>Anterior</button> : null}
{step < 5 ? (
<button className="button button-primary" type="button" onClick={nextStep}>Continuar</button>
) : (
<button className="button button-primary" type="button" onClick={submitInquiry}>Enviar consulta</button>
)}
</div>
</div>
{status ? <p className="muted">{status}</p> : null}
</div>
</div>
);
}
@@ -0,0 +1,437 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { divIcon, type DivIcon, type Map as LeafletMap } from "leaflet";
import { Circle, CircleMarker, MapContainer, Marker, Popup, TileLayer, useMap, useMapEvents } from "react-leaflet";
type DiscoverMapMarker = {
id: string;
slug: string;
businessName: string;
city: string;
province: string;
latitude: number;
longitude: number;
};
type DisplayMarker = DiscoverMapMarker & {
displayLatitude: number;
displayLongitude: number;
radius: number;
};
type ClusterGroup = {
key: string;
count: number;
latitude: number;
longitude: number;
markers: DisplayMarker[];
};
const cordobaCenter: [number, number] = [-31.4201, -64.1888];
const userApproximationRadius = 900;
function hashValue(input: string, seed: number) {
let value = seed;
for (let index = 0; index < input.length; index += 1) {
value = (value * 33 + input.charCodeAt(index) + index) % 1000003;
}
return value;
}
function toOffset(value: number, spread: number) {
return ((value % 1000) / 999 - 0.5) * spread;
}
function toDisplayMarkers(markers: DiscoverMapMarker[]): DisplayMarker[] {
return markers.map((marker) => {
const latSeed = hashValue(marker.id, 17);
const lngSeed = hashValue(marker.slug, 29);
const radiusSeed = hashValue(marker.businessName, 43);
return {
...marker,
displayLatitude: marker.latitude + toOffset(latSeed, 0.0072),
displayLongitude: marker.longitude + toOffset(lngSeed, 0.0094),
radius: 8 + (radiusSeed % 4)
};
});
}
function getClusterRadius(zoom: number) {
if (zoom >= 15) {
return 0;
}
if (zoom >= 14) {
return 34;
}
if (zoom >= 13) {
return 42;
}
if (zoom >= 12) {
return 50;
}
if (zoom >= 11) {
return 58;
}
return 66;
}
function buildClusterGroups(markers: DisplayMarker[], map: LeafletMap, zoom: number) {
const clusterRadius = getClusterRadius(zoom);
if (clusterRadius === 0) {
return markers.map((marker) => ({
key: marker.id,
count: 1,
latitude: marker.displayLatitude,
longitude: marker.displayLongitude,
markers: [marker]
}));
}
const projectedMarkers = markers.map((marker) => ({
marker,
point: map.project([marker.displayLatitude, marker.displayLongitude], zoom)
}));
const visited = new Set<number>();
const groups: ClusterGroup[] = [];
for (let index = 0; index < projectedMarkers.length; index += 1) {
if (visited.has(index)) {
continue;
}
const queue = [index];
const memberIndexes: number[] = [];
visited.add(index);
while (queue.length > 0) {
const currentIndex = queue.shift() as number;
const currentMarker = projectedMarkers[currentIndex];
memberIndexes.push(currentIndex);
for (let candidateIndex = 0; candidateIndex < projectedMarkers.length; candidateIndex += 1) {
if (visited.has(candidateIndex)) {
continue;
}
const candidateMarker = projectedMarkers[candidateIndex];
const deltaX = currentMarker.point.x - candidateMarker.point.x;
const deltaY = currentMarker.point.y - candidateMarker.point.y;
if (Math.hypot(deltaX, deltaY) <= clusterRadius) {
visited.add(candidateIndex);
queue.push(candidateIndex);
}
}
}
const bucket = memberIndexes.map((memberIndex) => projectedMarkers[memberIndex].marker);
const latitude = bucket.reduce((sum, marker) => sum + marker.displayLatitude, 0) / bucket.length;
const longitude = bucket.reduce((sum, marker) => sum + marker.displayLongitude, 0) / bucket.length;
groups.push({
key: bucket.map((marker) => marker.id).sort().join(":"),
count: bucket.length,
latitude,
longitude,
markers: bucket
});
}
return groups;
}
function buildClusterIcon(count: number): DivIcon {
const size = count >= 10 ? 60 : count >= 5 ? 54 : 48;
const toneClass = count >= 10 ? "is-large" : count >= 5 ? "is-medium" : "is-small";
return divIcon({
className: "map-home-cluster-icon-shell",
html: `<span class="map-home-cluster-badge ${toneClass}">${count}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2]
});
}
function FitMapToMarkers({ markers }: { markers: DisplayMarker[] }) {
const map = useMap();
useEffect(() => {
if (markers.length === 0) {
map.setView(cordobaCenter, 12);
return;
}
map.fitBounds(
markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
{
padding: [40, 40],
maxZoom: 14
}
);
}, [map, markers]);
return null;
}
function RegisterMapInstance({ mapRef }: { mapRef: MutableRefObject<LeafletMap | null> }) {
const map = useMap();
useEffect(() => {
mapRef.current = map;
return () => {
if (mapRef.current === map) {
mapRef.current = null;
}
};
}, [map, mapRef]);
return null;
}
function ClusteredMarkerLayer({
markers,
selectedMarkerId,
onSelectMarker
}: {
markers: DisplayMarker[];
selectedMarkerId?: string;
onSelectMarker: (makerId: string) => void;
}) {
const map = useMap();
const [zoom, setZoom] = useState(() => map.getZoom());
useMapEvents({
zoomend() {
setZoom(map.getZoom());
}
});
const groups = buildClusterGroups(markers, map, zoom);
return (
<>
{groups.map((group) => {
if (group.count === 1) {
const marker = group.markers[0];
const isSelected = marker.id === selectedMarkerId;
return (
<CircleMarker
key={marker.id}
center={[marker.displayLatitude, marker.displayLongitude]}
radius={isSelected ? marker.radius + 4 : marker.radius}
pathOptions={{
color: "#ffffff",
weight: isSelected ? 3 : 2,
fillColor: isSelected ? "#4f89ff" : "#10358d",
fillOpacity: 0.98
}}
eventHandlers={{
click: () => onSelectMarker(marker.id)
}}
>
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
<div className="map-home-popup-card">
<strong>{marker.businessName}</strong>
<span>{marker.city}, {marker.province}</span>
<a href={`/makers/${marker.slug}`}>Ver maker</a>
</div>
</Popup>
</CircleMarker>
);
}
return (
<Marker
key={group.key}
position={[group.latitude, group.longitude]}
icon={buildClusterIcon(group.count)}
eventHandlers={{
click: () => {
map.fitBounds(
group.markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
{
padding: [48, 48],
maxZoom: 15,
animate: true
}
);
}
}}
>
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
<div className="map-home-popup-card">
<strong>{group.count} makers en esta zona</strong>
<span>Toca el grupo para acercar y separarlos.</span>
</div>
</Popup>
</Marker>
);
})}
</>
);
}
export default function InteractiveDiscoverMap({
markers,
selectedMarkerId,
onSelectMarker,
onApplyVisibleArea,
focusRequest
}: {
markers: DiscoverMapMarker[];
selectedMarkerId?: string;
onSelectMarker: (makerId: string) => void;
onApplyVisibleArea: (makerIds: string[]) => void;
focusRequest: number;
}) {
const mapRef = useRef<LeafletMap | null>(null);
const displayMarkers = toDisplayMarkers(markers);
const focusMarkers = () => {
const map = mapRef.current;
if (!map) {
return;
}
if (markers.length === 0) {
map.setView(cordobaCenter, 12, { animate: true });
return;
}
map.fitBounds(
displayMarkers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
{
padding: [40, 40],
maxZoom: 14,
animate: true
}
);
};
const applyVisibleArea = () => {
const map = mapRef.current;
if (!map) {
onApplyVisibleArea(markers.map((marker) => marker.id));
return;
}
const bounds = map.getBounds();
const visibleIds = displayMarkers
.filter((marker) => bounds.contains([marker.displayLatitude, marker.displayLongitude]))
.map((marker) => marker.id);
onApplyVisibleArea(visibleIds);
};
useEffect(() => {
if (focusRequest > 0) {
focusMarkers();
}
}, [focusRequest]);
useEffect(() => {
const map = mapRef.current;
if (!map || !selectedMarkerId) {
return;
}
const selectedMarker = displayMarkers.find((marker) => marker.id === selectedMarkerId);
if (!selectedMarker) {
return;
}
map.flyTo([selectedMarker.displayLatitude, selectedMarker.displayLongitude], Math.max(map.getZoom(), 13), {
animate: true,
duration: 0.6
});
}, [selectedMarkerId, displayMarkers]);
return (
<section className="discover-map-stage">
<div className="discover-map-surface">
<MapContainer center={cordobaCenter} zoom={13} scrollWheelZoom className="discover-leaflet">
<RegisterMapInstance mapRef={mapRef} />
<TileLayer
attribution="&copy; OpenStreetMap contributors &copy; CARTO"
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
/>
<FitMapToMarkers markers={displayMarkers} />
<Circle
center={cordobaCenter}
radius={userApproximationRadius}
pathOptions={{
color: "#2d7cff",
weight: 2,
fillColor: "#2d7cff",
fillOpacity: 0.14
}}
/>
<CircleMarker
center={cordobaCenter}
radius={8}
pathOptions={{
color: "#ffffff",
weight: 2,
fillColor: "#2e7cff",
fillOpacity: 1
}}
/>
<ClusteredMarkerLayer
markers={displayMarkers}
selectedMarkerId={selectedMarkerId}
onSelectMarker={onSelectMarker}
/>
</MapContainer>
</div>
<div className="discover-map-overlay" aria-hidden="false">
<div className="discover-map-side-tools">
<button className="discover-map-control" type="button" aria-label="Recentrar mapa" onClick={focusMarkers}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5.4" />
<path d="M12 2v3" />
<path d="M12 19v3" />
<path d="M2 12h3" />
<path d="M19 12h3" />
</svg>
</button>
<button className="discover-map-control" type="button" aria-label="Capas del mapa">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
<path d="m12 4 8 4-8 4-8-4 8-4Z" />
<path d="m4 12 8 4 8-4" />
<path d="m4 16 8 4 8-4" />
</svg>
</button>
<button className="discover-map-radar" type="button" aria-label="Radar maker">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="2.2" />
<path d="M12 5a7 7 0 0 1 7 7" />
<path d="M12 2a10 10 0 0 1 10 10" />
<path d="M5 12a7 7 0 0 1 7-7" />
</svg>
<span>Radar Maker</span>
</button>
</div>
<button className="discover-map-cta" type="button" onClick={applyVisibleArea}>
Buscar en esta zona
</button>
</div>
</section>
);
}
@@ -0,0 +1,49 @@
"use client";
import dynamic from "next/dynamic";
export type DiscoverMapMarker = {
id: string;
slug: string;
businessName: string;
city: string;
province: string;
latitude: number;
longitude: number;
};
const InteractiveDiscoverMap = dynamic(
() => import("./InteractiveDiscoverMap"),
{
ssr: false,
loading: () => (
<section className="discover-map-stage">
<div className="discover-map-loading">Cargando mapa real...</div>
</section>
)
}
);
export default function InteractiveDiscoverMapClient({
markers,
selectedMarkerId,
onSelectMarker,
onApplyVisibleArea,
focusRequest
}: {
markers: DiscoverMapMarker[];
selectedMarkerId?: string;
onSelectMarker: (makerId: string) => void;
onApplyVisibleArea: (makerIds: string[]) => void;
focusRequest: number;
}) {
return (
<InteractiveDiscoverMap
markers={markers}
selectedMarkerId={selectedMarkerId}
onSelectMarker={onSelectMarker}
onApplyVisibleArea={onApplyVisibleArea}
focusRequest={focusRequest}
/>
);
}
@@ -0,0 +1,354 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { divIcon, type DivIcon, type Map as LeafletMap } from "leaflet";
import { Circle, CircleMarker, MapContainer, Marker, Popup, TileLayer, useMap, useMapEvents } from "react-leaflet";
type MakerMarker = {
id: string;
slug: string;
businessName: string;
city: string;
province: string;
latitude: number;
longitude: number;
};
type DisplayMarker = MakerMarker & {
displayLatitude: number;
displayLongitude: number;
radius: number;
};
type ClusterGroup = {
key: string;
count: number;
latitude: number;
longitude: number;
markers: DisplayMarker[];
};
const cordobaCenter: [number, number] = [-31.4201, -64.1888];
const userApproximationRadius = 900;
function hashValue(input: string, seed: number) {
let value = seed;
for (let index = 0; index < input.length; index += 1) {
value = (value * 33 + input.charCodeAt(index) + index) % 1000003;
}
return value;
}
function toOffset(value: number, spread: number) {
return ((value % 1000) / 999 - 0.5) * spread;
}
function toDisplayMarkers(markers: MakerMarker[]): DisplayMarker[] {
return markers.map((marker) => {
const latSeed = hashValue(marker.id, 17);
const lngSeed = hashValue(marker.slug, 29);
const radiusSeed = hashValue(marker.businessName, 43);
return {
...marker,
displayLatitude: marker.latitude + toOffset(latSeed, 0.0072),
displayLongitude: marker.longitude + toOffset(lngSeed, 0.0094),
radius: 8 + (radiusSeed % 4)
};
});
}
function getClusterRadius(zoom: number) {
if (zoom >= 15) {
return 0;
}
if (zoom >= 14) {
return 34;
}
if (zoom >= 13) {
return 42;
}
if (zoom >= 12) {
return 50;
}
if (zoom >= 11) {
return 58;
}
return 66;
}
function buildClusterGroups(markers: DisplayMarker[], map: LeafletMap, zoom: number) {
const clusterRadius = getClusterRadius(zoom);
if (clusterRadius === 0) {
return markers.map((marker) => ({
key: marker.id,
count: 1,
latitude: marker.displayLatitude,
longitude: marker.displayLongitude,
markers: [marker]
}));
}
const projectedMarkers = markers.map((marker) => ({
marker,
point: map.project([marker.displayLatitude, marker.displayLongitude], zoom)
}));
const visited = new Set<number>();
const groups: ClusterGroup[] = [];
for (let index = 0; index < projectedMarkers.length; index += 1) {
if (visited.has(index)) {
continue;
}
const queue = [index];
const memberIndexes: number[] = [];
visited.add(index);
while (queue.length > 0) {
const currentIndex = queue.shift() as number;
const currentMarker = projectedMarkers[currentIndex];
memberIndexes.push(currentIndex);
for (let candidateIndex = 0; candidateIndex < projectedMarkers.length; candidateIndex += 1) {
if (visited.has(candidateIndex)) {
continue;
}
const candidateMarker = projectedMarkers[candidateIndex];
const deltaX = currentMarker.point.x - candidateMarker.point.x;
const deltaY = currentMarker.point.y - candidateMarker.point.y;
if (Math.hypot(deltaX, deltaY) <= clusterRadius) {
visited.add(candidateIndex);
queue.push(candidateIndex);
}
}
}
const bucket = memberIndexes.map((memberIndex) => projectedMarkers[memberIndex].marker);
const latitude = bucket.reduce((sum, marker) => sum + marker.displayLatitude, 0) / bucket.length;
const longitude = bucket.reduce((sum, marker) => sum + marker.displayLongitude, 0) / bucket.length;
groups.push({
key: bucket.map((marker) => marker.id).sort().join(":"),
count: bucket.length,
latitude,
longitude,
markers: bucket
});
}
return groups;
}
function buildClusterIcon(count: number): DivIcon {
const size = count >= 10 ? 60 : count >= 5 ? 54 : 48;
const toneClass = count >= 10 ? "is-large" : count >= 5 ? "is-medium" : "is-small";
return divIcon({
className: "map-home-cluster-icon-shell",
html: `<span class="map-home-cluster-badge ${toneClass}">${count}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2]
});
}
function FitMapToMarkers({ markers }: { markers: DisplayMarker[] }) {
const map = useMap();
useEffect(() => {
if (markers.length === 0) {
map.setView(cordobaCenter, 12);
return;
}
const bounds = markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]);
map.fitBounds(bounds, {
padding: [40, 40],
maxZoom: 14
});
}, [map, markers]);
return null;
}
function ClusteredMarkerLayer({ markers }: { markers: DisplayMarker[] }) {
const map = useMap();
const [zoom, setZoom] = useState(() => map.getZoom());
useMapEvents({
zoomend() {
setZoom(map.getZoom());
}
});
const groups = buildClusterGroups(markers, map, zoom);
return (
<>
{groups.map((group) => {
if (group.count === 1) {
const marker = group.markers[0];
return (
<CircleMarker
key={marker.id}
center={[marker.displayLatitude, marker.displayLongitude]}
radius={marker.radius}
pathOptions={{
color: "#ffffff",
weight: 2,
fillColor: "#10358d",
fillOpacity: 0.95
}}
>
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
<div className="map-home-popup-card">
<strong>{marker.businessName}</strong>
<span>{marker.city}, {marker.province}</span>
<a href={`/makers/${marker.slug}`}>Ver maker</a>
</div>
</Popup>
</CircleMarker>
);
}
return (
<Marker
key={group.key}
position={[group.latitude, group.longitude]}
icon={buildClusterIcon(group.count)}
eventHandlers={{
click: () => {
map.fitBounds(
group.markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
{
padding: [48, 48],
maxZoom: 15,
animate: true
}
);
}
}}
>
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
<div className="map-home-popup-card">
<strong>{group.count} makers en esta zona</strong>
<span>Toca el grupo para acercar y separarlos.</span>
</div>
</Popup>
</Marker>
);
})}
</>
);
}
function RegisterMapInstance({ mapRef }: { mapRef: MutableRefObject<LeafletMap | null> }) {
const map = useMap();
useEffect(() => {
mapRef.current = map;
return () => {
if (mapRef.current === map) {
mapRef.current = null;
}
};
}, [map, mapRef]);
return null;
}
export default function InteractiveMakerMap({ markers }: { markers: MakerMarker[] }) {
const mapRef = useRef<LeafletMap | null>(null);
const displayMarkers = toDisplayMarkers(markers);
const focusMarkers = () => {
const map = mapRef.current;
if (!map) {
return;
}
if (markers.length === 0) {
map.setView(cordobaCenter, 12, { animate: true });
return;
}
map.fitBounds(
displayMarkers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
{
padding: [40, 40],
maxZoom: 14,
animate: true
}
);
};
return (
<section className="map-home-map">
<div className="map-home-realmap">
<MapContainer
center={cordobaCenter}
zoom={13}
scrollWheelZoom
className="map-home-leaflet"
>
<RegisterMapInstance mapRef={mapRef} />
<TileLayer
attribution='&copy; OpenStreetMap contributors &copy; CARTO'
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
/>
<FitMapToMarkers markers={displayMarkers} />
<Circle
center={cordobaCenter}
radius={userApproximationRadius}
pathOptions={{
color: "#1d67ff",
weight: 2,
fillColor: "#2e7cff",
fillOpacity: 0.1
}}
/>
<CircleMarker
center={cordobaCenter}
radius={8}
pathOptions={{
color: "#ffffff",
weight: 2,
fillColor: "#2e7cff",
fillOpacity: 1
}}
/>
<ClusteredMarkerLayer markers={displayMarkers} />
</MapContainer>
</div>
<div className="map-home-map-overlay" aria-hidden="false">
<button className="map-home-crosshair" type="button" aria-label="Recentrar mapa" onClick={focusMarkers}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5.4" />
<path d="M12 2v3" />
<path d="M12 19v3" />
<path d="M2 12h3" />
<path d="M19 12h3" />
</svg>
</button>
<button className="map-home-area-cta" type="button" onClick={focusMarkers}>
Buscar en esta area
</button>
</div>
</section>
);
}
@@ -0,0 +1,26 @@
"use client";
import dynamic from "next/dynamic";
type MakerMarker = {
id: string;
slug: string;
businessName: string;
city: string;
province: string;
latitude: number;
longitude: number;
};
const InteractiveMakerMap = dynamic(() => import("./InteractiveMakerMap"), {
ssr: false,
loading: () => (
<section className="map-home-map">
<div className="map-home-map-loading">Cargando mapa real...</div>
</section>
)
});
export default function InteractiveMakerMapClient({ markers }: { markers: MakerMarker[] }) {
return <InteractiveMakerMap markers={markers} />;
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
import { browserFetch } from "../lib/api";
import { notifyAuthChanged } from "../lib/authEvents";
export default function LoginForm({ redirectTo = "/account" }: { redirectTo?: string }) {
const [error, setError] = useState("");
const [form, setForm] = useState({ email: "", password: "" });
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
try {
const response = await browserFetch<{ user: { role: string } }>("/auth/login", {
method: "POST",
body: JSON.stringify(form)
});
notifyAuthChanged("login");
window.location.href = response.user.role === "admin" ? "/app/backoffice" : redirectTo;
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "No se pudo entrar");
}
}
return (
<form className="stack" onSubmit={onSubmit}>
<input className="field" type="email" placeholder="Correo" value={form.email} onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))} required />
<input className="field" type="password" placeholder="Contrasena" value={form.password} onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} required />
<button className="button button-primary" type="submit">Entrar</button>
{error ? <p className="muted">{error}</p> : null}
</form>
);
}
@@ -0,0 +1,174 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { browserFetch } from "../lib/api";
export type MapHomeMakerListItem = {
id: string;
slug: string;
name: string;
city: string;
province: string;
imageUrl: string;
distance: string;
rating: string;
reviewCount: number;
satisfaction: string;
tags: string[];
};
type FavoritesResponse = {
makers: Array<{ id: string; slug: string }>;
};
function loginForCurrentPage() {
const currentPath = `${window.location.pathname}${window.location.search}`;
window.location.assign(`/login?returnTo=${encodeURIComponent(currentPath)}`);
}
export function MapHomeMakerList({ makers }: { makers: MapHomeMakerListItem[] }) {
const [favoriteSlugs, setFavoriteSlugs] = useState<Set<string>>(new Set());
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
const [loadingSlug, setLoadingSlug] = useState("");
const [needsLogin, setNeedsLogin] = useState(false);
useEffect(() => {
let active = true;
async function loadFavorites() {
try {
const response = await browserFetch<FavoritesResponse>("/favorites");
if (!active) {
return;
}
setFavoriteSlugs(new Set(response.makers.map((maker) => maker.slug)));
setFavoriteIds(new Set(response.makers.map((maker) => maker.id)));
setNeedsLogin(false);
} catch (error) {
const apiError = error as Error & { status?: number };
if (active && (apiError.status === 401 || apiError.status === 403 || apiError.message.includes("Authentication"))) {
setNeedsLogin(true);
}
}
}
void loadFavorites();
return () => {
active = false;
};
}, []);
async function toggleFavorite(maker: MapHomeMakerListItem) {
if (needsLogin) {
loginForCurrentPage();
return;
}
const isFavorite = favoriteSlugs.has(maker.slug) || favoriteIds.has(maker.id);
setLoadingSlug(maker.slug);
try {
await browserFetch<{ favorited: boolean }>(`/favorites/makers/${encodeURIComponent(maker.slug)}`, {
method: isFavorite ? "DELETE" : "POST"
});
setFavoriteSlugs((current) => {
const next = new Set(current);
if (isFavorite) {
next.delete(maker.slug);
} else {
next.add(maker.slug);
}
return next;
});
setFavoriteIds((current) => {
const next = new Set(current);
if (isFavorite) {
next.delete(maker.id);
} else {
next.add(maker.id);
}
return next;
});
} catch (error) {
const apiError = error as Error & { status?: number };
if (apiError.status === 401 || apiError.status === 403 || apiError.message.includes("Authentication")) {
loginForCurrentPage();
}
} finally {
setLoadingSlug("");
}
}
if (makers.length === 0) {
return (
<div className="empty-state">
No encontramos makers con esa busqueda. Prueba otra categoria o abre la vista completa de descubrir.
</div>
);
}
return (
<div className="map-home-maker-list">
{makers.map((maker) => {
const isFavorite = favoriteSlugs.has(maker.slug) || favoriteIds.has(maker.id);
return (
<article key={maker.id} className="map-home-maker-card">
<Link
href={`/makers/${maker.slug}`}
className="map-home-maker-card-main map-home-maker-card-link"
aria-label={`Abrir perfil de ${maker.name}`}
>
<div className="map-home-maker-media">
<img className="thumb" src={maker.imageUrl} alt={maker.name} />
<span className="map-home-distance-pill">{maker.distance}</span>
</div>
<div className="map-home-maker-body">
<div className="map-home-maker-topline">
<div className="map-home-maker-brand">
<span className="map-home-maker-badge">{maker.name.slice(0, 2).toUpperCase()}</span>
<div className="map-home-maker-copy">
<div className="map-home-maker-heading">
<strong>{maker.name}</strong>
<span className="map-home-online-dot" />
</div>
<span className="map-home-maker-subline">{maker.city}, {maker.province}</span>
</div>
</div>
</div>
<div className="map-home-maker-score">
<span className="map-home-star">*</span>
<span>{maker.rating} ({maker.reviewCount})</span>
<span>|</span>
<span>{maker.satisfaction}</span>
</div>
<div className="map-home-tag-row">
{maker.tags.map((tag) => (
<span key={`${maker.id}-${tag}`} className="map-home-tag">{tag}</span>
))}
</div>
</div>
</Link>
<button
className={`map-home-bookmark ${isFavorite ? "is-active" : ""}`}
type="button"
aria-label={isFavorite ? `Quitar ${maker.name} de favoritos` : `Guardar ${maker.name} en favoritos`}
aria-pressed={isFavorite}
disabled={loadingSlug === maker.slug}
onClick={() => void toggleFavorite(maker)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 4.5h10a1 1 0 0 1 1 1V20l-6-3.2L6 20V5.5a1 1 0 0 1 1-1Z" />
</svg>
</button>
</article>
);
})}
</div>
);
}
+268
View File
@@ -0,0 +1,268 @@
import InteractiveMakerMapClient from "./InteractiveMakerMapClient";
import { MapHomeMakerList, type MapHomeMakerListItem } from "./MapHomeMakerList";
import { serverFetch } from "../lib/api";
type PublicMaker = {
id: string;
slug: string;
business_name: string;
description: string;
province: string;
city: string;
main_image_url: string | null;
avg_rating: number | string;
review_count: number;
service_count: number;
work_count: number;
public_latitude: number | null;
public_longitude: number | null;
public_locations?: Array<{
id: string;
label?: string | null;
city?: string | null;
province?: string | null;
latitude: number | null;
longitude: number | null;
}>;
categories?: string[];
};
type SearchParams = Record<string, string | string[] | undefined>;
type HomeCategory = "all" | "print-3d" | "design-3d" | "resin" | "fdm";
const quickCategories: Array<{ id: HomeCategory; label: string }> = [
{ id: "print-3d", label: "Impresion 3D" },
{ id: "design-3d", label: "Diseno 3D" },
{ id: "resin", label: "Resina" },
{ id: "fdm", label: "FDM" }
];
const preferredOrder = [
"MakerLab 3D",
"PrintCraft",
"3D Ideas",
"ImpresionAR",
"ProtoWorks",
"Resin Forge"
];
const distanceLabels = ["1,2 km", "1,8 km", "2,1 km", "2,4 km", "2,6 km", "2,9 km", "3,2 km"];
function getMakerImage(maker: PublicMaker) {
return maker.main_image_url || "/demo/maker-hero-1.svg";
}
function readParam(value: string | string[] | undefined) {
return typeof value === "string" ? value : "";
}
function getRating(value: number | string) {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function getSatisfaction(value: number | string) {
const rating = getRating(value);
return `${Math.max(92, Math.round(rating * 20))}% satisfaccion`;
}
function normalizeCategory(value: string): HomeCategory {
if (value === "print-3d" || value === "design-3d" || value === "resin" || value === "fdm") {
return value;
}
return "all";
}
function hasCategory(maker: PublicMaker, expected: string) {
return (maker.categories || []).some((category) => category.toLowerCase() === expected.toLowerCase());
}
function filterByCategory(makers: PublicMaker[], category: HomeCategory) {
if (category === "all") {
return makers;
}
if (category === "print-3d") {
return makers.filter((maker) => hasCategory(maker, "Impresion FDM") || hasCategory(maker, "Impresion Resina") || hasCategory(maker, "Prototipado"));
}
if (category === "design-3d") {
return makers.filter((maker) => hasCategory(maker, "Diseno 3D"));
}
if (category === "resin") {
return makers.filter((maker) => hasCategory(maker, "Impresion Resina"));
}
return makers.filter((maker) => hasCategory(maker, "Impresion FDM"));
}
function orderMakers(makers: PublicMaker[]) {
return [...makers].sort((left, right) => {
const leftIndex = preferredOrder.indexOf(left.business_name);
const rightIndex = preferredOrder.indexOf(right.business_name);
if (leftIndex !== -1 || rightIndex !== -1) {
return (leftIndex === -1 ? 999 : leftIndex) - (rightIndex === -1 ? 999 : rightIndex);
}
return getRating(right.avg_rating) - getRating(left.avg_rating);
});
}
function getMakerTags(maker: PublicMaker) {
const tags = new Set<string>();
for (const category of maker.categories || []) {
if (category === "Impresion FDM") {
tags.add("FDM");
}
if (category === "Impresion Resina") {
tags.add("Resina");
}
if (category === "Diseno 3D") {
tags.add("Diseno 3D");
}
if (category === "Prototipado") {
tags.add("Prototipos");
}
}
if (tags.size === 0) {
tags.add("Impresion 3D");
}
return Array.from(tags).slice(0, 3);
}
function buildHomeHref(query: string, category: HomeCategory) {
const params = new URLSearchParams();
if (query) {
params.set("q", query);
}
if (category !== "all") {
params.set("category", category);
}
const serialized = params.toString();
return serialized ? `/?${serialized}` : "/";
}
function buildDiscoverHref(query: string, category: HomeCategory) {
const params = new URLSearchParams();
if (query) {
params.set("q", query);
}
if (category === "design-3d") {
params.set("serviceCategory", "Diseno 3D");
}
if (category === "resin") {
params.set("serviceCategory", "Impresion Resina");
}
if (category === "fdm") {
params.set("serviceCategory", "Impresion FDM");
}
const serialized = params.toString();
return serialized ? `/discover?${serialized}` : "/discover";
}
export async function MapHomeScreen({ searchParams = {} }: { searchParams?: SearchParams }) {
const query = readParam(searchParams.q);
const activeCategory = normalizeCategory(readParam(searchParams.category));
const requestParams = new URLSearchParams();
if (query) {
requestParams.set("q", query);
}
const data = await serverFetch<{ makers: PublicMaker[] }>(`/makers?${requestParams.toString()}`);
const makers = orderMakers(filterByCategory(data.makers, activeCategory));
const visibleMakers = makers.slice(0, 10);
const makerListItems: MapHomeMakerListItem[] = visibleMakers.map((maker, index) => ({
id: maker.id,
slug: maker.slug,
name: maker.business_name,
city: maker.city,
province: maker.province,
imageUrl: getMakerImage(maker),
distance: distanceLabels[index % distanceLabels.length],
rating: getRating(maker.avg_rating).toFixed(1),
reviewCount: Math.max(maker.review_count * 32, 12),
satisfaction: getSatisfaction(maker.avg_rating),
tags: getMakerTags(maker)
}));
const discoverHref = buildDiscoverHref(query, activeCategory);
const mapMarkers = makers
.flatMap((maker) => {
const publicLocations = (maker.public_locations || []).filter((location) => location.latitude !== null && location.longitude !== null);
const locations = publicLocations.length
? publicLocations
: maker.public_latitude !== null && maker.public_longitude !== null
? [{ id: maker.id, city: maker.city, province: maker.province, latitude: maker.public_latitude, longitude: maker.public_longitude }]
: [];
return locations.map((location, index) => ({
id: `${maker.id}:${location.id || index}`,
slug: maker.slug,
businessName: maker.business_name,
city: location.city || maker.city,
province: location.province || maker.province,
latitude: location.latitude as number,
longitude: location.longitude as number
}));
});
return (
<section className="map-home-shell">
<div className="map-home-device">
<form action="/" className="map-home-search-stack">
<div className="map-home-search-row">
<label className="map-home-searchbar">
<span className="map-home-search-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="6.6" />
<path d="m20 20-3.6-3.6" />
</svg>
</span>
<input name="q" placeholder="Buscar makers, servicios, trabajos..." defaultValue={query} />
</label>
{activeCategory !== "all" ? <input type="hidden" name="category" value={activeCategory} /> : null}
<a href={discoverHref} className="map-home-filter-link" aria-label="Abrir filtros">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h16" />
<path d="M7 12h10" />
<path d="M10 17h4" />
</svg>
</a>
</div>
</form>
<div className="map-home-chip-row">
<a href={discoverHref} className="map-home-chip map-home-chip-primary">Filtros</a>
{quickCategories.map((category) => (
<a
key={category.id}
href={buildHomeHref(query, category.id)}
className={`map-home-chip ${activeCategory === category.id ? "active" : ""}`}
>
{category.label}
</a>
))}
</div>
<InteractiveMakerMapClient markers={mapMarkers} />
<section className="map-home-results">
<div className="map-home-results-head">
<div className="stack" style={{ gap: 4 }}>
<strong className="map-home-results-title">Makers cerca de ti</strong>
<span className="mini-note">Cordoba, Argentina | 1,2 km</span>
</div>
<a href={discoverHref} className="map-home-view-all">Ver todos</a>
</div>
<MapHomeMakerList makers={makerListItems} />
</section>
</div>
</section>
);
}
+637
View File
@@ -0,0 +1,637 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { browserFetch } from "../lib/api";
type User = {
id: string;
email: string;
role: string;
};
type MakerProfile = {
id: string;
slug: string;
business_name: string | null;
status: string;
};
type MessageAccess = "customer" | "maker" | "admin";
type InquiryStatus = "open" | "agreed" | "completed" | "rejected" | "incident";
type Conversation = {
id: string;
business_name: string;
maker_slug: string;
customer_name: string;
customer_id: string;
need_text: string;
size_text: string | null;
has_model: boolean;
material_preference: string | null;
quantity: number;
urgency: string;
delivery_type: string;
source_type: string;
status: InquiryStatus;
rejection_reason: string | null;
proposal_price_cents: number | null;
proposal_quantity: number | null;
proposal_lead_time_days: number | null;
proposal_note: string | null;
proposal_created_at: string | null;
agreed_at: string | null;
maker_completed_at: string | null;
customer_completed_at: string | null;
completed_at: string | null;
incident_reason: string | null;
incident_detail: string | null;
last_message: string | null;
last_message_at: string | null;
last_sender_user_id: string | null;
message_count: number;
created_at: string;
};
type Message = {
id: string;
sender_user_id: string;
full_name: string;
body: string;
created_at: string;
};
type ConversationDetail = {
inquiry: Conversation & {
maker_user_id: string;
customer_name: string;
};
messages: Message[];
};
type FilterKey = "all" | "consultas" | "active";
const incidentOptions = [
"El trabajo no fue entregado",
"El resultado no fue el acordado",
"Hubo un problema con el plazo",
"El maker no responde",
"Otro"
];
function formatTime(value?: string | null) {
if (!value) {
return "";
}
return new Intl.DateTimeFormat("es-AR", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
}
function formatMoney(value?: number | null) {
if (!value) {
return "";
}
return `$${Math.round(value / 100).toLocaleString("es-AR")}`;
}
function statusInfo(conversation: Conversation, userId?: string) {
if (conversation.status === "completed") {
return { label: "Finalizado", tone: "success", icon: "✓" };
}
if (conversation.status === "rejected") {
return { label: "Rechazado", tone: "danger", icon: "×" };
}
if (conversation.status === "incident") {
return { label: "Incidencia", tone: "danger", icon: "!" };
}
if (conversation.status === "agreed") {
return { label: "Trabajo acordado", tone: "info", icon: "↔" };
}
if (conversation.message_count <= 1) {
return { label: "Nueva consulta", tone: "info", icon: "✉" };
}
if (conversation.last_sender_user_id === userId) {
return { label: "Esperando respuesta", tone: "warning", icon: "○" };
}
return { label: "En conversacion", tone: "success", icon: "●" };
}
function deliveryLabel(value: string) {
return value === "local" ? "Retiro / local" : "Envio";
}
function urgencyLabel(value: string) {
if (value === "high") {
return "Urgente";
}
if (value === "low") {
return "Sin urgencia";
}
return "Esta semana";
}
export default function MessagesClient() {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [messageAccess, setMessageAccess] = useState<MessageAccess>("customer");
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<ConversationDetail | null>(null);
const [filter, setFilter] = useState<FilterKey>("all");
const [reply, setReply] = useState("");
const [review, setReview] = useState({ rating: 5, comment: "" });
const [incident, setIncident] = useState({ reason: incidentOptions[0], detail: "" });
const [showActions, setShowActions] = useState(false);
const [status, setStatus] = useState("");
async function loadConversations(preferredId?: string, access = messageAccess) {
if (access === "admin") {
setConversations([]);
setSelectedId("");
setDetail(null);
return;
}
const scope = access === "maker" ? "maker" : "customer";
const data = await browserFetch<{ conversations: Conversation[] }>(`/account/conversations?scope=${scope}`);
setConversations(data.conversations);
setSelectedId((current) => preferredId || current || data.conversations[0]?.id || "");
}
async function refreshDetail(id = selectedId) {
if (!id) {
return;
}
const data = await browserFetch<ConversationDetail>(`/inquiries/${id}`);
setDetail(data);
}
async function refreshAll(id = selectedId) {
await Promise.all([loadConversations(id), refreshDetail(id)]);
}
useEffect(() => {
let active = true;
async function load() {
setLoading(true);
try {
const me = await browserFetch<{ user: User | null; makerProfile: MakerProfile | null }>("/auth/me");
if (!active) {
return;
}
setUser(me.user);
const access: MessageAccess = me.user?.role === "admin"
? "admin"
: me.makerProfile && me.makerProfile.status !== "draft"
? "maker"
: "customer";
setMessageAccess(access);
if (me.user) {
await loadConversations(undefined, access);
}
} catch (error) {
if (active) {
setStatus(error instanceof Error ? error.message : "No se pudo cargar mensajes");
}
} finally {
if (active) {
setLoading(false);
}
}
}
void load();
return () => {
active = false;
};
}, []);
useEffect(() => {
if (!selectedId || !user) {
setDetail(null);
return;
}
let active = true;
async function loadDetail() {
try {
const data = await browserFetch<ConversationDetail>(`/inquiries/${selectedId}`);
if (active) {
setDetail(data);
setShowActions(false);
}
} catch (error) {
if (active) {
setStatus(error instanceof Error ? error.message : "No se pudo abrir la conversacion");
}
}
}
void loadDetail();
return () => {
active = false;
};
}, [selectedId, user]);
const filteredConversations = useMemo(() => {
if (filter === "consultas") {
return conversations.filter((conversation) => conversation.status === "open");
}
if (filter === "active") {
return conversations.filter((conversation) => ["open", "agreed", "incident"].includes(conversation.status));
}
return conversations;
}, [conversations, filter]);
const accessCopy = messageAccess === "maker"
? {
eyebrow: "Consultas maker",
title: "Bandeja de clientes",
empty: "No hay consultas recibidas para este filtro.",
auth: "Inicia sesion como maker para responder consultas de clientes."
}
: {
eyebrow: "Mensajes",
title: "Bandeja de consultas",
empty: "No hay conversaciones para este filtro.",
auth: "Inicia sesion para ver tus consultas y respuestas de los makers."
};
async function sendReply(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = reply.trim();
if (!selectedId || !body) {
return;
}
setStatus("Enviando...");
try {
await browserFetch(`/inquiries/${selectedId}/messages`, {
method: "POST",
body: JSON.stringify({ body })
});
setReply("");
setStatus("");
await refreshAll();
} catch (error) {
setStatus(error instanceof Error ? error.message : "No se pudo enviar el mensaje");
}
}
async function acceptProposal() {
if (!selectedId) {
return;
}
setStatus("Aceptando propuesta...");
try {
await browserFetch(`/inquiries/${selectedId}/accept-proposal`, { method: "POST" });
setStatus("");
await refreshAll();
} catch (error) {
setStatus(error instanceof Error ? error.message : "No se pudo aceptar la propuesta");
}
}
async function confirmCompleted() {
if (!selectedId) {
return;
}
setStatus("Confirmando finalizacion...");
try {
await browserFetch(`/inquiries/${selectedId}/complete`, { method: "POST" });
setStatus("");
await refreshAll();
} catch (error) {
setStatus(error instanceof Error ? error.message : "No se pudo confirmar");
}
}
async function submitReview(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!detail) {
return;
}
setStatus("Publicando resena...");
try {
await browserFetch("/reviews", {
method: "POST",
body: JSON.stringify({
inquiryId: detail.inquiry.id,
ratingOverall: review.rating,
ratingQuality: review.rating,
ratingCommunication: review.rating,
ratingValue: review.rating,
comment: review.comment
})
});
setReview({ rating: 5, comment: "" });
setStatus("Resena publicada.");
} catch (error) {
setStatus(error instanceof Error ? error.message : "No se pudo publicar la resena");
}
}
async function submitIncident(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedId) {
return;
}
setStatus("Reportando incidencia...");
try {
await browserFetch(`/inquiries/${selectedId}/incident`, {
method: "POST",
body: JSON.stringify(incident)
});
setIncident({ reason: incidentOptions[0], detail: "" });
setStatus("");
await refreshAll();
} catch (error) {
setStatus(error instanceof Error ? error.message : "No se pudo reportar la incidencia");
}
}
if (loading) {
return (
<section className="messages-shell">
<div className="messages-loading">
<span />
<span />
<span />
</div>
</section>
);
}
if (!user) {
return (
<section className="messages-shell">
<div className="messages-auth-card">
<h1>Mensajes</h1>
<p>{accessCopy.auth}</p>
<Link href="/login?returnTo=%2Fmessages" className="button button-primary">Entrar</Link>
</div>
</section>
);
}
if (messageAccess === "admin") {
return (
<section className="messages-shell">
<div className="messages-auth-card">
<span className="eyebrow">Mensajes administrador</span>
<h1>Bandeja administrativa</h1>
<p>Las consultas operativas, incidencias y reportes del administrador se gestionan desde el backoffice para mantener trazabilidad.</p>
<div className="home-actions">
<a href="/app/backoffice/cases" className="button button-primary">Abrir casos</a>
<a href="/app/backoffice" className="button button-secondary">Ir al backoffice</a>
</div>
</div>
</section>
);
}
const current = detail?.inquiry;
const currentInfo = current ? statusInfo(current, user.id) : null;
const currentDisplayName = current
? messageAccess === "maker" ? current.customer_name : current.business_name
: "";
const whatsappText = current
? encodeURIComponent(`Hola, soy ${detail.inquiry.customer_name}. Te contacto por la consulta "${current.need_text}".`)
: "";
return (
<section className="messages-shell messages-flow-shell">
<aside className="messages-list">
<div className="messages-head">
<div>
<span className="eyebrow">{accessCopy.eyebrow}</span>
<h1>{accessCopy.title}</h1>
</div>
<span className="badge">{conversations.length}</span>
</div>
<div className="messages-filter-row" aria-label="Filtros de mensajes">
{[
["all", "Todos"],
["consultas", "Consultas"],
["active", "En curso"]
].map(([key, label]) => (
<button
key={key}
type="button"
className={`messages-filter-chip ${filter === key ? "is-active" : ""}`}
onClick={() => setFilter(key as FilterKey)}
>
{label}
</button>
))}
</div>
<div className="messages-conversation-list">
{filteredConversations.length === 0 ? (
<div className="empty-state">{accessCopy.empty}</div>
) : null}
{filteredConversations.map((conversation) => {
const info = statusInfo(conversation, user.id);
const displayName = messageAccess === "maker" ? conversation.customer_name : conversation.business_name;
return (
<button
key={conversation.id}
type="button"
className={`messages-conversation ${conversation.id === selectedId ? "is-active" : ""}`}
onClick={() => setSelectedId(conversation.id)}
>
<span className="messages-avatar">{displayName.slice(0, 2).toUpperCase()}</span>
<span className="messages-conversation-copy">
<strong>{displayName}</strong>
<span>{conversation.last_message || conversation.need_text}</span>
<span className={`messages-state-text is-${info.tone}`}>{info.icon} {info.label}</span>
</span>
<span className="messages-time">{formatTime(conversation.last_message_at || conversation.created_at)}</span>
</button>
);
})}
</div>
</aside>
<section className="messages-thread">
{current && detail ? (
<>
<div className="messages-thread-head">
<div className="messages-maker-title">
<span className="messages-avatar small">{currentDisplayName.slice(0, 2).toUpperCase()}</span>
<div>
<h2>{currentDisplayName}</h2>
<span className={`messages-state-text is-${currentInfo?.tone}`}>{currentInfo?.icon} {currentInfo?.label}</span>
</div>
</div>
<button className="messages-icon-button" type="button" onClick={() => setShowActions((value) => !value)} aria-label="Mas acciones">
⋮
</button>
</div>
<article className="messages-context-card">
<div className="messages-context-main">
<span className="eyebrow">Consulta estructurada</span>
<strong>{current.need_text}</strong>
<Link href={`/makers/${current.maker_slug}`}>Ver perfil del maker</Link>
</div>
<div className="messages-context-grid">
<span>Cantidad <strong>{current.quantity || 1}</strong></span>
<span>Urgencia <strong>{urgencyLabel(current.urgency)}</strong></span>
<span>Entrega <strong>{deliveryLabel(current.delivery_type)}</strong></span>
<span>Material <strong>{current.material_preference || "A definir"}</strong></span>
<span>Medidas <strong>{current.size_text || "Sin medidas"}</strong></span>
<span>Adjuntos <strong>{current.has_model ? "Con archivo" : "Sin archivo"}</strong></span>
</div>
</article>
{showActions ? (
<div className="messages-actions-panel">
<button type="button" onClick={() => setReply((value) => value || "Te comparto las medidas actualizadas.")}>Compartir medidas</button>
<button type="button" onClick={() => setReply((value) => value || "Adjunto fotos de referencia para que lo revises.")}>Anadir fotos</button>
<a href={`https://wa.me/?text=${whatsappText}`} target="_blank" rel="noreferrer">Continuar por WhatsApp</a>
<a href={`mailto:?subject=Consulta Makers3D&body=${whatsappText}`}>Continuar por email</a>
</div>
) : null}
<div className="messages-bubbles">
{detail.messages.map((message) => {
const isMine = message.sender_user_id === user.id;
return (
<article key={message.id} className={`messages-bubble ${isMine ? "is-mine" : "is-maker"}`}>
<div className="messages-bubble-top">
<strong>{isMine ? "Tu" : currentDisplayName}</strong>
<span>{formatTime(message.created_at)}</span>
</div>
<p>{message.body}</p>
</article>
);
})}
{current.proposal_price_cents ? (
<article className="messages-system-card">
<span className="messages-system-icon">↔</span>
<div>
<strong>{current.status === "agreed" || current.status === "completed" ? "Trabajo acordado" : "Propuesta de trabajo"}</strong>
<dl>
<div><dt>Trabajo</dt><dd>{current.need_text}</dd></div>
<div><dt>Cantidad</dt><dd>{current.proposal_quantity || current.quantity || 1}</dd></div>
<div><dt>Precio</dt><dd>{formatMoney(current.proposal_price_cents)}</dd></div>
<div><dt>Plazo</dt><dd>{current.proposal_lead_time_days} dias</dd></div>
</dl>
{current.proposal_note ? <p>{current.proposal_note}</p> : null}
{current.status === "open" ? (
<button className="button button-primary" type="button" onClick={acceptProposal}>Aceptar propuesta</button>
) : null}
</div>
</article>
) : null}
{current.status === "completed" ? (
<article className="messages-system-card success">
<span className="messages-system-icon">✓</span>
<div>
<strong>Trabajo finalizado</strong>
<p>Ambas partes confirmaron que el trabajo fue completado.</p>
<small>Finalizado el {formatTime(current.completed_at)}</small>
</div>
</article>
) : null}
{current.status === "rejected" ? (
<article className="messages-system-card danger">
<span className="messages-system-icon">×</span>
<div>
<strong>Consulta rechazada</strong>
<p>{current.rejection_reason || "El maker no puede realizar este trabajo."}</p>
<Link href="/discover" className="button button-primary">Ver mas alternativas</Link>
</div>
</article>
) : null}
{current.status === "incident" ? (
<article className="messages-system-card danger">
<span className="messages-system-icon">!</span>
<div>
<strong>Incidencia abierta</strong>
<p>{current.incident_reason}: {current.incident_detail}</p>
</div>
</article>
) : null}
</div>
<div className="messages-work-actions">
{current.status === "agreed" || current.status === "open" ? (
<button className="button button-secondary" type="button" onClick={confirmCompleted}>
Marcar como finalizado
</button>
) : null}
{current.status !== "incident" && current.status !== "rejected" ? (
<button className="button button-secondary" type="button" onClick={() => setIncident((value) => ({ ...value, detail: value.detail || "Necesito ayuda con esta consulta." }))}>
Reportar problema
</button>
) : null}
</div>
{current.status === "completed" ? (
<form className="messages-review-box" onSubmit={submitReview}>
<strong>Dejar resena verificada</strong>
<div className="messages-stars" aria-label="Puntuacion">
{[1, 2, 3, 4, 5].map((star) => (
<button key={star} type="button" className={review.rating >= star ? "is-active" : ""} onClick={() => setReview((value) => ({ ...value, rating: star }))}>★</button>
))}
</div>
<textarea value={review.comment} onChange={(event) => setReview((value) => ({ ...value, comment: event.target.value }))} placeholder="Cuenta como fue tu experiencia..." required minLength={10} />
<button className="button button-primary" type="submit">Publicar resena</button>
</form>
) : null}
{incident.detail ? (
<form className="messages-incident-box" onSubmit={submitIncident}>
<strong>Cuentanos que ocurrio</strong>
<select value={incident.reason} onChange={(event) => setIncident((value) => ({ ...value, reason: event.target.value }))}>
{incidentOptions.map((option) => <option key={option}>{option}</option>)}
</select>
<textarea value={incident.detail} onChange={(event) => setIncident((value) => ({ ...value, detail: event.target.value }))} placeholder="Danos mas detalles..." required minLength={5} />
<button className="button button-primary" type="submit">Enviar reporte</button>
</form>
) : null}
<form className="messages-composer" onSubmit={sendReply}>
<button type="button" className="messages-plus-button" onClick={() => setShowActions((value) => !value)} aria-label="Acciones">+</button>
<input
value={reply}
onChange={(event) => setReply(event.target.value)}
placeholder="Escribe un mensaje..."
aria-label="Mensaje"
/>
<button className="button button-primary" type="submit">Enviar</button>
</form>
</>
) : (
<div className="empty-state">Selecciona una conversacion para ver el detalle.</div>
)}
{status ? <p className="messages-status">{status}</p> : null}
</section>
</section>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
type DockItem = {
href: string;
label: string;
isActive: (pathname: string) => boolean;
icon: ReactNode;
};
function DockIcon({ children }: { children: ReactNode }) {
return (
<span className="mobile-link-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
</span>
);
}
const dockItems: DockItem[] = [
{
href: "/",
label: "Mapa",
isActive: (pathname) => pathname === "/",
icon: <DockIcon><path d="M12 20s6-5.2 6-10a6 6 0 1 0-12 0c0 4.8 6 10 6 10Z" /><circle cx="12" cy="10" r="2.2" /></DockIcon>
},
{
href: "/discover",
label: "Descubrir",
isActive: (pathname) => pathname === "/discover" || pathname === "/results",
icon: <DockIcon><circle cx="11" cy="11" r="6.3" /><path d="m20 20-3.4-3.4" /></DockIcon>
},
{
href: "/messages",
label: "Mensajes",
isActive: (pathname) => pathname.startsWith("/messages") || pathname.startsWith("/account/inbox"),
icon: <DockIcon><path d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v7A2.5 2.5 0 0 1 17.5 16H10l-4.5 4v-4H6.5A2.5 2.5 0 0 1 4 13.5v-7Z" /></DockIcon>
},
{
href: "/favorites",
label: "Favoritos",
isActive: (pathname) => pathname.startsWith("/favorites"),
icon: <DockIcon><path d="m12 20-1.35-1.23C5.4 14 2 10.92 2 7.1 2 4.48 4.07 2.5 6.6 2.5c1.64 0 3.22.83 4.2 2.14A5.16 5.16 0 0 1 15 2.5c2.53 0 4.6 1.98 4.6 4.6 0 3.82-3.4 6.9-8.65 11.67L12 20Z" /></DockIcon>
},
{
href: "/profile",
label: "Perfil",
isActive: (pathname) => pathname.startsWith("/profile") || pathname === "/account",
icon: <DockIcon><path d="M19 20a7 7 0 0 0-14 0" /><circle cx="12" cy="8" r="4" /></DockIcon>
}
];
export default function MobileAppDock() {
const pathname = usePathname();
if (
pathname.startsWith("/app/backoffice")
|| pathname.startsWith("/admin")
) {
return null;
}
return (
<nav className="mobile-dock" aria-label="Navegacion principal movil">
{dockItems.map((item) => (
<a key={item.href} href={item.href} className={`mobile-link ${item.isActive(pathname) ? "active" : ""}`}>
{item.icon}
<span>{item.label}</span>
</a>
))}
</nav>
);
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { browserFetch } from "../lib/api";
import { logoutByNavigation } from "../lib/logout";
type MeResponse = {
user: { id: string; email: string; role: string } | null;
makerProfile: { id: string; slug: string; business_name: string | null; status: string } | null;
};
export default function ProfileClient() {
const [session, setSession] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState("");
useEffect(() => {
let active = true;
async function loadSession() {
try {
const response = await browserFetch<MeResponse>("/auth/me");
if (active) {
setSession(response);
}
} catch (error) {
if (active) {
setStatus(error instanceof Error ? error.message : "No se pudo cargar la sesion");
setSession({ user: null, makerProfile: null });
}
} finally {
if (active) {
setLoading(false);
}
}
}
void loadSession();
return () => {
active = false;
};
}, []);
useEffect(() => {
if (session?.user?.role === "admin") {
window.location.replace("/app/backoffice");
}
}, [session?.user?.role]);
function logout() {
logoutByNavigation();
}
if (loading) {
return (
<section className="profile-account-card stack">
<h1 className="section-title">Cargando perfil</h1>
<p className="muted">Validando tu sesion para mostrar el acceso correcto.</p>
</section>
);
}
if (!session?.user) {
return (
<section className="profile-account-card stack">
<h1 className="section-title">Necesitas iniciar sesion</h1>
<p className="muted">Entra con un usuario demo para probar cliente, maker o administrador.</p>
<Link href="/login?returnTo=%2Fprofile" className="button button-primary">Entrar</Link>
</section>
);
}
if (session.user.role === "admin") {
return (
<section className="workspace-main-card stack">
<span className="eyebrow">Backoffice</span>
<h1 className="section-title">Redirigiendo al panel administrador</h1>
<p className="muted">La cuenta superadmin no usa perfil maker ni perfil cliente.</p>
<button className="button button-secondary" type="button" onClick={logout}>Cerrar sesion</button>
{status ? <p className="muted">{status}</p> : null}
</section>
);
}
if (session.makerProfile && session.makerProfile.status !== "draft") {
const makerName = session.makerProfile.business_name || "Tu espacio Maker";
return (
<section className="profile-account-card stack">
<div className="profile-identity">
<span className="profile-avatar">{makerName.slice(0, 2).toUpperCase()}</span>
<div>
<span className="eyebrow">Perfil maker</span>
<h1 className="section-title">{makerName}</h1>
<p className="muted">{session.user.email}</p>
</div>
</div>
<div className="profile-status-grid">
<div className="stat-tile"><span className="stat-value">Activo</span><span className="mini-note">Estado de cuenta</span></div>
<div className="stat-tile"><span className="stat-value">{session.makerProfile.status}</span><span className="mini-note">Perfil publico</span></div>
</div>
<div className="profile-action-grid">
<Link href="/account" className="profile-action-tile">
<strong>Mi espacio maker</strong>
<span>Dashboard, trabajos, servicios y reputacion.</span>
</Link>
<Link href="/account/inbox" className="profile-action-tile">
<strong>Consultas recibidas</strong>
<span>Mensajes de clientes y propuestas de trabajo.</span>
</Link>
<Link href={`/makers/${session.makerProfile.slug}`} className="profile-action-tile">
<strong>Ver perfil publico</strong>
<span>Asi te ven clientes y proveedores.</span>
</Link>
<Link href="/favorites" className="profile-action-tile">
<strong>Favoritos</strong>
<span>Makers y trabajos que guardaste.</span>
</Link>
</div>
<button className="button button-secondary" type="button" onClick={logout}>Cerrar sesion</button>
{status ? <p className="muted">{status}</p> : null}
</section>
);
}
return (
<section className="profile-account-card stack client-profile-card">
<div className="profile-identity">
<span className="profile-avatar">CL</span>
<div>
<span className="eyebrow">Perfil cliente</span>
<h1 className="section-title">Tu cuenta de cliente</h1>
<p className="muted">{session.user.email}</p>
</div>
</div>
<div className="profile-action-grid">
<Link href="/messages" className="profile-action-tile">
<strong>Mensajes</strong>
<span>Tus consultas enviadas a makers y sus respuestas.</span>
</Link>
<Link href="/favorites" className="profile-action-tile">
<strong>Favoritos</strong>
<span>Makers y trabajos guardados para revisar despues.</span>
</Link>
<Link href="/account" className="profile-action-tile">
<strong>Crear mi espacio maker</strong>
<span>Solo si tambien queres publicar servicios como maker.</span>
</Link>
</div>
<button className="button button-secondary" type="button" onClick={logout}>Cerrar sesion</button>
{status ? <p className="muted">{status}</p> : null}
</section>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
import { browserFetch } from "../lib/api";
import { notifyAuthChanged } from "../lib/authEvents";
export default function RegisterForm({ redirectTo = "/account" }: { redirectTo?: string }) {
const [error, setError] = useState("");
const [form, setForm] = useState({ fullName: "", email: "", password: "" });
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
try {
await browserFetch("/auth/register", {
method: "POST",
body: JSON.stringify(form)
});
notifyAuthChanged("login");
window.location.href = redirectTo;
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "No se pudo crear la cuenta");
}
}
return (
<form className="stack" onSubmit={onSubmit}>
<input className="field" placeholder="Nombre completo" value={form.fullName} onChange={(event) => setForm((current) => ({ ...current, fullName: event.target.value }))} required />
<input className="field" type="email" placeholder="Correo" value={form.email} onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))} required />
<input className="field" type="password" placeholder="Contrasena" value={form.password} onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} required />
<button className="button button-primary" type="submit">Crear cuenta</button>
{error ? <p className="muted">{error}</p> : null}
</form>
);
}
@@ -0,0 +1,17 @@
"use client";
import { useEffect } from "react";
export function ServiceWorkerRegister() {
useEffect(() => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return;
}
navigator.serviceWorker.register("/sw.js").catch(() => {
// The app works without offline support, so registration failures stay silent in the demo MVP.
});
}, []);
return null;
}
@@ -0,0 +1,93 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import FavoriteToggle from "./FavoriteToggle";
type WorkHeroGalleryProps = {
gallery: string[];
title: string;
workId: string;
shareUrl?: string;
};
export default function WorkHeroGallery({ gallery, title, workId, shareUrl }: WorkHeroGalleryProps) {
const router = useRouter();
const galleryRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
function updateActiveSlide() {
const galleryNode = galleryRef.current;
if (!galleryNode) {
return;
}
const nextIndex = Math.round(galleryNode.scrollLeft / Math.max(galleryNode.clientWidth, 1));
setActiveIndex(Math.min(Math.max(nextIndex, 0), gallery.length - 1));
}
function goBack() {
if (typeof window !== "undefined" && window.history.length > 1) {
router.back();
return;
}
router.push("/discover");
}
async function shareWork() {
const url = shareUrl || (typeof window !== "undefined" ? window.location.href : "");
if (!url) {
return;
}
if (typeof navigator !== "undefined" && navigator.share) {
await navigator.share({
title,
text: `Mira este trabajo que encontre en Makers3D: ${title}`,
url
}).catch(() => undefined);
return;
}
await navigator.clipboard?.writeText(url).catch(() => undefined);
}
return (
<section className="work-detail-hero">
<div ref={galleryRef} className="work-detail-hero-gallery" onScroll={updateActiveSlide} aria-label={`Galeria de ${title}`}>
{gallery.map((image, index) => (
<div key={`${image}-${index}`} className="work-detail-hero-slide">
<img src={image} alt={`${title} - foto ${index + 1}`} />
</div>
))}
</div>
{gallery.length > 1 ? (
<div className="work-detail-hero-dots" aria-hidden="true">
{gallery.map((image, index) => (
<span key={`${image}-dot-${index}`} className={index === activeIndex ? "is-active" : ""} />
))}
</div>
) : null}
<div className="work-detail-top-actions">
<button className="work-detail-circle-action" type="button" onClick={goBack} aria-label="Volver">&lt;</button>
<div className="work-detail-action-pair">
<FavoriteToggle targetType="work" targetId={workId} compact icon="bookmark" />
<button className="work-detail-circle-action" type="button" onClick={() => void shareWork()} aria-label="Compartir trabajo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="m8.6 10.5 6.8-4" />
<path d="m8.6 13.5 6.8 4" />
</svg>
</button>
</div>
</div>
<span className="work-detail-gallery-count">{activeIndex + 1}/{gallery.length}</span>
</section>
);
}
@@ -0,0 +1,63 @@
"use client";
import { useMemo, useState } from "react";
type WorkShareActionsProps = {
title: string;
url: string;
};
export default function WorkShareActions({ title, url }: WorkShareActionsProps) {
const [status, setStatus] = useState("");
const shareText = useMemo(
() => `Mira esto que encontre en Makers3D: ${title}`,
[title]
);
const whatsappHref = `https://wa.me/?text=${encodeURIComponent(`${shareText}\n${url}`)}`;
async function copyLink() {
try {
await navigator.clipboard.writeText(url);
setStatus("Enlace copiado.");
} catch {
setStatus("No se pudo copiar. Manten presionado el enlace.");
}
}
async function shareWork() {
if (navigator.share) {
try {
await navigator.share({
title,
text: shareText,
url
});
setStatus("Listo para compartir.");
return;
} catch (error) {
const shareError = error as Error;
if (shareError.name === "AbortError") {
return;
}
}
}
await copyLink();
}
return (
<div className="work-detail-share-actions">
<button type="button" className="button button-primary" onClick={shareWork}>
Compartir
</button>
<a className="work-detail-share-option" href={whatsappHref} target="_blank" rel="noreferrer">
WhatsApp
</a>
<button type="button" className="work-detail-share-option" onClick={copyLink}>
Copiar enlace
</button>
{status ? <span className="work-detail-share-status">{status}</span> : null}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import InquiryWizardClient from "../../components/InquiryWizardClient";
export default async function NewInquiryPage({
searchParams
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const makerId = typeof params?.makerId === "string" ? params.makerId : "";
const sourceType = typeof params?.sourceType === "string" ? params.sourceType as "profile" | "service" | "work" : "profile";
const sourceId = typeof params?.sourceId === "string" ? params.sourceId : "";
const makerName = typeof params?.makerName === "string" ? params.makerName : "Maker";
const contextTitle = typeof params?.contextTitle === "string" ? params.contextTitle : "Consulta nueva";
const returnTo = typeof params?.returnTo === "string" ? params.returnTo : "/";
return (
<main className="page-shell wizard-page">
<InquiryWizardClient
makerId={makerId}
sourceType={sourceType}
sourceId={sourceId || undefined}
makerName={makerName}
contextTitle={contextTitle}
returnTo={returnTo}
/>
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { DiscoverResultsScreen } from "../components/DiscoverResultsScreen";
export default async function DiscoverPage({
searchParams
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
return (
<main className="page-shell discover-page">
<DiscoverResultsScreen searchParams={params} />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import FavoritesClient from "../components/FavoritesClient";
export default function FavoritesPage() {
return (
<main className="page-shell favorites-page">
<FavoritesClient />
</main>
);
}
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
import type { Metadata, Viewport } from "next";
import Link from "next/link";
import { Space_Grotesk, Manrope } from "next/font/google";
import AuthNav from "./components/AuthNav";
import AuthSessionSync from "./components/AuthSessionSync";
import MobileAppDock from "./components/MobileAppDock";
import { ServiceWorkerRegister } from "./components/ServiceWorkerRegister";
import "leaflet/dist/leaflet.css";
import "./globals.css";
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
variable: "--font-head"
});
const manrope = Manrope({
subsets: ["latin"],
variable: "--font-body"
});
export const metadata: Metadata = {
title: "Makers3D",
description: "Descubre, evalua y contacta makers 3D en Argentina.",
applicationName: "Makers3D",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "Makers3D"
},
icons: {
icon: "/icon.svg",
apple: "/icon.svg"
}
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: "#09111d"
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es" className={`${spaceGrotesk.variable} ${manrope.variable}`}>
<body>
<ServiceWorkerRegister />
<AuthSessionSync />
<header className="topbar">
<div className="topbar-inner">
<Link href="/" className="brand">
<span className="brand-mark" />
<span>
Makers3D
<span className="brand-note">MVP operativo</span>
</span>
</Link>
<div className="topbar-search">
<span>Buscar maker, trabajo, servicio o incidencia...</span>
<span>CTRL K</span>
</div>
<AuthNav />
</div>
</header>
{children}
<MobileAppDock />
</body>
</html>
);
}
+37
View File
@@ -0,0 +1,37 @@
export const browserApiBase = process.env.NEXT_PUBLIC_API_BASE_URL || "/api/v1";
const internalApiBase = process.env.INTERNAL_API_URL || "http://api:4000/api/v1";
export async function serverFetch<T>(path: string): Promise<T> {
const response = await fetch(`${internalApiBase}${path}`, {
cache: "no-store"
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json() as Promise<T>;
}
export async function browserFetch<T>(path: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers);
const isFormData = typeof FormData !== "undefined" && options?.body instanceof FormData;
if (options?.body && !isFormData && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(`${browserApiBase}${path}`, {
...options,
credentials: "include",
cache: "no-store",
headers
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(data.error || `Request failed: ${response.status}`) as Error & { status?: number };
error.status = response.status;
throw error;
}
return data as T;
}
+69
View File
@@ -0,0 +1,69 @@
const authChannelName = "makers3d-auth";
const authTabIdKey = "makers3d-auth-tab";
export type AuthEventType = "login" | "logout";
function getTabId() {
let tabId = window.sessionStorage.getItem(authTabIdKey);
if (!tabId) {
tabId = crypto.randomUUID();
window.sessionStorage.setItem(authTabIdKey, tabId);
}
return tabId;
}
export function notifyAuthChanged(type: AuthEventType) {
if (typeof window === "undefined") {
return;
}
const payload = JSON.stringify({ type, source: getTabId(), at: Date.now() });
if ("BroadcastChannel" in window) {
const channel = new BroadcastChannel(authChannelName);
channel.postMessage(payload);
channel.close();
}
window.localStorage.setItem(authChannelName, payload);
}
export function listenAuthChanged(callback: (type: AuthEventType) => void) {
if (typeof window === "undefined") {
return () => {};
}
function handlePayload(payload: unknown) {
if (typeof payload !== "string") {
return;
}
try {
const event = JSON.parse(payload) as { type?: AuthEventType; source?: string };
if (event.source === getTabId()) {
return;
}
if (event.type === "login" || event.type === "logout") {
callback(event.type);
}
} catch {
// Ignore malformed cross-tab payloads.
}
}
const channel = "BroadcastChannel" in window ? new BroadcastChannel(authChannelName) : null;
channel?.addEventListener("message", (event) => handlePayload(event.data));
const storageListener = (event: StorageEvent) => {
if (event.key === authChannelName) {
handlePayload(event.newValue);
}
};
window.addEventListener("storage", storageListener);
return () => {
channel?.close();
window.removeEventListener("storage", storageListener);
};
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { browserApiBase } from "./api";
import { notifyAuthChanged } from "./authEvents";
export function logoutByNavigation(redirectTo = "/login") {
notifyAuthChanged("logout");
window.location.assign(`${browserApiBase}/auth/logout?redirect=${encodeURIComponent(redirectTo)}`);
}
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
import LoginForm from "../components/LoginForm";
export default async function LoginPage({
searchParams
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const redirectTo = typeof params?.returnTo === "string" ? params.returnTo : "/account";
return (
<main className="page-shell">
<div className="auth-wrap">
<section className="auth-card">
<span className="eyebrow">Acceso</span>
<h1 className="section-title">Entrar en Makers3D</h1>
<p className="muted">Usa una cuenta demo para revisar la experiencia publica, el panel maker y el backoffice.</p>
<LoginForm redirectTo={redirectTo} />
<p className="muted">Si no tienes cuenta, <Link href={`/register?returnTo=${encodeURIComponent(redirectTo)}`}>creala aqui</Link>.</p>
</section>
</div>
</main>
);
}
+285
View File
@@ -0,0 +1,285 @@
import Link from "next/link";
import FavoriteToggle from "../../components/FavoriteToggle";
import { serverFetch } from "../../lib/api";
function toText(value: unknown, fallback = "") {
return typeof value === "string" && value ? value : fallback;
}
function toNumber(value: unknown, fallback = 0) {
return typeof value === "number" ? value : Number(value) || fallback;
}
function asArray(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => String(item)) : [];
}
function availabilityLabel(value: string) {
if (value === "available") {
return "Aceptando trabajos";
}
if (value === "limited") {
return "Disponibilidad limitada";
}
if (value === "unavailable") {
return "No acepta trabajos";
}
return value || "Aceptando trabajos";
}
function workImage(work: Record<string, unknown> | undefined, fallback = "/demo/work-custom.svg") {
return work ? toText(work.image_url, fallback) : fallback;
}
export default async function MakerPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const data = await serverFetch<{
maker: Record<string, unknown>;
services: Array<Record<string, unknown>>;
showcases: Array<Record<string, unknown>>;
works: Array<Record<string, unknown>>;
reviews: Array<Record<string, unknown>>;
}>(`/makers/${slug}`);
const { maker, services, showcases, works, reviews } = data;
const makerName = toText(maker.business_name, "Maker");
const makerImage = toText(maker.main_image_url, workImage(works[0], "/demo/maker-hero-1.svg"));
const rating = toNumber(maker.avg_rating, 4.9).toFixed(1);
const allowVerifiedReviews = maker.allow_verified_reviews !== false;
const showSatisfactionScore = maker.show_satisfaction_score !== false;
const showReviewTags = maker.show_review_tags !== false;
const showPastReviews = maker.show_past_reviews !== false;
const visibleReviews = showPastReviews ? reviews : [];
const reviewCount = showPastReviews ? Math.max(toNumber(maker.review_count, reviews.length), reviews.length) : 0;
const contactHref = `/consultas/nueva?makerId=${encodeURIComponent(String(maker.id))}&sourceType=profile&makerName=${encodeURIComponent(makerName)}&contextTitle=${encodeURIComponent(makerName)}&returnTo=${encodeURIComponent(`/makers/${slug}`)}`;
const tabs = [
"Destacado",
`Trabajos (${works.length})`,
`Servicios (${services.length})`,
`Escaparates (${showcases.length})`,
`Opiniones (${visibleReviews.length})`,
"Informacion"
];
const statCards = [
{ value: rating, label: "Reputacion", icon: "*" },
...(showSatisfactionScore ? [{ value: "96%", label: "Satisfaccion", icon: "ok" }] : []),
{ value: "~1 h", label: "Respuesta", icon: "rz" },
{ value: "Alta", label: "Confianza", icon: "cf" }
];
const publicBadges = [
...(reviewCount ? ["Trabajo verificado"] : []),
...(reviewCount >= 2 ? ["Cliente recurrente"] : []),
"Respuesta rapida",
"Confianza + Transparencia"
];
const socialLinks = [
["Web", toText(maker.website_url)],
["Instagram", toText(maker.instagram_url)],
["TikTok", toText(maker.tiktok_url)],
["X", toText(maker.twitter_url)],
["Facebook", toText(maker.facebook_url)],
["YouTube", toText(maker.youtube_url)],
["LinkedIn", toText(maker.linkedin_url)]
].filter(([, href]) => href);
return (
<main className="page-shell maker-public-page">
<section className="maker-public-phone">
<section className="maker-public-hero">
<img src={makerImage} alt={makerName} />
<div className="maker-public-top-actions">
<Link href="/discover" className="maker-public-circle-action" aria-label="Volver a descubrir">&lt;</Link>
<div className="maker-public-action-pair">
<FavoriteToggle targetType="maker" targetId={String(maker.id)} targetSlug={slug} compact />
<button className="maker-public-circle-action" type="button" aria-label="Compartir">sh</button>
</div>
</div>
<span className="maker-public-gallery-count">1/6</span>
</section>
<section className="maker-public-main-card">
<div className="maker-public-identity">
<div className="maker-public-logo">
<span>{makerName.slice(0, 2).toUpperCase()}</span>
</div>
<div className="maker-public-title-block">
<div className="maker-public-name-row">
<h1>{makerName}</h1>
<span className="maker-public-online-dot" />
<span className="maker-public-availability">{availabilityLabel(toText(maker.availability, "available"))}</span>
</div>
<div className="maker-public-rating">
<span className="map-home-star">*</span>
<strong>{rating}</strong>
<span>({reviewCount} resenas verificadas)</span>
</div>
<p>{toText(maker.city, "Cordoba")}, {toText(maker.province, "Argentina")} | 1,2 km</p>
</div>
</div>
<div className="maker-public-stats">
{statCards.map((stat) => (
<article key={stat.label}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
<small>{stat.icon}</small>
</article>
))}
</div>
{showReviewTags ? (
<div className="maker-public-trust-badges">
{publicBadges.map((badge) => <span key={badge}>{badge}</span>)}
</div>
) : null}
<div className="maker-public-tags">
{services.slice(0, 5).map((service) => (
<span key={String(service.id)}>{toText(service.category, "Servicio")}</span>
))}
{services.length === 0 ? <span>Impresion 3D</span> : null}
</div>
<div className="maker-public-actions-row">
<a href={contactHref} className="button button-primary">Escribir al maker</a>
<a href={`https://wa.me/${toText(maker.public_whatsapp, "")}`} className="maker-public-whatsapp" aria-label="WhatsApp">wa</a>
</div>
<section className="maker-public-recommendation">
<span className="maker-public-section-icon">i</span>
<div>
<strong>Por que te lo recomendamos?</strong>
<ul>
<li>A 1,2 km de tu ubicacion</li>
<li>Especialista en trabajos funcionales</li>
<li>{works.length} trabajos verificados</li>
<li>Excelente calidad/precio</li>
</ul>
</div>
</section>
</section>
<nav className="maker-public-tabs" aria-label="Secciones del perfil">
{tabs.map((tab, index) => (
<a key={tab} href={index === 0 ? "#destacado" : index === 1 ? "#trabajos" : index === 2 ? "#servicios" : index === 3 ? "#escaparates" : index === 4 ? "#opiniones" : "#informacion"}>
{tab}
</a>
))}
</nav>
<section id="destacado" className="maker-public-section">
<span className="eyebrow">Destacado</span>
<h2>Especialidades</h2>
<div className="maker-public-chip-grid">
{["Repuestos", "Ingenieria", "Automotor", "Diseno CAD", "Prototipos"].map((item) => (
<span key={item}>{item}</span>
))}
</div>
</section>
<section className="maker-public-section">
<div className="maker-public-section-head">
<h2>Escaparates</h2>
<Link href="#escaparates">Ver todos</Link>
</div>
<div id="escaparates" className="maker-public-showcases">
{showcases.slice(0, 4).map((showcase) => (
<Link key={String(showcase.id)} href={`/showcases/${toText(showcase.slug)}`} style={{ backgroundImage: `url(${toText(showcase.cover_image_url, workImage(works[0], "/demo/feed-print-technical.svg"))})` }}>
<strong>{toText(showcase.title, "Escaparate")}</strong>
<span>{Array.isArray(showcase.selected_work_ids) ? showcase.selected_work_ids.length : 0} trabajos</span>
</Link>
))}
{showcases.length === 0 ? (
<Link href={works[0] ? `/works/${toText(works[0].slug)}` : "#"} style={{ backgroundImage: `url(${workImage(works[0], "/demo/feed-print-technical.svg")})` }}>
<strong>Trabajos destacados</strong>
<span>{Math.max(works.length, 1)} trabajos</span>
</Link>
) : null}
</div>
</section>
<section id="trabajos" className="maker-public-section">
<div className="maker-public-section-head">
<h2>Trabajos destacados</h2>
<Link href="/discover">Ver todos</Link>
</div>
<div className="maker-public-work-rail">
{works.map((work) => (
<Link key={String(work.id)} href={`/works/${toText(work.slug)}`} className="maker-public-work-card">
<img src={workImage(work)} alt={toText(work.title)} />
<strong>{toText(work.title)}</strong>
<span>{toText(work.technology, "FDM")} | {toText(work.material, "PLA")}</span>
<small><span className="map-home-star">*</span> {rating}</small>
</Link>
))}
</div>
</section>
<section id="servicios" className="maker-public-section">
<div className="maker-public-section-head">
<h2>Servicios y precios</h2>
<Link href="#servicios">Ver todos</Link>
</div>
<div className="maker-public-service-list">
{services.map((service) => (
<Link key={String(service.id)} href={`/services/${toText(service.slug)}`}>
<img src={workImage(works[0], "/demo/feed-print-gear.svg")} alt={toText(service.title)} />
<div>
<strong>{toText(service.title)}</strong>
<span>{asArray(service.materials).slice(0, 3).join(" | ") || toText(service.category)}</span>
<small>Desde ${Math.round(toNumber(service.price_from_cents, 0) / 100).toLocaleString("es-AR")}</small>
</div>
<b>&gt;</b>
</Link>
))}
</div>
</section>
<section id="opiniones" className="maker-public-section">
<div className="maker-public-section-head">
<h2>Opinion destacada</h2>
<Link href="#opiniones">Ver todas</Link>
</div>
{!allowVerifiedReviews ? (
<div className="empty-state">Este maker pauso temporalmente las nuevas reseñas verificadas.</div>
) : null}
{!showPastReviews ? (
<div className="empty-state">Este maker mantiene privadas sus resenas anteriores.</div>
) : null}
{showPastReviews ? visibleReviews.slice(0, 2).map((review) => (
<article key={String(review.id)} className="maker-public-review">
<div>
<strong>Trabajo verificado</strong>
<span>{toNumber(review.rating_overall, 5).toFixed(1)} / 5</span>
</div>
<p>{toText(review.comment, "Excelente comunicacion y calidad. Cumplio con el plazo y el resultado fue perfecto.")}</p>
</article>
)) : null}
{showPastReviews && visibleReviews.length === 0 ? <div className="empty-state">Todavia no hay opiniones publicadas.</div> : null}
</section>
<section id="informacion" className="maker-public-section maker-public-info-bottom">
<h2>Informacion del maker</h2>
<p>{toText(maker.description, "Especialista en piezas funcionales, prototipos y trabajos con contexto real.")}</p>
{socialLinks.length ? (
<div className="maker-public-social-links">
{socialLinks.map(([label, href]) => {
const safeHref = href.startsWith("http") ? href : `https://${href}`;
return (
<a key={label} href={safeHref} target="_blank" rel="noreferrer">
{label}
</a>
);
})}
</div>
) : null}
</section>
</section>
<div className="maker-public-bottom-cta">
<a href={contactHref} className="button button-primary">Escribir al maker</a>
</div>
</main>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Makers3D MVP",
short_name: "Makers3D",
description: "Marketplace MVP para descubrir y contactar makers 3D.",
start_url: "/",
display: "standalone",
background_color: "#fff7ed",
theme_color: "#f97316",
lang: "es",
icons: [
{
src: "/icon.svg",
sizes: "any",
type: "image/svg+xml",
purpose: "any"
}
]
};
}
+9
View File
@@ -0,0 +1,9 @@
import MessagesClient from "../components/MessagesClient";
export default function MessagesPage() {
return (
<main className="page-shell messages-page">
<MessagesClient />
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { MapHomeScreen } from "./components/MapHomeScreen";
export default async function HomePage({
searchParams
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
return (
<main className="page-shell map-home-page">
<MapHomeScreen searchParams={params} />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import ProfileClient from "../components/ProfileClient";
export default function ProfilePage() {
return (
<main className="page-shell profile-account-page">
<ProfileClient />
</main>
);
}
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
import RegisterForm from "../components/RegisterForm";
export default async function RegisterPage({
searchParams
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const redirectTo = typeof params?.returnTo === "string" ? params.returnTo : "/account";
return (
<main className="page-shell">
<div className="auth-wrap">
<section className="auth-card">
<span className="eyebrow">Registro</span>
<h1 className="section-title">Crear cuenta unica</h1>
<p className="muted">La misma cuenta sirve para actuar como cliente, seguir consultas y abrir tu espacio maker.</p>
<RegisterForm redirectTo={redirectTo} />
<p className="muted">Si ya tienes acceso, <Link href={`/login?returnTo=${encodeURIComponent(redirectTo)}`}>entra aqui</Link>.</p>
</section>
</div>
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { DiscoverResultsScreen } from "../components/DiscoverResultsScreen";
export default async function ResultsPage({
searchParams
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
return (
<main className="page-shell discover-page">
<DiscoverResultsScreen searchParams={params} />
</main>
);
}
+99
View File
@@ -0,0 +1,99 @@
import Link from "next/link";
import { serverFetch } from "../../lib/api";
function toText(value: unknown, fallback = "") {
return typeof value === "string" && value ? value : fallback;
}
function toBool(value: unknown) {
return value === true || value === "true";
}
function asArray(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => String(item)) : [];
}
export default async function ServicePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const { service } = await serverFetch<{ service: Record<string, unknown> }>(`/services/${slug}`);
return (
<main className="page-shell service-page">
<div className="detail-grid">
<section className="service-content">
<section className="panel stack">
<span className="eyebrow">Servicio publico</span>
<h1 className="headline" style={{ fontSize: "clamp(2rem, 5vw, 3.1rem)" }}>{toText(service.title)}</h1>
<p className="lead">{toText(service.description)}</p>
<div className="badges">
<span className="badge">{toText(service.category)}</span>
<span className="badge">{toBool(service.local_pickup) ? "Retiro local" : "Solo envio"}</span>
<span className="badge">{toBool(service.nationwide_shipping) ? "Envio nacional" : "Cobertura puntual"}</span>
</div>
<div className="meta-grid">
<div className="stat-tile"><span className="stat-value">{String(service.lead_time_days || 4)} dias</span><span className="mini-note">Plazo orientativo</span></div>
<div className="stat-tile"><span className="stat-value">Desde</span><span className="mini-note">${Math.round(Number(service.price_from_cents || 0) / 100).toLocaleString("es-AR")}</span></div>
<div className="stat-tile"><span className="stat-value">Visible</span><span className="mini-note">En mapa y perfil</span></div>
<div className="stat-tile"><span className="stat-value">Guiado</span><span className="mini-note">Contacto contextual</span></div>
</div>
</section>
<section className="panel stack">
<div className="section-head">
<div className="stack" style={{ gap: 6 }}>
<span className="eyebrow">Materiales y capacidades</span>
<h2 className="section-title">Informacion pensada para decidir rapido</h2>
</div>
</div>
<div className="badges">
{asArray(service.materials).map((item) => (
<span key={item} className="badge">{item}</span>
))}
{asArray(service.technologies).map((item) => (
<span key={item} className="badge">{item}</span>
))}
</div>
<div className="grid-cards">
<article className="visual-card">
<img className="thumb" src="/demo/work-dashboard-bracket.svg" alt="Uso" />
<div className="visual-body">
<strong>Uso principal</strong>
<span className="mini-note">Piezas funcionales, prototipos y necesidades reales con restricciones claras.</span>
</div>
</article>
<article className="visual-card">
<img className="thumb" src="/demo/work-custom.svg" alt="Entrega" />
<div className="visual-body">
<strong>Entrega</strong>
<span className="mini-note">Retiro local, envio o cobertura segun la configuracion del maker.</span>
</div>
</article>
<article className="visual-card">
<img className="thumb" src="/demo/work-coffee-hinge.svg" alt="Contexto" />
<div className="visual-body">
<strong>Consulta guiada</strong>
<span className="mini-note">El cliente no llega en frio. Llega con referencia, urgencia y uso final.</span>
</div>
</article>
</div>
</section>
</section>
<aside className="stack">
<section className="panel contact-card stack">
<span className="eyebrow">Iniciar consulta</span>
<h2 className="section-title">Pide este servicio con un flujo corto y util</h2>
<Link
href={`/consultas/nueva?makerId=${encodeURIComponent(String(service.maker_id))}&sourceType=service&sourceId=${encodeURIComponent(String(service.id))}&makerName=${encodeURIComponent(toText(service.maker_name, "Maker"))}&contextTitle=${encodeURIComponent(toText(service.title))}&returnTo=${encodeURIComponent(`/services/${slug}`)}`}
className="button button-primary"
>
Abrir contacto guiado
</Link>
<Link href={`/makers/${toText(service.maker_slug)}`} className="button button-secondary">Ver maker</Link>
</section>
</aside>
</div>
</main>
);
}
+88
View File
@@ -0,0 +1,88 @@
import Link from "next/link";
import { serverFetch } from "../../lib/api";
function toText(value: unknown, fallback = "") {
return typeof value === "string" && value ? value : fallback;
}
function asWorks(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? value as Array<Record<string, unknown>> : [];
}
export default async function ShowcasePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const { showcase } = await serverFetch<{ showcase: Record<string, unknown> }>(`/showcases/${slug}`);
const works = asWorks(showcase.works);
const makerName = toText(showcase.maker_name, "Maker");
const title = toText(showcase.title, "Escaparate");
const coverImage = toText(showcase.cover_image_url, toText(works[0]?.image_url, "/demo/work-custom.svg"));
const contactHref = `/consultas/nueva?makerId=${encodeURIComponent(String(showcase.maker_id))}&sourceType=showcase&sourceId=${encodeURIComponent(String(showcase.id))}&makerName=${encodeURIComponent(makerName)}&contextTitle=${encodeURIComponent(title)}&returnTo=${encodeURIComponent(`/showcases/${slug}`)}`;
return (
<main className="page-shell showcase-public-page">
<section className="showcase-public-phone">
<section className="showcase-public-hero" style={{ backgroundImage: `url(${coverImage})` }}>
<div className="maker-public-top-actions">
<Link href={`/makers/${toText(showcase.maker_slug)}`} className="maker-public-circle-action" aria-label="Volver al maker">&lt;</Link>
<button className="maker-public-circle-action" type="button" aria-label="Compartir">sh</button>
</div>
<div>
<span className="eyebrow">Escaparate publico</span>
<h1>{title}</h1>
<p>{toText(showcase.description, "Coleccion de trabajos destacados del maker.")}</p>
</div>
</section>
<section className="showcase-public-stats">
<article><strong>{works.length}</strong><span>Trabajos</span></article>
<article><strong>1.284</strong><span>Visitas</span></article>
<article><strong>38</strong><span>Consultas</span></article>
<article><strong>4,9</strong><span>Valoracion</span></article>
</section>
{works[0] ? (
<section className="maker-public-section">
<span className="eyebrow">Trabajo destacado</span>
<Link href={`/works/${toText(works[0].slug)}`} className="showcase-featured-work">
<img src={toText(works[0].image_url, "/demo/work-custom.svg")} alt={toText(works[0].title)} />
<div>
<strong>{toText(works[0].title)}</strong>
<span>{toText(works[0].technology, "FDM")} | {toText(works[0].material, "PLA")}</span>
</div>
<b>&gt;</b>
</Link>
</section>
) : null}
<section className="maker-public-section">
<div className="maker-public-section-head">
<h2>Todos los trabajos</h2>
<span>{works.length}</span>
</div>
<div className="showcase-public-work-grid">
{works.map((work) => (
<Link key={String(work.id)} href={`/works/${toText(work.slug)}`}>
<img src={toText(work.image_url, "/demo/work-custom.svg")} alt={toText(work.title)} />
<strong>{toText(work.title)}</strong>
<span>{toText(work.technology, "FDM")} | {toText(work.material, "PLA")}</span>
</Link>
))}
</div>
</section>
<section className="maker-public-section maker-public-info-bottom">
<span className="eyebrow">Compartir escaparate</span>
<div className="work-detail-share-actions">
<a className="work-detail-share-option" href={`https://wa.me/?text=${encodeURIComponent(`Mira este escaparate de ${makerName}: ${title} - https://dev.jazari.com.ar/showcases/${slug}`)}`}>WhatsApp</a>
<button className="work-detail-share-option" type="button">Copiar enlace</button>
</div>
</section>
</section>
<div className="maker-public-bottom-cta">
<a href={contactHref} className="button button-primary">Iniciar consulta desde este escaparate</a>
</div>
</main>
);
}
+170
View File
@@ -0,0 +1,170 @@
import Link from "next/link";
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import FavoriteToggle from "../../components/FavoriteToggle";
import WorkHeroGallery from "../../components/WorkHeroGallery";
import WorkShareActions from "../../components/WorkShareActions";
import { serverFetch } from "../../lib/api";
function toText(value: unknown, fallback = "") {
return typeof value === "string" && value ? value : fallback;
}
function asArray(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => String(item)) : [];
}
function asRecords(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item)) : [];
}
function galleryFor(work: Record<string, unknown>) {
const images = [toText(work.image_url), ...asArray(work.gallery_urls)].filter(Boolean);
return Array.from(new Set(images));
}
function draftDataFor(work: Record<string, unknown>) {
return work.draft_data && typeof work.draft_data === "object" && !Array.isArray(work.draft_data)
? work.draft_data as Record<string, unknown>
: {};
}
export default async function WorkPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
let work: Record<string, unknown>;
try {
const data = await serverFetch<{ work: Record<string, unknown> }>(`/works/${slug}`);
work = data.work;
} catch {
notFound();
}
const requestHeaders = await headers();
const gallery = galleryFor(work);
const draftData = draftDataFor(work);
const beforeImage = toText(draftData.beforeImageUrl);
const afterImage = toText(draftData.afterImageUrl);
const showBeforeAfter = Boolean(draftData.includeBeforeAfter && beforeImage && afterImage);
const relatedWorks = asRecords(work.related_works);
const title = toText(work.title, "Trabajo publicado");
const makerName = toText(work.maker_name, "Maker");
const host = requestHeaders.get("x-forwarded-host") || requestHeaders.get("host") || "dev.jazari.com.ar";
const protocol = requestHeaders.get("x-forwarded-proto") || (host.includes("localhost") ? "http" : "https");
const workUrl = `${protocol}://${host}/works/${slug}`;
const contactHref = `/consultas/nueva?makerId=${encodeURIComponent(String(work.maker_id))}&sourceType=work&sourceId=${encodeURIComponent(String(work.id))}&makerName=${encodeURIComponent(makerName)}&contextTitle=${encodeURIComponent(title)}&returnTo=${encodeURIComponent(`/works/${slug}`)}`;
return (
<main className="page-shell work-detail-page">
<section className="work-detail-phone">
<WorkHeroGallery gallery={gallery} title={title} workId={String(work.id)} shareUrl={workUrl} />
<section className="work-detail-title-card">
<h1>{title}</h1>
<div className="work-detail-tags">
<span>{toText(work.technology, "FDM")}</span>
<span>{toText(work.material, "PLA")}</span>
</div>
<Link href={`/makers/${toText(work.maker_slug)}`} className="work-detail-maker-card">
<span>{makerName.slice(0, 2).toUpperCase()}</span>
<div>
<strong>{makerName}</strong>
<small><span className="map-home-star">*</span> 4,9 | 128 opiniones | Aceptando trabajos</small>
</div>
<b>&gt;</b>
</Link>
</section>
<section className="work-detail-section">
<h2>Que se hizo?</h2>
<p>{toText(work.summary, "Se reprodujo una pieza a medida para resolver una necesidad real del cliente.")}</p>
<div className="work-detail-feature-grid">
<article><span>01</span><strong>Pieza funcional</strong></article>
<article><span>02</span><strong>Resiste calor</strong></article>
<article><span>03</span><strong>1 unidad</strong></article>
<article><span>04</span><strong>48 h fabricacion</strong></article>
</div>
</section>
{showBeforeAfter ? (
<section className="work-detail-section">
<div className="work-detail-section-head">
<h2>Antes / Resultado</h2>
<Link href="#galeria">Ver mas</Link>
</div>
<div className="work-detail-before-after">
<article>
<img src={beforeImage} alt="Antes" />
<span>Antes</span>
</article>
<b>&gt;</b>
<article>
<img src={afterImage} alt="Resultado" />
<span>Resultado</span>
</article>
</div>
</section>
) : null}
<section className="work-detail-section">
<h2>Historia del proyecto</h2>
<p>{toText(work.story, "El cliente necesitaba recuperar una pieza o validar una solucion. Se tomo una referencia, se ajusto el diseno y se fabrico una version funcional lista para probar.")}</p>
</section>
<section className="work-detail-section">
<details open>
<summary>Detalles tecnicos</summary>
<div className="work-detail-specs">
<span>Tecnologia</span><strong>{toText(work.technology, "FDM")}</strong>
<span>Material</span><strong>{toText(work.material, "PETG")}</strong>
<span>Acabado</span><strong>Lijado y ajuste manual</strong>
<span>Resolucion</span><strong>0,16 mm capa</strong>
<span>Relleno</span><strong>40%</strong>
<span>Tiempo</span><strong>48 horas</strong>
</div>
</details>
</section>
<section id="galeria" className="work-detail-section">
<div className="work-detail-section-head">
<h2>Galeria</h2>
<span>{gallery.length} fotos</span>
</div>
<div className="work-detail-gallery-grid">
{gallery.map((image, index) => (
<img key={`${image}-${index}`} src={image} alt={`${title} ${index + 1}`} />
))}
</div>
</section>
{relatedWorks.length ? (
<section className="work-detail-section">
<div className="work-detail-section-head">
<h2>Tambien puede interesarte</h2>
<Link href="/discover">Ver todos</Link>
</div>
<div className="work-detail-related-rail">
{relatedWorks.map((item) => (
<Link key={String(item.id)} href={`/works/${toText(item.slug)}`}>
<img src={toText(item.image_url, "/demo/work-custom.svg")} alt={toText(item.title, "Trabajo sugerido")} />
<strong>{toText(item.title, "Trabajo sugerido")}</strong>
<span>{toText(item.technology, "FDM")} | {toText(item.material, "PETG")}</span>
</Link>
))}
</div>
</section>
) : null}
<section className="work-detail-section work-detail-share">
<h2>Compartir este trabajo</h2>
<p>Comparte este enlace para mostrar la referencia o enviarla por WhatsApp.</p>
<WorkShareActions title={title} url={workUrl} />
</section>
</section>
<div className="work-detail-bottom-cta">
<FavoriteToggle targetType="work" targetId={String(work.id)} compact />
<a href={contactHref} className="button button-primary">Quiero algo similar</a>
</div>
</main>
);
}