first commit

This commit is contained in:
Bivekich
2025-06-26 06:59:59 +03:00
commit d44874775c
450 changed files with 76635 additions and 0 deletions

View File

@ -0,0 +1,372 @@
import React, { useState, useEffect, useRef } from 'react';
import { useQuery } from '@apollo/client';
import {
YANDEX_PICKUP_POINTS_BY_CITY,
YANDEX_PICKUP_POINTS_BY_COORDINATES,
YandexPickupPoint
} from '@/lib/graphql/yandex-delivery';
interface PickupPointSelectorProps {
selectedPoint?: YandexPickupPoint;
onPointSelect: (point: YandexPickupPoint) => void;
onCityChange?: (cityName: string) => void;
placeholder?: string;
className?: string;
typeFilter?: string;
}
const PickupPointSelector: React.FC<PickupPointSelectorProps> = ({
selectedPoint,
onPointSelect,
onCityChange,
placeholder = "Выберите пункт выдачи",
className = "",
typeFilter
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [location, setLocation] = useState<{ lat: number; lng: number } | null>(null);
const [cityName, setCityName] = useState('Москва'); // По умолчанию Москва (где есть ПВЗ)
const [showCitySelector, setShowCitySelector] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Запрос ПВЗ по городу
const { data: cityData, loading: cityLoading, error: cityError } = useQuery(YANDEX_PICKUP_POINTS_BY_CITY, {
variables: { cityName },
skip: !cityName,
errorPolicy: 'all' // Продолжаем работу даже при ошибках
});
// Запрос ПВЗ по координатам (если есть геолокация)
const { data: coordinatesData, loading: coordinatesLoading, error: coordinatesError } = useQuery(YANDEX_PICKUP_POINTS_BY_COORDINATES, {
variables: {
latitude: location?.lat,
longitude: location?.lng,
radiusKm: 10
},
skip: !location,
errorPolicy: 'all' // Продолжаем работу даже при ошибках
});
// Определяем какие данные использовать
const pickupPoints = coordinatesData?.yandexPickupPointsByCoordinates ||
cityData?.yandexPickupPointsByCity ||
[];
const loading = cityLoading || coordinatesLoading;
const hasError = cityError || coordinatesError;
// Координаты городов для центрирования карты
const cityCoordinates: Record<string, [number, number]> = {
'Москва': [55.7558, 37.6176],
'Санкт-Петербург': [59.9311, 30.3609],
'Новосибирск': [55.0084, 82.9357],
'Екатеринбург': [56.8431, 60.6454],
'Казань': [55.8304, 49.0661],
'Нижний Новгород': [56.2965, 43.9361],
'Челябинск': [55.1644, 61.4368],
'Самара': [53.2001, 50.15],
'Омск': [54.9885, 73.3242],
'Ростов-на-Дону': [47.2357, 39.7015],
'Уфа': [54.7388, 55.9721],
'Красноярск': [56.0184, 92.8672],
'Воронеж': [51.6720, 39.1843],
'Пермь': [58.0105, 56.2502],
'Волгоград': [48.7080, 44.5133],
'Краснодар': [45.0355, 38.9753],
'Саратов': [51.5924, 46.0348],
'Тюмень': [57.1522, 65.5272],
'Тольятти': [53.5303, 49.3461],
'Ижевск': [56.8527, 53.2118],
'Барнаул': [53.3606, 83.7636],
'Ульяновск': [54.3142, 48.4031],
'Иркутск': [52.2978, 104.2964],
'Хабаровск': [48.4827, 135.0839],
'Ярославль': [57.6261, 39.8845],
'Владивосток': [43.1056, 131.8735],
'Махачкала': [42.9849, 47.5047],
'Томск': [56.4977, 84.9744],
'Оренбург': [51.7727, 55.0988],
'Кемерово': [55.3331, 86.0833],
'Новокузнецк': [53.7557, 87.1099],
'Рязань': [54.6269, 39.6916],
'Набережные Челны': [55.7558, 52.4069],
'Астрахань': [46.3497, 48.0408],
'Пенза': [53.2001, 45.0000],
'Липецк': [52.6031, 39.5708],
'Тула': [54.1961, 37.6182],
'Киров': [58.6035, 49.6679],
'Чебоксары': [56.1439, 47.2517],
'Калининград': [54.7065, 20.5110],
'Брянск': [53.2434, 34.3640],
'Курск': [51.7373, 36.1873],
'Иваново': [57.0000, 40.9737],
'Магнитогорск': [53.4078, 59.0647],
'Тверь': [56.8587, 35.9176],
'Ставрополь': [45.0428, 41.9734],
'Симферополь': [44.9572, 34.1108],
'Белгород': [50.5951, 36.5804],
'Архангельск': [64.5401, 40.5433],
'Владимир': [56.1366, 40.3966],
'Сочи': [43.6028, 39.7342],
'Курган': [55.4500, 65.3333],
'Смоленск': [54.7818, 32.0401],
'Калуга': [54.5293, 36.2754],
'Чита': [52.0307, 113.5006],
'Орёл': [52.9651, 36.0785],
'Волжский': [48.7854, 44.7759],
'Череповец': [59.1374, 37.9097],
'Владикавказ': [43.0370, 44.6830],
'Мурманск': [68.9792, 33.0925],
'Сургут': [61.2500, 73.4167],
'Вологда': [59.2239, 39.8840],
'Тамбов': [52.7319, 41.4520],
'Стерлитамак': [53.6241, 55.9504],
'Грозный': [43.3181, 45.6942],
'Якутск': [62.0355, 129.6755],
'Кострома': [57.7665, 40.9265],
'Комсомольск-на-Амуре': [50.5496, 137.0067],
'Петрозаводск': [61.7849, 34.3469],
'Таганрог': [47.2362, 38.8969],
'Нижневартовск': [60.9344, 76.5531],
'Йошкар-Ола': [56.6372, 47.8753],
'Братск': [56.1326, 101.6140],
'Новороссийск': [44.7209, 37.7677],
'Дзержинск': [56.2342, 43.4582],
'Шахты': [47.7090, 40.2060],
'Нижнекамск': [55.6367, 51.8209],
'Орск': [51.2045, 58.5434],
'Ангарск': [52.5406, 103.8887],
'Старый Оскол': [51.2965, 37.8411],
'Великий Новгород': [58.5218, 31.2756],
'Благовещенск': [50.2941, 127.5405],
'Прокопьевск': [53.9058, 86.7194],
'Химки': [55.8970, 37.4296],
'Энгельс': [51.4827, 46.1124],
'Рыбинск': [58.0446, 38.8486],
'Балашиха': [55.7969, 37.9386],
'Подольск': [55.4297, 37.5547],
'Королёв': [55.9226, 37.8251],
'Петропавловск-Камчатский': [53.0446, 158.6483],
'Мытищи': [55.9116, 37.7307],
'Люберцы': [55.6758, 37.8939],
'Магадан': [59.5638, 150.8063],
'Норильск': [69.3558, 88.1893],
'Южно-Сахалинск': [46.9588, 142.7386]
};
// Популярные города с ПВЗ Яндекса (расширенный список)
const availableCities = Object.keys(cityCoordinates).sort();
// Фильтрация ПВЗ по поисковому запросу и типу
const filteredPoints = pickupPoints.filter((point: YandexPickupPoint) => {
const matchesSearch = point.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
point.address.fullAddress.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = !typeFilter || point.type === typeFilter;
return matchesSearch && matchesType;
});
// Получение геолокации
const handleGetLocation = () => {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({
lat: position.coords.latitude,
lng: position.coords.longitude
});
},
(error) => {
console.error('Ошибка получения геолокации:', error);
// Fallback на Калининград
setLocation({ lat: 54.7104, lng: 20.4522 });
}
);
} else {
// Fallback на Калининград
setLocation({ lat: 54.7104, lng: 20.4522 });
}
};
// Закрытие дропдаунов при клике вне них
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
setShowCitySelector(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Автоматическая загрузка геолокации убрана - пользователь может выбрать город или нажать кнопку геолокации
const handlePointSelect = (point: YandexPickupPoint) => {
onPointSelect(point);
setIsOpen(false);
setSearchTerm('');
};
return (
<div className={`relative ${className}`} ref={dropdownRef}>
{/* Выбор города */}
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Город для поиска ПВЗ:
</label>
<div className="relative">
<button
onClick={() => setShowCitySelector(!showCitySelector)}
className="w-full gap-2.5 px-6 py-3 text-base leading-6 bg-white rounded border border-solid border-stone-300 h-[45px] text-gray-700 outline-none flex items-center justify-between hover:border-gray-400 transition-colors"
>
<span>{cityName}</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`transition-transform ${showCitySelector ? 'rotate-180' : ''}`}
>
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{/* Дропдаун с городами */}
{showCitySelector && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-48 overflow-y-auto">
{availableCities.map((city) => (
<div
key={city}
onClick={() => {
setCityName(city);
setShowCitySelector(false);
setLocation(null); // Сбрасываем геолокацию при выборе города
onCityChange?.(city); // Уведомляем родительский компонент
}}
className={`px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors ${
cityName === city ? 'bg-red-50 text-red-600 font-medium' : 'text-gray-700'
}`}
>
{city}
</div>
))}
</div>
)}
</div>
</div>
{/* Поле ввода */}
<div className="relative">
<input
type="text"
value={selectedPoint ? selectedPoint.name : searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
placeholder={`${placeholder} в г. ${cityName}`}
className="w-full gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 h-[55px] text-neutral-500 outline-none pr-20"
/>
{/* Кнопка геолокации */}
<button
onClick={() => {
handleGetLocation();
setIsOpen(true);
}}
className="absolute right-2 top-2 p-2 text-gray-400 hover:text-gray-600 transition-colors"
title="Определить местоположение"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
</button>
</div>
{/* Дропдаун с ПВЗ */}
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-64 overflow-y-auto">
{loading ? (
<div className="p-4 text-center text-gray-500">
Загрузка пунктов выдачи...
</div>
) : hasError ? (
<div className="p-4 text-center text-red-500">
<div className="mb-2">Ошибка загрузки пунктов выдачи</div>
<div className="text-sm text-gray-500 mb-2">
{cityError?.message || coordinatesError?.message || 'Неизвестная ошибка'}
</div>
<button
onClick={() => {
// Перезагружаем данные
window.location.reload();
}}
className="text-red-600 hover:text-red-700 underline text-sm"
>
Попробовать снова
</button>
</div>
) : filteredPoints.length === 0 ? (
<div className="p-4 text-center text-gray-500">
{searchTerm ? (
`Пункты выдачи не найдены по запросу "${searchTerm}"`
) : (
<div>
<div className="mb-2">Нет доступных пунктов выдачи в г. {cityName}</div>
<div className="text-xs text-gray-400">
Попробуйте выбрать другой город или использовать геолокацию
</div>
</div>
)}
</div>
) : (
<div className="py-2">
{/* Заголовок с количеством */}
<div className="px-4 py-2 bg-gray-50 border-b text-sm text-gray-600 font-medium">
{location
? `Найдено ${filteredPoints.length} ПВЗ рядом с вами`
: `Найдено ${filteredPoints.length} ПВЗ в г. ${cityName}`
}
</div>
{filteredPoints.map((point: YandexPickupPoint) => (
<div
key={point.id}
onClick={() => handlePointSelect(point)}
className={`px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors ${
selectedPoint?.id === point.id ? 'bg-red-50 border-l-4 border-red-500' : ''
}`}
>
<div className="font-medium text-gray-900 mb-1">
{point.name}
</div>
<div className="text-sm text-gray-600 mb-1">
{point.address.fullAddress}
</div>
<div className="text-xs text-gray-500">
{point.contact.phone} {point.typeLabel}
</div>
{point.formattedSchedule && (
<div className="text-xs text-gray-500 mt-1">
{point.formattedSchedule}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
};
export default PickupPointSelector;

View File

@ -0,0 +1,172 @@
import React, { useEffect, useRef, useState } from 'react';
import { YandexPickupPoint } from '@/lib/graphql/yandex-delivery';
interface YandexPickupPointsMapProps {
pickupPoints: YandexPickupPoint[];
selectedPoint?: YandexPickupPoint;
onPointSelect: (point: YandexPickupPoint) => void;
center?: [number, number];
zoom?: number;
className?: string;
}
declare global {
interface Window {
ymaps: any;
selectPickupPoint?: (pointId: string) => void;
}
}
const YandexPickupPointsMap: React.FC<YandexPickupPointsMapProps> = ({
pickupPoints,
selectedPoint,
onPointSelect,
center = [55.76, 37.64], // Москва по умолчанию
zoom = 10,
className = "w-full h-full"
}) => {
const mapRef = useRef<HTMLDivElement>(null);
const [map, setMap] = useState<any>(null);
const [clusterer, setClusterer] = useState<any>(null);
const [isLoaded, setIsLoaded] = useState(false);
// Загрузка Яндекс карт API
useEffect(() => {
if (window.ymaps) {
setIsLoaded(true);
return;
}
const script = document.createElement('script');
script.src = `https://api-maps.yandex.ru/2.1/?apikey=${process.env.NEXT_PUBLIC_YANDEX_MAPS_API_KEY}&lang=ru_RU`;
script.onload = () => {
window.ymaps.ready(() => {
setIsLoaded(true);
});
};
document.head.appendChild(script);
return () => {
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
}, []);
// Инициализация карты
useEffect(() => {
if (!isLoaded || !mapRef.current || map) return;
const ymap = new window.ymaps.Map(mapRef.current, {
center,
zoom,
controls: ['zoomControl', 'searchControl', 'trafficControl', 'fullscreenControl']
});
const clstr = new window.ymaps.Clusterer({
preset: 'islands#redClusterIcons',
groupByCoordinates: false,
clusterDisableClickZoom: false,
clusterHideIconOnBalloonOpen: false,
geoObjectHideIconOnBalloonOpen: false
});
ymap.geoObjects.add(clstr);
setMap(ymap);
setClusterer(clstr);
}, [isLoaded, center, zoom, map]);
// Обновление точек на карте
useEffect(() => {
if (!map || !clusterer) return;
clusterer.removeAll();
const placemarks = pickupPoints.map(point => {
const placemark = new window.ymaps.Placemark(
[point.position.latitude, point.position.longitude],
{
balloonContentHeader: `<strong>${point.name}</strong>`,
balloonContentBody: `
<div style="max-width: 300px;">
<p><strong>Адрес:</strong> ${point.address.fullAddress}</p>
<p><strong>Тип:</strong> ${point.typeLabel}</p>
<p><strong>Телефон:</strong> ${point.contact.phone}</p>
<p><strong>Режим работы:</strong><br/>${point.formattedSchedule}</p>
${point.instruction ? `<p><strong>Инструкция:</strong> ${point.instruction}</p>` : ''}
<button
onclick="window.selectPickupPoint('${point.id}')"
style="
background: #dc2626;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
margin-top: 10px;
font-size: 14px;
"
>
Выбрать этот пункт
</button>
</div>
`,
hintContent: point.name
},
{
preset: selectedPoint?.id === point.id ? 'islands#redIcon' : 'islands#blueIcon',
iconColor: selectedPoint?.id === point.id ? '#dc2626' : '#3b82f6'
}
);
placemark.events.add('click', () => {
onPointSelect(point);
});
return placemark;
});
clusterer.add(placemarks);
// Если есть точки, подгоняем карту под них
if (pickupPoints.length > 0) {
map.setBounds(clusterer.getBounds(), {
checkZoomRange: true,
zoomMargin: 20
});
}
}, [map, clusterer, pickupPoints, selectedPoint, onPointSelect]);
// Глобальная функция для выбора точки из балуна
useEffect(() => {
window.selectPickupPoint = (pointId: string) => {
const point = pickupPoints.find(p => p.id === pointId);
if (point) {
onPointSelect(point);
}
};
return () => {
delete window.selectPickupPoint;
};
}, [pickupPoints, onPointSelect]);
// Центрирование на выбранной точке
useEffect(() => {
if (map && selectedPoint) {
map.setCenter([selectedPoint.position.latitude, selectedPoint.position.longitude], 15);
}
}, [map, selectedPoint]);
if (!isLoaded) {
return (
<div className={`${className} bg-gray-100 flex items-center justify-center`}>
<div className="text-gray-600">Загрузка карты...</div>
</div>
);
}
return <div ref={mapRef} className={className} />;
};
export default YandexPickupPointsMap;