diff --git a/package.json b/package.json index 38833e9..7b99b05 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-navigation-menu": "^1.2.10", "@radix-ui/react-popover": "^1.1.11", "@radix-ui/react-select": "^2.2.2", + "@radix-ui/react-separator": "^1.1.4", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.8", "@radix-ui/react-toast": "^1.2.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed43332..a4d7e28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@radix-ui/react-select': specifier: ^2.2.2 version: 2.2.2(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-separator': + specifier: ^1.1.4 + version: 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': specifier: ^1.2.0 version: 1.2.0(@types/react@19.1.2)(react@19.1.0) @@ -824,6 +827,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-separator@1.1.4': + resolution: {integrity: sha512-2fTm6PSiUm8YPq9W0E4reYuv01EE3aFSzt8edBiXqPHshF8N9+Kymt/k0/R+F3dkY5lQyB/zPtrP82phskLi7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.0': resolution: {integrity: sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==} peerDependencies: @@ -3382,6 +3398,15 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) + '@radix-ui/react-separator@1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.2 + '@types/react-dom': 19.1.2(@types/react@19.1.2) + '@radix-ui/react-slot@1.2.0(@types/react@19.1.2)(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) diff --git a/public/map/oriyo-marker.png b/public/map/oriyo-marker.png new file mode 100644 index 0000000..4fa44f0 Binary files /dev/null and b/public/map/oriyo-marker.png differ diff --git a/src/features/map/ui/gas-station-map.tsx b/src/features/map/ui/gas-station-map.tsx index 5f1357e..6bfa874 100644 --- a/src/features/map/ui/gas-station-map.tsx +++ b/src/features/map/ui/gas-station-map.tsx @@ -8,13 +8,14 @@ import { List, MapPin, } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import { Stations } from '@/app/api-utlities/@types'; import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { Badge } from '@/shared/shadcn-ui/badge'; import { Button } from '@/shared/shadcn-ui/button'; +import { Separator } from '@/shared/shadcn-ui/separator'; import { Tabs, TabsContent, @@ -22,443 +23,221 @@ import { TabsTrigger, } from '@/shared/shadcn-ui/tabs'; -// Sample data for gas stations -const stations = [ - { - id: 1, - name: 'АЗС Душанбе-Центр', - address: 'ул. Рудаки 150, Душанбе', - city: 'Душанбе', - coordinates: { x: 0.2, y: 0.3 }, - services: ['ДТ', 'АИ-92', 'АИ-95', 'Z-100 Power', 'Минимаркет', 'Туалет'], - }, - { - id: 2, - name: 'АЗС Худжанд', - address: 'ул. Ленина 45, Худжанд', - city: 'Худжанд', - coordinates: { x: 0.5, y: 0.2 }, - services: [ - 'ДТ', - 'АИ-92', - 'АИ-95', - 'Пропан', - 'Минимаркет', - 'Автомойка', - 'Туалет', - ], - }, - { - id: 3, - name: 'АЗС Куляб', - address: 'ул. Сомони 78, Куляб', - city: 'Куляб', - coordinates: { x: 0.7, y: 0.4 }, - services: ['ДТ', 'АИ-92', 'Пропан', 'Туалет'], - }, - { - id: 4, - name: 'АЗС Бохтар', - address: 'ул. Айни 23, Бохтар', - city: 'Бохтар', - coordinates: { x: 0.3, y: 0.6 }, - services: [ - 'ДТ', - 'АИ-92', - 'АИ-95', - 'Z-100 Power', - 'Минимаркет', - 'Зарядная станция', - 'Туалет', - ], - }, - { - id: 5, - name: 'АЗС Хорог', - address: 'ул. Горная 12, Хорог', - city: 'Хорог', - coordinates: { x: 0.6, y: 0.7 }, - services: ['ДТ', 'АИ-92', 'Автомойка', 'Туалет'], - }, - { - id: 6, - name: 'АЗС Истаравшан', - address: 'ул. Исмоили Сомони 34, Истаравшан', - city: 'Истаравшан', - coordinates: { x: 0.8, y: 0.8 }, - services: ['ДТ', 'АИ-92', 'АИ-95', 'Минимаркет', 'Туалет'], - }, - { - id: 7, - name: 'АЗС Пенджикент', - address: 'ул. Рудаки 56, Пенджикент', - city: 'Пенджикент', - coordinates: { x: 0.1, y: 0.9 }, - services: ['ДТ', 'АИ-92', 'АИ-95', 'Пропан', 'Минимаркет', 'Туалет'], - }, - { - id: 8, - name: 'АЗС Душанбе-Запад', - address: 'ул. Джами 23, Душанбе', - city: 'Душанбе', - coordinates: { x: 0.25, y: 0.35 }, - services: [ - 'ДТ', - 'АИ-92', - 'АИ-95', - 'Z-100 Power', - 'Пропан', - 'Минимаркет', - 'Автомойка', - 'Туалет', - ], - }, - { - id: 9, - name: 'АЗС Душанбе-Восток', - address: 'ул. Айни 78, Душанбе', - city: 'Душанбе', - coordinates: { x: 0.15, y: 0.25 }, - services: [ - 'ДТ', - 'АИ-92', - 'АИ-95', - 'Зарядная станция', - 'Минимаркет', - 'Туалет', - ], - }, - { - id: 10, - name: 'АЗС Гиссар', - address: 'ул. Центральная 12, Гиссар', - city: 'Гиссар', - coordinates: { x: 0.4, y: 0.4 }, - services: ['ДТ', 'АИ-92', 'Пропан', 'Туалет'], - }, - { - id: 11, - name: 'АЗС Вахдат', - address: 'ул. Сомони 45, Вахдат', - city: 'Вахдат', - coordinates: { x: 0.55, y: 0.45 }, - services: ['ДТ', 'АИ-92', 'АИ-95', 'Минимаркет', 'Туалет'], - }, - { - id: 12, - name: 'АЗС Турсунзаде', - address: 'ул. Ленина 34, Турсунзаде', - city: 'Турсунзаде', - coordinates: { x: 0.65, y: 0.55 }, - services: ['ДТ', 'АИ-92', 'АИ-95', 'Z-100 Power', 'Автомойка', 'Туалет'], - }, -]; - -// All available filters -const allFilters = [ - 'ДТ', - 'АИ-92', - 'АИ-95', - 'Z-100 Power', - 'Пропан', - 'Зарядная станция', - 'Минимаркет', - 'Автомойка', - 'Туалет', -]; - -// Extract unique cities from stations -const allCities = [...new Set(stations.map((station) => station.city))].sort(); +import { Point } from '../model'; +import { YandexMap } from './yandex-map'; +// Пропсы для компонента GasStationMap interface GasStationMapProps { stations: Stations; } -export default function GasStationMap({ - stations: _stations, -}: GasStationMapProps) { - const { t } = useTextController(); - const mapRef = useRef(null); - const [activeFilters, setActiveFilters] = useState([]); - const [activeCities, setActiveCities] = useState([]); - const [filteredStations, setFilteredStations] = useState(stations); - const [selectedStation, setSelectedStation] = useState(null); - const [isFilterOpen, setIsFilterOpen] = useState(false); - const [isStationListOpen, setIsStationListOpen] = useState(false); - const [activeFilterTab, setActiveFilterTab] = useState('cities'); +// Пропсы для панели фильтров +interface FilterPanelProps { + isOpen: boolean; + onClose: () => void; + activeFilters: string[]; + activeCities: string[]; + allCities: string[]; + allFilters: string[]; + activeFilterTab: string; + toggleFilter: (filter: string) => void; + toggleCity: (city: string) => void; + selectAllCities: () => void; + setActiveFilterTab: (tab: string) => void; + resetFilters: () => void; + resetCities: () => void; + t: (key: string) => string; +} - // Toggle service filter - const toggleFilter = (filter: string) => { - if (activeFilters.includes(filter)) { - setActiveFilters(activeFilters.filter((f) => f !== filter)); - } else { - setActiveFilters([...activeFilters, filter]); - } - }; - - // Toggle city filter - const toggleCity = (city: string) => { - if (activeCities.includes(city)) { - setActiveCities(activeCities.filter((c) => c !== city)); - } else { - setActiveCities([...activeCities, city]); - } - }; - - // Select all cities - const selectAllCities = () => { - if (activeCities.length === allCities.length) { - setActiveCities([]); - } else { - setActiveCities([...allCities]); - } - }; - - // Filter stations based on active filters and cities - useEffect(() => { - let filtered = stations; - - // Filter by services - if (activeFilters.length > 0) { - filtered = filtered.filter((station) => - activeFilters.every((filter) => station.services.includes(filter)), - ); - } - - // Filter by cities - if (activeCities.length > 0) { - filtered = filtered.filter((station) => - activeCities.includes(station.city), - ); - } - - setFilteredStations(filtered); - }, [activeFilters, activeCities]); - - useEffect(() => { - // This is a placeholder for a real map implementation - // In a real application, you would use a mapping library like Mapbox, Google Maps, or Leaflet - if (mapRef.current) { - const canvas = document.createElement('canvas'); - canvas.width = mapRef.current.clientWidth; - canvas.height = mapRef.current.clientHeight; - mapRef.current.appendChild(canvas); - - const ctx = canvas.getContext('2d'); - if (ctx) { - // Draw a simple map placeholder - ctx.fillStyle = '#f3f4f6'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - // Draw some roads - ctx.strokeStyle = '#d1d5db'; - ctx.lineWidth = 5; - - // Horizontal roads - for (let i = 1; i < 5; i++) { - ctx.beginPath(); - ctx.moveTo(0, canvas.height * (i / 5)); - ctx.lineTo(canvas.width, canvas.height * (i / 5)); - ctx.stroke(); - } - - // Vertical roads - for (let i = 1; i < 8; i++) { - ctx.beginPath(); - ctx.moveTo(canvas.width * (i / 8), 0); - ctx.lineTo(canvas.width * (i / 8), canvas.height); - ctx.stroke(); - } - - // Draw gas station markers - filteredStations.forEach((station) => { - const isSelected = selectedStation === station.id; - // Draw marker - ctx.fillStyle = isSelected ? '#3b82f6' : '#ef4444'; - ctx.beginPath(); - ctx.arc( - station.coordinates.x * canvas.width, - station.coordinates.y * canvas.height, - isSelected ? 12 : 10, - 0, - 2 * Math.PI, - ); - ctx.fill(); - - // Draw white border - ctx.strokeStyle = 'white'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc( - station.coordinates.x * canvas.width, - station.coordinates.y * canvas.height, - isSelected ? 12 : 10, - 0, - 2 * Math.PI, - ); - ctx.stroke(); - }); - - // Add city names - ctx.fillStyle = '#1f2937'; - ctx.font = 'bold 16px Arial'; - ctx.fillText('Душанбе', canvas.width * 0.45, canvas.height * 0.15); - ctx.fillText('Худжанд', canvas.width * 0.2, canvas.height * 0.25); - ctx.fillText('Куляб', canvas.width * 0.7, canvas.height * 0.35); - ctx.fillText('Бохтар', canvas.width * 0.3, canvas.height * 0.55); - ctx.fillText('Хорог', canvas.width * 0.6, canvas.height * 0.65); - ctx.fillText('Истаравшан', canvas.width * 0.8, canvas.height * 0.75); - ctx.fillText('Пенджикент', canvas.width * 0.1, canvas.height * 0.85); - } - } - - return () => { - if (mapRef.current) { - while (mapRef.current.firstChild) { - mapRef.current.removeChild(mapRef.current.firstChild); - } - } - }; - }, [filteredStations, selectedStation]); +// Пропсы для панели списка станций +interface StationListPanelProps { + isOpen: boolean; + onClose: () => void; + stations: Stations; + selectedStation: number | null; + activeFilters: string[]; + activeCities: string[]; + setSelectedStation: (id: number | null) => void; + t: (key: string) => string; + filterToFieldMap: { [key: string]: keyof Stations[number] }; + allFilters: string[]; + resetFilters: () => void; + resetCities: () => void; +} +// Компонент панели фильтров +function FilterPanel({ + isOpen, + onClose, + activeFilters, + activeCities, + allCities, + allFilters, + activeFilterTab, + toggleFilter, + toggleCity, + selectAllCities, + setActiveFilterTab, + resetFilters, + resetCities, + t, +}: FilterPanelProps) { return ( -
- {/* Filter panel - slides from left */} -
-
-
- - {t('map.filters')} -
- -
- -
- - - {t('map.cities')} - {t('map.services')} - - - - - -
- {allCities.map((city) => ( - - ))} -
- - {activeCities.length > 0 && ( - - )} -
- - -
- {allFilters.map((filter) => ( - - ))} -
- {activeFilters.length > 0 && ( - - )} -
-
+
+
+
+ + {t('map.filters')}
+
- {/* Station list panel - slides from right */} -
-
- -
- {t('map.stationsList')} - {filteredStations.length} -
-
-
+ - {filteredStations.length > 0 ? ( -
- {filteredStations.map((station) => ( + + {t('map.cities')} + {t('map.services')} + + + + + +
+ {allCities.map((city) => ( + + ))} +
+
+ + +
+ {allFilters.map((filter) => ( + + ))} +
+
+ + + + {/* Кнопка сброса фильтров */} + {activeFilterTab === 'cities' + ? activeCities.length > 0 && ( + + ) + : activeFilters.length > 0 && ( + + )} + +
+
+ ); +} + +// Компонент панели списка станций +function StationListPanel({ + isOpen, + onClose, + stations, + selectedStation, + activeFilters, + activeCities, + setSelectedStation, + t, + filterToFieldMap, + allFilters, + resetCities, + resetFilters, +}: StationListPanelProps) { + return ( +
+
+ +
+ {t('map.stationsList')} + {stations.length} +
+
+
+ {stations.length > 0 ? ( +
+ {stations.map((station) => { + const services = allFilters.filter( + (filter) => station[filterToFieldMap[filter]], + ); + + return (
{station.address}

+ {station.workingHours && ( +

+ {t('map.workingHours')}: {station.workingHours} +

+ )} + {station.description && ( +

+ {station.description} +

+ )}
- - {station.city} - - {station.services.map((service) => ( + {station.region && ( + + {station.region} + + )} + {services.map((service) => ( ))}
+ {station.image && ( + {station.name} + )}
- ))} + ); + })} +
+ ) : ( +
+

{t('map.noStations')}

+
+ {activeFilters.length > 0 && ( + + )} + {activeCities.length > 0 && ( + + )}
- ) : ( -
-

{t('map.noStations')}

-
- {activeFilters.length > 0 && ( - - )} - {activeCities.length > 0 && ( - - )} -
-
- )} -
+
+ )}
+
+ ); +} + +// Главный компонент +export default function GasStationMap({ stations }: GasStationMapProps) { + const { t } = useTextController(); + const [activeFilters, setActiveFilters] = useState([]); + const [activeCities, setActiveCities] = useState([]); + const [selectedStation, setSelectedStation] = useState(null); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [isStationListOpen, setIsStationListOpen] = useState(false); + const [activeFilterTab, setActiveFilterTab] = useState('cities'); + + // Все доступные фильтры + const allFilters = [ + // 'ДТ', -> нет значения в интерфейсе - TODO: поправить + 'АИ-92', + 'АИ-95', + 'Z-100 Power', + 'Пропан', + 'Зарядная станция', + 'Минимаркет', + 'Автомойка', + 'Туалет', + ]; + + // Маппинг фильтров на поля Station + const filterToFieldMap: { [key: string]: keyof Stations[number] } = { + 'АИ-92': 'ai92', + 'АИ-95': 'ai95', + 'Z-100 Power': 'z100', + Пропан: 'propan', + 'Зарядная станция': 'electricCharge', + Минимаркет: 'miniMarket', + Автомойка: 'carWash', + Туалет: 'toilet', + }; + + // Мемоизация списка уникальных регионов + const allCities = useMemo(() => { + return [ + ...new Set( + stations + .map((station) => station.region) + .filter((region): region is string => region !== null), + ), + ].sort(); + }, [stations]); + + // Мемоизация фильтрованных станций + const filteredStations = useMemo(() => { + let filtered = stations; + + // Фильтрация по регионам (ИЛИ) + if (activeCities.length > 0) { + filtered = filtered.filter( + (station) => station.region && activeCities.includes(station.region), + ); + } + + // Фильтрация по услугам (И) + if (activeFilters.length > 0) { + filtered = filtered.filter((station) => + activeFilters.every((filter) => { + const field = filterToFieldMap[filter]; + return Boolean(station[field]) === true; + }), + ); + } + return filtered; + }, [activeFilters, activeCities, stations]); + + // Мемоизация точек для карты + const points = useMemo( + (): Point[] => + filteredStations.map((st) => ({ + id: st.id, + coordinates: [st.latitude, st.longitude], + })), + [filteredStations], + ); + + // Переключение фильтра услуг + const toggleFilter = (filter: string) => { + setActiveFilters((prev) => + prev.includes(filter) + ? prev.filter((f) => f !== filter) + : [...prev, filter], + ); + }; + + // Переключение фильтра региона + const toggleCity = (city: string) => { + setActiveCities((prev) => + prev.includes(city) ? prev.filter((c) => c !== city) : [...prev, city], + ); + }; + + // Выбор всех регионов + const selectAllCities = () => { + setActiveCities( + activeCities.length === allCities.length ? [] : [...allCities], + ); + }; + + // Сброс фильтров услуг + const resetFilters = () => { + setActiveFilters([]); + }; + + // Сброс фильтров регионов + const resetCities = () => { + setActiveCities([]); + }; + + return ( +
+ {/* Filter panel */} + setIsFilterOpen(false)} + activeFilters={activeFilters} + activeCities={activeCities} + allCities={allCities} + allFilters={allFilters} + activeFilterTab={activeFilterTab} + toggleFilter={toggleFilter} + toggleCity={toggleCity} + selectAllCities={selectAllCities} + setActiveFilterTab={setActiveFilterTab} + resetFilters={resetFilters} + resetCities={resetCities} + t={t} + /> + + {/* Station list panel */} + setIsStationListOpen(false)} + stations={filteredStations} + selectedStation={selectedStation} + activeFilters={activeFilters} + activeCities={activeCities} + setSelectedStation={setSelectedStation} + t={t} + filterToFieldMap={filterToFieldMap} + allFilters={allFilters} + resetFilters={resetFilters} + resetCities={resetCities} + /> {/* Map */}
-
+
{/* Control buttons */} @@ -568,7 +517,7 @@ export default function GasStationMap({ {t('map.ourStations')}

- {t('map.totalStations')}: {stations.length} + {t('map.totalStations')}: {filteredStations.length}

diff --git a/src/features/map/ui/yandex-map.tsx b/src/features/map/ui/yandex-map.tsx index 20f0d7f..93b667a 100644 --- a/src/features/map/ui/yandex-map.tsx +++ b/src/features/map/ui/yandex-map.tsx @@ -1,6 +1,7 @@ 'use client'; import { Map, Placemark, YMaps } from '@pbe/react-yandex-maps'; +import React from 'react'; import { Point } from '../model'; @@ -8,29 +9,43 @@ type YandexMapProps = { points: Point[]; }; +const mapCenter = [55.751574, 37.573856]; + export const YandexMap = ({ points }: YandexMapProps) => { return ( {points.map((point) => ( - + ))} diff --git a/src/shared/shadcn-ui/separator.tsx b/src/shared/shadcn-ui/separator.tsx new file mode 100644 index 0000000..1851e42 --- /dev/null +++ b/src/shared/shadcn-ui/separator.tsx @@ -0,0 +1,31 @@ +'use client'; + +import * as SeparatorPrimitive from '@radix-ui/react-separator'; +import * as React from 'react'; + +import { cn } from '@/shared/lib/utils'; + +const Separator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = 'horizontal', decorative = true, ...props }, + ref, + ) => ( + + ), +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/src/widgets/map-section.tsx b/src/widgets/map-section.tsx index dc4c187..656d2c6 100644 --- a/src/widgets/map-section.tsx +++ b/src/widgets/map-section.tsx @@ -4,8 +4,7 @@ import { MapPin } from 'lucide-react'; import { Stations } from '@/app/api-utlities/@types'; -import { Point } from '@/features/map/model'; -import { YandexMap } from '@/features/map/ui/yandex-map'; +import { GasStationMap } from '@/features/map'; import { useTextController } from '@/shared/language/hooks/use-text-controller'; @@ -16,15 +15,9 @@ interface MapSectionProps { export const MapSection = ({ stations }: MapSectionProps) => { const { t } = useTextController(); - const points = stations.map((st) => ({ - id: st.id, - coordinates: [st.latitude, st.longitude], - })) as Point[]; - return (
-
@@ -40,7 +33,7 @@ export const MapSection = ({ stations }: MapSectionProps) => { className='h-[500px] overflow-hidden rounded-xl border shadow-lg' data-aos='fade-up' > - {/* */} +