Initial commit: Japan PWA guide

This commit is contained in:
Cosmo
2026-03-21 04:59:39 +00:00
commit 7db42fd784
36 changed files with 5705 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.git
*.md
.env*

30
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Allow: /

87
src/App.tsx Normal file
View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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

File diff suppressed because it is too large Load Diff

735
src/data/places.json.bak Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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
View 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
View 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='&copy; <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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

37
tailwind.config.js Normal file
View 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
View 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
View 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
View 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
}
});