250 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|