Files
Makers3D/apps/web/app/components/DiscoverExperienceClient.tsx

250 lines
8.2 KiB
TypeScript

"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>
);
}