This commit is contained in:
Flatlogic Bot 2026-05-01 13:40:17 +00:00
parent b6c33de759
commit f9af0ce921
28 changed files with 5289 additions and 161 deletions

View File

@ -1,6 +1,6 @@
# ChinchorreoRPR
# Chinchorreo PR
## This project was generated by [Flatlogic Platform](https://flatlogic.com).

View File

@ -1,5 +1,5 @@
#ChinchorreoRPR - template backend,
#Chinchorreo PR - template backend,
#### Run App on local machine:

View File

@ -1,6 +1,6 @@
{
"name": "chinchorreorpr",
"description": "ChinchorreoRPR - template backend",
"description": "Chinchorreo PR - template backend",
"scripts": {
"start": "npm run db:migrate && npm run db:seed && npm run watch",
"lint": "eslint . --ext .js",

View File

@ -39,7 +39,7 @@ const config = {
},
uploadDir: os.tmpdir(),
email: {
from: 'ChinchorreoRPR <app@flatlogic.app>',
from: 'Chinchorreo PR <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
auth: {

View File

@ -6,7 +6,7 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
const db = require('./db/models');
require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@ -74,8 +74,8 @@ const options = {
openapi: "3.0.0",
info: {
version: "1.0.0",
title: "ChinchorreoRPR",
description: "ChinchorreoRPR Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.",
title: "Chinchorreo PR",
description: "Chinchorreo PR Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.",
},
servers: [
{

View File

@ -1,6 +1,6 @@
const errors = {
app: {
title: 'ChinchorreoRPR',
title: 'Chinchorreo PR',
},
auth: {

View File

@ -1,4 +1,4 @@
# ChinchorreoRPR
# Chinchorreo PR
## This project was generated by Flatlogic Platform.
## Install

View File

@ -39,7 +39,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
>
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
<b className="font-black">ChinchorreoRPR</b>
<b className="font-black">Chinchorreo PR</b>
</div>

View File

@ -0,0 +1,234 @@
import {
mdiCalendarStar,
mdiDice5Outline,
mdiHomeVariantOutline,
mdiLightbulbOnOutline,
mdiLogin,
mdiMapMarkerPath,
mdiMapMarkerRadius,
mdiMapSearchOutline,
mdiSilverwareForkKnife,
mdiStarOutline,
mdiThemeLightDark,
} from '@mdi/js';
import Link from 'next/link';
import React, { ReactNode } from 'react';
import BaseIcon from '../BaseIcon';
import { guideStats } from '../../helpers/chinchorreoData';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { setDarkMode } from '../../stores/styleSlice';
type SectionKey =
| 'inicio'
| 'rutas'
| 'restaurantes'
| 'lugares'
| 'eventos'
| 'tips'
| 'favoritos'
| 'mapa';
type Props = {
activeSection: SectionKey;
children: ReactNode;
floatingAction?: {
label: string;
icon: string;
onClick: () => void;
};
};
const navigationItems: Array<{
key: SectionKey;
href: string;
label: string;
emoji: string;
icon: string;
}> = [
{ key: 'inicio', href: '/', label: 'Inicio', emoji: '🏠', icon: mdiHomeVariantOutline },
{ key: 'rutas', href: '/rutas', label: 'Rutas', emoji: '🗺️', icon: mdiMapMarkerPath },
{
key: 'restaurantes',
href: '/restaurantes',
label: 'Chinchorros',
emoji: '🍽️',
icon: mdiSilverwareForkKnife,
},
{
key: 'lugares',
href: '/lugares',
label: 'Lugares',
emoji: '📍',
icon: mdiMapMarkerRadius,
},
{ key: 'eventos', href: '/eventos', label: 'Eventos', emoji: '🗓️', icon: mdiCalendarStar },
{ key: 'tips', href: '/tips', label: 'Tips', emoji: '💡', icon: mdiLightbulbOnOutline },
{
key: 'favoritos',
href: '/mis-favoritos',
label: 'Favoritos',
emoji: '⭐',
icon: mdiStarOutline,
},
{ key: 'mapa', href: '/mapa', label: 'Mapa', emoji: '🧭', icon: mdiMapSearchOutline },
];
export default function PublicShell({ activeSection, children, floatingAction }: Props) {
const dispatch = useAppDispatch();
const darkMode = useAppSelector((state) => state.style.darkMode);
return (
<div className="relative min-h-screen overflow-hidden bg-[#07111f] text-white dark:bg-[#020712]">
<div className="pointer-events-none absolute inset-0 opacity-80">
<div className="absolute left-[-10rem] top-[-5rem] h-72 w-72 rounded-full bg-[#CE1126]/25 blur-3xl" />
<div className="absolute right-[-6rem] top-24 h-72 w-72 rounded-full bg-[#228B22]/20 blur-3xl" />
<div className="absolute bottom-[-8rem] left-1/4 h-80 w-80 rounded-full bg-[#002D62]/40 blur-3xl" />
</div>
<aside className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex lg:w-72 lg:flex-col lg:border-r lg:border-white/10 lg:bg-[#04111f]/85 lg:backdrop-blur-xl">
<div className="border-b border-white/10 px-6 py-8">
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-[#DAA520]/40 bg-[#DAA520]/10 px-3 py-1 text-xs font-bold uppercase tracking-[0.35em] text-[#FDE68A]">
Chinchorreo PR
</div>
<h1 className="text-3xl font-black tracking-tight text-white">Guía boricua para salir a chinchorrear</h1>
<p className="mt-3 text-sm leading-6 text-slate-300">
Rutas, chinchorros, lugares y favoritos para montar el próximo road trip con sazón.
</p>
<div className="mt-5 grid grid-cols-2 gap-3 text-sm">
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
<div className="text-2xl font-black text-[#FDE68A]">{guideStats.routes}</div>
<div className="text-slate-300">rutas listas</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
<div className="text-2xl font-black text-[#A7F3D0]">{guideStats.chinchorros}</div>
<div className="text-slate-300">paradas</div>
</div>
</div>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-4 py-5">
{navigationItems.map((item) => {
const isActive = item.key === activeSection;
return (
<Link
key={item.key}
href={item.href}
className={[
'group flex items-center gap-3 rounded-2xl border px-4 py-3 transition-all duration-200',
isActive
? 'border-[#DAA520]/40 bg-white/10 text-white shadow-lg shadow-black/20'
: 'border-transparent bg-transparent text-slate-300 hover:border-white/10 hover:bg-white/5 hover:text-white',
].join(' ')}
>
<div
className={[
'flex h-10 w-10 items-center justify-center rounded-2xl border',
isActive
? 'border-[#DAA520]/50 bg-[#DAA520]/15 text-[#FDE68A]'
: 'border-white/10 bg-white/5 text-slate-300',
].join(' ')}
>
<BaseIcon path={item.icon} size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">
<span className="mr-2">{item.emoji}</span>
{item.label}
</div>
<div className="text-xs text-slate-400">Explora esta sección</div>
</div>
</Link>
);
})}
</nav>
<div className="border-t border-white/10 px-4 py-4">
<div className="flex items-center gap-2">
<Link
href="/login"
className="flex flex-1 items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-3 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/10"
>
<BaseIcon path={mdiLogin} size={18} />
Entrar al admin
</Link>
<button
type="button"
onClick={() => dispatch(setDarkMode(null))}
className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-white transition hover:border-white/20 hover:bg-white/10"
aria-label={darkMode ? 'Activar modo claro' : 'Activar modo oscuro'}
>
<BaseIcon path={mdiThemeLightDark} size={20} />
</button>
</div>
</div>
</aside>
<main className="relative min-h-screen pb-28 lg:pl-72 lg:pb-16">
<header className="sticky top-0 z-30 border-b border-white/10 bg-[#04111f]/75 px-4 py-4 backdrop-blur-xl sm:px-6 lg:px-10">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4">
<div>
<div className="text-xs font-bold uppercase tracking-[0.3em] text-[#FDE68A]">Chinchorreo PR</div>
<div className="text-sm text-slate-300">Explora Puerto Rico con sabor, ritmo y ruta.</div>
</div>
<div className="flex items-center gap-2">
<Link
href="/login"
className="hidden 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 sm:inline-flex"
>
<BaseIcon path={mdiLogin} size={16} />
Admin
</Link>
<button
type="button"
onClick={() => dispatch(setDarkMode(null))}
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-white/10 bg-white/5 text-white transition hover:border-white/20 hover:bg-white/10"
aria-label={darkMode ? 'Activar modo claro' : 'Activar modo oscuro'}
>
<BaseIcon path={mdiThemeLightDark} size={18} />
</button>
</div>
</div>
</header>
<div className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 py-6 sm:px-6 lg:px-10 lg:py-8">
{children}
</div>
</main>
{floatingAction ? (
<button
type="button"
onClick={floatingAction.onClick}
className="fixed bottom-24 right-4 z-40 inline-flex items-center gap-3 rounded-full border border-[#DAA520]/30 bg-gradient-to-r from-[#CE1126] to-[#DAA520] px-5 py-3 text-sm font-bold text-white shadow-2xl shadow-black/35 transition hover:translate-y-[-1px] hover:shadow-black/50 lg:bottom-8 lg:right-8"
>
<BaseIcon path={floatingAction.icon || mdiDice5Outline} size={18} />
{floatingAction.label}
</button>
) : null}
<nav className="fixed inset-x-0 bottom-0 z-40 border-t border-white/10 bg-[#04111f]/95 px-2 py-3 backdrop-blur-2xl lg:hidden">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{navigationItems.map((item) => {
const isActive = item.key === activeSection;
return (
<Link
key={item.key}
href={item.href}
className={[
'flex min-w-[96px] flex-col items-center justify-center rounded-2xl border px-3 py-2 text-center transition',
isActive
? 'border-[#DAA520]/40 bg-white/10 text-white'
: 'border-transparent bg-transparent text-slate-300',
].join(' ')}
>
<BaseIcon path={item.icon} size={18} />
<span className="mt-1 text-[11px] font-semibold">{item.label}</span>
</Link>
);
})}
</div>
</nav>
</div>
);
}

View File

@ -0,0 +1,59 @@
import React from 'react';
import { RouteGuide } from '../../helpers/chinchorreoData';
type Props = {
route: RouteGuide;
compact?: boolean;
};
export default function RoutePreview({ route, compact = false }: Props) {
const visibleStops = route.stops.slice(0, compact ? 3 : 4);
return (
<div
className={[
'relative overflow-hidden rounded-[1.75rem] border border-white/15 p-4 text-white',
compact ? 'min-h-[140px]' : 'min-h-[180px]',
].join(' ')}
style={{
backgroundImage: `linear-gradient(135deg, ${route.accentFrom}, ${route.accentTo})`,
}}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.28),transparent_38%),linear-gradient(to_bottom,transparent,rgba(4,17,31,0.45))]" />
<div className="relative flex h-full flex-col justify-between">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.25em] text-white/80">
Preview de ruta
</div>
<div className="mt-1 text-lg font-black tracking-tight">{route.region}</div>
</div>
<div className="rounded-full border border-white/20 bg-black/15 px-3 py-1 text-sm font-semibold">
{route.emoji} {route.difficulty}
</div>
</div>
<div className="mt-6">
<div className="relative flex items-center justify-between gap-2">
<div className="absolute left-4 right-4 top-1/2 h-px -translate-y-1/2 border-t border-dashed border-white/60" />
{visibleStops.map((stop, index) => (
<div key={stop.id} className="relative flex flex-col items-center gap-2 text-center">
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-white/60 bg-white/20 text-xs font-bold">
{index + 1}
</div>
<div className="max-w-[76px] text-[11px] font-semibold leading-4 text-white/90">
{stop.town}
</div>
</div>
))}
</div>
</div>
<div className="mt-5 flex items-center justify-between text-xs text-white/90">
<span>{route.stops.length} paradas</span>
<span>{Math.round(route.estimatedMinutes / 60)}h aprox.</span>
</div>
</div>
</div>
);
}

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,31 @@
export const openWhatsAppShare = (text: string) => {
if (typeof window === 'undefined') {
return;
}
const url = `https://wa.me/?text=${encodeURIComponent(text)}`;
window.open(url, '_blank', 'noopener,noreferrer');
};
export const shareContent = async (title: string, text: string) => {
if (typeof window === 'undefined') {
return;
}
const fullText = `${title}\n${text}\n${window.location.href}`;
if (navigator.share) {
try {
await navigator.share({
title,
text,
url: window.location.href,
});
return;
} catch (error) {
console.error('Native share failed, falling back to WhatsApp', error);
}
}
openWhatsAppShare(fullText);
};

View File

@ -0,0 +1,356 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
export type FavoriteCategory = 'route' | 'chinchorro' | 'place' | 'event';
export type FavoriteList = {
id: string;
name: string;
description: string;
createdAt: string;
};
export type FavoriteEntry = {
id: string;
listId: string;
category: FavoriteCategory;
itemSlug: string;
itemName: string;
itemHref: string;
town?: string;
region?: string;
emoji?: string;
note?: string;
addedAt: string;
};
type PublicGuideState = {
lists: FavoriteList[];
activeListId: string;
favorites: FavoriteEntry[];
routeProgress: Record<string, string[]>;
};
export const DEFAULT_PUBLIC_LIST_ID = 'default-list';
const STORAGE_KEY = 'chinchorreorpr-public-guide';
const STORAGE_EVENT = 'chinchorreorpr:storage';
const createDefaultList = (): FavoriteList => ({
id: DEFAULT_PUBLIC_LIST_ID,
name: 'Mi chinchorreo',
description: 'Selección rápida para el próximo jangueo.',
createdAt: new Date().toISOString(),
});
const createDefaultState = (): PublicGuideState => ({
lists: [createDefaultList()],
activeListId: DEFAULT_PUBLIC_LIST_ID,
favorites: [],
routeProgress: {},
});
const sanitizeState = (value: unknown): PublicGuideState => {
const fallback = createDefaultState();
if (!value || typeof value !== 'object') {
return fallback;
}
const state = value as Partial<PublicGuideState>;
const rawLists = Array.isArray(state.lists) ? state.lists : [];
const hasDefaultList = rawLists.some((list) => list && list.id === DEFAULT_PUBLIC_LIST_ID);
const lists = hasDefaultList
? rawLists.filter(Boolean)
: [createDefaultList(), ...rawLists.filter(Boolean)];
const activeListId =
typeof state.activeListId === 'string' && lists.some((list) => list.id === state.activeListId)
? state.activeListId
: DEFAULT_PUBLIC_LIST_ID;
return {
lists,
activeListId,
favorites: Array.isArray(state.favorites) ? state.favorites.filter(Boolean) : [],
routeProgress:
state.routeProgress && typeof state.routeProgress === 'object'
? state.routeProgress
: fallback.routeProgress,
};
};
const canUseStorage = () => typeof window !== 'undefined';
const readState = (): PublicGuideState => {
if (!canUseStorage()) {
return createDefaultState();
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return createDefaultState();
}
return sanitizeState(JSON.parse(raw));
} catch (error) {
console.error('Failed to read Chinchorreo PR local state', error);
return createDefaultState();
}
};
const writeState = (state: PublicGuideState) => {
if (!canUseStorage()) {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
window.dispatchEvent(new Event(STORAGE_EVENT));
} catch (error) {
console.error('Failed to persist Chinchorreo PR local state', error);
}
};
export const useChinchorreoStorage = () => {
const [state, setState] = useState<PublicGuideState>(createDefaultState);
const syncState = useCallback(() => {
setState(readState());
}, []);
useEffect(() => {
syncState();
if (!canUseStorage()) {
return undefined;
}
const handleSync = () => syncState();
window.addEventListener('storage', handleSync);
window.addEventListener(STORAGE_EVENT, handleSync);
return () => {
window.removeEventListener('storage', handleSync);
window.removeEventListener(STORAGE_EVENT, handleSync);
};
}, [syncState]);
const updateState = useCallback(
(updater: (current: PublicGuideState) => PublicGuideState) => {
const current = readState();
const next = sanitizeState(updater(current));
writeState(next);
setState(next);
return next;
},
[],
);
const createList = useCallback(
(name: string, description: string) => {
const trimmedName = name.trim();
const trimmedDescription = description.trim();
if (!trimmedName) {
return {
ok: false,
message: 'Ponle un nombre a la lista para guardarla.',
};
}
let createdList: FavoriteList | null = null;
updateState((current) => {
createdList = {
id: `list-${Date.now()}`,
name: trimmedName,
description: trimmedDescription || 'Lista creada desde la guía pública.',
createdAt: new Date().toISOString(),
};
return {
...current,
lists: [createdList, ...current.lists],
activeListId: createdList.id,
};
});
return {
ok: true,
message: `Lista “${trimmedName}” creada y lista para usar.`,
list: createdList,
};
},
[updateState],
);
const setActiveListId = useCallback(
(listId: string) => {
updateState((current) => ({
...current,
activeListId: current.lists.some((list) => list.id === listId)
? listId
: DEFAULT_PUBLIC_LIST_ID,
}));
},
[updateState],
);
const addFavorite = useCallback(
(
item: Omit<FavoriteEntry, 'id' | 'listId' | 'addedAt'> & {
listId?: string;
},
) => {
let wasAdded = false;
let usedListId = DEFAULT_PUBLIC_LIST_ID;
updateState((current) => {
const listId = item.listId || current.activeListId || DEFAULT_PUBLIC_LIST_ID;
usedListId = listId;
const exists = current.favorites.some(
(favorite) =>
favorite.category === item.category &&
favorite.itemSlug === item.itemSlug &&
favorite.listId === listId,
);
if (exists) {
return current;
}
wasAdded = true;
return {
...current,
favorites: [
{
...item,
id: `favorite-${Date.now()}-${item.category}-${item.itemSlug}`,
listId,
addedAt: new Date().toISOString(),
},
...current.favorites,
],
};
});
return {
added: wasAdded,
listId: usedListId,
};
},
[updateState],
);
const removeFavorite = useCallback(
(category: FavoriteCategory, itemSlug: string, listId?: string) => {
updateState((current) => ({
...current,
favorites: current.favorites.filter(
(favorite) =>
!(
favorite.category === category &&
favorite.itemSlug === itemSlug &&
(!listId || favorite.listId === listId)
),
),
}));
},
[updateState],
);
const removeList = useCallback(
(listId: string) => {
if (listId === DEFAULT_PUBLIC_LIST_ID) {
return false;
}
updateState((current) => ({
...current,
lists: current.lists.filter((list) => list.id !== listId),
activeListId:
current.activeListId === listId ? DEFAULT_PUBLIC_LIST_ID : current.activeListId,
favorites: current.favorites.filter((favorite) => favorite.listId !== listId),
}));
return true;
},
[updateState],
);
const clearListFavorites = useCallback(
(listId: string) => {
updateState((current) => ({
...current,
favorites: current.favorites.filter((favorite) => favorite.listId !== listId),
}));
},
[updateState],
);
const isFavorite = useCallback(
(category: FavoriteCategory, itemSlug: string, listId?: string) =>
state.favorites.some(
(favorite) =>
favorite.category === category &&
favorite.itemSlug === itemSlug &&
(!listId || favorite.listId === listId),
),
[state.favorites],
);
const favoritesByList = useMemo(() => {
return state.lists.reduce<Record<string, FavoriteEntry[]>>((accumulator, list) => {
accumulator[list.id] = state.favorites.filter((favorite) => favorite.listId === list.id);
return accumulator;
}, {});
}, [state.favorites, state.lists]);
const toggleRouteStop = useCallback(
(routeSlug: string, stopId: string) => {
let completed = false;
updateState((current) => {
const currentStops = current.routeProgress[routeSlug] || [];
const alreadyCompleted = currentStops.includes(stopId);
const nextStops = alreadyCompleted
? currentStops.filter((entry) => entry !== stopId)
: [...currentStops, stopId];
completed = !alreadyCompleted;
return {
...current,
routeProgress: {
...current.routeProgress,
[routeSlug]: nextStops,
},
};
});
return { completed };
},
[updateState],
);
return {
state,
lists: state.lists,
activeListId: state.activeListId,
favorites: state.favorites,
favoritesByList,
routeProgress: state.routeProgress,
createList,
setActiveListId,
addFavorite,
removeFavorite,
removeList,
clearListFavorites,
isFavorite,
toggleRouteStop,
};
};

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'

View File

@ -149,7 +149,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
setStepsEnabled(false);
};
const title = 'ChinchorreoRPR'
const title = 'Chinchorreo PR'
const description = "Guía interactiva de chinchorreo en Puerto Rico con rutas, chinchorros, eventos, mapa y favoritos."
const url = "https://flatlogic.com/"
const image = "https://project-screens.s3.amazonaws.com/screenshots/39856/app-hero-20260501-125746.png"

View File

@ -0,0 +1,257 @@
import {
mdiCalendarStar,
mdiCheckCircleOutline,
mdiClockOutline,
mdiStarOutline,
} from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useMemo, useState } from 'react';
import PublicShell from '../../components/Chinchorreo/PublicShell';
import BaseIcon from '../../components/BaseIcon';
import {
eventsGuide,
formatEventDate,
getCountdownLabel,
} from '../../helpers/chinchorreoData';
import { useChinchorreoStorage } from '../../hooks/useChinchorreoStorage';
import LayoutGuest from '../../layouts/Guest';
const weekDays = ['D', 'L', 'M', 'M', 'J', 'V', 'S'];
const monthLabel = (date: Date) =>
new Intl.DateTimeFormat('es-PR', {
month: 'long',
year: 'numeric',
}).format(date);
export default function EventsPage() {
const { addFavorite, isFavorite, lists, activeListId } = useChinchorreoStorage();
const [searchValue, setSearchValue] = useState('');
const [feedback, setFeedback] = useState('');
const activeList = lists.find((list) => list.id === activeListId) || lists[0];
const filteredEvents = useMemo(() => {
return [...eventsGuide]
.filter((event) => {
const haystack = `${event.name} ${event.town} ${event.venue} ${event.category} ${event.teaser}`.toLowerCase();
return haystack.includes(searchValue.toLowerCase().trim());
})
.sort(
(left, right) =>
new Date(left.nextDateIso).getTime() - new Date(right.nextDateIso).getTime(),
);
}, [searchValue]);
const spotlightDate = filteredEvents[0]
? new Date(filteredEvents[0].nextDateIso)
: new Date();
const calendarMonth = new Date(
Date.UTC(spotlightDate.getUTCFullYear(), spotlightDate.getUTCMonth(), 1),
);
const calendarDays = useMemo(() => {
const firstDay = new Date(calendarMonth);
const lastDay = new Date(
Date.UTC(calendarMonth.getUTCFullYear(), calendarMonth.getUTCMonth() + 1, 0),
);
const cells: Array<{ day?: number; event?: (typeof filteredEvents)[number] }> = [];
for (let index = 0; index < firstDay.getUTCDay(); index += 1) {
cells.push({});
}
for (let day = 1; day <= lastDay.getUTCDate(); day += 1) {
const eventForDay = filteredEvents.find((event) => {
const eventDate = new Date(event.nextDateIso);
return (
eventDate.getUTCFullYear() === calendarMonth.getUTCFullYear() &&
eventDate.getUTCMonth() === calendarMonth.getUTCMonth() &&
eventDate.getUTCDate() === day
);
});
cells.push({ day, event: eventForDay });
}
return cells;
}, [calendarMonth, filteredEvents]);
const handleFavorite = (slug: string, name: string, town: string) => {
const result = addFavorite({
category: 'event',
itemSlug: slug,
itemName: name,
itemHref: `/eventos?search=${encodeURIComponent(name)}`,
town,
emoji: '🗓️',
note: 'Evento guardado desde el calendario público.',
});
setFeedback(
result.added
? `Evento guardado en “${activeList?.name || 'Mi chinchorreo'}”.`
: 'Ese evento ya estaba en la lista activa.',
);
};
return (
<>
<Head>
<title>Chinchorreo PR | Eventos & Festivales</title>
</Head>
<PublicShell activeSection="eventos">
<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]">Eventos & Festivales</div>
<h1 className="mt-2 text-4xl font-black tracking-tight text-white">
Calendario sabroso entre comida, cultura y tradición
</h1>
<p className="mt-3 text-base leading-7 text-slate-300">
Aquí tienes un calendario mensual simplificado con contador regresivo y una lista de los próximos eventos más importantes del chinchorreo boricua.
</p>
</div>
<div className="mt-6 max-w-xl">
<input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Buscar por festival, pueblo o venue"
className="w-full rounded-2xl border border-white/10 bg-[#04111f]/50 px-4 py-3 text-white placeholder:text-slate-400 focus:border-[#DAA520] focus:outline-none focus:ring-0"
/>
</div>
{feedback ? (
<div className="mt-4 inline-flex items-center gap-2 rounded-full border border-[#228B22]/30 bg-[#228B22]/10 px-4 py-2 text-sm text-[#A7F3D0]">
<BaseIcon path={mdiCheckCircleOutline} size={16} />
{feedback}
</div>
) : null}
</section>
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<div className="rounded-[2rem] border border-white/10 bg-white/5 p-6 shadow-xl shadow-black/20">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-bold uppercase tracking-[0.35em] text-[#FDE68A]">Mes destacado</div>
<h2 className="mt-2 text-3xl font-black text-white">{monthLabel(calendarMonth)}</h2>
</div>
<div className="rounded-full border border-white/10 bg-white/5 px-3 py-2 text-sm font-semibold text-slate-200">
{filteredEvents.length} eventos visibles
</div>
</div>
<div className="mt-6 grid grid-cols-7 gap-2 text-center text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
{weekDays.map((day) => (
<div key={day}>{day}</div>
))}
</div>
<div className="mt-3 grid grid-cols-7 gap-2">
{calendarDays.map((cell, index) => (
<div
key={`${cell.day || 'empty'}-${index}`}
className={[
'min-h-[78px] rounded-2xl border p-2 text-sm transition',
cell.event
? 'border-[#DAA520]/30 bg-[#DAA520]/10 text-white'
: 'border-white/5 bg-[#04111f]/50 text-slate-400',
].join(' ')}
>
{cell.day ? (
<>
<div className="font-bold">{cell.day}</div>
{cell.event ? (
<div className="mt-2 text-[10px] font-semibold leading-4 text-[#FDE68A]">
{cell.event.emoji} {cell.event.name}
</div>
) : null}
</>
) : null}
</div>
))}
</div>
</div>
<div className="space-y-4">
{filteredEvents.map((event) => {
const saved = isFavorite('event', event.slug);
return (
<article
key={event.slug}
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, ${event.accentFrom}, ${event.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">
{event.emoji} {event.category}
</div>
<h2 className="mt-4 text-2xl font-black text-white">{event.name}</h2>
<p className="mt-2 text-sm text-white/90">{event.teaser}</p>
</div>
<div className="space-y-4 p-5">
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-[1.4rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm text-slate-300">
<div className="text-xs uppercase tracking-[0.2em] text-slate-500">Fecha</div>
<div className="mt-2 font-semibold text-white">{formatEventDate(event.nextDateIso)}</div>
</div>
<div className="rounded-[1.4rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm text-slate-300">
<div className="text-xs uppercase tracking-[0.2em] text-slate-500">Venue</div>
<div className="mt-2 font-semibold text-white">{event.venue}</div>
</div>
<div className="rounded-[1.4rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm text-slate-300">
<div className="text-xs uppercase tracking-[0.2em] text-slate-500">Cuenta regresiva</div>
<div className="mt-2 font-semibold text-[#FDE68A]">{getCountdownLabel(event.nextDateIso)}</div>
</div>
</div>
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200">
<BaseIcon path={mdiClockOutline} size={16} />
{event.schedule}
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => handleFavorite(event.slug, event.name, event.town)}
className={[
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition',
saved
? '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(' ')}
>
<BaseIcon path={mdiStarOutline} size={16} />
{saved ? 'Guardado' : 'Guardar evento'}
</button>
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200">
<BaseIcon path={mdiCalendarStar} size={16} />
{event.town}
</div>
</div>
</div>
</article>
);
})}
</div>
</section>
{!filteredEvents.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">No encontramos eventos con ese término</h2>
<p className="mt-3 text-sm leading-6 text-slate-300">
Intenta con otro pueblo, venue o limpia la búsqueda para ver el calendario completo.
</p>
</section>
) : null}
</PublicShell>
</>
);
}
EventsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -1,166 +1,395 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import {
mdiCalendarStar,
mdiChevronRight,
mdiDice5Outline,
mdiLogin,
mdiMagnify,
mdiMapMarkerPath,
mdiTrendingUp,
mdiWeatherPartlyCloudy,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import { useRouter } from 'next/router';
import React, { ReactElement, useMemo, useState } from 'react';
import PublicShell from '../components/Chinchorreo/PublicShell';
import RoutePreview from '../components/Chinchorreo/RoutePreview';
import BaseIcon from '../components/BaseIcon';
import {
formatEventDate,
getCountdownLabel,
getFeaturedChinchorros,
getPopularRoutes,
getRandomRoute,
getTipOfTheDay,
getUpcomingEvents,
guideStats,
searchGuide,
weatherSnapshot,
} from '../helpers/chinchorreoData';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
const featuredRoutes = getPopularRoutes();
const featuredStops = getFeaturedChinchorros(3);
const upcomingEvents = getUpcomingEvents(3);
const tipOfTheDay = getTipOfTheDay();
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
const SectionHeader = ({
eyebrow,
title,
description,
href,
}: {
eyebrow: string;
title: string;
description: string;
href?: string;
}) => (
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="text-xs font-bold uppercase tracking-[0.35em] text-[#FDE68A]">{eyebrow}</div>
<h2 className="mt-2 text-3xl font-black tracking-tight text-white">{title}</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-300">{description}</p>
</div>
{href ? (
<Link
href={href}
className="inline-flex items-center gap-2 self-start 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"
>
Ver todo
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
) : null}
</div>
);
const title = 'ChinchorreoRPR'
export default function HomePage() {
const router = useRouter();
const [searchValue, setSearchValue] = useState('');
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const searchResults = useMemo(() => {
if (searchValue.trim().length < 2) {
return [];
}
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
return searchGuide(searchValue).slice(0, 6);
}, [searchValue]);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
const handleRandomRoute = () => {
const selectedRoute = getRandomRoute();
router.push(`/rutas/${selectedRoute.slug}`);
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>Chinchorreo PR | Inicio</title>
<meta
name="description"
content="Guía boricua para descubrir rutas, chinchorros, eventos y favoritos en Puerto Rico."
/>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
<PublicShell
activeSection="inicio"
floatingAction={{
label: 'Iniciar Ruta Aleatoria 🎲',
icon: mdiDice5Outline,
onClick: handleRandomRoute,
}}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your ChinchorreoRPR app!"/>
<div className="space-y-3">
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center '>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<section
className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-[#04111f] p-6 shadow-2xl shadow-black/30 sm:p-8 lg:p-10"
style={{
backgroundImage:
'linear-gradient(110deg, rgba(4,17,31,0.88), rgba(4,17,31,0.45)), url(https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=1800&q=80)',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(218,165,32,0.18),transparent_30%),radial-gradient(circle_at_left_bottom,rgba(34,139,34,0.25),transparent_35%)]" />
<div className="relative grid gap-8 lg:grid-cols-[1.3fr_0.7fr] lg:items-end">
<div className="max-w-3xl">
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-xs font-bold uppercase tracking-[0.35em] text-[#FDE68A]">
<span>🇵🇷</span>
¡Vamos a chinchorrear!
</div>
<h1 className="mt-5 text-4xl font-black tracking-tight text-white sm:text-5xl lg:text-6xl">
Tu guía interactiva para janguear Puerto Rico con sabor, ruta y corillo.
</h1>
<p className="mt-4 max-w-2xl text-base leading-7 text-slate-200 sm:text-lg">
Explora rutas auténticas, guarda tus paradas favoritas y descubre eventos, vistas y
chinchorros con una estética boricua vibrante y elegante.
</p>
<div className="relative mt-6 max-w-2xl">
<div className="flex items-center gap-3 rounded-[1.5rem] border border-white/15 bg-[#04111f]/75 p-3 shadow-lg shadow-black/20 backdrop-blur">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/10 text-white">
<BaseIcon path={mdiMagnify} size={22} />
</div>
<input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Buscar restaurante, ruta, pueblo o festival"
className="w-full border-0 bg-transparent text-base text-white placeholder:text-slate-400 focus:ring-0"
/>
<button
type="button"
onClick={() => router.push(`/rutas?search=${encodeURIComponent(searchValue)}`)}
className="inline-flex items-center rounded-full bg-white px-4 py-2 text-sm font-bold text-[#04111f] transition hover:bg-slate-100"
>
Buscar
</button>
</div>
{searchResults.length ? (
<div className="absolute left-0 right-0 top-[calc(100%+0.75rem)] z-20 overflow-hidden rounded-[1.5rem] border border-white/10 bg-[#04111f]/95 shadow-2xl shadow-black/40 backdrop-blur-xl">
{searchResults.map((result) => (
<Link
key={result.id}
href={result.href}
className="flex items-center justify-between gap-3 border-b border-white/5 px-4 py-4 transition last:border-b-0 hover:bg-white/5"
>
<div>
<div className="text-sm font-semibold text-white">
<span className="mr-2">{result.emoji}</span>
{result.label}
</div>
<div className="text-xs text-slate-400">{result.subtitle}</div>
</div>
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-300">
{result.typeLabel}
</span>
</Link>
))}
</div>
) : null}
</div>
<div className="mt-6 flex flex-wrap items-center gap-3">
<Link
href="/rutas"
className="inline-flex items-center gap-2 rounded-full bg-[#CE1126] px-5 py-3 text-sm font-bold text-white transition hover:bg-[#b90f23]"
>
<BaseIcon path={mdiMapMarkerPath} size={18} />
Explorar rutas
</Link>
<Link
href="/login"
className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-5 py-3 text-sm font-bold text-white transition hover:border-white/25 hover:bg-white/15"
>
<BaseIcon path={mdiLogin} size={18} />
Entrar al admin
</Link>
</div>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
<div className="grid gap-4 sm:grid-cols-3 lg:grid-cols-1">
{[
{ label: 'Rutas curadas', value: guideStats.routes, accent: '#FDE68A' },
{ label: 'Chinchorros destacados', value: guideStats.chinchorros, accent: '#A7F3D0' },
{ label: 'Eventos cercanos', value: guideStats.events, accent: '#BFDBFE' },
].map((stat) => (
<div
key={stat.label}
className="rounded-[1.75rem] border border-white/15 bg-white/10 p-5 backdrop-blur"
>
<div className="text-xs font-semibold uppercase tracking-[0.25em] text-white/70">
{stat.label}
</div>
<div className="mt-3 text-4xl font-black tracking-tight" style={{ color: stat.accent }}>
{stat.value}
</div>
<div className="mt-2 text-sm text-slate-200">Listas para descubrir hoy mismo.</div>
</div>
))}
</div>
</div>
</section>
</div>
<section className="space-y-5">
<SectionHeader
eyebrow="Rutas populares"
title="Desliza y arma tu próximo chinchorreo"
description="Las rutas más queridas combinan paisaje, comida y paradas pensadas para un road trip sabroso."
href="/rutas"
/>
<div className="flex snap-x gap-5 overflow-x-auto pb-2">
{featuredRoutes.map((route) => (
<Link
key={route.slug}
href={`/rutas/${route.slug}`}
className="min-w-[300px] snap-start rounded-[1.8rem] border border-white/10 bg-white/5 p-4 shadow-xl shadow-black/20 transition hover:-translate-y-1 hover:border-white/20 sm:min-w-[360px]"
>
<RoutePreview route={route} compact />
<div className="mt-4">
<div className="text-xs font-bold uppercase tracking-[0.25em] text-[#FDE68A]">
{route.region}
</div>
<h3 className="mt-2 text-xl font-black text-white">{route.name}</h3>
<p className="mt-2 text-sm leading-6 text-slate-300">{route.summary}</p>
<div className="mt-3 flex flex-wrap gap-2">
{route.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold text-slate-200"
>
{tag}
</span>
))}
</div>
</div>
</Link>
))}
</div>
</section>
<section className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-5">
<SectionHeader
eyebrow="Destacados del día"
title="Paradas con mucho flow boricua"
description="Tres chinchorros para irte a la segura si hoy lo que quieres es comer bien y pasarla mejor."
href="/restaurantes"
/>
<div className="grid gap-4 md:grid-cols-3">
{featuredStops.map((stop) => (
<div
key={stop.slug}
className="overflow-hidden rounded-[1.8rem] border border-white/10 bg-[#04111f] shadow-xl shadow-black/20"
>
<div
className="p-5"
style={{
backgroundImage: `linear-gradient(135deg, ${stop.accentFrom}, ${stop.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">
{stop.region}
</div>
<h3 className="mt-4 text-2xl font-black text-white">{stop.name}</h3>
<p className="mt-2 text-sm text-white/90">{stop.specialties[0]}</p>
</div>
<div className="space-y-4 p-5">
<p className="text-sm leading-6 text-slate-300">{stop.description}</p>
<div className="flex flex-wrap gap-2">
{stop.specialties.map((specialty) => (
<span
key={specialty}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold text-slate-200"
>
{specialty}
</span>
))}
</div>
<Link
href={`/restaurantes?search=${encodeURIComponent(stop.name)}`}
className="inline-flex items-center gap-2 text-sm font-semibold text-[#FDE68A]"
>
Ver en la guía
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
</div>
</div>
))}
</div>
</div>
<div className="space-y-5">
<SectionHeader
eyebrow="Hoy en la isla"
title="Clima + tip del día"
description="Un vistazo rápido para salir mejor preparado antes de arrancar."
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
<div className="rounded-[1.8rem] border border-white/10 bg-white/5 p-5 shadow-xl shadow-black/20">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-xs font-bold uppercase tracking-[0.25em] text-[#BFDBFE]">
Clima actual
</div>
<div className="mt-2 text-2xl font-black text-white">{weatherSnapshot.location}</div>
<div className="mt-1 text-sm text-slate-300">{weatherSnapshot.condition}</div>
</div>
<div className="flex h-16 w-16 items-center justify-center rounded-[1.4rem] bg-[#002D62]/40 text-3xl">
{weatherSnapshot.icon}
</div>
</div>
<div className="mt-5 flex items-end gap-3">
<div className="text-5xl font-black text-white">{weatherSnapshot.temperatureF}°</div>
<div className="pb-1 text-sm text-slate-300">
{weatherSnapshot.temperatureC}°C · Humedad {weatherSnapshot.humidity}%
</div>
</div>
<div className="mt-4 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={mdiWeatherPartlyCloudy} size={16} />
Ideal para ruta mixta de costa y montaña
</div>
</div>
<div className="rounded-[1.8rem] border border-white/10 bg-white/5 p-5 shadow-xl shadow-black/20">
<div className="inline-flex items-center gap-2 rounded-full border border-[#DAA520]/25 bg-[#DAA520]/10 px-3 py-1 text-xs font-bold uppercase tracking-[0.25em] text-[#FDE68A]">
<BaseIcon path={mdiTrendingUp} size={14} />
Tip del día
</div>
<h3 className="mt-4 text-2xl font-black text-white">{tipOfTheDay.category}</h3>
<p className="mt-3 text-sm leading-7 text-slate-200">
<span className="mr-2 text-lg">{tipOfTheDay.emoji}</span>
{tipOfTheDay.text}
</p>
<Link
href="/tips"
className="mt-4 inline-flex items-center gap-2 text-sm font-semibold text-[#A7F3D0]"
>
Ver guía completa
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
</div>
</div>
</div>
</section>
<section className="space-y-5">
<SectionHeader
eyebrow="Eventos y festivales"
title="Próximas fechas para montar el plan"
description="Un calendario vivo del chinchorreo boricua: gastronomía, cultura y música para combinar con tu ruta."
href="/eventos"
/>
<div className="grid gap-4 lg:grid-cols-3">
{upcomingEvents.map((event) => (
<div
key={event.slug}
className="rounded-[1.8rem] border border-white/10 bg-white/5 p-5 shadow-xl shadow-black/20"
>
<div
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1 text-xs font-bold uppercase tracking-[0.25em] text-white"
style={{
backgroundImage: `linear-gradient(135deg, ${event.accentFrom}, ${event.accentTo})`,
}}
>
<span>{event.emoji}</span>
{event.category}
</div>
<h3 className="mt-4 text-xl font-black text-white">{event.name}</h3>
<p className="mt-2 text-sm leading-6 text-slate-300">{event.teaser}</p>
<div className="mt-4 flex items-center justify-between rounded-[1.4rem] border border-white/10 bg-[#04111f]/60 px-4 py-3 text-sm text-slate-200">
<div>
<div className="font-semibold text-white">{formatEventDate(event.nextDateIso)}</div>
<div className="text-xs text-slate-400">{event.venue}</div>
</div>
<div className="rounded-full border border-[#DAA520]/25 bg-[#DAA520]/10 px-3 py-1 text-xs font-bold text-[#FDE68A]">
{getCountdownLabel(event.nextDateIso)}
</div>
</div>
</div>
))}
</div>
</section>
</PublicShell>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
HomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -44,7 +44,7 @@ export default function Login() {
password: '16ca6a8c',
remember: true })
const title = 'ChinchorreoRPR'
const title = 'Chinchorreo PR'
// Fetch Pexels image/video
useEffect( () => {

View File

@ -0,0 +1,202 @@
import {
mdiCheckCircleOutline,
mdiCompassOutline,
mdiMapMarker,
mdiStarOutline,
} from '@mdi/js';
import Head from 'next/head';
import { useRouter } from 'next/router';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import PublicShell from '../../components/Chinchorreo/PublicShell';
import BaseIcon from '../../components/BaseIcon';
import { placesGuide } from '../../helpers/chinchorreoData';
import { useChinchorreoStorage } from '../../hooks/useChinchorreoStorage';
import LayoutGuest from '../../layouts/Guest';
const categoryFilters = ['Todas', ...Array.from(new Set(placesGuide.map((place) => place.category)))];
export default function PlacesPage() {
const router = useRouter();
const { addFavorite, isFavorite, lists, activeListId } = useChinchorreoStorage();
const [searchValue, setSearchValue] = useState('');
const [selectedCategory, setSelectedCategory] = useState('Todas');
const [feedback, setFeedback] = useState('');
const activeList = lists.find((list) => list.id === activeListId) || lists[0];
useEffect(() => {
if (typeof router.query.search === 'string') {
setSearchValue(router.query.search);
}
}, [router.query.search]);
const filteredPlaces = useMemo(() => {
return placesGuide.filter((place) => {
const searchMatch = `${place.name} ${place.category} ${place.town} ${place.description}`
.toLowerCase()
.includes(searchValue.toLowerCase().trim());
const categoryMatch = selectedCategory === 'Todas' || place.category === selectedCategory;
return searchMatch && categoryMatch;
});
}, [searchValue, selectedCategory]);
const handleFavorite = (slug: string, name: string, town: string) => {
const result = addFavorite({
category: 'place',
itemSlug: slug,
itemName: name,
itemHref: `/lugares?search=${encodeURIComponent(name)}`,
town,
emoji: '📍',
note: 'Lugar guardado desde la sección de interés.',
});
setFeedback(
result.added
? `Lugar guardado en “${activeList?.name || 'Mi chinchorreo'}”.`
: 'Ese lugar ya estaba en la lista activa.',
);
};
return (
<>
<Head>
<title>Chinchorreo PR | Lugares de Interés</title>
</Head>
<PublicShell activeSection="lugares">
<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]">Lugares de interés</div>
<h1 className="mt-2 text-4xl font-black tracking-tight text-white">
Playas, miradores, pueblos y cultura para completar la ruta
</h1>
<p className="mt-3 text-base leading-7 text-slate-300">
Usa estos lugares como pausas estratégicas entre paradas gastronómicas: fotos, vistas,
artesanías o una caminata corta para seguir con el plan.
</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]">
<label className="block">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.25em] text-slate-400">
Buscar lugar
</div>
<input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Loíza, playa, mirador, 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>
<label className="block">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.25em] text-slate-400">
Categoría
</div>
<select
value={selectedCategory}
onChange={(event) => setSelectedCategory(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-white focus:border-[#DAA520] focus:outline-none focus:ring-0"
>
{categoryFilters.map((option) => (
<option key={option} value={option} className="bg-[#04111f]">
{option}
</option>
))}
</select>
</label>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-slate-300">
<div className="rounded-full border border-white/10 bg-white/5 px-4 py-2">
{filteredPlaces.length} lugares disponibles
</div>
{feedback ? (
<div className="inline-flex items-center gap-2 rounded-full border border-[#228B22]/30 bg-[#228B22]/10 px-4 py-2 text-[#A7F3D0]">
<BaseIcon path={mdiCheckCircleOutline} size={16} />
{feedback}
</div>
) : null}
</div>
</section>
{!filteredPlaces.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">No encontramos lugares con esos filtros</h2>
<p className="mt-3 text-sm leading-6 text-slate-300">
Prueba buscando por pueblo, cambia la categoría o limpia el término de búsqueda.
</p>
</section>
) : null}
<section className="grid gap-5 lg:grid-cols-2 xl:grid-cols-3">
{filteredPlaces.map((place) => {
const saved = isFavorite('place', place.slug);
return (
<article
key={place.slug}
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, ${place.accentFrom}, ${place.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">
{place.emoji} {place.category}
</div>
<h2 className="mt-4 text-2xl font-black text-white">{place.name}</h2>
<p className="mt-2 text-sm text-white/90">{place.town}</p>
</div>
<div className="space-y-4 p-5">
<p className="text-sm leading-6 text-slate-300">{place.description}</p>
<div className="rounded-[1.4rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm text-slate-300">
<div className="text-xs uppercase tracking-[0.2em] text-slate-500">Mejor horario</div>
<div className="mt-2 font-semibold text-white">{place.bestTime}</div>
</div>
<div className="rounded-[1.4rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm text-slate-300">
<div className="text-xs uppercase tracking-[0.2em] text-slate-500">Consejo local</div>
<div className="mt-2 leading-6 text-white">{place.localTip}</div>
</div>
<div className="flex flex-wrap gap-3">
<a
href={place.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} />
Cómo llegar
</a>
<button
type="button"
onClick={() => handleFavorite(place.slug, place.name, place.town)}
className={[
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition',
saved
? '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(' ')}
>
<BaseIcon path={mdiStarOutline} size={16} />
{saved ? 'Guardado' : 'Guardar en Favoritos'}
</button>
</div>
<div className="inline-flex items-center gap-2 text-sm text-slate-300">
<BaseIcon path={mdiMapMarker} size={16} />
{place.town}, Puerto Rico
</div>
</div>
</article>
);
})}
</section>
</PublicShell>
</>
);
}
PlacesPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

394
frontend/src/pages/mapa.tsx Normal file
View File

@ -0,0 +1,394 @@
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 detalle
<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">Pins seleccionados</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">
Toca varios pins para armar una mini ruta. Necesitas al menos dos para trazarla.
</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} />
Trazar 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">No hay pins visibles con esos filtros</h2>
<p className="mt-3 text-sm leading-6 text-slate-300">
Activa chinchorros o lugares nuevamente, o limpia la búsqueda para reconstruir el mapa.
</p>
</section>
) : null}
</PublicShell>
</>
);
}
MapPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,354 @@
import {
mdiCheckCircleOutline,
mdiChevronRight,
mdiCloseCircleOutline,
mdiPlaylistStar,
mdiShareVariantOutline,
mdiStarOutline,
} 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 { openWhatsAppShare } from '../helpers/chinchorreoShare';
import {
DEFAULT_PUBLIC_LIST_ID,
FavoriteCategory,
useChinchorreoStorage,
} from '../hooks/useChinchorreoStorage';
import LayoutGuest from '../layouts/Guest';
const categoryLabels: Record<FavoriteCategory, string> = {
route: 'Rutas',
chinchorro: 'Restaurantes',
place: 'Lugares',
event: 'Eventos',
};
const categoryAccents: Record<FavoriteCategory, string> = {
route: 'text-[#FDE68A]',
chinchorro: 'text-[#A7F3D0]',
place: 'text-[#BFDBFE]',
event: 'text-[#FCA5A5]',
};
export default function FavoritesPage() {
const {
lists,
activeListId,
setActiveListId,
favoritesByList,
createList,
removeFavorite,
removeList,
clearListFavorites,
} = useChinchorreoStorage();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [feedback, setFeedback] = useState('');
const activeList = lists.find((list) => list.id === activeListId) || lists[0];
const activeFavorites = favoritesByList[activeListId] || [];
const groupedFavorites = useMemo(() => {
return {
route: activeFavorites.filter((favorite) => favorite.category === 'route'),
chinchorro: activeFavorites.filter((favorite) => favorite.category === 'chinchorro'),
place: activeFavorites.filter((favorite) => favorite.category === 'place'),
event: activeFavorites.filter((favorite) => favorite.category === 'event'),
};
}, [activeFavorites]);
const handleCreateList = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const result = createList(name, description);
setFeedback(result.message);
if (result.ok) {
setName('');
setDescription('');
}
};
const shareList = () => {
if (!activeFavorites.length) {
setFeedback('Agrega al menos una ruta o chinchorro antes de compartir la lista.');
return;
}
const message = [
`Lista: ${activeList?.name || 'Mi chinchorreo'}`,
activeList?.description || 'Plan armado en Chinchorreo PR',
'',
...activeFavorites.map(
(favorite) => `${favorite.itemName} (${categoryLabels[favorite.category]})`,
),
].join('\n');
openWhatsAppShare(message);
};
const handleRemoveFavorite = (category: FavoriteCategory, itemSlug: string) => {
removeFavorite(category, itemSlug, activeListId);
setFeedback('Favorito removido de la lista activa.');
};
const handleRemoveList = () => {
const removed = removeList(activeListId);
setFeedback(
removed
? 'La lista personalizada fue eliminada.'
: 'La lista principal “Mi chinchorreo” no se puede eliminar.',
);
};
const handleClearList = () => {
clearListFavorites(activeListId);
setFeedback('Se vació la lista activa, pero la lista sigue creada.');
};
return (
<>
<Head>
<title>Chinchorreo PR | Mis Favoritos</title>
</Head>
<PublicShell activeSection="favoritos">
<section className="grid gap-6 xl:grid-cols-[1fr_1fr]">
<div className="rounded-[2rem] border border-white/10 bg-white/5 p-6 shadow-xl shadow-black/20 sm:p-8">
<div className="text-xs font-bold uppercase tracking-[0.35em] text-[#FDE68A]">Mis Favoritos</div>
<h1 className="mt-2 text-4xl font-black tracking-tight text-white">
Crea listas y guarda tus próximos jangueos
</h1>
<p className="mt-3 text-base leading-7 text-slate-300">
En esta primera iteración, tus favoritos viven en el navegador: puedes crear listas,
elegir una lista activa y compartirla por WhatsApp.
</p>
<div className="mt-6 grid gap-4 sm:grid-cols-3">
<div className="rounded-[1.7rem] border border-white/10 bg-[#04111f]/60 p-4">
<div className="text-xs uppercase tracking-[0.25em] text-slate-400">Listas</div>
<div className="mt-2 text-3xl font-black text-white">{lists.length}</div>
</div>
<div className="rounded-[1.7rem] border border-white/10 bg-[#04111f]/60 p-4">
<div className="text-xs uppercase tracking-[0.25em] text-slate-400">Activa</div>
<div className="mt-2 text-xl font-black text-[#FDE68A]">{activeList?.name}</div>
</div>
<div className="rounded-[1.7rem] border border-white/10 bg-[#04111f]/60 p-4">
<div className="text-xs uppercase tracking-[0.25em] text-slate-400">Guardados</div>
<div className="mt-2 text-3xl font-black text-white">{activeFavorites.length}</div>
</div>
</div>
{feedback ? (
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-[#228B22]/30 bg-[#228B22]/10 px-4 py-2 text-sm text-[#A7F3D0]">
<BaseIcon path={mdiCheckCircleOutline} size={16} />
{feedback}
</div>
) : null}
</div>
<form
onSubmit={handleCreateList}
className="rounded-[2rem] border border-white/10 bg-white/5 p-6 shadow-xl shadow-black/20 sm:p-8"
>
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-bold uppercase tracking-[0.25em] text-[#A7F3D0]">
<BaseIcon path={mdiPlaylistStar} size={14} />
Nueva lista personalizada
</div>
<div className="mt-4 space-y-4">
<label className="block">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.25em] text-slate-400">
Nombre de la lista
</div>
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Mi próximo chinchorreo con el grupo"
className="w-full rounded-2xl border border-white/10 bg-[#04111f]/60 px-4 py-3 text-white placeholder:text-slate-400 focus:border-[#DAA520] focus:outline-none focus:ring-0"
/>
</label>
<label className="block">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.25em] text-slate-400">
Descripción corta
</div>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={4}
placeholder="Ruta de sábado con vistas, frituras y una parada para café."
className="w-full rounded-2xl border border-white/10 bg-[#04111f]/60 px-4 py-3 text-white placeholder:text-slate-400 focus:border-[#DAA520] focus:outline-none focus:ring-0"
/>
</label>
<button
type="submit"
className="inline-flex items-center gap-2 rounded-full bg-white px-5 py-3 text-sm font-bold text-[#04111f]"
>
Crear lista
<BaseIcon path={mdiChevronRight} size={16} />
</button>
</div>
</form>
</section>
<section className="rounded-[2rem] border border-white/10 bg-white/5 p-6 shadow-xl shadow-black/20 sm:p-8">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<div className="text-xs font-bold uppercase tracking-[0.35em] text-[#FDE68A]">Tus listas</div>
<h2 className="mt-2 text-3xl font-black text-white">Selecciona una lista activa</h2>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={shareList}
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={mdiShareVariantOutline} size={16} />
Compartir lista
</button>
<button
type="button"
onClick={handleClearList}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/5"
>
Vaciar lista activa
</button>
{activeListId !== DEFAULT_PUBLIC_LIST_ID ? (
<button
type="button"
onClick={handleRemoveList}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/5"
>
Eliminar lista
</button>
) : null}
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{lists.map((list) => {
const isActive = list.id === activeListId;
return (
<button
type="button"
key={list.id}
onClick={() => setActiveListId(list.id)}
className={[
'rounded-[1.75rem] border p-5 text-left transition',
isActive
? 'border-[#DAA520]/35 bg-[#DAA520]/10'
: 'border-white/10 bg-[#04111f]/60 hover:border-white/20 hover:bg-white/5',
].join(' ')}
>
<div className="text-xs uppercase tracking-[0.25em] text-slate-400">
{isActive ? 'Lista activa' : 'Toca para activar'}
</div>
<h3 className="mt-2 text-2xl font-black text-white">{list.name}</h3>
<p className="mt-2 text-sm leading-6 text-slate-300">{list.description}</p>
<div className="mt-4 text-sm text-slate-400">
{(favoritesByList[list.id] || []).length} guardados
</div>
</button>
);
})}
</div>
</section>
<section className="grid gap-5 xl:grid-cols-2">
{(Object.keys(groupedFavorites) as FavoriteCategory[]).map((category) => {
const items = groupedFavorites[category];
return (
<article
key={category}
className="rounded-[2rem] border border-white/10 bg-white/5 p-6 shadow-xl shadow-black/20"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className={`text-xs font-bold uppercase tracking-[0.35em] ${categoryAccents[category]}`}>
{categoryLabels[category]}
</div>
<h2 className="mt-2 text-2xl font-black text-white">{items.length} guardados</h2>
</div>
<div className="rounded-full border border-white/10 bg-white/5 px-3 py-2 text-sm font-semibold text-slate-200">
{categoryLabels[category]}
</div>
</div>
{items.length ? (
<div className="mt-5 space-y-3">
{items.map((item) => (
<div
key={item.id}
className="flex flex-col gap-4 rounded-[1.6rem] border border-white/10 bg-[#04111f]/60 p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<div className="text-sm font-bold text-white">
<span className="mr-2">{item.emoji || '⭐'}</span>
{item.itemName}
</div>
<div className="mt-1 text-sm text-slate-300">
{[item.town, item.region].filter(Boolean).join(' · ') || categoryLabels[item.category]}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Link
href={item.itemHref}
className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-semibold text-[#04111f]"
>
Ver detalle
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
<button
type="button"
onClick={() => handleRemoveFavorite(item.category, item.itemSlug)}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/5"
>
<BaseIcon path={mdiCloseCircleOutline} size={16} />
Quitar
</button>
</div>
</div>
))}
</div>
) : (
<div className="mt-5 rounded-[1.6rem] border border-dashed border-white/10 bg-[#04111f]/40 p-6 text-sm leading-6 text-slate-300">
Todavía no tienes {categoryLabels[category].toLowerCase()} guardados en esta lista.
</div>
)}
</article>
);
})}
</section>
{!activeFavorites.length ? (
<section className="rounded-[2rem] border border-dashed border-white/15 bg-white/5 p-10 text-center shadow-xl shadow-black/20">
<BaseIcon path={mdiStarOutline} size={42} className="mx-auto text-[#FDE68A]" />
<h2 className="mt-4 text-3xl font-black text-white">Tu lista activa todavía está vacía</h2>
<p className="mt-3 text-sm leading-6 text-slate-300">
Empieza guardando una ruta, un chinchorro o un lugar desde el resto de la guía pública.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-3">
<Link
href="/rutas"
className="rounded-full bg-white px-5 py-3 text-sm font-bold text-[#04111f]"
>
Explorar rutas
</Link>
<Link
href="/restaurantes"
className="rounded-full border border-white/10 bg-white/5 px-5 py-3 text-sm font-bold text-white"
>
Ver chinchorros
</Link>
</div>
</section>
) : null}
</PublicShell>
</>
);
}
FavoritesPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
export default function PrivacyPolicy() {
const title = 'ChinchorreoRPR'
const title = 'Chinchorreo PR'
const [projectUrl, setProjectUrl] = useState('');
useEffect(() => {

View File

@ -0,0 +1,409 @@
import {
mdiCheckCircleOutline,
mdiChevronRight,
mdiFacebook,
mdiInstagram,
mdiMapMarker,
mdiOpenInNew,
mdiPhoneOutline,
mdiShareVariantOutline,
mdiStarOutline,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import PublicShell from '../../components/Chinchorreo/PublicShell';
import BaseIcon from '../../components/BaseIcon';
import {
chinchorrosGuide,
priceOptions,
regionOptions,
typeOptions,
vibeOptions,
} from '../../helpers/chinchorreoData';
import { shareContent } from '../../helpers/chinchorreoShare';
import { useChinchorreoStorage } from '../../hooks/useChinchorreoStorage';
import LayoutGuest from '../../layouts/Guest';
const renderStars = (rating: number) => {
const rounded = Math.round(rating);
return Array.from({ length: 5 }, (_, index) => (
<span key={`${rating}-${index}`} className={index < rounded ? 'text-[#FDE68A]' : 'text-slate-600'}>
</span>
));
};
export default function RestaurantsPage() {
const router = useRouter();
const { addFavorite, isFavorite, lists, activeListId } = useChinchorreoStorage();
const [searchValue, setSearchValue] = useState('');
const [selectedRegion, setSelectedRegion] = useState<'Todas' | (typeof regionOptions)[number]>('Todas');
const [selectedType, setSelectedType] = useState<'Todas' | (typeof typeOptions)[number]>('Todas');
const [selectedVibe, setSelectedVibe] = useState<'Todas' | (typeof vibeOptions)[number]>('Todas');
const [selectedPrice, setSelectedPrice] = useState<'Todas' | (typeof priceOptions)[number]>('Todas');
const [openNowOnly, setOpenNowOnly] = useState(false);
const [feedback, setFeedback] = useState('');
const activeList = lists.find((list) => list.id === activeListId) || lists[0];
useEffect(() => {
if (typeof router.query.search === 'string') {
setSearchValue(router.query.search);
}
}, [router.query.search]);
const filteredChinchorros = useMemo(() => {
return chinchorrosGuide.filter((chinchorro) => {
const searchMatch = `${chinchorro.name} ${chinchorro.town} ${chinchorro.description} ${chinchorro.specialties.join(
' ',
)}`
.toLowerCase()
.includes(searchValue.toLowerCase().trim());
const regionMatch = selectedRegion === 'Todas' || chinchorro.region === selectedRegion;
const typeMatch = selectedType === 'Todas' || chinchorro.type === selectedType;
const vibeMatch = selectedVibe === 'Todas' || chinchorro.vibe === selectedVibe;
const priceMatch = selectedPrice === 'Todas' || chinchorro.price === selectedPrice;
const openNowMatch = !openNowOnly || chinchorro.openNow;
return searchMatch && regionMatch && typeMatch && vibeMatch && priceMatch && openNowMatch;
});
}, [searchValue, selectedRegion, selectedType, selectedVibe, selectedPrice, openNowOnly]);
const handleFavorite = (
slug: string,
name: string,
town: string,
region: string,
) => {
const result = addFavorite({
category: 'chinchorro',
itemSlug: slug,
itemName: name,
itemHref: `/restaurantes?search=${encodeURIComponent(name)}`,
town,
region,
emoji: '🍽️',
note: 'Guardado desde la sección de chinchorros.',
});
setFeedback(
result.added
? `Chinchorro guardado en “${activeList?.name || 'Mi chinchorreo'}”.`
: 'Ese chinchorro ya estaba en la lista activa.',
);
};
return (
<>
<Head>
<title>Chinchorreo PR | Restaurantes & Chinchorros</title>
</Head>
<PublicShell activeSection="restaurantes">
<section className="rounded-[2rem] border border-white/10 bg-white/5 p-6 shadow-xl shadow-black/20 sm:p-8">
<div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<div className="text-xs font-bold uppercase tracking-[0.35em] text-[#FDE68A]">
Restaurantes & Chinchorros
</div>
<h1 className="mt-2 text-4xl font-black tracking-tight text-white">
Filtra por región, ambiente y antojo
</h1>
<p className="mt-3 text-base leading-7 text-slate-300">
Desde Piñones hasta La Parguera, aquí tienes una lista buscable con especialidades,
redes, mapas y favoritos guardados localmente.
</p>
</div>
<button
type="button"
onClick={() =>
shareContent(
'Mis chinchorros favoritos',
'Te comparto esta selección de Chinchorreo PR para el próximo jangueo.',
)
}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-5 py-3 text-sm font-bold text-white transition hover:border-white/20 hover:bg-white/10"
>
<BaseIcon path={mdiShareVariantOutline} size={16} />
Compartir esta página
</button>
</div>
<div className="mt-6 grid gap-4 rounded-[1.8rem] border border-white/10 bg-[#04111f]/50 p-4 md:grid-cols-2 xl:grid-cols-5">
<label className="block xl:col-span-2">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.25em] text-slate-400">
Buscar
</div>
<input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Piñones, lechonera, longaniza, Luquillo..."
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>
{[
{
title: 'Región',
value: selectedRegion,
options: ['Todas', ...regionOptions],
onChange: setSelectedRegion,
},
{
title: 'Tipo',
value: selectedType,
options: ['Todas', ...typeOptions],
onChange: setSelectedType,
},
{
title: 'Ambiente',
value: selectedVibe,
options: ['Todas', ...vibeOptions],
onChange: setSelectedVibe,
},
{
title: 'Precio',
value: selectedPrice,
options: ['Todas', ...priceOptions],
onChange: setSelectedPrice,
},
].map((filter) => (
<label key={filter.title} className="block">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.25em] text-slate-400">
{filter.title}
</div>
<select
value={filter.value}
onChange={(event) => filter.onChange(event.target.value as never)}
className="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-white focus:border-[#DAA520] focus:outline-none focus:ring-0"
>
{filter.options.map((option) => (
<option key={option} value={option} className="bg-[#04111f]">
{option}
</option>
))}
</select>
</label>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-slate-300">
<button
type="button"
onClick={() => setOpenNowOnly((current) => !current)}
className={[
'rounded-full border px-4 py-2 font-semibold transition',
openNowOnly
? 'border-[#228B22]/35 bg-[#228B22]/10 text-[#A7F3D0]'
: 'border-white/10 bg-white/5 text-white hover:border-white/20 hover:bg-white/10',
].join(' ')}
>
Abierto ahora {openNowOnly ? '✓' : ''}
</button>
<div className="rounded-full border border-white/10 bg-white/5 px-4 py-2">
{filteredChinchorros.length} chinchorros encontrados
</div>
{feedback ? (
<div className="inline-flex items-center gap-2 rounded-full border border-[#228B22]/30 bg-[#228B22]/10 px-4 py-2 text-[#A7F3D0]">
<BaseIcon path={mdiCheckCircleOutline} size={16} />
{feedback}
</div>
) : null}
</div>
</section>
<section className="grid gap-5 xl:grid-cols-2">
{filteredChinchorros.map((chinchorro) => {
const saved = isFavorite('chinchorro', chinchorro.slug);
return (
<article
key={chinchorro.slug}
className="overflow-hidden rounded-[2rem] border border-white/10 bg-white/5 shadow-xl shadow-black/20"
>
<div
className="p-5 sm:p-6"
style={{
backgroundImage: `linear-gradient(135deg, ${chinchorro.accentFrom}, ${chinchorro.accentTo})`,
}}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<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">
{chinchorro.region} · {chinchorro.type}
</div>
<h2 className="mt-4 text-3xl font-black text-white">{chinchorro.name}</h2>
<p className="mt-2 text-sm leading-6 text-white/90">{chinchorro.description}</p>
</div>
<div className="rounded-[1.5rem] border border-white/20 bg-black/10 px-4 py-3 text-sm text-white/90">
<div className="font-semibold">{chinchorro.price}</div>
<div className="mt-1 text-xs uppercase tracking-[0.2em]">{chinchorro.vibe}</div>
</div>
</div>
</div>
<div className="space-y-5 p-5 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2 text-lg font-bold text-white">
<span>{renderStars(chinchorro.rating)}</span>
<span>{chinchorro.rating.toFixed(1)}</span>
</div>
<div className="mt-1 text-sm text-slate-400">{chinchorro.reviewsCount} reseñas</div>
</div>
<div
className={[
'inline-flex items-center rounded-full border px-3 py-1 text-xs font-bold uppercase tracking-[0.2em]',
chinchorro.openNow
? 'border-[#228B22]/30 bg-[#228B22]/10 text-[#A7F3D0]'
: 'border-white/10 bg-white/5 text-slate-300',
].join(' ')}
>
{chinchorro.openNow ? 'Abierto ahora' : 'Abre luego'}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-[1.4rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm text-slate-300">
<div className="text-xs uppercase tracking-[0.2em] text-slate-500">Dirección</div>
<div className="mt-2 flex items-start gap-2 text-white">
<BaseIcon path={mdiMapMarker} size={16} className="mt-0.5" />
<span>{chinchorro.address}</span>
</div>
</div>
<div className="rounded-[1.4rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm text-slate-300">
<div className="text-xs uppercase tracking-[0.2em] text-slate-500">Horario</div>
<div className="mt-2 text-white">{chinchorro.hours}</div>
</div>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-[0.25em] text-slate-400">Especialidades</div>
<div className="mt-3 flex flex-wrap gap-2">
{chinchorro.specialties.map((specialty) => (
<span
key={specialty}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold text-slate-200"
>
{specialty}
</span>
))}
</div>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-[0.25em] text-slate-400">Galería de platos</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{chinchorro.dishGallery.map((dish) => (
<div
key={dish}
className="rounded-[1.4rem] border border-white/10 bg-gradient-to-br from-white/10 to-transparent p-4 text-sm font-semibold text-white"
>
{dish}
</div>
))}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{chinchorro.reviewHighlights.map((review) => (
<div
key={`${chinchorro.slug}-${review.author}`}
className="rounded-[1.4rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm leading-6 text-slate-300"
>
<div className="font-semibold text-white">{review.author}</div>
<div className="mt-1 text-[#FDE68A]">{review.rating.toFixed(1)} / 5</div>
<p className="mt-2">{review.text}</p>
</div>
))}
</div>
<div className="flex flex-wrap gap-3">
<a
href={`tel:${chinchorro.phone}`}
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={mdiPhoneOutline} size={16} />
{chinchorro.phone}
</a>
<a
href={chinchorro.instagramUrl}
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={mdiInstagram} size={16} />
Instagram
</a>
<a
href={chinchorro.facebookUrl}
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={mdiFacebook} size={16} />
Facebook
</a>
<a
href={chinchorro.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={mdiOpenInNew} size={16} />
Google Maps
</a>
<button
type="button"
onClick={() =>
handleFavorite(
chinchorro.slug,
chinchorro.name,
chinchorro.town,
chinchorro.region,
)
}
className={[
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition',
saved
? '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(' ')}
>
<BaseIcon path={mdiStarOutline} size={16} />
{saved ? 'Guardado' : 'Guardar en Favoritos'}
</button>
{chinchorro.routeSlug ? (
<Link
href={`/rutas/${chinchorro.routeSlug}`}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/5"
>
Ver ruta
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
) : null}
</div>
</div>
</article>
);
})}
</section>
{!filteredChinchorros.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">No encontramos chinchorros con esos filtros</h2>
<p className="mt-3 text-sm leading-6 text-slate-300">
Cambia la región, quita el filtro de abierto ahora o busca por pueblo para ver más opciones.
</p>
</section>
) : null}
</PublicShell>
</>
);
}
RestaurantsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,328 @@
import {
mdiCheckCircleOutline,
mdiChevronRight,
mdiClockOutline,
mdiCompassOutline,
mdiMapMarker,
mdiShareVariantOutline,
mdiStarOutline,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { ReactElement, useMemo, useState } from 'react';
import PublicShell from '../../components/Chinchorreo/PublicShell';
import RoutePreview from '../../components/Chinchorreo/RoutePreview';
import BaseIcon from '../../components/BaseIcon';
import {
chinchorrosGuide,
findRouteBySlug,
routeDirectionsLookup,
} from '../../helpers/chinchorreoData';
import { shareContent } from '../../helpers/chinchorreoShare';
import { useChinchorreoStorage } from '../../hooks/useChinchorreoStorage';
import LayoutGuest from '../../layouts/Guest';
export default function RouteDetailPage() {
const router = useRouter();
const slug = typeof router.query.slug === 'string' ? router.query.slug : '';
const route = useMemo(() => findRouteBySlug(slug), [slug]);
const {
addFavorite,
isFavorite,
toggleRouteStop,
routeProgress,
activeListId,
lists,
} = useChinchorreoStorage();
const [feedback, setFeedback] = useState('');
const activeList = lists.find((list) => list.id === activeListId) || lists[0];
const completedStops = route ? routeProgress[route.slug] || [] : [];
const progress = route ? Math.round((completedStops.length / route.stops.length) * 100) : 0;
const saved = route ? isFavorite('route', route.slug) : false;
const relatedChinchorros = route
? chinchorrosGuide.filter((chinchorro) => chinchorro.routeSlug === route.slug).slice(0, 4)
: [];
if (!route) {
return (
<>
<Head>
<title>Chinchorreo PR | Ruta no encontrada</title>
</Head>
<PublicShell activeSection="rutas">
<section className="rounded-[2rem] border border-dashed border-white/15 bg-white/5 p-10 text-center shadow-xl shadow-black/20">
<h1 className="text-3xl font-black text-white">Ruta no encontrada</h1>
<p className="mt-3 text-sm leading-6 text-slate-300">
Parece que esta ruta todavía no está publicada en la primera iteración de la guía.
</p>
<Link
href="/rutas"
className="mt-5 inline-flex items-center gap-2 rounded-full bg-white px-5 py-3 text-sm font-bold text-[#04111f]"
>
Volver a rutas
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
</section>
</PublicShell>
</>
);
}
const directionsUrl = routeDirectionsLookup[route.slug];
const handleFavorite = () => {
const result = addFavorite({
category: 'route',
itemSlug: route.slug,
itemName: route.name,
itemHref: `/rutas/${route.slug}`,
region: route.region,
emoji: route.emoji,
note: 'Guardada desde la vista detallada de una ruta.',
});
setFeedback(
result.added
? `Ruta guardada en “${activeList?.name || 'Mi chinchorreo'}”.`
: 'Esta ruta ya está guardada en tu lista activa.',
);
};
const handleToggleStop = (stopId: string) => {
const result = toggleRouteStop(route.slug, stopId);
setFeedback(result.completed ? 'Parada marcada como completada ✅' : 'Parada marcada como pendiente.');
};
return (
<>
<Head>
<title>{`Chinchorreo PR | ${route.name}`}</title>
</Head>
<PublicShell activeSection="rutas">
<section
className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-[#04111f] p-6 shadow-2xl shadow-black/30 sm:p-8"
style={{
backgroundImage: `linear-gradient(120deg, rgba(4,17,31,0.92), rgba(4,17,31,0.5)), url(${route.heroImage})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.16),transparent_25%),radial-gradient(circle_at_left_bottom,rgba(218,165,32,0.18),transparent_30%)]" />
<div className="relative grid gap-8 lg:grid-cols-[1.05fr_0.95fr] lg:items-end">
<div>
<Link href="/rutas" className="inline-flex items-center gap-2 text-sm font-semibold text-slate-200">
Volver a rutas
</Link>
<div className="mt-4 inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-xs font-bold uppercase tracking-[0.35em] text-[#FDE68A]">
{route.emoji} {route.region}
</div>
<h1 className="mt-5 text-4xl font-black tracking-tight text-white sm:text-5xl">
{route.name}
</h1>
<p className="mt-4 max-w-2xl text-base leading-7 text-slate-200">{route.summary}</p>
<div className="mt-6 flex flex-wrap gap-2">
{route.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-semibold text-white"
>
{tag}
</span>
))}
</div>
<div className="mt-6 flex flex-wrap gap-3">
<a
href={directionsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-full bg-white px-5 py-3 text-sm font-bold text-[#04111f] transition hover:bg-slate-100"
>
<BaseIcon path={mdiCompassOutline} size={16} />
Iniciar Navegación
</a>
<button
type="button"
onClick={handleFavorite}
className={[
'inline-flex items-center gap-2 rounded-full border px-5 py-3 text-sm font-bold transition',
saved
? 'border-[#DAA520]/35 bg-[#DAA520]/10 text-[#FDE68A]'
: 'border-white/10 bg-white/10 text-white hover:border-white/20 hover:bg-white/15',
].join(' ')}
>
<BaseIcon path={mdiStarOutline} size={16} />
{saved ? 'Ruta guardada' : 'Agregar a Favoritos ⭐'}
</button>
<button
type="button"
onClick={() => shareContent(route.name, `Te comparto esta ruta: ${route.summary}`)}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/10 px-5 py-3 text-sm font-bold text-white transition hover:border-white/20 hover:bg-white/15"
>
<BaseIcon path={mdiShareVariantOutline} size={16} />
Compartir por WhatsApp
</button>
</div>
</div>
<RoutePreview route={route} />
</div>
</section>
<section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-[2rem] border border-white/10 bg-white/5 p-6 shadow-xl shadow-black/20">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="text-xs font-bold uppercase tracking-[0.35em] text-[#FDE68A]">
Progreso de ruta
</div>
<h2 className="mt-2 text-3xl font-black text-white">Checklist de paradas</h2>
<p className="mt-2 text-sm leading-6 text-slate-300">
Marca cada parada mientras vas recorriendo la ruta. El progreso se guarda en tu navegador.
</p>
</div>
<div className="rounded-[1.5rem] border border-white/10 bg-[#04111f]/60 px-4 py-3 text-sm text-slate-200">
<div className="font-semibold text-white">{progress}% completado</div>
<div className="mt-1 text-slate-400">
{completedStops.length} de {route.stops.length} paradas hechas
</div>
</div>
</div>
<div className="mt-5 h-3 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-gradient-to-r from-[#CE1126] via-[#DAA520] to-[#228B22] transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<div className="mt-6 space-y-4">
{route.stops.map((stop, index) => {
const completed = completedStops.includes(stop.id);
return (
<button
type="button"
key={stop.id}
onClick={() => handleToggleStop(stop.id)}
className={[
'w-full rounded-[1.75rem] border p-5 text-left transition',
completed
? 'border-[#228B22]/35 bg-[#228B22]/10'
: 'border-white/10 bg-[#04111f]/60 hover:border-white/20 hover:bg-white/5',
].join(' ')}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-xs font-bold uppercase tracking-[0.25em] text-slate-400">
Parada {index + 1}
</div>
<h3 className="mt-2 text-2xl font-black text-white">{stop.name}</h3>
<div className="mt-2 inline-flex items-center gap-2 text-sm text-slate-300">
<BaseIcon path={mdiMapMarker} size={16} />
{stop.town}
</div>
<p className="mt-3 text-sm leading-6 text-slate-300">{stop.description}</p>
</div>
<div className="flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-2 text-sm font-semibold text-slate-200">
<BaseIcon path={mdiCheckCircleOutline} size={16} />
{completed ? 'Completada' : 'Marcar parada'}
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-3">
<div className="rounded-[1.4rem] border border-white/10 bg-white/5 p-4">
<div className="text-xs uppercase tracking-[0.25em] text-slate-400">Especialidad</div>
<div className="mt-2 text-sm font-semibold text-white">{stop.specialty}</div>
</div>
<div className="rounded-[1.4rem] border border-white/10 bg-white/5 p-4">
<div className="text-xs uppercase tracking-[0.25em] text-slate-400">Horario aprox.</div>
<div className="mt-2 text-sm font-semibold text-white">{stop.hours}</div>
</div>
<div className="rounded-[1.4rem] border border-white/10 bg-white/5 p-4">
<div className="text-xs uppercase tracking-[0.25em] text-slate-400">Quédate</div>
<div className="mt-2 text-sm font-semibold text-white">{stop.recommendedStay}</div>
</div>
</div>
</button>
);
})}
</div>
</div>
<div className="space-y-6">
<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-[#A7F3D0]">
Tu ritmo ideal
</div>
<div className="mt-3 flex items-center gap-3 text-white">
<BaseIcon path={mdiClockOutline} size={18} />
<div>
<div className="text-xl font-black">{route.estimatedMinutes} minutos estimados</div>
<div className="text-sm text-slate-300">Planifica entre 3 y 5 horas para disfrutarla completa.</div>
</div>
</div>
<div className="mt-5 rounded-[1.5rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm leading-6 text-slate-300">
{feedback || `Tu lista activa ahora mismo es “${activeList?.name || 'Mi chinchorreo'}”.`}
</div>
<Link
href="/mis-favoritos"
className="mt-5 inline-flex items-center gap-2 text-sm font-semibold text-[#FDE68A]"
>
Ver mis favoritos
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
</div>
<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]">Chinchorros de la ruta</div>
<h2 className="mt-2 text-2xl font-black text-white">Paradas relacionadas</h2>
<div className="mt-5 space-y-3">
{relatedChinchorros.map((chinchorro) => (
<Link
key={chinchorro.slug}
href={`/restaurantes?search=${encodeURIComponent(chinchorro.name)}`}
className="block rounded-[1.5rem] border border-white/10 bg-[#04111f]/60 p-4 transition hover:border-white/20 hover:bg-white/5"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-bold text-white">{chinchorro.name}</div>
<div className="mt-1 text-sm text-slate-300">{chinchorro.specialties[0]}</div>
</div>
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold text-slate-200">
{chinchorro.town}
</span>
</div>
</Link>
))}
</div>
</div>
<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]">Reseñas</div>
<h2 className="mt-2 text-2xl font-black text-white">Lo que dice la gente</h2>
<div className="mt-5 space-y-4">
{route.reviews.map((review) => (
<div key={review.author} className="rounded-[1.5rem] border border-white/10 bg-[#04111f]/60 p-4">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-bold text-white">{review.author}</div>
<div className="text-sm font-semibold text-[#FDE68A]">{review.rating.toFixed(1)} / 5</div>
</div>
<p className="mt-3 text-sm leading-6 text-slate-300">{review.text}</p>
</div>
))}
</div>
</div>
</div>
</section>
</PublicShell>
</>
);
}
RouteDetailPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,332 @@
import {
mdiCheckCircleOutline,
mdiChevronRight,
mdiClockOutline,
mdiCompassOutline,
mdiDice5Outline,
mdiFilterVariant,
mdiStarOutline,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import PublicShell from '../../components/Chinchorreo/PublicShell';
import RoutePreview from '../../components/Chinchorreo/RoutePreview';
import BaseIcon from '../../components/BaseIcon';
import {
getRandomRoute,
routeDirectionsLookup,
routeGuides,
} from '../../helpers/chinchorreoData';
import { shareContent } from '../../helpers/chinchorreoShare';
import { useChinchorreoStorage } from '../../hooks/useChinchorreoStorage';
import LayoutGuest from '../../layouts/Guest';
const regionFilters = ['Todas', ...Array.from(new Set(routeGuides.map((route) => route.region)))];
const difficultyFilters = ['Todas', 'Familiar', 'Moderado', 'Aventurero'];
const tagFilters = [
'Todas',
...Array.from(new Set(routeGuides.flatMap((route) => route.tags))).sort(),
];
export default function RoutesPage() {
const router = useRouter();
const {
addFavorite,
isFavorite,
lists,
activeListId,
} = useChinchorreoStorage();
const [searchValue, setSearchValue] = useState('');
const [selectedRegion, setSelectedRegion] = useState('Todas');
const [selectedDifficulty, setSelectedDifficulty] = useState('Todas');
const [selectedTag, setSelectedTag] = useState('Todas');
const [feedback, setFeedback] = useState('');
const activeList = lists.find((list) => list.id === activeListId) || lists[0];
useEffect(() => {
if (typeof router.query.search === 'string') {
setSearchValue(router.query.search);
}
}, [router.query.search]);
const filteredRoutes = useMemo(() => {
return routeGuides.filter((route) => {
const matchesSearch = `${route.name} ${route.summary} ${route.region} ${route.tags.join(' ')} ${route.stops
.map((stop) => `${stop.name} ${stop.town}`)
.join(' ')}`
.toLowerCase()
.includes(searchValue.toLowerCase().trim());
const matchesRegion = selectedRegion === 'Todas' || route.region === selectedRegion;
const matchesDifficulty =
selectedDifficulty === 'Todas' || route.difficulty === selectedDifficulty;
const matchesTag = selectedTag === 'Todas' || route.tags.includes(selectedTag);
return matchesSearch && matchesRegion && matchesDifficulty && matchesTag;
});
}, [searchValue, selectedRegion, selectedDifficulty, selectedTag]);
const handleRandomRoute = () => {
const selectedRoute = getRandomRoute();
router.push(`/rutas/${selectedRoute.slug}`);
};
const handleFavorite = (slug: string, name: string, region: string, emoji: string) => {
const result = addFavorite({
category: 'route',
itemSlug: slug,
itemName: name,
itemHref: `/rutas/${slug}`,
region,
emoji,
note: 'Guardada desde la sección de rutas.',
});
setFeedback(
result.added
? `Ruta guardada en “${activeList?.name || 'Mi chinchorreo'}”.`
: 'Esta ruta ya estaba guardada en tu lista activa.',
);
};
return (
<>
<Head>
<title>Chinchorreo PR | Rutas</title>
</Head>
<PublicShell
activeSection="rutas"
floatingAction={{
label: 'Sorpréndeme con otra ruta',
icon: mdiDice5Outline,
onClick: handleRandomRoute,
}}
>
<section className="rounded-[2rem] border border-white/10 bg-white/5 p-6 shadow-xl shadow-black/20 sm:p-8">
<div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<div className="text-xs font-bold uppercase tracking-[0.35em] text-[#FDE68A]">Rutas</div>
<h1 className="mt-2 text-4xl font-black tracking-tight text-white">
Explora la isla parada por parada
</h1>
<p className="mt-3 text-base leading-7 text-slate-300">
Esta primera iteración te deja descubrir rutas populares, ver sus paradas en orden,
iniciar navegación y guardarlas en una lista personalizada.
</p>
</div>
<div className="rounded-[1.75rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm text-slate-200">
<div className="font-semibold text-white">Lista activa</div>
<div className="mt-1 text-[#FDE68A]">{activeList?.name || 'Mi chinchorreo'}</div>
<Link href="/mis-favoritos" className="mt-3 inline-flex items-center gap-2 text-sm font-semibold text-[#A7F3D0]">
Ver favoritos
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
</div>
</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">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.25em] text-slate-400">
Buscar ruta
</div>
<input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Arecibo, montaña, mariscos, Guavate..."
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>
{[{
title: 'Región',
value: selectedRegion,
options: regionFilters,
onChange: setSelectedRegion,
},
{
title: 'Dificultad',
value: selectedDifficulty,
options: difficultyFilters,
onChange: setSelectedDifficulty,
},
{
title: 'Etiqueta',
value: selectedTag,
options: tagFilters,
onChange: setSelectedTag,
}].map((filter) => (
<label key={filter.title} className="block">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.25em] text-slate-400">
{filter.title}
</div>
<select
value={filter.value}
onChange={(event) => filter.onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-white focus:border-[#DAA520] focus:outline-none focus:ring-0"
>
{filter.options.map((option) => (
<option key={option} value={option} className="bg-[#04111f]">
{option}
</option>
))}
</select>
</label>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-slate-300">
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-2">
<BaseIcon path={mdiFilterVariant} size={16} />
{filteredRoutes.length} rutas que matchean tu vibe
</div>
{feedback ? (
<div className="inline-flex items-center gap-2 rounded-full border border-[#228B22]/30 bg-[#228B22]/10 px-3 py-2 text-[#A7F3D0]">
<BaseIcon path={mdiCheckCircleOutline} size={16} />
{feedback}
</div>
) : null}
</div>
</section>
<section className="grid gap-5 xl:grid-cols-2">
{filteredRoutes.map((route) => {
const saved = isFavorite('route', route.slug);
const directionsUrl = routeDirectionsLookup[route.slug];
return (
<article
key={route.slug}
className="overflow-hidden rounded-[2rem] border border-white/10 bg-white/5 shadow-xl shadow-black/20"
>
<div className="p-5 sm:p-6">
<RoutePreview route={route} />
<div className="mt-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-xs font-bold uppercase tracking-[0.3em] text-[#FDE68A]">
{route.region}
</div>
<h2 className="mt-2 text-2xl font-black text-white">{route.name}</h2>
<p className="mt-2 text-sm leading-6 text-slate-300">{route.summary}</p>
</div>
<div className="rounded-[1.5rem] border border-white/10 bg-[#04111f]/60 px-4 py-3 text-sm text-slate-200">
<div className="font-semibold text-white">{route.bestFor}</div>
<div className="mt-2 flex items-center gap-2 text-slate-300">
<BaseIcon path={mdiClockOutline} size={16} />
{route.estimatedMinutes} min estimados
</div>
</div>
</div>
<div className="mt-5 flex flex-wrap gap-2">
{route.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold text-slate-200"
>
{tag}
</span>
))}
</div>
<div className="mt-6 grid gap-3 md:grid-cols-3">
<div className="rounded-[1.5rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm text-slate-200">
<div className="text-xs uppercase tracking-[0.25em] text-slate-400">Paradas</div>
<div className="mt-2 text-2xl font-black text-white">{route.stops.length}</div>
</div>
<div className="rounded-[1.5rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm text-slate-200">
<div className="text-xs uppercase tracking-[0.25em] text-slate-400">Dificultad</div>
<div className="mt-2 text-xl font-black text-white">{route.difficulty}</div>
</div>
<div className="rounded-[1.5rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm text-slate-200">
<div className="text-xs uppercase tracking-[0.25em] text-slate-400">Ideal para</div>
<div className="mt-2 text-sm font-semibold text-white">{route.bestFor}</div>
</div>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href={`/rutas/${route.slug}`}
className="inline-flex items-center gap-2 rounded-full bg-white px-5 py-3 text-sm font-bold text-[#04111f] transition hover:bg-slate-100"
>
Ver detalle
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
<a
href={directionsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-5 py-3 text-sm font-bold text-white transition hover:border-white/20 hover:bg-white/10"
>
<BaseIcon path={mdiCompassOutline} size={16} />
Navegar
</a>
<button
type="button"
onClick={() => handleFavorite(route.slug, route.name, route.region, route.emoji)}
className={[
'inline-flex items-center gap-2 rounded-full border px-5 py-3 text-sm font-bold transition',
saved
? '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(' ')}
>
<BaseIcon path={mdiStarOutline} size={16} />
{saved ? 'Guardada' : 'Agregar a Favoritos'}
</button>
<button
type="button"
onClick={() =>
shareContent(route.name, `Te comparto esta ruta de Chinchorreo PR: ${route.summary}`)
}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-transparent px-5 py-3 text-sm font-bold text-slate-200 transition hover:border-white/20 hover:bg-white/5"
>
Compartir por WhatsApp
</button>
</div>
</div>
</article>
);
})}
</section>
{!filteredRoutes.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">No encontramos una ruta con esos filtros</h2>
<p className="mt-3 text-sm leading-6 text-slate-300">
Prueba quitando una etiqueta, cambia la dificultad o usa el botón Sorpréndeme para
descubrir una ruta nueva.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-3">
<button
type="button"
onClick={() => {
setSearchValue('');
setSelectedRegion('Todas');
setSelectedDifficulty('Todas');
setSelectedTag('Todas');
}}
className="rounded-full bg-white px-5 py-3 text-sm font-bold text-[#04111f]"
>
Limpiar filtros
</button>
<button
type="button"
onClick={handleRandomRoute}
className="rounded-full border border-white/10 bg-white/5 px-5 py-3 text-sm font-bold text-white"
>
Sorpréndeme
</button>
</div>
</section>
) : null}
</PublicShell>
</>
);
}
RoutesPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
export default function PrivacyPolicy() {
const title = 'ChinchorreoRPR';
const title = 'Chinchorreo PR';
const [projectUrl, setProjectUrl] = useState('');
useEffect(() => {

View File

@ -0,0 +1,135 @@
import { mdiChevronRight, mdiLightbulbOnOutline } 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 {
getTipOfTheDay,
glossaryTerms,
travelerTips,
} from '../../helpers/chinchorreoData';
import LayoutGuest from '../../layouts/Guest';
const flatTips = travelerTips.flatMap((group) =>
group.items.map((item) => ({ title: group.title, emoji: group.emoji, text: item })),
);
export default function TipsPage() {
const defaultTip = getTipOfTheDay();
const defaultIndex = Math.max(
0,
flatTips.findIndex((tip) => tip.text === defaultTip.text),
);
const [tipIndex, setTipIndex] = useState(defaultIndex);
const spotlightTip = useMemo(() => flatTips[tipIndex % flatTips.length], [tipIndex]);
return (
<>
<Head>
<title>Chinchorreo PR | Tips & Guía del Viajero</title>
</Head>
<PublicShell activeSection="tips">
<section className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div className="rounded-[2rem] border border-white/10 bg-white/5 p-6 shadow-xl shadow-black/20 sm:p-8">
<div className="text-xs font-bold uppercase tracking-[0.35em] text-[#FDE68A]">
Tips & Guía del viajero
</div>
<h1 className="mt-2 text-4xl font-black tracking-tight text-white">
Consejos prácticos para chinchorrear con calma y responsabilidad
</h1>
<p className="mt-3 max-w-2xl text-base leading-7 text-slate-300">
Una guía educativa para salir mejor preparado, comer mejor y respetar cada lugar que visitas.
</p>
<div className="mt-6 rounded-[1.9rem] border border-[#DAA520]/20 bg-gradient-to-br from-[#DAA520]/10 via-white/5 to-transparent p-6">
<div className="inline-flex items-center gap-2 rounded-full border border-[#DAA520]/25 bg-[#DAA520]/10 px-3 py-1 text-xs font-bold uppercase tracking-[0.25em] text-[#FDE68A]">
<BaseIcon path={mdiLightbulbOnOutline} size={16} />
Tip rotativo
</div>
<h2 className="mt-4 text-3xl font-black text-white">
{spotlightTip.emoji} {spotlightTip.title}
</h2>
<p className="mt-4 text-base leading-7 text-slate-200">{spotlightTip.text}</p>
<button
type="button"
onClick={() => setTipIndex((current) => current + 1)}
className="mt-5 inline-flex items-center gap-2 rounded-full bg-white px-5 py-3 text-sm font-bold text-[#04111f]"
>
Dame otro tip
<BaseIcon path={mdiChevronRight} size={16} />
</button>
</div>
</div>
<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-[#A7F3D0]">Checklist rápido</div>
<h2 className="mt-2 text-3xl font-black text-white">Antes de arrancar</h2>
<div className="mt-5 space-y-3">
{travelerTips[0].items.map((item) => (
<div
key={item}
className="rounded-[1.5rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm leading-6 text-slate-300"
>
{item}
</div>
))}
</div>
<Link
href="/rutas"
className="mt-6 inline-flex items-center gap-2 text-sm font-semibold text-[#FDE68A]"
>
Ver rutas y poner estos tips en práctica
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
</div>
</section>
<section className="grid gap-5 lg:grid-cols-3">
{travelerTips.map((group) => (
<article
key={group.title}
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]">
{group.emoji} {group.title}
</div>
<div className="mt-5 space-y-3">
{group.items.map((item) => (
<div
key={item}
className="rounded-[1.5rem] border border-white/10 bg-[#04111f]/60 p-4 text-sm leading-6 text-slate-300"
>
{item}
</div>
))}
</div>
</article>
))}
</section>
<section className="rounded-[2rem] border border-white/10 bg-white/5 p-6 shadow-xl shadow-black/20 sm:p-8">
<div className="text-xs font-bold uppercase tracking-[0.35em] text-[#FDE68A]">Glosario boricua</div>
<h2 className="mt-2 text-3xl font-black text-white">Palabras que vas a escuchar en el chinchorreo</h2>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{glossaryTerms.map((term) => (
<article
key={term.term}
className="rounded-[1.6rem] border border-white/10 bg-[#04111f]/60 p-5 shadow-lg shadow-black/10"
>
<h3 className="text-xl font-black text-white">{term.term}</h3>
<p className="mt-3 text-sm leading-6 text-slate-300">{term.definition}</p>
</article>
))}
</div>
</section>
</PublicShell>
</>
);
}
TipsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};