Files
frontend/app/mainValounter/page.jsx
fullofempt 433b9e896c WIP API
2025-12-14 18:47:14 +05:00

368 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 = [58.0105, 56.2294];
const MainVolunteerPage = () => {
const [position, setPosition] = useState(DEFAULT_POSITION);
const [hasLocation, setHasLocation] = useState(false);
const [userName, setUserName] = useState("Волонтёр");
const [requests, setRequests] = useState([]); // заявки из /requests/nearby
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("");
// получить токен из localStorage
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);
}
);
}, []);
// загрузка имени волонтёра из /users/me[file:519]
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();
}, []);
// загрузка заявок рядом: /requests/nearby?lat=&lon=&radius=[file:519]
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(); // массив RequestWithDistance[file:519]
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);
}
};
// загружаем, когда уже знаем позицию
if (hasLocation || position !== DEFAULT_POSITION) {
fetchNearbyRequests();
} else {
// если геолокация не дала позицию — всё равно пробуем из центра
fetchNearbyRequests();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [position, hasLocation]);
// отклик: POST /requests/{id}/responses[file:519]
const handleAccept = async (req, message = "") => {
if (!API_BASE || !req) 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;
}
setAcceptError(msg);
setAcceptLoading(false);
return;
}
const createdResponse = await res.json();
console.log("Отклик создан:", createdResponse);
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="&copy; 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;