384 lines
14 KiB
JavaScript
384 lines
14 KiB
JavaScript
"use client";
|
||
|
||
import React, { useEffect, useState } from "react";
|
||
import dynamic from "next/dynamic";
|
||
import { FaUser, FaCog } from "react-icons/fa";
|
||
import TabBar from "../components/TabBar";
|
||
import AcceptPopup from "../components/acceptPopUp";
|
||
|
||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||
|
||
// динамический импорт карты (только клиент)
|
||
const MapContainer = dynamic(
|
||
() => import("react-leaflet").then((m) => m.MapContainer),
|
||
{ ssr: false }
|
||
);
|
||
const TileLayer = dynamic(
|
||
() => import("react-leaflet").then((m) => m.TileLayer),
|
||
{ ssr: false }
|
||
);
|
||
const Marker = dynamic(
|
||
() => import("react-leaflet").then((m) => m.Marker),
|
||
{ ssr: false }
|
||
);
|
||
const Popup = dynamic(
|
||
() => import("react-leaflet").then((m) => m.Popup),
|
||
{ ssr: false }
|
||
);
|
||
|
||
// центр Перми
|
||
const DEFAULT_POSITION = [57.997962, 56.147201];
|
||
|
||
const MainVolunteerPage = () => {
|
||
const [position, setPosition] = useState(DEFAULT_POSITION);
|
||
const [hasLocation, setHasLocation] = useState(false);
|
||
|
||
const [userName, setUserName] = useState("Волонтёр");
|
||
|
||
const [requests, setRequests] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState("");
|
||
|
||
const [selectedRequest, setSelectedRequest] = useState(null);
|
||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||
|
||
const [acceptLoading, setAcceptLoading] = useState(false);
|
||
const [acceptError, setAcceptError] = useState("");
|
||
|
||
// фикс иконок leaflet: только на клиенте
|
||
useEffect(() => {
|
||
const setupLeafletIcons = async () => {
|
||
if (typeof window === "undefined") return;
|
||
|
||
const L = (await import("leaflet")).default;
|
||
await import("leaflet/dist/leaflet.css");
|
||
|
||
// @ts-ignore
|
||
delete L.Icon.Default.prototype._getIconUrl;
|
||
L.Icon.Default.mergeOptions({
|
||
iconRetinaUrl: "/leaflet/marker-icon-2x.png",
|
||
iconUrl: "/leaflet/marker-icon.png",
|
||
shadowUrl: "/leaflet/marker-shadow.png",
|
||
});
|
||
};
|
||
|
||
setupLeafletIcons();
|
||
}, []);
|
||
|
||
const getAccessToken = () => {
|
||
if (typeof window === "undefined") return null;
|
||
const saved = localStorage.getItem("authUser");
|
||
const authUser = saved ? JSON.parse(saved) : null;
|
||
return authUser?.accessToken || null;
|
||
};
|
||
|
||
const openPopup = (req) => {
|
||
setSelectedRequest(req);
|
||
setIsPopupOpen(true);
|
||
setAcceptError("");
|
||
};
|
||
|
||
const closePopup = () => {
|
||
setIsPopupOpen(false);
|
||
setSelectedRequest(null);
|
||
setAcceptError("");
|
||
};
|
||
|
||
// геолокация
|
||
useEffect(() => {
|
||
if (!navigator.geolocation) return;
|
||
navigator.geolocation.getCurrentPosition(
|
||
(pos) => {
|
||
setPosition([pos.coords.latitude, pos.coords.longitude]);
|
||
setHasLocation(true);
|
||
},
|
||
() => {
|
||
setHasLocation(false);
|
||
}
|
||
);
|
||
}, []);
|
||
|
||
// профиль
|
||
useEffect(() => {
|
||
const fetchProfile = async () => {
|
||
if (!API_BASE) return;
|
||
const accessToken = getAccessToken();
|
||
if (!accessToken) return;
|
||
|
||
try {
|
||
const res = await fetch(`${API_BASE}/users/me`, {
|
||
headers: {
|
||
Accept: "application/json",
|
||
Authorization: `Bearer ${accessToken}`,
|
||
},
|
||
});
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
const fullName =
|
||
[data.first_name, data.last_name].filter(Boolean).join(" ").trim() ||
|
||
data.email;
|
||
setUserName(fullName);
|
||
} catch {
|
||
//
|
||
}
|
||
};
|
||
|
||
fetchProfile();
|
||
}, []);
|
||
|
||
// заявки рядом
|
||
useEffect(() => {
|
||
const fetchNearbyRequests = async () => {
|
||
if (!API_BASE) {
|
||
setError("API_BASE_URL не задан");
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
const accessToken = getAccessToken();
|
||
if (!accessToken) {
|
||
setError("Вы не авторизованы");
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
const [lat, lon] = position;
|
||
|
||
try {
|
||
const params = new URLSearchParams({
|
||
lat: String(lat),
|
||
lon: String(lon),
|
||
radius: "5000",
|
||
limit: "50",
|
||
offset: "0",
|
||
});
|
||
|
||
const res = await fetch(
|
||
`${API_BASE}/requests/nearby?${params.toString()}`,
|
||
{
|
||
headers: {
|
||
Accept: "application/json",
|
||
Authorization: `Bearer ${accessToken}`,
|
||
},
|
||
}
|
||
);
|
||
|
||
if (!res.ok) {
|
||
let msg = "Не удалось загрузить заявки рядом";
|
||
try {
|
||
const data = await res.json();
|
||
if (data.error) msg = data.error;
|
||
} catch {
|
||
const text = await res.text();
|
||
if (text) msg = text;
|
||
}
|
||
setError(msg);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
const data = await res.json();
|
||
const mapped = data.map((item) => ({
|
||
id: item.id,
|
||
title: item.title,
|
||
description: item.description,
|
||
address: item.address,
|
||
city: item.city,
|
||
urgency: item.urgency,
|
||
contact_phone: item.contact_phone,
|
||
contact_notes: item.contact_notes,
|
||
desired_completion_date: item.desired_completion_date,
|
||
coords: [item.latitude ?? lat, item.longitude ?? lon],
|
||
distance: item.distance_meters
|
||
? `${(item.distance_meters / 1000).toFixed(1)} км`
|
||
: null,
|
||
}));
|
||
|
||
setRequests(mapped);
|
||
setLoading(false);
|
||
} catch (e) {
|
||
setError(e.message || "Ошибка сети");
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchNearbyRequests();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [position, hasLocation]);
|
||
|
||
// отклик
|
||
// волонтёр СОДАЁТ отклик
|
||
const handleAccept = async (req, message = "") => {
|
||
if (!API_BASE || !req || !req.id) {
|
||
setAcceptError("Некорректная заявка (нет id)");
|
||
return;
|
||
}
|
||
const accessToken = getAccessToken();
|
||
if (!accessToken) {
|
||
setAcceptError("Вы не авторизованы");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setAcceptLoading(true);
|
||
setAcceptError("");
|
||
|
||
const res = await fetch(`${API_BASE}/requests/${req.id}/responses`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
Authorization: `Bearer ${accessToken}`,
|
||
},
|
||
body: JSON.stringify(
|
||
message ? { message } : {} // message опционален по схеме
|
||
),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
let msg = "Не удалось отправить отклик";
|
||
try {
|
||
const data = await res.json();
|
||
if (data.error) msg = data.error;
|
||
} catch {
|
||
const text = await res.text();
|
||
if (text) msg = text;
|
||
}
|
||
console.error("Ошибка отклика:", msg);
|
||
setAcceptError(msg);
|
||
setAcceptLoading(false);
|
||
return;
|
||
}
|
||
|
||
await res.json(); // VolunteerResponse
|
||
setAcceptLoading(false);
|
||
closePopup();
|
||
} catch (e) {
|
||
setAcceptError(e.message || "Ошибка сети");
|
||
setAcceptLoading(false);
|
||
}
|
||
};
|
||
|
||
|
||
|
||
return (
|
||
<div className="min-h-screen w-full bg-[#90D2F9] flex justify-center px-4">
|
||
<div className="relative w-full max-w-md flex flex-col pb-20 pt-4">
|
||
{/* Header */}
|
||
<header className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-8 h-8 rounded-full border border-white flex items-center justify-center">
|
||
<FaUser className="text-white text-sm" />
|
||
</div>
|
||
<p className="font-montserrat font-extrabold text-[20px] leading-[11px] text-white">
|
||
{userName}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="w-8 h-8 rounded-full border border-white flex items-center justify-center"
|
||
>
|
||
<FaCog className="text-white text-sm" />
|
||
</button>
|
||
</header>
|
||
|
||
<h1 className="font-montserrat font-extrabold text-[20px] leading-[20px] text-white mb-2">
|
||
Кому нужна помощь
|
||
</h1>
|
||
|
||
{error && (
|
||
<p className="mb-2 text-xs font-montserrat text-red-200">
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
{/* Карта */}
|
||
<div className="w-full bg-transparent mb-3">
|
||
<div className="w-full h-[250px] bg-[#D9D9D9] rounded-2xl overflow-hidden">
|
||
<MapContainer
|
||
center={position}
|
||
zoom={13}
|
||
style={{ width: "100%", height: "100%" }}
|
||
>
|
||
<TileLayer
|
||
attribution="© OpenStreetMap contributors"
|
||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||
/>
|
||
{hasLocation && (
|
||
<Marker position={position}>
|
||
<Popup>Вы здесь</Popup>
|
||
</Marker>
|
||
)}
|
||
{requests.map((req) => (
|
||
<Marker key={req.id} position={req.coords}>
|
||
<Popup>{req.title}</Popup>
|
||
</Marker>
|
||
))}
|
||
</MapContainer>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Заявки ниже карты */}
|
||
<main className="space-y-3">
|
||
{loading && (
|
||
<p className="text-white text-sm font-montserrat">
|
||
Загрузка заявок...
|
||
</p>
|
||
)}
|
||
|
||
{!loading && requests.length === 0 && !error && (
|
||
<p className="text-white text-sm font-montserrat">
|
||
Рядом пока нет заявок
|
||
</p>
|
||
)}
|
||
|
||
{requests.map((req) => (
|
||
<div
|
||
key={req.id}
|
||
className="bg-white rounded-xl px-3 py-2 flex flex-col gap-1"
|
||
onClick={() => openPopup(req)}
|
||
>
|
||
<p className="font-montserrat font-semibold text-[15px] leading-[14px] text-black">
|
||
{req.title}
|
||
</p>
|
||
<p className="font-montserrat text-[15px] text-black">
|
||
{req.address}
|
||
</p>
|
||
{req.distance && (
|
||
<p className="font-montserrat text-[12px] text-gray-500">
|
||
Расстояние: {req.distance}
|
||
</p>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
openPopup(req);
|
||
}}
|
||
className="mt-2 w-full bg-[#94E067] rounded-lg py-2 flex items-center justify-center"
|
||
>
|
||
<span className="font-montserrat font-bold text-[14px] text-white">
|
||
Информация
|
||
</span>
|
||
</button>
|
||
</div>
|
||
))}
|
||
</main>
|
||
|
||
<TabBar />
|
||
</div>
|
||
|
||
<AcceptPopup
|
||
request={selectedRequest}
|
||
isOpen={isPopupOpen}
|
||
onClose={closePopup}
|
||
// onAccept={handleAccept}
|
||
// loading={acceptLoading}
|
||
// error={acceptError}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default MainVolunteerPage;
|