Initial commit: Japan PWA guide
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built files
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
42
README.md
Normal file
42
README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 🗾 Japan Trip Companion
|
||||||
|
|
||||||
|
PWA-приложение для поездки в Японию 3-18 марта 2026.
|
||||||
|
|
||||||
|
## Функции
|
||||||
|
|
||||||
|
- 🗺️ **Карта** — все точки на интерактивной карте (Leaflet/OSM)
|
||||||
|
- 📅 **План дня** — маршрут на каждый из 16 дней
|
||||||
|
- 📍 **Места** — 45+ достопримечательностей с поиском и фильтрами
|
||||||
|
- 🍜 **Еда** — рестораны и рынки
|
||||||
|
- 📍 **GPS** — текущая позиция и "что рядом"
|
||||||
|
- 📱 **Офлайн** — PWA с кэшированием карт
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
|
- React 18 + TypeScript + Vite
|
||||||
|
- Tailwind CSS
|
||||||
|
- Leaflet + OpenStreetMap
|
||||||
|
- vite-plugin-pwa
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nginx Proxy Manager
|
||||||
|
|
||||||
|
- **Domain:** japan.digital-home.site
|
||||||
|
- **Forward Host:** japan-app (или 172.18.0.16)
|
||||||
|
- **Forward Port:** 80
|
||||||
|
- **SSL:** Let's Encrypt
|
||||||
|
|
||||||
|
## Города
|
||||||
|
|
||||||
|
- 🗼 Токио (дни 1-4)
|
||||||
|
- 🗻 Хаконэ (дни 5-6)
|
||||||
|
- ⛩️ Киото (дни 7-9, 11)
|
||||||
|
- 🦌 Нара (день 10)
|
||||||
|
- 🏯 Осака (дни 12-14)
|
||||||
|
- 🕊️ Хиросима (дни 15-16)
|
||||||
6
deploy.sh
Executable file
6
deploy.sh
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /opt/digital-home/japan-app
|
||||||
|
docker compose down
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs -f
|
||||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
japan-app:
|
||||||
|
build: .
|
||||||
|
container_name: japan-app
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- services_proxy
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
services_proxy:
|
||||||
|
external: true
|
||||||
20
index.html
Normal file
20
index.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/icon-192.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#1a1a2e" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<title>🗾 Japan Trip 2026</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
nginx.conf
Normal file
30
nginx.conf
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied expired no-cache no-store private auth;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Service worker - no cache
|
||||||
|
location /sw.js {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
expires off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "japan-trip-companion",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.22.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"react-leaflet": "^4.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.55",
|
||||||
|
"@types/react-dom": "^18.2.19",
|
||||||
|
"@types/leaflet": "^1.9.8",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.1.0",
|
||||||
|
"vite-plugin-pwa": "^0.19.2",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
5
public/icon-192.svg
Normal file
5
public/icon-192.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
||||||
|
<rect width="192" height="192" fill="#1a1a2e" rx="24"/>
|
||||||
|
<circle cx="96" cy="76" r="32" fill="#c41e3a"/>
|
||||||
|
<text x="96" y="150" text-anchor="middle" font-size="48" fill="#f5f5f5">🗾</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 264 B |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
87
src/App.tsx
Normal file
87
src/App.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { TabType, Place } from './types';
|
||||||
|
import { Navigation } from './components/Navigation';
|
||||||
|
import { PlaceDetails } from './components/PlaceDetails';
|
||||||
|
import { MapPage } from './pages/MapPage';
|
||||||
|
import { PlanPage } from './pages/PlanPage';
|
||||||
|
import { PlacesPage } from './pages/PlacesPage';
|
||||||
|
import { FoodPage } from './pages/FoodPage';
|
||||||
|
import { useGeolocation } from './hooks/useGeolocation';
|
||||||
|
import { usePlaces } from './hooks/usePlaces';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('plan');
|
||||||
|
const [selectedPlace, setSelectedPlace] = useState<Place | null>(null);
|
||||||
|
const [detailsPlace, setDetailsPlace] = useState<Place | null>(null);
|
||||||
|
const [showRouteForDay, setShowRouteForDay] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { position } = useGeolocation();
|
||||||
|
const { places } = usePlaces(position);
|
||||||
|
|
||||||
|
const handleSelectPlace = useCallback((place: Place) => {
|
||||||
|
setDetailsPlace(place);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNavigateToPlace = useCallback((place: Place) => {
|
||||||
|
setSelectedPlace(place);
|
||||||
|
setDetailsPlace(null);
|
||||||
|
setActiveTab('map');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowDayRoute = useCallback((day: number) => {
|
||||||
|
setShowRouteForDay(day);
|
||||||
|
setActiveTab('map');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseDetails = useCallback(() => {
|
||||||
|
setDetailsPlace(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-container">
|
||||||
|
<main className="app-main">
|
||||||
|
{activeTab === 'map' && (
|
||||||
|
<MapPage
|
||||||
|
places={places}
|
||||||
|
position={position}
|
||||||
|
selectedPlace={selectedPlace}
|
||||||
|
onSelectPlace={handleSelectPlace}
|
||||||
|
showRouteForDay={showRouteForDay}
|
||||||
|
onClearRoute={() => setShowRouteForDay(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'plan' && (
|
||||||
|
<PlanPage
|
||||||
|
places={places}
|
||||||
|
onSelectPlace={handleSelectPlace}
|
||||||
|
onShowDayRoute={handleShowDayRoute}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'places' && (
|
||||||
|
<PlacesPage
|
||||||
|
places={places}
|
||||||
|
position={position}
|
||||||
|
onSelectPlace={handleSelectPlace}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'food' && (
|
||||||
|
<FoodPage
|
||||||
|
places={places}
|
||||||
|
position={position}
|
||||||
|
onSelectPlace={handleSelectPlace}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Navigation activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
|
|
||||||
|
<PlaceDetails
|
||||||
|
place={places.find(p => p.id === detailsPlace?.id) || null}
|
||||||
|
onClose={handleCloseDetails}
|
||||||
|
onNavigate={handleNavigateToPlace}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
208
src/components/DayTimeline.tsx
Normal file
208
src/components/DayTimeline.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { Place } from '../types';
|
||||||
|
|
||||||
|
interface ScheduleItem {
|
||||||
|
time: string;
|
||||||
|
placeId?: number;
|
||||||
|
name: string;
|
||||||
|
note?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DaySchedule {
|
||||||
|
date: string;
|
||||||
|
dayOfWeek: string;
|
||||||
|
city: string;
|
||||||
|
title: string;
|
||||||
|
schedule: ScheduleItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayTimelineProps {
|
||||||
|
day: number;
|
||||||
|
dayData: DaySchedule;
|
||||||
|
places: Place[];
|
||||||
|
onSelectPlace: (place: Place) => void;
|
||||||
|
isToday?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeIcon = (type?: string, category?: string): string => {
|
||||||
|
if (type === 'transport') return '🚃';
|
||||||
|
if (type === 'lunch' || type === 'dinner') return '🍽️';
|
||||||
|
if (type === 'hotel') return '🏨';
|
||||||
|
if (type === 'onsen') return '♨️';
|
||||||
|
if (type === 'free') return '✨';
|
||||||
|
if (type === 'attraction') return '🎢';
|
||||||
|
if (category === 'sight') return '🏯';
|
||||||
|
if (category === 'restaurant') return '🍜';
|
||||||
|
if (category === 'coffee') return '☕';
|
||||||
|
if (category === 'snack') return '🍡';
|
||||||
|
if (category === 'hotel') return '🏨';
|
||||||
|
return '📍';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCityEmoji = (city: string): string => {
|
||||||
|
const cities: Record<string, string> = {
|
||||||
|
tokyo: '🗼',
|
||||||
|
kyoto: '⛩️',
|
||||||
|
osaka: '🏯',
|
||||||
|
nara: '🦌',
|
||||||
|
hiroshima: '🕊️',
|
||||||
|
miyajima: '⛩️',
|
||||||
|
hakone: '🗻',
|
||||||
|
nikko: '🌲',
|
||||||
|
himeji: '🏰',
|
||||||
|
};
|
||||||
|
return cities[city] || '📍';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (minutes: number): string => {
|
||||||
|
if (minutes < 60) return `${minutes}мин`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return mins > 0 ? `${hours}ч ${mins}мин` : `${hours}ч`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseDuration = (duration?: string): number => {
|
||||||
|
if (!duration) return 60;
|
||||||
|
const match = duration.match(/([\d.]+)[-–]?([\d.]+)?\s*(час|ч|hour|минут|мин)/i);
|
||||||
|
if (match) {
|
||||||
|
const val = parseFloat(match[2] || match[1]);
|
||||||
|
if (duration.includes('час') || duration.includes('hour') || duration.includes('ч')) {
|
||||||
|
return Math.round(val * 60);
|
||||||
|
}
|
||||||
|
return Math.round(val);
|
||||||
|
}
|
||||||
|
return 60;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEndTime = (startTime: string, durationMinutes: number): string => {
|
||||||
|
const [hours, minutes] = startTime.split(':').map(Number);
|
||||||
|
const totalMinutes = hours * 60 + minutes + durationMinutes;
|
||||||
|
const endHours = Math.floor(totalMinutes / 60) % 24;
|
||||||
|
const endMinutes = totalMinutes % 60;
|
||||||
|
return `${endHours.toString().padStart(2, '0')}:${endMinutes.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DayTimeline({ day, dayData, places, onSelectPlace, isToday }: DayTimelineProps) {
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
weekday: 'short'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
{/* Day Header */}
|
||||||
|
<div className="sticky top-0 bg-[#FAFAFA]/95 backdrop-blur-sm z-10 pb-3 pt-1">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<span className="text-2xl">{getCityEmoji(dayData.city)}</span>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">День {day}</h2>
|
||||||
|
{isToday && (
|
||||||
|
<span className="px-2 py-0.5 bg-[#FF6B6B] text-white text-xs font-medium rounded-full">
|
||||||
|
Сегодня
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">{formatDate(dayData.date)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-medium text-gray-700 ml-11">{dayData.title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline - используем grid для точного выравнивания */}
|
||||||
|
<div className="relative">
|
||||||
|
{dayData.schedule.map((item, index) => {
|
||||||
|
const place = item.placeId ? places.find(p => p.id === item.placeId) : null;
|
||||||
|
const duration = place ? parseDuration(place.duration) : 60;
|
||||||
|
const endTime = getEndTime(item.time, duration);
|
||||||
|
const icon = getTypeIcon(item.type, place?.category);
|
||||||
|
const isActivity = !item.type || item.type === 'attraction';
|
||||||
|
const isLast = index === dayData.schedule.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="grid grid-cols-[50px_20px_1fr] gap-2 items-start"
|
||||||
|
style={{ paddingBottom: isLast ? 0 : '16px' }}
|
||||||
|
>
|
||||||
|
{/* Time column */}
|
||||||
|
<div className="text-right pr-1">
|
||||||
|
<span className="text-sm font-semibold text-gray-900">{item.time}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline column with dot and line */}
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
{/* Вертикальная линия */}
|
||||||
|
{!isLast && (
|
||||||
|
<div
|
||||||
|
className="absolute top-4 bottom-0 w-0.5 bg-gray-200"
|
||||||
|
style={{ left: '50%', transform: 'translateX(-50%)' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Точка */}
|
||||||
|
<div
|
||||||
|
className={`w-4 h-4 rounded-full border-2 border-white shadow-sm z-10 ${
|
||||||
|
isActivity && place ? 'bg-[#FF6B6B]' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content column */}
|
||||||
|
<div className="min-w-0">
|
||||||
|
{place ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectPlace(place)}
|
||||||
|
className="w-full text-left bg-white rounded-xl p-3 shadow-sm border border-gray-100 card-hover"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-2xl">{icon}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<h4 className="font-semibold text-gray-900 truncate">{item.name}</h4>
|
||||||
|
{place.rating === 5 && <span className="text-yellow-500">★</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500 mb-1">
|
||||||
|
<span>{formatDuration(duration)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>до {endTime}</span>
|
||||||
|
{place.price && place.price !== 'Бесплатно' && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-[#FF6B6B]">{place.price}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.note && (
|
||||||
|
<p className="text-xs text-gray-400 line-clamp-1">{item.note}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<svg className="w-5 h-5 text-gray-300 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-50 rounded-xl p-3 border border-gray-100">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl opacity-70">{icon}</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-700">{item.name}</h4>
|
||||||
|
{item.note && (
|
||||||
|
<p className="text-xs text-gray-400">{item.note}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/Navigation.tsx
Normal file
47
src/components/Navigation.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { TabType } from '../types';
|
||||||
|
|
||||||
|
interface NavigationProps {
|
||||||
|
activeTab: TabType;
|
||||||
|
onTabChange: (tab: TabType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: { id: TabType; label: string; icon: string }[] = [
|
||||||
|
{ id: 'plan', label: 'Сегодня', icon: '📅' },
|
||||||
|
{ id: 'places', label: 'Дни', icon: '🗓️' },
|
||||||
|
{ id: 'map', label: 'Карта', icon: '🗺️' },
|
||||||
|
{ id: 'food', label: 'Еда', icon: '🍜' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Navigation({ activeTab, onTabChange }: NavigationProps) {
|
||||||
|
return (
|
||||||
|
<nav className="fixed bottom-0 left-0 right-0 bg-white/95 backdrop-blur-lg border-t border-gray-100 safe-bottom z-50">
|
||||||
|
<div className="flex justify-around items-center h-16 max-w-lg mx-auto px-2">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = activeTab === tab.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
className={"flex flex-col items-center justify-center flex-1 py-2 transition-all duration-200 " +
|
||||||
|
(isActive
|
||||||
|
? "text-[#FF6B6B]"
|
||||||
|
: "text-gray-400 hover:text-gray-600"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={"text-xl mb-0.5 transition-transform duration-200 " + (isActive ? "scale-110" : "")}>
|
||||||
|
{tab.icon}
|
||||||
|
</span>
|
||||||
|
<span className={"text-xs font-medium " + (isActive ? "font-semibold" : "")}>
|
||||||
|
{tab.label}
|
||||||
|
</span>
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute bottom-1 w-1 h-1 rounded-full bg-[#FF6B6B]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/components/PlaceCard.tsx
Normal file
103
src/components/PlaceCard.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Place } from '../types';
|
||||||
|
|
||||||
|
interface PlaceCardProps {
|
||||||
|
place: Place;
|
||||||
|
onClick: () => void;
|
||||||
|
showDay?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCategoryIcon = (category: string): string => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
sight: '🏯',
|
||||||
|
restaurant: '🍜',
|
||||||
|
coffee: '☕',
|
||||||
|
snack: '🍡',
|
||||||
|
hotel: '🏨',
|
||||||
|
};
|
||||||
|
return icons[category] || '📍';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryLabel = (category: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
sight: 'Достопримечательность',
|
||||||
|
restaurant: 'Ресторан',
|
||||||
|
coffee: 'Кофейня',
|
||||||
|
snack: 'Перекус',
|
||||||
|
hotel: 'Отель',
|
||||||
|
};
|
||||||
|
return labels[category] || 'Место';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlaceCard({ place, onClick, showDay = false, compact = false }: PlaceCardProps) {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex items-center gap-3 w-full p-3 bg-white rounded-xl shadow-sm border border-gray-100 card-hover text-left"
|
||||||
|
>
|
||||||
|
<span className="text-2xl">{getCategoryIcon(place.category)}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-900 truncate">{place.name}</h3>
|
||||||
|
<p className="text-xs text-gray-500">{place.nameJp}</p>
|
||||||
|
</div>
|
||||||
|
{place.rating === 5 && <span className="text-yellow-500">★</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-full text-left bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden card-hover"
|
||||||
|
>
|
||||||
|
{/* Image placeholder */}
|
||||||
|
<div className="h-32 bg-gradient-to-br from-gray-100 to-gray-200 relative">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-5xl opacity-50">{getCategoryIcon(place.category)}</span>
|
||||||
|
</div>
|
||||||
|
{place.rating === 5 && (
|
||||||
|
<div className="absolute top-2 right-2 bg-white/90 backdrop-blur-sm px-2 py-1 rounded-full">
|
||||||
|
<span className="text-yellow-500 text-sm">★ Must See</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showDay && place.day && (
|
||||||
|
<div className="absolute top-2 left-2 bg-[#FF6B6B] text-white px-2 py-1 rounded-full text-xs font-medium">
|
||||||
|
День {place.day}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-gray-900 text-lg leading-tight">{place.name}</h3>
|
||||||
|
<p className="text-sm text-gray-400">{place.nameJp}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 line-clamp-2 mb-3">{place.description}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span className="px-2 py-1 bg-gray-100 rounded-full text-gray-600">
|
||||||
|
{getCategoryLabel(place.category)}
|
||||||
|
</span>
|
||||||
|
{place.duration && (
|
||||||
|
<span className="px-2 py-1 bg-gray-100 rounded-full text-gray-600">
|
||||||
|
⏱️ {place.duration}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{place.price && (
|
||||||
|
<span className={"px-2 py-1 rounded-full " +
|
||||||
|
(place.price === 'Бесплатно'
|
||||||
|
? "bg-green-50 text-green-600"
|
||||||
|
: "bg-[#FFE8E8] text-[#FF6B6B]")
|
||||||
|
}>
|
||||||
|
{place.price === 'Бесплатно' ? '✓ Бесплатно' : place.price}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
src/components/PlaceDetails.tsx
Normal file
197
src/components/PlaceDetails.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { Place } from '../types';
|
||||||
|
|
||||||
|
interface PlaceDetailsProps {
|
||||||
|
place: Place | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onNavigate: (place: Place) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCategoryIcon = (category: string): string => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
sight: '🏯',
|
||||||
|
restaurant: '🍜',
|
||||||
|
coffee: '☕',
|
||||||
|
snack: '🍡',
|
||||||
|
hotel: '🏨',
|
||||||
|
};
|
||||||
|
return icons[category] || '📍';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlaceDetails({ place, onClose, onNavigate }: PlaceDetailsProps) {
|
||||||
|
if (!place) return null;
|
||||||
|
|
||||||
|
const openGoogleMaps = () => {
|
||||||
|
const url = `https://www.google.com/maps/dir/?api=1&destination=${place.lat},${place.lng}&destination_place_id=${encodeURIComponent(place.name)}`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 animate-fade-in"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-50 max-h-[85vh] animate-slide-up">
|
||||||
|
<div className="bg-white rounded-t-3xl shadow-2xl overflow-hidden">
|
||||||
|
{/* Handle */}
|
||||||
|
<div className="flex justify-center pt-3 pb-2">
|
||||||
|
<div className="w-10 h-1 bg-gray-300 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header image/placeholder */}
|
||||||
|
<div className="h-40 bg-gradient-to-br from-gray-100 to-gray-200 relative">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-7xl opacity-40">{getCategoryIcon(place.category)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-3 right-3 w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center shadow-sm"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{place.rating === 5 && (
|
||||||
|
<div className="absolute bottom-3 left-3 bg-white/90 backdrop-blur-sm px-3 py-1.5 rounded-full">
|
||||||
|
<span className="text-yellow-500 font-medium">★ Must See</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-5 pb-8 overflow-y-auto max-h-[calc(85vh-200px)] scrollbar-hide">
|
||||||
|
{/* Title */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-1">{place.name}</h2>
|
||||||
|
<p className="text-lg text-gray-400">{place.nameJp}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick info */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{place.day && (
|
||||||
|
<span className="px-3 py-1.5 bg-[#FF6B6B] text-white rounded-full text-sm font-medium">
|
||||||
|
📅 День {place.day}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{place.duration && (
|
||||||
|
<span className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-full text-sm">
|
||||||
|
⏱️ {place.duration}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{place.price && (
|
||||||
|
<span className={"px-3 py-1.5 rounded-full text-sm " +
|
||||||
|
(place.price === 'Бесплатно'
|
||||||
|
? "bg-green-50 text-green-700"
|
||||||
|
: "bg-[#FFE8E8] text-[#FF6B6B]")
|
||||||
|
}>
|
||||||
|
{place.price === 'Бесплатно' ? '✓ Бесплатно' : `💰 ${place.price}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-gray-700 leading-relaxed mb-5">{place.description}</p>
|
||||||
|
|
||||||
|
{/* Details grid */}
|
||||||
|
<div className="space-y-4 mb-5">
|
||||||
|
{place.bestTime && (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-xl">🌅</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 uppercase tracking-wide">Лучшее время</p>
|
||||||
|
<p className="text-gray-900 font-medium">{place.bestTime}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{place.hours && (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-xl">🕐</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 uppercase tracking-wide">Часы работы</p>
|
||||||
|
<p className="text-gray-900 font-medium">{place.hours}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{place.address && (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-xl">📍</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 uppercase tracking-wide">Адрес</p>
|
||||||
|
<p className="text-gray-900 font-medium">{place.address}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
{place.history && (
|
||||||
|
<div className="mb-5">
|
||||||
|
<h3 className="text-sm font-bold text-gray-900 uppercase tracking-wide mb-2">📜 История</h3>
|
||||||
|
<p className="text-gray-600 text-sm leading-relaxed">{place.history}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Facts */}
|
||||||
|
{place.facts && place.facts.length > 0 && (
|
||||||
|
<div className="mb-5">
|
||||||
|
<h3 className="text-sm font-bold text-gray-900 uppercase tracking-wide mb-2">💡 Интересные факты</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{place.facts.map((fact, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
|
||||||
|
<span className="text-[#FF6B6B] mt-0.5">•</span>
|
||||||
|
{fact}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
{(place.tips || place.localTips) && (
|
||||||
|
<div className="mb-5 p-4 bg-[#FFF9E6] rounded-xl border border-yellow-100">
|
||||||
|
<h3 className="text-sm font-bold text-yellow-800 uppercase tracking-wide mb-2">💡 Советы</h3>
|
||||||
|
<p className="text-yellow-900 text-sm">{place.tips || place.localTips}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Photo spots */}
|
||||||
|
{place.photoSpots && (
|
||||||
|
<div className="mb-5 p-4 bg-purple-50 rounded-xl border border-purple-100">
|
||||||
|
<h3 className="text-sm font-bold text-purple-800 uppercase tracking-wide mb-2">📸 Фото-споты</h3>
|
||||||
|
<p className="text-purple-900 text-sm">{place.photoSpots}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="p-4 pt-0 pb-6 flex gap-3 safe-bottom">
|
||||||
|
<button
|
||||||
|
onClick={openGoogleMaps}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 bg-[#FF6B6B] text-white py-3.5 rounded-xl font-semibold shadow-lg shadow-red-200 active:scale-98 transition-transform"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
Навигация
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate(place)}
|
||||||
|
className="flex items-center justify-center gap-2 bg-gray-100 text-gray-700 px-5 py-3.5 rounded-xl font-semibold active:scale-98 transition-transform"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||||
|
</svg>
|
||||||
|
На карте
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/TripProgress.tsx
Normal file
43
src/components/TripProgress.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
interface TripProgressProps {
|
||||||
|
currentDay: number;
|
||||||
|
totalDays: number;
|
||||||
|
tripStartDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TripProgress({ currentDay, totalDays, tripStartDate }: TripProgressProps) {
|
||||||
|
const progress = Math.min((currentDay / totalDays) * 100, 100);
|
||||||
|
const startDate = new Date(tripStartDate);
|
||||||
|
const endDate = new Date(startDate);
|
||||||
|
endDate.setDate(endDate.getDate() + totalDays - 1);
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl p-4 shadow-sm border border-gray-100">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-2xl font-bold text-gray-900">День {currentDay}</span>
|
||||||
|
<span className="text-gray-400 ml-1">из {totalDays}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[#FF6B6B]">
|
||||||
|
<span className="text-xl">🇯🇵</span>
|
||||||
|
<span className="text-sm font-medium">Japan Trip</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-2 bg-gray-100 rounded-full overflow-hidden mb-2">
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 h-full bg-gradient-to-r from-[#FF6B6B] to-[#FF8E8E] rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-xs text-gray-400">
|
||||||
|
<span>{formatDate(startDate)}</span>
|
||||||
|
<span>{formatDate(endDate)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1831
src/data/places.json
Normal file
1831
src/data/places.json
Normal file
File diff suppressed because it is too large
Load Diff
735
src/data/places.json.bak
Normal file
735
src/data/places.json.bak
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Сэнсодзи",
|
||||||
|
"nameJp": "浅草寺",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.7148,
|
||||||
|
"lng": 139.7967,
|
||||||
|
"description": "Древнейший храм Токио (645 г.), знаменитые ворота Каминаримон с гигантским фонарём. 20 млн паломников в год!",
|
||||||
|
"address": "2-3-1 Asakusa, Taito City, Tokyo",
|
||||||
|
"hours": "6:00-17:00",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 1,
|
||||||
|
"links": ["https://www.senso-ji.jp/"],
|
||||||
|
"tips": "Приходите к 5:45 на утреннюю церемонию"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "Парк Уэно",
|
||||||
|
"nameJp": "上野公園",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.7146,
|
||||||
|
"lng": 139.7732,
|
||||||
|
"description": "Первый европейский парк Японии (1873), пруд Синобадзу, 1200 деревьев сакуры. Рай для отдыха!",
|
||||||
|
"address": "Uenokoen, Taito City, Tokyo",
|
||||||
|
"hours": "5:00-23:00",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 4,
|
||||||
|
"day": 1,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "Tokyo Skytree",
|
||||||
|
"nameJp": "東京スカイツリー",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.7101,
|
||||||
|
"lng": 139.8107,
|
||||||
|
"description": "Самая высокая телебашня мира (634м). Смотровые на 350м и 450м с панорамой на Фудзи!",
|
||||||
|
"address": "1-1-2 Oshiage, Sumida City, Tokyo",
|
||||||
|
"hours": "8:00-22:00",
|
||||||
|
"price": "¥2100-3100",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 1,
|
||||||
|
"links": ["https://www.tokyo-skytree.jp/"],
|
||||||
|
"tips": "Приходите за час до заката"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "Акихабара",
|
||||||
|
"nameJp": "秋葉原",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.7023,
|
||||||
|
"lng": 139.7745,
|
||||||
|
"description": "Район электроники и отаку-культуры. 500+ магазинов техники, аниме, мейд-кафе!",
|
||||||
|
"address": "Akihabara, Chiyoda City, Tokyo",
|
||||||
|
"hours": "10:00-20:00",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 4,
|
||||||
|
"day": 1,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"name": "Храм Мэйдзи",
|
||||||
|
"nameJp": "明治神宮",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.6764,
|
||||||
|
"lng": 139.6993,
|
||||||
|
"description": "Синтоистский храм в честь императора Мэйдзи. 100,000 деревьев, священный лес в центре мегаполиса.",
|
||||||
|
"address": "1-1 Yoyogikamizonocho, Shibuya City, Tokyo",
|
||||||
|
"hours": "Рассвет-закат",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 2,
|
||||||
|
"links": ["https://www.meijijingu.or.jp/"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"name": "Улица Такэсита",
|
||||||
|
"nameJp": "竹下通り",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.6718,
|
||||||
|
"lng": 139.7028,
|
||||||
|
"description": "Мекка молодёжной моды в Харадзюку. Крепы, косплей, необычные магазины!",
|
||||||
|
"address": "Takeshita Street, Shibuya, Tokyo",
|
||||||
|
"hours": "10:00-20:00",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 4,
|
||||||
|
"day": 2,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"name": "Сибуя — перекрёсток",
|
||||||
|
"nameJp": "渋谷スクランブル交差点",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.6595,
|
||||||
|
"lng": 139.7004,
|
||||||
|
"description": "Самый загруженный перекрёсток мира! До 3000 человек одновременно переходят улицу.",
|
||||||
|
"address": "Shibuya Scramble Crossing, Tokyo",
|
||||||
|
"hours": "24/7",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 2,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"name": "Shibuya Sky",
|
||||||
|
"nameJp": "渋谷スカイ",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.6584,
|
||||||
|
"lng": 139.7022,
|
||||||
|
"description": "Смотровая на крыше Shibuya Scramble Square (230м). Потрясающий вид на закат!",
|
||||||
|
"address": "2-24-12 Shibuya, Shibuya City, Tokyo",
|
||||||
|
"hours": "10:00-22:30",
|
||||||
|
"price": "¥2000",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 2,
|
||||||
|
"links": ["https://www.shibuya-scramble-square.com/sky/"],
|
||||||
|
"tips": "Бронируйте онлайн заранее!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"name": "Синдзюку Гёэн",
|
||||||
|
"nameJp": "新宿御苑",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.6852,
|
||||||
|
"lng": 139.7100,
|
||||||
|
"description": "Императорский сад с тремя стилями: японский, французский, английский. Лучшее место для сакуры!",
|
||||||
|
"address": "11 Naitomachi, Shinjuku City, Tokyo",
|
||||||
|
"hours": "9:00-16:30",
|
||||||
|
"price": "¥500",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 2,
|
||||||
|
"links": ["https://fng.or.jp/shinjuku/"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"name": "Цукидзи",
|
||||||
|
"nameJp": "築地場外市場",
|
||||||
|
"category": "restaurant",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.6654,
|
||||||
|
"lng": 139.7707,
|
||||||
|
"description": "Легендарный рыбный рынок. Свежайшие суши, тамаго-сандо, морепродукты на завтрак!",
|
||||||
|
"address": "Tsukiji, Chuo City, Tokyo",
|
||||||
|
"hours": "5:00-14:00",
|
||||||
|
"price": "¥1500-3000",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 3,
|
||||||
|
"links": [],
|
||||||
|
"tips": "Приходите рано утром"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"name": "teamLab Planets",
|
||||||
|
"nameJp": "チームラボ プラネッツ",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.6427,
|
||||||
|
"lng": 139.7889,
|
||||||
|
"description": "Иммерсивное цифровое искусство — ходите босиком по воде и свету! Незабываемый опыт.",
|
||||||
|
"address": "6-1-16 Toyosu, Koto City, Tokyo",
|
||||||
|
"hours": "9:00-22:00",
|
||||||
|
"price": "¥3200",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 3,
|
||||||
|
"links": ["https://planets.teamlab.art/"],
|
||||||
|
"tips": "Бронируйте за 2 недели!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"name": "Одайба",
|
||||||
|
"nameJp": "お台場",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.6267,
|
||||||
|
"lng": 139.7768,
|
||||||
|
"description": "Футуристический остров: Радужный мост, статуя Gundam в натуральную величину, ночные огни залива.",
|
||||||
|
"address": "Odaiba, Minato City, Tokyo",
|
||||||
|
"hours": "24/7",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 4,
|
||||||
|
"day": 3,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"name": "Императорский дворец",
|
||||||
|
"nameJp": "皇居",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.6852,
|
||||||
|
"lng": 139.7528,
|
||||||
|
"description": "Резиденция императора Японии. Восточные сады бесплатны, экскурсии по бронированию.",
|
||||||
|
"address": "1-1 Chiyoda, Chiyoda City, Tokyo",
|
||||||
|
"hours": "9:00-16:00 (сады)",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 4,
|
||||||
|
"day": 3,
|
||||||
|
"links": ["https://sankan.kunaicho.go.jp/"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"name": "Никко Тосё-гу",
|
||||||
|
"nameJp": "日光東照宮",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 36.7580,
|
||||||
|
"lng": 139.5988,
|
||||||
|
"description": "Роскошный храмовый комплекс UNESCO, мавзолей сёгуна Токугава Иэясу. Знаменитые \"три обезьяны\"!",
|
||||||
|
"address": "2301 Sannai, Nikko, Tochigi",
|
||||||
|
"hours": "8:00-17:00",
|
||||||
|
"price": "¥1300",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 4,
|
||||||
|
"links": ["https://www.toshogu.jp/"],
|
||||||
|
"tips": "2 часа от Токио на поезде"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"name": "Водопад Кэгон",
|
||||||
|
"nameJp": "華厳の滝",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 36.7383,
|
||||||
|
"lng": 139.5006,
|
||||||
|
"description": "Один из красивейших водопадов Японии (97м). Смотровая площадка на лифте вниз!",
|
||||||
|
"address": "Chugushi, Nikko, Tochigi",
|
||||||
|
"hours": "8:00-17:00",
|
||||||
|
"price": "¥570",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 4,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"name": "Овакудани",
|
||||||
|
"nameJp": "大涌谷",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "hakone",
|
||||||
|
"lat": 35.2429,
|
||||||
|
"lng": 139.0208,
|
||||||
|
"description": "Вулканическая долина с горячими источниками. Чёрные яйца +7 лет жизни! Вид на Фудзи.",
|
||||||
|
"address": "Owakudani, Hakone, Kanagawa",
|
||||||
|
"hours": "9:00-17:00",
|
||||||
|
"price": "¥1500 (канатка)",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 5,
|
||||||
|
"links": [],
|
||||||
|
"tips": "Приезжайте в ясную погоду для вида на Фудзи"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"name": "Озеро Аси",
|
||||||
|
"nameJp": "芦ノ湖",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "hakone",
|
||||||
|
"lat": 35.1953,
|
||||||
|
"lng": 139.0229,
|
||||||
|
"description": "Кратерное озеро с видом на Фудзи. Прогулка на \"пиратском корабле\" — must do!",
|
||||||
|
"address": "Lake Ashi, Hakone, Kanagawa",
|
||||||
|
"hours": "9:30-17:00",
|
||||||
|
"price": "¥1000 (корабль)",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 6,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"name": "Храм Хаконэ-дзиндзя",
|
||||||
|
"nameJp": "箱根神社",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "hakone",
|
||||||
|
"lat": 35.2042,
|
||||||
|
"lng": 139.0228,
|
||||||
|
"description": "Древний храм у озера Аси (757 г.). Знаменитые красные тории в воде!",
|
||||||
|
"address": "80-1 Motohakone, Hakone, Kanagawa",
|
||||||
|
"hours": "24/7",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 6,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"name": "Фусими Инари",
|
||||||
|
"nameJp": "伏見稲荷大社",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "kyoto",
|
||||||
|
"lat": 34.9671,
|
||||||
|
"lng": 135.7727,
|
||||||
|
"description": "10,000+ красных тории! Путь на вершину 2-3 часа. Самый фотогеничный храм Японии.",
|
||||||
|
"address": "68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto",
|
||||||
|
"hours": "24/7",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 7,
|
||||||
|
"links": ["http://inari.jp/"],
|
||||||
|
"tips": "Приходите к 7:00 — почти никого нет!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"name": "Кийомидзу-дэра",
|
||||||
|
"nameJp": "清水寺",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "kyoto",
|
||||||
|
"lat": 34.9949,
|
||||||
|
"lng": 135.7850,
|
||||||
|
"description": "Деревянный храм на сваях (778 г.), БЕЗ ЕДИНОГО ГВОЗДЯ! UNESCO, вид на весь Киото.",
|
||||||
|
"address": "1-294 Kiyomizu, Higashiyama Ward, Kyoto",
|
||||||
|
"hours": "6:00-18:00",
|
||||||
|
"price": "¥400",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 7,
|
||||||
|
"links": ["https://www.kiyomizudera.or.jp/"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"name": "Гион",
|
||||||
|
"nameJp": "祇園",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "kyoto",
|
||||||
|
"lat": 35.0037,
|
||||||
|
"lng": 135.7756,
|
||||||
|
"description": "Легендарный квартал гейш. Вечером шанс увидеть настоящих майко! Атмосфера старой Японии.",
|
||||||
|
"address": "Gion, Higashiyama Ward, Kyoto",
|
||||||
|
"hours": "Гейши 17:00-19:00",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 7,
|
||||||
|
"links": [],
|
||||||
|
"tips": "НЕ бегите за гейшами с камерой!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"name": "Бамбуковый лес Арасияма",
|
||||||
|
"nameJp": "嵐山竹林",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "kyoto",
|
||||||
|
"lat": 35.0173,
|
||||||
|
"lng": 135.6717,
|
||||||
|
"description": "Магический бамбуковый лес — звук ветра в стеблях признан сокровищем Японии!",
|
||||||
|
"address": "Sagaogurayama, Ukyo Ward, Kyoto",
|
||||||
|
"hours": "24/7",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 8,
|
||||||
|
"links": [],
|
||||||
|
"tips": "Приходите к 7 утра"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"name": "Кинкаку-дзи (Золотой павильон)",
|
||||||
|
"nameJp": "金閣寺",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "kyoto",
|
||||||
|
"lat": 35.0394,
|
||||||
|
"lng": 135.7292,
|
||||||
|
"description": "Самый фотографируемый храм Японии! 3 этажа покрыты настоящим золотом (5 кг).",
|
||||||
|
"address": "1 Kinkakujicho, Kita Ward, Kyoto",
|
||||||
|
"hours": "9:00-17:00",
|
||||||
|
"price": "¥400",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 8,
|
||||||
|
"links": ["https://www.shokoku-ji.jp/kinkakuji/"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 24,
|
||||||
|
"name": "Рёан-дзи",
|
||||||
|
"nameJp": "龍安寺",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "kyoto",
|
||||||
|
"lat": 35.0345,
|
||||||
|
"lng": 135.7182,
|
||||||
|
"description": "Самый знаменитый сад камней в мире. 15 камней, которые невозможно увидеть все сразу!",
|
||||||
|
"address": "13 Ryoanji Goryonoshitacho, Ukyo Ward, Kyoto",
|
||||||
|
"hours": "8:00-17:00",
|
||||||
|
"price": "¥500",
|
||||||
|
"rating": 4,
|
||||||
|
"day": 8,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 25,
|
||||||
|
"name": "Гинкаку-дзи (Серебряный павильон)",
|
||||||
|
"nameJp": "銀閣寺",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "kyoto",
|
||||||
|
"lat": 35.0271,
|
||||||
|
"lng": 135.7982,
|
||||||
|
"description": "Изысканный дзен-храм с садом. Начало Философского пути. Серебра нет, но красота есть!",
|
||||||
|
"address": "2 Ginkakujicho, Sakyo Ward, Kyoto",
|
||||||
|
"hours": "8:30-17:00",
|
||||||
|
"price": "¥500",
|
||||||
|
"rating": 4,
|
||||||
|
"day": 9,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 26,
|
||||||
|
"name": "Философский путь",
|
||||||
|
"nameJp": "哲学の道",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "kyoto",
|
||||||
|
"lat": 35.0200,
|
||||||
|
"lng": 135.7950,
|
||||||
|
"description": "2 км вдоль канала под вишнями. Идеален для медитативной прогулки!",
|
||||||
|
"address": "Tetsugaku no Michi, Sakyo Ward, Kyoto",
|
||||||
|
"hours": "24/7",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 9,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 27,
|
||||||
|
"name": "Нисики Маркет",
|
||||||
|
"nameJp": "錦市場",
|
||||||
|
"category": "restaurant",
|
||||||
|
"city": "kyoto",
|
||||||
|
"lat": 35.0050,
|
||||||
|
"lng": 135.7647,
|
||||||
|
"description": "\"Кухня Киото\" — 400-летний рынок. Маття мороженое, тамаго, моти, цукемоно!",
|
||||||
|
"address": "Nishiki Market, Nakagyo Ward, Kyoto",
|
||||||
|
"hours": "9:00-18:00",
|
||||||
|
"price": "¥500-2000",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 9,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 28,
|
||||||
|
"name": "Замок Нидзо",
|
||||||
|
"nameJp": "二条城",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "kyoto",
|
||||||
|
"lat": 35.0142,
|
||||||
|
"lng": 135.7481,
|
||||||
|
"description": "Резиденция сёгунов Токугава. \"Поющие полы\" предупреждали о ниндзя! UNESCO.",
|
||||||
|
"address": "541 Nijojocho, Nakagyo Ward, Kyoto",
|
||||||
|
"hours": "8:45-17:00",
|
||||||
|
"price": "¥1000",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 11,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 29,
|
||||||
|
"name": "Нара Парк",
|
||||||
|
"nameJp": "奈良公園",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "nara",
|
||||||
|
"lat": 34.6851,
|
||||||
|
"lng": 135.8430,
|
||||||
|
"description": "1200+ священных оленей свободно гуляют по парку! Можно покормить и сфотографироваться.",
|
||||||
|
"address": "Nara Park, Nara",
|
||||||
|
"hours": "24/7",
|
||||||
|
"price": "Бесплатно (корм ¥200)",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 10,
|
||||||
|
"links": [],
|
||||||
|
"tips": "Олени кусаются, если дразнить едой"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 30,
|
||||||
|
"name": "Тодай-дзи",
|
||||||
|
"nameJp": "東大寺",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "nara",
|
||||||
|
"lat": 34.6890,
|
||||||
|
"lng": 135.8399,
|
||||||
|
"description": "Гигантский бронзовый Будда (15м) в самом большом деревянном здании мира!",
|
||||||
|
"address": "406-1 Zoshicho, Nara",
|
||||||
|
"hours": "7:30-17:30",
|
||||||
|
"price": "¥600",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 10,
|
||||||
|
"links": ["https://www.todaiji.or.jp/"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 31,
|
||||||
|
"name": "Касуга-тайся",
|
||||||
|
"nameJp": "春日大社",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "nara",
|
||||||
|
"lat": 34.6809,
|
||||||
|
"lng": 135.8490,
|
||||||
|
"description": "3000 каменных и бронзовых фонарей! Дважды в год зажигают все — магическое зрелище.",
|
||||||
|
"address": "160 Kasuganocho, Nara",
|
||||||
|
"hours": "6:30-17:30",
|
||||||
|
"price": "¥500",
|
||||||
|
"rating": 4,
|
||||||
|
"day": 10,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 32,
|
||||||
|
"name": "Замок в Осаке",
|
||||||
|
"nameJp": "大阪城",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "osaka",
|
||||||
|
"lat": 34.6873,
|
||||||
|
"lng": 135.5262,
|
||||||
|
"description": "Символ Осаки (1583). Музей внутри, смотровая на 8 этаже с панорамой города!",
|
||||||
|
"address": "1-1 Osakajo, Chuo Ward, Osaka",
|
||||||
|
"hours": "9:00-17:00",
|
||||||
|
"price": "¥600",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 12,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 33,
|
||||||
|
"name": "Куромон Маркет",
|
||||||
|
"nameJp": "黒門市場",
|
||||||
|
"category": "restaurant",
|
||||||
|
"city": "osaka",
|
||||||
|
"lat": 34.6672,
|
||||||
|
"lng": 135.5065,
|
||||||
|
"description": "\"Кухня Осаки\" — вагю, тунец, фугу, устрицы! Лучший стрит-фуд в городе.",
|
||||||
|
"address": "Kuromon Market, Chuo Ward, Osaka",
|
||||||
|
"hours": "9:00-18:00",
|
||||||
|
"price": "¥1000-5000",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 12,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 34,
|
||||||
|
"name": "Дотонбори",
|
||||||
|
"nameJp": "道頓堀",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "osaka",
|
||||||
|
"lat": 34.6687,
|
||||||
|
"lng": 135.5013,
|
||||||
|
"description": "Неоновое сердце Осаки! Вывеска Glico Man, такояки, гёдза, ночная жизнь.",
|
||||||
|
"address": "Dotonbori, Chuo Ward, Osaka",
|
||||||
|
"hours": "24/7",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 12,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 35,
|
||||||
|
"name": "Universal Studios Japan",
|
||||||
|
"nameJp": "ユニバーサル・スタジオ・ジャパン",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "osaka",
|
||||||
|
"lat": 34.6654,
|
||||||
|
"lng": 135.4323,
|
||||||
|
"description": "Super Nintendo World + Harry Potter! Полный день приключений.",
|
||||||
|
"address": "2-1-33 Sakurajima, Konohana Ward, Osaka",
|
||||||
|
"hours": "9:00-21:00",
|
||||||
|
"price": "¥8600",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 13,
|
||||||
|
"links": ["https://www.usj.co.jp/"],
|
||||||
|
"tips": "Билеты уже куплены! 🎟️"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 36,
|
||||||
|
"name": "Замок Химэдзи",
|
||||||
|
"nameJp": "姫路城",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "osaka",
|
||||||
|
"lat": 34.8394,
|
||||||
|
"lng": 134.6939,
|
||||||
|
"description": "Самый красивый замок Японии — \"Белая цапля\". 400 лет без разрушений! UNESCO.",
|
||||||
|
"address": "68 Honmachi, Himeji, Hyogo",
|
||||||
|
"hours": "9:00-17:00",
|
||||||
|
"price": "¥1000",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 14,
|
||||||
|
"links": [],
|
||||||
|
"tips": "45 мин от Осаки"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 37,
|
||||||
|
"name": "Синсэкай",
|
||||||
|
"nameJp": "新世界",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "osaka",
|
||||||
|
"lat": 34.6524,
|
||||||
|
"lng": 135.5063,
|
||||||
|
"description": "Ретро-район с башней Цутэнкаку. Родина кусикацу! Атмосфера Осаки 60-х.",
|
||||||
|
"address": "Shinsekai, Naniwa Ward, Osaka",
|
||||||
|
"hours": "24/7",
|
||||||
|
"price": "Бесплатно",
|
||||||
|
"rating": 4,
|
||||||
|
"day": 14,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 38,
|
||||||
|
"name": "Мемориал мира",
|
||||||
|
"nameJp": "広島平和記念公園",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "hiroshima",
|
||||||
|
"lat": 34.3955,
|
||||||
|
"lng": 132.4536,
|
||||||
|
"description": "Атомный купол, Мемориальный музей. Тяжело, но важно. Заложите 2-3 часа.",
|
||||||
|
"address": "1-2 Nakajimacho, Naka Ward, Hiroshima",
|
||||||
|
"hours": "8:30-18:00",
|
||||||
|
"price": "¥200",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 15,
|
||||||
|
"links": ["https://hpmmuseum.jp/"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 39,
|
||||||
|
"name": "Святилище Ицукусима",
|
||||||
|
"nameJp": "厳島神社",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "hiroshima",
|
||||||
|
"lat": 34.2960,
|
||||||
|
"lng": 132.3198,
|
||||||
|
"description": "Иконические красные тории в воде! UNESCO, один из самых красивых видов Японии.",
|
||||||
|
"address": "1-1 Miyajimacho, Hatsukaichi, Hiroshima",
|
||||||
|
"hours": "6:30-18:00",
|
||||||
|
"price": "¥300",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 16,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 40,
|
||||||
|
"name": "Гора Мисэн",
|
||||||
|
"nameJp": "弥山",
|
||||||
|
"category": "sight",
|
||||||
|
"city": "hiroshima",
|
||||||
|
"lat": 34.2803,
|
||||||
|
"lng": 132.3158,
|
||||||
|
"description": "Священная гора острова Миядзима (535м). Канатка или 2 часа пешком. Потрясающие виды!",
|
||||||
|
"address": "Mount Misen, Miyajima, Hiroshima",
|
||||||
|
"hours": "9:00-17:00 (канатка)",
|
||||||
|
"price": "¥1800",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 16,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 41,
|
||||||
|
"name": "Daikokuya Tempura",
|
||||||
|
"nameJp": "大黒家天麩羅",
|
||||||
|
"category": "restaurant",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.7134,
|
||||||
|
"lng": 139.7945,
|
||||||
|
"description": "Легендарная темпура с 1887 года! Хрустящие креветки и овощи.",
|
||||||
|
"address": "1-38-10 Asakusa, Taito City, Tokyo",
|
||||||
|
"hours": "11:00-20:30",
|
||||||
|
"price": "¥2000",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 1,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"name": "Ichiran Ramen",
|
||||||
|
"nameJp": "一蘭",
|
||||||
|
"category": "restaurant",
|
||||||
|
"city": "tokyo",
|
||||||
|
"lat": 35.6598,
|
||||||
|
"lng": 139.6998,
|
||||||
|
"description": "Персональные кабинки для рамена! Тонкоцу бульон, выбираешь крепость, масло, чеснок.",
|
||||||
|
"address": "1-22-7 Jinnan, Shibuya City, Tokyo",
|
||||||
|
"hours": "24/7",
|
||||||
|
"price": "¥1200",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 2,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 43,
|
||||||
|
"name": "Okonomimura",
|
||||||
|
"nameJp": "お好み村",
|
||||||
|
"category": "restaurant",
|
||||||
|
"city": "hiroshima",
|
||||||
|
"lat": 34.3942,
|
||||||
|
"lng": 132.4572,
|
||||||
|
"description": "Трёхэтажный комплекс с 25 магазинами окономияки! Хиросимский стиль с лапшой.",
|
||||||
|
"address": "5-13 Shintenchi, Naka Ward, Hiroshima",
|
||||||
|
"hours": "11:00-21:00",
|
||||||
|
"price": "¥1000-1500",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 15,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 44,
|
||||||
|
"name": "Daruma Kushikatsu",
|
||||||
|
"nameJp": "串カツだるま",
|
||||||
|
"category": "restaurant",
|
||||||
|
"city": "osaka",
|
||||||
|
"lat": 34.6687,
|
||||||
|
"lng": 135.5018,
|
||||||
|
"description": "Знаменитые кусикацу с 1929 года! Панированные шашлычки, НЕ макать дважды в соус!",
|
||||||
|
"address": "2-3-9 Dotonbori, Chuo Ward, Osaka",
|
||||||
|
"hours": "11:00-22:30",
|
||||||
|
"price": "¥2000",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 12,
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 45,
|
||||||
|
"name": "Takoyaki Wanaka",
|
||||||
|
"nameJp": "たこ焼き わなか",
|
||||||
|
"category": "restaurant",
|
||||||
|
"city": "osaka",
|
||||||
|
"lat": 34.6685,
|
||||||
|
"lng": 135.5008,
|
||||||
|
"description": "Лучшие такояки в Осаке! Шарики с осьминогом, хрустящие снаружи, кремовые внутри.",
|
||||||
|
"address": "Dotonbori, Chuo Ward, Osaka",
|
||||||
|
"hours": "10:00-23:00",
|
||||||
|
"price": "¥600",
|
||||||
|
"rating": 5,
|
||||||
|
"day": 12,
|
||||||
|
"links": []
|
||||||
|
}
|
||||||
|
]
|
||||||
1039
src/data/schedule.json
Normal file
1039
src/data/schedule.json
Normal file
File diff suppressed because it is too large
Load Diff
64
src/hooks/useGeolocation.ts
Normal file
64
src/hooks/useGeolocation.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { GeoPosition } from "../types";
|
||||||
|
|
||||||
|
export function useGeolocation() {
|
||||||
|
const [position, setPosition] = useState<GeoPosition | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const getCurrentPosition = useCallback(() => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
setError("Геолокация не поддерживается");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
setPosition({
|
||||||
|
lat: pos.coords.latitude,
|
||||||
|
lng: pos.coords.longitude,
|
||||||
|
accuracy: pos.coords.accuracy
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 10000,
|
||||||
|
maximumAge: 60000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCurrentPosition();
|
||||||
|
|
||||||
|
const watchId = navigator.geolocation?.watchPosition(
|
||||||
|
(pos) => {
|
||||||
|
setPosition({
|
||||||
|
lat: pos.coords.latitude,
|
||||||
|
lng: pos.coords.longitude,
|
||||||
|
accuracy: pos.coords.accuracy
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 30000,
|
||||||
|
maximumAge: 10000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (watchId) navigator.geolocation.clearWatch(watchId);
|
||||||
|
};
|
||||||
|
}, [getCurrentPosition]);
|
||||||
|
|
||||||
|
return { position, error, loading, refresh: getCurrentPosition };
|
||||||
|
}
|
||||||
55
src/hooks/usePlaces.ts
Normal file
55
src/hooks/usePlaces.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Place, GeoPosition } from "../types";
|
||||||
|
import placesData from "../data/places.json";
|
||||||
|
|
||||||
|
function getDistance(p1: GeoPosition, p2: { lat: number; lng: number }): number {
|
||||||
|
const R = 6371; // Earth radius in km
|
||||||
|
const dLat = ((p2.lat - p1.lat) * Math.PI) / 180;
|
||||||
|
const dLng = ((p2.lng - p1.lng) * Math.PI) / 180;
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos((p1.lat * Math.PI) / 180) *
|
||||||
|
Math.cos((p2.lat * Math.PI) / 180) *
|
||||||
|
Math.sin(dLng / 2) *
|
||||||
|
Math.sin(dLng / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDistance(km: number): string {
|
||||||
|
if (km < 1) return `${Math.round(km * 1000)}м`;
|
||||||
|
return `${km.toFixed(1)}км`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlaces(position: GeoPosition | null) {
|
||||||
|
const places = placesData as Place[];
|
||||||
|
|
||||||
|
const placesWithDistance = useMemo(() => {
|
||||||
|
if (!position) return places.map(p => ({ ...p, distance: undefined }));
|
||||||
|
|
||||||
|
return places.map(place => ({
|
||||||
|
...place,
|
||||||
|
distance: getDistance(position, { lat: place.lat, lng: place.lng })
|
||||||
|
})).sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0));
|
||||||
|
}, [places, position]);
|
||||||
|
|
||||||
|
const getPlacesByDay = (day: number) =>
|
||||||
|
placesWithDistance.filter(p => p.day === day);
|
||||||
|
|
||||||
|
const getPlacesByCategory = (category: string) =>
|
||||||
|
placesWithDistance.filter(p => p.category === category);
|
||||||
|
|
||||||
|
const getPlacesByCity = (city: string) =>
|
||||||
|
placesWithDistance.filter(p => p.city === city);
|
||||||
|
|
||||||
|
const getNearby = (limit = 5) =>
|
||||||
|
position ? placesWithDistance.slice(0, limit) : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
places: placesWithDistance,
|
||||||
|
getPlacesByDay,
|
||||||
|
getPlacesByCategory,
|
||||||
|
getPlacesByCity,
|
||||||
|
getNearby
|
||||||
|
};
|
||||||
|
}
|
||||||
187
src/index.css
Normal file
187
src/index.css
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #FAFAFA;
|
||||||
|
--bg-card: #FFFFFF;
|
||||||
|
--accent: #FF6B6B;
|
||||||
|
--accent-light: #FFE8E8;
|
||||||
|
--text-primary: #1A1A1A;
|
||||||
|
--text-secondary: #6B7280;
|
||||||
|
--text-muted: #9CA3AF;
|
||||||
|
--border: #E5E7EB;
|
||||||
|
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
--nav-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Noto Sans JP', sans-serif;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
/* Оставляем место для фиксированной навигации */
|
||||||
|
padding-bottom: var(--nav-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map Styles - карта занимает всё пространство */
|
||||||
|
.map-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ограничиваем z-index leaflet чтобы не перекрывал навигацию */
|
||||||
|
.leaflet-container {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
background: #F3F4F6 !important;
|
||||||
|
z-index: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-pane {
|
||||||
|
z-index: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-top,
|
||||||
|
.leaflet-bottom {
|
||||||
|
z-index: 10 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-zoom a {
|
||||||
|
background: var(--bg-card) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-attribution {
|
||||||
|
background: rgba(255, 255, 255, 0.9) !important;
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
font-size: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
background: var(--bg-card) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
box-shadow: var(--shadow-lg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: var(--bg-card) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for desktop */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(255, 107, 107, 0.4); }
|
||||||
|
70% { box-shadow: 0 0 0 10px rgba(255, 107, 107, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(255, 107, 107, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card hover effect */
|
||||||
|
.card-hover {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.card-hover:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch feedback */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.card-hover:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safe area for PWA */
|
||||||
|
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||||
|
.safe-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
147
src/pages/FoodPage.tsx
Normal file
147
src/pages/FoodPage.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Place, GeoPosition } from '../types';
|
||||||
|
|
||||||
|
interface FoodPageProps {
|
||||||
|
places: Place[];
|
||||||
|
position: GeoPosition | null;
|
||||||
|
onSelectPlace: (place: Place) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FoodCategory = 'all' | 'restaurant' | 'coffee' | 'snack';
|
||||||
|
|
||||||
|
const categoryLabels: Record<FoodCategory, { label: string; icon: string }> = {
|
||||||
|
all: { label: 'Все', icon: '🍽️' },
|
||||||
|
restaurant: { label: 'Рестораны', icon: '🍜' },
|
||||||
|
coffee: { label: 'Кофейни', icon: '☕' },
|
||||||
|
snack: { label: 'Перекусы', icon: '🍡' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCityName = (city: string): string => {
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
tokyo: 'Токио',
|
||||||
|
kyoto: 'Киото',
|
||||||
|
osaka: 'Осака',
|
||||||
|
nara: 'Нара',
|
||||||
|
hiroshima: 'Хиросима',
|
||||||
|
hakone: 'Хаконэ',
|
||||||
|
};
|
||||||
|
return names[city] || city;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FoodPage({ places, onSelectPlace }: FoodPageProps) {
|
||||||
|
const [category, setCategory] = useState<FoodCategory>('all');
|
||||||
|
|
||||||
|
const foodPlaces = useMemo(() => {
|
||||||
|
return places.filter(p =>
|
||||||
|
p.category === 'restaurant' ||
|
||||||
|
p.category === 'coffee' ||
|
||||||
|
p.category === 'snack'
|
||||||
|
);
|
||||||
|
}, [places]);
|
||||||
|
|
||||||
|
const filteredPlaces = useMemo(() => {
|
||||||
|
if (category === 'all') return foodPlaces;
|
||||||
|
return foodPlaces.filter(p => p.category === category);
|
||||||
|
}, [foodPlaces, category]);
|
||||||
|
|
||||||
|
const groupedByCity = useMemo(() => {
|
||||||
|
const groups: Record<string, Place[]> = {};
|
||||||
|
filteredPlaces.forEach(place => {
|
||||||
|
const city = place.city || 'other';
|
||||||
|
if (!groups[city]) groups[city] = [];
|
||||||
|
groups[city].push(place);
|
||||||
|
});
|
||||||
|
// Sort by day within each city
|
||||||
|
Object.keys(groups).forEach(city => {
|
||||||
|
groups[city].sort((a, b) => (a.day || 99) - (b.day || 99));
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}, [filteredPlaces]);
|
||||||
|
|
||||||
|
const categories: FoodCategory[] = ['all', 'restaurant', 'coffee', 'snack'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-[#FAFAFA]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex-shrink-0 px-4 pt-4 pb-2">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-1">Еда и кофе</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-3">
|
||||||
|
{foodPlaces.length} мест для гурманов
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Category filter */}
|
||||||
|
<div className="flex gap-2 overflow-x-auto scrollbar-hide pb-1 -mx-4 px-4">
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const isSelected = cat === category;
|
||||||
|
const { label, icon } = categoryLabels[cat];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => setCategory(cat)}
|
||||||
|
className={"flex-shrink-0 flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-all " +
|
||||||
|
(isSelected
|
||||||
|
? "bg-[#FF6B6B] text-white"
|
||||||
|
: "bg-white text-gray-600 border border-gray-200")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{icon}</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pb-24">
|
||||||
|
<div className="space-y-6 py-2">
|
||||||
|
{Object.entries(groupedByCity).map(([city, cityPlaces]) => (
|
||||||
|
<div key={city}>
|
||||||
|
<h2 className="font-bold text-gray-900 mb-3 sticky top-0 bg-[#FAFAFA] py-2 z-10">
|
||||||
|
{getCityName(city)}
|
||||||
|
<span className="text-sm text-gray-400 ml-2">({cityPlaces.length})</span>
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{cityPlaces.map((place) => (
|
||||||
|
<button
|
||||||
|
key={place.id}
|
||||||
|
onClick={() => onSelectPlace(place)}
|
||||||
|
className="w-full text-left bg-white rounded-xl p-3 shadow-sm border border-gray-100 card-hover"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-2xl">
|
||||||
|
{place.category === 'restaurant' ? '🍜' :
|
||||||
|
place.category === 'coffee' ? '☕' : '🍡'}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-gray-900 truncate">{place.name}</h3>
|
||||||
|
{place.rating === 5 && <span className="text-yellow-500 text-sm">★</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mb-1">{place.nameJp}</p>
|
||||||
|
<p className="text-sm text-gray-600 line-clamp-2">{place.description}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-xs">
|
||||||
|
{place.day && (
|
||||||
|
<span className="px-2 py-0.5 bg-[#FFE8E8] text-[#FF6B6B] rounded-full">
|
||||||
|
День {place.day}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{place.price && (
|
||||||
|
<span className="text-gray-400">{place.price}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg className="w-5 h-5 text-gray-300 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
src/pages/MapPage.tsx
Normal file
231
src/pages/MapPage.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, useMap, Polyline } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import { Place, GeoPosition } from '../types';
|
||||||
|
import scheduleData from '../data/schedule.json';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
interface MapPageProps {
|
||||||
|
places: Place[];
|
||||||
|
position: GeoPosition | null;
|
||||||
|
selectedPlace: Place | null;
|
||||||
|
onSelectPlace: (place: Place) => void;
|
||||||
|
showRouteForDay: number | null;
|
||||||
|
onClearRoute: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix marker icons
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
const createIcon = (emoji: string, isSelected: boolean = false) => {
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'custom-marker',
|
||||||
|
html: `
|
||||||
|
<div style="
|
||||||
|
font-size: ${isSelected ? '28px' : '22px'};
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: all 0.2s;
|
||||||
|
${isSelected ? 'filter: drop-shadow(0 0 8px #FF6B6B);' : ''}
|
||||||
|
">
|
||||||
|
${emoji}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
iconSize: [40, 40],
|
||||||
|
iconAnchor: [20, 20],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryEmoji = (category: string): string => {
|
||||||
|
const emojis: Record<string, string> = {
|
||||||
|
sight: '🏯',
|
||||||
|
restaurant: '🍜',
|
||||||
|
coffee: '☕',
|
||||||
|
snack: '🍡',
|
||||||
|
hotel: '🏨',
|
||||||
|
};
|
||||||
|
return emojis[category] || '📍';
|
||||||
|
};
|
||||||
|
|
||||||
|
function MapController({ center, zoom }: { center: [number, number]; zoom: number }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
map.flyTo(center, zoom, { duration: 0.5 });
|
||||||
|
}, [center, zoom, map]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocationMarker({ position }: { position: GeoPosition | null }) {
|
||||||
|
if (!position) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
position={[position.lat, position.lng]}
|
||||||
|
icon={L.divIcon({
|
||||||
|
className: 'location-marker',
|
||||||
|
html: `
|
||||||
|
<div style="
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #4285F4;
|
||||||
|
border: 3px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2px 8px rgba(66, 133, 244, 0.5);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
"></div>
|
||||||
|
`,
|
||||||
|
iconSize: [22, 22],
|
||||||
|
iconAnchor: [11, 11],
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapPage({ places, position, selectedPlace, onSelectPlace, showRouteForDay, onClearRoute }: MapPageProps) {
|
||||||
|
const [mapCenter, setMapCenter] = useState<[number, number]>([35.6762, 139.6503]);
|
||||||
|
const [mapZoom, setMapZoom] = useState(11);
|
||||||
|
const schedule = scheduleData as Record<string, any>;
|
||||||
|
|
||||||
|
// Get route for selected day
|
||||||
|
const routeCoordinates = useMemo(() => {
|
||||||
|
if (!showRouteForDay) return [];
|
||||||
|
const dayData = schedule[`day${showRouteForDay}`];
|
||||||
|
if (!dayData) return [];
|
||||||
|
|
||||||
|
const coords: [number, number][] = [];
|
||||||
|
dayData.schedule.forEach((item: any) => {
|
||||||
|
if (item.placeId) {
|
||||||
|
const place = places.find(p => p.id === item.placeId);
|
||||||
|
if (place) {
|
||||||
|
coords.push([place.lat, place.lng]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return coords;
|
||||||
|
}, [showRouteForDay, places, schedule]);
|
||||||
|
|
||||||
|
// Update center when selected place changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPlace) {
|
||||||
|
setMapCenter([selectedPlace.lat, selectedPlace.lng]);
|
||||||
|
setMapZoom(15);
|
||||||
|
}
|
||||||
|
}, [selectedPlace]);
|
||||||
|
|
||||||
|
// Update center for route
|
||||||
|
useEffect(() => {
|
||||||
|
if (routeCoordinates.length > 0) {
|
||||||
|
const bounds = L.latLngBounds(routeCoordinates);
|
||||||
|
const center = bounds.getCenter();
|
||||||
|
setMapCenter([center.lat, center.lng]);
|
||||||
|
setMapZoom(12);
|
||||||
|
}
|
||||||
|
}, [routeCoordinates]);
|
||||||
|
|
||||||
|
const visiblePlaces = useMemo(() => {
|
||||||
|
if (showRouteForDay) {
|
||||||
|
const dayData = schedule[`day${showRouteForDay}`];
|
||||||
|
if (!dayData) return places;
|
||||||
|
const placeIds = dayData.schedule
|
||||||
|
.filter((item: any) => item.placeId)
|
||||||
|
.map((item: any) => item.placeId);
|
||||||
|
return places.filter(p => placeIds.includes(p.id));
|
||||||
|
}
|
||||||
|
return places;
|
||||||
|
}, [places, showRouteForDay, schedule]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full relative">
|
||||||
|
<MapContainer
|
||||||
|
center={mapCenter}
|
||||||
|
zoom={mapZoom}
|
||||||
|
className="h-full w-full"
|
||||||
|
zoomControl={false}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OSM</a>'
|
||||||
|
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MapController center={mapCenter} zoom={mapZoom} />
|
||||||
|
<LocationMarker position={position} />
|
||||||
|
|
||||||
|
{/* Route polyline */}
|
||||||
|
{routeCoordinates.length > 1 && (
|
||||||
|
<Polyline
|
||||||
|
positions={routeCoordinates}
|
||||||
|
color="#FF6B6B"
|
||||||
|
weight={4}
|
||||||
|
opacity={0.8}
|
||||||
|
dashArray="10, 10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Place markers */}
|
||||||
|
{visiblePlaces.map((place) => (
|
||||||
|
<Marker
|
||||||
|
key={place.id}
|
||||||
|
position={[place.lat, place.lng]}
|
||||||
|
icon={createIcon(getCategoryEmoji(place.category), selectedPlace?.id === place.id)}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () => onSelectPlace(place),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popup className="custom-popup">
|
||||||
|
<div className="p-1">
|
||||||
|
<h3 className="font-bold text-gray-900">{place.name}</h3>
|
||||||
|
<p className="text-xs text-gray-500">{place.nameJp}</p>
|
||||||
|
{place.day && (
|
||||||
|
<span className="inline-block mt-1 px-2 py-0.5 bg-[#FFE8E8] text-[#FF6B6B] text-xs rounded-full">
|
||||||
|
День {place.day}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
|
||||||
|
{/* Map controls */}
|
||||||
|
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-2">
|
||||||
|
{position && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMapCenter([position.lat, position.lng]);
|
||||||
|
setMapZoom(15);
|
||||||
|
}}
|
||||||
|
className="w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-[#4285F4]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Route info bar */}
|
||||||
|
{showRouteForDay && (
|
||||||
|
<div className="absolute top-4 left-4 right-16 z-[1000]">
|
||||||
|
<div className="bg-white rounded-xl shadow-lg p-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900">Маршрут дня {showRouteForDay}</p>
|
||||||
|
<p className="text-xs text-gray-500">{visiblePlaces.length} мест</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClearRoute}
|
||||||
|
className="px-3 py-1.5 bg-gray-100 text-gray-600 rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
Скрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
src/pages/PlacesPage.tsx
Normal file
208
src/pages/PlacesPage.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Place, GeoPosition } from '../types';
|
||||||
|
import { PlaceCard } from '../components/PlaceCard';
|
||||||
|
import scheduleData from '../data/schedule.json';
|
||||||
|
|
||||||
|
interface PlacesPageProps {
|
||||||
|
places: Place[];
|
||||||
|
position: GeoPosition | null;
|
||||||
|
onSelectPlace: (place: Place) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = 'days' | 'places' | 'search';
|
||||||
|
|
||||||
|
const getCityEmoji = (city: string): string => {
|
||||||
|
const cities: Record<string, string> = {
|
||||||
|
tokyo: '🗼',
|
||||||
|
kyoto: '⛩️',
|
||||||
|
osaka: '🏯',
|
||||||
|
nara: '🦌',
|
||||||
|
hiroshima: '🕊️',
|
||||||
|
miyajima: '⛩️',
|
||||||
|
hakone: '🗻',
|
||||||
|
nikko: '🌲',
|
||||||
|
himeji: '🏰',
|
||||||
|
};
|
||||||
|
return cities[city] || '📍';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCityName = (city: string): string => {
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
tokyo: 'Токио',
|
||||||
|
kyoto: 'Киото',
|
||||||
|
osaka: 'Осака',
|
||||||
|
nara: 'Нара',
|
||||||
|
hiroshima: 'Хиросима',
|
||||||
|
miyajima: 'Миядзима',
|
||||||
|
hakone: 'Хаконэ',
|
||||||
|
nikko: 'Никко',
|
||||||
|
himeji: 'Химэдзи',
|
||||||
|
};
|
||||||
|
return names[city] || city;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlacesPage({ places, onSelectPlace }: PlacesPageProps) {
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('days');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const schedule = scheduleData as Record<string, any>;
|
||||||
|
|
||||||
|
const days = useMemo(() => {
|
||||||
|
return Object.entries(schedule).map(([key, data]: [string, any]) => ({
|
||||||
|
day: parseInt(key.replace('day', '')),
|
||||||
|
...data,
|
||||||
|
})).sort((a, b) => a.day - b.day);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredPlaces = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return places;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return places.filter(p =>
|
||||||
|
p.name.toLowerCase().includes(query) ||
|
||||||
|
p.nameJp?.toLowerCase().includes(query) ||
|
||||||
|
p.description?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}, [places, searchQuery]);
|
||||||
|
|
||||||
|
const groupedByCity = useMemo(() => {
|
||||||
|
const groups: Record<string, Place[]> = {};
|
||||||
|
filteredPlaces.forEach(place => {
|
||||||
|
const city = place.city || 'other';
|
||||||
|
if (!groups[city]) groups[city] = [];
|
||||||
|
groups[city].push(place);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}, [filteredPlaces]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-[#FAFAFA]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex-shrink-0 px-4 pt-4 pb-2">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-3">Путешествие</h1>
|
||||||
|
|
||||||
|
{/* View mode tabs */}
|
||||||
|
<div className="flex gap-2 bg-gray-100 p-1 rounded-xl">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('days')}
|
||||||
|
className={"flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-all " +
|
||||||
|
(viewMode === 'days'
|
||||||
|
? "bg-white text-gray-900 shadow-sm"
|
||||||
|
: "text-gray-500")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
По дням
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('places')}
|
||||||
|
className={"flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-all " +
|
||||||
|
(viewMode === 'places'
|
||||||
|
? "bg-white text-gray-900 shadow-sm"
|
||||||
|
: "text-gray-500")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
По городам
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('search')}
|
||||||
|
className={"flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-all " +
|
||||||
|
(viewMode === 'search'
|
||||||
|
? "bg-white text-gray-900 shadow-sm"
|
||||||
|
: "text-gray-500")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
🔍
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
{viewMode === 'search' && (
|
||||||
|
<div className="px-4 py-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Поиск мест..."
|
||||||
|
className="w-full px-4 py-3 bg-white rounded-xl border border-gray-200 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FF6B6B] focus:border-transparent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pb-24">
|
||||||
|
{viewMode === 'days' && (
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
{days.map((dayData) => (
|
||||||
|
<div
|
||||||
|
key={dayData.day}
|
||||||
|
className="w-full text-left bg-white rounded-2xl p-4 shadow-sm border border-gray-100"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-shrink-0 w-14 h-14 bg-[#FFE8E8] rounded-xl flex flex-col items-center justify-center">
|
||||||
|
<span className="text-xl">{getCityEmoji(dayData.city)}</span>
|
||||||
|
<span className="text-xs font-bold text-[#FF6B6B]">Day {dayData.day}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-bold text-gray-900 mb-0.5">{getCityName(dayData.city)}</h3>
|
||||||
|
<p className="text-sm text-gray-500 truncate">{dayData.title}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{new Date(dayData.date).toLocaleDateString('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
weekday: 'short'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'places' && (
|
||||||
|
<div className="space-y-6 py-2">
|
||||||
|
{Object.entries(groupedByCity).map(([city, cityPlaces]) => (
|
||||||
|
<div key={city}>
|
||||||
|
<div className="flex items-center gap-2 mb-3 sticky top-0 bg-[#FAFAFA] py-2 z-10">
|
||||||
|
<span className="text-xl">{getCityEmoji(city)}</span>
|
||||||
|
<h2 className="font-bold text-gray-900">{getCityName(city)}</h2>
|
||||||
|
<span className="text-sm text-gray-400">({cityPlaces.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{cityPlaces.map((place) => (
|
||||||
|
<PlaceCard
|
||||||
|
key={place.id}
|
||||||
|
place={place}
|
||||||
|
onClick={() => onSelectPlace(place)}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'search' && (
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
{filteredPlaces.length > 0 ? (
|
||||||
|
filteredPlaces.map((place) => (
|
||||||
|
<PlaceCard
|
||||||
|
key={place.id}
|
||||||
|
place={place}
|
||||||
|
onClick={() => onSelectPlace(place)}
|
||||||
|
showDay
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-48 text-gray-400">
|
||||||
|
<span className="text-4xl mb-2">🔍</span>
|
||||||
|
<p>Ничего не найдено</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/pages/PlanPage.tsx
Normal file
124
src/pages/PlanPage.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Place } from '../types';
|
||||||
|
import { TripProgress } from '../components/TripProgress';
|
||||||
|
import { DayTimeline } from '../components/DayTimeline';
|
||||||
|
import scheduleData from '../data/schedule.json';
|
||||||
|
|
||||||
|
interface PlanPageProps {
|
||||||
|
places: Place[];
|
||||||
|
onSelectPlace: (place: Place) => void;
|
||||||
|
onShowDayRoute: (day: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRIP_START_DATE = '2026-03-03';
|
||||||
|
const TOTAL_DAYS = 16;
|
||||||
|
|
||||||
|
export function PlanPage({ places, onSelectPlace, onShowDayRoute }: PlanPageProps) {
|
||||||
|
const schedule = scheduleData as Record<string, any>;
|
||||||
|
|
||||||
|
// Calculate current day based on date
|
||||||
|
const currentDay = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
// Compare dates at local midnight to avoid timezone issues
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const tripStart = new Date(2026, 2, 3); // March 3, 2026 (month is 0-indexed)
|
||||||
|
|
||||||
|
const diffTime = today.getTime() - tripStart.getTime();
|
||||||
|
const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
|
||||||
|
if (diffDays < 1) return 1; // Before trip
|
||||||
|
if (diffDays > TOTAL_DAYS) return TOTAL_DAYS; // After trip
|
||||||
|
return diffDays;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [selectedDay, setSelectedDay] = useState(currentDay);
|
||||||
|
|
||||||
|
const dayKey = `day${selectedDay}`;
|
||||||
|
const dayData = schedule[dayKey];
|
||||||
|
|
||||||
|
const isToday = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const tripStart = new Date(2026, 2, 3);
|
||||||
|
const diffTime = today.getTime() - tripStart.getTime();
|
||||||
|
const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
return diffDays === selectedDay;
|
||||||
|
}, [selectedDay]);
|
||||||
|
|
||||||
|
// Quick day navigation
|
||||||
|
const days = Array.from({ length: TOTAL_DAYS }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-[#FAFAFA]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex-shrink-0 px-4 pt-4 pb-2">
|
||||||
|
<TripProgress
|
||||||
|
currentDay={currentDay}
|
||||||
|
totalDays={TOTAL_DAYS}
|
||||||
|
tripStartDate={TRIP_START_DATE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day selector - horizontal scroll */}
|
||||||
|
<div className="flex-shrink-0 px-4 py-3">
|
||||||
|
<div className="flex gap-2 overflow-x-auto scrollbar-hide pb-1 -mx-4 px-4">
|
||||||
|
{days.map((day) => {
|
||||||
|
const isSelected = day === selectedDay;
|
||||||
|
const isCurrent = day === currentDay;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
onClick={() => setSelectedDay(day)}
|
||||||
|
className={"flex-shrink-0 flex flex-col items-center px-4 py-2 rounded-xl transition-all " +
|
||||||
|
(isSelected
|
||||||
|
? "bg-[#FF6B6B] text-white shadow-lg shadow-red-200"
|
||||||
|
: "bg-white text-gray-600 border border-gray-100")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-xs opacity-70">День</span>
|
||||||
|
<span className="text-lg font-bold">{day}</span>
|
||||||
|
{isCurrent && !isSelected && (
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-[#FF6B6B] mt-1" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pb-24">
|
||||||
|
{dayData ? (
|
||||||
|
<DayTimeline
|
||||||
|
day={selectedDay}
|
||||||
|
dayData={dayData}
|
||||||
|
places={places}
|
||||||
|
onSelectPlace={onSelectPlace}
|
||||||
|
isToday={isToday}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 text-gray-400">
|
||||||
|
<span className="text-4xl mb-2">📅</span>
|
||||||
|
<p>Расписание не найдено</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show route button */}
|
||||||
|
{dayData && (
|
||||||
|
<div className="fixed bottom-20 left-4 right-4 safe-bottom">
|
||||||
|
<button
|
||||||
|
onClick={() => onShowDayRoute(selectedDay)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-white text-gray-700 py-3 rounded-xl font-semibold shadow-lg border border-gray-200"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||||
|
</svg>
|
||||||
|
Показать маршрут на карте
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/types.ts
Normal file
74
src/types.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
export interface Place {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
nameJp: string;
|
||||||
|
category: "sight" | "restaurant" | "hotel" | "coffee" | "snack";
|
||||||
|
city: "tokyo" | "kyoto" | "osaka" | "nara" | "hakone" | "hiroshima" | "miyajima" | "nikko" | "himeji";
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
description: string;
|
||||||
|
address: string;
|
||||||
|
hours: string;
|
||||||
|
price: string;
|
||||||
|
rating: number;
|
||||||
|
day: number;
|
||||||
|
links: string[];
|
||||||
|
tips?: string;
|
||||||
|
history?: string;
|
||||||
|
facts?: string[];
|
||||||
|
duration?: string;
|
||||||
|
photoSpots?: string;
|
||||||
|
localTips?: string;
|
||||||
|
bestTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoPosition {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
accuracy?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabType = "map" | "plan" | "places" | "food";
|
||||||
|
|
||||||
|
export const CITIES: Record<string, { name: string; emoji: string }> = {
|
||||||
|
tokyo: { name: "Токио", emoji: "🗼" },
|
||||||
|
kyoto: { name: "Киото", emoji: "⛩️" },
|
||||||
|
osaka: { name: "Осака", emoji: "🏯" },
|
||||||
|
nara: { name: "Нара", emoji: "🦌" },
|
||||||
|
hakone: { name: "Хаконэ", emoji: "🗻" },
|
||||||
|
hiroshima: { name: "Хиросима", emoji: "🕊️" },
|
||||||
|
miyajima: { name: "Миядзима", emoji: "⛩️" },
|
||||||
|
nikko: { name: "Никко", emoji: "🌲" },
|
||||||
|
himeji: { name: "Химэдзи", emoji: "🏰" }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CATEGORIES: Record<string, { name: string; emoji: string }> = {
|
||||||
|
sight: { name: "Достопримечательности", emoji: "📍" },
|
||||||
|
restaurant: { name: "Рестораны", emoji: "🍜" },
|
||||||
|
coffee: { name: "Кофе", emoji: "☕" },
|
||||||
|
snack: { name: "Перекус", emoji: "🍡" },
|
||||||
|
hotel: { name: "Отели", emoji: "🏨" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trip dates: March 3-18, 2026
|
||||||
|
export const TRIP_START = new Date("2026-03-03");
|
||||||
|
export const TRIP_END = new Date("2026-03-18");
|
||||||
|
|
||||||
|
export const SCHEDULE = [
|
||||||
|
{ day: 1, date: "3 марта", city: "tokyo", title: "Асакуса + Уэно + Скайтри" },
|
||||||
|
{ day: 2, date: "4 марта", city: "tokyo", title: "Харадзюку → Сибуя → Синдзюку" },
|
||||||
|
{ day: 3, date: "5 марта", city: "tokyo", title: "Цукидзи → Гиндза → Одайба" },
|
||||||
|
{ day: 4, date: "6 марта", city: "tokyo", title: "Никко (дневная поездка)" },
|
||||||
|
{ day: 5, date: "7 марта", city: "hakone", title: "Переезд в Хаконэ" },
|
||||||
|
{ day: 6, date: "8 марта", city: "hakone", title: "Озеро Аси → Киото" },
|
||||||
|
{ day: 7, date: "9 марта", city: "kyoto", title: "Восточный Киото" },
|
||||||
|
{ day: 8, date: "10 марта", city: "kyoto", title: "Арасияма + Северо-запад" },
|
||||||
|
{ day: 9, date: "11 марта", city: "kyoto", title: "Философский путь" },
|
||||||
|
{ day: 10, date: "12 марта", city: "nara", title: "Нара (дневная поездка)" },
|
||||||
|
{ day: 11, date: "13 марта", city: "kyoto", title: "Храмы + Замок Нидзо" },
|
||||||
|
{ day: 12, date: "14 марта", city: "osaka", title: "Замок + Куромон + Дотонбори" },
|
||||||
|
{ day: 13, date: "15 марта", city: "osaka", title: "Universal Studios 🎢" },
|
||||||
|
{ day: 14, date: "16 марта", city: "osaka", title: "Химэдзи + Синсэкай" },
|
||||||
|
{ day: 15, date: "17 марта", city: "hiroshima", title: "Мемориал мира" },
|
||||||
|
{ day: 16, date: "18 марта", city: "hiroshima", title: "Миядзима" }
|
||||||
|
];
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
37
tailwind.config.js
Normal file
37
tailwind.config.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
'./index.html',
|
||||||
|
'./src/**/*.{js,ts,jsx,tsx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
accent: '#FF6B6B',
|
||||||
|
'accent-light': '#FFE8E8',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['-apple-system', 'BlinkMacSystemFont', 'SF Pro Display', 'Noto Sans JP', 'sans-serif'],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'card': '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
|
'card-lg': '0 8px 24px rgba(0, 0, 0, 0.12)',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.3s ease-out',
|
||||||
|
'slide-up': 'slideUp 0.3s ease-out',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
from: { opacity: '0', transform: 'translateY(10px)' },
|
||||||
|
to: { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
from: { transform: 'translateY(100%)' },
|
||||||
|
to: { transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
57
vite.config.ts
Normal file
57
vite.config.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
includeAssets: ["favicon.ico", "robots.txt", "apple-touch-icon.png"],
|
||||||
|
manifest: {
|
||||||
|
name: "Japan Trip Companion",
|
||||||
|
short_name: "Japan Trip",
|
||||||
|
description: "Companion app for Japan trip March 3-18, 2026",
|
||||||
|
theme_color: "#1a1a2e",
|
||||||
|
background_color: "#1a1a2e",
|
||||||
|
display: "standalone",
|
||||||
|
orientation: "portrait",
|
||||||
|
start_url: "/",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "icon-192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "icon-512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ["**/*.{js,css,html,ico,png,svg,json}"],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/[abc]\.tile\.openstreetmap\.org\/.*/i,
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "osm-tiles",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 500,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 30
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
host: true
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user