2026-05-01 14:11:27 +00:00

395 lines
17 KiB
TypeScript

import {
mdiChevronRight,
mdiCloseCircleOutline,
mdiCompassOutline,
mdiMapLegend,
mdiMapMarker,
mdiMapSearchOutline,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useMemo, useState } from 'react';
import PublicShell from '../components/Chinchorreo/PublicShell';
import BaseIcon from '../components/BaseIcon';
import {
chinchorrosGuide,
createDirectionsUrl,
placesGuide,
} from '../helpers/chinchorreoData';
import LayoutGuest from '../layouts/Guest';
type MapViewMode = 'mapa' | 'lista';
type MapItem = {
id: string;
kind: 'chinchorro' | 'place';
label: string;
subtitle: string;
href: string;
town: string;
query: string;
x: number;
y: number;
accentFrom: string;
accentTo: string;
emoji: string;
description: string;
mapsUrl: string;
};
const mapItems: MapItem[] = [
...chinchorrosGuide.map((item) => ({
id: `chinchorro-${item.slug}`,
kind: 'chinchorro' as const,
label: item.name,
subtitle: `${item.town} · ${item.type}`,
href: `/restaurantes?search=${encodeURIComponent(item.name)}`,
town: item.town,
query: `${item.name} ${item.town} Puerto Rico`,
x: item.mapX,
y: item.mapY,
accentFrom: item.accentFrom,
accentTo: item.accentTo,
emoji: '🍽️',
description: item.description,
mapsUrl: item.mapsUrl,
})),
...placesGuide.map((item) => ({
id: `place-${item.slug}`,
kind: 'place' as const,
label: item.name,
subtitle: `${item.town} · ${item.category}`,
href: `/lugares?search=${encodeURIComponent(item.name)}`,
town: item.town,
query: `${item.name} ${item.town} Puerto Rico`,
x: item.mapX,
y: item.mapY,
accentFrom: item.accentFrom,
accentTo: item.accentTo,
emoji: item.emoji,
description: item.description,
mapsUrl: item.mapsUrl,
})),
];
export default function MapPage() {
const [mode, setMode] = useState<MapViewMode>('mapa');
const [searchValue, setSearchValue] = useState('');
const [showChinchorros, setShowChinchorros] = useState(true);
const [showPlaces, setShowPlaces] = useState(true);
const [selectedItemId, setSelectedItemId] = useState(mapItems[0]?.id || '');
const [routeSelection, setRouteSelection] = useState<string[]>([]);
const visibleItems = useMemo(() => {
return mapItems.filter((item) => {
const searchMatch = `${item.label} ${item.subtitle} ${item.description}`
.toLowerCase()
.includes(searchValue.toLowerCase().trim());
const kindMatch =
(item.kind === 'chinchorro' && showChinchorros) ||
(item.kind === 'place' && showPlaces);
return searchMatch && kindMatch;
});
}, [searchValue, showChinchorros, showPlaces]);
const selectedItem = visibleItems.find((item) => item.id === selectedItemId) || visibleItems[0];
const selectedRouteItems = routeSelection
.map((id) => mapItems.find((item) => item.id === id))
.filter((item): item is MapItem => Boolean(item));
const directionsUrl =
selectedRouteItems.length >= 2
? createDirectionsUrl(selectedRouteItems.map((item) => item.query))
: '';
const toggleRouteItem = (id: string) => {
setRouteSelection((current) =>
current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id],
);
};
return (
<>
<Head>
<title>Chinchorreo PR | Mapa Interactivo</title>
</Head>
<PublicShell activeSection="mapa">
<section className="rounded-[2rem] border border-white/10 bg-white/5 p-6 shadow-xl shadow-black/20 sm:p-8">
<div className="max-w-3xl">
<div className="text-xs font-bold uppercase tracking-[0.35em] text-[#FDE68A]">Mapa interactivo</div>
<h1 className="mt-2 text-4xl font-black tracking-tight text-white">
Un mapa estilizado para descubrir paradas por categoría
</h1>
<p className="mt-3 text-base leading-7 text-slate-300">
Esta primera versión te deja alternar entre mapa y lista, filtrar categorías y trazar una mini ruta con los pins que selecciones.
</p>
</div>
<div className="mt-6 grid gap-4 rounded-[1.8rem] border border-white/10 bg-[#04111f]/50 p-4 md:grid-cols-[2fr_1fr_1fr] lg:grid-cols-[2fr_1fr_1fr_1fr]">
<label className="block md:col-span-1 lg:col-span-2">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.25em] text-slate-400">
Buscar pin
</div>
<input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Piñones, Loíza, Luquillo, cultura..."
className="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder:text-slate-400 focus:border-[#DAA520] focus:outline-none focus:ring-0"
/>
</label>
<div className="flex flex-col gap-2">
<div className="text-xs font-semibold uppercase tracking-[0.25em] text-slate-400">Categorías</div>
<button
type="button"
onClick={() => setShowChinchorros((current) => !current)}
className={[
'rounded-full border px-4 py-2 text-sm font-semibold transition',
showChinchorros
? 'border-[#DAA520]/35 bg-[#DAA520]/10 text-[#FDE68A]'
: 'border-white/10 bg-white/5 text-slate-300',
].join(' ')}
>
Chinchorros
</button>
<button
type="button"
onClick={() => setShowPlaces((current) => !current)}
className={[
'rounded-full border px-4 py-2 text-sm font-semibold transition',
showPlaces
? 'border-[#228B22]/35 bg-[#228B22]/10 text-[#A7F3D0]'
: 'border-white/10 bg-white/5 text-slate-300',
].join(' ')}
>
Lugares
</button>
</div>
<div className="flex flex-col gap-2">
<div className="text-xs font-semibold uppercase tracking-[0.25em] text-slate-400">Vista</div>
{(['mapa', 'lista'] as MapViewMode[]).map((viewMode) => (
<button
key={viewMode}
type="button"
onClick={() => setMode(viewMode)}
className={[
'rounded-full border px-4 py-2 text-sm font-semibold capitalize transition',
mode === viewMode
? 'border-white/20 bg-white/10 text-white'
: 'border-white/10 bg-white/5 text-slate-300',
].join(' ')}
>
{viewMode}
</button>
))}
</div>
</div>
</section>
<section className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
{mode === 'mapa' ? (
<div className="relative min-h-[620px] overflow-hidden rounded-[2rem] border border-white/10 bg-[#04111f] shadow-xl shadow-black/20">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(34,139,34,0.18),transparent_25%),radial-gradient(circle_at_bottom_right,rgba(206,17,38,0.18),transparent_30%)]" />
<div className="absolute inset-x-8 top-10 h-[420px] rounded-[46%_54%_45%_55%/55%_38%_62%_45%] bg-gradient-to-br from-[#0b2238] via-[#10314d] to-[#16455f] opacity-95 shadow-[0_0_0_1px_rgba(255,255,255,0.05)]" />
<div className="absolute inset-x-16 top-20 h-[380px] rounded-[44%_56%_52%_48%/55%_45%_55%_45%] border border-white/10 bg-gradient-to-br from-[#08304b] via-[#0B5670] to-[#0f766e] opacity-80" />
<div className="absolute inset-x-24 top-28 h-[320px] rounded-[40%_60%_45%_55%/60%_45%_55%_40%] bg-gradient-to-br from-[#14532d]/40 to-[#16a34a]/20 blur-sm" />
{visibleItems.map((item) => {
const selected = selectedItem?.id === item.id;
const inRoute = routeSelection.includes(item.id);
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedItemId(item.id)}
className="group absolute -translate-x-1/2 -translate-y-1/2"
style={{ left: `${item.x}%`, top: `${item.y}%` }}
>
<div
className={[
'relative flex h-12 w-12 items-center justify-center rounded-full border text-lg shadow-lg shadow-black/30 transition',
selected
? 'border-white bg-white text-[#04111f]'
: 'border-white/25 bg-[#04111f]/80 text-white group-hover:border-white/40 group-hover:bg-white/10',
].join(' ')}
style={{
boxShadow: inRoute
? `0 0 0 6px ${item.accentTo}33`
: '0 15px 30px rgba(0,0,0,0.25)',
}}
>
{item.emoji}
</div>
</button>
);
})}
<div className="absolute bottom-6 left-6 rounded-[1.6rem] border border-white/10 bg-[#04111f]/70 p-4 text-sm text-slate-200 backdrop-blur">
<div className="inline-flex items-center gap-2 text-[#FDE68A]">
<BaseIcon path={mdiMapLegend} size={16} />
Selecciona un pin para ver detalle
</div>
<div className="mt-2">Usa Agregar a la ruta para conectar varios puntos.</div>
</div>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{visibleItems.map((item) => {
const inRoute = routeSelection.includes(item.id);
return (
<article
key={item.id}
className="rounded-[1.8rem] border border-white/10 bg-white/5 p-5 shadow-xl shadow-black/20"
>
<div
className="rounded-[1.4rem] p-4"
style={{
backgroundImage: `linear-gradient(135deg, ${item.accentFrom}, ${item.accentTo})`,
}}
>
<div className="text-sm font-bold text-white">
{item.emoji} {item.label}
</div>
<div className="mt-1 text-sm text-white/85">{item.subtitle}</div>
</div>
<p className="mt-4 text-sm leading-6 text-slate-300">{item.description}</p>
<button
type="button"
onClick={() => toggleRouteItem(item.id)}
className={[
'mt-4 rounded-full border px-4 py-2 text-sm font-semibold transition',
inRoute
? 'border-[#DAA520]/35 bg-[#DAA520]/10 text-[#FDE68A]'
: 'border-white/10 bg-white/5 text-white hover:border-white/20 hover:bg-white/10',
].join(' ')}
>
{inRoute ? 'Quitar de la ruta' : 'Agregar a la ruta'}
</button>
</article>
);
})}
</div>
)}
<aside className="space-y-5">
{selectedItem ? (
<div className="overflow-hidden rounded-[2rem] border border-white/10 bg-white/5 shadow-xl shadow-black/20">
<div
className="p-5"
style={{
backgroundImage: `linear-gradient(135deg, ${selectedItem.accentFrom}, ${selectedItem.accentTo})`,
}}
>
<div className="inline-flex rounded-full border border-white/20 bg-black/10 px-3 py-1 text-xs font-bold uppercase tracking-[0.25em] text-white/90">
{selectedItem.kind === 'chinchorro' ? 'Chinchorro' : 'Lugar'}
</div>
<h2 className="mt-4 text-3xl font-black text-white">{selectedItem.label}</h2>
<p className="mt-2 text-sm text-white/90">{selectedItem.subtitle}</p>
</div>
<div className="space-y-4 p-5">
<p className="text-sm leading-6 text-slate-300">{selectedItem.description}</p>
<div className="inline-flex items-center gap-2 text-sm text-slate-300">
<BaseIcon path={mdiMapMarker} size={16} />
{selectedItem.town}, Puerto Rico
</div>
<div className="flex flex-wrap gap-3">
<Link
href={selectedItem.href}
className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-semibold text-[#04111f]"
>
Ver en la guía
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
<a
href={selectedItem.mapsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/10"
>
<BaseIcon path={mdiCompassOutline} size={16} />
Google Maps
</a>
<button
type="button"
onClick={() => toggleRouteItem(selectedItem.id)}
className={[
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition',
routeSelection.includes(selectedItem.id)
? 'border-[#DAA520]/35 bg-[#DAA520]/10 text-[#FDE68A]'
: 'border-white/10 bg-white/5 text-white hover:border-white/20 hover:bg-white/10',
].join(' ')}
>
{routeSelection.includes(selectedItem.id)
? 'Quitar de la ruta'
: 'Agregar a la ruta'}
</button>
</div>
</div>
</div>
) : null}
<div className="rounded-[2rem] border border-white/10 bg-white/5 p-6 shadow-xl shadow-black/20">
<div className="text-xs font-bold uppercase tracking-[0.35em] text-[#FDE68A]">Ruta desde el mapa</div>
<h2 className="mt-2 text-2xl font-black text-white">Paradas seleccionadas</h2>
{selectedRouteItems.length ? (
<div className="mt-5 space-y-3">
{selectedRouteItems.map((item, index) => (
<div
key={item.id}
className="flex items-center justify-between gap-3 rounded-[1.5rem] border border-white/10 bg-[#04111f]/60 p-4"
>
<div>
<div className="text-sm font-bold text-white">
{index + 1}. {item.label}
</div>
<div className="mt-1 text-sm text-slate-300">{item.subtitle}</div>
</div>
<button
type="button"
onClick={() => toggleRouteItem(item.id)}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-2 text-xs font-semibold text-slate-200"
>
<BaseIcon path={mdiCloseCircleOutline} size={14} />
Quitar
</button>
</div>
))}
</div>
) : (
<div className="mt-5 rounded-[1.6rem] border border-dashed border-white/10 bg-[#04111f]/40 p-5 text-sm leading-6 text-slate-300">
Marca varias paradas para armar una mini ruta. Con dos o más ya puedes abrirla.
</div>
)}
{directionsUrl ? (
<a
href={directionsUrl}
target="_blank"
rel="noreferrer"
className="mt-5 inline-flex items-center gap-2 rounded-full bg-white px-5 py-3 text-sm font-bold text-[#04111f]"
>
<BaseIcon path={mdiMapSearchOutline} size={16} />
Abrir ruta
</a>
) : null}
</div>
</aside>
</section>
{!visibleItems.length ? (
<section className="rounded-[2rem] border border-dashed border-white/15 bg-white/5 p-10 text-center shadow-xl shadow-black/20">
<h2 className="text-2xl font-black text-white">Con esos filtros no se ve ninguna parada en el mapa</h2>
<p className="mt-3 text-sm leading-6 text-slate-300">
Vuelve a activar chinchorros o lugares, o limpia la búsqueda para verlo completo otra vez.
</p>
</section>
) : null}
</PublicShell>
</>
);
}
MapPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};