+
+ e.stopPropagation()}
+ className="glass rounded-3xl shadow-2xl w-full max-w-md overflow-hidden flex flex-col max-h-[80vh]"
+ >
+ {/* Header - Gradient */}
+
+
- {/* Header */}
-
-
- ❤️ My Favorites
-
-
+
+
+
+
+
+ My Favorites
+
+
+
+
+
+
+
+ {favorites.length > 0 && (
+
+ {favorites.length} {favorites.length === 1 ? 'place' : 'places'} saved
+
+ )}
{/* List */}
-
+
{favorites.length === 0 ? (
-
-
-
-
-
No favorites yet.
-
Start exploring and save places you like!
-
+
+
+
+
+ No favorites yet
+ Start exploring and save places you like!
+
) : (
{favorites.map((place, index) => (
-
-
-
-
+ {/* Gradient border on hover */}
+
+
+
+
+
+
onSelect(place)}>
-
{place.name}
-
{place.category || 'Place'}
+
{place.name}
+
{place.category || 'Place'}
-
-
-
+
+ onRemove(place)}
- className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
+ className="p-2.5 text-gray-400 dark:text-gray-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-xl transition-all min-w-[40px] min-h-[40px] flex items-center justify-center"
title="Remove"
>
-
-
+
+
-
+
))}
)}
-
-
-
+
+
);
};
diff --git a/components/GroundingChips.test.tsx b/components/GroundingChips.test.tsx
new file mode 100644
index 0000000..cbcba14
--- /dev/null
+++ b/components/GroundingChips.test.tsx
@@ -0,0 +1,354 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '../src/test/testUtils';
+import GroundingChips from './GroundingChips';
+import { GroundingMetadata } from '../types';
+
+describe('GroundingChips', () => {
+ describe('Rendering with No Data', () => {
+ it('should render nothing when metadata is undefined', () => {
+ const { container } = render(
);
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should render nothing when metadata is empty', () => {
+ const metadata: GroundingMetadata = {};
+ const { container } = render(
);
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should render nothing when both chunks are empty arrays', () => {
+ const metadata: GroundingMetadata = {
+ mapChunks: [],
+ searchChunks: [],
+ };
+ const { container } = render(
);
+ expect(container.firstChild).toBeNull();
+ });
+ });
+
+ describe('Map Chunks Rendering', () => {
+ it('should render map chunks header', () => {
+ const metadata: GroundingMetadata = {
+ mapChunks: [
+ {
+ uri: 'https://maps.google.com/place1',
+ title: 'Restaurant A',
+ address: '123 Main St',
+ },
+ ],
+ };
+
+ render(
);
+
+ expect(screen.getByText('Locations Found')).toBeInTheDocument();
+ });
+
+ it('should render map chunk with all details', () => {
+ const metadata: GroundingMetadata = {
+ mapChunks: [
+ {
+ uri: 'https://maps.google.com/place1',
+ title: 'Test Restaurant',
+ address: '456 Oak Ave',
+ snippet: 'Great food and service!',
+ },
+ ],
+ };
+
+ render(
);
+
+ expect(screen.getByText('Test Restaurant')).toBeInTheDocument();
+ expect(screen.getByText('456 Oak Ave')).toBeInTheDocument();
+ expect(screen.getByText('"Great food and service!"')).toBeInTheDocument();
+ });
+
+ it('should render map chunk without address', () => {
+ const metadata: GroundingMetadata = {
+ mapChunks: [
+ {
+ uri: 'https://maps.google.com/place1',
+ title: 'Museum B',
+ },
+ ],
+ };
+
+ render(
);
+
+ expect(screen.getByText('Museum B')).toBeInTheDocument();
+ expect(screen.getByText('Open in Google Maps')).toBeInTheDocument();
+ });
+
+ it('should render multiple map chunks', () => {
+ const metadata: GroundingMetadata = {
+ mapChunks: [
+ {
+ uri: 'https://maps.google.com/place1',
+ title: 'Place 1',
+ address: 'Address 1',
+ },
+ {
+ uri: 'https://maps.google.com/place2',
+ title: 'Place 2',
+ address: 'Address 2',
+ },
+ {
+ uri: 'https://maps.google.com/place3',
+ title: 'Place 3',
+ address: 'Address 3',
+ },
+ ],
+ };
+
+ render(
);
+
+ expect(screen.getByText('Place 1')).toBeInTheDocument();
+ expect(screen.getByText('Place 2')).toBeInTheDocument();
+ expect(screen.getByText('Place 3')).toBeInTheDocument();
+ });
+
+ it('should render map chunks as clickable links', () => {
+ const metadata: GroundingMetadata = {
+ mapChunks: [
+ {
+ uri: 'https://maps.google.com/place1',
+ title: 'Test Place',
+ },
+ ],
+ };
+
+ render(
);
+
+ const link = screen.getByText('Test Place').closest('a');
+ expect(link).toHaveAttribute('href', 'https://maps.google.com/place1');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+ });
+
+ describe('Search Chunks Rendering', () => {
+ it('should render search chunks header', () => {
+ const metadata: GroundingMetadata = {
+ searchChunks: [
+ {
+ uri: 'https://example.com',
+ title: 'Example Article',
+ source: 'example.com',
+ },
+ ],
+ };
+
+ render(
);
+
+ expect(screen.getByText('Web Sources')).toBeInTheDocument();
+ });
+
+ it('should render search chunk with title', () => {
+ const metadata: GroundingMetadata = {
+ searchChunks: [
+ {
+ uri: 'https://example.com/article',
+ title: 'Interesting Article About Places',
+ source: 'example.com',
+ },
+ ],
+ };
+
+ render(
);
+
+ expect(screen.getByText('Interesting Article About Places')).toBeInTheDocument();
+ });
+
+ it('should render multiple search chunks', () => {
+ const metadata: GroundingMetadata = {
+ searchChunks: [
+ {
+ uri: 'https://site1.com',
+ title: 'Article 1',
+ source: 'site1.com',
+ },
+ {
+ uri: 'https://site2.com',
+ title: 'Article 2',
+ source: 'site2.com',
+ },
+ ],
+ };
+
+ render(
);
+
+ expect(screen.getByText('Article 1')).toBeInTheDocument();
+ expect(screen.getByText('Article 2')).toBeInTheDocument();
+ });
+
+ it('should render search chunks as clickable links', () => {
+ const metadata: GroundingMetadata = {
+ searchChunks: [
+ {
+ uri: 'https://test.com/page',
+ title: 'Test Page',
+ source: 'test.com',
+ },
+ ],
+ };
+
+ render(
);
+
+ const link = screen.getByText('Test Page').closest('a');
+ expect(link).toHaveAttribute('href', 'https://test.com/page');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+ });
+
+ describe('Combined Rendering', () => {
+ it('should render both map and search chunks together', () => {
+ const metadata: GroundingMetadata = {
+ mapChunks: [
+ {
+ uri: 'https://maps.google.com/place1',
+ title: 'Restaurant',
+ address: '123 St',
+ },
+ ],
+ searchChunks: [
+ {
+ uri: 'https://article.com',
+ title: 'Article',
+ source: 'article.com',
+ },
+ ],
+ };
+
+ render(
);
+
+ expect(screen.getByText('Locations Found')).toBeInTheDocument();
+ expect(screen.getByText('Web Sources')).toBeInTheDocument();
+ expect(screen.getByText('Restaurant')).toBeInTheDocument();
+ expect(screen.getByText('Article')).toBeInTheDocument();
+ });
+
+ it('should render only map chunks when search chunks are empty', () => {
+ const metadata: GroundingMetadata = {
+ mapChunks: [
+ {
+ uri: 'https://maps.google.com/place1',
+ title: 'Place',
+ },
+ ],
+ searchChunks: [],
+ };
+
+ render(
);
+
+ expect(screen.getByText('Locations Found')).toBeInTheDocument();
+ expect(screen.queryByText('Web Sources')).not.toBeInTheDocument();
+ });
+
+ it('should render only search chunks when map chunks are empty', () => {
+ const metadata: GroundingMetadata = {
+ mapChunks: [],
+ searchChunks: [
+ {
+ uri: 'https://test.com',
+ title: 'Test',
+ source: 'test.com',
+ },
+ ],
+ };
+
+ render(
);
+
+ expect(screen.queryByText('Locations Found')).not.toBeInTheDocument();
+ expect(screen.getByText('Web Sources')).toBeInTheDocument();
+ });
+ });
+
+ describe('Styling', () => {
+ it('should have proper container styling', () => {
+ const metadata: GroundingMetadata = {
+ mapChunks: [
+ {
+ uri: 'https://maps.google.com/test',
+ title: 'Test',
+ },
+ ],
+ };
+
+ const { container } = render(
);
+
+ // Check for border-t class (border at top)
+ const mainContainer = container.querySelector('.border-t');
+ expect(mainContainer).toBeInTheDocument();
+ });
+
+ it('should apply hover styles to map chunks', () => {
+ const metadata: GroundingMetadata = {
+ mapChunks: [
+ {
+ uri: 'https://maps.google.com/test',
+ title: 'Test',
+ },
+ ],
+ };
+
+ const { container } = render(
);
+
+ const link = container.querySelector('a');
+ expect(link?.className).toContain('hover:shadow-md');
+ });
+
+ it('should apply hover styles to search chunks', () => {
+ const metadata: GroundingMetadata = {
+ searchChunks: [
+ {
+ uri: 'https://test.com',
+ title: 'Test',
+ source: 'test.com',
+ },
+ ],
+ };
+
+ const { container } = render(
);
+
+ const link = container.querySelector('a');
+ expect(link?.className).toContain('hover:bg-blue-50');
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle map chunk with snippet but no address', () => {
+ const metadata: GroundingMetadata = {
+ mapChunks: [
+ {
+ uri: 'https://maps.google.com/place',
+ title: 'Place',
+ snippet: 'Great reviews!',
+ },
+ ],
+ };
+
+ render(
);
+
+ expect(screen.getByText('"Great reviews!"')).toBeInTheDocument();
+ expect(screen.queryByText('Open in Google Maps')).not.toBeInTheDocument();
+ });
+
+ it('should handle very long titles', () => {
+ const metadata: GroundingMetadata = {
+ searchChunks: [
+ {
+ uri: 'https://test.com',
+ title: 'This is a very long title that should be truncated in the UI to prevent layout issues',
+ source: 'test.com',
+ },
+ ],
+ };
+
+ render(
);
+
+ expect(
+ screen.getByText('This is a very long title that should be truncated in the UI to prevent layout issues')
+ ).toBeInTheDocument();
+ });
+ });
+});
diff --git a/components/LanguageToggle.tsx b/components/LanguageToggle.tsx
new file mode 100644
index 0000000..1c09c4d
--- /dev/null
+++ b/components/LanguageToggle.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Languages } from 'lucide-react';
+import { useLanguage } from '../contexts/LanguageContext';
+
+const LanguageToggle: React.FC = () => {
+ const { language, setLanguage, t } = useLanguage();
+
+ const toggleLanguage = () => {
+ setLanguage(language === 'tr' ? 'en' : 'tr');
+ };
+
+ return (
+
+ {/* Background glow effect */}
+
+
+ {/* Icon container with rotation animation */}
+
+
+
+ {language}
+
+
+
+ );
+};
+
+export default LanguageToggle;
diff --git a/components/LoadingSkeleton.tsx b/components/LoadingSkeleton.tsx
new file mode 100644
index 0000000..b4e3bac
--- /dev/null
+++ b/components/LoadingSkeleton.tsx
@@ -0,0 +1,95 @@
+import React from 'react';
+
+interface LoadingSkeletonProps {
+ type?: 'message' | 'place' | 'card' | 'text';
+ count?: number;
+}
+
+/**
+ * Loading skeleton component for better UX during data fetching
+ */
+const LoadingSkeleton: React.FC
= ({ type = 'text', count = 1 }) => {
+ const renderSkeleton = () => {
+ switch (type) {
+ case 'message':
+ return (
+
+
+ {/* Avatar skeleton */}
+
+
+ {/* Message content skeleton */}
+
+
+
+ );
+
+ case 'place':
+ return (
+
+ {/* Icon skeleton */}
+
+
+ {/* Content skeleton */}
+
+
+ {/* Arrow skeleton */}
+
+
+ );
+
+ case 'card':
+ return (
+
+ {/* Image skeleton */}
+
+
+ {/* Content skeleton */}
+
+
+
+
+
+ {/* Rating skeleton */}
+
+
+ {/* Buttons skeleton */}
+
+
+
+ );
+
+ case 'text':
+ default:
+ return (
+
+ );
+ }
+ };
+
+ return (
+ <>
+ {Array.from({ length: count }).map((_, index) => (
+ {renderSkeleton()}
+ ))}
+ >
+ );
+};
+
+export default LoadingSkeleton;
diff --git a/components/OfflineIndicator.tsx b/components/OfflineIndicator.tsx
new file mode 100644
index 0000000..f1fc170
--- /dev/null
+++ b/components/OfflineIndicator.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { WifiOff } from 'lucide-react';
+import { useOnlineStatus } from '../hooks/useOnlineStatus';
+
+/**
+ * Displays a banner when user is offline
+ */
+const OfflineIndicator: React.FC = () => {
+ const isOnline = useOnlineStatus();
+
+ if (isOnline) {
+ return null;
+ }
+
+ return (
+
+
+ 📡 İnternet bağlantısı yok - Bazı özellikler çalışmayabilir
+
+ );
+};
+
+export default OfflineIndicator;
diff --git a/components/OfflineMapDownloader.tsx b/components/OfflineMapDownloader.tsx
new file mode 100644
index 0000000..1be8fee
--- /dev/null
+++ b/components/OfflineMapDownloader.tsx
@@ -0,0 +1,540 @@
+import React, { useState, useEffect } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Download, Trash2, Check, MapPin, X, HardDrive, Plus } from 'lucide-react';
+import { offlineMapManager, AVAILABLE_REGIONS, MapRegion } from '../services/offlineMapManager';
+import { OfflineMap } from '../services/offlineMapDB';
+
+interface OfflineMapDownloaderProps {
+ onClose: () => void;
+}
+
+interface DownloadState {
+ regionId: string;
+ progress: number;
+ loaded: number;
+ total: number;
+ isDownloading: boolean;
+ error?: string;
+}
+
+const OfflineMapDownloader: React.FC = ({ onClose }) => {
+ const [downloadedMaps, setDownloadedMaps] = useState([]);
+ const [downloadStates, setDownloadStates] = useState