Modulo makers3d desarrollado con codex V 0.0.1
@@ -0,0 +1,19 @@
|
||||
FROM node:22-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json tsconfig.base.json ./
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build -w @makers3d/web
|
||||
|
||||
FROM node:22-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/package.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/web/.next ./apps/web/.next
|
||||
COPY --from=build /app/apps/web/public ./apps/web/public
|
||||
COPY --from=build /app/apps/web/package.json ./apps/web/package.json
|
||||
COPY --from=build /app/apps/web/next.config.ts ./apps/web/next.config.ts
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node_modules/.bin/next", "start", "apps/web", "-p", "3000"]
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountInboxPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="inbox" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountLocationsPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="locations" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../components/AccountClient";
|
||||
|
||||
export default function AccountPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="summary" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountProfilePage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="profile" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountReviewsPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="reviews" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountServicesPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="services" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountSettingsPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="settings" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountShowcasesPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="showcases" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
import { logoutByNavigation } from "../lib/logout";
|
||||
|
||||
type MeResponse = {
|
||||
user: { id: string; email: string; role: string } | null;
|
||||
makerProfile: { id: string; slug: string; business_name: string | null; status: string } | null;
|
||||
};
|
||||
|
||||
export default function AuthNav() {
|
||||
const [session, setSession] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadSession() {
|
||||
try {
|
||||
const response = await browserFetch<MeResponse>("/auth/me");
|
||||
if (active) {
|
||||
setSession(response);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setSession({ user: null, makerProfile: null });
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadSession();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function logout() {
|
||||
logoutByNavigation();
|
||||
}
|
||||
|
||||
const user = session?.user || null;
|
||||
const makerProfile = session?.makerProfile || null;
|
||||
const isAdmin = user?.role === "admin";
|
||||
const isMaker = Boolean(makerProfile && makerProfile.status !== "draft");
|
||||
|
||||
return (
|
||||
<nav className="nav-links">
|
||||
<Link href="/" className="nav-link">Inicio</Link>
|
||||
<Link href="/discover" className="nav-link">Descubrir</Link>
|
||||
|
||||
{loading ? <span className="nav-session-chip">Sesion...</span> : null}
|
||||
|
||||
{!loading && !user ? (
|
||||
<Link href="/login" className="button button-primary">Entrar</Link>
|
||||
) : null}
|
||||
|
||||
{!loading && user && isAdmin ? (
|
||||
<>
|
||||
<a href="/app/backoffice" className="nav-link nav-link-admin">Backoffice</a>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!loading && user && !isAdmin && isMaker ? (
|
||||
<>
|
||||
<Link href="/account" className="nav-link">Mi espacio maker</Link>
|
||||
<Link href="/account/inbox" className="nav-link">Consultas maker</Link>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!loading && user && !isAdmin && !isMaker ? (
|
||||
<>
|
||||
<Link href="/messages" className="nav-link">Mensajes</Link>
|
||||
<Link href="/favorites" className="nav-link">Favoritos</Link>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!loading && user ? (
|
||||
<button className="button button-secondary nav-logout-button" type="button" onClick={logout}>
|
||||
Cerrar sesion
|
||||
</button>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { listenAuthChanged } from "../lib/authEvents";
|
||||
|
||||
export default function AuthSessionSync() {
|
||||
useEffect(() => listenAuthChanged(() => {
|
||||
window.location.reload();
|
||||
}), []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
import AdminClient, { type AdminSection } from "./AdminClient";
|
||||
|
||||
export default function BackofficeEntryClient({ section }: { section: AdminSection }) {
|
||||
const pathname = usePathname();
|
||||
const [state, setState] = useState<"loading" | "ready" | "forbidden">("loading");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function checkSession() {
|
||||
try {
|
||||
const response = await browserFetch<{ user: { id: string; role: string } | null }>("/auth/me");
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.user) {
|
||||
window.location.replace(`/login?returnTo=${encodeURIComponent(pathname || "/app/backoffice")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.user.role !== "admin") {
|
||||
setState("forbidden");
|
||||
setError("Esta cuenta no tiene permisos de backoffice.");
|
||||
return;
|
||||
}
|
||||
|
||||
setState("ready");
|
||||
} catch (sessionError) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = sessionError instanceof Error ? sessionError.message : "No se pudo validar la sesion";
|
||||
setError(message);
|
||||
window.location.replace(`/login?returnTo=${encodeURIComponent(pathname || "/app/backoffice")}`);
|
||||
}
|
||||
}
|
||||
|
||||
void checkSession();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
if (state === "loading") {
|
||||
return (
|
||||
<main className="page-shell ops-page">
|
||||
<section className="panel stack">
|
||||
<span className="eyebrow">Backoffice</span>
|
||||
<h1 className="section-title">Validando acceso</h1>
|
||||
<p className="muted">Comprobando la sesion antes de abrir el panel operativo.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "forbidden") {
|
||||
return (
|
||||
<main className="page-shell ops-page">
|
||||
<section className="panel stack">
|
||||
<span className="eyebrow">Backoffice</span>
|
||||
<h1 className="section-title">Acceso restringido</h1>
|
||||
<p className="muted">{error}</p>
|
||||
<div className="home-actions">
|
||||
<Link href="/" className="button button-primary">Volver a la app</Link>
|
||||
<Link href="/login?returnTo=%2Fapp%2Fbackoffice" className="button button-secondary">Entrar con otra cuenta</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page-shell ops-page">
|
||||
<AdminClient section={section} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import FavoriteToggle from "./FavoriteToggle";
|
||||
|
||||
type PublicMaker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
business_name: string;
|
||||
};
|
||||
|
||||
type PublicWork = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
technology: string;
|
||||
material: string;
|
||||
image_url: string | null;
|
||||
gallery_urls: string[] | null;
|
||||
maker_id: string;
|
||||
maker_slug: string;
|
||||
maker_name: string;
|
||||
avg_rating: number | string;
|
||||
review_count: number;
|
||||
};
|
||||
|
||||
type DiscoverCategory = "all" | "fdm" | "resin" | "design-3d" | "repuestos" | "ingenieria";
|
||||
|
||||
const discoverReturnKey = "makers3d:discover:return-position";
|
||||
|
||||
function getRating(value: number | string) {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function workImages(work: PublicWork) {
|
||||
const images = [work.image_url, ...(work.gallery_urls || [])]
|
||||
.filter((image): image is string => Boolean(image));
|
||||
return Array.from(new Set(images)).slice(0, 8);
|
||||
}
|
||||
|
||||
function getWorkTags(work: PublicWork) {
|
||||
return [work.technology, work.material].filter(Boolean).slice(0, 3);
|
||||
}
|
||||
|
||||
function matchesCategory(work: PublicWork, category: DiscoverCategory) {
|
||||
if (category === "all") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = [work.title, work.summary, work.technology, work.material, work.maker_name]
|
||||
.join(" ")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
if (category === "fdm") {
|
||||
return haystack.includes("fdm");
|
||||
}
|
||||
if (category === "resin") {
|
||||
return haystack.includes("resina") || haystack.includes("resin");
|
||||
}
|
||||
if (category === "design-3d") {
|
||||
return haystack.includes("diseno") || haystack.includes("diseño") || haystack.includes("cad");
|
||||
}
|
||||
if (category === "repuestos") {
|
||||
return haystack.includes("repuesto") || haystack.includes("restauracion") || haystack.includes("pieza");
|
||||
}
|
||||
|
||||
return haystack.includes("ingenieria") || haystack.includes("industrial") || haystack.includes("tecnic");
|
||||
}
|
||||
|
||||
function orderWorks(works: PublicWork[]) {
|
||||
return [...works].sort((left, right) => {
|
||||
if (getRating(right.avg_rating) !== getRating(left.avg_rating)) {
|
||||
return getRating(right.avg_rating) - getRating(left.avg_rating);
|
||||
}
|
||||
|
||||
return right.review_count - left.review_count;
|
||||
});
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="6.6" />
|
||||
<path d="m20 20-3.6-3.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DiscoverExperienceClient({
|
||||
initialWorks,
|
||||
initialQuery,
|
||||
initialCategory
|
||||
}: {
|
||||
initialMakers: PublicMaker[];
|
||||
initialWorks: PublicWork[];
|
||||
initialQuery: string;
|
||||
initialCategory: DiscoverCategory;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const works = orderWorks(initialWorks.filter((work) => matchesCategory(work, initialCategory)));
|
||||
|
||||
useEffect(() => {
|
||||
const storedPosition = window.sessionStorage.getItem(discoverReturnKey);
|
||||
if (!storedPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const position = JSON.parse(storedPosition) as {
|
||||
scrollTop?: number;
|
||||
query?: string;
|
||||
category?: DiscoverCategory;
|
||||
};
|
||||
|
||||
if (position.query !== initialQuery || position.category !== initialCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
scrollRef.current?.scrollTo({ top: position.scrollTop || 0, behavior: "auto" });
|
||||
};
|
||||
|
||||
requestAnimationFrame(restore);
|
||||
window.setTimeout(restore, 120);
|
||||
} catch {
|
||||
window.sessionStorage.removeItem(discoverReturnKey);
|
||||
}
|
||||
}, [initialCategory, initialQuery, works.length]);
|
||||
|
||||
function rememberReturnPosition(workId: string) {
|
||||
window.sessionStorage.setItem(
|
||||
discoverReturnKey,
|
||||
JSON.stringify({
|
||||
category: initialCategory,
|
||||
query: initialQuery,
|
||||
scrollTop: scrollRef.current?.scrollTop || 0,
|
||||
workId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="discover-feed-shell">
|
||||
<div className="discover-feed-topbar">
|
||||
<form action="/discover" className="discover-feed-search">
|
||||
{initialCategory !== "all" ? <input type="hidden" name="category" value={initialCategory} /> : null}
|
||||
<span aria-hidden="true">
|
||||
<SearchIcon />
|
||||
</span>
|
||||
<input name="q" placeholder="Buscar trabajos, piezas, materiales..." defaultValue={initialQuery} />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="discover-feed-scroll" aria-label="Feed de trabajos">
|
||||
{works.map((work, index) => {
|
||||
const images = workImages(work);
|
||||
|
||||
return (
|
||||
<article key={work.id} className="discover-feed-item">
|
||||
<section className="discover-feed-visual">
|
||||
<div className="discover-feed-gallery" aria-label={`Galeria de ${work.title}`}>
|
||||
{images.map((image, imageIndex) => (
|
||||
<div key={`${work.id}-${image}`} className="discover-feed-slide">
|
||||
<img src={image} alt={`${work.title} - imagen ${imageIndex + 1}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{images.length > 1 ? (
|
||||
<div className="discover-feed-dots" aria-hidden="true">
|
||||
{images.map((image) => (
|
||||
<span key={`${work.id}-dot-${image}`} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<span className="discover-feed-photo-count">{images.length === 1 ? "1 foto" : `${images.length} fotos | desliza`}</span>
|
||||
|
||||
<div className="discover-feed-actions" aria-label="Acciones del trabajo">
|
||||
<FavoriteToggle targetType="work" targetId={work.id} compact />
|
||||
<Link
|
||||
href={`/works/${work.slug}`}
|
||||
className="discover-feed-action-button"
|
||||
onClick={() => rememberReturnPosition(work.id)}
|
||||
aria-label="Ver trabajo completo"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M7 17 17 7" />
|
||||
<path d="M8 7h9v9" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section className="discover-feed-copy">
|
||||
<div className="discover-feed-maker-row">
|
||||
<Link href={`/makers/${work.maker_slug}`} className="discover-feed-maker">
|
||||
<span>{work.maker_name.slice(0, 2).toUpperCase()}</span>
|
||||
<strong>{work.maker_name}</strong>
|
||||
</Link>
|
||||
<div className="discover-feed-rating">
|
||||
<span className="map-home-star">*</span>
|
||||
<span>{getRating(work.avg_rating).toFixed(1)}</span>
|
||||
<span>({Math.max(work.review_count * 32, 12)})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="discover-feed-title-row">
|
||||
<h1>{work.title}</h1>
|
||||
<Link
|
||||
href={`/works/${work.slug}`}
|
||||
className="button button-primary discover-feed-cta"
|
||||
onClick={() => rememberReturnPosition(work.id)}
|
||||
>
|
||||
Ver trabajo
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p>{work.summary}</p>
|
||||
|
||||
<div className="discover-feed-tags">
|
||||
{getWorkTags(work).map((tag) => (
|
||||
<span key={`${work.id}-${tag}`} className="map-home-tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
{works.length === 0 ? (
|
||||
<section className="discover-feed-empty">
|
||||
<span className="eyebrow">Descubrir</span>
|
||||
<h1>No encontramos trabajos</h1>
|
||||
<p>Prueba otra busqueda para volver a explorar el portfolio de makers.</p>
|
||||
<Link href="/discover" className="button button-primary">Ver todos</Link>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { serverFetch } from "../lib/api";
|
||||
import DiscoverExperienceClient from "./DiscoverExperienceClient";
|
||||
|
||||
type PublicMaker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
business_name: string;
|
||||
description: string;
|
||||
province: string;
|
||||
city: string;
|
||||
delivery_scope: string;
|
||||
availability: string;
|
||||
main_image_url: string | null;
|
||||
avg_rating: number | string;
|
||||
review_count: number;
|
||||
service_count: number;
|
||||
work_count: number;
|
||||
public_latitude: number | null;
|
||||
public_longitude: number | null;
|
||||
categories?: string[];
|
||||
};
|
||||
|
||||
type PublicWork = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
technology: string;
|
||||
material: string;
|
||||
image_url: string | null;
|
||||
gallery_urls: string[] | null;
|
||||
maker_id: string;
|
||||
maker_slug: string;
|
||||
maker_name: string;
|
||||
avg_rating: number | string;
|
||||
review_count: number;
|
||||
};
|
||||
|
||||
type DiscoverCategory = "all" | "fdm" | "resin" | "design-3d" | "repuestos" | "ingenieria";
|
||||
|
||||
function readParam(value: string | string[] | undefined) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function normalizeCategory(value: string): DiscoverCategory {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
if (normalized === "fdm" || normalized === "impresion fdm") {
|
||||
return "fdm";
|
||||
}
|
||||
if (normalized === "resina" || normalized === "impresion resina") {
|
||||
return "resin";
|
||||
}
|
||||
if (normalized === "design-3d" || normalized === "diseno 3d") {
|
||||
return "design-3d";
|
||||
}
|
||||
if (normalized === "repuestos") {
|
||||
return "repuestos";
|
||||
}
|
||||
if (normalized === "ingenieria") {
|
||||
return "ingenieria";
|
||||
}
|
||||
|
||||
return "all";
|
||||
}
|
||||
|
||||
export async function DiscoverResultsScreen({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const query = readParam(searchParams?.q);
|
||||
const initialCategory = normalizeCategory(
|
||||
readParam(searchParams?.category) || readParam(searchParams?.serviceCategory)
|
||||
);
|
||||
|
||||
const requestQuery = new URLSearchParams();
|
||||
if (query) {
|
||||
requestQuery.set("q", query);
|
||||
}
|
||||
|
||||
const serialized = requestQuery.toString();
|
||||
const [makersData, worksData] = await Promise.all([
|
||||
serverFetch<{ makers: PublicMaker[] }>(`/makers${serialized ? `?${serialized}` : ""}`),
|
||||
serverFetch<{ works: PublicWork[] }>(`/works${serialized ? `?${serialized}` : ""}`)
|
||||
]);
|
||||
|
||||
return (
|
||||
<DiscoverExperienceClient
|
||||
initialMakers={makersData.makers}
|
||||
initialWorks={worksData.works}
|
||||
initialQuery={query}
|
||||
initialCategory={initialCategory}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
|
||||
type FavoriteToggleProps = {
|
||||
targetId: string;
|
||||
targetType: "maker" | "work";
|
||||
targetSlug?: string;
|
||||
compact?: boolean;
|
||||
icon?: "heart" | "bookmark";
|
||||
};
|
||||
|
||||
const favoriteSyncEvent = "makers3d:favorite-updated";
|
||||
|
||||
function loginForCurrentPage() {
|
||||
const currentPath = `${window.location.pathname}${window.location.search}`;
|
||||
window.location.assign(`/login?returnTo=${encodeURIComponent(currentPath)}`);
|
||||
}
|
||||
|
||||
function BookmarkIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill={active ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M7 4.8A1.8 1.8 0 0 1 8.8 3h6.4A1.8 1.8 0 0 1 17 4.8V21l-5-3-5 3V4.8Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FavoriteToggle({ targetId, targetType, targetSlug, compact = false, icon = "heart" }: FavoriteToggleProps) {
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState("");
|
||||
const syncKey = `${targetType}:${targetSlug || targetId}`;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadFavorite() {
|
||||
try {
|
||||
const statusQuery = targetType === "maker" && targetSlug
|
||||
? `/favorites/status?targetType=maker&targetSlug=${encodeURIComponent(targetSlug)}`
|
||||
: `/favorites/status?targetType=${targetType}&targetId=${targetId}`;
|
||||
const response = await browserFetch<{ favorited: boolean }>(
|
||||
statusQuery
|
||||
);
|
||||
if (active) {
|
||||
setFavorited(response.favorited);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setFavorited(false);
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadFavorite();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [targetId, targetSlug, targetType]);
|
||||
|
||||
useEffect(() => {
|
||||
function syncFavorite(event: Event) {
|
||||
const detail = (event as CustomEvent<{ key: string; favorited: boolean }>).detail;
|
||||
if (detail?.key === syncKey) {
|
||||
setFavorited(detail.favorited);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(favoriteSyncEvent, syncFavorite);
|
||||
return () => window.removeEventListener(favoriteSyncEvent, syncFavorite);
|
||||
}, [syncKey]);
|
||||
|
||||
async function toggleFavorite() {
|
||||
setStatus("");
|
||||
try {
|
||||
const identifier = targetSlug || targetId || "current";
|
||||
const endpoint = targetType === "maker"
|
||||
? `/favorites/makers/${encodeURIComponent(identifier)}`
|
||||
: `/favorites/works/${encodeURIComponent(identifier)}`;
|
||||
const response = await browserFetch<{ favorited: boolean }>(endpoint, {
|
||||
method: favorited ? "DELETE" : "POST"
|
||||
});
|
||||
setFavorited(response.favorited);
|
||||
window.dispatchEvent(new CustomEvent(favoriteSyncEvent, {
|
||||
detail: {
|
||||
key: syncKey,
|
||||
favorited: response.favorited
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
const apiError = error as Error & { status?: number };
|
||||
const message = apiError.message || "";
|
||||
if (apiError.status === 401 || apiError.status === 403 || message.includes("Authentication")) {
|
||||
loginForCurrentPage();
|
||||
return;
|
||||
}
|
||||
setStatus(apiError.status ? `No se pudo guardar (${apiError.status}).` : "No se pudo actualizar favoritos.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`favorite-toggle-wrap ${compact ? "is-compact" : ""} favorite-icon-${icon}`}>
|
||||
<button
|
||||
className={`favorite-toggle ${favorited ? "is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={toggleFavorite}
|
||||
disabled={loading}
|
||||
aria-pressed={favorited}
|
||||
>
|
||||
<span className="favorite-heart" aria-hidden="true">
|
||||
{icon === "bookmark" ? <BookmarkIcon active={favorited} /> : (favorited ? "\u2764\uFE0F" : "\u2661")}
|
||||
</span>
|
||||
<span>{favorited ? "Guardado" : "Guardar"}</span>
|
||||
</button>
|
||||
{status ? <span className="mini-note">{status}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
|
||||
type FavoriteMaker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
business_name: string;
|
||||
city: string | null;
|
||||
province: string | null;
|
||||
main_image_url: string | null;
|
||||
availability: string | null;
|
||||
avg_rating: number | string;
|
||||
review_count: number;
|
||||
};
|
||||
|
||||
type FavoriteWork = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
technology: string;
|
||||
material: string;
|
||||
image_url: string;
|
||||
maker_slug: string;
|
||||
maker_name: string;
|
||||
};
|
||||
|
||||
type FavoritesResponse = {
|
||||
makers: FavoriteMaker[];
|
||||
works: FavoriteWork[];
|
||||
};
|
||||
|
||||
function rating(value: number | string) {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(parsed) ? parsed.toFixed(1) : "0.0";
|
||||
}
|
||||
|
||||
export default function FavoritesClient() {
|
||||
const [activeTab, setActiveTab] = useState<"makers" | "works">("makers");
|
||||
const [favorites, setFavorites] = useState<FavoritesResponse>({ makers: [], works: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [needsLogin, setNeedsLogin] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
async function loadFavorites() {
|
||||
setStatus("");
|
||||
try {
|
||||
const response = await browserFetch<FavoritesResponse>("/favorites");
|
||||
setFavorites(response);
|
||||
setNeedsLogin(false);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (message.includes("Authentication")) {
|
||||
setNeedsLogin(true);
|
||||
return;
|
||||
}
|
||||
setStatus("No se pudieron cargar tus favoritos.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadFavorites();
|
||||
}, []);
|
||||
|
||||
async function removeFavorite(type: "maker" | "work", id: string) {
|
||||
const endpoint = type === "maker" ? `/favorites/makers/${id}` : `/favorites/works/${id}`;
|
||||
await browserFetch(endpoint, { method: "DELETE" });
|
||||
await loadFavorites();
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="favorites-shell">
|
||||
<div className="profile-account-card stack">
|
||||
<span className="eyebrow">Favoritos</span>
|
||||
<h1 className="section-title">Cargando guardados</h1>
|
||||
<p className="muted">Estamos preparando tus makers y trabajos favoritos.</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (needsLogin) {
|
||||
return (
|
||||
<section className="favorites-shell">
|
||||
<div className="profile-account-card stack">
|
||||
<span className="eyebrow">Favoritos</span>
|
||||
<h1 className="section-title">Inicia sesion para guardar favoritos</h1>
|
||||
<p className="muted">Tus makers y trabajos guardados quedan asociados a tu cuenta para recuperarlos desde cualquier pestana.</p>
|
||||
<Link href="/login?returnTo=%2Ffavorites" className="button button-primary">Entrar</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleMakers = activeTab === "makers";
|
||||
const empty = visibleMakers ? favorites.makers.length === 0 : favorites.works.length === 0;
|
||||
|
||||
return (
|
||||
<section className="favorites-shell">
|
||||
<div className="favorites-header">
|
||||
<div>
|
||||
<span className="eyebrow">Favoritos</span>
|
||||
<h1 className="section-title">Guardados para volver rapido</h1>
|
||||
<p className="muted">Separados por makers y trabajos para que puedas comparar proveedores o ejemplos concretos.</p>
|
||||
</div>
|
||||
<div className="favorites-counts">
|
||||
<span>{favorites.makers.length} makers</span>
|
||||
<span>{favorites.works.length} trabajos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="favorites-tabs" role="tablist" aria-label="Secciones de favoritos">
|
||||
<button className={activeTab === "makers" ? "is-active" : ""} type="button" onClick={() => setActiveTab("makers")}>
|
||||
Makers
|
||||
</button>
|
||||
<button className={activeTab === "works" ? "is-active" : ""} type="button" onClick={() => setActiveTab("works")}>
|
||||
Trabajos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status ? <p className="messages-status">{status}</p> : null}
|
||||
|
||||
{empty ? (
|
||||
<div className="profile-account-card stack">
|
||||
<h2 className="section-title">{visibleMakers ? "Todavia no guardaste makers" : "Todavia no guardaste trabajos"}</h2>
|
||||
<p className="muted">Usa el boton Guardar desde un perfil de maker o desde el detalle de un trabajo.</p>
|
||||
<Link href="/discover" className="button button-primary">Descubrir makers</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{visibleMakers ? (
|
||||
<div className="favorites-grid">
|
||||
{favorites.makers.map((maker) => (
|
||||
<article key={maker.id} className="favorite-card">
|
||||
<img className="thumb" src={maker.main_image_url || "/demo/maker-hero-1.svg"} alt={maker.business_name} />
|
||||
<div className="favorite-card-body">
|
||||
<div>
|
||||
<strong>{maker.business_name}</strong>
|
||||
<span className="mini-note">{maker.city || "Cordoba"}, {maker.province || "Argentina"}</span>
|
||||
</div>
|
||||
<div className="badges">
|
||||
<span className="badge">Rating {rating(maker.avg_rating)}</span>
|
||||
<span className="badge">{maker.review_count} resenas</span>
|
||||
<span className="badge">{maker.availability || "available"}</span>
|
||||
</div>
|
||||
<div className="favorite-card-actions">
|
||||
<Link href={`/makers/${maker.slug}`} className="button button-primary">Ver perfil</Link>
|
||||
<button className="button button-secondary" type="button" onClick={() => removeFavorite("maker", maker.id)}>Quitar</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="favorites-grid">
|
||||
{favorites.works.map((work) => (
|
||||
<article key={work.id} className="favorite-card">
|
||||
<img className="thumb" src={work.image_url || "/demo/work-custom.svg"} alt={work.title} />
|
||||
<div className="favorite-card-body">
|
||||
<div>
|
||||
<strong>{work.title}</strong>
|
||||
<span className="mini-note">{work.maker_name}</span>
|
||||
</div>
|
||||
<p className="muted">{work.summary}</p>
|
||||
<div className="badges">
|
||||
<span className="badge">{work.technology}</span>
|
||||
<span className="badge">{work.material}</span>
|
||||
</div>
|
||||
<div className="favorite-card-actions">
|
||||
<Link href={`/works/${work.slug}`} className="button button-primary">Ver trabajo</Link>
|
||||
<button className="button button-secondary" type="button" onClick={() => removeFavorite("work", work.id)}>Quitar</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
|
||||
type Props = {
|
||||
makerId: string;
|
||||
sourceType: "profile" | "service" | "work";
|
||||
sourceId?: string | null;
|
||||
};
|
||||
|
||||
export function InquiryForm({ makerId, sourceType, sourceId }: Props) {
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [form, setForm] = useState({
|
||||
needText: "",
|
||||
sizeText: "",
|
||||
hasModel: false,
|
||||
materialPreference: "",
|
||||
quantity: 1,
|
||||
urgency: "medium",
|
||||
deliveryType: "shipping"
|
||||
});
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setStatus("Enviando consulta...");
|
||||
try {
|
||||
await browserFetch("/inquiries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
makerId,
|
||||
sourceType,
|
||||
sourceId,
|
||||
...form
|
||||
})
|
||||
});
|
||||
setStatus("Consulta enviada. Ya puedes seguirla desde tu cuenta.");
|
||||
setForm({
|
||||
needText: "",
|
||||
sizeText: "",
|
||||
hasModel: false,
|
||||
materialPreference: "",
|
||||
quantity: 1,
|
||||
urgency: "medium",
|
||||
deliveryType: "shipping"
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo enviar la consulta");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="inquiry-card" onSubmit={onSubmit}>
|
||||
<div className="guide-steps">
|
||||
<div className="guide-step">
|
||||
<div className="step-index">1</div>
|
||||
<div>
|
||||
<strong>Que quieres hacer?</strong>
|
||||
<div className="mini-note">Cuanto mejor expliques el uso, mejor se ajusta la respuesta del maker.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inquiry-section">
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Quiero algo muy parecido, necesito modificar una pieza o tengo una idea similar..."
|
||||
value={form.needText}
|
||||
onChange={(event) => setForm((current) => ({ ...current, needText: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="inquiry-section">
|
||||
<strong>Detalles que ayudan al maker</strong>
|
||||
<div className="input-grid-compact">
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Tamano o medidas aproximadas"
|
||||
value={form.sizeText}
|
||||
onChange={(event) => setForm((current) => ({ ...current, sizeText: event.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Material preferido"
|
||||
value={form.materialPreference}
|
||||
onChange={(event) => setForm((current) => ({ ...current, materialPreference: event.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="field"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.quantity}
|
||||
onChange={(event) => setForm((current) => ({ ...current, quantity: Number(event.target.value) }))}
|
||||
/>
|
||||
<select
|
||||
className="select"
|
||||
value={form.urgency}
|
||||
onChange={(event) => setForm((current) => ({ ...current, urgency: event.target.value }))}
|
||||
>
|
||||
<option value="low">Sin urgencia</option>
|
||||
<option value="medium">Esta semana</option>
|
||||
<option value="high">Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-grid-compact">
|
||||
<label className="choice-chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.hasModel}
|
||||
onChange={(event) => setForm((current) => ({ ...current, hasModel: event.target.checked }))}
|
||||
/>
|
||||
<span>Ya tengo archivo o pieza de referencia</span>
|
||||
</label>
|
||||
<select
|
||||
className="select"
|
||||
value={form.deliveryType}
|
||||
onChange={(event) => setForm((current) => ({ ...current, deliveryType: event.target.value }))}
|
||||
>
|
||||
<option value="shipping">Necesito envio</option>
|
||||
<option value="local">Puedo retirar personalmente</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button className="button button-primary" type="submit">Enviar consulta</button>
|
||||
{status ? <p className="muted">{status}</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
|
||||
type Props = {
|
||||
makerId: string;
|
||||
sourceType: "profile" | "service" | "work";
|
||||
sourceId?: string;
|
||||
makerName: string;
|
||||
contextTitle: string;
|
||||
returnTo: string;
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
needText: string;
|
||||
sizeText: string;
|
||||
usageText: string;
|
||||
materialPreference: string;
|
||||
quantity: number;
|
||||
urgency: string;
|
||||
deliveryType: string;
|
||||
hasModel: boolean;
|
||||
referenceNotes: string;
|
||||
};
|
||||
|
||||
const initialDraft: Draft = {
|
||||
needText: "",
|
||||
sizeText: "",
|
||||
usageText: "",
|
||||
materialPreference: "",
|
||||
quantity: 1,
|
||||
urgency: "medium",
|
||||
deliveryType: "shipping",
|
||||
hasModel: false,
|
||||
referenceNotes: ""
|
||||
};
|
||||
|
||||
export default function InquiryWizardClient({ makerId, sourceType, sourceId, makerName, contextTitle, returnTo }: Props) {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(1);
|
||||
const [status, setStatus] = useState("");
|
||||
const [me, setMe] = useState<{ id: string } | null>(null);
|
||||
const [draft, setDraft] = useState<Draft>(initialDraft);
|
||||
|
||||
const storageKey = useMemo(
|
||||
() => `makers3d_inquiry_draft:${makerId}:${sourceType}:${sourceId || "root"}`,
|
||||
[makerId, sourceId, sourceType]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = window.sessionStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
try {
|
||||
setDraft({ ...initialDraft, ...JSON.parse(saved) as Draft });
|
||||
} catch {
|
||||
window.sessionStorage.removeItem(storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
void browserFetch<{ user: { id: string } | null }>("/auth/me")
|
||||
.then((result) => setMe(result.user))
|
||||
.catch(() => setMe(null));
|
||||
}, [storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
window.sessionStorage.setItem(storageKey, JSON.stringify(draft));
|
||||
}, [draft, storageKey]);
|
||||
|
||||
function nextStep() {
|
||||
setStep((current) => Math.min(5, current + 1));
|
||||
}
|
||||
|
||||
function previousStep() {
|
||||
setStep((current) => Math.max(1, current - 1));
|
||||
}
|
||||
|
||||
async function submitInquiry() {
|
||||
if (!me) {
|
||||
const nextUrl = `/consultas/nueva?makerId=${encodeURIComponent(makerId)}&sourceType=${encodeURIComponent(sourceType)}&sourceId=${encodeURIComponent(sourceId || "")}&makerName=${encodeURIComponent(makerName)}&contextTitle=${encodeURIComponent(contextTitle)}`;
|
||||
window.location.href = `/register?returnTo=${encodeURIComponent(nextUrl)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Enviando consulta...");
|
||||
try {
|
||||
await browserFetch("/inquiries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
makerId,
|
||||
sourceType,
|
||||
sourceId,
|
||||
needText: `${draft.needText}\n\nUso final: ${draft.usageText}\nReferencia: ${draft.referenceNotes}`.trim(),
|
||||
sizeText: draft.sizeText,
|
||||
hasModel: draft.hasModel,
|
||||
materialPreference: draft.materialPreference,
|
||||
quantity: draft.quantity,
|
||||
urgency: draft.urgency,
|
||||
deliveryType: draft.deliveryType
|
||||
})
|
||||
});
|
||||
window.sessionStorage.removeItem(storageKey);
|
||||
setStatus("Consulta enviada.");
|
||||
router.push("/account/inbox");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo enviar la consulta");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wizard-shell">
|
||||
<div className="panel wizard-context stack">
|
||||
<span className="eyebrow">Consulta guiada</span>
|
||||
<h1 className="section-title">Paso {step} de 5</h1>
|
||||
<div className="list-item stack">
|
||||
<strong>{contextTitle}</strong>
|
||||
<span className="mini-note">Maker: {makerName}</span>
|
||||
<span className="mini-note">Si vuelves tras registrarte, el borrador se conserva.</span>
|
||||
</div>
|
||||
<div className="wizard-progress">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<span key={value} className={`wizard-dot ${value <= step ? "active" : ""}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel stack">
|
||||
{step === 1 && (
|
||||
<>
|
||||
<span className="eyebrow">Necesidad</span>
|
||||
<h2 className="section-title">Que necesitas resolver?</h2>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Quiero algo igual, modificar una pieza o fabricar algo parecido..."
|
||||
value={draft.needText}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, needText: event.target.value }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<span className="eyebrow">Detalles del trabajo</span>
|
||||
<h2 className="section-title">Dimension, material y referencia</h2>
|
||||
<div className="input-grid-compact">
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Tamano o medidas"
|
||||
value={draft.sizeText}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, sizeText: event.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Material preferido"
|
||||
value={draft.materialPreference}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, materialPreference: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<label className="choice-chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.hasModel}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, hasModel: event.target.checked }))}
|
||||
/>
|
||||
<span>Ya tengo archivo o una pieza de referencia</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Describe el archivo, la pieza o lo que puedes adjuntar despues en la conversacion."
|
||||
value={draft.referenceNotes}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, referenceNotes: event.target.value }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<>
|
||||
<span className="eyebrow">Uso final</span>
|
||||
<h2 className="section-title">Para que se utilizara?</h2>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Pieza funcional, exterior, calor, agua, decoracion, prototipo..."
|
||||
value={draft.usageText}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, usageText: event.target.value }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<>
|
||||
<span className="eyebrow">Urgencia y entrega</span>
|
||||
<h2 className="section-title">Cantidad, plazo y modalidad</h2>
|
||||
<div className="input-grid-compact">
|
||||
<input
|
||||
className="field"
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.quantity}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, quantity: Number(event.target.value) }))}
|
||||
/>
|
||||
<select
|
||||
className="select"
|
||||
value={draft.urgency}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, urgency: event.target.value }))}
|
||||
>
|
||||
<option value="low">Sin urgencia</option>
|
||||
<option value="medium">Esta semana</option>
|
||||
<option value="high">Urgente</option>
|
||||
</select>
|
||||
<select
|
||||
className="select"
|
||||
value={draft.deliveryType}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, deliveryType: event.target.value }))}
|
||||
>
|
||||
<option value="shipping">Necesito envio</option>
|
||||
<option value="local">Puedo retirar</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<>
|
||||
<span className="eyebrow">Resumen</span>
|
||||
<h2 className="section-title">Revisa antes de enviar</h2>
|
||||
<div className="checklist-list">
|
||||
<div className="checklist-item"><strong>Necesidad</strong><span>{draft.needText || "Pendiente"}</span></div>
|
||||
<div className="checklist-item"><strong>Detalles</strong><span>{draft.sizeText || "Sin medidas"} / {draft.materialPreference || "Sin material"}</span></div>
|
||||
<div className="checklist-item"><strong>Uso final</strong><span>{draft.usageText || "No indicado"}</span></div>
|
||||
<div className="checklist-item"><strong>Entrega</strong><span>{draft.quantity} unidad / {draft.deliveryType}</span></div>
|
||||
</div>
|
||||
{!me ? (
|
||||
<p className="muted">Para enviar la consulta necesitas crear cuenta o entrar. El borrador no se pierde.</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="wizard-actions">
|
||||
<a href={returnTo} className="button button-ghost">Volver</a>
|
||||
<div className="wizard-actions-right">
|
||||
{step > 1 ? <button className="button button-secondary" type="button" onClick={previousStep}>Anterior</button> : null}
|
||||
{step < 5 ? (
|
||||
<button className="button button-primary" type="button" onClick={nextStep}>Continuar</button>
|
||||
) : (
|
||||
<button className="button button-primary" type="button" onClick={submitInquiry}>Enviar consulta</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{status ? <p className="muted">{status}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { divIcon, type DivIcon, type Map as LeafletMap } from "leaflet";
|
||||
import { Circle, CircleMarker, MapContainer, Marker, Popup, TileLayer, useMap, useMapEvents } from "react-leaflet";
|
||||
|
||||
type DiscoverMapMarker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
businessName: string;
|
||||
city: string;
|
||||
province: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
type DisplayMarker = DiscoverMapMarker & {
|
||||
displayLatitude: number;
|
||||
displayLongitude: number;
|
||||
radius: number;
|
||||
};
|
||||
|
||||
type ClusterGroup = {
|
||||
key: string;
|
||||
count: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
markers: DisplayMarker[];
|
||||
};
|
||||
|
||||
const cordobaCenter: [number, number] = [-31.4201, -64.1888];
|
||||
const userApproximationRadius = 900;
|
||||
|
||||
function hashValue(input: string, seed: number) {
|
||||
let value = seed;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
value = (value * 33 + input.charCodeAt(index) + index) % 1000003;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function toOffset(value: number, spread: number) {
|
||||
return ((value % 1000) / 999 - 0.5) * spread;
|
||||
}
|
||||
|
||||
function toDisplayMarkers(markers: DiscoverMapMarker[]): DisplayMarker[] {
|
||||
return markers.map((marker) => {
|
||||
const latSeed = hashValue(marker.id, 17);
|
||||
const lngSeed = hashValue(marker.slug, 29);
|
||||
const radiusSeed = hashValue(marker.businessName, 43);
|
||||
|
||||
return {
|
||||
...marker,
|
||||
displayLatitude: marker.latitude + toOffset(latSeed, 0.0072),
|
||||
displayLongitude: marker.longitude + toOffset(lngSeed, 0.0094),
|
||||
radius: 8 + (radiusSeed % 4)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getClusterRadius(zoom: number) {
|
||||
if (zoom >= 15) {
|
||||
return 0;
|
||||
}
|
||||
if (zoom >= 14) {
|
||||
return 34;
|
||||
}
|
||||
if (zoom >= 13) {
|
||||
return 42;
|
||||
}
|
||||
if (zoom >= 12) {
|
||||
return 50;
|
||||
}
|
||||
if (zoom >= 11) {
|
||||
return 58;
|
||||
}
|
||||
return 66;
|
||||
}
|
||||
|
||||
function buildClusterGroups(markers: DisplayMarker[], map: LeafletMap, zoom: number) {
|
||||
const clusterRadius = getClusterRadius(zoom);
|
||||
|
||||
if (clusterRadius === 0) {
|
||||
return markers.map((marker) => ({
|
||||
key: marker.id,
|
||||
count: 1,
|
||||
latitude: marker.displayLatitude,
|
||||
longitude: marker.displayLongitude,
|
||||
markers: [marker]
|
||||
}));
|
||||
}
|
||||
|
||||
const projectedMarkers = markers.map((marker) => ({
|
||||
marker,
|
||||
point: map.project([marker.displayLatitude, marker.displayLongitude], zoom)
|
||||
}));
|
||||
const visited = new Set<number>();
|
||||
const groups: ClusterGroup[] = [];
|
||||
|
||||
for (let index = 0; index < projectedMarkers.length; index += 1) {
|
||||
if (visited.has(index)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queue = [index];
|
||||
const memberIndexes: number[] = [];
|
||||
visited.add(index);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentIndex = queue.shift() as number;
|
||||
const currentMarker = projectedMarkers[currentIndex];
|
||||
memberIndexes.push(currentIndex);
|
||||
|
||||
for (let candidateIndex = 0; candidateIndex < projectedMarkers.length; candidateIndex += 1) {
|
||||
if (visited.has(candidateIndex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateMarker = projectedMarkers[candidateIndex];
|
||||
const deltaX = currentMarker.point.x - candidateMarker.point.x;
|
||||
const deltaY = currentMarker.point.y - candidateMarker.point.y;
|
||||
|
||||
if (Math.hypot(deltaX, deltaY) <= clusterRadius) {
|
||||
visited.add(candidateIndex);
|
||||
queue.push(candidateIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bucket = memberIndexes.map((memberIndex) => projectedMarkers[memberIndex].marker);
|
||||
const latitude = bucket.reduce((sum, marker) => sum + marker.displayLatitude, 0) / bucket.length;
|
||||
const longitude = bucket.reduce((sum, marker) => sum + marker.displayLongitude, 0) / bucket.length;
|
||||
|
||||
groups.push({
|
||||
key: bucket.map((marker) => marker.id).sort().join(":"),
|
||||
count: bucket.length,
|
||||
latitude,
|
||||
longitude,
|
||||
markers: bucket
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function buildClusterIcon(count: number): DivIcon {
|
||||
const size = count >= 10 ? 60 : count >= 5 ? 54 : 48;
|
||||
const toneClass = count >= 10 ? "is-large" : count >= 5 ? "is-medium" : "is-small";
|
||||
|
||||
return divIcon({
|
||||
className: "map-home-cluster-icon-shell",
|
||||
html: `<span class="map-home-cluster-badge ${toneClass}">${count}</span>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2]
|
||||
});
|
||||
}
|
||||
|
||||
function FitMapToMarkers({ markers }: { markers: DisplayMarker[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (markers.length === 0) {
|
||||
map.setView(cordobaCenter, 12);
|
||||
return;
|
||||
}
|
||||
|
||||
map.fitBounds(
|
||||
markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
|
||||
{
|
||||
padding: [40, 40],
|
||||
maxZoom: 14
|
||||
}
|
||||
);
|
||||
}, [map, markers]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function RegisterMapInstance({ mapRef }: { mapRef: MutableRefObject<LeafletMap | null> }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
if (mapRef.current === map) {
|
||||
mapRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [map, mapRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ClusteredMarkerLayer({
|
||||
markers,
|
||||
selectedMarkerId,
|
||||
onSelectMarker
|
||||
}: {
|
||||
markers: DisplayMarker[];
|
||||
selectedMarkerId?: string;
|
||||
onSelectMarker: (makerId: string) => void;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const [zoom, setZoom] = useState(() => map.getZoom());
|
||||
|
||||
useMapEvents({
|
||||
zoomend() {
|
||||
setZoom(map.getZoom());
|
||||
}
|
||||
});
|
||||
|
||||
const groups = buildClusterGroups(markers, map, zoom);
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => {
|
||||
if (group.count === 1) {
|
||||
const marker = group.markers[0];
|
||||
const isSelected = marker.id === selectedMarkerId;
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={marker.id}
|
||||
center={[marker.displayLatitude, marker.displayLongitude]}
|
||||
radius={isSelected ? marker.radius + 4 : marker.radius}
|
||||
pathOptions={{
|
||||
color: "#ffffff",
|
||||
weight: isSelected ? 3 : 2,
|
||||
fillColor: isSelected ? "#4f89ff" : "#10358d",
|
||||
fillOpacity: 0.98
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => onSelectMarker(marker.id)
|
||||
}}
|
||||
>
|
||||
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
|
||||
<div className="map-home-popup-card">
|
||||
<strong>{marker.businessName}</strong>
|
||||
<span>{marker.city}, {marker.province}</span>
|
||||
<a href={`/makers/${marker.slug}`}>Ver maker</a>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={group.key}
|
||||
position={[group.latitude, group.longitude]}
|
||||
icon={buildClusterIcon(group.count)}
|
||||
eventHandlers={{
|
||||
click: () => {
|
||||
map.fitBounds(
|
||||
group.markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
|
||||
{
|
||||
padding: [48, 48],
|
||||
maxZoom: 15,
|
||||
animate: true
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
|
||||
<div className="map-home-popup-card">
|
||||
<strong>{group.count} makers en esta zona</strong>
|
||||
<span>Toca el grupo para acercar y separarlos.</span>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InteractiveDiscoverMap({
|
||||
markers,
|
||||
selectedMarkerId,
|
||||
onSelectMarker,
|
||||
onApplyVisibleArea,
|
||||
focusRequest
|
||||
}: {
|
||||
markers: DiscoverMapMarker[];
|
||||
selectedMarkerId?: string;
|
||||
onSelectMarker: (makerId: string) => void;
|
||||
onApplyVisibleArea: (makerIds: string[]) => void;
|
||||
focusRequest: number;
|
||||
}) {
|
||||
const mapRef = useRef<LeafletMap | null>(null);
|
||||
const displayMarkers = toDisplayMarkers(markers);
|
||||
|
||||
const focusMarkers = () => {
|
||||
const map = mapRef.current;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (markers.length === 0) {
|
||||
map.setView(cordobaCenter, 12, { animate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
map.fitBounds(
|
||||
displayMarkers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
|
||||
{
|
||||
padding: [40, 40],
|
||||
maxZoom: 14,
|
||||
animate: true
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const applyVisibleArea = () => {
|
||||
const map = mapRef.current;
|
||||
if (!map) {
|
||||
onApplyVisibleArea(markers.map((marker) => marker.id));
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const visibleIds = displayMarkers
|
||||
.filter((marker) => bounds.contains([marker.displayLatitude, marker.displayLongitude]))
|
||||
.map((marker) => marker.id);
|
||||
|
||||
onApplyVisibleArea(visibleIds);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequest > 0) {
|
||||
focusMarkers();
|
||||
}
|
||||
}, [focusRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !selectedMarkerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedMarker = displayMarkers.find((marker) => marker.id === selectedMarkerId);
|
||||
if (!selectedMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.flyTo([selectedMarker.displayLatitude, selectedMarker.displayLongitude], Math.max(map.getZoom(), 13), {
|
||||
animate: true,
|
||||
duration: 0.6
|
||||
});
|
||||
}, [selectedMarkerId, displayMarkers]);
|
||||
|
||||
return (
|
||||
<section className="discover-map-stage">
|
||||
<div className="discover-map-surface">
|
||||
<MapContainer center={cordobaCenter} zoom={13} scrollWheelZoom className="discover-leaflet">
|
||||
<RegisterMapInstance mapRef={mapRef} />
|
||||
|
||||
<TileLayer
|
||||
attribution="© OpenStreetMap contributors © CARTO"
|
||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
/>
|
||||
|
||||
<FitMapToMarkers markers={displayMarkers} />
|
||||
|
||||
<Circle
|
||||
center={cordobaCenter}
|
||||
radius={userApproximationRadius}
|
||||
pathOptions={{
|
||||
color: "#2d7cff",
|
||||
weight: 2,
|
||||
fillColor: "#2d7cff",
|
||||
fillOpacity: 0.14
|
||||
}}
|
||||
/>
|
||||
|
||||
<CircleMarker
|
||||
center={cordobaCenter}
|
||||
radius={8}
|
||||
pathOptions={{
|
||||
color: "#ffffff",
|
||||
weight: 2,
|
||||
fillColor: "#2e7cff",
|
||||
fillOpacity: 1
|
||||
}}
|
||||
/>
|
||||
|
||||
<ClusteredMarkerLayer
|
||||
markers={displayMarkers}
|
||||
selectedMarkerId={selectedMarkerId}
|
||||
onSelectMarker={onSelectMarker}
|
||||
/>
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
<div className="discover-map-overlay" aria-hidden="false">
|
||||
<div className="discover-map-side-tools">
|
||||
<button className="discover-map-control" type="button" aria-label="Recentrar mapa" onClick={focusMarkers}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5.4" />
|
||||
<path d="M12 2v3" />
|
||||
<path d="M12 19v3" />
|
||||
<path d="M2 12h3" />
|
||||
<path d="M19 12h3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button className="discover-map-control" type="button" aria-label="Capas del mapa">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 4 8 4-8 4-8-4 8-4Z" />
|
||||
<path d="m4 12 8 4 8-4" />
|
||||
<path d="m4 16 8 4 8-4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button className="discover-map-radar" type="button" aria-label="Radar maker">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="2.2" />
|
||||
<path d="M12 5a7 7 0 0 1 7 7" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" />
|
||||
<path d="M5 12a7 7 0 0 1 7-7" />
|
||||
</svg>
|
||||
<span>Radar Maker</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button className="discover-map-cta" type="button" onClick={applyVisibleArea}>
|
||||
Buscar en esta zona
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export type DiscoverMapMarker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
businessName: string;
|
||||
city: string;
|
||||
province: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
const InteractiveDiscoverMap = dynamic(
|
||||
() => import("./InteractiveDiscoverMap"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<section className="discover-map-stage">
|
||||
<div className="discover-map-loading">Cargando mapa real...</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
export default function InteractiveDiscoverMapClient({
|
||||
markers,
|
||||
selectedMarkerId,
|
||||
onSelectMarker,
|
||||
onApplyVisibleArea,
|
||||
focusRequest
|
||||
}: {
|
||||
markers: DiscoverMapMarker[];
|
||||
selectedMarkerId?: string;
|
||||
onSelectMarker: (makerId: string) => void;
|
||||
onApplyVisibleArea: (makerIds: string[]) => void;
|
||||
focusRequest: number;
|
||||
}) {
|
||||
return (
|
||||
<InteractiveDiscoverMap
|
||||
markers={markers}
|
||||
selectedMarkerId={selectedMarkerId}
|
||||
onSelectMarker={onSelectMarker}
|
||||
onApplyVisibleArea={onApplyVisibleArea}
|
||||
focusRequest={focusRequest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { divIcon, type DivIcon, type Map as LeafletMap } from "leaflet";
|
||||
import { Circle, CircleMarker, MapContainer, Marker, Popup, TileLayer, useMap, useMapEvents } from "react-leaflet";
|
||||
|
||||
type MakerMarker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
businessName: string;
|
||||
city: string;
|
||||
province: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
type DisplayMarker = MakerMarker & {
|
||||
displayLatitude: number;
|
||||
displayLongitude: number;
|
||||
radius: number;
|
||||
};
|
||||
|
||||
type ClusterGroup = {
|
||||
key: string;
|
||||
count: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
markers: DisplayMarker[];
|
||||
};
|
||||
|
||||
const cordobaCenter: [number, number] = [-31.4201, -64.1888];
|
||||
const userApproximationRadius = 900;
|
||||
|
||||
function hashValue(input: string, seed: number) {
|
||||
let value = seed;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
value = (value * 33 + input.charCodeAt(index) + index) % 1000003;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function toOffset(value: number, spread: number) {
|
||||
return ((value % 1000) / 999 - 0.5) * spread;
|
||||
}
|
||||
|
||||
function toDisplayMarkers(markers: MakerMarker[]): DisplayMarker[] {
|
||||
return markers.map((marker) => {
|
||||
const latSeed = hashValue(marker.id, 17);
|
||||
const lngSeed = hashValue(marker.slug, 29);
|
||||
const radiusSeed = hashValue(marker.businessName, 43);
|
||||
|
||||
return {
|
||||
...marker,
|
||||
displayLatitude: marker.latitude + toOffset(latSeed, 0.0072),
|
||||
displayLongitude: marker.longitude + toOffset(lngSeed, 0.0094),
|
||||
radius: 8 + (radiusSeed % 4)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getClusterRadius(zoom: number) {
|
||||
if (zoom >= 15) {
|
||||
return 0;
|
||||
}
|
||||
if (zoom >= 14) {
|
||||
return 34;
|
||||
}
|
||||
if (zoom >= 13) {
|
||||
return 42;
|
||||
}
|
||||
if (zoom >= 12) {
|
||||
return 50;
|
||||
}
|
||||
if (zoom >= 11) {
|
||||
return 58;
|
||||
}
|
||||
return 66;
|
||||
}
|
||||
|
||||
function buildClusterGroups(markers: DisplayMarker[], map: LeafletMap, zoom: number) {
|
||||
const clusterRadius = getClusterRadius(zoom);
|
||||
|
||||
if (clusterRadius === 0) {
|
||||
return markers.map((marker) => ({
|
||||
key: marker.id,
|
||||
count: 1,
|
||||
latitude: marker.displayLatitude,
|
||||
longitude: marker.displayLongitude,
|
||||
markers: [marker]
|
||||
}));
|
||||
}
|
||||
|
||||
const projectedMarkers = markers.map((marker) => ({
|
||||
marker,
|
||||
point: map.project([marker.displayLatitude, marker.displayLongitude], zoom)
|
||||
}));
|
||||
const visited = new Set<number>();
|
||||
const groups: ClusterGroup[] = [];
|
||||
|
||||
for (let index = 0; index < projectedMarkers.length; index += 1) {
|
||||
if (visited.has(index)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queue = [index];
|
||||
const memberIndexes: number[] = [];
|
||||
visited.add(index);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentIndex = queue.shift() as number;
|
||||
const currentMarker = projectedMarkers[currentIndex];
|
||||
memberIndexes.push(currentIndex);
|
||||
|
||||
for (let candidateIndex = 0; candidateIndex < projectedMarkers.length; candidateIndex += 1) {
|
||||
if (visited.has(candidateIndex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateMarker = projectedMarkers[candidateIndex];
|
||||
const deltaX = currentMarker.point.x - candidateMarker.point.x;
|
||||
const deltaY = currentMarker.point.y - candidateMarker.point.y;
|
||||
|
||||
if (Math.hypot(deltaX, deltaY) <= clusterRadius) {
|
||||
visited.add(candidateIndex);
|
||||
queue.push(candidateIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bucket = memberIndexes.map((memberIndex) => projectedMarkers[memberIndex].marker);
|
||||
const latitude = bucket.reduce((sum, marker) => sum + marker.displayLatitude, 0) / bucket.length;
|
||||
const longitude = bucket.reduce((sum, marker) => sum + marker.displayLongitude, 0) / bucket.length;
|
||||
|
||||
groups.push({
|
||||
key: bucket.map((marker) => marker.id).sort().join(":"),
|
||||
count: bucket.length,
|
||||
latitude,
|
||||
longitude,
|
||||
markers: bucket
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function buildClusterIcon(count: number): DivIcon {
|
||||
const size = count >= 10 ? 60 : count >= 5 ? 54 : 48;
|
||||
const toneClass = count >= 10 ? "is-large" : count >= 5 ? "is-medium" : "is-small";
|
||||
|
||||
return divIcon({
|
||||
className: "map-home-cluster-icon-shell",
|
||||
html: `<span class="map-home-cluster-badge ${toneClass}">${count}</span>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2]
|
||||
});
|
||||
}
|
||||
|
||||
function FitMapToMarkers({ markers }: { markers: DisplayMarker[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (markers.length === 0) {
|
||||
map.setView(cordobaCenter, 12);
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]);
|
||||
map.fitBounds(bounds, {
|
||||
padding: [40, 40],
|
||||
maxZoom: 14
|
||||
});
|
||||
}, [map, markers]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ClusteredMarkerLayer({ markers }: { markers: DisplayMarker[] }) {
|
||||
const map = useMap();
|
||||
const [zoom, setZoom] = useState(() => map.getZoom());
|
||||
|
||||
useMapEvents({
|
||||
zoomend() {
|
||||
setZoom(map.getZoom());
|
||||
}
|
||||
});
|
||||
|
||||
const groups = buildClusterGroups(markers, map, zoom);
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => {
|
||||
if (group.count === 1) {
|
||||
const marker = group.markers[0];
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={marker.id}
|
||||
center={[marker.displayLatitude, marker.displayLongitude]}
|
||||
radius={marker.radius}
|
||||
pathOptions={{
|
||||
color: "#ffffff",
|
||||
weight: 2,
|
||||
fillColor: "#10358d",
|
||||
fillOpacity: 0.95
|
||||
}}
|
||||
>
|
||||
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
|
||||
<div className="map-home-popup-card">
|
||||
<strong>{marker.businessName}</strong>
|
||||
<span>{marker.city}, {marker.province}</span>
|
||||
<a href={`/makers/${marker.slug}`}>Ver maker</a>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={group.key}
|
||||
position={[group.latitude, group.longitude]}
|
||||
icon={buildClusterIcon(group.count)}
|
||||
eventHandlers={{
|
||||
click: () => {
|
||||
map.fitBounds(
|
||||
group.markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
|
||||
{
|
||||
padding: [48, 48],
|
||||
maxZoom: 15,
|
||||
animate: true
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
|
||||
<div className="map-home-popup-card">
|
||||
<strong>{group.count} makers en esta zona</strong>
|
||||
<span>Toca el grupo para acercar y separarlos.</span>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RegisterMapInstance({ mapRef }: { mapRef: MutableRefObject<LeafletMap | null> }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
if (mapRef.current === map) {
|
||||
mapRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [map, mapRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function InteractiveMakerMap({ markers }: { markers: MakerMarker[] }) {
|
||||
const mapRef = useRef<LeafletMap | null>(null);
|
||||
const displayMarkers = toDisplayMarkers(markers);
|
||||
|
||||
const focusMarkers = () => {
|
||||
const map = mapRef.current;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (markers.length === 0) {
|
||||
map.setView(cordobaCenter, 12, { animate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
map.fitBounds(
|
||||
displayMarkers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
|
||||
{
|
||||
padding: [40, 40],
|
||||
maxZoom: 14,
|
||||
animate: true
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="map-home-map">
|
||||
<div className="map-home-realmap">
|
||||
<MapContainer
|
||||
center={cordobaCenter}
|
||||
zoom={13}
|
||||
scrollWheelZoom
|
||||
className="map-home-leaflet"
|
||||
>
|
||||
<RegisterMapInstance mapRef={mapRef} />
|
||||
|
||||
<TileLayer
|
||||
attribution='© OpenStreetMap contributors © CARTO'
|
||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
/>
|
||||
|
||||
<FitMapToMarkers markers={displayMarkers} />
|
||||
|
||||
<Circle
|
||||
center={cordobaCenter}
|
||||
radius={userApproximationRadius}
|
||||
pathOptions={{
|
||||
color: "#1d67ff",
|
||||
weight: 2,
|
||||
fillColor: "#2e7cff",
|
||||
fillOpacity: 0.1
|
||||
}}
|
||||
/>
|
||||
|
||||
<CircleMarker
|
||||
center={cordobaCenter}
|
||||
radius={8}
|
||||
pathOptions={{
|
||||
color: "#ffffff",
|
||||
weight: 2,
|
||||
fillColor: "#2e7cff",
|
||||
fillOpacity: 1
|
||||
}}
|
||||
/>
|
||||
|
||||
<ClusteredMarkerLayer markers={displayMarkers} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
<div className="map-home-map-overlay" aria-hidden="false">
|
||||
<button className="map-home-crosshair" type="button" aria-label="Recentrar mapa" onClick={focusMarkers}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5.4" />
|
||||
<path d="M12 2v3" />
|
||||
<path d="M12 19v3" />
|
||||
<path d="M2 12h3" />
|
||||
<path d="M19 12h3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button className="map-home-area-cta" type="button" onClick={focusMarkers}>
|
||||
Buscar en esta area
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
type MakerMarker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
businessName: string;
|
||||
city: string;
|
||||
province: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
const InteractiveMakerMap = dynamic(() => import("./InteractiveMakerMap"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<section className="map-home-map">
|
||||
<div className="map-home-map-loading">Cargando mapa real...</div>
|
||||
</section>
|
||||
)
|
||||
});
|
||||
|
||||
export default function InteractiveMakerMapClient({ markers }: { markers: MakerMarker[] }) {
|
||||
return <InteractiveMakerMap markers={markers} />;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
import { notifyAuthChanged } from "../lib/authEvents";
|
||||
|
||||
export default function LoginForm({ redirectTo = "/account" }: { redirectTo?: string }) {
|
||||
const [error, setError] = useState("");
|
||||
const [form, setForm] = useState({ email: "", password: "" });
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
const response = await browserFetch<{ user: { role: string } }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(form)
|
||||
});
|
||||
notifyAuthChanged("login");
|
||||
window.location.href = response.user.role === "admin" ? "/app/backoffice" : redirectTo;
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "No se pudo entrar");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="stack" onSubmit={onSubmit}>
|
||||
<input className="field" type="email" placeholder="Correo" value={form.email} onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))} required />
|
||||
<input className="field" type="password" placeholder="Contrasena" value={form.password} onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} required />
|
||||
<button className="button button-primary" type="submit">Entrar</button>
|
||||
{error ? <p className="muted">{error}</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
|
||||
export type MapHomeMakerListItem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
city: string;
|
||||
province: string;
|
||||
imageUrl: string;
|
||||
distance: string;
|
||||
rating: string;
|
||||
reviewCount: number;
|
||||
satisfaction: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type FavoritesResponse = {
|
||||
makers: Array<{ id: string; slug: string }>;
|
||||
};
|
||||
|
||||
function loginForCurrentPage() {
|
||||
const currentPath = `${window.location.pathname}${window.location.search}`;
|
||||
window.location.assign(`/login?returnTo=${encodeURIComponent(currentPath)}`);
|
||||
}
|
||||
|
||||
export function MapHomeMakerList({ makers }: { makers: MapHomeMakerListItem[] }) {
|
||||
const [favoriteSlugs, setFavoriteSlugs] = useState<Set<string>>(new Set());
|
||||
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
|
||||
const [loadingSlug, setLoadingSlug] = useState("");
|
||||
const [needsLogin, setNeedsLogin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadFavorites() {
|
||||
try {
|
||||
const response = await browserFetch<FavoritesResponse>("/favorites");
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setFavoriteSlugs(new Set(response.makers.map((maker) => maker.slug)));
|
||||
setFavoriteIds(new Set(response.makers.map((maker) => maker.id)));
|
||||
setNeedsLogin(false);
|
||||
} catch (error) {
|
||||
const apiError = error as Error & { status?: number };
|
||||
if (active && (apiError.status === 401 || apiError.status === 403 || apiError.message.includes("Authentication"))) {
|
||||
setNeedsLogin(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadFavorites();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function toggleFavorite(maker: MapHomeMakerListItem) {
|
||||
if (needsLogin) {
|
||||
loginForCurrentPage();
|
||||
return;
|
||||
}
|
||||
|
||||
const isFavorite = favoriteSlugs.has(maker.slug) || favoriteIds.has(maker.id);
|
||||
setLoadingSlug(maker.slug);
|
||||
|
||||
try {
|
||||
await browserFetch<{ favorited: boolean }>(`/favorites/makers/${encodeURIComponent(maker.slug)}`, {
|
||||
method: isFavorite ? "DELETE" : "POST"
|
||||
});
|
||||
|
||||
setFavoriteSlugs((current) => {
|
||||
const next = new Set(current);
|
||||
if (isFavorite) {
|
||||
next.delete(maker.slug);
|
||||
} else {
|
||||
next.add(maker.slug);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setFavoriteIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (isFavorite) {
|
||||
next.delete(maker.id);
|
||||
} else {
|
||||
next.add(maker.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
const apiError = error as Error & { status?: number };
|
||||
if (apiError.status === 401 || apiError.status === 403 || apiError.message.includes("Authentication")) {
|
||||
loginForCurrentPage();
|
||||
}
|
||||
} finally {
|
||||
setLoadingSlug("");
|
||||
}
|
||||
}
|
||||
|
||||
if (makers.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
No encontramos makers con esa busqueda. Prueba otra categoria o abre la vista completa de descubrir.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="map-home-maker-list">
|
||||
{makers.map((maker) => {
|
||||
const isFavorite = favoriteSlugs.has(maker.slug) || favoriteIds.has(maker.id);
|
||||
return (
|
||||
<article key={maker.id} className="map-home-maker-card">
|
||||
<Link
|
||||
href={`/makers/${maker.slug}`}
|
||||
className="map-home-maker-card-main map-home-maker-card-link"
|
||||
aria-label={`Abrir perfil de ${maker.name}`}
|
||||
>
|
||||
<div className="map-home-maker-media">
|
||||
<img className="thumb" src={maker.imageUrl} alt={maker.name} />
|
||||
<span className="map-home-distance-pill">{maker.distance}</span>
|
||||
</div>
|
||||
<div className="map-home-maker-body">
|
||||
<div className="map-home-maker-topline">
|
||||
<div className="map-home-maker-brand">
|
||||
<span className="map-home-maker-badge">{maker.name.slice(0, 2).toUpperCase()}</span>
|
||||
<div className="map-home-maker-copy">
|
||||
<div className="map-home-maker-heading">
|
||||
<strong>{maker.name}</strong>
|
||||
<span className="map-home-online-dot" />
|
||||
</div>
|
||||
<span className="map-home-maker-subline">{maker.city}, {maker.province}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="map-home-maker-score">
|
||||
<span className="map-home-star">*</span>
|
||||
<span>{maker.rating} ({maker.reviewCount})</span>
|
||||
<span>|</span>
|
||||
<span>{maker.satisfaction}</span>
|
||||
</div>
|
||||
|
||||
<div className="map-home-tag-row">
|
||||
{maker.tags.map((tag) => (
|
||||
<span key={`${maker.id}-${tag}`} className="map-home-tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
className={`map-home-bookmark ${isFavorite ? "is-active" : ""}`}
|
||||
type="button"
|
||||
aria-label={isFavorite ? `Quitar ${maker.name} de favoritos` : `Guardar ${maker.name} en favoritos`}
|
||||
aria-pressed={isFavorite}
|
||||
disabled={loadingSlug === maker.slug}
|
||||
onClick={() => void toggleFavorite(maker)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M7 4.5h10a1 1 0 0 1 1 1V20l-6-3.2L6 20V5.5a1 1 0 0 1 1-1Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import InteractiveMakerMapClient from "./InteractiveMakerMapClient";
|
||||
import { MapHomeMakerList, type MapHomeMakerListItem } from "./MapHomeMakerList";
|
||||
import { serverFetch } from "../lib/api";
|
||||
|
||||
type PublicMaker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
business_name: string;
|
||||
description: string;
|
||||
province: string;
|
||||
city: string;
|
||||
main_image_url: string | null;
|
||||
avg_rating: number | string;
|
||||
review_count: number;
|
||||
service_count: number;
|
||||
work_count: number;
|
||||
public_latitude: number | null;
|
||||
public_longitude: number | null;
|
||||
public_locations?: Array<{
|
||||
id: string;
|
||||
label?: string | null;
|
||||
city?: string | null;
|
||||
province?: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
}>;
|
||||
categories?: string[];
|
||||
};
|
||||
|
||||
type SearchParams = Record<string, string | string[] | undefined>;
|
||||
type HomeCategory = "all" | "print-3d" | "design-3d" | "resin" | "fdm";
|
||||
|
||||
const quickCategories: Array<{ id: HomeCategory; label: string }> = [
|
||||
{ id: "print-3d", label: "Impresion 3D" },
|
||||
{ id: "design-3d", label: "Diseno 3D" },
|
||||
{ id: "resin", label: "Resina" },
|
||||
{ id: "fdm", label: "FDM" }
|
||||
];
|
||||
|
||||
const preferredOrder = [
|
||||
"MakerLab 3D",
|
||||
"PrintCraft",
|
||||
"3D Ideas",
|
||||
"ImpresionAR",
|
||||
"ProtoWorks",
|
||||
"Resin Forge"
|
||||
];
|
||||
|
||||
const distanceLabels = ["1,2 km", "1,8 km", "2,1 km", "2,4 km", "2,6 km", "2,9 km", "3,2 km"];
|
||||
|
||||
function getMakerImage(maker: PublicMaker) {
|
||||
return maker.main_image_url || "/demo/maker-hero-1.svg";
|
||||
}
|
||||
|
||||
function readParam(value: string | string[] | undefined) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function getRating(value: number | string) {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function getSatisfaction(value: number | string) {
|
||||
const rating = getRating(value);
|
||||
return `${Math.max(92, Math.round(rating * 20))}% satisfaccion`;
|
||||
}
|
||||
|
||||
function normalizeCategory(value: string): HomeCategory {
|
||||
if (value === "print-3d" || value === "design-3d" || value === "resin" || value === "fdm") {
|
||||
return value;
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
function hasCategory(maker: PublicMaker, expected: string) {
|
||||
return (maker.categories || []).some((category) => category.toLowerCase() === expected.toLowerCase());
|
||||
}
|
||||
|
||||
function filterByCategory(makers: PublicMaker[], category: HomeCategory) {
|
||||
if (category === "all") {
|
||||
return makers;
|
||||
}
|
||||
|
||||
if (category === "print-3d") {
|
||||
return makers.filter((maker) => hasCategory(maker, "Impresion FDM") || hasCategory(maker, "Impresion Resina") || hasCategory(maker, "Prototipado"));
|
||||
}
|
||||
|
||||
if (category === "design-3d") {
|
||||
return makers.filter((maker) => hasCategory(maker, "Diseno 3D"));
|
||||
}
|
||||
|
||||
if (category === "resin") {
|
||||
return makers.filter((maker) => hasCategory(maker, "Impresion Resina"));
|
||||
}
|
||||
|
||||
return makers.filter((maker) => hasCategory(maker, "Impresion FDM"));
|
||||
}
|
||||
|
||||
function orderMakers(makers: PublicMaker[]) {
|
||||
return [...makers].sort((left, right) => {
|
||||
const leftIndex = preferredOrder.indexOf(left.business_name);
|
||||
const rightIndex = preferredOrder.indexOf(right.business_name);
|
||||
|
||||
if (leftIndex !== -1 || rightIndex !== -1) {
|
||||
return (leftIndex === -1 ? 999 : leftIndex) - (rightIndex === -1 ? 999 : rightIndex);
|
||||
}
|
||||
|
||||
return getRating(right.avg_rating) - getRating(left.avg_rating);
|
||||
});
|
||||
}
|
||||
|
||||
function getMakerTags(maker: PublicMaker) {
|
||||
const tags = new Set<string>();
|
||||
|
||||
for (const category of maker.categories || []) {
|
||||
if (category === "Impresion FDM") {
|
||||
tags.add("FDM");
|
||||
}
|
||||
if (category === "Impresion Resina") {
|
||||
tags.add("Resina");
|
||||
}
|
||||
if (category === "Diseno 3D") {
|
||||
tags.add("Diseno 3D");
|
||||
}
|
||||
if (category === "Prototipado") {
|
||||
tags.add("Prototipos");
|
||||
}
|
||||
}
|
||||
|
||||
if (tags.size === 0) {
|
||||
tags.add("Impresion 3D");
|
||||
}
|
||||
|
||||
return Array.from(tags).slice(0, 3);
|
||||
}
|
||||
|
||||
function buildHomeHref(query: string, category: HomeCategory) {
|
||||
const params = new URLSearchParams();
|
||||
if (query) {
|
||||
params.set("q", query);
|
||||
}
|
||||
if (category !== "all") {
|
||||
params.set("category", category);
|
||||
}
|
||||
const serialized = params.toString();
|
||||
return serialized ? `/?${serialized}` : "/";
|
||||
}
|
||||
|
||||
function buildDiscoverHref(query: string, category: HomeCategory) {
|
||||
const params = new URLSearchParams();
|
||||
if (query) {
|
||||
params.set("q", query);
|
||||
}
|
||||
if (category === "design-3d") {
|
||||
params.set("serviceCategory", "Diseno 3D");
|
||||
}
|
||||
if (category === "resin") {
|
||||
params.set("serviceCategory", "Impresion Resina");
|
||||
}
|
||||
if (category === "fdm") {
|
||||
params.set("serviceCategory", "Impresion FDM");
|
||||
}
|
||||
const serialized = params.toString();
|
||||
return serialized ? `/discover?${serialized}` : "/discover";
|
||||
}
|
||||
|
||||
export async function MapHomeScreen({ searchParams = {} }: { searchParams?: SearchParams }) {
|
||||
const query = readParam(searchParams.q);
|
||||
const activeCategory = normalizeCategory(readParam(searchParams.category));
|
||||
|
||||
const requestParams = new URLSearchParams();
|
||||
if (query) {
|
||||
requestParams.set("q", query);
|
||||
}
|
||||
|
||||
const data = await serverFetch<{ makers: PublicMaker[] }>(`/makers?${requestParams.toString()}`);
|
||||
const makers = orderMakers(filterByCategory(data.makers, activeCategory));
|
||||
const visibleMakers = makers.slice(0, 10);
|
||||
const makerListItems: MapHomeMakerListItem[] = visibleMakers.map((maker, index) => ({
|
||||
id: maker.id,
|
||||
slug: maker.slug,
|
||||
name: maker.business_name,
|
||||
city: maker.city,
|
||||
province: maker.province,
|
||||
imageUrl: getMakerImage(maker),
|
||||
distance: distanceLabels[index % distanceLabels.length],
|
||||
rating: getRating(maker.avg_rating).toFixed(1),
|
||||
reviewCount: Math.max(maker.review_count * 32, 12),
|
||||
satisfaction: getSatisfaction(maker.avg_rating),
|
||||
tags: getMakerTags(maker)
|
||||
}));
|
||||
const discoverHref = buildDiscoverHref(query, activeCategory);
|
||||
const mapMarkers = makers
|
||||
.flatMap((maker) => {
|
||||
const publicLocations = (maker.public_locations || []).filter((location) => location.latitude !== null && location.longitude !== null);
|
||||
const locations = publicLocations.length
|
||||
? publicLocations
|
||||
: maker.public_latitude !== null && maker.public_longitude !== null
|
||||
? [{ id: maker.id, city: maker.city, province: maker.province, latitude: maker.public_latitude, longitude: maker.public_longitude }]
|
||||
: [];
|
||||
|
||||
return locations.map((location, index) => ({
|
||||
id: `${maker.id}:${location.id || index}`,
|
||||
slug: maker.slug,
|
||||
businessName: maker.business_name,
|
||||
city: location.city || maker.city,
|
||||
province: location.province || maker.province,
|
||||
latitude: location.latitude as number,
|
||||
longitude: location.longitude as number
|
||||
}));
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="map-home-shell">
|
||||
<div className="map-home-device">
|
||||
<form action="/" className="map-home-search-stack">
|
||||
<div className="map-home-search-row">
|
||||
<label className="map-home-searchbar">
|
||||
<span className="map-home-search-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="6.6" />
|
||||
<path d="m20 20-3.6-3.6" />
|
||||
</svg>
|
||||
</span>
|
||||
<input name="q" placeholder="Buscar makers, servicios, trabajos..." defaultValue={query} />
|
||||
</label>
|
||||
{activeCategory !== "all" ? <input type="hidden" name="category" value={activeCategory} /> : null}
|
||||
<a href={discoverHref} className="map-home-filter-link" aria-label="Abrir filtros">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 7h16" />
|
||||
<path d="M7 12h10" />
|
||||
<path d="M10 17h4" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="map-home-chip-row">
|
||||
<a href={discoverHref} className="map-home-chip map-home-chip-primary">Filtros</a>
|
||||
{quickCategories.map((category) => (
|
||||
<a
|
||||
key={category.id}
|
||||
href={buildHomeHref(query, category.id)}
|
||||
className={`map-home-chip ${activeCategory === category.id ? "active" : ""}`}
|
||||
>
|
||||
{category.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<InteractiveMakerMapClient markers={mapMarkers} />
|
||||
|
||||
<section className="map-home-results">
|
||||
<div className="map-home-results-head">
|
||||
<div className="stack" style={{ gap: 4 }}>
|
||||
<strong className="map-home-results-title">Makers cerca de ti</strong>
|
||||
<span className="mini-note">Cordoba, Argentina | 1,2 km</span>
|
||||
</div>
|
||||
<a href={discoverHref} className="map-home-view-all">Ver todos</a>
|
||||
</div>
|
||||
|
||||
<MapHomeMakerList makers={makerListItems} />
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
type MakerProfile = {
|
||||
id: string;
|
||||
slug: string;
|
||||
business_name: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type MessageAccess = "customer" | "maker" | "admin";
|
||||
|
||||
type InquiryStatus = "open" | "agreed" | "completed" | "rejected" | "incident";
|
||||
|
||||
type Conversation = {
|
||||
id: string;
|
||||
business_name: string;
|
||||
maker_slug: string;
|
||||
customer_name: string;
|
||||
customer_id: string;
|
||||
need_text: string;
|
||||
size_text: string | null;
|
||||
has_model: boolean;
|
||||
material_preference: string | null;
|
||||
quantity: number;
|
||||
urgency: string;
|
||||
delivery_type: string;
|
||||
source_type: string;
|
||||
status: InquiryStatus;
|
||||
rejection_reason: string | null;
|
||||
proposal_price_cents: number | null;
|
||||
proposal_quantity: number | null;
|
||||
proposal_lead_time_days: number | null;
|
||||
proposal_note: string | null;
|
||||
proposal_created_at: string | null;
|
||||
agreed_at: string | null;
|
||||
maker_completed_at: string | null;
|
||||
customer_completed_at: string | null;
|
||||
completed_at: string | null;
|
||||
incident_reason: string | null;
|
||||
incident_detail: string | null;
|
||||
last_message: string | null;
|
||||
last_message_at: string | null;
|
||||
last_sender_user_id: string | null;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type Message = {
|
||||
id: string;
|
||||
sender_user_id: string;
|
||||
full_name: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type ConversationDetail = {
|
||||
inquiry: Conversation & {
|
||||
maker_user_id: string;
|
||||
customer_name: string;
|
||||
};
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
type FilterKey = "all" | "consultas" | "active";
|
||||
|
||||
const incidentOptions = [
|
||||
"El trabajo no fue entregado",
|
||||
"El resultado no fue el acordado",
|
||||
"Hubo un problema con el plazo",
|
||||
"El maker no responde",
|
||||
"Otro"
|
||||
];
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("es-AR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatMoney(value?: number | null) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `$${Math.round(value / 100).toLocaleString("es-AR")}`;
|
||||
}
|
||||
|
||||
function statusInfo(conversation: Conversation, userId?: string) {
|
||||
if (conversation.status === "completed") {
|
||||
return { label: "Finalizado", tone: "success", icon: "✓" };
|
||||
}
|
||||
if (conversation.status === "rejected") {
|
||||
return { label: "Rechazado", tone: "danger", icon: "×" };
|
||||
}
|
||||
if (conversation.status === "incident") {
|
||||
return { label: "Incidencia", tone: "danger", icon: "!" };
|
||||
}
|
||||
if (conversation.status === "agreed") {
|
||||
return { label: "Trabajo acordado", tone: "info", icon: "↔" };
|
||||
}
|
||||
if (conversation.message_count <= 1) {
|
||||
return { label: "Nueva consulta", tone: "info", icon: "✉" };
|
||||
}
|
||||
if (conversation.last_sender_user_id === userId) {
|
||||
return { label: "Esperando respuesta", tone: "warning", icon: "○" };
|
||||
}
|
||||
return { label: "En conversacion", tone: "success", icon: "●" };
|
||||
}
|
||||
|
||||
function deliveryLabel(value: string) {
|
||||
return value === "local" ? "Retiro / local" : "Envio";
|
||||
}
|
||||
|
||||
function urgencyLabel(value: string) {
|
||||
if (value === "high") {
|
||||
return "Urgente";
|
||||
}
|
||||
if (value === "low") {
|
||||
return "Sin urgencia";
|
||||
}
|
||||
return "Esta semana";
|
||||
}
|
||||
|
||||
export default function MessagesClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [messageAccess, setMessageAccess] = useState<MessageAccess>("customer");
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [detail, setDetail] = useState<ConversationDetail | null>(null);
|
||||
const [filter, setFilter] = useState<FilterKey>("all");
|
||||
const [reply, setReply] = useState("");
|
||||
const [review, setReview] = useState({ rating: 5, comment: "" });
|
||||
const [incident, setIncident] = useState({ reason: incidentOptions[0], detail: "" });
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
async function loadConversations(preferredId?: string, access = messageAccess) {
|
||||
if (access === "admin") {
|
||||
setConversations([]);
|
||||
setSelectedId("");
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = access === "maker" ? "maker" : "customer";
|
||||
const data = await browserFetch<{ conversations: Conversation[] }>(`/account/conversations?scope=${scope}`);
|
||||
setConversations(data.conversations);
|
||||
setSelectedId((current) => preferredId || current || data.conversations[0]?.id || "");
|
||||
}
|
||||
|
||||
async function refreshDetail(id = selectedId) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const data = await browserFetch<ConversationDetail>(`/inquiries/${id}`);
|
||||
setDetail(data);
|
||||
}
|
||||
|
||||
async function refreshAll(id = selectedId) {
|
||||
await Promise.all([loadConversations(id), refreshDetail(id)]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const me = await browserFetch<{ user: User | null; makerProfile: MakerProfile | null }>("/auth/me");
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(me.user);
|
||||
const access: MessageAccess = me.user?.role === "admin"
|
||||
? "admin"
|
||||
: me.makerProfile && me.makerProfile.status !== "draft"
|
||||
? "maker"
|
||||
: "customer";
|
||||
setMessageAccess(access);
|
||||
if (me.user) {
|
||||
await loadConversations(undefined, access);
|
||||
}
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo cargar mensajes");
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId || !user) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
async function loadDetail() {
|
||||
try {
|
||||
const data = await browserFetch<ConversationDetail>(`/inquiries/${selectedId}`);
|
||||
if (active) {
|
||||
setDetail(data);
|
||||
setShowActions(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo abrir la conversacion");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadDetail();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selectedId, user]);
|
||||
|
||||
const filteredConversations = useMemo(() => {
|
||||
if (filter === "consultas") {
|
||||
return conversations.filter((conversation) => conversation.status === "open");
|
||||
}
|
||||
if (filter === "active") {
|
||||
return conversations.filter((conversation) => ["open", "agreed", "incident"].includes(conversation.status));
|
||||
}
|
||||
return conversations;
|
||||
}, [conversations, filter]);
|
||||
|
||||
const accessCopy = messageAccess === "maker"
|
||||
? {
|
||||
eyebrow: "Consultas maker",
|
||||
title: "Bandeja de clientes",
|
||||
empty: "No hay consultas recibidas para este filtro.",
|
||||
auth: "Inicia sesion como maker para responder consultas de clientes."
|
||||
}
|
||||
: {
|
||||
eyebrow: "Mensajes",
|
||||
title: "Bandeja de consultas",
|
||||
empty: "No hay conversaciones para este filtro.",
|
||||
auth: "Inicia sesion para ver tus consultas y respuestas de los makers."
|
||||
};
|
||||
|
||||
async function sendReply(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const body = reply.trim();
|
||||
if (!selectedId || !body) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Enviando...");
|
||||
try {
|
||||
await browserFetch(`/inquiries/${selectedId}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body })
|
||||
});
|
||||
setReply("");
|
||||
setStatus("");
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo enviar el mensaje");
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptProposal() {
|
||||
if (!selectedId) {
|
||||
return;
|
||||
}
|
||||
setStatus("Aceptando propuesta...");
|
||||
try {
|
||||
await browserFetch(`/inquiries/${selectedId}/accept-proposal`, { method: "POST" });
|
||||
setStatus("");
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo aceptar la propuesta");
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCompleted() {
|
||||
if (!selectedId) {
|
||||
return;
|
||||
}
|
||||
setStatus("Confirmando finalizacion...");
|
||||
try {
|
||||
await browserFetch(`/inquiries/${selectedId}/complete`, { method: "POST" });
|
||||
setStatus("");
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo confirmar");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReview(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
setStatus("Publicando resena...");
|
||||
try {
|
||||
await browserFetch("/reviews", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
inquiryId: detail.inquiry.id,
|
||||
ratingOverall: review.rating,
|
||||
ratingQuality: review.rating,
|
||||
ratingCommunication: review.rating,
|
||||
ratingValue: review.rating,
|
||||
comment: review.comment
|
||||
})
|
||||
});
|
||||
setReview({ rating: 5, comment: "" });
|
||||
setStatus("Resena publicada.");
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo publicar la resena");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitIncident(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!selectedId) {
|
||||
return;
|
||||
}
|
||||
setStatus("Reportando incidencia...");
|
||||
try {
|
||||
await browserFetch(`/inquiries/${selectedId}/incident`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(incident)
|
||||
});
|
||||
setIncident({ reason: incidentOptions[0], detail: "" });
|
||||
setStatus("");
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo reportar la incidencia");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="messages-shell">
|
||||
<div className="messages-loading">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<section className="messages-shell">
|
||||
<div className="messages-auth-card">
|
||||
<h1>Mensajes</h1>
|
||||
<p>{accessCopy.auth}</p>
|
||||
<Link href="/login?returnTo=%2Fmessages" className="button button-primary">Entrar</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (messageAccess === "admin") {
|
||||
return (
|
||||
<section className="messages-shell">
|
||||
<div className="messages-auth-card">
|
||||
<span className="eyebrow">Mensajes administrador</span>
|
||||
<h1>Bandeja administrativa</h1>
|
||||
<p>Las consultas operativas, incidencias y reportes del administrador se gestionan desde el backoffice para mantener trazabilidad.</p>
|
||||
<div className="home-actions">
|
||||
<a href="/app/backoffice/cases" className="button button-primary">Abrir casos</a>
|
||||
<a href="/app/backoffice" className="button button-secondary">Ir al backoffice</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const current = detail?.inquiry;
|
||||
const currentInfo = current ? statusInfo(current, user.id) : null;
|
||||
const currentDisplayName = current
|
||||
? messageAccess === "maker" ? current.customer_name : current.business_name
|
||||
: "";
|
||||
const whatsappText = current
|
||||
? encodeURIComponent(`Hola, soy ${detail.inquiry.customer_name}. Te contacto por la consulta "${current.need_text}".`)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<section className="messages-shell messages-flow-shell">
|
||||
<aside className="messages-list">
|
||||
<div className="messages-head">
|
||||
<div>
|
||||
<span className="eyebrow">{accessCopy.eyebrow}</span>
|
||||
<h1>{accessCopy.title}</h1>
|
||||
</div>
|
||||
<span className="badge">{conversations.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="messages-filter-row" aria-label="Filtros de mensajes">
|
||||
{[
|
||||
["all", "Todos"],
|
||||
["consultas", "Consultas"],
|
||||
["active", "En curso"]
|
||||
].map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`messages-filter-chip ${filter === key ? "is-active" : ""}`}
|
||||
onClick={() => setFilter(key as FilterKey)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="messages-conversation-list">
|
||||
{filteredConversations.length === 0 ? (
|
||||
<div className="empty-state">{accessCopy.empty}</div>
|
||||
) : null}
|
||||
|
||||
{filteredConversations.map((conversation) => {
|
||||
const info = statusInfo(conversation, user.id);
|
||||
const displayName = messageAccess === "maker" ? conversation.customer_name : conversation.business_name;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={conversation.id}
|
||||
type="button"
|
||||
className={`messages-conversation ${conversation.id === selectedId ? "is-active" : ""}`}
|
||||
onClick={() => setSelectedId(conversation.id)}
|
||||
>
|
||||
<span className="messages-avatar">{displayName.slice(0, 2).toUpperCase()}</span>
|
||||
<span className="messages-conversation-copy">
|
||||
<strong>{displayName}</strong>
|
||||
<span>{conversation.last_message || conversation.need_text}</span>
|
||||
<span className={`messages-state-text is-${info.tone}`}>{info.icon} {info.label}</span>
|
||||
</span>
|
||||
<span className="messages-time">{formatTime(conversation.last_message_at || conversation.created_at)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="messages-thread">
|
||||
{current && detail ? (
|
||||
<>
|
||||
<div className="messages-thread-head">
|
||||
<div className="messages-maker-title">
|
||||
<span className="messages-avatar small">{currentDisplayName.slice(0, 2).toUpperCase()}</span>
|
||||
<div>
|
||||
<h2>{currentDisplayName}</h2>
|
||||
<span className={`messages-state-text is-${currentInfo?.tone}`}>{currentInfo?.icon} {currentInfo?.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="messages-icon-button" type="button" onClick={() => setShowActions((value) => !value)} aria-label="Mas acciones">
|
||||
⋮
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<article className="messages-context-card">
|
||||
<div className="messages-context-main">
|
||||
<span className="eyebrow">Consulta estructurada</span>
|
||||
<strong>{current.need_text}</strong>
|
||||
<Link href={`/makers/${current.maker_slug}`}>Ver perfil del maker</Link>
|
||||
</div>
|
||||
<div className="messages-context-grid">
|
||||
<span>Cantidad <strong>{current.quantity || 1}</strong></span>
|
||||
<span>Urgencia <strong>{urgencyLabel(current.urgency)}</strong></span>
|
||||
<span>Entrega <strong>{deliveryLabel(current.delivery_type)}</strong></span>
|
||||
<span>Material <strong>{current.material_preference || "A definir"}</strong></span>
|
||||
<span>Medidas <strong>{current.size_text || "Sin medidas"}</strong></span>
|
||||
<span>Adjuntos <strong>{current.has_model ? "Con archivo" : "Sin archivo"}</strong></span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{showActions ? (
|
||||
<div className="messages-actions-panel">
|
||||
<button type="button" onClick={() => setReply((value) => value || "Te comparto las medidas actualizadas.")}>Compartir medidas</button>
|
||||
<button type="button" onClick={() => setReply((value) => value || "Adjunto fotos de referencia para que lo revises.")}>Anadir fotos</button>
|
||||
<a href={`https://wa.me/?text=${whatsappText}`} target="_blank" rel="noreferrer">Continuar por WhatsApp</a>
|
||||
<a href={`mailto:?subject=Consulta Makers3D&body=${whatsappText}`}>Continuar por email</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="messages-bubbles">
|
||||
{detail.messages.map((message) => {
|
||||
const isMine = message.sender_user_id === user.id;
|
||||
|
||||
return (
|
||||
<article key={message.id} className={`messages-bubble ${isMine ? "is-mine" : "is-maker"}`}>
|
||||
<div className="messages-bubble-top">
|
||||
<strong>{isMine ? "Tu" : currentDisplayName}</strong>
|
||||
<span>{formatTime(message.created_at)}</span>
|
||||
</div>
|
||||
<p>{message.body}</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
{current.proposal_price_cents ? (
|
||||
<article className="messages-system-card">
|
||||
<span className="messages-system-icon">↔</span>
|
||||
<div>
|
||||
<strong>{current.status === "agreed" || current.status === "completed" ? "Trabajo acordado" : "Propuesta de trabajo"}</strong>
|
||||
<dl>
|
||||
<div><dt>Trabajo</dt><dd>{current.need_text}</dd></div>
|
||||
<div><dt>Cantidad</dt><dd>{current.proposal_quantity || current.quantity || 1}</dd></div>
|
||||
<div><dt>Precio</dt><dd>{formatMoney(current.proposal_price_cents)}</dd></div>
|
||||
<div><dt>Plazo</dt><dd>{current.proposal_lead_time_days} dias</dd></div>
|
||||
</dl>
|
||||
{current.proposal_note ? <p>{current.proposal_note}</p> : null}
|
||||
{current.status === "open" ? (
|
||||
<button className="button button-primary" type="button" onClick={acceptProposal}>Aceptar propuesta</button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
{current.status === "completed" ? (
|
||||
<article className="messages-system-card success">
|
||||
<span className="messages-system-icon">✓</span>
|
||||
<div>
|
||||
<strong>Trabajo finalizado</strong>
|
||||
<p>Ambas partes confirmaron que el trabajo fue completado.</p>
|
||||
<small>Finalizado el {formatTime(current.completed_at)}</small>
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
{current.status === "rejected" ? (
|
||||
<article className="messages-system-card danger">
|
||||
<span className="messages-system-icon">×</span>
|
||||
<div>
|
||||
<strong>Consulta rechazada</strong>
|
||||
<p>{current.rejection_reason || "El maker no puede realizar este trabajo."}</p>
|
||||
<Link href="/discover" className="button button-primary">Ver mas alternativas</Link>
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
{current.status === "incident" ? (
|
||||
<article className="messages-system-card danger">
|
||||
<span className="messages-system-icon">!</span>
|
||||
<div>
|
||||
<strong>Incidencia abierta</strong>
|
||||
<p>{current.incident_reason}: {current.incident_detail}</p>
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="messages-work-actions">
|
||||
{current.status === "agreed" || current.status === "open" ? (
|
||||
<button className="button button-secondary" type="button" onClick={confirmCompleted}>
|
||||
Marcar como finalizado
|
||||
</button>
|
||||
) : null}
|
||||
{current.status !== "incident" && current.status !== "rejected" ? (
|
||||
<button className="button button-secondary" type="button" onClick={() => setIncident((value) => ({ ...value, detail: value.detail || "Necesito ayuda con esta consulta." }))}>
|
||||
Reportar problema
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{current.status === "completed" ? (
|
||||
<form className="messages-review-box" onSubmit={submitReview}>
|
||||
<strong>Dejar resena verificada</strong>
|
||||
<div className="messages-stars" aria-label="Puntuacion">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button key={star} type="button" className={review.rating >= star ? "is-active" : ""} onClick={() => setReview((value) => ({ ...value, rating: star }))}>★</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea value={review.comment} onChange={(event) => setReview((value) => ({ ...value, comment: event.target.value }))} placeholder="Cuenta como fue tu experiencia..." required minLength={10} />
|
||||
<button className="button button-primary" type="submit">Publicar resena</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{incident.detail ? (
|
||||
<form className="messages-incident-box" onSubmit={submitIncident}>
|
||||
<strong>Cuentanos que ocurrio</strong>
|
||||
<select value={incident.reason} onChange={(event) => setIncident((value) => ({ ...value, reason: event.target.value }))}>
|
||||
{incidentOptions.map((option) => <option key={option}>{option}</option>)}
|
||||
</select>
|
||||
<textarea value={incident.detail} onChange={(event) => setIncident((value) => ({ ...value, detail: event.target.value }))} placeholder="Danos mas detalles..." required minLength={5} />
|
||||
<button className="button button-primary" type="submit">Enviar reporte</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<form className="messages-composer" onSubmit={sendReply}>
|
||||
<button type="button" className="messages-plus-button" onClick={() => setShowActions((value) => !value)} aria-label="Acciones">+</button>
|
||||
<input
|
||||
value={reply}
|
||||
onChange={(event) => setReply(event.target.value)}
|
||||
placeholder="Escribe un mensaje..."
|
||||
aria-label="Mensaje"
|
||||
/>
|
||||
<button className="button button-primary" type="submit">Enviar</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">Selecciona una conversacion para ver el detalle.</div>
|
||||
)}
|
||||
|
||||
{status ? <p className="messages-status">{status}</p> : null}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type DockItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
isActive: (pathname: string) => boolean;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
function DockIcon({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="mobile-link-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const dockItems: DockItem[] = [
|
||||
{
|
||||
href: "/",
|
||||
label: "Mapa",
|
||||
isActive: (pathname) => pathname === "/",
|
||||
icon: <DockIcon><path d="M12 20s6-5.2 6-10a6 6 0 1 0-12 0c0 4.8 6 10 6 10Z" /><circle cx="12" cy="10" r="2.2" /></DockIcon>
|
||||
},
|
||||
{
|
||||
href: "/discover",
|
||||
label: "Descubrir",
|
||||
isActive: (pathname) => pathname === "/discover" || pathname === "/results",
|
||||
icon: <DockIcon><circle cx="11" cy="11" r="6.3" /><path d="m20 20-3.4-3.4" /></DockIcon>
|
||||
},
|
||||
{
|
||||
href: "/messages",
|
||||
label: "Mensajes",
|
||||
isActive: (pathname) => pathname.startsWith("/messages") || pathname.startsWith("/account/inbox"),
|
||||
icon: <DockIcon><path d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v7A2.5 2.5 0 0 1 17.5 16H10l-4.5 4v-4H6.5A2.5 2.5 0 0 1 4 13.5v-7Z" /></DockIcon>
|
||||
},
|
||||
{
|
||||
href: "/favorites",
|
||||
label: "Favoritos",
|
||||
isActive: (pathname) => pathname.startsWith("/favorites"),
|
||||
icon: <DockIcon><path d="m12 20-1.35-1.23C5.4 14 2 10.92 2 7.1 2 4.48 4.07 2.5 6.6 2.5c1.64 0 3.22.83 4.2 2.14A5.16 5.16 0 0 1 15 2.5c2.53 0 4.6 1.98 4.6 4.6 0 3.82-3.4 6.9-8.65 11.67L12 20Z" /></DockIcon>
|
||||
},
|
||||
{
|
||||
href: "/profile",
|
||||
label: "Perfil",
|
||||
isActive: (pathname) => pathname.startsWith("/profile") || pathname === "/account",
|
||||
icon: <DockIcon><path d="M19 20a7 7 0 0 0-14 0" /><circle cx="12" cy="8" r="4" /></DockIcon>
|
||||
}
|
||||
];
|
||||
|
||||
export default function MobileAppDock() {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (
|
||||
pathname.startsWith("/app/backoffice")
|
||||
|| pathname.startsWith("/admin")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="mobile-dock" aria-label="Navegacion principal movil">
|
||||
{dockItems.map((item) => (
|
||||
<a key={item.href} href={item.href} className={`mobile-link ${item.isActive(pathname) ? "active" : ""}`}>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
import { logoutByNavigation } from "../lib/logout";
|
||||
|
||||
type MeResponse = {
|
||||
user: { id: string; email: string; role: string } | null;
|
||||
makerProfile: { id: string; slug: string; business_name: string | null; status: string } | null;
|
||||
};
|
||||
|
||||
export default function ProfileClient() {
|
||||
const [session, setSession] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadSession() {
|
||||
try {
|
||||
const response = await browserFetch<MeResponse>("/auth/me");
|
||||
if (active) {
|
||||
setSession(response);
|
||||
}
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo cargar la sesion");
|
||||
setSession({ user: null, makerProfile: null });
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadSession();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.role === "admin") {
|
||||
window.location.replace("/app/backoffice");
|
||||
}
|
||||
}, [session?.user?.role]);
|
||||
|
||||
function logout() {
|
||||
logoutByNavigation();
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="profile-account-card stack">
|
||||
<h1 className="section-title">Cargando perfil</h1>
|
||||
<p className="muted">Validando tu sesion para mostrar el acceso correcto.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<section className="profile-account-card stack">
|
||||
<h1 className="section-title">Necesitas iniciar sesion</h1>
|
||||
<p className="muted">Entra con un usuario demo para probar cliente, maker o administrador.</p>
|
||||
<Link href="/login?returnTo=%2Fprofile" className="button button-primary">Entrar</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (session.user.role === "admin") {
|
||||
return (
|
||||
<section className="workspace-main-card stack">
|
||||
<span className="eyebrow">Backoffice</span>
|
||||
<h1 className="section-title">Redirigiendo al panel administrador</h1>
|
||||
<p className="muted">La cuenta superadmin no usa perfil maker ni perfil cliente.</p>
|
||||
<button className="button button-secondary" type="button" onClick={logout}>Cerrar sesion</button>
|
||||
{status ? <p className="muted">{status}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (session.makerProfile && session.makerProfile.status !== "draft") {
|
||||
const makerName = session.makerProfile.business_name || "Tu espacio Maker";
|
||||
|
||||
return (
|
||||
<section className="profile-account-card stack">
|
||||
<div className="profile-identity">
|
||||
<span className="profile-avatar">{makerName.slice(0, 2).toUpperCase()}</span>
|
||||
<div>
|
||||
<span className="eyebrow">Perfil maker</span>
|
||||
<h1 className="section-title">{makerName}</h1>
|
||||
<p className="muted">{session.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-status-grid">
|
||||
<div className="stat-tile"><span className="stat-value">Activo</span><span className="mini-note">Estado de cuenta</span></div>
|
||||
<div className="stat-tile"><span className="stat-value">{session.makerProfile.status}</span><span className="mini-note">Perfil publico</span></div>
|
||||
</div>
|
||||
<div className="profile-action-grid">
|
||||
<Link href="/account" className="profile-action-tile">
|
||||
<strong>Mi espacio maker</strong>
|
||||
<span>Dashboard, trabajos, servicios y reputacion.</span>
|
||||
</Link>
|
||||
<Link href="/account/inbox" className="profile-action-tile">
|
||||
<strong>Consultas recibidas</strong>
|
||||
<span>Mensajes de clientes y propuestas de trabajo.</span>
|
||||
</Link>
|
||||
<Link href={`/makers/${session.makerProfile.slug}`} className="profile-action-tile">
|
||||
<strong>Ver perfil publico</strong>
|
||||
<span>Asi te ven clientes y proveedores.</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="profile-action-tile">
|
||||
<strong>Favoritos</strong>
|
||||
<span>Makers y trabajos que guardaste.</span>
|
||||
</Link>
|
||||
</div>
|
||||
<button className="button button-secondary" type="button" onClick={logout}>Cerrar sesion</button>
|
||||
{status ? <p className="muted">{status}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="profile-account-card stack client-profile-card">
|
||||
<div className="profile-identity">
|
||||
<span className="profile-avatar">CL</span>
|
||||
<div>
|
||||
<span className="eyebrow">Perfil cliente</span>
|
||||
<h1 className="section-title">Tu cuenta de cliente</h1>
|
||||
<p className="muted">{session.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-action-grid">
|
||||
<Link href="/messages" className="profile-action-tile">
|
||||
<strong>Mensajes</strong>
|
||||
<span>Tus consultas enviadas a makers y sus respuestas.</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="profile-action-tile">
|
||||
<strong>Favoritos</strong>
|
||||
<span>Makers y trabajos guardados para revisar despues.</span>
|
||||
</Link>
|
||||
<Link href="/account" className="profile-action-tile">
|
||||
<strong>Crear mi espacio maker</strong>
|
||||
<span>Solo si tambien queres publicar servicios como maker.</span>
|
||||
</Link>
|
||||
</div>
|
||||
<button className="button button-secondary" type="button" onClick={logout}>Cerrar sesion</button>
|
||||
{status ? <p className="muted">{status}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
import { notifyAuthChanged } from "../lib/authEvents";
|
||||
|
||||
export default function RegisterForm({ redirectTo = "/account" }: { redirectTo?: string }) {
|
||||
const [error, setError] = useState("");
|
||||
const [form, setForm] = useState({ fullName: "", email: "", password: "" });
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
await browserFetch("/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(form)
|
||||
});
|
||||
notifyAuthChanged("login");
|
||||
window.location.href = redirectTo;
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "No se pudo crear la cuenta");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="stack" onSubmit={onSubmit}>
|
||||
<input className="field" placeholder="Nombre completo" value={form.fullName} onChange={(event) => setForm((current) => ({ ...current, fullName: event.target.value }))} required />
|
||||
<input className="field" type="email" placeholder="Correo" value={form.email} onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))} required />
|
||||
<input className="field" type="password" placeholder="Contrasena" value={form.password} onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} required />
|
||||
<button className="button button-primary" type="submit">Crear cuenta</button>
|
||||
{error ? <p className="muted">{error}</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ServiceWorkerRegister() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
// The app works without offline support, so registration failures stay silent in the demo MVP.
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import FavoriteToggle from "./FavoriteToggle";
|
||||
|
||||
type WorkHeroGalleryProps = {
|
||||
gallery: string[];
|
||||
title: string;
|
||||
workId: string;
|
||||
shareUrl?: string;
|
||||
};
|
||||
|
||||
export default function WorkHeroGallery({ gallery, title, workId, shareUrl }: WorkHeroGalleryProps) {
|
||||
const router = useRouter();
|
||||
const galleryRef = useRef<HTMLDivElement>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
function updateActiveSlide() {
|
||||
const galleryNode = galleryRef.current;
|
||||
if (!galleryNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = Math.round(galleryNode.scrollLeft / Math.max(galleryNode.clientWidth, 1));
|
||||
setActiveIndex(Math.min(Math.max(nextIndex, 0), gallery.length - 1));
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (typeof window !== "undefined" && window.history.length > 1) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/discover");
|
||||
}
|
||||
|
||||
async function shareWork() {
|
||||
const url = shareUrl || (typeof window !== "undefined" ? window.location.href : "");
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof navigator !== "undefined" && navigator.share) {
|
||||
await navigator.share({
|
||||
title,
|
||||
text: `Mira este trabajo que encontre en Makers3D: ${title}`,
|
||||
url
|
||||
}).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
await navigator.clipboard?.writeText(url).catch(() => undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="work-detail-hero">
|
||||
<div ref={galleryRef} className="work-detail-hero-gallery" onScroll={updateActiveSlide} aria-label={`Galeria de ${title}`}>
|
||||
{gallery.map((image, index) => (
|
||||
<div key={`${image}-${index}`} className="work-detail-hero-slide">
|
||||
<img src={image} alt={`${title} - foto ${index + 1}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{gallery.length > 1 ? (
|
||||
<div className="work-detail-hero-dots" aria-hidden="true">
|
||||
{gallery.map((image, index) => (
|
||||
<span key={`${image}-dot-${index}`} className={index === activeIndex ? "is-active" : ""} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="work-detail-top-actions">
|
||||
<button className="work-detail-circle-action" type="button" onClick={goBack} aria-label="Volver"><</button>
|
||||
<div className="work-detail-action-pair">
|
||||
<FavoriteToggle targetType="work" targetId={workId} compact icon="bookmark" />
|
||||
<button className="work-detail-circle-action" type="button" onClick={() => void shareWork()} aria-label="Compartir trabajo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<path d="m8.6 10.5 6.8-4" />
|
||||
<path d="m8.6 13.5 6.8 4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="work-detail-gallery-count">{activeIndex + 1}/{gallery.length}</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type WorkShareActionsProps = {
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export default function WorkShareActions({ title, url }: WorkShareActionsProps) {
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
const shareText = useMemo(
|
||||
() => `Mira esto que encontre en Makers3D: ${title}`,
|
||||
[title]
|
||||
);
|
||||
const whatsappHref = `https://wa.me/?text=${encodeURIComponent(`${shareText}\n${url}`)}`;
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setStatus("Enlace copiado.");
|
||||
} catch {
|
||||
setStatus("No se pudo copiar. Manten presionado el enlace.");
|
||||
}
|
||||
}
|
||||
|
||||
async function shareWork() {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title,
|
||||
text: shareText,
|
||||
url
|
||||
});
|
||||
setStatus("Listo para compartir.");
|
||||
return;
|
||||
} catch (error) {
|
||||
const shareError = error as Error;
|
||||
if (shareError.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await copyLink();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="work-detail-share-actions">
|
||||
<button type="button" className="button button-primary" onClick={shareWork}>
|
||||
Compartir
|
||||
</button>
|
||||
<a className="work-detail-share-option" href={whatsappHref} target="_blank" rel="noreferrer">
|
||||
WhatsApp
|
||||
</a>
|
||||
<button type="button" className="work-detail-share-option" onClick={copyLink}>
|
||||
Copiar enlace
|
||||
</button>
|
||||
{status ? <span className="work-detail-share-status">{status}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import FavoritesClient from "../components/FavoritesClient";
|
||||
|
||||
export default function FavoritesPage() {
|
||||
return (
|
||||
<main className="page-shell favorites-page">
|
||||
<FavoritesClient />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"><</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>></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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import MessagesClient from "../components/MessagesClient";
|
||||
|
||||
export default function MessagesPage() {
|
||||
return (
|
||||
<main className="page-shell messages-page">
|
||||
<MessagesClient />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import ProfileClient from "../components/ProfileClient";
|
||||
|
||||
export default function ProfilePage() {
|
||||
return (
|
||||
<main className="page-shell profile-account-page">
|
||||
<ProfileClient />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"><</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>></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>
|
||||
);
|
||||
}
|
||||
@@ -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>></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>></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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
typedRoutes: true
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@makers3d/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"dev": "next dev",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "1.9.4",
|
||||
"next": "16.2.10",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-leaflet": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "1.9.21"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
leaflet:
|
||||
specifier: 1.9.4
|
||||
version: 1.9.4
|
||||
next:
|
||||
specifier: 16.2.10
|
||||
version: 16.2.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
react:
|
||||
specifier: 19.2.0
|
||||
version: 19.2.0
|
||||
react-dom:
|
||||
specifier: 19.2.0
|
||||
version: 19.2.0(react@19.2.0)
|
||||
react-leaflet:
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
devDependencies:
|
||||
'@types/leaflet':
|
||||
specifier: 1.9.21
|
||||
version: 1.9.21
|
||||
|
||||
packages:
|
||||
|
||||
'@emnapi/runtime@1.11.2':
|
||||
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-ia32@0.34.5':
|
||||
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@next/env@16.2.10':
|
||||
resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==}
|
||||
|
||||
'@next/swc-darwin-arm64@16.2.10':
|
||||
resolution: {integrity: sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@16.2.10':
|
||||
resolution: {integrity: sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.2.10':
|
||||
resolution: {integrity: sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.2.10':
|
||||
resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.2.10':
|
||||
resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.2.10':
|
||||
resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.2.10':
|
||||
resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.2.10':
|
||||
resolution: {integrity: sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@react-leaflet/core@3.0.0':
|
||||
resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
|
||||
peerDependencies:
|
||||
leaflet: ^1.9.0
|
||||
react: ^19.0.0
|
||||
react-dom: ^19.0.0
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
'@types/geojson@7946.0.16':
|
||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||
|
||||
'@types/leaflet@1.9.21':
|
||||
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
||||
|
||||
baseline-browser-mapping@2.11.0:
|
||||
resolution: {integrity: sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
caniuse-lite@1.0.30001806:
|
||||
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
|
||||
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
leaflet@1.9.4:
|
||||
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
||||
|
||||
nanoid@3.3.16:
|
||||
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
next@16.2.10:
|
||||
resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.1.0
|
||||
'@playwright/test': ^1.51.1
|
||||
babel-plugin-react-compiler: '*'
|
||||
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
|
||||
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
|
||||
sass: ^1.3.0
|
||||
peerDependenciesMeta:
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@playwright/test':
|
||||
optional: true
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
postcss@8.4.31:
|
||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
react-dom@19.2.0:
|
||||
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
|
||||
react-leaflet@5.0.0:
|
||||
resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
|
||||
peerDependencies:
|
||||
leaflet: ^1.9.0
|
||||
react: ^19.0.0
|
||||
react-dom: ^19.0.0
|
||||
|
||||
react@19.2.0:
|
||||
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
scheduler@0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
semver@7.8.5:
|
||||
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
styled-jsx@5.1.6:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': '*'
|
||||
babel-plugin-macros: '*'
|
||||
react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
|
||||
peerDependenciesMeta:
|
||||
'@babel/core':
|
||||
optional: true
|
||||
babel-plugin-macros:
|
||||
optional: true
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@emnapi/runtime@1.11.2':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.11.2
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-ia32@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@next/env@16.2.10': {}
|
||||
|
||||
'@next/swc-darwin-arm64@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.2.10':
|
||||
optional: true
|
||||
|
||||
'@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
leaflet: 1.9.4
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
|
||||
'@types/leaflet@1.9.21':
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
|
||||
baseline-browser-mapping@2.11.0: {}
|
||||
|
||||
caniuse-lite@1.0.30001806: {}
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
optional: true
|
||||
|
||||
leaflet@1.9.4: {}
|
||||
|
||||
nanoid@3.3.16: {}
|
||||
|
||||
next@16.2.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
'@next/env': 16.2.10
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.11.0
|
||||
caniuse-lite: 1.0.30001806
|
||||
postcss: 8.4.31
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
styled-jsx: 5.1.6(react@19.2.0)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.2.10
|
||||
'@next/swc-darwin-x64': 16.2.10
|
||||
'@next/swc-linux-arm64-gnu': 16.2.10
|
||||
'@next/swc-linux-arm64-musl': 16.2.10
|
||||
'@next/swc-linux-x64-gnu': 16.2.10
|
||||
'@next/swc-linux-x64-musl': 16.2.10
|
||||
'@next/swc-win32-arm64-msvc': 16.2.10
|
||||
'@next/swc-win32-x64-msvc': 16.2.10
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
postcss@8.4.31:
|
||||
dependencies:
|
||||
nanoid: 3.3.16
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
react-dom@19.2.0(react@19.2.0):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
'@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
leaflet: 1.9.4
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
|
||||
react@19.2.0: {}
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
semver@7.8.5:
|
||||
optional: true
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.8.5
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.34.5
|
||||
'@img/sharp-darwin-x64': 0.34.5
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||
'@img/sharp-linux-arm': 0.34.5
|
||||
'@img/sharp-linux-arm64': 0.34.5
|
||||
'@img/sharp-linux-ppc64': 0.34.5
|
||||
'@img/sharp-linux-riscv64': 0.34.5
|
||||
'@img/sharp-linux-s390x': 0.34.5
|
||||
'@img/sharp-linux-x64': 0.34.5
|
||||
'@img/sharp-linuxmusl-arm64': 0.34.5
|
||||
'@img/sharp-linuxmusl-x64': 0.34.5
|
||||
'@img/sharp-wasm32': 0.34.5
|
||||
'@img/sharp-win32-arm64': 0.34.5
|
||||
'@img/sharp-win32-ia32': 0.34.5
|
||||
'@img/sharp-win32-x64': 0.34.5
|
||||
optional: true
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
styled-jsx@5.1.6(react@19.2.0):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.0
|
||||
|
||||
tslib@2.8.1: {}
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
sharp: set this to true or false
|
||||
@@ -0,0 +1,29 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#17110b"/>
|
||||
<stop offset="1" stop-color="#342313"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="filament" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffd37c"/>
|
||||
<stop offset="1" stop-color="#b96d24"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="800" fill="url(#bg)"/>
|
||||
<path d="M160 610h880" stroke="#604321" stroke-width="10" opacity=".55"/>
|
||||
<g transform="translate(602 386)">
|
||||
<circle r="196" fill="url(#filament)"/>
|
||||
<circle r="96" fill="#17110b"/>
|
||||
<circle r="54" fill="#e9a747"/>
|
||||
<g fill="#ffd37c">
|
||||
<path d="M-24-294h48l22 122h-92z"/>
|
||||
<path d="M-24 172h48l22 122h-92z"/>
|
||||
<path d="M-294-24v48l122 22v-92z"/>
|
||||
<path d="M172-24v48l122 22v-92z"/>
|
||||
<path d="M-224-224 -190-258 -88-187 -153-122z"/>
|
||||
<path d="M190-258 224-224 153-122 88-187z"/>
|
||||
<path d="M-224 224 -190 258 -88 187 -153 122z"/>
|
||||
<path d="M190 258 224 224 153 122 88 187z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="40%" r="70%">
|
||||
<stop offset="0" stop-color="#2a3046"/>
|
||||
<stop offset="1" stop-color="#060912"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="stone" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#d6d8df"/>
|
||||
<stop offset="1" stop-color="#636b80"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="800" fill="url(#bg)"/>
|
||||
<ellipse cx="600" cy="640" rx="360" ry="54" fill="#01040b" opacity=".58"/>
|
||||
<g fill="url(#stone)">
|
||||
<path d="M468 598h264l-28-88H496z"/>
|
||||
<path d="M528 506h144l34-166H494z"/>
|
||||
<circle cx="600" cy="278" r="82"/>
|
||||
<path d="M510 356c-86 36-126 88-122 156h96c4-50 30-82 78-102z"/>
|
||||
<path d="M690 356c86 36 126 88 122 156h-96c-4-50-30-82-78-102z"/>
|
||||
</g>
|
||||
<circle cx="570" cy="272" r="14" fill="#060912"/>
|
||||
<circle cx="630" cy="272" r="14" fill="#060912"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 958 B |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#06101f"/>
|
||||
<stop offset="1" stop-color="#16345c"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="part" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#ecf8ff"/>
|
||||
<stop offset="1" stop-color="#7ea7c8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="800" fill="url(#bg)"/>
|
||||
<ellipse cx="604" cy="624" rx="390" ry="60" fill="#020713" opacity=".6"/>
|
||||
<rect x="328" y="270" width="544" height="300" rx="54" fill="url(#part)"/>
|
||||
<rect x="406" y="354" width="130" height="92" rx="18" fill="#06101f" opacity=".7"/>
|
||||
<rect x="596" y="330" width="192" height="142" rx="28" fill="#06101f" opacity=".64"/>
|
||||
<path d="M328 515h544v55H328z" fill="#bcd4e8"/>
|
||||
<circle cx="350" cy="292" r="42" fill="#51d4ff" opacity=".75"/>
|
||||
<circle cx="850" cy="548" r="44" fill="#2b82ff" opacity=".65"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 995 B |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="35%" r="68%">
|
||||
<stop offset="0" stop-color="#3d155d"/>
|
||||
<stop offset="1" stop-color="#070812"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="resin" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#ff5ea8"/>
|
||||
<stop offset=".6" stop-color="#8f45ff"/>
|
||||
<stop offset="1" stop-color="#2fb6ff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="800" fill="url(#bg)"/>
|
||||
<ellipse cx="600" cy="625" rx="320" ry="54" fill="#01040b" opacity=".6"/>
|
||||
<path d="M428 590 326 428l86-182h226l122 124 116 32-52 138-148-28-88 78Z" fill="url(#resin)"/>
|
||||
<circle cx="486" cy="392" r="42" fill="#090b18" opacity=".48"/>
|
||||
<circle cx="670" cy="424" r="58" fill="#090b18" opacity=".38"/>
|
||||
<path d="M414 246c78 44 160 44 244 0" fill="none" stroke="#ffd6ef" stroke-width="20" opacity=".5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 939 B |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#09151d"/>
|
||||
<stop offset="1" stop-color="#10382d"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="part" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#7df7bd"/>
|
||||
<stop offset="1" stop-color="#0e9d78"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="800" fill="url(#bg)"/>
|
||||
<ellipse cx="600" cy="628" rx="380" ry="58" fill="#020713" opacity=".58"/>
|
||||
<path d="M334 534V316l148-86h236l148 86v218l-148 86H482z" fill="url(#part)"/>
|
||||
<path d="M464 366h272v116H464z" fill="#06101f" opacity=".58"/>
|
||||
<circle cx="438" cy="318" r="38" fill="#06101f" opacity=".62"/>
|
||||
<circle cx="762" cy="318" r="38" fill="#06101f" opacity=".62"/>
|
||||
<circle cx="438" cy="532" r="38" fill="#06101f" opacity=".62"/>
|
||||
<circle cx="762" cy="532" r="38" fill="#06101f" opacity=".62"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 972 B |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#1d4ed8" />
|
||||
<stop offset="1" stop-color="#06b6d4" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="800" fill="#081220" />
|
||||
<circle cx="900" cy="160" r="220" fill="rgba(45,168,255,0.2)" />
|
||||
<circle cx="220" cy="640" r="240" fill="rgba(124,228,255,0.14)" />
|
||||
<path d="M260 560l180-300h320l180 300-180 120H440z" fill="url(#g)" opacity="0.95" />
|
||||
<path d="M420 300h360v120H420z" fill="#07101b" opacity="0.65" />
|
||||
<text x="90" y="120" fill="#f8fafc" font-size="72" font-family="Arial, sans-serif" font-weight="700">MakerLab BA</text>
|
||||
<text x="90" y="190" fill="#cbd5e1" font-size="32" font-family="Arial, sans-serif">Repuestos funcionales y prototipos</text>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 854 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
|
||||
<defs>
|
||||
<linearGradient id="g2" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#7c3aed" />
|
||||
<stop offset="1" stop-color="#2dd4bf" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="800" fill="#0c182a" />
|
||||
<path d="M210 600l210-360h360l210 360-210 120H420z" fill="url(#g2)" opacity="0.88" />
|
||||
<text x="90" y="120" fill="#f8fafc" font-size="72" font-family="Arial, sans-serif" font-weight="700">Tu Maker</text>
|
||||
<text x="90" y="185" fill="#cbd5e1" font-size="32" font-family="Arial, sans-serif">Usa esta portada mientras preparas tu perfil</text>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 660 B |
@@ -0,0 +1,29 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="45%" r="62%">
|
||||
<stop offset="0" stop-color="#243f5f"/>
|
||||
<stop offset="1" stop-color="#07101d"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="plastic" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#f7e7bf"/>
|
||||
<stop offset="1" stop-color="#b38b4e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="800" fill="url(#bg)"/>
|
||||
<ellipse cx="610" cy="612" rx="370" ry="64" fill="#020713" opacity=".55"/>
|
||||
<g transform="translate(600 382)">
|
||||
<circle r="194" fill="url(#plastic)"/>
|
||||
<circle r="82" fill="#07101d"/>
|
||||
<circle r="48" fill="#d7b875"/>
|
||||
<g fill="#f7e7bf">
|
||||
<rect x="-26" y="-276" width="52" height="102" rx="12"/>
|
||||
<rect x="-26" y="174" width="52" height="102" rx="12"/>
|
||||
<rect x="-276" y="-26" width="102" height="52" rx="12"/>
|
||||
<rect x="174" y="-26" width="102" height="52" rx="12"/>
|
||||
<rect x="-214" y="-214" width="92" height="52" rx="12" transform="rotate(45)"/>
|
||||
<rect x="122" y="-214" width="92" height="52" rx="12" transform="rotate(45)"/>
|
||||
<rect x="-214" y="162" width="92" height="52" rx="12" transform="rotate(45)"/>
|
||||
<rect x="122" y="162" width="92" height="52" rx="12" transform="rotate(45)"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#061322"/>
|
||||
<stop offset="1" stop-color="#0d2e4f"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="part" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#78e6ff"/>
|
||||
<stop offset=".55" stop-color="#219cff"/>
|
||||
<stop offset="1" stop-color="#0e4fb3"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="800" fill="url(#bg)"/>
|
||||
<path d="M284 554 412 254h358l148 210-152 148H408Z" fill="url(#part)"/>
|
||||
<path d="M380 598h410l-42 48H420Z" fill="#7ce4ff" opacity=".75"/>
|
||||
<circle cx="438" cy="416" r="58" fill="#082033" opacity=".64"/>
|
||||
<circle cx="690" cy="412" r="76" fill="#082033" opacity=".64"/>
|
||||
<ellipse cx="610" cy="646" rx="360" ry="52" fill="#020713" opacity=".55"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 885 B |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="38%" r="70%">
|
||||
<stop offset="0" stop-color="#1c2941"/>
|
||||
<stop offset="1" stop-color="#050b14"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="part" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#d9e2ea"/>
|
||||
<stop offset="1" stop-color="#5b6877"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="800" fill="url(#bg)"/>
|
||||
<ellipse cx="600" cy="620" rx="360" ry="58" fill="#020713" opacity=".55"/>
|
||||
<path d="M310 508h580l-82 94H388Z" fill="#7c8795"/>
|
||||
<path d="M374 250h452a64 64 0 0 1 64 64v194H310V314a64 64 0 0 1 64-64Z" fill="url(#part)"/>
|
||||
<rect x="410" y="330" width="156" height="82" rx="18" fill="#07101d" opacity=".72"/>
|
||||
<rect x="634" y="330" width="156" height="82" rx="18" fill="#07101d" opacity=".72"/>
|
||||
<circle cx="420" cy="512" r="32" fill="#0a1220"/>
|
||||
<circle cx="780" cy="512" r="32" fill="#0a1220"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 987 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
|
||||
<title>Makers3D</title>
|
||||
<desc>Icono de la app Makers3D.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#f97316" />
|
||||
<stop offset="100%" stop-color="#ea580c" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="256" height="256" rx="56" fill="url(#bg)" />
|
||||
<path d="M70 180V76h22l36 48 36-48h22v104h-24v-64l-30 39h-8l-30-39v64z" fill="#fff7ed" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 533 B |
@@ -0,0 +1,11 @@
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", () => {
|
||||
// Minimal service worker to enable installability on Android without adding complex caching logic.
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||