Autosave: 20260302-135117

This commit is contained in:
Flatlogic Bot 2026-03-02 13:51:18 +00:00
parent 1905eb86bf
commit 2c504c5ff8
25 changed files with 2125 additions and 1321 deletions

0
.perm_test_apache Normal file
View File

0
.perm_test_exec Normal file
View File

View File

@ -2,6 +2,7 @@ import { memo } from 'react';
import { ACCOMMODATION_OPTIONS } from '@/constants/planner';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
interface AccommodationSelectorProps {
selectedId: string;
@ -10,34 +11,48 @@ interface AccommodationSelectorProps {
export const AccommodationSelector = memo(({ selectedId, onSelect }: AccommodationSelectorProps) => {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{ACCOMMODATION_OPTIONS.map((option) => {
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{ACCOMMODATION_OPTIONS.map((option, index) => {
const Icon = option.icon;
const isSelected = selectedId === option.id;
return (
<Button
<motion.div
key={option.id}
type="button"
variant="outline"
className={cn(
"h-auto py-3 flex items-center justify-start gap-3 border-2 transition-all duration-200",
isSelected
? "border-orange-500 bg-orange-50 text-orange-700 hover:bg-orange-100 hover:text-orange-800"
: "border-gray-100 hover:border-orange-200 hover:bg-orange-50/50"
)}
onClick={() => onSelect(option.id)}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
>
<Icon className={cn(
"h-5 w-5",
isSelected ? "text-orange-600" : "text-gray-400"
)} />
<span className="text-sm font-medium">{option.label}</span>
</Button>
<Button
type="button"
variant="outline"
className={cn(
"h-auto p-6 w-full flex flex-col items-center justify-center gap-4 rounded-2xl border-2 transition-luxury group relative overflow-hidden",
isSelected
? "border-primary bg-primary/5 text-primary shadow-xl shadow-primary/5"
: "border-gray-50 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-gray-500 hover:border-primary/30 hover:bg-primary/5"
)}
onClick={() => onSelect(option.id)}
>
<div className={cn(
"w-14 h-14 rounded-xl flex items-center justify-center transition-luxury group-hover:scale-105 group-hover:rotate-3 shadow-sm",
isSelected ? "bg-primary text-white" : "bg-gray-100 dark:bg-white/10 text-gray-400"
)}>
<Icon className="h-7 w-7" />
</div>
<div className="text-center space-y-0.5">
<span className="text-base font-black uppercase tracking-widest block">{option.label}</span>
<span className="text-xs font-medium italic opacity-60">Tercih Edilen Tarz</span>
</div>
{isSelected && (
<div className="absolute top-3 right-3 w-2 h-2 bg-primary rounded-full animate-pulse" />
)}
</Button>
</motion.div>
);
})}
</div>
);
});
AccommodationSelector.displayName = 'AccommodationSelector';
AccommodationSelector.displayName = 'AccommodationSelector';

View File

@ -2,6 +2,7 @@ import { memo } from 'react';
import { INTEREST_OPTIONS } from '@/constants/planner';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
interface InterestsGridProps {
selectedInterests: string[];
@ -10,34 +11,48 @@ interface InterestsGridProps {
export const InterestsGrid = memo(({ selectedInterests, onToggle }: InterestsGridProps) => {
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{INTEREST_OPTIONS.map((interest) => {
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{INTEREST_OPTIONS.map((interest, index) => {
const Icon = interest.icon;
const isSelected = selectedInterests.includes(interest.id);
return (
<Button
<motion.div
key={interest.id}
type="button"
variant="outline"
className={cn(
"h-auto py-4 flex flex-col items-center gap-2 border-2 transition-all duration-200",
isSelected
? "border-orange-500 bg-orange-50 text-orange-700 hover:bg-orange-100 hover:text-orange-800"
: "border-gray-100 hover:border-orange-200 hover:bg-orange-50/50"
)}
onClick={() => onToggle(interest.id)}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Icon className={cn(
"h-6 w-6",
isSelected ? "text-orange-600" : "text-gray-500"
)} />
<span className="text-xs font-semibold">{interest.label}</span>
</Button>
<Button
type="button"
variant="outline"
className={cn(
"h-auto py-6 w-full flex flex-col items-center gap-3 rounded-2xl border-2 transition-luxury group relative overflow-hidden",
isSelected
? "border-primary bg-primary/5 text-primary shadow-lg shadow-primary/5"
: "border-gray-50 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-gray-500 hover:border-primary/30 hover:bg-primary/5"
)}
onClick={() => onToggle(interest.id)}
>
{isSelected && (
<motion.div
layoutId="selected-bg"
className="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5 -z-10"
/>
)}
<div className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center transition-luxury group-hover:scale-105",
isSelected ? "bg-primary text-white" : "bg-gray-100 dark:bg-white/10 text-gray-400 group-hover:text-primary"
)}>
<Icon className="h-5 w-5" />
</div>
<span className="text-xs font-black uppercase tracking-widest">{interest.label}</span>
</Button>
</motion.div>
);
})}
</div>
);
});
InterestsGrid.displayName = 'InterestsGrid';
InterestsGrid.displayName = 'InterestsGrid';

View File

@ -1,6 +1,7 @@
import { memo } from 'react';
import { Button } from '@/components/ui/button';
import { Users, Minus, Plus } from 'lucide-react';
import { motion } from 'framer-motion';
interface TravelerInputProps {
value: number;
@ -9,31 +10,43 @@ interface TravelerInputProps {
export const TravelerInput = memo(({ value, onChange }: TravelerInputProps) => {
return (
<div className="flex items-center justify-between p-4 border border-gray-200 rounded-lg bg-gray-50/50">
<div className="flex items-center gap-3">
<Users className="h-5 w-5 text-gray-400" />
<div className="flex flex-col md:flex-row items-center justify-between p-6 border-2 border-gray-50 dark:border-white/5 rounded-2xl bg-gray-50/50 dark:bg-white/5 gap-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-white shadow-lg shadow-primary/20">
<Users className="h-6 w-6" />
</div>
<div>
<p className="text-sm font-semibold text-gray-700">Kişi Sayısı</p>
<p className="text-xs text-gray-500">{value} Yetişkin</p>
<p className="text-lg font-black text-gray-900 dark:text-white tracking-tighter">Kişi Sayısı</p>
<p className="text-sm text-gray-400 font-medium italic">{value} Yetişkin Gezgin</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-6 bg-white dark:bg-black/20 p-2 rounded-xl shadow-sm border-2 border-gray-50 dark:border-white/5">
<Button
type="button"
variant="outline"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full border-gray-300"
className="h-10 w-10 rounded-lg bg-gray-50 dark:bg-white/5 hover:bg-primary hover:text-white transition-luxury disabled:opacity-20"
onClick={() => onChange(Math.max(1, value - 1))}
disabled={value <= 1}
>
<Minus className="h-4 w-4" />
</Button>
<span className="text-lg font-bold w-4 text-center">{value}</span>
<motion.span
key={value}
initial={{ scale: 1.1, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="text-2xl font-black w-8 text-center tracking-tighter"
>
{value}
</motion.span>
<Button
type="button"
variant="outline"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full border-gray-300"
className="h-10 w-10 rounded-lg bg-gray-50 dark:bg-white/5 hover:bg-primary hover:text-white transition-luxury disabled:opacity-20"
onClick={() => onChange(Math.min(15, value + 1))}
disabled={value >= 15}
>
@ -44,4 +57,4 @@ export const TravelerInput = memo(({ value, onChange }: TravelerInputProps) => {
);
});
TravelerInput.displayName = 'TravelerInput';
TravelerInput.displayName = 'TravelerInput';

View File

@ -2,8 +2,10 @@ import { useEffect, useRef, useState, useMemo } from 'react';
import { ItineraryDay } from '@/db/api';
import { initGoogleMaps } from '@/lib/google-maps-loader';
import { Button } from '@/components/ui/button';
import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
import { ZoomIn, ZoomOut, Maximize2, Navigation2, Map as MapIcon, Layers } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import api from '@/db/api';
import { cn } from '@/lib/utils';
interface MapProps {
itinerary: { days: ItineraryDay[] };
@ -11,24 +13,24 @@ interface MapProps {
onMarkerClick: (id: string) => void;
}
// Color palette for different days
// More standard and vibrant color palette for Wanderlog feel
const DAY_COLORS = [
'#3B82F6', // Blue - Day 1
'#10B981', // Green - Day 2
'#8B5CF6', // Purple - Day 3
'#F59E0B', // Amber - Day 4
'#EF4444', // Red - Day 5
'#EA580C', // Orange-600
'#2563EB', // Blue-600
'#059669', // Emerald-600
'#7C3AED', // Violet-600
'#DB2777', // Pink-600
];
export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const [googleMap, setGoogleMap] = useState<google.maps.Map | null>(null);
const [error, setError] = useState<string | null>(null);
const [mapType, setMapType] = useState<'roadmap' | 'satellite' | 'terrain'>('roadmap');
const markersRef = useRef<{ [key: string]: { marker: google.maps.Marker; dayIndex: number } }>({});
const polylinesRef = useRef<google.maps.Polyline[]>([]);
const infoWindowRef = useRef<google.maps.InfoWindow | null>(null);
// Memoize itinerary to prevent unnecessary re-renders
const itineraryKey = useMemo(() =>
JSON.stringify(itinerary.days.map(d => d.items.map(i => i.place_id))),
[itinerary]
@ -38,10 +40,8 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
const loadMap = async () => {
try {
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
if (!apiKey || apiKey === 'YOUR_GOOGLE_MAPS_API_KEY') {
setError('Google Maps API anahtarı eksik. Lütfen .env dosyasına geçerli bir VITE_GOOGLE_MAPS_API_KEY ekleyin.');
console.error('Google Maps API anahtarı eksik. Lütfen .env dosyasına VITE_GOOGLE_MAPS_API_KEY ekleyin.');
setError('Google Maps API anahtarı eksik.');
return;
}
@ -49,39 +49,39 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
if (mapRef.current && !googleMap) {
const map = new google.maps.Map(mapRef.current, {
center: { lat: 38.6431, lng: 34.8347 }, // Central Cappadocia
center: { lat: 38.6431, lng: 34.8347 },
zoom: 12,
styles: [
// Standard clean map style
{
"featureType": "poi",
"elementType": "labels",
"featureType": "poi.business",
"stylers": [{ "visibility": "off" }]
},
{
"featureType": "poi.park",
"elementType": "labels.text",
"stylers": [{ "visibility": "on" }]
}
],
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: false,
zoomControl: false,
clickableIcons: true,
});
setGoogleMap(map);
infoWindowRef.current = new google.maps.InfoWindow();
}
} catch (error) {
const errorMsg = 'Google Maps yüklenemedi. API anahtarınızı kontrol edin.';
setError(errorMsg);
console.error('Google Maps yüklenemedi:', error);
setError('Google Maps yüklenemedi.');
}
};
if (!googleMap) {
loadMap();
}
if (!googleMap) loadMap();
}, [googleMap]);
useEffect(() => {
if (!googleMap || !itinerary?.days) return;
// Clear existing markers and polylines
Object.values(markersRef.current).forEach(({ marker }) => marker.setMap(null));
markersRef.current = {};
polylinesRef.current.forEach(polyline => polyline.setMap(null));
@ -89,7 +89,6 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
const bounds = new google.maps.LatLngBounds();
// Create markers and polylines for each day
itinerary.days.forEach((day, dayIndex) => {
const dayColor = DAY_COLORS[dayIndex % DAY_COLORS.length];
const dayPath: google.maps.LatLngLiteral[] = [];
@ -98,52 +97,48 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
const position = { lat: item.lat, lng: item.lng };
dayPath.push(position);
// Create custom marker with SVG
const isActive = item.place_id === activePlaceId;
const marker = new google.maps.Marker({
position,
map: googleMap,
title: item.name,
zIndex: isActive ? 1000 : 1,
label: {
text: (itemIndex + 1).toString(),
color: 'white',
fontSize: '12px',
fontWeight: 'bold',
fontSize: '11px',
fontWeight: '900',
},
icon: {
path: google.maps.SymbolPath.CIRCLE,
path: "M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z",
fillColor: dayColor,
fillOpacity: 1,
strokeWeight: 3,
strokeWeight: 2,
strokeColor: '#ffffff',
scale: 14,
scale: isActive ? 1.8 : 1.4,
anchor: new google.maps.Point(12, 22),
labelOrigin: new google.maps.Point(12, 9),
},
animation: item.place_id === activePlaceId ? google.maps.Animation.BOUNCE : undefined,
animation: isActive ? google.maps.Animation.BOUNCE : undefined,
});
marker.addListener('click', () => {
onMarkerClick(item.place_id);
if (infoWindowRef.current) {
const photoUrl = item.photo_reference
? `${window.location.origin}/api/photo?reference=${item.photo_reference}`
: '';
const photoUrl = item.photo_reference ? (
item.photo_reference.startsWith('http') ? item.photo_reference : api.getPhotoUrl(item.photo_reference)
) : '';
infoWindowRef.current.setContent(`
<div style="color: black; max-width: 250px;">
${photoUrl ? `<img src="${photoUrl}" alt="${item.name}" style="width: 100%; height: 120px; object-fit: cover; border-radius: 8px; margin-bottom: 8px;" />` : ''}
<h4 style="font-weight: bold; margin-bottom: 4px; font-size: 14px;">${item.name}</h4>
<p style="font-size: 12px; color: #6B7280; margin-bottom: 6px;">${item.formatted_address}</p>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="color: ${dayColor}; font-weight: bold;"> ${item.rating || 'N/A'}</span>
<span style="color: #6B7280; font-size: 12px;"> ${item.estimated_duration_minutes}dk</span>
<div style="padding: 8px; min-width: 200px; font-family: sans-serif;">
${photoUrl ? `<img src="${photoUrl}" style="width: 100%; height: 100px; object-fit: cover; border-radius: 8px; margin-bottom: 8px;" />` : ''}
<h4 style="margin: 0 0 2px 0; font-size: 14px; font-weight: bold;">${item.name}</h4>
<p style="margin: 0 0 8px 0; font-size: 11px; color: #6B7280;">${item.category}</p>
<div style="display: flex; align-items: center; justify-content: space-between;">
<span style="font-size: 11px; font-weight: bold;"> ${item.rating || 'N/A'}</span>
<a href="https://www.google.com/maps/dir/?api=1&destination=${item.lat},${item.lng}" target="_blank" style="color: #EA580C; font-size: 11px; font-weight: bold; text-decoration: none;">Yol Tarifi</a>
</div>
<a
href="https://www.google.com/maps/search/?api=1&query=${item.lat},${item.lng}&query_place_id=${item.place_id}"
target="_blank"
rel="noopener noreferrer"
style="display: inline-block; background: ${dayColor}; color: white; padding: 6px 12px; border-radius: 6px; text-decoration: none; font-size: 12px; font-weight: 500;"
>
Google Maps'te
</a>
</div>
`);
infoWindowRef.current.open(googleMap, marker);
@ -154,14 +149,13 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
bounds.extend(position);
});
// Create polyline for this day
if (dayPath.length > 1) {
const polyline = new google.maps.Polyline({
path: dayPath,
geodesic: true,
strokeColor: dayColor,
strokeOpacity: 0.7,
strokeWeight: 4,
strokeOpacity: 0.8,
strokeWeight: 3,
});
polyline.setMap(googleMap);
polylinesRef.current.push(polyline);
@ -169,129 +163,69 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
});
if (Object.keys(markersRef.current).length > 0) {
googleMap.fitBounds(bounds);
googleMap.fitBounds(bounds, { top: 60, bottom: 60, left: 60, right: 60 });
}
}, [googleMap, itineraryKey]); // Use itineraryKey instead of itinerary
}, [googleMap, itineraryKey]);
useEffect(() => {
if (googleMap && activePlaceId && markersRef.current[activePlaceId]) {
const { marker, dayIndex } = markersRef.current[activePlaceId];
const dayColor = DAY_COLORS[dayIndex % DAY_COLORS.length];
googleMap.panTo(marker.getPosition()!);
googleMap.setZoom(14);
// Update all markers - highlight active one
Object.keys(markersRef.current).forEach(id => {
const { marker: m, dayIndex: dIdx } = markersRef.current[id];
const color = DAY_COLORS[dIdx % DAY_COLORS.length];
const isActive = id === activePlaceId;
m.setIcon({
path: google.maps.SymbolPath.CIRCLE,
path: "M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z",
fillColor: color,
fillOpacity: isActive ? 1 : 0.8,
strokeWeight: isActive ? 4 : 3,
strokeColor: '#ffffff',
scale: isActive ? 18 : 14,
fillOpacity: 1,
strokeWeight: isActive ? 3 : 2,
strokeColor: isActive ? '#000000' : '#ffffff',
scale: isActive ? 1.8 : 1.4,
anchor: new google.maps.Point(12, 22),
labelOrigin: new google.maps.Point(12, 9),
});
m.setZIndex(isActive ? 1000 : 1);
m.setAnimation(isActive ? google.maps.Animation.BOUNCE : null);
});
}
}, [activePlaceId, googleMap]);
const handleZoomIn = () => {
if (googleMap) {
googleMap.setZoom((googleMap.getZoom() || 12) + 1);
}
};
const handleZoomOut = () => {
if (googleMap) {
googleMap.setZoom((googleMap.getZoom() || 12) - 1);
}
};
const handleFitBounds = () => {
if (googleMap && Object.keys(markersRef.current).length > 0) {
const bounds = new google.maps.LatLngBounds();
Object.values(markersRef.current).forEach(({ marker }) => {
const pos = marker.getPosition();
if (pos) bounds.extend(pos);
});
googleMap.fitBounds(bounds);
}
};
return (
<div className="relative w-full h-full">
<div className="relative w-full h-full bg-gray-50">
{error ? (
<div className="absolute inset-0 flex items-center justify-center bg-muted">
<div className="text-center p-8 max-w-md">
<div className="text-4xl mb-4">🗺</div>
<h3 className="text-lg font-semibold mb-2">Harita Yüklenemedi</h3>
<p className="text-sm text-muted-foreground mb-4">{error}</p>
<div className="text-xs text-left bg-card p-4 rounded-lg border">
<p className="font-mono mb-2">.env dosyasına ekleyin:</p>
<code className="text-primary">VITE_GOOGLE_MAPS_API_KEY=AIza...</code>
</div>
</div>
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-sm font-medium text-gray-500">{error}</p>
</div>
) : (
<>
<div ref={mapRef} className="absolute inset-0 w-full h-full" />
{/* Map Controls */}
{/* Compact Map Controls */}
<div className="absolute top-4 right-4 flex flex-col gap-2">
<Button
variant="secondary"
size="icon"
className="bg-white shadow-lg hover:bg-gray-50"
onClick={handleZoomIn}
>
<ZoomIn className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon"
className="bg-white shadow-lg hover:bg-gray-50"
onClick={handleZoomOut}
>
<ZoomOut className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon"
className="bg-white shadow-lg hover:bg-gray-50"
onClick={handleFitBounds}
>
<div className="flex flex-col bg-white border rounded-lg shadow-sm overflow-hidden">
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-none border-b" onClick={() => googleMap?.setZoom((googleMap.getZoom() || 12) + 1)}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-none" onClick={() => googleMap?.setZoom((googleMap.getZoom() || 12) - 1)}>
<ZoomOut className="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="icon" className="h-8 w-8 bg-white shadow-sm" onClick={() => {
if (googleMap && Object.keys(markersRef.current).length > 0) {
const bounds = new google.maps.LatLngBounds();
Object.values(markersRef.current).forEach(({ marker }) => bounds.extend(marker.getPosition()!));
googleMap.fitBounds(bounds);
}
}}>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
{/* Day Legend */}
<div className="absolute top-4 left-4 bg-white/95 backdrop-blur-sm rounded-lg shadow-lg p-3">
<div className="space-y-2">
{itinerary.days.map((day, index) => (
<div key={day.day} className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: DAY_COLORS[index % DAY_COLORS.length] }}
/>
<span className="text-xs font-medium text-gray-700">
Gün {day.day}
</span>
<Badge variant="secondary" className="text-xs font-normal">
{day.items.length}
</Badge>
</div>
))}
</div>
</div>
</>
)}
</div>
);
}
}

View File

@ -0,0 +1,168 @@
import { useEffect, useRef, useState } from 'react';
import { Search, MapPin, Star, Plus, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Place } from '@/db/api';
import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
interface PlaceSearchProps {
onPlaceSelect: (place: Place) => void;
className?: string;
placeholder?: string;
}
export function PlaceSearch({ onPlaceSelect, className, placeholder = "Yeni bir durak ara..." }: PlaceSearchProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<google.maps.places.AutocompletePrediction[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const autocompleteService = useRef<google.maps.places.AutocompleteService | null>(null);
const placesService = useRef<google.maps.places.PlacesService | null>(null);
useEffect(() => {
if (window.google && !autocompleteService.current) {
autocompleteService.current = new window.google.maps.places.AutocompleteService();
// PlacesService requires an HTML element, even if invisible
const dummyElement = document.createElement('div');
placesService.current = new window.google.maps.places.PlacesService(dummyElement);
}
}, []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSearch = async (value: string) => {
setQuery(value);
if (!value || !autocompleteService.current) {
setResults([]);
setIsOpen(false);
return;
}
setLoading(true);
autocompleteService.current.getPlacePredictions(
{
input: value,
locationBias: { lat: 38.6431, lng: 34.8347, radius: 50000 }, // Bias towards Cappadocia
componentRestrictions: { country: 'tr' }
},
(predictions, status) => {
setLoading(false);
if (status === window.google.maps.places.PlacesServiceStatus.OK && predictions) {
setResults(predictions);
setIsOpen(true);
} else {
setResults([]);
setIsOpen(false);
}
}
);
};
const handleSelectResult = (prediction: google.maps.places.AutocompletePrediction) => {
if (!placesService.current) return;
setLoading(true);
placesService.current.getDetails(
{
placeId: prediction.place_id,
fields: ['name', 'formatted_address', 'geometry', 'rating', 'photos', 'types']
},
(place, status) => {
setLoading(false);
if (status === window.google.maps.places.PlacesServiceStatus.OK && place && place.geometry?.location) {
const newPlace: Place = {
place_id: prediction.place_id,
name: place.name || '',
lat: place.geometry.location.lat(),
lng: place.geometry.location.lng(),
rating: place.rating,
formatted_address: place.formatted_address || '',
photo_reference: place.photos?.[0]?.getUrl(), // Note: In a real app, you'd store the reference or proxy the URL
description: place.types?.join(', ') || 'Turistik Nokta',
category: (place.types?.[0] || 'point_of_interest').replace(/_/g, ' '),
estimated_duration_minutes: 60,
start_time: '10:00',
end_time: '11:00',
};
onPlaceSelect(newPlace);
setQuery('');
setIsOpen(false);
setResults([]);
}
}
);
};
return (
<div ref={containerRef} className={cn("relative z-50", className)}>
<div className="relative group">
<div className="absolute inset-y-0 left-6 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400 group-focus-within:text-primary transition-colors" />
</div>
<Input
value={query}
onChange={(e) => handleSearch(e.target.value)}
onFocus={() => query && results.length > 0 && setIsOpen(true)}
placeholder={placeholder}
className="h-16 pl-14 pr-6 rounded-2xl bg-white dark:bg-white/5 border-2 border-gray-100 dark:border-white/10 focus:border-primary focus:ring-4 focus:ring-primary/10 transition-luxury text-lg font-medium italic"
/>
{loading && (
<div className="absolute inset-y-0 right-6 flex items-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
)}
</div>
<AnimatePresence>
{isOpen && results.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute top-full left-0 right-0 mt-3 bg-white dark:bg-secondary border border-gray-100 dark:border-white/10 rounded-[2rem] shadow-3xl overflow-hidden z-50"
>
<div className="p-4 max-h-[400px] overflow-y-auto custom-scrollbar">
{results.map((result) => (
<button
key={result.place_id}
onClick={() => handleSelectResult(result)}
className="w-full flex items-start gap-4 p-4 hover:bg-primary/5 rounded-2xl transition-luxury text-left group"
>
<div className="w-12 h-12 rounded-xl bg-gray-50 dark:bg-white/5 flex items-center justify-center border border-gray-100 dark:border-white/10 group-hover:bg-primary group-hover:text-white transition-luxury shrink-0">
<MapPin className="h-6 w-6" />
</div>
<div className="flex-1 min-w-0">
<div className="font-black text-gray-900 dark:text-white uppercase tracking-tighter truncate">
{result.structured_formatting.main_text}
</div>
<div className="text-sm text-gray-400 font-medium italic truncate">
{result.structured_formatting.secondary_text}
</div>
</div>
<div className="w-10 h-10 rounded-full border-2 border-primary/20 flex items-center justify-center group-hover:bg-primary group-hover:border-primary transition-luxury">
<Plus className="h-5 w-5 text-primary group-hover:text-white" />
</div>
</button>
))}
</div>
<div className="p-4 bg-gray-50 dark:bg-white/5 border-t border-gray-100 dark:border-white/10 text-center">
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">
Google Places Tarafından Desteklenmektedir
</span>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -1,9 +1,9 @@
import { ItineraryDay, Place } from '@/db/api';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Star, Clock, MapPin, GripVertical, Car } from 'lucide-react';
import { Star, Clock, MapPin, GripVertical, Car, Compass, Camera, Zap, Trash2, Edit3, MessageSquare, MoreVertical, Coffee, Sun, Sunset, Moon } from 'lucide-react';
import api from '@/db/api';
import { useMemo } from 'react';
import { useState } from 'react';
import {
DndContext,
closestCenter,
@ -22,23 +22,50 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { PlaceSearch } from './PlaceSearch';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface TimelineProps {
itinerary: { days: ItineraryDay[] };
onReorder: (dayIndex: number, newItems: Place[]) => void;
onAddPlace: (dayIndex: number, place: Place) => void;
onDeletePlace: (dayIndex: number, placeId: string) => void;
onUpdatePlaceNote: (dayIndex: number, placeId: string, note: string) => void;
onUpdateDayNote: (dayIndex: number, note: string) => void;
onPlaceClick: (id: string) => void;
activePlaceId: string | null;
}
export function Timeline({ itinerary, onReorder, onPlaceClick, activePlaceId }: TimelineProps) {
export function Timeline({
itinerary,
onReorder,
onAddPlace,
onDeletePlace,
onUpdatePlaceNote,
onUpdateDayNote,
onPlaceClick,
activePlaceId
}: TimelineProps) {
return (
<div className="p-6 space-y-8">
<div className="p-4 md:p-6 space-y-8">
{itinerary.days.map((day, dayIndex) => (
<DaySection
key={day.day}
day={day}
dayIndex={dayIndex}
onReorder={onReorder}
onAddPlace={onAddPlace}
onDeletePlace={onDeletePlace}
onUpdatePlaceNote={onUpdatePlaceNote}
onUpdateDayNote={onUpdateDayNote}
onPlaceClick={onPlaceClick}
activePlaceId={activePlaceId}
/>
@ -47,13 +74,30 @@ export function Timeline({ itinerary, onReorder, onPlaceClick, activePlaceId }:
);
}
function DaySection({ day, dayIndex, onReorder, onPlaceClick, activePlaceId }: {
function DaySection({
day,
dayIndex,
onReorder,
onAddPlace,
onDeletePlace,
onUpdatePlaceNote,
onUpdateDayNote,
onPlaceClick,
activePlaceId
}: {
day: ItineraryDay;
dayIndex: number;
onReorder: (dayIndex: number, newItems: Place[]) => void;
onAddPlace: (dayIndex: number, place: Place) => void;
onDeletePlace: (dayIndex: number, placeId: string) => void;
onUpdatePlaceNote: (dayIndex: number, placeId: string, note: string) => void;
onUpdateDayNote: (dayIndex: number, note: string) => void;
onPlaceClick: (id: string) => void;
activePlaceId: string | null;
}) {
const [isEditingDayNote, setIsEditingDayNote] = useState(false);
const [dayNote, setDayNote] = useState(day.notes || '');
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
@ -73,48 +117,56 @@ function DaySection({ day, dayIndex, onReorder, onPlaceClick, activePlaceId }: {
}
};
// Calculate estimated travel times between places (memoized to prevent re-renders)
const travelTimes = useMemo(() => {
const times: { [key: number]: number } = {};
day.items.forEach((_, index) => {
if (index > 0 && index < day.items.length) {
// Rough estimate: 5-15 minutes between places
times[index] = Math.floor(Math.random() * 10) + 5;
}
});
return times;
}, [day.items.length]);
const handleSaveDayNote = () => {
onUpdateDayNote(dayIndex, dayNote);
setIsEditingDayNote(false);
};
return (
<div className="space-y-4">
{/* Day Header with gradient divider */}
<div className="sticky top-0 bg-white/95 backdrop-blur-sm z-10 pb-3 border-b">
<div className="flex items-center justify-between mb-2">
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
Gün {day.day}
<Badge variant="secondary" className="font-normal text-sm">
{day.items.length} Mekan
</Badge>
</h2>
</div>
{(day.total_distance || day.total_duration) && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{day.total_distance && (
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{day.total_distance}
</span>
)}
{day.total_duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{day.total_duration}
</span>
)}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-6"
>
{/* Day Notes */}
<div className="relative group px-2">
{isEditingDayNote ? (
<div className="space-y-2">
<Textarea
value={dayNote}
onChange={(e) => setDayNote(e.target.value)}
placeholder="Bugün için planlarınızı buraya yazın..."
className="bg-gray-50 border-gray-200 rounded-xl p-3 text-sm font-medium min-h-[80px] focus:ring-orange-600/20 focus:border-orange-600"
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setIsEditingDayNote(false)} className="text-[10px] font-bold h-7">Vazgeç</Button>
<Button size="sm" onClick={handleSaveDayNote} className="bg-orange-600 text-white text-[10px] font-bold h-7 px-3 rounded-lg">Kaydet</Button>
</div>
</div>
) : (
<div
onClick={() => setIsEditingDayNote(true)}
className="flex items-start gap-3 p-3 rounded-xl hover:bg-gray-50 transition-all cursor-pointer group/note"
>
<MessageSquare className="h-4 w-4 text-gray-400 group-hover/note:text-orange-600 mt-0.5" />
<div className="flex-1">
{day.notes ? (
<p className="text-sm font-medium text-gray-600">"{day.notes}"</p>
) : (
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-widest group-hover/note:text-orange-600 transition-colors">Bugün için not ekle...</span>
)}
</div>
<Edit3 className="h-3.5 w-3.5 text-gray-300 opacity-0 group-hover/note:opacity-100 transition-all" />
</div>
)}
{/* Gradient divider */}
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-primary/30 to-transparent" />
</div>
<div className="px-2 py-2">
<div className="flex items-center gap-3 text-[10px] font-bold text-orange-600 uppercase tracking-widest">
<MapPin className="h-3.5 w-3.5" />
BAŞLANGIÇ NOKTASI
</div>
<p className="ml-7 mt-1 text-sm font-bold text-gray-900">Göreme Merkez</p>
</div>
<DndContext
@ -126,33 +178,72 @@ function DaySection({ day, dayIndex, onReorder, onPlaceClick, activePlaceId }: {
items={day.items.map(i => i.place_id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
<div className="space-y-4 relative">
<div className="absolute left-[13px] top-4 bottom-4 w-0.5 bg-gray-100 -z-10" />
<div className="flex items-center gap-3 text-[10px] font-bold text-orange-600 uppercase tracking-widest px-2 py-2">
<Sun className="h-3.5 w-3.5" />
SABAH
</div>
{day.items.map((item, index) => (
<div key={item.place_id}>
<SortableItem
item={item}
isActive={activePlaceId === item.place_id}
onClick={() => onPlaceClick(item.place_id)}
/>
<div key={item.place_id} id={`place-${item.place_id}`} className="relative group">
<div className={cn(
"absolute left-[9px] top-5 w-2.5 h-2.5 rounded-full border-2 z-10 transition-all",
activePlaceId === item.place_id ? "bg-orange-600 border-orange-600 scale-125 shadow-lg shadow-orange-600/40" : "bg-white border-gray-300"
)} />
{/* Travel time block between places */}
{index < day.items.length - 1 && (
<div className="flex items-center gap-2 py-2 px-4 text-xs text-muted-foreground">
<div className="w-0.5 h-6 bg-gradient-to-b from-primary/40 to-primary/10 mx-auto" />
<Car className="h-3 w-3" />
<span>{travelTimes[index + 1] || 10} dk sürüş</span>
</div>
)}
<div className="pl-8">
<SortableItem
item={item}
isActive={activePlaceId === item.place_id}
onClick={() => onPlaceClick(item.place_id)}
onDelete={() => onDeletePlace(dayIndex, item.place_id)}
onUpdateNote={(note) => onUpdatePlaceNote(dayIndex, item.place_id, note)}
index={index}
/>
{index < day.items.length - 1 && (
<div className="my-4 ml-2 flex items-center gap-2 text-[10px] font-bold text-gray-400 uppercase tracking-widest italic">
<Car className="h-3.5 w-3.5" />
<span>~15 DK SÜRÜŞ</span>
</div>
)}
</div>
</div>
))}
<div className="pl-8 pt-2">
<PlaceSearch
onPlaceSelect={(place) => onAddPlace(dayIndex, place)}
placeholder="Yeni bir durak ekle..."
/>
</div>
</div>
</SortableContext>
</DndContext>
</div>
</motion.div>
);
}
function SortableItem({ item, isActive, onClick }: { item: Place; isActive: boolean; onClick: () => void }) {
function SortableItem({
item,
isActive,
onClick,
onDelete,
onUpdateNote,
index
}: {
item: Place;
isActive: boolean;
onClick: () => void;
onDelete: () => void;
onUpdateNote: (note: string) => void;
index: number
}) {
const [isEditingNote, setIsEditingNote] = useState(false);
const [note, setNote] = useState(item.notes || '');
const {
attributes,
listeners,
@ -169,83 +260,113 @@ function SortableItem({ item, isActive, onClick }: { item: Place; isActive: bool
opacity: isDragging ? 0.5 : 1,
};
const photoUrl = item.photo_reference ? api.getPhotoUrl(item.photo_reference) : null;
const handleSaveNote = (e: React.MouseEvent) => {
e.stopPropagation();
onUpdateNote(note);
setIsEditingNote(false);
};
const photoUrl = item.photo_reference ? (
item.photo_reference.startsWith('http') ? item.photo_reference : api.getPhotoUrl(item.photo_reference)
) : null;
return (
<div ref={setNodeRef} style={style} className="group relative">
<div ref={setNodeRef} style={style} className="group/item relative">
<Card
className={cn(
"overflow-hidden cursor-pointer transition-all duration-200 border-2",
"overflow-hidden cursor-pointer transition-all border rounded-2xl",
isDragging && "border-dashed",
isActive
? "border-primary shadow-lg ring-2 ring-primary/20"
: "border-gray-200 hover:border-primary hover:shadow-md"
? "border-orange-600 bg-orange-50/30 shadow-md ring-1 ring-orange-600/10"
: "border-gray-100 bg-white hover:border-gray-200 hover:shadow-sm"
)}
onClick={onClick}
>
<CardContent className="p-0 flex">
{/* Image Section - 128px square */}
<div className="w-32 h-32 shrink-0 relative overflow-hidden">
{photoUrl ? (
<img
src={photoUrl}
alt={item.name}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center">
<MapPin className="text-primary/30 h-10 w-10" />
</div>
)}
{/* Time badge overlay */}
<div className="absolute top-2 left-2">
<Badge className="bg-white/90 backdrop-blur-sm text-gray-900 border-none hover:bg-white shadow-sm">
{item.start_time}
</Badge>
</div>
<CardContent className="p-3 flex items-center gap-4">
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center text-gray-900 font-bold text-xs shrink-0">
{index + 1}
</div>
{/* Rating badge - top right */}
{item.rating && (
<div className="absolute top-2 right-2">
<Badge className="bg-primary/90 backdrop-blur-sm text-white border-none hover:bg-primary shadow-sm">
<Star className="h-3 w-3 fill-white mr-1" />
{item.rating}
</Badge>
</div>
)}
</div>
{/* Content Section */}
<div className="flex-1 p-4 flex flex-col justify-between min-w-0">
<div>
<div className="flex items-start justify-between gap-2 mb-1">
<h4 className="font-bold text-base text-gray-900 line-clamp-1 flex-1">{item.name}</h4>
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-100 rounded transition-colors opacity-0 group-hover:opacity-100"
>
<GripVertical className="h-4 w-4 text-gray-400" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h4 className="text-sm font-bold text-gray-900 truncate">{item.name}</h4>
<Badge variant="secondary" className="bg-gray-100 text-gray-500 text-[9px] font-bold px-1.5 py-0 h-4">
{item.category}
</Badge>
</div>
</div>
<p className="text-sm text-muted-foreground line-clamp-2 leading-relaxed">{item.description}</p>
</div>
{/* Meta Information */}
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-2">
<div className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
<span>{item.estimated_duration_minutes}dk</span>
</div>
<div className="flex items-center gap-1 truncate">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{item.category}</span>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-all">
<div
{...attributes}
{...listeners}
className="p-1 hover:bg-gray-100 rounded text-gray-400 cursor-grab active:cursor-grabbing"
>
<GripVertical className="h-3.5 w-3.5" />
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreVertical className="h-3.5 w-3.5 text-gray-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36 rounded-xl p-1">
<DropdownMenuItem onClick={() => setIsEditingNote(true)} className="text-xs font-bold gap-2">
<Edit3 className="h-3.5 w-3.5 text-orange-600" />
Not Düzenle
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onDelete(); }} className="text-xs font-bold gap-2 text-red-500">
<Trash2 className="h-3.5 w-3.5" />
Kaldır
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex items-center gap-3 mt-1">
<div className="flex items-center gap-1 text-[10px] font-medium text-gray-500">
<Clock className="h-3 w-3" />
{item.start_time || '09:00'} - 11:00
</div>
{item.rating && (
<div className="flex items-center gap-1 text-[10px] font-medium text-gray-500">
<Star className="h-3 w-3 fill-orange-400 text-orange-400" />
{item.rating}
</div>
)}
</div>
{isEditingNote ? (
<div className="mt-2 space-y-2" onClick={(e) => e.stopPropagation()}>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Not ekle..."
className="bg-gray-50 border-gray-200 rounded-lg p-2 text-xs font-medium min-h-[50px]"
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setIsEditingNote(false)} className="text-[10px] font-bold h-6">Vazgeç</Button>
<Button size="sm" onClick={handleSaveNote} className="bg-orange-600 text-white text-[10px] font-bold h-6 px-3 rounded-lg">Kaydet</Button>
</div>
</div>
) : (
item.notes && (
<div className="mt-2 p-2 bg-orange-50 rounded-lg border-l-2 border-orange-600">
<p className="text-[11px] font-medium italic text-orange-800">
{item.notes}
</p>
</div>
)
)}
</div>
{photoUrl && (
<div className="w-16 h-16 rounded-xl overflow-hidden shrink-0">
<img src={photoUrl} alt={item.name} className="w-full h-full object-cover" />
</div>
)}
</CardContent>
</Card>
</div>
);
}
}

View File

@ -13,6 +13,7 @@ export interface Place {
estimated_duration_minutes: number;
start_time: string;
end_time: string;
notes?: string;
}
export interface ItineraryDay {
@ -20,6 +21,7 @@ export interface ItineraryDay {
items: Place[];
total_distance?: string;
total_duration?: string;
notes?: string;
}
export interface Trip {
@ -121,4 +123,4 @@ const api = {
}
};
export default api;
export default api;

View File

@ -4,35 +4,35 @@
@layer base {
:root {
/* Backgrounds */
--background: 0 0% 98%;
--foreground: 222 47% 11%;
/* Backgrounds - Warmer White */
--background: 30 20% 98%;
--foreground: 24 30% 10%;
/* Card & Surfaces */
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--card-foreground: 24 30% 10%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--popover-foreground: 24 30% 10%;
/* Primary - Blue (#3B82F6) */
--primary: 217 91% 60%;
/* Primary - Cappadocia Terracotta (#E36414) */
--primary: 24 85% 50%;
--primary-foreground: 0 0% 100%;
--primary-dark: 221 83% 53%;
--primary-dark: 24 85% 40%;
/* Secondary - Pink (#EC4899) */
--secondary: 328 86% 70%;
/* Secondary - Deep Slate Blue for contrast (#1B263B) */
--secondary: 220 37% 17%;
--secondary-foreground: 0 0% 100%;
/* Muted & Neutral */
--muted: 220 14% 96%;
--muted-foreground: 220 9% 46%;
--muted: 30 20% 94%;
--muted-foreground: 24 10% 45%;
/* Accent - Very light blue */
--accent: 217 91% 95%;
--accent-foreground: 217 91% 30%;
/* Accent - Golden Hour (#FFB703) */
--accent: 45 100% 51%;
--accent-foreground: 24 30% 10%;
/* Status Colors */
--destructive: 0 84.2% 60.2%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--success: 142 71% 45%;
--success-foreground: 0 0% 100%;
@ -42,41 +42,45 @@
--info-foreground: 0 0% 100%;
/* Borders & Inputs */
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 217 91% 60%;
--border: 24 20% 90%;
--input: 24 20% 90%;
--ring: 24 85% 50%;
/* Radius */
--radius: 1rem;
/* Radius - Standard but soft */
--radius: 0.75rem;
/* Custom Gradients */
--gradient-primary: linear-gradient(135deg, hsl(24 85% 50%), hsl(45 100% 51%));
--gradient-surface: linear-gradient(180deg, rgba(255,255,255,0.8) 0%, rgba(255,255,255,0.4) 100%);
}
.dark {
/* Backgrounds */
--background: 222 47% 11%;
--foreground: 0 0% 98%;
/* Backgrounds - Deep Night Blue/Grey */
--background: 224 45% 8%;
--foreground: 30 20% 98%;
/* Card & Surfaces */
--card: 222 47% 13%;
--card-foreground: 0 0% 98%;
--popover: 222 47% 13%;
--popover-foreground: 0 0% 98%;
--card: 224 45% 12%;
--card-foreground: 30 20% 98%;
--popover: 224 45% 12%;
--popover-foreground: 30 20% 98%;
/* Primary - Blue */
--primary: 217 91% 60%;
/* Primary - Stays consistent but slightly more vibrant */
--primary: 24 85% 55%;
--primary-foreground: 0 0% 100%;
--primary-dark: 221 83% 53%;
--primary-dark: 24 85% 45%;
/* Secondary - Pink */
--secondary: 328 86% 70%;
--secondary-foreground: 0 0% 100%;
/* Secondary */
--secondary: 224 30% 20%;
--secondary-foreground: 30 20% 98%;
/* Muted & Neutral */
--muted: 217 33% 17%;
--muted-foreground: 215 20% 65%;
--muted: 224 30% 15%;
--muted-foreground: 224 15% 70%;
/* Accent - Dark blue */
--accent: 217 91% 20%;
--accent-foreground: 217 91% 85%;
/* Accent */
--accent: 45 100% 60%;
--accent-foreground: 224 45% 8%;
/* Status Colors */
--destructive: 0 62.8% 30.6%;
@ -89,9 +93,11 @@
--info-foreground: 0 0% 100%;
/* Borders & Inputs */
--border: 217 33% 17%;
--input: 217 33% 17%;
--ring: 217 91% 60%;
--border: 224 30% 18%;
--input: 224 30% 18%;
--ring: 24 85% 55%;
--gradient-surface: linear-gradient(180deg, rgba(30,41,59,0.8) 0%, rgba(30,41,59,0.4) 100%);
}
}
@ -100,90 +106,55 @@
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
@apply bg-background text-foreground antialiased selection:bg-primary/20 selection:text-primary;
}
}
.gradient-text {
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--secondary)));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
/* Custom Utilities */
.glass {
@apply bg-white/10 backdrop-blur-xl border border-white/20;
}
.cappadocia-bg {
background-image: linear-gradient(rgba(0,0,0,0.3), rgba(0,0,0,0.3)), url('https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2000');
.glass-dark {
@apply bg-black/20 backdrop-blur-xl border border-white/10;
}
.card-shine {
@apply relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent;
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
.text-gradient {
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-accent;
}
.cappadocia-hero {
background: linear-gradient(to bottom, rgba(0,0,0,0.2), rgba(0,0,0,0.7)), url('https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2000');
background-size: cover;
background-position: center;
background-attachment: fixed;
}
.glassmorphism {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark .glassmorphism {
background: rgba(31, 41, 55, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
::-webkit-scrollbar-track {
@apply bg-transparent;
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/20 rounded-full hover:bg-muted-foreground/40 transition-colors;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
/* Hide scrollbar for horizontal scroll */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Navbar shadow on scroll */
.navbar-shadow {
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
/* Smooth transitions */
.transition-smooth {
transition: all 200ms ease;
}
/* Backdrop blur */
.backdrop-blur-navbar {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* Notification badge */
.notification-badge {
position: absolute;
top: -4px;
right: -4px;
display: flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
font-size: 10px;
font-weight: 600;
color: white;
background-color: hsl(var(--destructive));
border-radius: 9999px;
border: 2px solid hsl(var(--background));
}
/* Smooth Transitions */
.transition-luxury {
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
}

View File

@ -5,10 +5,11 @@ import api, { Trip } from '@/db/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Loader2, Calendar, MapPin, Trash2, ChevronRight, PlusCircle, Compass } from 'lucide-react';
import { Loader2, Calendar, MapPin, Trash2, ChevronRight, PlusCircle, Zap, Compass as ExploreIcon } from 'lucide-react';
import { format } from 'date-fns';
import { tr } from 'date-fns/locale';
import { toast } from 'sonner';
import { motion, AnimatePresence } from 'framer-motion';
import {
AlertDialog,
AlertDialogAction,
@ -56,16 +57,23 @@ export default function AccountPage() {
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<Card className="max-w-md w-full p-8 text-center space-y-6">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto">
<Compass className="h-8 w-8 text-orange-600" />
<div className="min-h-screen flex items-center justify-center p-6 bg-secondary relative overflow-hidden">
<div className="absolute inset-0 z-0 opacity-10">
<img
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
alt="bg"
className="w-full h-full object-cover grayscale"
/>
</div>
<Card className="max-w-sm w-full p-8 text-center space-y-6 bg-white/5 backdrop-blur-xl border-white/10 rounded-3xl shadow-2xl relative z-10">
<div className="w-16 h-16 bg-primary/20 rounded-2xl flex items-center justify-center mx-auto border border-primary/40">
<ExploreIcon className="h-8 w-8 text-primary" />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-bold">Hesabım</h2>
<p className="text-gray-500">Gezilerinizi kaydetmek ve yönetmek için giriş yapmalısınız.</p>
<h2 className="text-3xl font-black text-white tracking-tighter uppercase leading-none">HESABIM</h2>
<p className="text-white/60 font-medium italic text-sm">Gezilerinizi yönetmek için giriş yapmalısınız.</p>
</div>
<Button className="w-full bg-orange-600" asChild>
<Button className="w-full h-14 bg-primary hover:bg-primary-dark text-white font-black uppercase tracking-widest rounded-xl" asChild>
<Link to="/login">Giriş Yap</Link>
</Button>
</Card>
@ -74,135 +82,153 @@ export default function AccountPage() {
}
return (
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
<div className="max-w-5xl mx-auto px-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">Gezilerim</h1>
<p className="text-gray-500 mt-1">Planladığınız ve kaydettiğiniz tüm Kapadokya rotaları.</p>
<div className="min-h-screen bg-background pt-20 pb-12">
<div className="max-w-4xl mx-auto px-6 space-y-10">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-10 border-b border-border">
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-primary uppercase tracking-widest">Kişisel Arşiv</span>
<Badge className="bg-primary/10 text-primary border-none font-black px-2 py-0.5 rounded-full uppercase text-[9px]">
{trips.length} Rota
</Badge>
</div>
<h1 className="text-4xl md:text-5xl font-black text-gray-900 dark:text-white tracking-tighter uppercase leading-none">
GEZİLERİM
</h1>
<p className="text-lg text-gray-500 font-medium italic">Planladığınız Kapadokya efsaneleri.</p>
</div>
<Button className="bg-orange-600 shadow-lg shadow-orange-200" asChild>
<Button className="h-14 px-8 bg-primary hover:bg-primary-dark text-white font-black uppercase tracking-widest rounded-xl shadow-lg shadow-primary/20 gap-2 group transition-luxury" asChild>
<Link to="/planner">
<PlusCircle className="h-4 w-4 mr-2" />
Yeni Plan Oluştur
<PlusCircle className="h-5 w-5 group-hover:rotate-90 transition-transform" />
Yeni Plan
</Link>
</Button>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<Loader2 className="h-10 w-10 animate-spin text-orange-600" />
<p className="text-gray-500 font-medium">Gezileriniz yükleniyor...</p>
<Loader2 className="h-12 w-12 animate-spin text-primary opacity-40" />
<p className="text-gray-400 font-black uppercase tracking-widest text-[9px]">Yükleniyor...</p>
</div>
) : trips.length === 0 ? (
<Card className="border-dashed border-2 py-20 text-center space-y-4">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto">
<Compass className="h-8 w-8 text-gray-400" />
</div>
<div className="space-y-2">
<h3 className="text-xl font-bold text-gray-900">Henüz bir planınız yok</h3>
<p className="text-gray-500 max-w-sm mx-auto">
İlk Kapadokya rotanızı oluşturmak için hemen planlayıcıyı kullanmaya başlayın.
</p>
</div>
<Button variant="outline" className="mt-4" asChild>
<Link to="/planner">Planlamaya Başla</Link>
</Button>
</Card>
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
>
<Card className="bg-gray-50 dark:bg-white/5 border-dashed border-2 border-gray-100 dark:border-white/10 py-20 text-center space-y-6 rounded-3xl">
<div className="w-20 h-20 bg-white dark:bg-white/5 rounded-2xl flex items-center justify-center mx-auto border border-gray-100 dark:border-white/10">
<ExploreIcon className="h-8 w-8 text-gray-200" />
</div>
<div className="space-y-2">
<h3 className="text-2xl font-black text-gray-900 dark:text-white tracking-tighter uppercase">Planınız yok</h3>
<p className="text-gray-500 font-medium italic text-sm max-w-xs mx-auto">
İlk Kapadokya rotanızı oluşturun.
</p>
</div>
<Button variant="outline" className="h-12 px-8 rounded-xl border-2 border-primary text-primary hover:bg-primary hover:text-white font-black uppercase tracking-widest transition-luxury" asChild>
<Link to="/planner">Hemen Planla</Link>
</Button>
</Card>
</motion.div>
) : (
<div className="grid gap-6">
{trips.map((trip) => (
<Card key={trip.id} className="group overflow-hidden hover:shadow-xl transition-all duration-300 border-none shadow-sm">
<CardContent className="p-0">
<div className="flex flex-col md:flex-row">
{/* Preview Image */}
<div className="md:w-64 h-48 md:h-auto bg-gray-200 relative overflow-hidden">
<img
src="https://miaoda-site-img.s3cdn.medo.dev/images/KLing_8ea8dda5-57a3-4533-bd11-28440db86c34.jpg"
alt={trip.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute inset-0 bg-black/20" />
<div className="absolute top-4 left-4">
<Badge className="bg-white/90 text-gray-900 hover:bg-white border-none shadow-sm">
{trip.itinerary.days.length} Gün
</Badge>
</div>
</div>
{/* Content */}
<div className="flex-1 p-6 md:p-8 flex flex-col justify-between">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h3 className="text-2xl font-bold text-gray-900 group-hover:text-orange-600 transition-colors">
{trip.title}
</h3>
<div className="flex items-center gap-3 text-sm text-gray-500">
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<span>{format(new Date(trip.start_date), 'd MMMM yyyy', { locale: tr })}</span>
</div>
<div className="flex items-center gap-1.5">
<MapPin className="h-4 w-4" />
<span>{trip.destination}</span>
</div>
<AnimatePresence>
{trips.map((trip, idx) => (
<motion.div
key={trip.id}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
>
<Card className="group overflow-hidden bg-white dark:bg-white/5 border-2 border-gray-50 dark:border-white/5 rounded-3xl shadow-md hover:shadow-xl transition-luxury">
<CardContent className="p-0">
<div className="flex flex-col sm:flex-row">
<div className="sm:w-56 h-48 sm:h-auto bg-gray-200 relative overflow-hidden shrink-0">
<img
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
alt={trip.title}
className="w-full h-full object-cover group-hover:scale-105 transition-luxury duration-700"
/>
<div className="absolute top-4 left-4">
<Badge className="bg-primary text-white border-none font-black px-2 py-0.5 rounded-full uppercase text-[8px]">
<Zap className="h-2 w-2 mr-1" />
{trip.itinerary.days.length} GÜN
</Badge>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-gray-400 hover:text-red-600 hover:bg-red-50">
<Trash2 className="h-5 w-5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Planı silmek istediğinize emin misiniz?</AlertDialogTitle>
<AlertDialogDescription>
Bu işlem geri alınamaz. Kaydettiğiniz tüm rota verileri silinecektir.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>İptal</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(trip.id)}
className="bg-red-600 hover:bg-red-700"
>
Sil
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div className="flex-1 p-6 flex flex-col justify-between">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-[9px] font-black text-primary uppercase tracking-widest">{trip.destination}</span>
</div>
<h3 className="text-2xl font-black text-gray-900 dark:text-white tracking-tighter uppercase leading-none group-hover:text-primary transition-colors">
{trip.title}
</h3>
<div className="flex items-center gap-4 pt-1">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-gray-500 italic">
<Calendar className="h-3.5 w-3.5 text-primary" />
<span>{format(new Date(trip.start_date), 'd MMM yyyy', { locale: tr })}</span>
</div>
</div>
</div>
<div className="mt-8 flex items-center justify-between">
<div className="flex -space-x-2">
{trip.itinerary.days[0].items.slice(0, 3).map((item, i) => (
<div key={i} className="w-8 h-8 rounded-full border-2 border-white bg-gray-100 flex items-center justify-center overflow-hidden">
<img src={api.getPhotoUrl(item.place_id)} alt="" className="w-full h-full object-cover" onError={(e) => (e.currentTarget.src = 'https://via.placeholder.com/100')} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 rounded-xl text-gray-300 hover:text-red-600">
<Trash2 className="h-5 w-5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="rounded-3xl p-8 bg-secondary border-white/10">
<AlertDialogHeader className="space-y-3">
<AlertDialogTitle className="text-2xl font-black text-white tracking-tighter uppercase">Planı Sil?</AlertDialogTitle>
<AlertDialogDescription className="text-white/60 font-medium italic text-base">
Silmek istediğinize emin misiniz?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="pt-6">
<AlertDialogCancel className="h-12 px-8 rounded-xl font-bold bg-white/5 text-white border-white/10">Hayır</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(trip.id)}
className="h-12 px-8 rounded-xl font-black bg-red-600 hover:bg-red-700 text-white"
>
Evet, Sil
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div className="mt-8 flex items-center justify-between border-t border-gray-50 dark:border-white/5 pt-6">
<div className="flex -space-x-2">
{trip.itinerary.days[0].items.slice(0, 3).map((item, i) => (
<div key={i} className="w-10 h-10 rounded-xl border-2 border-white dark:border-secondary bg-gray-100 overflow-hidden shadow-md">
<img
src={item.photo_reference ? (item.photo_reference.startsWith('http') ? item.photo_reference : api.getPhotoUrl(item.photo_reference)) : 'https://via.placeholder.com/100'}
alt=""
className="w-full h-full object-cover"
/>
</div>
))}
</div>
))}
{trip.itinerary.days[0].items.length > 3 && (
<div className="w-8 h-8 rounded-full border-2 border-white bg-gray-100 flex items-center justify-center text-[10px] font-bold text-gray-500">
+{trip.itinerary.days[0].items.length - 3}
</div>
)}
<Button className="h-11 px-6 rounded-xl bg-secondary dark:bg-white/10 hover:bg-primary hover:text-white transition-luxury font-black uppercase tracking-widest text-[9px] gap-2" asChild>
<Link to={`/trip/${trip.id}`}>
<ChevronRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
<Button className="rounded-xl group/btn" asChild>
<Link to={`/trip/${trip.id}`}>
Görüntüle
<ChevronRight className="ml-1 h-4 w-4 group-hover/btn:translate-x-1 transition-transform" />
</Link>
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</CardContent>
</Card>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</div>
</div>
);
}
}

View File

@ -3,7 +3,8 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Search, MapPin, Star, Clock, Filter, Sparkles, Compass } from 'lucide-react';
import { Search, Star, Clock, Filter, Sparkles, Heart, Share2, ArrowUpRight, Camera } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
const CATEGORIES = [
{ id: 'all', label: 'Tümü' },
@ -33,7 +34,7 @@ const PLACES = [
reviews: 8500,
duration: '1 Saat',
image: 'https://miaoda-site-img.s3cdn.medo.dev/images/KLing_8ea8dda5-57a3-4533-bd11-28440db86c34.jpg',
description: 'Kapadokya\'nın en yüksek noktası. Tüm bölgeyi panoramik olarak görebileceğiniz devasa bir doğal kaya kalesi.',
description: 'Kapadokya\'s en yüksek noktası. Tüm bölgeyi panoramik olarak görebileceğiniz devasa bir doğal kaya kalesi.',
},
{
id: '3',
@ -89,125 +90,188 @@ export default function ExplorePage() {
});
return (
<div className="min-h-screen bg-gray-50 pt-24 pb-12">
<div className="max-w-7xl mx-auto px-6">
{/* Header Section */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-12">
<div className="space-y-4 max-w-2xl">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-orange-100 text-orange-700 text-xs font-bold uppercase tracking-wider">
<Sparkles className="h-3 w-3" />
Keşfet
</div>
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 tracking-tight">
Kapadokya'nın <span className="text-orange-600">En İyileri</span>
</h1>
<p className="text-gray-500 text-lg leading-relaxed">
Bölgenin en popüler noktalarını ve saklı kalmış eşsiz rotalarını keşfedin. Google onaylı verilerle seyahatinizi zenginleştirin.
</p>
</div>
<div className="min-h-screen bg-background selection:bg-primary/20 pb-20">
{/* Immersive Header */}
<section className="relative h-[40vh] flex items-center justify-center overflow-hidden mb-12">
<div className="absolute inset-0 z-0 scale-105">
<img
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
alt="Explore Hero"
className="w-full h-full object-cover grayscale-[0.2]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/20 to-transparent z-10" />
</div>
<div className="container relative z-20 px-6 text-center space-y-6">
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-white text-[9px] font-black uppercase tracking-widest"
>
<Sparkles className="h-3.5 w-3.5 text-accent" />
Efsanevi Duraklar
</motion.div>
<div className="relative w-full md:w-80">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<motion.h1
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-4xl md:text-6xl font-black text-white tracking-tighter leading-none uppercase"
>
KAPADOKYA <span className="text-primary uppercase">KEŞFİ</span>
</motion.h1>
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="max-w-xl mx-auto relative group"
>
<Search className="absolute left-5 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 group-focus-within:text-primary transition-colors" />
<Input
placeholder="Mekan veya aktivite ara..."
className="pl-10 h-12 bg-white border-gray-200 rounded-xl shadow-sm focus:ring-orange-500"
placeholder="Bir efsane veya aktivite arayın..."
className="w-full h-14 pl-12 pr-6 bg-white/10 backdrop-blur-xl border-white/20 text-white placeholder:text-white/40 rounded-2xl text-base font-bold focus:bg-white focus:text-gray-900 transition-luxury shadow-2xl"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</motion.div>
</div>
</section>
{/* Categories Bar */}
<div className="flex items-center gap-2 overflow-x-auto pb-4 mb-8 scrollbar-hide">
<Button variant="outline" size="sm" className="shrink-0 border-gray-200 bg-white">
<Filter className="h-4 w-4 mr-2" />
<div className="container px-6">
{/* Categories Carousel */}
<div className="flex items-center gap-3 overflow-x-auto pb-6 mb-12 no-scrollbar">
<Button variant="outline" className="h-11 px-6 rounded-xl border-2 border-gray-100 dark:border-white/10 font-black uppercase tracking-widest text-[10px] shrink-0 bg-white/5 backdrop-blur-md">
<Filter className="h-3.5 w-3.5 mr-2 text-primary" />
Filtrele
</Button>
<div className="w-px h-6 bg-gray-200 mx-2 shrink-0" />
<div className="w-px h-6 bg-gray-100 dark:bg-white/10 mx-1 shrink-0" />
{CATEGORIES.map((cat) => (
<Button
key={cat.id}
variant={selectedCategory === cat.id ? 'default' : 'outline'}
size="sm"
className={`shrink-0 rounded-full font-medium ${
selectedCategory === cat.id
? 'bg-orange-600 hover:bg-orange-700 border-orange-600'
: 'bg-white border-gray-200 hover:border-orange-200 hover:bg-orange-50'
}`}
onClick={() => setSelectedCategory(cat.id)}
className={`h-11 px-8 rounded-xl font-black uppercase tracking-widest text-[10px] shrink-0 transition-luxury ${selectedCategory === cat.id
? 'bg-primary text-white shadow-lg shadow-primary/20'
: 'bg-white/5 border-2 border-gray-100 dark:border-white/10 text-gray-400 hover:border-primary/40 hover:text-primary'
}`}
>
{cat.label}
</Button>
))}
</div>
{/* Places Grid */}
{filteredPlaces.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredPlaces.map((place) => (
<Card key={place.id} className="group overflow-hidden border-none shadow-sm hover:shadow-xl transition-all duration-300 rounded-2xl bg-white">
<CardContent className="p-0">
<div className="relative h-64 overflow-hidden">
<img
src={place.image}
alt={place.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute top-4 left-4 flex gap-2">
<Badge className="bg-white/90 backdrop-blur-sm text-gray-900 border-none shadow-sm capitalize">
{CATEGORIES.find(c => c.id === place.category)?.label}
</Badge>
</div>
<div className="absolute bottom-4 right-4">
<Badge className="bg-orange-600/90 backdrop-blur-sm text-white border-none shadow-sm flex items-center gap-1">
<Star className="h-3 w-3 fill-white" />
{place.rating}
</Badge>
</div>
</div>
<div className="p-6 space-y-3">
<div className="flex items-start justify-between gap-4">
<h3 className="text-xl font-bold text-gray-900 group-hover:text-orange-600 transition-colors">
{place.name}
</h3>
</div>
<p className="text-gray-500 text-sm line-clamp-2 leading-relaxed">
{place.description}
</p>
<div className="pt-4 flex items-center justify-between border-t border-gray-50">
<div className="flex items-center gap-4 text-xs text-gray-400 font-medium">
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
<span>{place.duration}</span>
{/* Results Grid */}
<AnimatePresence mode="wait">
{filteredPlaces.length > 0 ? (
<motion.div
key="grid"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="grid md:grid-cols-2 lg:grid-cols-3 gap-8"
>
{filteredPlaces.map((place, index) => (
<motion.div
key={place.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<Card className="group overflow-hidden border-2 border-gray-50 dark:border-white/5 bg-white dark:bg-white/5 rounded-3xl shadow-lg hover:shadow-2xl hover:-translate-y-1 transition-luxury">
<CardContent className="p-0">
<div className="relative h-64 overflow-hidden">
<img
src={place.image}
alt={place.name}
className="w-full h-full object-cover transition-luxury duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-luxury" />
<div className="absolute top-4 left-4">
<Badge className="bg-white/10 backdrop-blur-md text-white border-white/20 font-black uppercase tracking-widest text-[9px] px-3 py-0.5 rounded-full">
{CATEGORIES.find(c => c.id === place.category)?.label}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<Compass className="h-3.5 w-3.5" />
<span>{place.reviews.toLocaleString()} Yorum</span>
<div className="absolute bottom-4 left-4 right-4 flex items-center justify-between opacity-0 group-hover:opacity-100 translate-y-2 group-hover:translate-y-0 transition-luxury">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center text-white hover:bg-primary transition-colors cursor-pointer">
<Heart className="h-4 w-4" />
</div>
<div className="w-8 h-8 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center text-white hover:bg-primary transition-colors cursor-pointer">
<Share2 className="h-4 w-4" />
</div>
</div>
<Button className="bg-primary text-white font-black uppercase tracking-widest text-[9px] rounded-full h-8 px-4">
Planla
</Button>
</div>
<div className="absolute top-4 right-4">
<Badge className="bg-accent text-gray-900 border-none font-black text-xs px-2 py-0.5 rounded-lg flex items-center gap-1 shadow-lg">
<Star className="h-3 w-3 fill-gray-900" />
{place.rating}
</Badge>
</div>
</div>
<Button variant="ghost" size="sm" className="text-orange-600 font-bold hover:text-orange-700 hover:bg-orange-50 p-0 h-auto">
Detaylar
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="py-24 text-center space-y-4">
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
<Search className="h-10 w-10 text-gray-300" />
</div>
<h3 className="text-2xl font-bold text-gray-900">Sonuç Bulunamadı</h3>
<p className="text-gray-500 max-w-sm mx-auto">
Aramanıza uygun mekan bulunamadı. Farklı bir anahtar kelime veya kategori deneyin.
</p>
<Button variant="link" onClick={() => { setSearchQuery(''); setSelectedCategory('all'); }} className="text-orange-600 font-bold">
Tüm Mekanları Göster
</Button>
</div>
)}
<div className="p-6 md:p-8 space-y-4">
<div className="space-y-1">
<h3 className="text-2xl font-black text-gray-900 dark:text-white tracking-tighter uppercase leading-tight group-hover:text-primary transition-colors">
{place.name}
</h3>
<p className="text-gray-500 font-medium italic leading-relaxed text-sm line-clamp-2">
"{place.description}"
</p>
</div>
<div className="pt-6 flex items-center justify-between border-t border-gray-50 dark:border-white/5">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-1.5 text-[9px] font-black text-gray-400 uppercase tracking-widest">
<Clock className="h-3.5 w-3.5 text-primary" />
<span>{place.duration}</span>
</div>
<div className="flex items-center gap-1.5 text-[9px] font-black text-gray-400 uppercase tracking-widest">
<Camera className="h-3.5 w-3.5 text-primary" />
<span>Görsel</span>
</div>
</div>
<button className="w-10 h-10 rounded-xl bg-gray-50 dark:bg-white/10 flex items-center justify-center text-gray-400 hover:bg-primary hover:text-white transition-luxury group/btn">
<ArrowUpRight className="h-5 w-5 group-hover/btn:rotate-45 transition-transform" />
</button>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="py-20 text-center space-y-6"
>
<div className="w-24 h-24 bg-gray-50 dark:bg-white/5 rounded-2xl flex items-center justify-center mx-auto mb-6 border-2 border-dashed border-gray-200 dark:border-white/10">
<Search className="h-10 w-10 text-gray-200" />
</div>
<div className="space-y-2">
<h3 className="text-2xl font-black text-gray-900 dark:text-white tracking-tighter uppercase">BULUNAMADI</h3>
<p className="text-base text-gray-500 font-medium italic max-w-xs mx-auto">
Farklı bir keşif terimi deneyin.
</p>
</div>
<Button
variant="outline"
onClick={() => { setSearchQuery(''); setSelectedCategory('all'); }}
className="h-12 px-8 rounded-xl font-black uppercase tracking-widest text-[10px] border-2 border-primary text-primary hover:bg-primary hover:text-white transition-luxury"
>
Tümü
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);

View File

@ -1,170 +1,268 @@
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Sparkles, MapPin, Calendar, Compass, ShieldCheck, Zap, ArrowRight, Heart } from 'lucide-react';
import { motion } from 'framer-motion';
import { Sparkles, MapPin, Calendar, Compass, ShieldCheck, Zap, ArrowRight, Star, ArrowUpRight, Play, Globe } from 'lucide-react';
import { motion, useScroll, useTransform } from 'framer-motion';
import { useRef } from 'react';
const FeatureCard = ({ icon: Icon, title, description }: { icon: any, title: string, description: string }) => (
<div className="p-8 bg-white/60 backdrop-blur-md rounded-2xl border border-white/40 shadow-sm hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
<div className="w-14 h-14 bg-orange-100 rounded-xl flex items-center justify-center mb-6">
<Icon className="h-7 w-7 text-orange-600" />
const FeatureCard = ({ icon: Icon, title, description, index }: { icon: any, title: string, description: string, index: number }) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="group p-6 bg-white/40 dark:bg-white/5 backdrop-blur-xl rounded-3xl border border-white/20 dark:border-white/10 shadow-luxury hover:shadow-xl transition-luxury"
>
<div className="w-12 h-12 bg-gradient-to-br from-primary/20 to-accent/20 rounded-xl flex items-center justify-center mb-6 group-hover:scale-110 transition-luxury">
<Icon className="h-6 w-6 text-primary" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">{title}</h3>
<p className="text-gray-600 leading-relaxed">{description}</p>
</div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-3 tracking-tight">{title}</h3>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed text-sm font-medium">{description}</p>
</motion.div>
);
export default function LandingPage() {
return (
<div className="min-h-screen bg-[#FFF9F5]">
{/* Hero Section */}
<section className="relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden">
{/* Background blobs */}
<div className="absolute top-0 right-0 -translate-y-1/4 translate-x-1/4 w-[600px] h-[600px] bg-orange-200/30 rounded-full blur-3xl -z-10" />
<div className="absolute bottom-0 left-0 translate-y-1/4 -translate-x-1/4 w-[500px] h-[500px] bg-blue-100/30 rounded-full blur-3xl -z-10" />
const containerRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start start", "end end"]
});
<div className="max-w-7xl mx-auto px-6 text-center">
const heroY = useTransform(scrollYProgress, [0, 1], [0, 200]);
const heroOpacity = useTransform(scrollYProgress, [0, 0.2], [1, 0]);
return (
<div className="min-h-screen bg-background selection:bg-primary/20" ref={containerRef}>
{/* Hero Section */}
<section className="relative h-[90vh] flex items-center justify-center overflow-hidden">
{/* Cinematic Background */}
<motion.div
style={{ y: heroY, opacity: heroOpacity }}
className="absolute inset-0 z-0"
>
<div className="absolute inset-0 bg-black/40 z-10" />
<img
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
alt="Cappadocia"
className="w-full h-full object-cover scale-105"
/>
</motion.div>
{/* Hero Content */}
<div className="container relative z-20 px-6 text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-orange-100 text-orange-700 text-sm font-bold mb-8"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, ease: "easeOut" }}
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-white text-[10px] font-bold mb-8 tracking-widest uppercase"
>
<Sparkles className="h-4 w-4" />
Yapay Zeka Destekli Gezi Planlayıcısı
<Sparkles className="h-3.5 w-3.5 text-accent" />
Yapay Zeka Destekli Premium Deneyim
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-5xl lg:text-7xl font-extrabold text-gray-900 mb-8 leading-[1.1] tracking-tight"
transition={{ duration: 0.8, delay: 0.2 }}
className="text-5xl md:text-7xl lg:text-8xl font-black text-white mb-8 leading-[0.95] tracking-tighter"
>
Kapadokya'yı <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-500">
Yeniden Keşfedin
KAPADOKYA <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary via-accent to-primary animate-gradient">
EFSANESİ
</span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="text-xl text-gray-600 max-w-2xl mx-auto mb-12 leading-relaxed"
transition={{ duration: 0.8, delay: 0.4 }}
className="text-lg md:text-xl text-white/80 max-w-2xl mx-auto mb-12 leading-relaxed font-medium italic"
>
Saniyeler içinde kişiselleştirilmiş rotalar oluşturun. Google Maps tarafından doğrulanmış mekanlar ve yapay zeka tarafından hazırlanan akıllı programlar.
"Sıradan bir gezi değil, ruhunuza dokunacak bir keşif hikayesi. Saniyeler içinde size özel kurgulanmış premium rotalar."
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
transition={{ duration: 0.8, delay: 0.6 }}
className="flex flex-col sm:flex-row items-center justify-center gap-4"
>
<Button size="lg" className="h-14 px-10 text-lg font-bold bg-orange-600 hover:bg-orange-700 shadow-xl shadow-orange-200 rounded-xl" asChild>
<Button size="lg" className="h-14 px-8 text-lg font-bold bg-primary hover:bg-primary-dark shadow-xl shadow-primary/20 rounded-2xl transition-luxury group" asChild>
<Link to="/planner">
Hemen Planla
<ArrowRight className="ml-2 h-5 w-5" />
Hemen Keşfet
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</Link>
</Button>
<Button size="lg" variant="outline" className="h-14 px-10 text-lg font-bold border-2 rounded-xl bg-white/50 backdrop-blur-sm" asChild>
<Link to="/explore">Keşfetmeye Başla</Link>
</Button>
<button className="h-14 px-8 text-lg font-bold bg-white/10 backdrop-blur-xl border border-white/20 text-white rounded-2xl hover:bg-white/20 transition-luxury flex items-center gap-2">
<Play className="h-5 w-5 fill-white" />
Tanıtımı İzle
</button>
</motion.div>
</div>
{/* Social Proof */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1, delay: 0.8 }}
className="mt-20 flex flex-col items-center gap-4"
>
<div className="flex -space-x-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="w-12 h-12 rounded-full border-4 border-[#FFF9F5] bg-gray-200 overflow-hidden shadow-sm">
<img src={`https://i.pravatar.cc/150?u=${i}`} alt="user" />
{/* Scroll Indicator */}
<motion.div
animate={{ y: [0, 8, 0] }}
transition={{ repeat: Infinity, duration: 2 }}
className="absolute bottom-8 left-1/2 -translate-x-1/2 text-white/40"
>
<div className="w-5 h-8 border-2 border-white/20 rounded-full flex justify-center p-1">
<div className="w-1 h-1 bg-white/40 rounded-full" />
</div>
</motion.div>
</section>
{/* Stats Section */}
<section className="py-16 bg-secondary text-white relative overflow-hidden">
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-primary/10 rounded-full blur-[100px] -translate-y-1/2 translate-x-1/2" />
<div className="container relative z-10 px-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 text-center">
{[
{ label: "Mutlu Gezgin", value: "25k+", icon: Globe },
{ label: "Kişiye Özel Rota", value: "100k+", icon: Compass },
{ label: "Doğrulanmış Mekan", value: "1.2k", icon: MapPin },
{ label: "Müşteri Puanı", value: "4.9", icon: Star },
].map((stat, i) => (
<motion.div
key={i}
initial={{ opacity: 0, scale: 0.5 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.1 }}
className="space-y-3"
>
<div className="w-10 h-10 mx-auto bg-white/5 rounded-xl flex items-center justify-center">
<stat.icon className="h-5 w-5 text-accent" />
</div>
))}
<div className="w-12 h-12 rounded-full border-4 border-[#FFF9F5] bg-orange-600 flex items-center justify-center text-white text-xs font-bold shadow-sm">
+2k
</div>
</div>
<p className="text-sm font-medium text-gray-500">
2,000'den fazla gezgin Kapadokya rotasını bizimle planladı.
</p>
</motion.div>
<div className="text-3xl md:text-4xl font-black text-primary">{stat.value}</div>
<div className="text-white/60 font-bold tracking-widest uppercase text-[10px]">{stat.label}</div>
</motion.div>
))}
</div>
</div>
</section>
{/* Features Grid */}
<section className="py-24 bg-white/40">
<div className="max-w-7xl mx-auto px-6">
<div className="text-center mb-16 space-y-4">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900">Neden Bizimle Planlamalısınız?</h2>
<p className="text-gray-600 max-w-2xl mx-auto">Seyahatinizi unutulmaz kılmak için teknolojiyi Kapadokya'nın büyüsüyle birleştiriyoruz.</p>
<section className="py-20 bg-background relative overflow-hidden">
<div className="container px-6">
<div className="max-w-2xl mx-auto text-center mb-16 space-y-4">
<motion.span
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
className="text-primary font-black tracking-widest uppercase text-xs"
>
Kusursuz Mühendislik
</motion.span>
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
className="text-3xl md:text-5xl font-black text-gray-900 dark:text-white leading-tight"
>
Seyahatinizi Sanata <br /> <span className="text-gradient">Dönüştürüyoruz</span>
</motion.h2>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
<FeatureCard
index={0}
icon={Zap}
title="Yapay Zeka Hızı"
description="OpenAI altyapısıyla saniyeler içinde seyahat sürenize ve ilgi alanlarınıza özel rota oluşturun."
title="Yapay Zeka Mimari"
description="OpenAI 4o-mini altyapısıyla her detayı düşünülmüş, tutarlı ve akıcı bir seyahat programı."
/>
<FeatureCard
index={1}
icon={ShieldCheck}
title="Google Doğrulaması"
description="Önerilen tüm mekanlar Google Places API ile doğrulanır; güncel fotoğraflar ve gerçek yorumlarla sunulur."
title="Premium Veri"
description="Google Places verileriyle gerçek fotoğraflar, anlık çalışma saatleri ve güvenilir yorumlar."
/>
<FeatureCard
index={2}
icon={MapPin}
title="Akıllı Lojistik"
description="Google Directions API ile rotanızdaki mesafe ve süreler otomatik hesaplanır, zamanınız boşa gitmez."
title="Akıllı Navigasyon"
description="Sadece rota değil, Google Directions ile en mantıklı sıralama ve ulaşım süreleri."
/>
<FeatureCard
index={3}
icon={Compass}
title="Gizli Cevherler"
description="Sadece turistik yerleri değil, Kapadokya'nın saklı kalmış eşsiz köşelerini de keşfedin."
title="Kürasyon"
description="Yerel rehberlerin gizli favorileri ve turistik kalabalıkların ötesindeki özel duraklar."
/>
<FeatureCard
index={4}
icon={Calendar}
title="Esnek Yönetim"
description="Planınızı dilediğiniz gibi düzenleyin, sürükle-bırak özelliğiyle mekanların sırasını değiştirin."
title="Dinamik Kontrol"
description="Planınızı dilediğiniz zaman sürükle-bırak yöntemiyle revize edin, her an güncel kalın."
/>
<FeatureCard
icon={Heart}
title="Ücretsiz ve Hızlı"
description="Herhangi bir gizli ücret yok. En iyi Kapadokya deneyimi için tasarlandı."
index={5}
icon={Sparkles}
title="Modern Estetik"
description="En yüksek standartlarda kullanıcı deneyimi için tasarlanmış akıcı ve şık arayüz."
/>
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-24 px-6">
<div className="max-w-5xl mx-auto rounded-3xl bg-gradient-to-br from-orange-600 to-amber-500 p-12 lg:p-20 text-center relative overflow-hidden">
{/* Decorative circles */}
<div className="absolute top-0 left-0 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-white/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-0 translate-x-1/2 translate-y-1/2 w-96 h-96 bg-black/10 rounded-full blur-3xl" />
{/* Luxury CTA */}
<section className="py-20 px-6">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
className="max-w-6xl mx-auto rounded-[2.5rem] bg-secondary p-12 lg:p-20 text-center relative overflow-hidden group"
>
<div className="absolute inset-0 opacity-20 grayscale hover:grayscale-0 transition-luxury duration-1000 group-hover:scale-105">
<img src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400" alt="CTA" className="w-full h-full object-cover" />
</div>
<div className="relative z-10 space-y-8">
<h2 className="text-4xl lg:text-5xl font-bold text-white leading-tight">
Maceranıza Başlamak İçin <br /> Hazır mısınız?
<h2 className="text-4xl lg:text-7xl font-black text-white leading-[0.95] tracking-tighter uppercase">
BİR SONRAKİ <br /> <span className="text-primary">EFSANENİZİ</span> YAZIN
</h2>
<p className="text-white/80 text-lg max-w-xl mx-auto">
Kapadokya'nın büyüleyici atmosferine adım atmadan önce rotanızı profesyonelce hazırlayın.
<p className="text-white/60 text-lg md:text-xl max-w-xl mx-auto font-medium leading-relaxed">
Kapadokya'nın zamansız ruhunu, modern teknolojinin gücüyle birleştirin. Bugün başlayın.
</p>
<Button size="lg" className="h-14 px-12 text-lg font-bold bg-white text-orange-600 hover:bg-orange-50 rounded-xl" asChild>
<Link to="/planner">Rotamı Oluştur</Link>
</Button>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4">
<Button size="lg" className="h-16 px-10 text-xl font-bold bg-primary hover:bg-primary-dark rounded-2xl shadow-2xl shadow-primary/20 transition-luxury group" asChild>
<Link to="/planner" className="flex items-center gap-3">
Rotanı Oluştur
<ArrowUpRight className="h-6 w-6 group-hover:rotate-45 transition-transform" />
</Link>
</Button>
</div>
</div>
</div>
</motion.div>
</section>
{/* Footer */}
<footer className="py-12 border-t bg-white">
<div className="max-w-7xl mx-auto px-6 text-center text-gray-500 text-sm">
<p>© 2026 Cappadocia AI Travel Planner. Tüm hakları saklıdır.</p>
{/* Modern Footer */}
<footer className="py-16 border-t border-border bg-background">
<div className="container px-6">
<div className="flex flex-col md:flex-row justify-between items-center gap-8">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary rounded-xl flex items-center justify-center text-white">
<MapPin className="h-5 w-5" />
</div>
<span className="text-xl font-black tracking-tighter uppercase dark:text-white">
Kapadokya <span className="text-primary">Efsanesi</span>
</span>
</div>
<div className="flex items-center gap-8 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<Link to="/explore" className="hover:text-primary transition-colors">Keşfet</Link>
<Link to="/planner" className="hover:text-primary transition-colors">Planla</Link>
<Link to="/account" className="hover:text-primary transition-colors">Hesabım</Link>
</div>
</div>
<div className="mt-16 pt-10 border-t border-border flex flex-col md:flex-row justify-between items-center gap-4 text-gray-500 text-xs font-medium">
<p>© 2026 Cappadocia Legend. Her anı bir hikaye.</p>
<div className="flex items-center gap-6">
<span>TR</span>
<div className="h-3 w-px bg-border" />
<span>USD</span>
</div>
</div>
</div>
</footer>
</div>
);
}
}

View File

@ -1,12 +1,13 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { Loader2, MapPin, Rocket, Map, Save, Users, Eye, EyeOff, Shield } from 'lucide-react';
import { Loader2, MapPin, Sparkles, Shield, Eye, EyeOff, ArrowRight } from 'lucide-react';
import { motion } from 'framer-motion';
export default function LoginPage() {
const [isLogin, setIsLogin] = useState(true);
@ -20,7 +21,6 @@ export default function LoginPage() {
const location = useLocation();
const from = location.state?.from || '/explore';
// Eğer kullanıcı zaten giriş yapmışsa yönlendir
if (user) {
navigate(from, { replace: true });
}
@ -29,7 +29,6 @@ export default function LoginPage() {
e.preventDefault();
if (!username || !password) return;
// Kullanıcı adı validasyonu
if (!/^[a-z0-9_]+$/.test(username)) {
toast.error('Kullanıcı adı sadece harf, rakam ve alt çizgi içerebilir');
return;
@ -61,232 +60,172 @@ export default function LoginPage() {
throw error;
}
toast.success('Hesap oluşturuldu! Giriş yapılıyor...');
// Otomatik giriş yap
const { error: signInError } = await signInWithUsername(username, password);
if (!signInError) {
navigate(from, { replace: true });
}
}
} catch (error: any) {
const errorMessage = error.message || 'Bir hata oluştu. Lütfen tekrar deneyin.';
toast.error(errorMessage);
toast.error(error.message || 'Bir hata oluştu');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex">
{/* Sol Taraf - Hero Section (Sadece Desktop) */}
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-blue-500 via-blue-600 to-blue-800 p-12 flex-col justify-between text-white relative overflow-hidden">
{/* Dekoratif arka plan */}
<div className="absolute inset-0 opacity-10">
<div className="absolute top-20 left-20 w-72 h-72 bg-white rounded-full blur-3xl" />
<div className="absolute bottom-20 right-20 w-96 h-96 bg-white rounded-full blur-3xl" />
<div className="min-h-screen flex bg-background selection:bg-primary/20">
{/* Left Side - Cinematic Hero */}
<div className="hidden lg:flex lg:w-[50%] xl:w-[55%] relative overflow-hidden group">
<div className="absolute inset-0 z-0 transition-luxury duration-1000 group-hover:scale-105">
<img
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
alt="Cappadocia Login"
className="w-full h-full object-cover grayscale-[0.2]"
/>
<div className="absolute inset-0 bg-gradient-to-br from-secondary/80 via-secondary/40 to-transparent z-10" />
</div>
<div className="relative z-10">
{/* Logo */}
<div className="flex items-center space-x-3 mb-16">
<div className="p-3 bg-white/20 backdrop-blur-sm rounded-2xl">
<MapPin className="h-8 w-8" />
<div className="relative z-20 w-full p-12 xl:p-16 flex flex-col justify-between">
<Link to="/" className="flex items-center gap-3 group/logo">
<div className="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-white shadow-xl shadow-primary/40 group-hover/logo:scale-110 transition-luxury">
<MapPin className="h-6 w-6" />
</div>
<span className="text-2xl font-bold">Cappadocia AI</span>
</div>
<span className="text-2xl font-black text-white tracking-tighter uppercase">Kapadokya <span className="text-primary">Efsanesi</span></span>
</Link>
{/* Ana Başlık */}
<div className="space-y-6 max-w-md">
<h1 className="text-5xl font-bold leading-tight">
Kapadokya'ya Hoşgeldin
<div className="max-w-lg space-y-6">
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-white text-[10px] font-black uppercase tracking-widest"
>
<Sparkles className="h-3.5 w-3.5 text-accent" />
Sadece Size Özel Deneyim
</motion.div>
<h1 className="text-5xl xl:text-7xl font-black text-white leading-[0.9] tracking-tighter uppercase">
BİR SONRAKİ <br /> <span className="text-primary">MACERAYA</span> <br /> ADIM ATIN
</h1>
<p className="text-xl text-blue-100">
Yapay zeka rehberliğinde unutulmaz deneyimler yarat
<p className="text-lg text-white/60 font-medium italic leading-relaxed">
"Efsaneler, sadece cesaret edenler ve keşfedenler için yazılır. Rotanızı kaydedin ve Kapadokya'yı yaşayın."
</p>
{/* Özellikler Listesi */}
<div className="space-y-4 pt-8">
<div className="flex items-center space-x-3">
<div className="p-2 bg-white/20 backdrop-blur-sm rounded-lg">
<Rocket className="h-5 w-5" />
</div>
<span className="text-lg">AI Destekli Planlama</span>
</div>
<div className="flex items-center space-x-3">
<div className="p-2 bg-white/20 backdrop-blur-sm rounded-lg">
<Map className="h-5 w-5" />
</div>
<span className="text-lg">Gerçek Zamanlı Haritalar</span>
</div>
<div className="flex items-center space-x-3">
<div className="p-2 bg-white/20 backdrop-blur-sm rounded-lg">
<Save className="h-5 w-5" />
</div>
<span className="text-lg">Planlarını Kaydet</span>
</div>
<div className="flex items-center space-x-3">
<div className="p-2 bg-white/20 backdrop-blur-sm rounded-lg">
<Users className="h-5 w-5" />
</div>
<span className="text-lg">Arkadaşlarınla Paylaş</span>
</div>
</div>
</div>
</div>
{/* Alt Bilgi */}
<div className="relative z-10 flex items-center space-x-2 text-sm text-blue-100">
<Shield className="h-4 w-4" />
<span>Güvenlik: Verileriniz 256-bit SSL ile korunur</span>
<div className="flex items-center gap-3 text-white/40 text-[10px] font-black uppercase tracking-widest">
<Shield className="h-3.5 w-3.5 text-primary" />
<span>Premium Güvenlik Protokolü Aktif</span>
</div>
</div>
</div>
{/* Sağ Taraf - Login Form */}
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 bg-background">
<div className="w-full max-w-md space-y-8">
{/* Mobile Logo */}
<div className="lg:hidden flex justify-center mb-8">
<div className="flex items-center space-x-2">
<MapPin className="h-8 w-8 text-primary" />
<span className="text-2xl font-bold">Cappadocia AI</span>
{/* Right Side - Luxury Form */}
<div className="w-full lg:w-[50%] xl:w-[45%] flex items-center justify-center p-8 md:p-16 relative overflow-hidden bg-white dark:bg-card">
<div className="w-full max-w-sm relative z-10 space-y-10">
{/* Mobile Brand Header */}
<div className="lg:hidden flex flex-col items-center gap-3 text-center mb-10">
<div className="w-14 h-14 bg-primary rounded-xl flex items-center justify-center text-white shadow-xl shadow-primary/20">
<MapPin className="h-8 w-8" />
</div>
<h2 className="text-2xl font-black tracking-tighter uppercase">Kapadokya <span className="text-primary">Efsanesi</span></h2>
</div>
{/* Form Header */}
<div className="space-y-2 text-center lg:text-left">
<h2 className="text-3xl font-bold tracking-tight">
{isLogin ? 'Giriş Yap' : 'Hesap Oluştur'}
<div className="space-y-3">
<h2 className="text-3xl md:text-4xl font-black text-gray-900 dark:text-white tracking-tighter uppercase leading-none">
{isLogin ? 'HOŞ GELDİNİZ' : 'BİZE KATILIN'}
</h2>
<p className="text-muted-foreground">
{isLogin ? 'Rotanızı yönetin ve seyahatinizi planlayın' : 'Kapadokya maceranıza başlayın'}
<p className="text-base text-gray-500 font-medium italic">
{isLogin ? 'Efsane kaldığı yerden devam ediyor.' : 'Kendi Kapadokya hikayenizi başlatın.'}
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Kullanıcı Adı */}
<div className="space-y-2">
<Label htmlFor="username" className="text-sm font-semibold">
Kullanıcı Adı
</Label>
<Input
id="username"
placeholder="kullaniciadi"
required
value={username}
onChange={e => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
className="h-11 text-base"
autoComplete="username"
/>
<p className="text-xs text-muted-foreground">
Sadece harf, rakam ve alt çizgi kullanılabilir
</p>
</div>
<div className="space-y-5">
<div className="space-y-2.5">
<Label htmlFor="username" className="text-[10px] font-black uppercase tracking-widest text-primary">Kullanıcı Kimliği</Label>
<Input
id="username"
placeholder="kullanici_adi"
required
value={username}
onChange={e => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
className="h-14 rounded-xl border-2 border-gray-100 bg-gray-50/50 focus:border-primary px-5 text-base font-bold transition-luxury"
/>
</div>
{/* Şifre */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-sm font-semibold">
Şifre
</Label>
{isLogin && (
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-[10px] font-black uppercase tracking-widest text-primary">Güvenli Şifre</Label>
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="h-14 rounded-xl border-2 border-gray-100 bg-gray-50/50 focus:border-primary px-5 text-base font-bold transition-luxury pr-12"
/>
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => toast.info('Şifre sıfırlama özelliği yakında eklenecek')}
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-primary transition-colors"
>
Şifremi unuttum
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
)}
</div>
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="h-11 text-base pr-10"
autoComplete={isLogin ? 'current-password' : 'new-password'}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{!isLogin && password && (
<p className="text-xs text-muted-foreground">
{password.length < 6 ? '⚠️ En az 6 karakter gerekli' : '✓ Şifre uygun'}
</p>
{isLogin && (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2.5">
<Checkbox
id="remember"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked as boolean)}
className="w-4 h-4 rounded-md border-2 border-gray-300 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<Label htmlFor="remember" className="text-xs font-bold text-gray-500 cursor-pointer">Beni Hatırla</Label>
</div>
<button type="button" className="text-[10px] font-black uppercase tracking-widest text-primary hover:opacity-70 transition-opacity">Şifremi Unuttum</button>
</div>
)}
</div>
{/* Beni Hatırla */}
{isLogin && (
<div className="flex items-center space-x-2">
<Checkbox
id="remember"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked as boolean)}
/>
<Label
htmlFor="remember"
className="text-sm font-normal cursor-pointer"
>
Beni hatırla
</Label>
</div>
)}
{/* Submit Button */}
<Button
type="submit"
className="w-full h-11 text-base font-semibold"
className="w-full h-16 text-lg font-black bg-primary hover:bg-primary-dark rounded-xl shadow-lg shadow-primary/20 transition-luxury group"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{isLogin ? 'Giriş yapılıyor...' : 'Hesap oluşturuluyor...'}
</>
<Loader2 className="h-6 w-6 animate-spin" />
) : (
isLogin ? 'Giriş Yap' : 'Hesap Oluştur'
<div className="flex items-center gap-2">
{isLogin ? 'Giriş Yap' : 'Hesabı Oluştur'}
<ArrowRight className="h-5 w-5 group-hover:translate-x-1 transition-transform" />
</div>
)}
</Button>
</form>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">veya</span>
</div>
</div>
{/* Toggle Login/Signup */}
<div className="text-center">
<div className="pt-6 text-center border-t border-gray-100">
<button
type="button"
onClick={() => {
setIsLogin(!isLogin);
setPassword('');
}}
className="text-sm text-muted-foreground hover:text-primary transition-colors"
className="text-xs font-bold text-gray-400 hover:text-primary transition-luxury group"
>
{isLogin ? (
<>
Hesabınız yok mu?{' '}
<span className="font-semibold text-primary">Kayıt ol</span>
<span className="font-black text-primary uppercase tracking-widest ml-1 group-hover:underline">Kayıt Ol</span>
</>
) : (
<>
Zaten hesabınız var mı?{' '}
<span className="font-semibold text-primary">Giriş yap</span>
Hesabınız var mı?{' '}
<span className="font-black text-primary uppercase tracking-widest ml-1 group-hover:underline">Giriş Yap</span>
</>
)}
</button>
@ -295,4 +234,4 @@ export default function LoginPage() {
</div>
</div>
);
}
}

View File

@ -1,19 +1,20 @@
import { useState, useMemo, useEffect, useRef, useCallback, memo, Suspense, lazy } from 'react';
import { useState, useMemo, useEffect, useRef, useCallback, memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import api from '@/db/api';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import { Input } from '@/components/ui/input';
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { toast } from 'sonner';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { format, differenceInDays } from 'date-fns';
import { MapPin, Loader2 } from 'lucide-react';
import { Loader2, ArrowRight, ArrowLeft, Sparkles, MapPin, Calendar, Users, Coffee, Heart } from 'lucide-react';
import { parseApiError } from '@/utils/errorHandler';
import { retryWithBackoff, withTimeout } from '@/utils/retryWithBackoff';
import { motion, AnimatePresence } from 'framer-motion';
import { Button } from '@/components/ui/button';
// Constants
import { LOADING_STEPS } from '@/constants/planner';
@ -23,8 +24,6 @@ import { DateSelector } from '@/components/planner/DateSelector';
import { TravelerInput } from '@/components/planner/TravelerInput';
import { AccommodationSelector } from '@/components/planner/AccommodationSelector';
import { InterestsGrid } from '@/components/planner/InterestsGrid';
import { SubmitButton } from '@/components/planner/SubmitButton';
import { HeroSection } from '@/components/planner/HeroSection';
// Form validation schema
const formSchema = z.object({
@ -74,11 +73,19 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
const STEPS = [
{ id: 'dates', title: 'Tarihler', icon: Calendar, description: 'Ne zaman gidiyorsunuz?' },
{ id: 'travelers', title: 'Seyahat Grubu', icon: Users, description: 'Kiminle seyahat ediyorsunuz?' },
{ id: 'accommodation', title: 'Konaklama', icon: Coffee, description: 'Nasıl bir konaklama istersiniz?' },
{ id: 'interests', title: 'İlgi Alanları', icon: Heart, description: 'Neleri keşfetmek istersiniz?' }
];
const PlannerPage = () => {
const { user } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [loadingStep, setLoadingStep] = useState(0);
const [currentStep, setCurrentStep] = useState(0);
const [datePickerOpen, setDatePickerOpen] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
@ -95,40 +102,33 @@ const PlannerPage = () => {
},
});
// Load draft from localStorage
useEffect(() => {
const savedFormData = localStorage.getItem('planner_form_draft');
if (savedFormData) {
try {
const parsed = JSON.parse(savedFormData);
if (parsed.dateRange?.from) parsed.dateRange.from = new Date(parsed.dateRange.from);
if (parsed.dateRange?.to) parsed.dateRange.to = new Date(parsed.dateRange.to);
form.reset(parsed);
} catch (e) {
console.error('Draft loading error:', e);
}
}
}, [form]);
// Save draft to localStorage
useEffect(() => {
const subscription = form.watch((value) => {
localStorage.setItem('planner_form_draft', JSON.stringify(value));
});
return () => subscription.unsubscribe();
}, [form.watch]);
const watchedValues = form.watch();
const formProgress = useMemo(() => {
let completed = 0;
const total = 4;
if (watchedValues.dateRange?.from && watchedValues.dateRange?.to) completed++;
if (watchedValues.travelers >= 1) completed++;
if (watchedValues.accommodation) completed++;
if (watchedValues.interests?.length > 0) completed++;
return Math.round((completed / total) * 100);
}, [watchedValues]);
const nextStep = async () => {
// Validate current step fields
const currentStepId = STEPS[currentStep].id;
let isValid = false;
if (currentStepId === 'dates') {
isValid = await form.trigger('dateRange');
} else if (currentStepId === 'travelers') {
isValid = await form.trigger('travelers');
} else if (currentStepId === 'accommodation') {
isValid = await form.trigger('accommodation');
} else if (currentStepId === 'interests') {
isValid = await form.trigger('interests');
}
if (isValid && currentStep < STEPS.length - 1) {
setCurrentStep(prev => prev + 1);
}
};
const prevStep = () => {
if (currentStep > 0) {
setCurrentStep(prev => prev - 1);
}
};
const handleInterestToggle = useCallback((interestId: string) => {
const currentInterests = form.getValues('interests');
@ -138,15 +138,6 @@ const PlannerPage = () => {
form.setValue('interests', newInterests, { shouldValidate: true });
}, [form]);
const handleCancel = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setLoading(false);
setLoadingStep(0);
toast.info('Planlama iptal edildi');
}, []);
const simulateLoadingSteps = useCallback(() => {
setLoadingStep(0);
const interval = setInterval(() => {
@ -207,7 +198,6 @@ const PlannerPage = () => {
itinerary: result,
});
tripId = savedTrip.id;
localStorage.removeItem('planner_form_draft');
} else {
sessionStorage.setItem('pending_trip', JSON.stringify(result));
navigate('/login', { state: { from: '/planner', message: 'Planınızı kaydetmek için giriş yapın' } });
@ -227,129 +217,243 @@ const PlannerPage = () => {
}
};
const progress = ((currentStep + 1) / STEPS.length) * 100;
if (loading) {
return (
<div className="h-screen w-full flex flex-col items-center justify-center bg-secondary relative overflow-hidden">
<div className="absolute inset-0 z-0 opacity-20">
<img
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
alt="Loading bg"
className="w-full h-full object-cover grayscale"
/>
</div>
<div className="relative z-10 max-w-xl w-full px-6 text-center space-y-8">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="w-24 h-24 bg-primary rounded-2xl flex items-center justify-center mx-auto shadow-2xl shadow-primary/20"
>
<Loader2 className="h-12 w-12 text-white animate-spin" />
</motion.div>
<div className="space-y-4">
<h2 className="text-3xl md:text-4xl font-black text-white tracking-tighter uppercase leading-tight">
{LOADING_STEPS[loadingStep].label}
</h2>
<div className="w-full h-3 bg-white/10 rounded-full overflow-hidden border border-white/10">
<motion.div
className="h-full bg-primary"
initial={{ width: 0 }}
animate={{ width: `${LOADING_STEPS[loadingStep].progress}%` }}
transition={{ duration: 0.5 }}
/>
</div>
</div>
<p className="text-white/40 font-bold tracking-widest uppercase text-xs italic">
"Sizin için en kusursuz Kapadokya efsanesini kurguluyoruz..."
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex">
{/* Left Side - Form */}
<div className="w-full lg:w-[45%] xl:w-[40%] bg-white p-6 md:p-10 overflow-y-auto">
<div className="max-w-xl mx-auto space-y-8">
<div className="space-y-2">
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 tracking-tight">
Hayalinizdeki <span className="text-orange-600">Kapadokya</span> gezisi başlasın
<div className="min-h-screen bg-background flex flex-col lg:flex-row overflow-hidden">
{/* Sidebar - Progress & Stats */}
<div className="w-full lg:w-[320px] xl:w-[380px] bg-secondary p-8 lg:p-12 flex flex-col justify-between relative overflow-hidden shrink-0">
<div className="absolute top-0 left-0 w-full h-full bg-primary/5 rounded-full blur-[80px] -translate-x-1/2 -translate-y-1/2" />
<div className="relative z-10 space-y-10">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary rounded-xl flex items-center justify-center text-white">
<MapPin className="h-5 w-5" />
</div>
<span className="text-xl font-black text-white tracking-tighter uppercase">Kapadokya <span className="text-primary">Efsanesi</span></span>
</div>
<div className="space-y-6">
<h1 className="text-4xl font-black text-white leading-[0.95] tracking-tighter uppercase">
ROTANIZI <br /> <span className="text-primary">TASARLAYIN</span>
</h1>
<p className="text-gray-500 text-lg">
Kişiselleştirilmiş, Google doğrulamalı ve yapay zeka destekli gezi planlayıcısı.
<p className="text-white/40 text-base font-medium italic">
"Her durak bir hikaye, her an bir anı. Size özel kurgulanmış seyahat mimarisi."
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 font-medium">Planlama İlerlemesi</span>
<span className="text-orange-600 font-bold">{formProgress}%</span>
</div>
<Progress value={formProgress} className="h-2 bg-orange-100" />
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* Destination */}
<div className="space-y-3">
<Label className="text-sm font-bold text-gray-700">Gidilecek Yer</Label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<Input
value="Kapadokya, Nevşehir"
disabled
className="pl-10 h-12 bg-gray-50 border-gray-200 font-medium"
/>
</div>
</div>
{/* Date Selection */}
<FormField
control={form.control}
name="dateRange"
render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-sm font-bold text-gray-700">Ne Zaman Gidiyorsunuz?</Label>
<DateSelector
date={field.value}
onDateChange={field.onChange}
isOpen={datePickerOpen}
onOpenChange={setDatePickerOpen}
/>
<FormMessage />
</FormItem>
)}
/>
{/* Travelers */}
<FormField
control={form.control}
name="travelers"
render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-sm font-bold text-gray-700">Kiminle Gidiyorsunuz?</Label>
<TravelerInput value={field.value} onChange={field.onChange} />
<FormMessage />
</FormItem>
)}
/>
{/* Accommodation */}
<FormField
control={form.control}
name="accommodation"
render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-sm font-bold text-gray-700">Konaklama Tercihi</Label>
<AccommodationSelector selectedId={field.value} onSelect={field.onChange} />
<FormMessage />
</FormItem>
)}
/>
{/* Interests */}
<FormField
control={form.control}
name="interests"
render={({ field }) => (
<FormItem className="space-y-3">
<div className="flex justify-between items-end">
<Label className="text-sm font-bold text-gray-700">Nelerle İlgilenirsiniz?</Label>
<span className="text-xs text-gray-500 font-medium">{field.value.length}/6 Seçildi</span>
</div>
<InterestsGrid selectedInterests={field.value} onToggle={handleInterestToggle} />
<FormMessage />
</FormItem>
)}
/>
{loading && (
<div className="p-4 bg-orange-50 border border-orange-100 rounded-xl space-y-3 animate-in fade-in slide-in-from-bottom-2">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 text-orange-600 animate-spin" />
<span className="text-sm font-bold text-orange-900">{LOADING_STEPS[loadingStep].label}</span>
<div className="space-y-5">
{STEPS.map((step, i) => {
const Icon = step.icon;
const isActive = i === currentStep;
const isCompleted = i < currentStep;
return (
<div key={step.id} className="flex items-center gap-5 group">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center border-2 transition-luxury ${
isActive ? 'bg-primary border-primary text-white scale-110' :
isCompleted ? 'bg-white/5 border-primary/40 text-primary' :
'bg-white/5 border-white/10 text-white/20'
}`}>
<Icon className="h-5 w-5" />
</div>
<div className="space-y-0.5">
<div className={`text-[10px] font-black uppercase tracking-widest ${isActive ? 'text-white' : 'text-white/20'}`}>Step 0{i+1}</div>
<div className={`text-lg font-bold ${isActive ? 'text-white' : 'text-white/40'}`}>{step.title}</div>
</div>
<Progress value={LOADING_STEPS[loadingStep].progress} className="h-1.5 bg-orange-200" />
</div>
)}
);
})}
</div>
</div>
<div className="pt-4">
<SubmitButton
loading={loading}
disabled={formProgress < 100}
onCancel={handleCancel}
/>
<div className="relative z-10 pt-10">
<div className="p-6 bg-white/5 backdrop-blur-xl rounded-2xl border border-white/10 space-y-3">
<div className="flex justify-between items-center text-white/40 text-[10px] font-bold uppercase tracking-widest">
<span>Mükemmellik Oranı</span>
<span>100%</span>
</div>
<div className="h-1.5 bg-white/10 rounded-full overflow-hidden">
<div className="h-full bg-primary" style={{ width: `${progress}%` }} />
</div>
</div>
</div>
</div>
{/* Main Form Area */}
<div className="flex-1 bg-white dark:bg-card p-8 lg:p-16 overflow-y-auto">
<div className="max-w-2xl mx-auto h-full flex flex-col">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col">
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="flex-1 space-y-10"
>
<div className="space-y-3">
<h2 className="text-3xl md:text-5xl font-black text-gray-900 dark:text-white tracking-tighter leading-[0.95] uppercase">
{STEPS[currentStep].description}
</h2>
<p className="text-lg text-gray-500 font-medium italic">
Lütfen tercihlerinizi belirleyin, biz sizin için kurgulayalım.
</p>
</div>
<div className="space-y-8 pt-6">
{currentStep === 0 && (
<FormField
control={form.control}
name="dateRange"
render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Seyahat Takvimi</Label>
<DateSelector
date={field.value}
onDateChange={field.onChange}
isOpen={datePickerOpen}
onOpenChange={setDatePickerOpen}
/>
<FormMessage />
</FormItem>
)}
/>
)}
{currentStep === 1 && (
<FormField
control={form.control}
name="travelers"
render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Grup Büyüklüğü</Label>
<TravelerInput value={field.value} onChange={field.onChange} />
<FormMessage />
</FormItem>
)}
/>
)}
{currentStep === 2 && (
<FormField
control={form.control}
name="accommodation"
render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Konaklama Tarzı</Label>
<AccommodationSelector selectedId={field.value} onSelect={field.onChange} />
<FormMessage />
</FormItem>
)}
/>
)}
{currentStep === 3 && (
<FormField
control={form.control}
name="interests"
render={({ field }) => (
<FormItem className="space-y-3">
<div className="flex justify-between items-end">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Kişisel İlgi Alanları</Label>
<span className="text-[10px] font-bold text-gray-400">{field.value.length}/6 Seçildi</span>
</div>
<InterestsGrid selectedInterests={field.value} onToggle={handleInterestToggle} />
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</motion.div>
</AnimatePresence>
{/* Navigation Controls */}
<div className="pt-12 mt-auto flex items-center justify-between gap-4 border-t border-gray-100">
<Button
type="button"
variant="ghost"
size="lg"
onClick={prevStep}
disabled={currentStep === 0}
className="h-14 px-8 text-base font-bold rounded-xl hover:bg-gray-50 group"
>
<ArrowLeft className="mr-2 h-5 w-5 group-hover:-translate-x-1 transition-transform" />
Geri
</Button>
{currentStep < STEPS.length - 1 ? (
<Button
type="button"
size="lg"
onClick={nextStep}
className="h-14 px-10 text-base font-black bg-primary hover:bg-primary-dark rounded-xl shadow-lg shadow-primary/20 group uppercase tracking-widest"
>
Devam Et
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</Button>
) : (
<Button
type="submit"
size="lg"
className="h-14 px-12 text-base font-black bg-primary hover:bg-primary-dark rounded-xl shadow-lg shadow-primary/20 group uppercase tracking-widest"
>
Rotayı Oluştur
<Sparkles className="ml-2 h-5 w-5 animate-pulse" />
</Button>
)}
</div>
</form>
</Form>
</div>
</div>
{/* Right Side - Hero */}
<HeroSection />
</div>
);
};
export default memo(PlannerPage);
export default memo(PlannerPage);

View File

@ -1,14 +1,15 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import api, { Trip, Place } from '@/db/api';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api, { Trip, Place, ItineraryDay } from '@/db/api';
import { Timeline } from '@/components/trip/Timeline';
import { TripMap } from '@/components/trip/Map';
import { Loader2, Share2, Download, Copy, Calendar, MapPin, Clock } from 'lucide-react';
import { Loader2, Share2, Download, Calendar, MapPin, Trash2, ChevronLeft, Zap, Plus, Compass, ChevronRight, Save, Wand2, Sparkles, LayoutGrid, RotateCcw, RotateCw } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { format } from 'date-fns';
import { tr } from 'date-fns/locale';
import { motion, AnimatePresence } from 'framer-motion';
import {
Sheet,
SheetContent,
@ -16,27 +17,37 @@ import {
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { cn } from '@/lib/utils';
export default function TripDetailsPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [trip, setTrip] = useState<Trip | null>(null);
const [loading, setLoading] = useState(true);
const [activePlaceId, setActivePlaceId] = useState<string | null>(null);
const [isMapSheetOpen, setIsMapSheetOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [selectedDayIndex, setSelectedDayIndex] = useState(0);
useEffect(() => {
if (id) {
loadTrip(id);
}
}, [id]);
const loadTrip = async (tripId: string) => {
const loadTrip = useCallback(async (tripId: string) => {
try {
const data = await api.getTripById(tripId);
if (data) {
setTrip(data);
} else {
toast.error('Gezi bulunamadı');
navigate('/explore');
}
} catch (error) {
console.error(error);
@ -44,165 +55,322 @@ export default function TripDetailsPage() {
} finally {
setLoading(false);
}
};
}, [navigate]);
const handleReorder = async (dayIndex: number, newItems: Place[]) => {
if (!trip) return;
const newItinerary = { ...trip.itinerary };
newItinerary.days[dayIndex].items = newItems;
// Recalculate distance and duration
try {
if (newItems.length > 1) {
const origin = `${newItems[0].lat},${newItems[0].lng}`;
const destination = `${newItems[newItems.length - 1].lat},${newItems[newItems.length - 1].lng}`;
const waypoints = newItems.slice(1, -1).map(i => `${i.lat},${i.lng}`);
const directions = await api.getDirections({ origin, destination, waypoints });
if (directions.routes && directions.routes[0]) {
const leg = directions.routes[0].legs.reduce((acc: any, curr: any) => ({
distance: acc.distance + curr.distance.value,
duration: acc.duration + curr.duration.value
}), { distance: 0, duration: 0 });
newItinerary.days[dayIndex].total_distance = `${(leg.distance / 1000).toFixed(1)} km`;
newItinerary.days[dayIndex].total_duration = `${Math.round(leg.duration / 60)} dk sürüş`;
}
}
} catch (error) {
console.warn('Failed to calculate directions:', error);
useEffect(() => {
if (id) {
loadTrip(id);
}
}, [id, loadTrip]);
setTrip({ ...trip, itinerary: newItinerary });
const handleUpdateTrip = async (updatedTrip: Trip) => {
setTrip(updatedTrip);
try {
await api.updateTrip(trip.id, { itinerary: newItinerary });
toast.success('Rota güncellendi');
await api.updateTrip(updatedTrip.id, { itinerary: updatedTrip.itinerary });
} catch (error) {
toast.error('Değişiklikler kaydedilemedi');
}
};
const handleReorder = (dayIndex: number, newItems: Place[]) => {
if (!trip) return;
const newItinerary = { ...trip.itinerary };
newItinerary.days[dayIndex].items = newItems;
handleUpdateTrip({ ...trip, itinerary: newItinerary });
};
const handleAddPlace = (dayIndex: number, place: Place) => {
if (!trip) return;
const newItinerary = { ...trip.itinerary };
newItinerary.days[dayIndex].items = [...newItinerary.days[dayIndex].items, place];
handleUpdateTrip({ ...trip, itinerary: newItinerary });
toast.success(`${place.name} rotaya eklendi`);
};
const handleDeletePlace = (dayIndex: number, placeId: string) => {
if (!trip) return;
const newItinerary = { ...trip.itinerary };
newItinerary.days[dayIndex].items = newItinerary.days[dayIndex].items.filter(i => i.place_id !== placeId);
handleUpdateTrip({ ...trip, itinerary: newItinerary });
toast.success('Durak kaldırıldı');
};
const handleUpdatePlaceNote = (dayIndex: number, placeId: string, note: string) => {
if (!trip) return;
const newItinerary = { ...trip.itinerary };
const items = [...newItinerary.days[dayIndex].items];
const itemIndex = items.findIndex(i => i.place_id === placeId);
if (itemIndex > -1) {
items[itemIndex] = { ...items[itemIndex], notes: note };
newItinerary.days[dayIndex].items = items;
handleUpdateTrip({ ...trip, itinerary: newItinerary });
}
};
const handleUpdateDayNote = (dayIndex: number, note: string) => {
if (!trip) return;
const newItinerary = { ...trip.itinerary };
newItinerary.days[dayIndex] = { ...newItinerary.days[dayIndex], notes: note };
handleUpdateTrip({ ...trip, itinerary: newItinerary });
};
const handleAddDay = () => {
if (!trip) return;
const newItinerary = { ...trip.itinerary };
const nextDayNum = newItinerary.days.length + 1;
newItinerary.days.push({
day: nextDayNum,
items: []
});
handleUpdateTrip({ ...trip, itinerary: newItinerary });
toast.success(`Gün ${nextDayNum} eklendi`);
setSelectedDayIndex(newItinerary.days.length - 1);
};
const handleShare = () => {
const url = window.location.href;
navigator.clipboard.writeText(url);
toast.success('Link kopyalandı');
};
const handleExport = () => {
toast.info('Dışa aktarma özelliği yakında eklenecek');
const handleDelete = async () => {
if (!trip) return;
setIsDeleting(true);
try {
await api.deleteTrip(trip.id);
toast.success('Gezi silindi');
navigate('/account');
} catch (error) {
toast.error('Gezi silinemedi');
setIsDeleting(false);
}
};
const handleDuplicate = () => {
toast.info('Kopyalama özelliği yakında eklenecek');
};
const totalPlaces = useMemo(() =>
trip?.itinerary.days.reduce((sum, day) => sum + (day.items?.length || 0), 0) || 0,
[trip]
);
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="h-screen w-full flex flex-col items-center justify-center bg-background">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
</div>
);
}
if (!trip) return <div className="flex items-center justify-center min-h-screen">Gezi bulunamadı</div>;
// Calculate trip stats
const totalPlaces = trip.itinerary.days.reduce((sum, day) => sum + day.items.length, 0);
const totalDistance = trip.itinerary.days.reduce((sum, day) => {
const dist = day.total_distance ? parseFloat(day.total_distance) : 0;
return sum + dist;
}, 0);
const dayCount = trip.itinerary.days.length;
if (!trip) return null;
return (
<div className="flex flex-col h-screen overflow-hidden">
{/* Header Area */}
<div className="bg-gradient-to-b from-gray-100 to-white border-b">
<div className="max-w-7xl mx-auto px-6 py-6">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="flex-1">
<h1 className="text-3xl font-bold text-gray-900 mb-2">{trip.title}</h1>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<span>
{format(new Date(trip.start_date), 'd MMM', { locale: tr })} - {format(new Date(trip.end_date), 'd MMM yyyy', { locale: tr })}
</span>
</div>
<Badge variant="secondary" className="font-normal">
{dayCount} Gün
</Badge>
<Badge variant="secondary" className="font-normal">
{totalPlaces} Mekan
</Badge>
{totalDistance > 0 && (
<Badge variant="secondary" className="font-normal">
{totalDistance.toFixed(1)} km
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-2" />
Dışa Aktar
</Button>
<Button variant="outline" size="sm" onClick={handleShare}>
<Share2 className="h-4 w-4 mr-2" />
Paylaş
</Button>
<Button variant="outline" size="sm" onClick={handleDuplicate}>
<Copy className="h-4 w-4 mr-2" />
Kopyala
</Button>
</div>
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden bg-background">
{/* Sub-Header Navbar */}
<div className="h-14 border-b bg-white flex items-center px-4 justify-between shrink-0">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1 border rounded-lg p-0.5 bg-gray-50/50">
<Button variant="ghost" size="icon" className="h-7 w-7 text-gray-400 hover:text-gray-900" onClick={() => navigate(-1)}>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-gray-400 hover:text-gray-900">
<RotateCw className="h-3.5 w-3.5" />
</Button>
</div>
<div className="h-4 w-px bg-gray-200 mx-1" />
<h1 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
{trip.title}
<LayoutGrid className="h-3.5 w-3.5 text-gray-400" />
</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="default" className="bg-orange-600 hover:bg-orange-700 h-9 px-4 rounded-full text-xs font-bold gap-2">
Kaydetmek için giriş yap
</Button>
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-500" onClick={handleShare}>
<Share2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-500" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
</Button>
<div className="h-4 w-px bg-gray-200 mx-1" />
<Button variant="outline" className="h-9 px-3 rounded-xl border-gray-200 text-xs font-bold gap-2">
<Sparkles className="h-3.5 w-3.5 text-purple-600" />
AI Route
</Button>
<Button variant="outline" className="h-9 px-3 rounded-xl border-gray-200 text-xs font-bold gap-2">
<Zap className="h-3.5 w-3.5 text-blue-600" />
Optimize
</Button>
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-500">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
<main className="flex flex-1 overflow-hidden">
{/* Timeline Panel */}
<div className="w-full lg:w-[40%] overflow-y-auto bg-white border-r">
<Timeline
itinerary={trip.itinerary}
onReorder={handleReorder}
onPlaceClick={(id) => setActivePlaceId(id)}
activePlaceId={activePlaceId}
/>
</div>
<main className="flex-1 flex overflow-hidden">
{/* Left Sidebar: Days Selection */}
<aside className="w-16 md:w-20 border-r bg-gray-50/30 flex flex-col shrink-0 overflow-y-auto custom-scrollbar">
<div className="py-4 flex flex-col items-center">
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-4">Days</span>
<div className="flex flex-col gap-3 w-full px-2">
{trip.itinerary.days.map((day, idx) => (
<button
key={day.day}
onClick={() => setSelectedDayIndex(idx)}
className={cn(
"flex flex-col items-center justify-center py-3 rounded-xl transition-all relative group",
selectedDayIndex === idx
? "bg-orange-600 text-white shadow-lg shadow-orange-600/20"
: "text-gray-400 hover:bg-gray-100 hover:text-gray-900"
)}
>
<span className="text-[10px] font-bold uppercase tracking-tighter">Day {day.day}</span>
<span className="text-[9px] opacity-60 font-medium">
{format(new Date(trip.start_date), 'MM-dd', { locale: tr })}
</span>
<div className="mt-1 flex items-center justify-center">
<Badge variant="secondary" className={cn(
"text-[8px] px-1 py-0 h-3 min-w-[12px]",
selectedDayIndex === idx ? "bg-white/20 text-white" : "bg-gray-200 text-gray-500"
)}>
{day.items.length}
</Badge>
</div>
</button>
))}
<Button
variant="ghost"
size="icon"
onClick={handleAddDay}
className="h-10 w-full rounded-xl text-gray-400 hover:text-primary hover:bg-primary/5 border border-dashed border-gray-200"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</aside>
{/* Map Panel - Desktop */}
<div className="hidden lg:block flex-1 relative bg-muted">
{/* Center Panel: Focused Day Timeline */}
<section className="w-full lg:w-[45%] xl:w-[40%] overflow-y-auto bg-white border-r border-border custom-scrollbar flex flex-col">
<div className="p-6 border-b sticky top-0 bg-white/95 backdrop-blur-sm z-30 flex items-center justify-between">
<div className="space-y-0.5">
<h2 className="text-xl font-bold text-gray-900">
Day {trip.itinerary.days[selectedDayIndex].day} - Timeline
</h2>
<p className="text-xs text-gray-500 font-medium">{trip.itinerary.days[selectedDayIndex].items.length} places selected</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-8 rounded-lg text-xs font-bold gap-1.5 border-gray-200">
<Sparkles className="h-3.5 w-3.5 text-orange-500" />
AI
</Button>
<Button variant="default" size="sm" className="h-8 rounded-lg text-xs font-bold gap-1.5 bg-orange-600 hover:bg-orange-700">
<Plus className="h-3.5 w-3.5" />
Yer Ekle
</Button>
</div>
</div>
<div className="flex-1">
{/* Smart Suggestion Card (Placeholder for visual matching) */}
<div className="p-6 pb-0">
<div className="bg-orange-50/50 border border-orange-100 rounded-2xl p-4 space-y-3 relative overflow-hidden group">
<div className="absolute -right-4 -top-4 opacity-5 group-hover:rotate-12 transition-transform">
<Wand2 className="h-24 w-24 text-orange-600" />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-orange-800">Akıllı Tur Önerisi</span>
<Badge className="bg-orange-100 text-orange-600 hover:bg-orange-100 border-none text-[9px] font-bold">%82 Doğruluk</Badge>
</div>
</div>
<p className="text-[11px] text-gray-600 leading-relaxed max-w-[90%]">
Planınız Green Tour rotasıyla %82 uyumlu. Yeraltı şehri ve Ihlara Vadisi gibi doğa ve...
</p>
<div className="flex items-center gap-4 text-[10px] font-bold text-gray-500">
<div className="flex items-center gap-1.5">
<MapPin className="h-3 w-3" /> ~19km
</div>
<div className="flex items-center gap-1.5">
<Zap className="h-3 w-3" /> ~2sa
</div>
</div>
<Button variant="default" className="w-full bg-orange-600 hover:bg-orange-700 h-9 rounded-xl text-[10px] font-bold gap-2">
<Sparkles className="h-3.5 w-3.5" />
Seçenekleri İncele
</Button>
</div>
</div>
<Timeline
itinerary={{ days: [trip.itinerary.days[selectedDayIndex]] }}
onReorder={(idx, items) => handleReorder(selectedDayIndex, items)}
onAddPlace={(idx, place) => handleAddPlace(selectedDayIndex, place)}
onDeletePlace={(idx, id) => handleDeletePlace(selectedDayIndex, id)}
onUpdatePlaceNote={(idx, id, note) => handleUpdatePlaceNote(selectedDayIndex, id, note)}
onUpdateDayNote={(idx, note) => handleUpdateDayNote(selectedDayIndex, note)}
onPlaceClick={(id) => setActivePlaceId(id)}
activePlaceId={activePlaceId}
/>
</div>
</section>
{/* Right Panel: Map */}
<section className="hidden lg:block flex-1 relative bg-secondary">
<TripMap
itinerary={trip.itinerary}
itinerary={{ days: [trip.itinerary.days[selectedDayIndex]] }}
activePlaceId={activePlaceId}
onMarkerClick={(id) => setActivePlaceId(id)}
onMarkerClick={(id) => {
setActivePlaceId(id);
const element = document.getElementById(`place-${id}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}}
/>
</div>
{/* Legend / Stats overlay */}
<div className="absolute top-6 left-6 z-10">
<div className="bg-white/90 backdrop-blur-md p-3 rounded-2xl shadow-xl border border-white/20 space-y-1">
<div className="text-[9px] font-black text-gray-400 uppercase tracking-widest px-1">ÖZET</div>
<div className="flex items-center gap-4">
<div className="flex flex-col">
<span className="text-xl font-black text-gray-900 tracking-tighter">{trip.itinerary.days[selectedDayIndex].items.length}</span>
<span className="text-[8px] font-bold text-gray-400 uppercase">DURAK</span>
</div>
<div className="w-px h-6 bg-gray-200" />
<div className="flex flex-col">
<span className="text-xl font-black text-gray-900 tracking-tighter">~{trip.itinerary.days[selectedDayIndex].items.length * 25}</span>
<span className="text-[8px] font-bold text-gray-400 uppercase">KM</span>
</div>
</div>
</div>
</div>
</section>
{/* Map Sheet - Mobile */}
<div className="lg:hidden fixed bottom-4 right-4 z-50">
{/* Mobile Map Button */}
<div className="lg:hidden fixed bottom-6 right-6 z-50">
<Sheet open={isMapSheetOpen} onOpenChange={setIsMapSheetOpen}>
<SheetTrigger asChild>
<Button size="lg" className="rounded-full shadow-lg">
<MapPin className="h-5 w-5 mr-2" />
Haritayı Göster
<Button size="lg" className="h-12 px-6 rounded-full shadow-2xl bg-orange-600 hover:bg-orange-700 text-white font-bold uppercase tracking-widest text-[10px]">
<MapPin className="h-4 w-4 mr-2" />
Haritayı
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="h-[70vh]">
<SheetHeader>
<SheetTitle>Rota Haritası</SheetTitle>
<SheetContent side="bottom" className="h-[80vh] p-0 rounded-t-3xl overflow-hidden bg-white">
<SheetHeader className="p-4 border-b">
<SheetTitle className="text-lg font-bold">Rota Haritası - Gün {trip.itinerary.days[selectedDayIndex].day}</SheetTitle>
</SheetHeader>
<div className="h-full mt-4">
<div className="h-full relative">
<TripMap
itinerary={trip.itinerary}
itinerary={{ days: [trip.itinerary.days[selectedDayIndex]] }}
activePlaceId={activePlaceId}
onMarkerClick={(id) => {
setActivePlaceId(id);
setIsMapSheetOpen(false);
const element = document.getElementById(`place-${id}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}}
/>
</div>

View File

@ -39,6 +39,71 @@ function normalizePlaceName(name: string): string {
.replace(/\s*(open air museum|underground city|valley|village|castle|church)\s*$/i, (match) => ' ' + match.trim().toLowerCase())
}
/**
* Generate a mock itinerary when OpenAI is unavailable.
*/
function generateMockItinerary(startDate: string, endDate: string, interests: string[]) {
const start = new Date(startDate);
const end = new Date(endDate);
const diffTime = Math.abs(end.getTime() - start.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
const numDays = Math.min(diffDays, 14);
const pool = [
{ place_name: "Göreme Open Air Museum", category: "museum", duration: 120, desc: "UNESCO World Heritage site with rock-cut churches." },
{ place_name: "Uçhisar Castle", category: "landmark", duration: 90, desc: "The highest point in Cappadocia with panoramic views." },
{ place_name: "Pasabag (Monks Valley)", category: "nature", duration: 60, desc: "Famous fairy chimneys with multiple caps." },
{ place_name: "Devrent Valley (Imagination Valley)", category: "nature", duration: 45, desc: "Unique rock formations resembling animals." },
{ place_name: "Kaymakli Underground City", category: "history", duration: 90, desc: "Ancient multi-level underground city." },
{ place_name: "Derinkuyu Underground City", category: "history", duration: 120, desc: "The deepest underground city in the region." },
{ place_name: "Ihlara Valley", category: "nature", duration: 180, desc: "A stunning canyon with a river and rock-cut churches." },
{ place_name: "Selime Monastery", category: "history", duration: 60, desc: "The largest religious structure in Cappadocia." },
{ place_name: "Pigeon Valley", category: "nature", duration: 90, desc: "Valley named after the countless man-made dovecotes." },
{ place_name: "Love Valley", category: "nature", duration: 60, desc: "Famous for its phallic-shaped fairy chimneys." },
{ place_name: "Avanos Pottery Workshop", category: "culture", duration: 60, desc: "Traditional pottery making in the Red River town." },
{ place_name: "Zelve Open Air Museum", category: "museum", duration: 120, desc: "An abandoned cave town with ancient churches." },
{ place_name: "Ortahisar Castle", category: "landmark", duration: 60, desc: "A massive rock formation used as a fortress." },
{ place_name: "Rose Valley Sunset Hike", category: "nature", duration: 120, desc: "Beautiful valley that turns pink at sunset." },
{ place_name: "Red Valley", category: "nature", duration: 90, desc: "Stunning valley with sharp ridges and red hues." },
{ place_name: "Çavuşin Village", category: "culture", duration: 60, desc: "An old Greek village with a massive rock castle." },
];
const days = [];
for (let i = 1; i <= numDays; i++) {
const dailyItems = [];
// Always add Hot Air Balloon on Day 1 or 2 if duration is 120
if (i <= 2) {
dailyItems.push({
place_name: "Hot Air Balloon Flight",
category: "activity",
estimated_duration_minutes: 180,
description: "Breathtaking sunrise flight over the fairy chimneys."
});
}
// Pick 3-4 random items from the pool
const shuffled = [...pool].sort(() => 0.5 - Math.random());
const selected = shuffled.slice(0, 3);
selected.forEach(item => {
dailyItems.push({
place_name: item.place_name,
category: item.category,
estimated_duration_minutes: item.duration,
description: item.desc
});
});
days.push({
day: i,
items: dailyItems
});
}
return { days };
}
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
@ -53,57 +118,73 @@ serve(async (req) => {
SUPABASE_SERVICE_ROLE_KEY!
)
// 1. Call OpenAI to generate itinerary
const systemPrompt = `You are an expert travel planner for Cappadocia, Turkey.
Generate a personalized travel itinerary based on user preferences.
Allowed regions: Göreme, Uçhisar, Ürgüp, Avanos, Ortahisar, Çavuşin, Derinkuyu, Kaymaklı.
Rules:
- Balloon rides must be at sunrise (05:00 - 08:00) on Day 1 or 2.
- Sunset valley visits after 17:30.
- Max 5 places per day.
- No duplicate places.
- Only return JSON in the specified format.
- Interests: ${interests.join(', ')}.
- Daily Schedule: ${dailySchedule}.
Response Format:
{
"days": [
let itinerary;
// 1. Call OpenAI to generate itinerary (if API key exists)
if (OPENAI_API_KEY && OPENAI_API_KEY !== 'PASTE_YOUR_OPENAI_API_KEY_HERE') {
try {
const systemPrompt = `You are an expert travel planner for Cappadocia, Turkey.
Generate a personalized travel itinerary based on user preferences.
Allowed regions: Göreme, Uçhisar, Ürgüp, Avanos, Ortahisar, Çavuşin, Derinkuyu, Kaymaklı.
Rules:
- Balloon rides must be at sunrise (05:00 - 08:00) on Day 1 or 2.
- Sunset valley visits after 17:30.
- Max 5 places per day.
- No duplicate places.
- Only return JSON in the specified format.
- Interests: ${interests.join(', ')}.
- Daily Schedule: ${dailySchedule}.
Response Format:
{
"day": 1,
"items": [
"days": [
{
"place_name": "Göreme Open Air Museum",
"category": "museum",
"estimated_duration_minutes": 120,
"description": "UNESCO World Heritage site with rock-cut churches."
"day": 1,
"items": [
{
"place_name": "Göreme Open Air Museum",
"category": "museum",
"estimated_duration_minutes": 120,
"description": "UNESCO World Heritage site with rock-cut churches."
}
]
}
]
}`
const userPrompt = `Trip Dates: ${startDate} to ${endDate}. Preferences: ${preferences}`
const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
temperature: 0.3,
response_format: { type: 'json_object' }
}),
})
if (!openaiRes.ok) {
throw new Error(`OpenAI API error: ${openaiRes.statusText}`);
}
]
}`
const userPrompt = `Trip Dates: ${startDate} to ${endDate}. Preferences: ${preferences}`
const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
temperature: 0.3,
response_format: { type: 'json_object' }
}),
})
const openaiData = await openaiRes.json()
const itinerary = JSON.parse(openaiData.choices[0].message.content)
const openaiData = await openaiRes.json()
itinerary = JSON.parse(openaiData.choices[0].message.content)
} catch (err) {
console.error('OpenAI failed, falling back to mock:', err)
itinerary = generateMockItinerary(startDate, endDate, interests)
}
} else {
console.log('No OpenAI API key found, using mock itinerary')
itinerary = generateMockItinerary(startDate, endDate, interests)
}
// 2. Verify places with Google Places API (with cache layer)
const verifiedDays = []
@ -139,25 +220,38 @@ serve(async (req) => {
// Cache MISS - fetching from Google and caching
console.log(`Cache MISS for "${item.place_name}" (normalized: "${normalizedName}") - fetching from Google and caching`)
// Text Search to get place_id and basic info
const searchUrl = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(item.place_name + ' Cappadocia')}&key=${GOOGLE_MAPS_API_KEY}`
const searchRes = await fetch(searchUrl)
const searchData = await searchRes.json()
let placeInfo = null;
if (searchData.results && searchData.results.length > 0) {
const place = searchData.results[0]
if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') {
try {
// Text Search to get place_id and basic info
const searchUrl = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(item.place_name + ' Cappadocia')}&key=${GOOGLE_MAPS_API_KEY}`
const searchRes = await fetch(searchUrl)
const searchData = await searchRes.json()
if (searchData.results && searchData.results.length > 0) {
const place = searchData.results[0]
placeInfo = {
place_id: place.place_id,
name: place.name,
formatted_address: place.formatted_address,
lat: place.geometry.location.lat,
lng: place.geometry.location.lng,
rating: place.rating || null,
user_ratings_total: place.user_ratings_total || null,
photo_reference: place.photos?.[0]?.photo_reference || null
}
}
} catch (err) {
console.error(`Google Places API error for ${item.place_name}:`, err)
}
}
if (placeInfo) {
// Store in cache for future use with normalized name
const cacheEntry = {
place_name_normalized: normalizedName,
place_id: place.place_id,
name: place.name,
formatted_address: place.formatted_address,
lat: place.geometry.location.lat,
lng: place.geometry.location.lng,
rating: place.rating || null,
user_ratings_total: place.user_ratings_total || null,
photo_reference: place.photos?.[0]?.photo_reference || null
...placeInfo
}
await supabase
@ -166,14 +260,13 @@ serve(async (req) => {
verifiedItems.push({
...item,
place_id: place.place_id,
name: place.name,
formatted_address: place.formatted_address,
lat: place.geometry.location.lat,
lng: place.geometry.location.lng,
rating: place.rating,
user_ratings_total: place.user_ratings_total,
photo_reference: place.photos?.[0]?.photo_reference
...placeInfo
})
} else {
// No Google info found or key missing, use item as is but mark as unverified
verifiedItems.push({
...item,
unverified: true
})
}
}
@ -193,7 +286,7 @@ serve(async (req) => {
}
const startTime = currentTime.toTimeString().slice(0, 5)
currentTime.setMinutes(currentTime.getMinutes() + item.estimated_duration_minutes)
currentTime.setMinutes(currentTime.getMinutes() + (item.estimated_duration_minutes || 60))
const endTime = currentTime.toTimeString().slice(0, 5)
currentTime.setMinutes(currentTime.getMinutes() + 30) // 30 min travel/buffer
@ -206,9 +299,10 @@ serve(async (req) => {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} catch (error) {
console.error('Final catch error:', error)
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
})
})

View File

@ -44,35 +44,62 @@ serve(async (req) => {
}
}
// Cache miss or expired - call Google Directions API
console.log('Cache miss for:', cache_key)
let url = `https://maps.googleapis.com/maps/api/directions/json?origin=${origin}&destination=${destination}&key=${GOOGLE_MAPS_API_KEY}&mode=driving`
if (waypoints && waypoints.length > 0) {
url += `&waypoints=${waypoints.join('|')}`
// Cache miss or expired - call Google Directions API (if key exists)
if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') {
try {
console.log('Cache miss for:', cache_key)
let url = `https://maps.googleapis.com/maps/api/directions/json?origin=${origin}&destination=${destination}&key=${GOOGLE_MAPS_API_KEY}&mode=driving`
if (waypoints && waypoints.length > 0) {
url += `&waypoints=${waypoints.join('|')}`
}
const res = await fetch(url)
const data = await res.json()
if (data.status === 'OK') {
// Upsert the result into cache
await supabase
.from('directions_cache')
.upsert({
cache_key,
response: data,
created_at: new Date().toISOString()
}, {
onConflict: 'cache_key'
})
}
return new Response(JSON.stringify(data), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} catch (err) {
console.error('Google Directions API error, falling back to dummy:', err)
}
}
const res = await fetch(url)
const data = await res.json()
// No key or error - return dummy response
const dummyResponse = {
status: "OK",
routes: [{
summary: "Dummy route for demonstration",
legs: [{
distance: { text: "10 km", value: 10000 },
duration: { text: "15 mins", value: 900 },
steps: []
}],
overview_polyline: { points: "" }
}]
}
// Upsert the result into cache
await supabase
.from('directions_cache')
.upsert({
cache_key,
response: data,
created_at: new Date().toISOString()
}, {
onConflict: 'cache_key'
})
return new Response(JSON.stringify(data), {
return new Response(JSON.stringify(dummyResponse), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
})
})

View File

@ -10,6 +10,12 @@ const corsHeaders = {
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
const FALLBACK_PHOTO_URLS = [
'https://images.unsplash.com/photo-1544833316-64d88e00182a?q=80&w=800&auto=format&fit=crop', // Cappadocia Valley
'https://images.unsplash.com/photo-1570168007204-dfb528c6958f?q=80&w=800&auto=format&fit=crop', // Hot Air Balloons
'https://images.unsplash.com/photo-1524231757912-21f4fe3a7200?q=80&w=800&auto=format&fit=crop', // Stone Houses
]
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
@ -20,7 +26,9 @@ serve(async (req) => {
const photoReference = urlObj.searchParams.get('photo_reference')
if (!photoReference) {
return new Response('Missing photo_reference', { status: 400 })
// If missing photo_reference, redirect to a random fallback image
const fallbackUrl = FALLBACK_PHOTO_URLS[Math.floor(Math.random() * FALLBACK_PHOTO_URLS.length)]
return Response.redirect(fallbackUrl, 302)
}
// Initialize Supabase client with service role
@ -37,51 +45,57 @@ serve(async (req) => {
// Try to HEAD the file to check if it exists
try {
const headResponse = await fetch(publicUrlData.publicUrl, { method: 'HEAD' })
if (headResponse.ok) {
// File exists in storage, redirect to it
return Response.redirect(publicUrlData.publicUrl, 302)
}
} catch (headError) {
// File doesn't exist, continue to fetch from Google
console.log('File not in cache, fetching from Google:', headError)
console.log('File not in cache, fetching from Google...')
}
// File doesn't exist in storage, fetch from Google
const googlePhotoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=800&photo_reference=${photoReference}&key=${GOOGLE_MAPS_API_KEY}`
const googleResponse = await fetch(googlePhotoUrl)
if (!googleResponse.ok) {
throw new Error(`Failed to fetch photo from Google: ${googleResponse.statusText}`)
// File doesn't exist in storage, fetch from Google (if key exists)
if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') {
try {
const googlePhotoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=800&photo_reference=${photoReference}&key=${GOOGLE_MAPS_API_KEY}`
const googleResponse = await fetch(googlePhotoUrl)
if (googleResponse.ok) {
// Get the image blob
const imageBlob = await googleResponse.blob()
// Upload to Supabase Storage
const { error: uploadError } = await supabase.storage
.from('place-photos')
.upload(storagePath, imageBlob, {
contentType: 'image/jpeg',
cacheControl: '31536000', // Cache for 1 year
upsert: false
})
if (!uploadError) {
// Successfully uploaded, redirect to storage URL
return Response.redirect(publicUrlData.publicUrl, 302)
} else {
console.error('Failed to upload to storage:', uploadError)
// If upload fails, still redirect to Google URL as fallback
return Response.redirect(googlePhotoUrl, 302)
}
}
} catch (err) {
console.error('Failed to fetch photo from Google, using fallback:', err)
}
}
// Get the image blob
const imageBlob = await googleResponse.blob()
// Upload to Supabase Storage
const { error: uploadError } = await supabase.storage
.from('place-photos')
.upload(storagePath, imageBlob, {
contentType: 'image/jpeg',
cacheControl: '31536000', // Cache for 1 year
upsert: false
})
if (uploadError) {
console.error('Failed to upload to storage:', uploadError)
// If upload fails, still redirect to Google URL as fallback
return Response.redirect(googlePhotoUrl, 302)
}
// Successfully uploaded, redirect to storage URL
return Response.redirect(publicUrlData.publicUrl, 302)
// If key is missing or fetch fails, redirect to a random fallback image
const fallbackUrl = FALLBACK_PHOTO_URLS[Math.floor(Math.random() * FALLBACK_PHOTO_URLS.length)]
return Response.redirect(fallbackUrl, 302)
} catch (error) {
console.error('Error in get-place-photo:', error)
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
// Always fallback to something visual
const fallbackUrl = FALLBACK_PHOTO_URLS[Math.floor(Math.random() * FALLBACK_PHOTO_URLS.length)]
return Response.redirect(fallbackUrl, 302)
}
})
})

View File

@ -15,4 +15,9 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
});
server: {
host: '0.0.0.0',
port: 3001,
allowedHosts: ['.dev.flatlogic.app', '.dev.appwizzy.dev', 'localhost', '127.0.0.1']
}
});

View File

@ -0,0 +1,9 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html><head>
<title>403 Forbidden</title>
</head><body>
<h1>Forbidden</h1>
<p>You don't have permission to access this resource.</p>
<hr>
<address>Apache/2.4.66 (Debian) Server at app-9xzmfic2e4g1appversion-9y0h01so4s8w-273b.dev.appwizzy.dev Port 80</address>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

17
db/config.php Normal file
View File

@ -0,0 +1,17 @@
<?php
// Generated by setup_mariadb_project.sh — edit as needed.
define('DB_HOST', '127.0.0.1');
define('DB_NAME', 'app_38913');
define('DB_USER', 'app_38913');
define('DB_PASS', '9caf05b4-928b-4060-979c-c3d0cac2e16d');
function db() {
static $pdo;
if (!$pdo) {
$pdo = new PDO('mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4', DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
}
return $pdo;
}