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