1.01
This commit is contained in:
parent
b6c33de759
commit
f9af0ce921
@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
# ChinchorreoRPR
|
||||
# Chinchorreo PR
|
||||
|
||||
|
||||
## This project was generated by [Flatlogic Platform](https://flatlogic.com).
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
|
||||
#ChinchorreoRPR - template backend,
|
||||
#Chinchorreo PR - template backend,
|
||||
|
||||
#### Run App on local machine:
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
const errors = {
|
||||
app: {
|
||||
title: 'ChinchorreoRPR',
|
||||
title: 'Chinchorreo PR',
|
||||
},
|
||||
|
||||
auth: {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# ChinchorreoRPR
|
||||
# Chinchorreo PR
|
||||
|
||||
## This project was generated by Flatlogic Platform.
|
||||
## Install
|
||||
|
||||
@ -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>
|
||||
|
||||
234
frontend/src/components/Chinchorreo/PublicShell.tsx
Normal file
234
frontend/src/components/Chinchorreo/PublicShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
frontend/src/components/Chinchorreo/RoutePreview.tsx
Normal file
59
frontend/src/components/Chinchorreo/RoutePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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'
|
||||
|
||||
1810
frontend/src/helpers/chinchorreoData.ts
Normal file
1810
frontend/src/helpers/chinchorreoData.ts
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/src/helpers/chinchorreoShare.ts
Normal file
31
frontend/src/helpers/chinchorreoShare.ts
Normal 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);
|
||||
};
|
||||
356
frontend/src/hooks/useChinchorreoStorage.ts
Normal file
356
frontend/src/hooks/useChinchorreoStorage.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@ -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'
|
||||
|
||||
@ -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"
|
||||
|
||||
257
frontend/src/pages/eventos/index.tsx
Normal file
257
frontend/src/pages/eventos/index.tsx
Normal 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>;
|
||||
};
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ export default function Login() {
|
||||
password: '16ca6a8c',
|
||||
remember: true })
|
||||
|
||||
const title = 'ChinchorreoRPR'
|
||||
const title = 'Chinchorreo PR'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect( () => {
|
||||
|
||||
202
frontend/src/pages/lugares/index.tsx
Normal file
202
frontend/src/pages/lugares/index.tsx
Normal 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
394
frontend/src/pages/mapa.tsx
Normal 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>;
|
||||
};
|
||||
354
frontend/src/pages/mis-favoritos.tsx
Normal file
354
frontend/src/pages/mis-favoritos.tsx
Normal 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>;
|
||||
};
|
||||
@ -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(() => {
|
||||
|
||||
409
frontend/src/pages/restaurantes/index.tsx
Normal file
409
frontend/src/pages/restaurantes/index.tsx
Normal 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>;
|
||||
};
|
||||
328
frontend/src/pages/rutas/[slug].tsx
Normal file
328
frontend/src/pages/rutas/[slug].tsx
Normal 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>;
|
||||
};
|
||||
332
frontend/src/pages/rutas/index.tsx
Normal file
332
frontend/src/pages/rutas/index.tsx
Normal 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>;
|
||||
};
|
||||
@ -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(() => {
|
||||
|
||||
135
frontend/src/pages/tips/index.tsx
Normal file
135
frontend/src/pages/tips/index.tsx
Normal 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>;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user