diff --git a/README.md b/README.md index b658aff..e9fcbe2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# ChinchorreoRPR +# Chinchorreo PR ## This project was generated by [Flatlogic Platform](https://flatlogic.com). diff --git a/backend/README.md b/backend/README.md index 0ffcff6..aa63dbd 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,5 +1,5 @@ -#ChinchorreoRPR - template backend, +#Chinchorreo PR - template backend, #### Run App on local machine: diff --git a/backend/package.json b/backend/package.json index 7f4b173..3b2222f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/config.js b/backend/src/config.js index 74f408d..01b00a2 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -39,7 +39,7 @@ const config = { }, uploadDir: os.tmpdir(), email: { - from: 'ChinchorreoRPR ', + from: 'Chinchorreo PR ', host: 'email-smtp.us-east-1.amazonaws.com', port: 587, auth: { diff --git a/backend/src/index.js b/backend/src/index.js index 0b6a2e9..a6c65de 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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: [ { diff --git a/backend/src/services/notifications/list.js b/backend/src/services/notifications/list.js index 391e9b5..d392274 100644 --- a/backend/src/services/notifications/list.js +++ b/backend/src/services/notifications/list.js @@ -1,6 +1,6 @@ const errors = { app: { - title: 'ChinchorreoRPR', + title: 'Chinchorreo PR', }, auth: { diff --git a/frontend/README.md b/frontend/README.md index e956fd1..8c2f5b2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,4 +1,4 @@ -# ChinchorreoRPR +# Chinchorreo PR ## This project was generated by Flatlogic Platform. ## Install diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 549aead..09ae5e5 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -39,7 +39,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props >
- ChinchorreoRPR + Chinchorreo PR
diff --git a/frontend/src/components/Chinchorreo/PublicShell.tsx b/frontend/src/components/Chinchorreo/PublicShell.tsx new file mode 100644 index 0000000..1ea3ea0 --- /dev/null +++ b/frontend/src/components/Chinchorreo/PublicShell.tsx @@ -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 ( +
+
+
+
+
+
+ + + +
+
+
+
+
Chinchorreo PR
+
Explora Puerto Rico con sabor, ritmo y ruta.
+
+ +
+ + + Admin + + +
+
+
+ +
+ {children} +
+
+ + {floatingAction ? ( + + ) : null} + + +
+ ); +} diff --git a/frontend/src/components/Chinchorreo/RoutePreview.tsx b/frontend/src/components/Chinchorreo/RoutePreview.tsx new file mode 100644 index 0000000..758acd5 --- /dev/null +++ b/frontend/src/components/Chinchorreo/RoutePreview.tsx @@ -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 ( +
+
+
+
+
+
+ Preview de ruta +
+
{route.region}
+
+
+ {route.emoji} {route.difficulty} +
+
+ +
+
+
+ {visibleStops.map((stop, index) => ( +
+
+ {index + 1} +
+
+ {stop.town} +
+
+ ))} +
+
+ +
+ {route.stops.length} paradas + {Math.round(route.estimatedMinutes / 60)}h aprox. +
+
+
+ ); +} diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 6548433..2ef67e9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -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' diff --git a/frontend/src/helpers/chinchorreoData.ts b/frontend/src/helpers/chinchorreoData.ts new file mode 100644 index 0000000..363bce5 --- /dev/null +++ b/frontend/src/helpers/chinchorreoData.ts @@ -0,0 +1,1810 @@ +export type GuideReview = { + author: string; + rating: number; + text: string; +}; + +export type RouteStop = { + id: string; + name: string; + town: string; + description: string; + specialty: string; + hours: string; + recommendedStay: string; + mapQuery: string; +}; + +export type RouteGuide = { + slug: string; + emoji: string; + name: string; + region: string; + difficulty: 'Familiar' | 'Moderado' | 'Aventurero'; + estimatedMinutes: number; + tags: string[]; + summary: string; + bestFor: string; + featured: boolean; + accentFrom: string; + accentTo: string; + heroImage: string; + stops: RouteStop[]; + reviews: GuideReview[]; +}; + +export type ChinchorroGuide = { + slug: string; + name: string; + town: string; + region: 'Norte' | 'Sur' | 'Centro' | 'Este' | 'Oeste' | 'Naranjito'; + type: + | 'Lechonera' + | 'Mariscos' + | 'Longaniza' + | 'Bar & Grill' + | 'Cafetería' + | 'Colmado' + | 'Kioscos'; + vibe: 'Familiar' | 'Nocturno' | 'Al Aire Libre' | 'Romántico' | 'Animado'; + price: '$' | '$$' | '$$$'; + address: string; + hours: string; + specialties: string[]; + rating: number; + reviewsCount: number; + phone: string; + instagramUrl: string; + facebookUrl: string; + mapsUrl: string; + description: string; + featured: boolean; + openNow: boolean; + dishGallery: string[]; + reviewHighlights: GuideReview[]; + routeSlug?: string; + accentFrom: string; + accentTo: string; + mapX: number; + mapY: number; +}; + +export type PlaceGuide = { + slug: string; + name: string; + category: + | 'Playas' + | 'Naturaleza' + | 'Puntos Icónicos' + | 'Pueblos con Encanto' + | 'Actividades' + | 'Cultura'; + town: string; + description: string; + bestTime: string; + localTip: string; + mapsUrl: string; + accentFrom: string; + accentTo: string; + emoji: string; + mapX: number; + mapY: number; +}; + +export type EventGuide = { + slug: string; + name: string; + town: string; + venue: string; + category: string; + teaser: string; + nextDateIso: string; + schedule: string; + accentFrom: string; + accentTo: string; + emoji: string; +}; + +export type TipCategory = { + title: string; + emoji: string; + items: string[]; +}; + +export type GlossaryTerm = { + term: string; + definition: string; +}; + +export type GuideSearchResult = { + id: string; + label: string; + subtitle: string; + typeLabel: string; + href: string; + emoji: string; +}; + +export const createMapsSearchUrl = (query: string) => + `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; + +const createInstagramSearchUrl = (query: string) => + `https://www.instagram.com/explore/search/keyword/?q=${encodeURIComponent(query)}`; + +const createFacebookSearchUrl = (query: string) => + `https://www.facebook.com/search/top/?q=${encodeURIComponent(query)}`; + +export const createDirectionsUrl = (queries: string[]) => { + if (!queries.length) { + return createMapsSearchUrl('Puerto Rico'); + } + + if (queries.length === 1) { + return createMapsSearchUrl(queries[0]); + } + + const [origin, ...rest] = queries; + const destination = rest[rest.length - 1]; + const waypoints = rest.slice(0, -1); + const query = [ + `origin=${encodeURIComponent(origin)}`, + `destination=${encodeURIComponent(destination)}`, + waypoints.length + ? `waypoints=${encodeURIComponent(waypoints.join('|'))}` + : '', + 'travelmode=driving', + 'api=1', + ] + .filter(Boolean) + .join('&'); + + return `https://www.google.com/maps/dir/?${query}`; +}; + +const nextOccurrence = (month: number, day: number) => { + const now = new Date(); + const currentYear = now.getUTCFullYear(); + const candidate = new Date(Date.UTC(currentYear, month - 1, day, 15, 0, 0)); + + if (candidate.getTime() < now.getTime()) { + candidate.setUTCFullYear(currentYear + 1); + } + + return candidate; +}; + +const nextWeekdayOccurrence = (weekday: number) => { + const now = new Date(); + const candidate = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 15, 0, 0), + ); + const distance = ((weekday - candidate.getUTCDay() + 7) % 7) || 7; + candidate.setUTCDate(candidate.getUTCDate() + distance); + return candidate; +}; + +export const formatEventDate = (dateValue: string) => + new Intl.DateTimeFormat('es-PR', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(dateValue)); + +export const getCountdownLabel = (dateValue: string) => { + const now = new Date(); + const target = new Date(dateValue); + const diff = target.getTime() - now.getTime(); + const days = Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24))); + + if (days === 0) { + return 'Hoy mismo'; + } + + if (days === 1) { + return 'Falta 1 día'; + } + + return `Faltan ${days} días`; +}; + +const rutaArecibo = createDirectionsUrl([ + 'Pura Pesca Arecibo Puerto Rico', + 'El Carbón y Leña Arecibo Puerto Rico', + 'El Nuevo Guayabo Arecibo Puerto Rico', + 'El Clandestino Barceloneta Puerto Rico', +]); + +const rutaPinones = createDirectionsUrl([ + 'El Bohío Piñones Loíza Puerto Rico', + 'Paseo Pinones Loíza Puerto Rico', + 'Kioscos de Loíza Puerto Rico', +]); + +const rutaCiales = createDirectionsUrl([ + 'Casa Vieja Ciales Puerto Rico', + 'La Suiza de Ciales Puerto Rico', + 'Finca Cultura Ciales Puerto Rico', + 'Jayuya Puerto Rico', +]); + +const rutaMorovis = createDirectionsUrl([ + 'La Sombra Orocovis Puerto Rico', + 'Casa Bavaria Morovis Puerto Rico', + 'Hijos del Josco Morovis Puerto Rico', + 'Asador Don Pablo Morovis Puerto Rico', +]); + +const rutaGuavate = createDirectionsUrl([ + 'Guavate Cayey Puerto Rico', + 'Lechoneras Guavate Km 32.9 Cayey Puerto Rico', + 'Lechoneras Guavate Km 33.2 Cidra Puerto Rico', + 'Plaza de Cayey Puerto Rico', +]); + +const rutaNaranjito = createDirectionsUrl([ + 'Caldosos Bar and Restaurant Naranjito Puerto Rico', + 'Las Lágrimas Bar and Grill Naranjito Puerto Rico', + 'Calichi Gastrobar Naranjito Puerto Rico', + 'Pal Velde Naranjito Puerto Rico', +]); + +const rutaParguera = createDirectionsUrl([ + 'La Parguera Lajas Puerto Rico', + 'Malecon de La Parguera Puerto Rico', + 'Bahia bioluminiscente La Parguera Puerto Rico', +]); + +const rutaLuquillo = createDirectionsUrl([ + 'Kioscos de Luquillo Puerto Rico', + 'Balneario La Monserrate Luquillo Puerto Rico', + 'El Yunque Puerto Rico', +]); + +export const routeGuides: RouteGuide[] = [ + { + slug: 'arecibo-barceloneta', + emoji: '🌊', + name: 'Ruta Arecibo–Barceloneta', + region: 'Costa Norte', + difficulty: 'Familiar', + estimatedMinutes: 220, + tags: ['#Costa', '#Mariscos', '#Familiar', '#Atardecer'], + summary: + 'Un chinchorreo costero con mar, frituras y varias paradas fáciles de conectar en carro.', + bestFor: 'Vista al mar, frituras clásicas y tarde relajada en el norte.', + featured: true, + accentFrom: '#002D62', + accentTo: '#0EA5E9', + heroImage: + 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=1600&q=80', + stops: [ + { + id: 'pura-pesca', + name: 'Pura Pesca', + town: 'Arecibo', + description: 'Punto marinero para empezar suave con pescado fresco y brisa costera.', + specialty: 'Dorado en escabeche', + hours: '11:30 a.m. – 7:00 p.m.', + recommendedStay: '35-45 min', + mapQuery: 'Pura Pesca Arecibo Puerto Rico', + }, + { + id: 'carbon-lena', + name: 'El Carbón y Leña', + town: 'Arecibo', + description: 'Parada con mood más social, mojitos y platos criollos para compartir.', + specialty: 'Mofongo con churrasco y mojitos de parcha', + hours: '1:00 p.m. – 9:00 p.m.', + recommendedStay: '45 min', + mapQuery: 'El Carbón y Leña Arecibo Puerto Rico', + }, + { + id: 'nuevo-guayabo', + name: 'El Nuevo Guayabo', + town: 'Arecibo', + description: 'Cierre sabroso con frituras y ambiente de chinchorro clásico.', + specialty: 'Empanadas de cetí', + hours: '2:00 p.m. – 8:30 p.m.', + recommendedStay: '30-40 min', + mapQuery: 'El Nuevo Guayabo Arecibo Puerto Rico', + }, + { + id: 'clandestino', + name: 'El Clandestino', + town: 'Barceloneta', + description: 'Última parada para drinks con vista al mar y sunset northern style.', + specialty: 'Cocteles tropicales y pique de mariscos', + hours: '4:00 p.m. – 10:00 p.m.', + recommendedStay: '45-60 min', + mapQuery: 'El Clandestino Barceloneta Puerto Rico', + }, + ], + reviews: [ + { + author: 'Maritza', + rating: 5, + text: 'La combinación de mariscos con atardecer está demasiado dura para ir en grupo.', + }, + { + author: 'Jorge', + rating: 4, + text: 'Se siente bien fácil de recorrer y cada parada tiene su propia personalidad.', + }, + ], + }, + { + slug: 'pinones-loiza', + emoji: '🌴', + name: 'Ruta Piñones–Loíza', + region: 'Afrocaribeña', + difficulty: 'Familiar', + estimatedMinutes: 180, + tags: ['#Costa', '#Afrocaribeña', '#Frituras', '#Animado'], + summary: + 'Piñones, kioscos y cultura viva en un recorrido corto pero súper sabroso para janguear sin prisa.', + bestFor: 'Ambiente playero, frituras y cultura boricua vibrante.', + featured: true, + accentFrom: '#CE1126', + accentTo: '#F97316', + heroImage: + 'https://images.unsplash.com/photo-1500375592092-40eb2168fd21?auto=format&fit=crop&w=1600&q=80', + stops: [ + { + id: 'el-bohio', + name: 'El Bohío', + town: 'Piñones', + description: 'Arranca con frituras legendarias y vista a la carretera costera.', + specialty: 'Alcapurrias de jueyes', + hours: '11:00 a.m. – 6:00 p.m.', + recommendedStay: '30-40 min', + mapQuery: 'El Bohío Piñones Loíza Puerto Rico', + }, + { + id: 'paseo-pinones', + name: 'Paseo costero de Piñones', + town: 'Loíza', + description: 'Momento de caminar, bajar comida y sentir la brisa del Atlántico.', + specialty: 'Coco frío y vista al mar', + hours: 'Todo el día', + recommendedStay: '20-30 min', + mapQuery: 'Paseo Pinones Loíza Puerto Rico', + }, + { + id: 'kioscos-loiza', + name: 'Parada cultural en Loíza', + town: 'Loíza', + description: 'Cierre con música, bomba y sabor afroboricua.', + specialty: 'Bacalaítos y pinchos criollos', + hours: '1:00 p.m. – 8:00 p.m.', + recommendedStay: '45 min', + mapQuery: 'Loíza Puerto Rico', + }, + ], + reviews: [ + { + author: 'Yari', + rating: 5, + text: 'Ideal para chinchorrear de día y mezclar comida con playa sin complicarse.', + }, + { + author: 'César', + rating: 4, + text: 'Tiene una vibra bien alegre y cada parada invita a quedarse un ratito más.', + }, + ], + }, + { + slug: 'ciales-jayuya', + emoji: '⛰️', + name: 'Ruta Ciales–Jayuya', + region: 'Montaña Centro', + difficulty: 'Moderado', + estimatedMinutes: 260, + tags: ['#Montaña', '#Café', '#Familiar', '#Vista'], + summary: + 'Sube a la cordillera para probar sabores montañeros, vistas frescas y paradas con mucho carácter.', + bestFor: 'Brisa fresca, cafés largos y platos criollos con identidad.', + featured: true, + accentFrom: '#166534', + accentTo: '#84CC16', + heroImage: + 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1600&q=80', + stops: [ + { + id: 'casa-vieja', + name: 'Casa Vieja', + town: 'Ciales', + description: 'Una parada montañera icónica para arrancar con sabor a hogar.', + specialty: 'Carne frita con tostones', + hours: '11:00 a.m. – 7:00 p.m.', + recommendedStay: '40 min', + mapQuery: 'Casa Vieja Ciales Puerto Rico', + }, + { + id: 'suiza-ciales', + name: 'La Suiza de Ciales', + town: 'Ciales', + description: 'Clásico del norte central para seguir el ritmo con algo dulce o salado.', + specialty: 'Sopa del día y postres caseros', + hours: '12:00 p.m. – 6:30 p.m.', + recommendedStay: '30 min', + mapQuery: 'La Suiza de Ciales Puerto Rico', + }, + { + id: 'finca-cultura', + name: 'Finca Cultura', + town: 'Ciales', + description: 'Vista abierta y cervezas artesanales para bajar revoluciones.', + specialty: 'Flight de cervezas con picadera local', + hours: '1:00 p.m. – 8:00 p.m.', + recommendedStay: '45 min', + mapQuery: 'Finca Cultura Ciales Puerto Rico', + }, + { + id: 'jayuya', + name: 'Casco de Jayuya', + town: 'Jayuya', + description: 'Despedida con artesanías y aire de pueblo de altura.', + specialty: 'Café local y recuerdos artesanales', + hours: 'Hasta el atardecer', + recommendedStay: '30-45 min', + mapQuery: 'Jayuya Puerto Rico', + }, + ], + reviews: [ + { + author: 'Lynnette', + rating: 5, + text: 'Esta ruta se siente bien auténtica; montaña, comida y un pace perfecto.', + }, + { + author: 'Toño', + rating: 4, + text: 'La mejor para desconectar del ruido y chinchorrear sin calor pesado.', + }, + ], + }, + { + slug: 'morovis-orocovis-pr155', + emoji: '🏔️', + name: 'Ruta Morovis–Orocovis PR-155', + region: 'Longaniza Route', + difficulty: 'Moderado', + estimatedMinutes: 240, + tags: ['#Montaña', '#Longaniza', '#Aventurita', '#Smokehouse'], + summary: + 'La PR-155 mezcla tradición, smokehouse y paradas con mucha personalidad gastronómica.', + bestFor: 'Fans de la longaniza, carnes ahumadas y rutas con curvas sabrosas.', + featured: false, + accentFrom: '#7C2D12', + accentTo: '#DAA520', + heroImage: + 'https://images.unsplash.com/photo-1505765050516-f72dcac9c60b?auto=format&fit=crop&w=1600&q=80', + stops: [ + { + id: 'la-sombra', + name: 'La Sombra', + town: 'Orocovis', + description: 'Punto histórico para probar longanizas con tradición centenaria.', + specialty: 'Longaniza artesanal', + hours: '10:30 a.m. – 6:00 p.m.', + recommendedStay: '35 min', + mapQuery: 'La Sombra Orocovis Puerto Rico', + }, + { + id: 'casa-bavaria', + name: 'Casa Bavaria', + town: 'Morovis', + description: 'Cambio de ritmo con vibe europeo, cervezas y ambiente festivo.', + specialty: 'Salchichas alemanas y cerveza fría', + hours: '12:00 p.m. – 9:00 p.m.', + recommendedStay: '45 min', + mapQuery: 'Casa Bavaria Morovis Puerto Rico', + }, + { + id: 'hijos-josco', + name: 'Hijos del Josco', + town: 'Morovis', + description: 'Parada con identidad boricua para pedir algo diferente.', + specialty: 'El Tacolao de bacalaao', + hours: '1:00 p.m. – 8:00 p.m.', + recommendedStay: '35-45 min', + mapQuery: 'Hijos del Josco Morovis Puerto Rico', + }, + { + id: 'don-pablo', + name: 'Asador Don Pablo', + town: 'Morovis', + description: 'Final potente de smokehouse y carnes lentas.', + specialty: 'Brisket ahumado y sides criollos', + hours: '2:00 p.m. – 9:30 p.m.', + recommendedStay: '45-60 min', + mapQuery: 'Asador Don Pablo Morovis Puerto Rico', + }, + ], + reviews: [ + { + author: 'Pao', + rating: 5, + text: 'Tiene flow de road trip foodie. La PR-155 se presta brutal para un día completo.', + }, + { + author: 'Ángel', + rating: 4, + text: 'Muy buena mezcla entre tradición y conceptos más nuevos.', + }, + ], + }, + { + slug: 'guavate-cayey-pr184', + emoji: '🐷', + name: 'Ruta del Lechón – Guavate, Cayey PR-184', + region: 'Guavate', + difficulty: 'Familiar', + estimatedMinutes: 210, + tags: ['#Lechón', '#Familiar', '#Domingo', '#Cuerito'], + summary: + 'La ruta clásica para ir con corillo, comer lechón y sentir la fiesta dominguera del centro-sur.', + bestFor: 'Lechón, morcilla y chinchorreo familiar con música en vivo.', + featured: true, + accentFrom: '#CE1126', + accentTo: '#DAA520', + heroImage: + 'https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&w=1600&q=80', + stops: [ + { + id: 'guavate-view', + name: 'Mirador de Guavate', + town: 'Cayey', + description: 'Pausa inicial para fotos, café y escoger por dónde arrancar.', + specialty: 'Café con vista a la montaña', + hours: '10:00 a.m. – 5:00 p.m.', + recommendedStay: '20 min', + mapQuery: 'Guavate Cayey Puerto Rico', + }, + { + id: 'lechonera-km329', + name: 'Lechoneras Guavate Km 32.9', + town: 'Cayey', + description: 'La parada obligada para cuerito crujiente y morcilla.', + specialty: 'Lechón, cuerito y morcilla', + hours: '10:30 a.m. – 7:00 p.m.', + recommendedStay: '45 min', + mapQuery: 'Lechoneras Guavate Km 32.9 Cayey Puerto Rico', + }, + { + id: 'lechonera-km332', + name: 'Lechoneras Guavate Km 33.2', + town: 'Cidra', + description: 'Segunda ronda para comparar sazón con los panas.', + specialty: 'Lechón asado y arroz con gandules', + hours: '11:00 a.m. – 6:00 p.m.', + recommendedStay: '40 min', + mapQuery: 'Lechoneras Guavate Km 33.2 Cidra Puerto Rico', + }, + { + id: 'plaza-cayey', + name: 'Plaza de Cayey', + town: 'Cayey', + description: 'Remate tranquilo con postre o café en el casco urbano.', + specialty: 'Helado, café y paseo breve', + hours: 'Hasta el atardecer', + recommendedStay: '25-35 min', + mapQuery: 'Plaza de Cayey Puerto Rico', + }, + ], + reviews: [ + { + author: 'Rebeca', + rating: 5, + text: 'Ruta sabrosa y cero estrés: parking, música y lechón para todos los gustos.', + }, + { + author: 'Carlos', + rating: 4, + text: 'Perfecta para traer visitantes y enseñarles un clásico boricua.', + }, + ], + }, + { + slug: 'naranjito-pr152', + emoji: '🐒', + name: 'Ruta Gastronómica PR-152 Naranjito', + region: 'Naranjito', + difficulty: 'Moderado', + estimatedMinutes: 230, + tags: ['#Naranjito', '#Río', '#Smokehouse', '#Original'], + summary: + 'Una ruta central con vistas al río, sangría famosa y restaurantes con identidad propia.', + bestFor: 'Salir del área metro y probar sabor central con panorama.', + featured: true, + accentFrom: '#228B22', + accentTo: '#14B8A6', + heroImage: + 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=1600&q=80', + stops: [ + { + id: 'caldosos', + name: 'Caldosos Bar & Rest.', + town: 'Naranjito', + description: 'Primera parada con sangría famosa y vibe de festival de jueves.', + specialty: 'Sangría y tiraera de peseta', + hours: '4:00 p.m. – 11:00 p.m.', + recommendedStay: '40-50 min', + mapQuery: 'Caldosos Naranjito Puerto Rico', + }, + { + id: 'las-lagrimas', + name: 'Las Lágrimas Bar & Grill', + town: 'Naranjito', + description: 'Vista al río para seguir el jangueo con algo más tranquilo.', + specialty: 'Picadera criolla con vista', + hours: '1:00 p.m. – 9:00 p.m.', + recommendedStay: '35-45 min', + mapQuery: 'Las Lágrimas Bar and Grill Naranjito Puerto Rico', + }, + { + id: 'calichi', + name: 'Calichi Gastrobar', + town: 'Naranjito', + description: 'Terraza moderna para brunch tardío, cócteles y fotos chéveres.', + specialty: 'Coctelería de la casa', + hours: '12:00 p.m. – 10:00 p.m.', + recommendedStay: '45 min', + mapQuery: 'Calichi Gastrobar Naranjito Puerto Rico', + }, + { + id: 'pal-velde', + name: 'Pal Velde', + town: 'Naranjito', + description: 'Cierre bien comfort con smokehouse, cerdo ahumado y mac n’ cheese.', + specialty: 'Cerdo ahumado y mac n’ cheese', + hours: '5:00 p.m. – 11:00 p.m.', + recommendedStay: '45-60 min', + mapQuery: 'Pal Velde Naranjito Puerto Rico', + }, + ], + reviews: [ + { + author: 'Lina', + rating: 5, + text: 'Naranjito sorprende full. Tiene un balance brutal entre vista y buen comer.', + }, + { + author: 'Héctor', + rating: 4, + text: 'Se presta para una tarde larga terminando con buena música.', + }, + ], + }, + { + slug: 'la-parguera-lajas', + emoji: '🦞', + name: 'Ruta La Parguera, Lajas', + region: 'Suroeste', + difficulty: 'Moderado', + estimatedMinutes: 250, + tags: ['#Mariscos', '#Bioluminiscencia', '#Nocturno', '#Costa'], + summary: + 'Mariscos, malecón y un cierre nocturno con bahía bioluminiscente para una ruta memorable.', + bestFor: 'Sunset, seafood y chinchorreo que termina de noche.', + featured: false, + accentFrom: '#0F766E', + accentTo: '#38BDF8', + heroImage: + 'https://images.unsplash.com/photo-1473116763249-2faaef81ccda?auto=format&fit=crop&w=1600&q=80', + stops: [ + { + id: 'la-parguera-seafood', + name: 'La Parguera', + town: 'Lajas', + description: 'Parada principal para mariscos frescos y ambiente playero.', + specialty: 'Mofongo relleno de mariscos', + hours: '12:00 p.m. – 9:00 p.m.', + recommendedStay: '45 min', + mapQuery: 'La Parguera Lajas Puerto Rico', + }, + { + id: 'malecon-parguera', + name: 'Malecón de La Parguera', + town: 'Lajas', + description: 'Paseo obligado para ver lanchas, música y atardecer.', + specialty: 'Piña colada y paseo corto', + hours: 'Todo el día', + recommendedStay: '30 min', + mapQuery: 'Malecon de La Parguera Puerto Rico', + }, + { + id: 'bio-bay', + name: 'Tour bioluminiscente', + town: 'Lajas', + description: 'Cierre wow para convertir el chinchorreo en experiencia nocturna.', + specialty: 'Tour guiado en bote', + hours: '7:00 p.m. – 9:00 p.m.', + recommendedStay: '60-90 min', + mapQuery: 'Bahia bioluminiscente La Parguera Puerto Rico', + }, + ], + reviews: [ + { + author: 'Verónica', + rating: 5, + text: 'Pocas rutas mezclan comida y actividad de noche tan bien como esta.', + }, + { + author: 'Eli', + rating: 4, + text: 'Ideal si quieres algo distinto al típico chinchorreo de montaña.', + }, + ], + }, + { + slug: 'luquillo-el-yunque', + emoji: '🍤', + name: 'Ruta Luquillo–El Yunque', + region: 'Este Verde', + difficulty: 'Aventurero', + estimatedMinutes: 270, + tags: ['#Naturaleza', '#Kioscos', '#Costa', '#Aventurero'], + summary: + 'Empieza entre kioscos frente al mar y termina en la zona verde más famosa de Puerto Rico.', + bestFor: 'Combinar playa, kioscos y naturaleza en una sola escapada.', + featured: true, + accentFrom: '#0F766E', + accentTo: '#22C55E', + heroImage: + 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?auto=format&fit=crop&w=1600&q=80', + stops: [ + { + id: 'kioscos-luquillo', + name: 'Kioscos de Luquillo', + town: 'Luquillo', + description: 'Un arranque ideal para compartir varias cositas en una misma parada.', + specialty: 'Variedad de +60 opciones junto al mar', + hours: '10:00 a.m. – 10:00 p.m.', + recommendedStay: '45-60 min', + mapQuery: 'Kioscos de Luquillo Puerto Rico', + }, + { + id: 'balneario-luquillo', + name: 'Balneario La Monserrate', + town: 'Luquillo', + description: 'Parada breve para bajar el calor y respirar playa antes de subir.', + specialty: 'Baño rápido y coco frío', + hours: '9:00 a.m. – 5:00 p.m.', + recommendedStay: '25-35 min', + mapQuery: 'Balneario La Monserrate Luquillo Puerto Rico', + }, + { + id: 'el-yunque', + name: 'El Yunque', + town: 'Río Grande', + description: 'Final aventurero con naturaleza, cascadas y brisa fresca.', + specialty: 'Sendero corto y vista verde', + hours: '8:00 a.m. – 5:00 p.m.', + recommendedStay: '60 min', + mapQuery: 'El Yunque Puerto Rico', + }, + ], + reviews: [ + { + author: 'Stephanie', + rating: 5, + text: 'Se siente como mini vacaciones: kioscos, playa y bosque en un mismo día.', + }, + { + author: 'Raúl', + rating: 4, + text: 'Es la más aventurera, pero vale cada parada por la mezcla de paisajes.', + }, + ], + }, +]; + +const makeChinchorro = ( + slug: string, + name: string, + town: ChinchorroGuide['town'], + region: ChinchorroGuide['region'], + type: ChinchorroGuide['type'], + vibe: ChinchorroGuide['vibe'], + price: ChinchorroGuide['price'], + specialties: string[], + description: string, + routeSlug: string | undefined, + featured: boolean, + openNow: boolean, + accentFrom: string, + accentTo: string, + mapX: number, + mapY: number, +) => ({ + slug, + name, + town, + region, + type, + vibe, + price, + address: `${name}, ${town}, Puerto Rico`, + hours: + vibe === 'Nocturno' + ? 'Jue–Dom · 4:00 p.m. – 12:00 a.m.' + : vibe === 'Familiar' + ? 'Mié–Dom · 11:00 a.m. – 8:00 p.m.' + : 'Jue–Dom · 12:00 p.m. – 10:00 p.m.', + specialties, + rating: + featured && openNow ? 4.9 : featured ? 4.8 : openNow ? 4.7 : 4.6, + reviewsCount: 120 + slug.length, + phone: `787-555-${String(slug.length * 17).padStart(4, '0').slice(0, 4)}`, + instagramUrl: createInstagramSearchUrl(`${name} ${town} Puerto Rico`), + facebookUrl: createFacebookSearchUrl(`${name} ${town} Puerto Rico`), + mapsUrl: createMapsSearchUrl(`${name} ${town} Puerto Rico`), + description, + featured, + openNow, + dishGallery: [...specialties.slice(0, 3)], + reviewHighlights: [ + { + author: 'Corillo PR', + rating: 5, + text: `La especialidad “${specialties[0]}” hace que ${name} siempre se quede en la lista corta.`, + }, + { + author: 'Ruta Foodie', + rating: 4, + text: `${name} tiene ambiente ${vibe.toLowerCase()} y una parada que se siente auténtica.`, + }, + ], + routeSlug, + accentFrom, + accentTo, + mapX, + mapY, +}); + +export const chinchorrosGuide: ChinchorroGuide[] = [ + makeChinchorro( + 'el-bohio-pinones', + 'El Bohío', + 'Piñones', + 'Norte', + 'Kioscos', + 'Al Aire Libre', + '$$', + ['Alcapurrias de jueyes', 'Bacalaítos', 'Piña colada'], + 'Uno de esos spots clásicos de Piñones para arrancar el jangueo con frituras bien hechas.', + 'pinones-loiza', + true, + true, + '#CE1126', + '#F59E0B', + 82, + 35, + ), + makeChinchorro( + 'el-clandestino-barceloneta', + 'El Clandestino', + 'Barceloneta', + 'Norte', + 'Bar & Grill', + 'Nocturno', + '$$$', + ['Drinks con vista al mar', 'Tacos de pescado', 'Mojito de parcha'], + 'Perfecto para rematar la ruta del norte con cocteles y ambiente de sunset.', + 'arecibo-barceloneta', + true, + true, + '#002D62', + '#38BDF8', + 31, + 28, + ), + makeChinchorro( + 'pura-pesca-arecibo', + 'Pura Pesca', + 'Arecibo', + 'Norte', + 'Mariscos', + 'Familiar', + '$$', + ['Dorado en escabeche', 'Pulpo a la parrilla', 'Arañitas'], + 'Mariscos con sazón criolla y una parada fácil de amar si vienes con hambre.', + 'arecibo-barceloneta', + true, + true, + '#0F766E', + '#2DD4BF', + 35, + 28, + ), + makeChinchorro( + 'el-carbon-y-lena-arecibo', + 'El Carbón y Leña', + 'Arecibo', + 'Norte', + 'Bar & Grill', + 'Animado', + '$$', + ['Mojitos', 'Mofongo relleno', 'Pinchos'], + 'Sabor criollo con espíritu de corillo y una barra que siempre prende la conversación.', + 'arecibo-barceloneta', + true, + false, + '#7C2D12', + '#FB923C', + 36, + 30, + ), + makeChinchorro( + 'el-nuevo-guayabo-arecibo', + 'El Nuevo Guayabo', + 'Arecibo', + 'Norte', + 'Colmado', + 'Animado', + '$', + ['Empanadas de cetí', 'Tostones de pana', 'Cerveza fría'], + 'Parada más relajada con espíritu de chinchorro de carretera y fritura memorables.', + 'arecibo-barceloneta', + false, + true, + '#B91C1C', + '#F97316', + 37, + 31, + ), + makeChinchorro( + 'casa-vieja-ciales', + 'Casa Vieja', + 'Ciales', + 'Norte', + 'Cafetería', + 'Familiar', + '$$', + ['Carne frita', 'Arroz mamposteao', 'Jugos naturales'], + 'Una parada montañera que se siente como casa de campo con sazón casera.', + 'ciales-jayuya', + true, + true, + '#166534', + '#65A30D', + 42, + 34, + ), + makeChinchorro( + 'la-suiza-ciales', + 'La Suiza de Ciales', + 'Ciales', + 'Norte', + 'Cafetería', + 'Romántico', + '$$', + ['Postres de la casa', 'Sándwiches artesanales', 'Café local'], + 'Clásico con personalidad propia para una pausa tranquila entre curvas.', + 'ciales-jayuya', + false, + false, + '#4D7C0F', + '#A3E635', + 43, + 35, + ), + makeChinchorro( + 'finca-cultura-ciales', + 'Finca Cultura', + 'Ciales', + 'Norte', + 'Bar & Grill', + 'Romántico', + '$$$', + ['Cervezas con vista', 'Tabla de quesos', 'Pan artesanal'], + 'Más contemplativo, ideal para bajar revoluciones con una vista abierta.', + 'ciales-jayuya', + true, + true, + '#14532D', + '#22C55E', + 44, + 36, + ), + makeChinchorro( + 'el-desahogo-ciales', + 'El Desahogo', + 'Ciales', + 'Norte', + 'Bar & Grill', + 'Animado', + '$$', + ['Carne ahumada', 'Sangría de la casa', 'Tacos criollos'], + 'Una parada para quien quiere ambiente con humo, música y una sangría seria.', + 'ciales-jayuya', + false, + true, + '#365314', + '#84CC16', + 45, + 37, + ), + makeChinchorro( + 'la-sombra-orocovis', + 'La Sombra', + 'Orocovis', + 'Centro', + 'Longaniza', + 'Familiar', + '$$', + ['Longanizas desde 1934', 'Arroz con longaniza', 'Morcilla'], + 'Institución de Orocovis para quien quiere tradición sin rodeos.', + 'morovis-orocovis-pr155', + true, + true, + '#7C2D12', + '#DAA520', + 49, + 45, + ), + makeChinchorro( + 'casa-bavaria-morovis', + 'Casa Bavaria', + 'Morovis', + 'Centro', + 'Bar & Grill', + 'Animado', + '$$$', + ['Salchichas artesanales', 'Pretzels', 'Cerveza draft'], + 'El spot más peculiar de la zona, con un twist alemán y mood de celebración.', + 'morovis-orocovis-pr155', + true, + false, + '#1D4ED8', + '#93C5FD', + 47, + 40, + ), + makeChinchorro( + 'hijos-del-josco-morovis', + 'Hijos del Josco', + 'Morovis', + 'Centro', + 'Bar & Grill', + 'Animado', + '$$', + ['El Tacolao de bacalaao', 'Croquetas de yautía', 'Cocteles de la casa'], + 'Creativo, sabroso y perfecto para el corillo foodie.', + 'morovis-orocovis-pr155', + true, + true, + '#B45309', + '#F59E0B', + 46, + 39, + ), + makeChinchorro( + 'asador-don-pablo-morovis', + 'Asador Don Pablo', + 'Morovis', + 'Centro', + 'Bar & Grill', + 'Romántico', + '$$$', + ['Smokehouse único', 'Brisket', 'Mac n’ cheese'], + 'Un smokehouse con alma propia para cerrar la ruta con algo memorable.', + 'morovis-orocovis-pr155', + true, + true, + '#7C2D12', + '#F97316', + 45, + 41, + ), + makeChinchorro( + 'caldosos-naranjito', + 'Caldosos Bar & Rest.', + 'Naranjito', + 'Naranjito', + 'Bar & Grill', + 'Animado', + '$$', + ['Sangría famosa', 'Tiraera de peseta', 'Picadera criolla'], + 'La parada más icónica del jueves en Naranjito.', + 'naranjito-pr152', + true, + true, + '#228B22', + '#F59E0B', + 58, + 47, + ), + makeChinchorro( + 'las-lagrimas-naranjito', + 'Las Lágrimas Bar & Grill', + 'Naranjito', + 'Naranjito', + 'Bar & Grill', + 'Al Aire Libre', + '$$', + ['Vista al río', 'Picadera mixta', 'Limonada fresca'], + 'Vista natural y mood más relajado para conversar sin prisa.', + 'naranjito-pr152', + true, + false, + '#0F766E', + '#22C55E', + 59, + 49, + ), + makeChinchorro( + 'calichi-gastrobar-naranjito', + 'Calichi Gastrobar', + 'Naranjito', + 'Naranjito', + 'Bar & Grill', + 'Romántico', + '$$$', + ['Terraza con vistas', 'Cocteles botánicos', 'Brunch tardío'], + 'Más moderno, ideal para fotos y un chinchorreo con presentación cuidada.', + 'naranjito-pr152', + true, + true, + '#0F766E', + '#38BDF8', + 60, + 46, + ), + makeChinchorro( + 'pal-velde-naranjito', + 'Pal Velde', + 'Naranjito', + 'Naranjito', + 'Bar & Grill', + 'Animado', + '$$$', + ['Cerdo ahumado', 'Mac n’ Cheese', 'Old fashioned boricua'], + 'Una de las paradas más comentadas para cerrar la noche con comfort food.', + 'naranjito-pr152', + true, + true, + '#7C2D12', + '#FACC15', + 57, + 48, + ), + makeChinchorro( + 'el-rancho-don-nando-naranjito', + 'El Rancho Don Nando', + 'Naranjito', + 'Naranjito', + 'Longaniza', + 'Familiar', + '$$', + ['Longanizas artesanales', 'Yuca al mojo', 'Chicharrón de pollo'], + 'Parada buena para meter tradición en medio de la ruta gastronómica.', + 'naranjito-pr152', + false, + true, + '#166534', + '#84CC16', + 56, + 50, + ), + makeChinchorro( + 'el-flamboyan-naranjito', + 'El Flamboyan', + 'Naranjito', + 'Naranjito', + 'Bar & Grill', + 'Nocturno', + '$$', + ['Karaoke', 'Carne ahumada', 'Tragos frozen'], + 'Una opción perfecta si el plan es alargar el chinchorreo en karaoke.', + 'naranjito-pr152', + false, + false, + '#B91C1C', + '#F59E0B', + 58, + 51, + ), + makeChinchorro( + 'asador-san-miguel-naranjito', + 'Asador San Miguel', + 'Naranjito', + 'Naranjito', + 'Bar & Grill', + 'Romántico', + '$$$', + ['Reserva recomendada', 'Carnes premium', 'Vinos por copa'], + 'Un spot de ocasión especial dentro de la escena de Naranjito.', + 'naranjito-pr152', + false, + true, + '#7C2D12', + '#FB923C', + 55, + 47, + ), + makeChinchorro( + 'lechoneras-guavate-km-33-2-cidra', + 'Lechoneras Guavate Km 33.2', + 'Cidra', + 'Sur', + 'Lechonera', + 'Familiar', + '$$', + ['Lechón asado', 'Arroz con gandules', 'Guineítos en escabeche'], + 'Lechón jugoso y el tipo de parada donde todo el mundo termina contento.', + 'guavate-cayey-pr184', + true, + true, + '#CE1126', + '#DAA520', + 63, + 57, + ), + makeChinchorro( + 'lechoneras-guavate-km-32-9-cayey', + 'Lechoneras Guavate Km 32.9', + 'Cayey', + 'Sur', + 'Lechonera', + 'Animado', + '$$', + ['Cuerito y morcilla', 'Lechón', 'Arroz mamposteao'], + 'Más movida y perfecta para el domingo de música en vivo.', + 'guavate-cayey-pr184', + true, + true, + '#B91C1C', + '#F59E0B', + 62, + 59, + ), + makeChinchorro( + 'kioscos-de-luquillo', + 'Kioscos de Luquillo', + 'Luquillo', + 'Este', + 'Kioscos', + 'Al Aire Libre', + '$$', + ['+60 opciones junto al mar', 'Alcapurrias', 'Ceviches'], + 'Una parada múltiple que funciona para grupos con gustos distintos.', + 'luquillo-el-yunque', + true, + true, + '#0284C7', + '#22C55E', + 84, + 44, + ), + makeChinchorro( + 'la-parguera-lajas-seafood', + 'La Parguera', + 'Lajas', + 'Oeste', + 'Mariscos', + 'Nocturno', + '$$$', + ['Mariscos frescos', 'Mofongo relleno', 'Cóctel de camarones'], + 'Clásico del suroeste para comer antes o después del paseo por la bahía.', + 'la-parguera-lajas', + true, + false, + '#0F766E', + '#38BDF8', + 22, + 68, + ), +]; + +export const placesGuide: PlaceGuide[] = [ + { + slug: 'balneario-luquillo', + name: 'Luquillo', + category: 'Playas', + town: 'Luquillo', + description: 'Una playa cómoda para combinar con kioscos y un chapuzón rápido.', + bestTime: 'Temprano en la mañana o antes del sunset', + localTip: 'Llega con cambio de ropa si luego piensas subir a El Yunque.', + mapsUrl: createMapsSearchUrl('Balneario Luquillo Puerto Rico'), + accentFrom: '#0284C7', + accentTo: '#38BDF8', + emoji: '🏖️', + mapX: 84, + mapY: 46, + }, + { + slug: 'pinones', + name: 'Piñones', + category: 'Playas', + town: 'Loíza', + description: 'Costa, kioscos y un paseo que mezcla mar con frituras.', + bestTime: 'De 10:00 a.m. a 4:00 p.m.', + localTip: 'Usa bloqueador y deja tiempo para caminar el paseo.', + mapsUrl: createMapsSearchUrl('Piñones Puerto Rico'), + accentFrom: '#CE1126', + accentTo: '#F59E0B', + emoji: '🌴', + mapX: 80, + mapY: 36, + }, + { + slug: 'la-parguera', + name: 'La Parguera', + category: 'Playas', + town: 'Lajas', + description: 'Malecón costero, botes y acceso a la bahía bioluminiscente.', + bestTime: 'Atardecer y noche', + localTip: 'Reserva el tour de bioluminiscencia con anticipación.', + mapsUrl: createMapsSearchUrl('La Parguera Lajas Puerto Rico'), + accentFrom: '#0F766E', + accentTo: '#38BDF8', + emoji: '🦞', + mapX: 20, + mapY: 68, + }, + { + slug: 'mar-chiquita', + name: 'Mar Chiquita', + category: 'Playas', + town: 'Manatí', + description: 'Postcard spot del norte para una parada corta y fotos épicas.', + bestTime: 'Mañana con sol alto', + localTip: 'No subestimes la fuerza del oleaje fuera de la poza.', + mapsUrl: createMapsSearchUrl('Mar Chiquita Manatí Puerto Rico'), + accentFrom: '#1D4ED8', + accentTo: '#60A5FA', + emoji: '🌊', + mapX: 40, + mapY: 26, + }, + { + slug: 'el-yunque', + name: 'El Yunque', + category: 'Naturaleza', + town: 'Río Grande', + description: 'La experiencia verde por excelencia para mezclar carretera con naturaleza.', + bestTime: 'Temprano en la mañana', + localTip: 'Lleva tenis con agarre y una capa ligera por si cambia el clima.', + mapsUrl: createMapsSearchUrl('El Yunque Puerto Rico'), + accentFrom: '#166534', + accentTo: '#22C55E', + emoji: '🌿', + mapX: 86, + mapY: 49, + }, + { + slug: 'chorro-dona-juana', + name: 'El Chorro de Doña Juana', + category: 'Naturaleza', + town: 'Orocovis', + description: 'Cascada escénica ideal para acompañar una ruta del centro.', + bestTime: 'Media mañana', + localTip: 'Haz la parada breve; luego sigue a las longanizas con hambre.', + mapsUrl: createMapsSearchUrl('Chorro de Doña Juana Orocovis Puerto Rico'), + accentFrom: '#14532D', + accentTo: '#84CC16', + emoji: '💧', + mapX: 49, + mapY: 44, + }, + { + slug: 'las-delicias-ciales', + name: 'Las Delicias', + category: 'Naturaleza', + town: 'Ciales', + description: 'Una zona fresca y verde para respirar profundo entre paradas.', + bestTime: 'Al mediodía con clima fresco', + localTip: 'Ideal para estirar las piernas antes de seguir chinchorreando.', + mapsUrl: createMapsSearchUrl('Las Delicias Ciales Puerto Rico'), + accentFrom: '#4D7C0F', + accentTo: '#A3E635', + emoji: '🌳', + mapX: 43, + mapY: 36, + }, + { + slug: 'lago-la-plata', + name: 'Lago La Plata', + category: 'Naturaleza', + town: 'Naranjito', + description: 'Paisaje central perfecto para un break con mirada amplia al agua.', + bestTime: 'Tarde fresca', + localTip: 'Buena parada antes de ir a Caldosos o Las Lágrimas.', + mapsUrl: createMapsSearchUrl('Lago La Plata Naranjito Puerto Rico'), + accentFrom: '#0F766E', + accentTo: '#14B8A6', + emoji: '🚣', + mapX: 58, + mapY: 50, + }, + { + slug: 'puente-atirantado', + name: 'Puente Atirantado', + category: 'Puntos Icónicos', + town: 'Naranjito', + description: 'El ícono visual de la zona para una parada corta y una foto fija.', + bestTime: 'Golden hour', + localTip: 'Combínalo con Lago La Plata para una mini pausa escénica.', + mapsUrl: createMapsSearchUrl('Puente Atirantado Naranjito Puerto Rico'), + accentFrom: '#1D4ED8', + accentTo: '#93C5FD', + emoji: '🌉', + mapX: 57, + mapY: 48, + }, + { + slug: 'plaza-cayey', + name: 'Plaza de Cayey', + category: 'Puntos Icónicos', + town: 'Cayey', + description: 'Casco urbano bonito para cerrar una ruta con café o postre.', + bestTime: 'Al final de la tarde', + localTip: 'Si vas un domingo, úsala como cierre después de Guavate.', + mapsUrl: createMapsSearchUrl('Plaza de Cayey Puerto Rico'), + accentFrom: '#B91C1C', + accentTo: '#F59E0B', + emoji: '🏛️', + mapX: 63, + mapY: 60, + }, + { + slug: 'mirador-orocovis', + name: 'Mirador Orocovis', + category: 'Puntos Icónicos', + town: 'Orocovis', + description: 'Mirador para sacar el teléfono, respirar y seguir la ruta con calma.', + bestTime: 'Media tarde', + localTip: 'Perfecto para un agua fría antes de volver a comer.', + mapsUrl: createMapsSearchUrl('Mirador Orocovis Puerto Rico'), + accentFrom: '#7C2D12', + accentTo: '#FACC15', + emoji: '📸', + mapX: 50, + mapY: 43, + }, + { + slug: 'naranjito-town', + name: 'Naranjito', + category: 'Pueblos con Encanto', + town: 'Naranjito', + description: 'Pueblo central con buen pulso gastronómico y vistas agradables.', + bestTime: 'Tarde a noche', + localTip: 'Dedícale más tiempo si vas jueves por el Festival de la Peseta.', + mapsUrl: createMapsSearchUrl('Naranjito Puerto Rico'), + accentFrom: '#228B22', + accentTo: '#14B8A6', + emoji: '🏘️', + mapX: 58, + mapY: 48, + }, + { + slug: 'loiza-town', + name: 'Loíza', + category: 'Cultura', + town: 'Loíza', + description: 'Historia afrocaribeña, música y tradición que enriquecen cualquier recorrido.', + bestTime: 'Todo el día, mejor fines de semana', + localTip: 'Habla con la gente del área; siempre aparece una recomendación nueva.', + mapsUrl: createMapsSearchUrl('Loiza Puerto Rico'), + accentFrom: '#7C3AED', + accentTo: '#EC4899', + emoji: '🎭', + mapX: 81, + mapY: 38, + }, + { + slug: 'jayuya-artesanias', + name: 'Artesanías de Jayuya', + category: 'Cultura', + town: 'Jayuya', + description: 'Un buen desvío para conectar comida con artesanía del centro montañoso.', + bestTime: 'Mediodía', + localTip: 'Compra algo pequeño a un artesano local para apoyar la economía del pueblo.', + mapsUrl: createMapsSearchUrl('Jayuya Puerto Rico'), + accentFrom: '#7C2D12', + accentTo: '#FB923C', + emoji: '🧶', + mapX: 38, + mapY: 47, + }, +]; + +const festivalPeseta = nextWeekdayOccurrence(4); + +export const eventsGuide: EventGuide[] = [ + { + slug: 'festival-del-lechon', + name: 'Festival del Lechón', + town: 'Cayey', + venue: 'Guavate', + category: 'Gastronomía', + teaser: 'Lechón, música típica y corillo en la PR-184.', + nextDateIso: nextOccurrence(6, 21).toISOString(), + schedule: 'Desde las 10:00 a.m. · Domingo familiar', + accentFrom: '#CE1126', + accentTo: '#DAA520', + emoji: '🐷', + }, + { + slug: 'oktoberfest-pr', + name: 'Oktoberfest Puerto Rico', + town: 'Morovis', + venue: 'Casa Bavaria', + category: 'Festival temático', + teaser: 'Cervezas, bratwurst y ambiente festivo en el centro.', + nextDateIso: nextOccurrence(10, 10).toISOString(), + schedule: 'Desde las 3:00 p.m. · Música en vivo', + accentFrom: '#1D4ED8', + accentTo: '#93C5FD', + emoji: '🍺', + }, + { + slug: 'festival-de-la-peseta', + name: 'Festival de la Peseta', + town: 'Naranjito', + venue: 'Caldosos Bar & Rest.', + category: 'Tradición local', + teaser: 'Todos los jueves se prende la tiraera y la sangría.', + nextDateIso: festivalPeseta.toISOString(), + schedule: 'Jueves · 6:00 p.m. en adelante', + accentFrom: '#228B22', + accentTo: '#F59E0B', + emoji: '🪙', + }, + { + slug: 'festival-de-la-longaniza', + name: 'Festival de la Longaniza', + town: 'Orocovis', + venue: 'Casco urbano', + category: 'Gastronomía', + teaser: 'Longaniza protagonista, kioscos y música de pueblo.', + nextDateIso: nextOccurrence(7, 5).toISOString(), + schedule: 'Sábado y domingo · Desde las 11:00 a.m.', + accentFrom: '#7C2D12', + accentTo: '#DAA520', + emoji: '🌭', + }, + { + slug: 'fiestas-patronales', + name: 'Fiestas Patronales', + town: 'Naranjito', + venue: 'Plaza pública', + category: 'Cultura popular', + teaser: 'Tarima, comida y artesanos en el corazón del pueblo.', + nextDateIso: nextOccurrence(7, 18).toISOString(), + schedule: 'Fin de semana · Programación familiar', + accentFrom: '#7C3AED', + accentTo: '#EC4899', + emoji: '🎉', + }, + { + slug: 'festival-de-mascaras', + name: 'Festival de Máscaras', + town: 'Loíza', + venue: 'Casco y plazas culturales', + category: 'Tradición', + teaser: 'Bomba, vejigantes y cultura afrocaribeña viva.', + nextDateIso: nextOccurrence(7, 26).toISOString(), + schedule: 'Domingo · Desde el mediodía', + accentFrom: '#CE1126', + accentTo: '#7C3AED', + emoji: '🎭', + }, + { + slug: 'feria-de-artesanias-jayuya', + name: 'Feria de Artesanías', + town: 'Jayuya', + venue: 'Plaza y calles principales', + category: 'Arte local', + teaser: 'Piezas hechas a mano, música y buen café del centro.', + nextDateIso: nextOccurrence(8, 16).toISOString(), + schedule: 'Sábado · 10:00 a.m. – 8:00 p.m.', + accentFrom: '#166534', + accentTo: '#FACC15', + emoji: '🧵', + }, +]; + +export const travelerTips: TipCategory[] = [ + { + title: 'Antes de salir', + emoji: '🚗', + items: [ + 'Designa un chofer o usa transporte grupal.', + 'Llega temprano; antes de las 11:00 a.m. los fines de semana todo fluye mejor.', + 'Lleva efectivo por si alguna parada no trabaja tarjetas.', + 'Usa ropa cómoda y calzado para caminar entre paradas.', + ], + }, + { + title: 'Durante el chinchorreo', + emoji: '🍽️', + items: [ + 'Come porciones pequeñas en cada parada para poder probar de todo.', + 'Alterna bebidas alcohólicas con agua.', + 'Pregunta por la especialidad de la casa; ahí está el verdadero sabor.', + 'Habla con los dueños o el personal para recibir recomendaciones reales.', + ], + }, + { + title: 'Responsabilidad', + emoji: '🌿', + items: [ + 'No dejes basura en playas, ríos o estacionamientos.', + 'Respeta los horarios y el ambiente del lugar.', + 'Si bebes, pasa la llave 🔑.', + 'Apoya negocios familiares y compra algo local si puedes.', + ], + }, +]; + +export const glossaryTerms: GlossaryTerm[] = [ + { + term: 'Chinchorro', + definition: + 'Establecimiento pequeño y rústico donde abundan las bebidas, frituras y buena conversación.', + }, + { + term: 'Janguear', + definition: 'Salir a pasarla bien con amistades o familia.', + }, + { + term: 'Tiraera de peseta', + definition: 'Juego popular para ganarte una bebida gratis si encestas la peseta.', + }, + { + term: 'Pitorro', + definition: 'Ron casero artesanal puertorriqueño.', + }, + { + term: 'Cuerito', + definition: 'La piel crujiente del lechón asado; para mucha gente, lo mejor del plato.', + }, + { + term: 'Bacalaíto', + definition: 'Fritura delgada de harina con bacalao.', + }, + { + term: 'Alcapurria', + definition: 'Fritura de masa de yuca y guineo rellena, bien clásica en el chinchorreo.', + }, +]; + +export const weatherSnapshot = { + location: 'Cordillera y costa norte', + temperatureC: 27, + temperatureF: 81, + icon: '⛅', + condition: 'Brisa parcial con claros durante la tarde', + humidity: 74, +}; + +export const getPopularRoutes = (limit = 4) => + routeGuides.filter((route) => route.featured).slice(0, limit); + +export const getFeaturedChinchorros = (limit = 4) => + chinchorrosGuide.filter((item) => item.featured).slice(0, limit); + +export const getUpcomingEvents = (limit = 4) => + [...eventsGuide] + .sort( + (left, right) => + new Date(left.nextDateIso).getTime() - new Date(right.nextDateIso).getTime(), + ) + .slice(0, limit); + +export const getTipOfTheDay = () => { + const flatTips = travelerTips.flatMap((category) => + category.items.map((item) => ({ category: category.title, emoji: category.emoji, text: item })), + ); + const now = new Date(); + const daySeed = Math.floor( + (Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - + Date.UTC(now.getUTCFullYear(), 0, 0)) / + (1000 * 60 * 60 * 24), + ); + + return flatTips[daySeed % flatTips.length]; +}; + +export const getRandomRoute = () => + routeGuides[Math.floor(Math.random() * routeGuides.length)]; + +export const findRouteBySlug = (slug: string) => + routeGuides.find((route) => route.slug === slug); + +export const routeDirectionsLookup: Record = { + 'arecibo-barceloneta': rutaArecibo, + 'pinones-loiza': rutaPinones, + 'ciales-jayuya': rutaCiales, + 'morovis-orocovis-pr155': rutaMorovis, + 'guavate-cayey-pr184': rutaGuavate, + 'naranjito-pr152': rutaNaranjito, + 'la-parguera-lajas': rutaParguera, + 'luquillo-el-yunque': rutaLuquillo, +}; + +export const allSearchResults: GuideSearchResult[] = [ + ...routeGuides.map((route) => ({ + id: `route-${route.slug}`, + label: route.name, + subtitle: `${route.region} · ${route.tags.slice(0, 2).join(' ')}`, + typeLabel: 'Ruta', + href: `/rutas/${route.slug}`, + emoji: route.emoji, + })), + ...chinchorrosGuide.map((chinchorro) => ({ + id: `chinchorro-${chinchorro.slug}`, + label: chinchorro.name, + subtitle: `${chinchorro.town} · ${chinchorro.type}`, + typeLabel: 'Chinchorro', + href: `/restaurantes?search=${encodeURIComponent(chinchorro.name)}`, + emoji: '🍽️', + })), + ...placesGuide.map((place) => ({ + id: `place-${place.slug}`, + label: place.name, + subtitle: `${place.town} · ${place.category}`, + typeLabel: 'Lugar', + href: `/lugares?search=${encodeURIComponent(place.name)}`, + emoji: place.emoji, + })), + ...eventsGuide.map((event) => ({ + id: `event-${event.slug}`, + label: event.name, + subtitle: `${event.town} · ${formatEventDate(event.nextDateIso)}`, + typeLabel: 'Evento', + href: `/eventos?search=${encodeURIComponent(event.name)}`, + emoji: event.emoji, + })), + ...Array.from(new Set(chinchorrosGuide.map((item) => item.town))).map((town) => ({ + id: `town-${town}`, + label: town, + subtitle: 'Pueblo recomendado para chinchorrear', + typeLabel: 'Pueblo', + href: `/restaurantes?search=${encodeURIComponent(town)}`, + emoji: '📍', + })), +]; + +export const searchGuide = (query: string) => { + const trimmed = query.trim().toLowerCase(); + + if (!trimmed) { + return []; + } + + return allSearchResults.filter((item) => { + const haystack = `${item.label} ${item.subtitle} ${item.typeLabel}`.toLowerCase(); + return haystack.includes(trimmed); + }); +}; + +export const guideStats = { + routes: routeGuides.length, + chinchorros: chinchorrosGuide.length, + places: placesGuide.length, + events: eventsGuide.length, +}; + +export const regionOptions = ['Norte', 'Sur', 'Centro', 'Este', 'Oeste', 'Naranjito'] as const; +export const typeOptions = [ + 'Lechonera', + 'Mariscos', + 'Longaniza', + 'Bar & Grill', + 'Cafetería', + 'Colmado', + 'Kioscos', +] as const; +export const vibeOptions = [ + 'Familiar', + 'Nocturno', + 'Al Aire Libre', + 'Romántico', + 'Animado', +] as const; +export const priceOptions = ['$', '$$', '$$$'] as const; diff --git a/frontend/src/helpers/chinchorreoShare.ts b/frontend/src/helpers/chinchorreoShare.ts new file mode 100644 index 0000000..0e19c0f --- /dev/null +++ b/frontend/src/helpers/chinchorreoShare.ts @@ -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); +}; diff --git a/frontend/src/hooks/useChinchorreoStorage.ts b/frontend/src/hooks/useChinchorreoStorage.ts new file mode 100644 index 0000000..7bdf7b1 --- /dev/null +++ b/frontend/src/hooks/useChinchorreoStorage.ts @@ -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; +}; + +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; + 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(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 & { + 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>((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, + }; +}; diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -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' diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 84c9e84..c41050a 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -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" diff --git a/frontend/src/pages/eventos/index.tsx b/frontend/src/pages/eventos/index.tsx new file mode 100644 index 0000000..e67a90a --- /dev/null +++ b/frontend/src/pages/eventos/index.tsx @@ -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 ( + <> + + Chinchorreo PR | Eventos & Festivales + + + +
+
+
Eventos & Festivales
+

+ Calendario sabroso entre comida, cultura y tradición +

+

+ Aquí tienes un calendario mensual simplificado con contador regresivo y una lista de los próximos eventos más importantes del chinchorreo boricua. +

+
+ +
+ 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" + /> +
+ + {feedback ? ( +
+ + {feedback} +
+ ) : null} +
+ +
+
+
+
+
Mes destacado
+

{monthLabel(calendarMonth)}

+
+
+ {filteredEvents.length} eventos visibles +
+
+ +
+ {weekDays.map((day) => ( +
{day}
+ ))} +
+
+ {calendarDays.map((cell, index) => ( +
+ {cell.day ? ( + <> +
{cell.day}
+ {cell.event ? ( +
+ {cell.event.emoji} {cell.event.name} +
+ ) : null} + + ) : null} +
+ ))} +
+
+ +
+ {filteredEvents.map((event) => { + const saved = isFavorite('event', event.slug); + + return ( +
+
+
+ {event.emoji} {event.category} +
+

{event.name}

+

{event.teaser}

+
+
+
+
+
Fecha
+
{formatEventDate(event.nextDateIso)}
+
+
+
Venue
+
{event.venue}
+
+
+
Cuenta regresiva
+
{getCountdownLabel(event.nextDateIso)}
+
+
+
+ + {event.schedule} +
+
+ +
+ + {event.town} +
+
+
+
+ ); + })} +
+
+ + {!filteredEvents.length ? ( +
+

No encontramos eventos con ese término

+

+ Intenta con otro pueblo, venue o limpia la búsqueda para ver el calendario completo. +

+
+ ) : null} +
+ + ); +} + +EventsPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index b26ce37..4167535 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -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; +}) => ( +
+
+
{eyebrow}
+

{title}

+

{description}

+
+ {href ? ( + + Ver todo + + + ) : null} +
+); - 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) => ( - - ); + return searchGuide(searchValue).slice(0, 6); + }, [searchValue]); - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } - }; + const handleRandomRoute = () => { + const selectedRoute = getRandomRoute(); + router.push(`/rutas/${selectedRoute.slug}`); + }; return ( -
+ <> - {getPageTitle('Starter Page')} + Chinchorreo PR | Inicio + - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+
+
+ 🇵🇷 + ¡Vamos a chinchorrear! +
+

+ Tu guía interactiva para janguear Puerto Rico con sabor, ruta y corillo. +

+

+ Explora rutas auténticas, guarda tus paradas favoritas y descubre eventos, vistas y + chinchorros con una estética boricua vibrante y elegante. +

+ +
+
+
+ +
+ 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" + /> + +
+ {searchResults.length ? ( +
+ {searchResults.map((result) => ( + +
+
+ {result.emoji} + {result.label} +
+
{result.subtitle}
+
+ + {result.typeLabel} + + + ))} +
+ ) : null} +
+ +
+ + + Explorar rutas + + + + Entrar al admin + +
- - - - - -
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+
+ {[ + { 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) => ( +
+
+ {stat.label} +
+
+ {stat.value} +
+
Listas para descubrir hoy mismo.
+
+ ))} +
+
+ -
+
+ +
+ {featuredRoutes.map((route) => ( + + +
+
+ {route.region} +
+

{route.name}

+

{route.summary}

+
+ {route.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+
+ + ))} +
+
+ +
+
+ +
+ {featuredStops.map((stop) => ( +
+
+
+ {stop.region} +
+

{stop.name}

+

{stop.specialties[0]}

+
+
+

{stop.description}

+
+ {stop.specialties.map((specialty) => ( + + {specialty} + + ))} +
+ + Ver en la guía + + +
+
+ ))} +
+
+ +
+ +
+
+
+
+
+ Clima actual +
+
{weatherSnapshot.location}
+
{weatherSnapshot.condition}
+
+
+ {weatherSnapshot.icon} +
+
+
+
{weatherSnapshot.temperatureF}°
+
+ {weatherSnapshot.temperatureC}°C · Humedad {weatherSnapshot.humidity}% +
+
+
+ + Ideal para ruta mixta de costa y montaña +
+
+ +
+
+ + Tip del día +
+

{tipOfTheDay.category}

+

+ {tipOfTheDay.emoji} + {tipOfTheDay.text} +

+ + Ver guía completa + + +
+
+
+
+ +
+ +
+ {upcomingEvents.map((event) => ( +
+
+ {event.emoji} + {event.category} +
+

{event.name}

+

{event.teaser}

+
+
+
{formatEventDate(event.nextDateIso)}
+
{event.venue}
+
+
+ {getCountdownLabel(event.nextDateIso)} +
+
+
+ ))} +
+
+ + ); } -Starter.getLayout = function getLayout(page: ReactElement) { +HomePage.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index cf170b3..3038f94 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -44,7 +44,7 @@ export default function Login() { password: '16ca6a8c', remember: true }) - const title = 'ChinchorreoRPR' + const title = 'Chinchorreo PR' // Fetch Pexels image/video useEffect( () => { diff --git a/frontend/src/pages/lugares/index.tsx b/frontend/src/pages/lugares/index.tsx new file mode 100644 index 0000000..bd49cf5 --- /dev/null +++ b/frontend/src/pages/lugares/index.tsx @@ -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 ( + <> + + Chinchorreo PR | Lugares de Interés + + + +
+
+
Lugares de interés
+

+ Playas, miradores, pueblos y cultura para completar la ruta +

+

+ Usa estos lugares como pausas estratégicas entre paradas gastronómicas: fotos, vistas, + artesanías o una caminata corta para seguir con el plan. +

+
+ +
+ + +
+ +
+
+ {filteredPlaces.length} lugares disponibles +
+ {feedback ? ( +
+ + {feedback} +
+ ) : null} +
+
+ + {!filteredPlaces.length ? ( +
+

No encontramos lugares con esos filtros

+

+ Prueba buscando por pueblo, cambia la categoría o limpia el término de búsqueda. +

+
+ ) : null} + +
+ {filteredPlaces.map((place) => { + const saved = isFavorite('place', place.slug); + return ( +
+
+
+ {place.emoji} {place.category} +
+

{place.name}

+

{place.town}

+
+
+

{place.description}

+
+
Mejor horario
+
{place.bestTime}
+
+
+
Consejo local
+
{place.localTip}
+
+
+ + + Cómo llegar + + +
+
+ + {place.town}, Puerto Rico +
+
+
+ ); + })} +
+
+ + ); +} + +PlacesPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/mapa.tsx b/frontend/src/pages/mapa.tsx new file mode 100644 index 0000000..9b0e898 --- /dev/null +++ b/frontend/src/pages/mapa.tsx @@ -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('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([]); + + 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 ( + <> + + Chinchorreo PR | Mapa Interactivo + + + +
+
+
Mapa interactivo
+

+ Un mapa estilizado para descubrir paradas por categoría +

+

+ Esta primera versión te deja alternar entre mapa y lista, filtrar categorías y trazar una mini ruta con los pins que selecciones. +

+
+ +
+ +
+
Categorías
+ + +
+
+
Vista
+ {(['mapa', 'lista'] as MapViewMode[]).map((viewMode) => ( + + ))} +
+
+
+ +
+ {mode === 'mapa' ? ( +
+
+
+
+
+ + {visibleItems.map((item) => { + const selected = selectedItem?.id === item.id; + const inRoute = routeSelection.includes(item.id); + + return ( + + ); + })} + +
+
+ + Selecciona un pin para ver detalle +
+
Usa “Agregar a la ruta” para conectar varios puntos.
+
+
+ ) : ( +
+ {visibleItems.map((item) => { + const inRoute = routeSelection.includes(item.id); + return ( +
+
+
+ {item.emoji} {item.label} +
+
{item.subtitle}
+
+

{item.description}

+ +
+ ); + })} +
+ )} + + +
+ + {!visibleItems.length ? ( +
+

No hay pins visibles con esos filtros

+

+ Activa chinchorros o lugares nuevamente, o limpia la búsqueda para reconstruir el mapa. +

+
+ ) : null} +
+ + ); +} + +MapPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/mis-favoritos.tsx b/frontend/src/pages/mis-favoritos.tsx new file mode 100644 index 0000000..223324a --- /dev/null +++ b/frontend/src/pages/mis-favoritos.tsx @@ -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 = { + route: 'Rutas', + chinchorro: 'Restaurantes', + place: 'Lugares', + event: 'Eventos', +}; + +const categoryAccents: Record = { + 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) => { + 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 ( + <> + + Chinchorreo PR | Mis Favoritos + + + +
+
+
Mis Favoritos
+

+ Crea listas y guarda tus próximos jangueos +

+

+ En esta primera iteración, tus favoritos viven en el navegador: puedes crear listas, + elegir una lista activa y compartirla por WhatsApp. +

+ +
+
+
Listas
+
{lists.length}
+
+
+
Activa
+
{activeList?.name}
+
+
+
Guardados
+
{activeFavorites.length}
+
+
+ + {feedback ? ( +
+ + {feedback} +
+ ) : null} +
+ +
+
+ + Nueva lista personalizada +
+
+ +