395 lines
17 KiB
TypeScript
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>;
|
|
};
|