WIPVOLONT
This commit is contained in:
@@ -13,7 +13,7 @@ const ModeratorRequestModal = ({ request, onClose, onModerated }) => {
|
|||||||
// request.status: "pending_moderation" | "approved" | "rejected"
|
// request.status: "pending_moderation" | "approved" | "rejected"
|
||||||
const isApproved = request.status === "approved";
|
const isApproved = request.status === "approved";
|
||||||
const isRejected = request.status === "rejected";
|
const isRejected = request.status === "rejected";
|
||||||
const isPending = request.status === "pending_moderation";
|
const isPending = request.status === "На модерации";
|
||||||
|
|
||||||
const getAccessToken = () => {
|
const getAccessToken = () => {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
@@ -43,171 +43,170 @@ const ModeratorRequestModal = ({ request, onClose, onModerated }) => {
|
|||||||
const deadlineTime = request.deadlineTime || request.time || "";
|
const deadlineTime = request.deadlineTime || request.time || "";
|
||||||
|
|
||||||
const handleApprove = async () => {
|
const handleApprove = async () => {
|
||||||
if (!API_BASE || submitting) return;
|
if (!API_BASE || submitting) return;
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError("Вы не авторизованы");
|
setError("Вы не авторизованы");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[MODERATION] APPROVE start", {
|
console.log("[MODERATION] APPROVE start", {
|
||||||
requestId: request.id,
|
requestId: request.id,
|
||||||
statusBefore: request.status,
|
statusBefore: request.status,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE}/moderation/requests/${request.id}/approve`,
|
`${API_BASE}/moderation/requests/${request.id}/approve`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ comment: null }),
|
body: JSON.stringify({ comment: null }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("[MODERATION] APPROVE response status", res.status);
|
console.log("[MODERATION] APPROVE response status", res.status);
|
||||||
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
let data = null;
|
let data = null;
|
||||||
if (text) {
|
if (text) {
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(text);
|
data = JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
data = null;
|
data = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[MODERATION] APPROVE response body", data || text);
|
console.log("[MODERATION] APPROVE response body", data || text);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = "Не удалось одобрить заявку";
|
let msg = "Не удалось одобрить заявку";
|
||||||
if (data && typeof data === "object" && data.error) {
|
if (data && typeof data === "object" && data.error) {
|
||||||
msg = data.error;
|
msg = data.error;
|
||||||
} else if (text) {
|
} else if (text) {
|
||||||
msg = text;
|
msg = text;
|
||||||
}
|
}
|
||||||
console.log("[MODERATION] APPROVE error", msg);
|
console.log("[MODERATION] APPROVE error", msg);
|
||||||
setError(msg);
|
setError(msg);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onModerated?.({
|
onModerated?.({
|
||||||
...request,
|
...request,
|
||||||
status: "approved",
|
status: "approved",
|
||||||
moderationResult: data,
|
moderationResult: data,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[MODERATION] APPROVE success", {
|
console.log("[MODERATION] APPROVE success", {
|
||||||
requestId: request.id,
|
requestId: request.id,
|
||||||
newStatus: "approved",
|
newStatus: "approved",
|
||||||
});
|
});
|
||||||
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("[MODERATION] APPROVE exception", e);
|
console.log("[MODERATION] APPROVE exception", e);
|
||||||
setError(e.message || "Ошибка сети");
|
setError(e.message || "Ошибка сети");
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRejectConfirm = async () => {
|
const handleRejectConfirm = async () => {
|
||||||
if (!API_BASE || submitting) return;
|
if (!API_BASE || submitting) return;
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError("Вы не авторизованы");
|
setError("Вы не авторизованы");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!rejectReason.trim()) {
|
if (!rejectReason.trim()) {
|
||||||
setError("Укажите причину отклонения");
|
setError("Укажите причину отклонения");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[MODERATION] REJECT start", {
|
console.log("[MODERATION] REJECT start", {
|
||||||
requestId: request.id,
|
requestId: request.id,
|
||||||
statusBefore: request.status,
|
statusBefore: request.status,
|
||||||
reason: rejectReason,
|
reason: rejectReason,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE}/moderation/requests/${request.id}/reject`,
|
`${API_BASE}/moderation/requests/${request.id}/reject`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ comment: rejectReason }),
|
body: JSON.stringify({ comment: rejectReason }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("[MODERATION] REJECT response status", res.status);
|
console.log("[MODERATION] REJECT response status", res.status);
|
||||||
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
let data = null;
|
let data = null;
|
||||||
if (text) {
|
if (text) {
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(text);
|
data = JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
data = null;
|
data = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[MODERATION] REJECT response body", data || text);
|
console.log("[MODERATION] REJECT response body", data || text);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = "Не удалось отклонить заявку";
|
let msg = "Не удалось отклонить заявку";
|
||||||
if (data && typeof data === "object" && data.error) {
|
if (data && typeof data === "object" && data.error) {
|
||||||
msg = data.error;
|
msg = data.error;
|
||||||
} else if (text) {
|
} else if (text) {
|
||||||
msg = text;
|
msg = text;
|
||||||
}
|
}
|
||||||
console.log("[MODERATION] REJECT error", msg);
|
console.log("[MODERATION] REJECT error", msg);
|
||||||
setError(msg);
|
setError(msg);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onModerated?.({
|
onModerated?.({
|
||||||
...request,
|
...request,
|
||||||
status: "rejected",
|
status: "rejected",
|
||||||
rejectReason,
|
rejectReason: (data && data.moderation_comment) || rejectReason,
|
||||||
moderationResult: data,
|
moderationResult: data,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[MODERATION] REJECT success", {
|
console.log("[MODERATION] REJECT success", {
|
||||||
requestId: request.id,
|
requestId: request.id,
|
||||||
newStatus: "rejected",
|
newStatus: "rejected",
|
||||||
});
|
});
|
||||||
|
|
||||||
setShowRejectPopup(false);
|
|
||||||
setSubmitting(false);
|
|
||||||
onClose();
|
|
||||||
} catch (e) {
|
|
||||||
console.log("[MODERATION] REJECT exception", e);
|
|
||||||
setError(e.message || "Ошибка сети");
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
setShowRejectPopup(false);
|
||||||
|
setSubmitting(false);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[MODERATION] REJECT exception", e);
|
||||||
|
setError(e.message || "Ошибка сети");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40 flex flex-col bg-[#90D2F9] px-4 pt-4 pb-20">
|
<div className="fixed inset-0 z-40 flex flex-col bg-[#90D2F9] px-4 pt-4 pb-20">
|
||||||
<div className="flex.items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -272,7 +271,7 @@ const handleRejectConfirm = async () => {
|
|||||||
: "bg-[#E9D171]"
|
: "bg-[#E9D171]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-[12px] font-montserrat font-semibold text-black">
|
<span className="text-[12px] font-montserrat font-semibold text-white">
|
||||||
{isApproved
|
{isApproved
|
||||||
? "Принята"
|
? "Принята"
|
||||||
: isRejected
|
: isRejected
|
||||||
@@ -299,14 +298,14 @@ const handleRejectConfirm = async () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isRejected && (
|
{isRejected && (
|
||||||
<div className="w-full.bg-[#FFE2E2] rounded-[10px] px-3 py-2">
|
<div className="w-full bg-[#FFE2E2] rounded-[10px] px-3 py-2">
|
||||||
<p className="text-[14px] font-montserrat font-bold text-[#E06767] mb-1">
|
<p className="text-[14px] font-montserrat font-bold text-[#E06767] mb-1">
|
||||||
Причина отклонения
|
Причина отклонения
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[12px] font-montserrat text-black whitespace-pre-line">
|
<p className="text-[12px] font-montserrat text-black whitespace-pre-line">
|
||||||
{request.rejectReason && request.rejectReason.trim().length > 0
|
{(request.rejectReason && request.rejectReason.trim().length > 0
|
||||||
? request.rejectReason
|
? request.rejectReason
|
||||||
: "Причина не указана"}
|
: request.moderation_comment) || "Причина не указана"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -319,7 +318,8 @@ const handleRejectConfirm = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопки показываем только для pending_moderation */}
|
||||||
|
{isPending && (
|
||||||
<div className="mt-4 w-full max-w-[400px] mx-auto flex items-center justify-between gap-3">
|
<div className="mt-4 w-full max-w-[400px] mx-auto flex items-center justify-between gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -342,7 +342,7 @@ const handleRejectConfirm = async () => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showRejectPopup && (
|
{showRejectPopup && (
|
||||||
@@ -368,7 +368,7 @@ const handleRejectConfirm = async () => {
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="w-full h-10 bg-[#E06767] rounded-[10px] flex items-center justify-center disabled:opacity-60"
|
className="w-full h-10 bg-[#E06767] rounded-[10px] flex items-center justify-center disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<span className="text-[16px] font-montserrat font-bold text-white">
|
<span className="text-[16px] font-montserrat font-bold text.white">
|
||||||
{submitting ? "Сохранение..." : "Подтвердить"}
|
{submitting ? "Сохранение..." : "Подтвердить"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { FaStar } from "react-icons/fa";
|
|||||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
const RequestDetailsModal = ({ request, onClose }) => {
|
const RequestDetailsModal = ({ request, onClose }) => {
|
||||||
const [details, setDetails] = useState(null); // полная заявка из API
|
const [details, setDetails] = useState(null); // RequestDetail
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState("");
|
const [loadError, setLoadError] = useState("");
|
||||||
|
|
||||||
@@ -16,8 +16,14 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
const [rating, setRating] = useState(0);
|
const [rating, setRating] = useState(0);
|
||||||
const [review, setReview] = useState("");
|
const [review, setReview] = useState("");
|
||||||
const [rejectFeedback, setRejectFeedback] = useState("");
|
const [rejectFeedback, setRejectFeedback] = useState("");
|
||||||
|
|
||||||
|
|
||||||
// подгружаем детальную заявку /requests/{id}[file:519]
|
// для приёма отклика
|
||||||
|
const [acceptLoading, setAcceptLoading] = useState(false);
|
||||||
|
const [acceptError, setAcceptError] = useState("");
|
||||||
|
const [acceptSuccess, setAcceptSuccess] = useState("");
|
||||||
|
|
||||||
|
// подгружаем детальную заявку /requests/{id}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDetails = async () => {
|
const fetchDetails = async () => {
|
||||||
if (!API_BASE) {
|
if (!API_BASE) {
|
||||||
@@ -61,7 +67,7 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json(); // RequestDetail[file:519]
|
const data = await res.json(); // RequestDetail
|
||||||
setDetails(data);
|
setDetails(data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -88,7 +94,64 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
// подготовка текстов из details (без изменения верстки)
|
// приём отклика волонтёра: POST /requests/{id}/responses/{response_id}/accept
|
||||||
|
const handleAcceptResponse = async (responseId) => {
|
||||||
|
if (!API_BASE || !request.id || !responseId) {
|
||||||
|
setAcceptError("Некорректные данные для приёма отклика");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? localStorage.getItem("authUser")
|
||||||
|
: null;
|
||||||
|
const authUser = saved ? JSON.parse(saved) : null;
|
||||||
|
const accessToken = authUser?.accessToken;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
setAcceptError("Вы не авторизованы");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setAcceptLoading(true);
|
||||||
|
setAcceptError("");
|
||||||
|
setAcceptSuccess("");
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/requests/${request.id}/responses/${responseId}/accept`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
setAcceptError(msg);
|
||||||
|
setAcceptLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await res.json(); // { success, message, ... }
|
||||||
|
setAcceptSuccess("Волонтёр принят на заявку");
|
||||||
|
setAcceptLoading(false);
|
||||||
|
} catch (e) {
|
||||||
|
setAcceptError(e.message || "Ошибка сети");
|
||||||
|
setAcceptLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fullDescription =
|
const fullDescription =
|
||||||
details?.description || request.description || "Описание отсутствует";
|
details?.description || request.description || "Описание отсутствует";
|
||||||
|
|
||||||
@@ -102,10 +165,14 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
|
|
||||||
const requestTypeName = details?.request_type?.name;
|
const requestTypeName = details?.request_type?.name;
|
||||||
|
|
||||||
|
// здесь предполагаем, что детали заявки содержат массив responses,
|
||||||
|
// либо ты добавишь его на бэке к RequestDetail
|
||||||
|
const responses = details?.responses || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex flex-col bg-[#90D2F9] px-4 pt-4 pb-20">
|
<div className="fixed inset-0 z-40 flex flex-col bg-[#90D2F9] px-4 pt-4 pb-20">
|
||||||
{/* Заголовок */}
|
{/* Заголовок */}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex.items-center gap-2 mb-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -182,12 +249,13 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
|
|
||||||
{requesterName && (
|
{requesterName && (
|
||||||
<p className="font-montserrat text-[12px] text-black">
|
<p className="font-montserrat text-[12px] text-black">
|
||||||
<span className="font-semibold">Заявитель:</span> {requesterName}
|
<span className="font-semibold">Заявитель:</span>{" "}
|
||||||
|
{requesterName}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{details?.contact_phone && (
|
{details?.contact_phone && (
|
||||||
<p className="font-montserrat text-[12px] text.black">
|
<p className="font-montserrat text-[12px] text-black">
|
||||||
<span className="font-semibold">Телефон:</span>{" "}
|
<span className="font-semibold">Телефон:</span>{" "}
|
||||||
{details.contact_phone}
|
{details.contact_phone}
|
||||||
</p>
|
</p>
|
||||||
@@ -200,7 +268,7 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="font-montserrat text-[12px] text.black mt-1 whitespace-pre-line">
|
<p className="font-montserrat text-[12px] text-black mt-1 whitespace-pre-line">
|
||||||
<span className="font-semibold">Описание:</span>{" "}
|
<span className="font-semibold">Описание:</span>{" "}
|
||||||
{fullDescription}
|
{fullDescription}
|
||||||
</p>
|
</p>
|
||||||
@@ -208,6 +276,55 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Блок откликов волонтёров: принять/отклонить */}
|
||||||
|
{!loading && !loadError && responses.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-col gap-2">
|
||||||
|
<p className="font-montserrat font-semibold text-[13px] text-black">
|
||||||
|
Отклики волонтёров
|
||||||
|
</p>
|
||||||
|
{responses.map((resp) => (
|
||||||
|
<div
|
||||||
|
key={resp.id}
|
||||||
|
className="w-full rounded-xl bg-[#E4E4E4] px-3 py-2 flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<p className="font-montserrat text-[12px] text-black">
|
||||||
|
<span className="font-semibold">Волонтёр:</span>{" "}
|
||||||
|
{resp.volunteer_name || resp.volunteername || resp.volunteer?.name}
|
||||||
|
</p>
|
||||||
|
{resp.message && (
|
||||||
|
<p className="font-montserrat text-[12px] text-black">
|
||||||
|
<span className="font-semibold">Сообщение:</span>{" "}
|
||||||
|
{resp.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={acceptLoading}
|
||||||
|
onClick={() => handleAcceptResponse(resp.id)}
|
||||||
|
className="flex-1 h-[32px] bg-[#94E067] rounded-full flex items-center justify-center text-white text-[12px] font-montserrat disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Принять
|
||||||
|
</button>
|
||||||
|
{/* Если позже добавишь отклонение отклика — вторая кнопка здесь */}
|
||||||
|
{/* <button ...>Отклонить</button> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{acceptError && (
|
||||||
|
<p className="text-[11px] font-montserrat text-red-500">
|
||||||
|
{acceptError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{acceptSuccess && (
|
||||||
|
<p className="text-[11px] font-montserrat text-green-600">
|
||||||
|
{acceptSuccess}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Выполнена: блок отзыва */}
|
{/* Выполнена: блок отзыва */}
|
||||||
{isDone && (
|
{isDone && (
|
||||||
<div className="bg-[#72B8E2] rounded-3xl p-3 flex flex-col gap-2">
|
<div className="bg-[#72B8E2] rounded-3xl p-3 flex flex-col gap-2">
|
||||||
@@ -218,7 +335,7 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
value={review}
|
value={review}
|
||||||
onChange={(e) => setReview(e.target.value)}
|
onChange={(e) => setReview(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full bg-[#72B8E2] rounded-2xl px-3 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none resize-none border border-white/20"
|
className="w-full.bg-[#72B8E2] rounded-2xl px-3 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none resize-none border border.white/20"
|
||||||
placeholder="Напишите, как прошла помощь"
|
placeholder="Напишите, как прошла помощь"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,7 +363,7 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
value={rejectFeedback}
|
value={rejectFeedback}
|
||||||
onChange={(e) => setRejectFeedback(e.target.value)}
|
onChange={(e) => setRejectFeedback(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full rounded-2xl px-3 py-2 text-sm font-montserrat text-black.placeholder:text-black/40 outline-none resize-none border border-[#FF8282]"
|
className="w-full rounded-2xl px-3 py-2 text-sm font-montserrat text-black placeholder:text-black/40 outline-none resize-none border border-[#FF8282]"
|
||||||
placeholder="Расскажите, что можно улучшить"
|
placeholder="Расскажите, что можно улучшить"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,9 +403,9 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="mt-4 w-full max-w-[360px] mx-auto bg-[#94E067] rounded-2xl py-3 flex items-center.justify-center"
|
className="mt-4 w-full max-w-[360px] mx-auto bg-[#94E067] rounded-2xl py-3 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<span className="font-montserrat font-extrabold text-[16px] text-white">
|
<span className="font-mонтserrat font-extrabold text-[16px] text-white">
|
||||||
{isRejected ? "Отправить комментарий" : "Оставить отзыв"}
|
{isRejected ? "Отправить комментарий" : "Оставить отзыв"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import React, { useState } from "react";
|
|||||||
import { FaStar } from "react-icons/fa";
|
import { FaStar } from "react-icons/fa";
|
||||||
|
|
||||||
const RequestDetailsModal = ({ request, onClose }) => {
|
const RequestDetailsModal = ({ request, onClose }) => {
|
||||||
const isDone = request.rawStatus === "completed" || request.status === "Выполнена";
|
const isDone =
|
||||||
|
request.rawStatus === "completed" || request.status === "Выполнена";
|
||||||
const isInProgress =
|
const isInProgress =
|
||||||
request.rawStatus === "in_progress" || request.status === "В процессе";
|
request.rawStatus === "in_progress" || request.status === "В процессе";
|
||||||
|
|
||||||
@@ -42,6 +43,15 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
|
|
||||||
const place = [request.address, request.city].filter(Boolean).join(", ");
|
const place = [request.address, request.city].filter(Boolean).join(", ");
|
||||||
const requesterName = request.requesterName || "Заявитель";
|
const requesterName = request.requesterName || "Заявитель";
|
||||||
|
const createdDate = request.date || "";
|
||||||
|
const createdTime = request.time || "";
|
||||||
|
|
||||||
|
// ВЫПОЛНИТЬ ДО: берём дату из заявки
|
||||||
|
let deadlineText = "—";
|
||||||
|
if (request.desiredCompletionDate) {
|
||||||
|
const d = new Date(request.desiredCompletionDate);
|
||||||
|
deadlineText = d.toLocaleDateString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex flex-col bg-[#90D2F9] px-4 pt-4 pb-20">
|
<div className="fixed inset-0 z-40 flex flex-col bg-[#90D2F9] px-4 pt-4 pb-20">
|
||||||
@@ -73,10 +83,10 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
</span>
|
</span>
|
||||||
<div className="text-right leading-tight">
|
<div className="text-right leading-tight">
|
||||||
<p className="font-montserrat text-[10px] text-black">
|
<p className="font-montserrat text-[10px] text-black">
|
||||||
{request.date}
|
{createdDate}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-montserrat text-[10px] text-black">
|
<p className="font-montserrat text-[10px] text-black">
|
||||||
{request.time}
|
{createdTime}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,9 +102,11 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
<p>Заявитель: {request.requesterName || requesterName}</p>
|
<p>Заявитель: {request.requesterName || requesterName}</p>
|
||||||
<p>Адрес: {place || "Не указан"}</p>
|
<p>Адрес: {place || "Не указан"}</p>
|
||||||
{urgencyText && <p>Срочность: {urgencyText}</p>}
|
{urgencyText && <p>Срочность: {urgencyText}</p>}
|
||||||
|
{/* НОВОЕ: строка "Выполнить до" */}
|
||||||
|
<p>Выполнить до: {deadlineText}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Описание / список покупок */}
|
{/* Описание */}
|
||||||
{request.description && (
|
{request.description && (
|
||||||
<div className="bg-[#E4E4E4] rounded-2xl px-3 py-2 max-h-[140px] overflow-y-auto">
|
<div className="bg-[#E4E4E4] rounded-2xl px-3 py-2 max-h-[140px] overflow-y-auto">
|
||||||
<p className="text-[11px] leading-[13px] font-montserrat whitespace-pre-line">
|
<p className="text-[11px] leading-[13px] font-montserrat whitespace-pre-line">
|
||||||
@@ -103,7 +115,7 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Блок отзыва + рейтинг — и для Выполнена, и для В процессе */}
|
{/* Отзыв и рейтинг */}
|
||||||
{(isDone || isInProgress) && (
|
{(isDone || isInProgress) && (
|
||||||
<>
|
<>
|
||||||
<div className="bg-[#72B8E2] rounded-3xl p-3 flex flex-col gap-2">
|
<div className="bg-[#72B8E2] rounded-3xl p-3 flex flex-col gap-2">
|
||||||
@@ -114,7 +126,7 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
value={review}
|
value={review}
|
||||||
onChange={(e) => setReview(e.target.value)}
|
onChange={(e) => setReview(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full bg-[#72B8E2] rounded-2xl px-3 py-2 text-sm font-montserrat text-white placeholder:text.white/70 outline-none resize-none border border-white/20"
|
className="w-full bg-[#72B8E2] rounded-2xl px-3 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none resize-none border border-white/20"
|
||||||
placeholder={
|
placeholder={
|
||||||
isDone
|
isDone
|
||||||
? "Напишите, как прошла помощь"
|
? "Напишите, как прошла помощь"
|
||||||
@@ -123,7 +135,7 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-1 flex flex-col items-center gap-2">
|
<div className="mt-1 flex flex-col.items-center gap-2">
|
||||||
<p className="font-montserrat font-semibold text-[14px] text-black">
|
<p className="font-montserrat font-semibold text-[14px] text-black">
|
||||||
Оценить заявителя
|
Оценить заявителя
|
||||||
</p>
|
</p>
|
||||||
@@ -150,12 +162,11 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопка внизу */}
|
|
||||||
{(isDone || isInProgress) && (
|
{(isDone || isInProgress) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="mt-4 w-full max-w-[360px] mx-auto bg-[#94E067] rounded-2xl py-3 flex.items-center justify-center"
|
className="mt-4 w-full max-w-[360px] mx-auto bg-[#94E067] rounded-2xl py-3 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<span className="font-montserrat font-extrabold text-[16px] text-white">
|
<span className="font-montserrat font-extrabold text-[16px] text-white">
|
||||||
{isDone ? "Оставить отзыв" : "Сохранить прогресс"}
|
{isDone ? "Оставить отзыв" : "Сохранить прогресс"}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { FaTimesCircle } from "react-icons/fa";
|
import { FaTimesCircle } from "react-icons/fa";
|
||||||
|
|
||||||
const AcceptPopup = ({ request, isOpen, onClose, onAccept, loading, error }) => {
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
const AcceptPopup = ({ request, isOpen, onClose }) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
if (!isOpen || !request) return null;
|
if (!isOpen || !request) return null;
|
||||||
|
|
||||||
const title = request.title;
|
const title = request.title;
|
||||||
@@ -15,14 +20,21 @@ const AcceptPopup = ({ request, isOpen, onClose, onAccept, loading, error }) =>
|
|||||||
const city = request.city ? `, ${request.city}` : "";
|
const city = request.city ? `, ${request.city}` : "";
|
||||||
const place = `${baseAddress}${city}`;
|
const place = `${baseAddress}${city}`;
|
||||||
|
|
||||||
const deadline = request.desired_completion_date
|
let deadline = "Не указано";
|
||||||
? new Date(request.desired_completion_date).toLocaleString("ru-RU", {
|
if (request.desired_completion_date) {
|
||||||
|
const d = new Date(request.desired_completion_date);
|
||||||
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
const datePart = d.toLocaleDateString("ru-RU", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
|
});
|
||||||
|
const timePart = d.toLocaleTimeString("ru-RU", {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
})
|
});
|
||||||
: "Не указано";
|
deadline = `${datePart}, ${timePart}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const phone = request.contact_phone || request.phone;
|
const phone = request.contact_phone || request.phone;
|
||||||
const contactNotes = request.contact_notes || request.contactNotes;
|
const contactNotes = request.contact_notes || request.contactNotes;
|
||||||
@@ -42,26 +54,79 @@ const AcceptPopup = ({ request, isOpen, onClose, onAccept, loading, error }) =>
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const handleClick = () => {
|
const getAccessToken = () => {
|
||||||
// здесь видно, с каким id ты стучишься в /requests/{id}/responses
|
if (typeof window === "undefined") return null;
|
||||||
console.log("Отклик на заявку из попапа:", {
|
const saved = localStorage.getItem("authUser");
|
||||||
id: request.id,
|
const authUser = saved ? JSON.parse(saved) : null;
|
||||||
title: request.title,
|
return authUser?.accessToken || null;
|
||||||
raw: request,
|
};
|
||||||
});
|
|
||||||
onAccept(request);
|
// тут полностью логика отклика
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (!API_BASE || !request.id) {
|
||||||
|
setError("Некорректная заявка (нет id)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accessToken = getAccessToken();
|
||||||
|
if (!accessToken) {
|
||||||
|
setError("Вы не авторизованы");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
console.log("POST отклик", {
|
||||||
|
url: `${API_BASE}/requests/${request.id}/responses`,
|
||||||
|
requestId: request.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/requests/${request.id}/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
// по схеме тело обязательно, просто пустой объект допустим [file:598]
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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("Ответ API /responses:", msg);
|
||||||
|
setError(msg);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await res.json(); // VolunteerResponse [file:598]
|
||||||
|
setLoading(false);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || "Ошибка сети");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
{/* затемнение */}
|
{/* затемнение */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/40"
|
className="absolute inset-0 bg-black/40"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* карточка на всю страницу */}
|
{/* карточка поверх всего */}
|
||||||
<div className="relative z-10 w-full h-250px bg-white rounded-2xl px-4 pt-4 pb-6 flex flex-col">
|
<div className="relative z-50 w-full max-w-[400px] bg-white rounded-2xl px-4 pt-4 pb-6 flex flex-col mt-50">
|
||||||
{/* крестик */}
|
{/* крестик */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -80,9 +145,9 @@ const AcceptPopup = ({ request, isOpen, onClose, onAccept, loading, error }) =>
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Только время выполнить до */}
|
{/* Только время выполнить до */}
|
||||||
<div className="flex.items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div className="w-full h-[40px] bg-[#90D2F9] rounded-full flex flex-col items-center justify-center">
|
<div className="w-full h-[40px] bg-[#90D2F9] rounded-full flex flex-col items-center justify-center">
|
||||||
<span className="text-[12px] leading-[11px] text-white font-semibold.mb-2">
|
<span className="text-[12px] leading-[11px] text-white font-semibold mb-1">
|
||||||
Выполнить до
|
Выполнить до
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[15px] leading-[13px] text-white font-semibold">
|
<span className="text-[15px] leading-[13px] text-white font-semibold">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import AcceptPopup from "../components/acceptPopUp";
|
|||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
// динамический импорт карты
|
// динамический импорт карты (только клиент)
|
||||||
const MapContainer = dynamic(
|
const MapContainer = dynamic(
|
||||||
() => import("react-leaflet").then((m) => m.MapContainer),
|
() => import("react-leaflet").then((m) => m.MapContainer),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
@@ -27,7 +27,7 @@ const Popup = dynamic(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// центр Перми
|
// центр Перми
|
||||||
const DEFAULT_POSITION = [58.0105, 56.2294];
|
const DEFAULT_POSITION = [57.997962, 56.147201];
|
||||||
|
|
||||||
const MainVolunteerPage = () => {
|
const MainVolunteerPage = () => {
|
||||||
const [position, setPosition] = useState(DEFAULT_POSITION);
|
const [position, setPosition] = useState(DEFAULT_POSITION);
|
||||||
@@ -35,7 +35,7 @@ const MainVolunteerPage = () => {
|
|||||||
|
|
||||||
const [userName, setUserName] = useState("Волонтёр");
|
const [userName, setUserName] = useState("Волонтёр");
|
||||||
|
|
||||||
const [requests, setRequests] = useState([]); // заявки из /requests/nearby
|
const [requests, setRequests] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@@ -45,7 +45,26 @@ const MainVolunteerPage = () => {
|
|||||||
const [acceptLoading, setAcceptLoading] = useState(false);
|
const [acceptLoading, setAcceptLoading] = useState(false);
|
||||||
const [acceptError, setAcceptError] = useState("");
|
const [acceptError, setAcceptError] = useState("");
|
||||||
|
|
||||||
// получить токен из localStorage
|
// фикс иконок 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 = () => {
|
const getAccessToken = () => {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
const saved = localStorage.getItem("authUser");
|
const saved = localStorage.getItem("authUser");
|
||||||
@@ -65,7 +84,7 @@ const MainVolunteerPage = () => {
|
|||||||
setAcceptError("");
|
setAcceptError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
// геолокация волонтёра
|
// геолокация
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!navigator.geolocation) return;
|
if (!navigator.geolocation) return;
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
@@ -79,7 +98,7 @@ const MainVolunteerPage = () => {
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// загрузка имени волонтёра из /users/me[file:519]
|
// профиль
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProfile = async () => {
|
const fetchProfile = async () => {
|
||||||
if (!API_BASE) return;
|
if (!API_BASE) return;
|
||||||
@@ -96,20 +115,18 @@ const MainVolunteerPage = () => {
|
|||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const fullName =
|
const fullName =
|
||||||
[data.first_name, data.last_name]
|
[data.first_name, data.last_name].filter(Boolean).join(" ").trim() ||
|
||||||
.filter(Boolean)
|
data.email;
|
||||||
.join(" ")
|
|
||||||
.trim() || data.email;
|
|
||||||
setUserName(fullName);
|
setUserName(fullName);
|
||||||
} catch {
|
} catch {
|
||||||
// оставляем дефолтное имя
|
//
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// загрузка заявок рядом: /requests/nearby?lat=&lon=&radius=[file:519]
|
// заявки рядом
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchNearbyRequests = async () => {
|
const fetchNearbyRequests = async () => {
|
||||||
if (!API_BASE) {
|
if (!API_BASE) {
|
||||||
@@ -136,12 +153,15 @@ const MainVolunteerPage = () => {
|
|||||||
offset: "0",
|
offset: "0",
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/requests/nearby?${params.toString()}`, {
|
const res = await fetch(
|
||||||
headers: {
|
`${API_BASE}/requests/nearby?${params.toString()}`,
|
||||||
Accept: "application/json",
|
{
|
||||||
Authorization: `Bearer ${accessToken}`,
|
headers: {
|
||||||
},
|
Accept: "application/json",
|
||||||
});
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = "Не удалось загрузить заявки рядом";
|
let msg = "Не удалось загрузить заявки рядом";
|
||||||
@@ -157,24 +177,23 @@ const MainVolunteerPage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json(); // массив RequestWithDistance[file:519]
|
const data = await res.json();
|
||||||
const mapped = data.map((item) => ({
|
const mapped = data.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
description: item.description, // <= добавили
|
description: item.description,
|
||||||
address: item.address,
|
address: item.address,
|
||||||
city: item.city,
|
city: item.city,
|
||||||
urgency: item.urgency, // <= добавили
|
urgency: item.urgency,
|
||||||
contact_phone: item.contact_phone, // если есть в ответе
|
contact_phone: item.contact_phone,
|
||||||
contact_notes: item.contact_notes, // если есть в ответе
|
contact_notes: item.contact_notes,
|
||||||
desired_completion_date: item.desired_completion_date, // <= уже есть
|
desired_completion_date: item.desired_completion_date,
|
||||||
coords: [item.latitude ?? lat, item.longitude ?? lon],
|
coords: [item.latitude ?? lat, item.longitude ?? lon],
|
||||||
distance: item.distance_meters
|
distance: item.distance_meters
|
||||||
? `${(item.distance_meters / 1000).toFixed(1)} км`
|
? `${(item.distance_meters / 1000).toFixed(1)} км`
|
||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
setRequests(mapped);
|
setRequests(mapped);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -183,19 +202,17 @@ const MainVolunteerPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// загружаем, когда уже знаем позицию
|
fetchNearbyRequests();
|
||||||
if (hasLocation || position !== DEFAULT_POSITION) {
|
|
||||||
fetchNearbyRequests();
|
|
||||||
} else {
|
|
||||||
// если геолокация не дала позицию — всё равно пробуем из центра
|
|
||||||
fetchNearbyRequests();
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [position, hasLocation]);
|
}, [position, hasLocation]);
|
||||||
|
|
||||||
// отклик: POST /requests/{id}/responses[file:519]
|
// отклик
|
||||||
|
// волонтёр СОДАЁТ отклик
|
||||||
const handleAccept = async (req, message = "") => {
|
const handleAccept = async (req, message = "") => {
|
||||||
if (!API_BASE || !req) return;
|
if (!API_BASE || !req || !req.id) {
|
||||||
|
setAcceptError("Некорректная заявка (нет id)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const accessToken = getAccessToken();
|
const accessToken = getAccessToken();
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
setAcceptError("Вы не авторизованы");
|
setAcceptError("Вы не авторизованы");
|
||||||
@@ -214,7 +231,7 @@ const MainVolunteerPage = () => {
|
|||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(
|
body: JSON.stringify(
|
||||||
message ? { message } : {} // поле message опционально
|
message ? { message } : {} // message опционален по схеме
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -227,14 +244,13 @@ const MainVolunteerPage = () => {
|
|||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (text) msg = text;
|
if (text) msg = text;
|
||||||
}
|
}
|
||||||
|
console.error("Ошибка отклика:", msg);
|
||||||
setAcceptError(msg);
|
setAcceptError(msg);
|
||||||
setAcceptLoading(false);
|
setAcceptLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdResponse = await res.json();
|
await res.json(); // VolunteerResponse
|
||||||
console.log("Отклик создан:", createdResponse);
|
|
||||||
|
|
||||||
setAcceptLoading(false);
|
setAcceptLoading(false);
|
||||||
closePopup();
|
closePopup();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -243,6 +259,8 @@ const MainVolunteerPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full bg-[#90D2F9] flex justify-center px-4">
|
<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">
|
<div className="relative w-full max-w-md flex flex-col pb-20 pt-4">
|
||||||
@@ -286,13 +304,11 @@ const MainVolunteerPage = () => {
|
|||||||
attribution="© OpenStreetMap contributors"
|
attribution="© OpenStreetMap contributors"
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
{/* Маркер волонтёра */}
|
|
||||||
{hasLocation && (
|
{hasLocation && (
|
||||||
<Marker position={position}>
|
<Marker position={position}>
|
||||||
<Popup>Вы здесь</Popup>
|
<Popup>Вы здесь</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
)}
|
)}
|
||||||
{/* Маркеры заявок */}
|
|
||||||
{requests.map((req) => (
|
{requests.map((req) => (
|
||||||
<Marker key={req.id} position={req.coords}>
|
<Marker key={req.id} position={req.coords}>
|
||||||
<Popup>{req.title}</Popup>
|
<Popup>{req.title}</Popup>
|
||||||
@@ -339,10 +355,10 @@ const MainVolunteerPage = () => {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openPopup(req);
|
openPopup(req);
|
||||||
}}
|
}}
|
||||||
className="mt-2 w-full bg-[#94E067] rounded-lg py-2 flex.items-center justify-center"
|
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 className="font-montserrat font-bold text-[14px] text-white">
|
||||||
Откликнуться
|
Информация
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,9 +372,9 @@ const MainVolunteerPage = () => {
|
|||||||
request={selectedRequest}
|
request={selectedRequest}
|
||||||
isOpen={isPopupOpen}
|
isOpen={isPopupOpen}
|
||||||
onClose={closePopup}
|
onClose={closePopup}
|
||||||
onAccept={handleAccept}
|
// onAccept={handleAccept}
|
||||||
loading={acceptLoading}
|
// loading={acceptLoading}
|
||||||
error={acceptError}
|
// error={acceptError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -102,15 +102,18 @@ const HistoryRequestModeratorPage = () => {
|
|||||||
|
|
||||||
const list = Array.isArray(data) ? data : [];
|
const list = Array.isArray(data) ? data : [];
|
||||||
|
|
||||||
// RequestListItem: id, title, description, address, city, urgency, status, requester_name, request_type_name, created_at
|
// status: { request_status: "approved" | "rejected", valid: true }
|
||||||
// оставляем только approved / rejected
|
const filtered = list.filter((item) => {
|
||||||
const filtered = list.filter(
|
const s = String(item.status?.request_status || "").toLowerCase();
|
||||||
(item) => item.status === "approved" || item.status === "rejected"
|
return s === "approved" || s === "rejected";
|
||||||
);
|
});
|
||||||
|
|
||||||
const mapped = filtered.map((item) => {
|
const mapped = filtered.map((item) => {
|
||||||
const m = statusMap[item.status] || {
|
const rawStatus = String(
|
||||||
label: item.status,
|
item.status?.request_status || ""
|
||||||
|
).toLowerCase();
|
||||||
|
const m = statusMap[rawStatus] || {
|
||||||
|
label: rawStatus || "Неизвестен",
|
||||||
color: "#E2E2E2",
|
color: "#E2E2E2",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,10 +134,9 @@ const HistoryRequestModeratorPage = () => {
|
|||||||
time,
|
time,
|
||||||
createdAt: date,
|
createdAt: date,
|
||||||
fullName: item.requester_name,
|
fullName: item.requester_name,
|
||||||
address: item.city
|
rejectReason: item.moderation_comment || "",
|
||||||
? `${item.city}, ${item.address}`
|
address: item.city ? `${item.city}, ${item.address}` : item.address,
|
||||||
: item.address,
|
rawStatus, // "approved" | "rejected"
|
||||||
rawStatus: item.status, // "approved" | "rejected"
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,7 +165,9 @@ const HistoryRequestModeratorPage = () => {
|
|||||||
|
|
||||||
const handleModeratedUpdate = (updated) => {
|
const handleModeratedUpdate = (updated) => {
|
||||||
setRequests((prev) =>
|
setRequests((prev) =>
|
||||||
prev.map((r) => (r.id === updated.id ? { ...r, rawStatus: updated.status } : r))
|
prev.map((r) =>
|
||||||
|
r.id === updated.id ? { ...r, rawStatus: updated.status } : r
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -217,7 +221,7 @@ const HistoryRequestModeratorPage = () => {
|
|||||||
key={req.id}
|
key={req.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpen(req)}
|
onClick={() => handleOpen(req)}
|
||||||
className="w-full text-left bg.white rounded-xl px-3 py-2 flex flex-col gap-1"
|
className="w-full text-left bg-white rounded-xl px-3 py-2 flex flex-col gap-1"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span
|
<span
|
||||||
@@ -226,7 +230,7 @@ const HistoryRequestModeratorPage = () => {
|
|||||||
>
|
>
|
||||||
{req.status}
|
{req.status}
|
||||||
</span>
|
</span>
|
||||||
<div className="text-right.leading-tight">
|
<div className="text-right leading-tight">
|
||||||
<p className="font-montserrat text-[10px] text-black">
|
<p className="font-montserrat text-[10px] text-black">
|
||||||
{req.date}
|
{req.date}
|
||||||
</p>
|
</p>
|
||||||
@@ -247,7 +251,7 @@ const HistoryRequestModeratorPage = () => {
|
|||||||
{req.address}
|
{req.address}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-2 w-full bg-[#94E067] rounded-lg py-3 flex.items-center justify-center">
|
<div className="mt-2 w-full bg-[#94E067] rounded-lg py-3 flex items-center justify-center">
|
||||||
<span className="font-montserrat font-bold text-[15px] leading-[18px] text-white">
|
<span className="font-montserrat font-bold text-[15px] leading-[18px] text-white">
|
||||||
Развернуть
|
Развернуть
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,16 +1,90 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { FaUserCircle, FaStar } from "react-icons/fa";
|
import { FaUserCircle, FaStar } from "react-icons/fa";
|
||||||
import TabBar from "../components/TabBar";
|
import TabBar from "../components/TabBar";
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
const ModeratorProfilePage = () => {
|
const ModeratorProfilePage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const fullName = "Иванов Александр Сергеевич";
|
const [profile, setProfile] = useState(null);
|
||||||
const birthDate = "12.03.1990";
|
const [loading, setLoading] = useState(true);
|
||||||
const rating = 4.8;
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const getAccessToken = () => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const saved = localStorage.getItem("authUser");
|
||||||
|
const authUser = saved ? JSON.parse(saved) : null;
|
||||||
|
return authUser?.accessToken || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
if (!API_BASE) {
|
||||||
|
setError("API_BASE_URL не задан");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
setError("Вы не авторизованы");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/users/me`, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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(); // UserProfile[web:598]
|
||||||
|
setProfile(data);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || "Ошибка сети");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fullName =
|
||||||
|
profile &&
|
||||||
|
([profile.first_name, profile.last_name].filter(Boolean).join(" ") ||
|
||||||
|
profile.email);
|
||||||
|
|
||||||
|
const rating =
|
||||||
|
profile && profile.volunteer_rating != null
|
||||||
|
? Number(profile.volunteer_rating)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const birthDateText = profile?.created_at
|
||||||
|
? new Date(profile.created_at).toLocaleDateString("ru-RU")
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
const email = profile?.email || "—";
|
||||||
|
const phone = profile?.phone || "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full bg-[#90D2F9] flex justify-center px-4">
|
<div className="min-h-screen w-full bg-[#90D2F9] flex justify-center px-4">
|
||||||
@@ -32,72 +106,99 @@ const ModeratorProfilePage = () => {
|
|||||||
|
|
||||||
{/* Карточка профиля */}
|
{/* Карточка профиля */}
|
||||||
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
|
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
|
||||||
{/* Аватар */}
|
{loading && (
|
||||||
<FaUserCircle className="text-[#72B8E2] w-20 h-20" />
|
<p className="font-montserrat text-[14px] text-black">
|
||||||
|
Загрузка профиля...
|
||||||
{/* ФИО и рейтинг */}
|
|
||||||
<div className="text-center space-y-1">
|
|
||||||
{/* <p className="font-montserrat font-extrabold text-[16px] text-black">
|
|
||||||
ФИО
|
|
||||||
</p> */}
|
|
||||||
<p className="font-montserrat font-bold text-[20px] text-black">
|
|
||||||
{fullName}
|
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Рейтинг + звезды */}
|
{error && !loading && (
|
||||||
<div className="mt-2 flex items-center justify-center gap-2">
|
<p className="font-montserrat text-[12px] text-red-500">
|
||||||
<span className="font-montserrat font-semibold text-[14px] text-black">
|
{error}
|
||||||
Рейтинг: {rating.toFixed(1)}
|
</p>
|
||||||
</span>
|
)}
|
||||||
<div className="flex gap-1">
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
{!loading && profile && (
|
||||||
<FaStar
|
<>
|
||||||
key={star}
|
{/* Аватар */}
|
||||||
size={18}
|
{profile.avatar_url ? (
|
||||||
className={
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
star <= Math.round(rating)
|
<img
|
||||||
? "text-[#F6E168] fill-[#F6E168]"
|
src={profile.avatar_url}
|
||||||
: "text-[#F6E168] fill-[#F6E168]/30"
|
alt="Аватар"
|
||||||
}
|
className="w-20 h-20 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
))}
|
) : (
|
||||||
|
<FaUserCircle className="text-[#72B8E2] w-20 h-20" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ФИО и рейтинг */}
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<p className="font-montserrat font-bold text-[20px] text-black">
|
||||||
|
{fullName}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{rating != null && (
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-2">
|
||||||
|
<span className="font-montserrat font-semibold text-[14px] text-black">
|
||||||
|
Рейтинг: {rating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<FaStar
|
||||||
|
key={star}
|
||||||
|
size={18}
|
||||||
|
className={
|
||||||
|
star <= Math.round(rating)
|
||||||
|
? "text-[#F6E168] fill-[#F6E168]"
|
||||||
|
: "text-[#F6E168] fill-[#F6E168]/30"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Контакты и день рождения */}
|
{/* Контакты и «дата рождения» как дата регистрации */}
|
||||||
<div className="w-full bg-[#72B8E2] rounded-2xl p-3 text-white space-y-1">
|
<div className="w-full bg-[#72B8E2] rounded-2xl p-3 text-white space-y-1">
|
||||||
<p className="font-montserrat text-[12px]">
|
<p className="font-montserrat text-[12px]">
|
||||||
Дата рождения: {birthDate}
|
Дата регистрации: {birthDateText}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-montserrat text-[12px]">
|
<p className="font-montserrat text-[12px]">Почта: {email}</p>
|
||||||
Почта: example@mail.com
|
<p className="font-montserrat text-[12px]">
|
||||||
</p>
|
Телефон: {phone}
|
||||||
<p className="font-montserrat text-[12px]">
|
</p>
|
||||||
Телефон: +7 (900) 000-00-00
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Кнопки */}
|
{/* Кнопки */}
|
||||||
<div className="w-full flex flex-col gap-2 mt-2">
|
<div className="w-full flex flex-col gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push("/valounterProfileSettings")}
|
onClick={() => router.push("/moderatorProfileSettings")}
|
||||||
className="w-full bg-[#E0B267] rounded-full py-2 flex items-center justify-center"
|
className="w-full bg-[#E0B267] rounded-full py-2 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
||||||
Редактировать профиль
|
Редактировать профиль
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full bg-[#E07567] rounded-full py-2 flex items-center justify-center"
|
className="w-full bg-[#E07567] rounded-full py-2 flex items-center justify-center"
|
||||||
>
|
onClick={() => {
|
||||||
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
if (typeof window !== "undefined") {
|
||||||
Выйти из аккаунта
|
localStorage.removeItem("authUser");
|
||||||
</span>
|
}
|
||||||
</button>
|
router.push("/");
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
||||||
|
Выйти из аккаунта
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<TabBar />
|
<TabBar />
|
||||||
|
|||||||
@@ -1,31 +1,145 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { FaUserCircle } from "react-icons/fa";
|
import { FaUserCircle } from "react-icons/fa";
|
||||||
import TabBar from "../components/TabBar";
|
import TabBar from "../components/TabBar";
|
||||||
|
|
||||||
const ValounterProfileSettingsPage = () => {
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
const ModeratorProfileSettingsPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [avatarUrl, setAvatarUrl] = useState("");
|
const [avatarUrl, setAvatarUrl] = useState("");
|
||||||
const [fullName, setFullName] = useState("Иванов Александр Сергеевич");
|
const [firstName, setFirstName] = useState("");
|
||||||
const [birthDate, setBirthDate] = useState("1990-03-12");
|
const [lastName, setLastName] = useState("");
|
||||||
const [email, setEmail] = useState("example@mail.com");
|
const [email, setEmail] = useState("");
|
||||||
const [phone, setPhone] = useState("+7 (900) 000-00-00");
|
const [phone, setPhone] = useState("");
|
||||||
|
|
||||||
const handleSave = (e) => {
|
const [loading, setLoading] = useState(true);
|
||||||
e.preventDefault();
|
const [saving, setSaving] = useState(false);
|
||||||
console.log("Сохранить профиль:", {
|
const [error, setError] = useState("");
|
||||||
avatarUrl,
|
const [saveMessage, setSaveMessage] = useState("");
|
||||||
fullName,
|
|
||||||
birthDate,
|
const getAccessToken = () => {
|
||||||
email,
|
if (typeof window === "undefined") return null;
|
||||||
phone,
|
const saved = localStorage.getItem("authUser");
|
||||||
});
|
const authUser = saved ? JSON.parse(saved) : null;
|
||||||
// здесь будет запрос на бэк
|
return authUser?.accessToken || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// загрузить профиль
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
if (!API_BASE) {
|
||||||
|
setError("API_BASE_URL не задан");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
setError("Вы не авторизованы");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/users/me`, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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(); // UserProfile[web:598]
|
||||||
|
setFirstName(data.first_name || "");
|
||||||
|
setLastName(data.last_name || "");
|
||||||
|
setEmail(data.email || "");
|
||||||
|
setPhone(data.phone || "");
|
||||||
|
setAvatarUrl(data.avatar_url || "");
|
||||||
|
setLoading(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || "Ошибка сети");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!API_BASE) return;
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
setError("Вы не авторизованы");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
setSaveMessage("");
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
first_name: firstName || null,
|
||||||
|
last_name: lastName || null,
|
||||||
|
phone: phone || null,
|
||||||
|
}; // UpdateProfileInput[web:598]
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/users/me`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await res.json();
|
||||||
|
setSaveMessage("Изменения сохранены");
|
||||||
|
setSaving(false);
|
||||||
|
|
||||||
|
setFirstName(updated.first_name || "");
|
||||||
|
setLastName(updated.last_name || "");
|
||||||
|
setPhone(updated.phone || "");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || "Ошибка сети");
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fullName = [firstName, lastName].filter(Boolean).join(" ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full bg-[#90D2F9] flex justify-center px-4">
|
<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">
|
<div className="relative w-full max-w-md flex flex-col pb-20 pt-4">
|
||||||
@@ -46,102 +160,128 @@ const ValounterProfileSettingsPage = () => {
|
|||||||
|
|
||||||
{/* Карточка настроек */}
|
{/* Карточка настроек */}
|
||||||
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
|
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
|
||||||
{/* Аватар */}
|
{loading && (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<p className="font-montserrat text-[14px] text-black">
|
||||||
<div className="w-24 h-24 rounded-full bg-[#E5F3FB] flex items-center justify-center overflow-hidden">
|
Загрузка профиля...
|
||||||
{avatarUrl ? (
|
</p>
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
)}
|
||||||
<img
|
|
||||||
src={avatarUrl}
|
{!loading && (
|
||||||
alt="Аватар"
|
<>
|
||||||
className="w-full h-full object-cover"
|
{/* Аватар */}
|
||||||
/>
|
<div className="flex flex-col items-center gap-2">
|
||||||
) : (
|
<div className="w-24 h-24 rounded-full bg-[#E5F3FB] flex items-center justify-center overflow-hidden">
|
||||||
<FaUserCircle className="text-[#72B8E2] w-20 h-20" />
|
{avatarUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="Аватар"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FaUserCircle className="text-[#72B8E2] w-20 h-20" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="font-montserrat text-[12px] text-[#72B8E2] underline cursor-pointer">
|
||||||
|
Загрузить аватар
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setAvatarUrl(url);
|
||||||
|
// загрузку файла на бэк можно добавить отдельно
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="font-montserrat text-[12px] text-red-500">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saveMessage && (
|
||||||
|
<p className="font-montserrat text-[12px] text-green-600">
|
||||||
|
{saveMessage}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<label className="font-montserrat text-[12px] text-[#72B8E2] underline cursor-pointer">
|
|
||||||
Загрузить аватар
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
setAvatarUrl(url);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSave} className="w-full flex flex-col gap-3">
|
<form
|
||||||
{/* ФИО */}
|
onSubmit={handleSave}
|
||||||
<div className="flex flex-col gap-1">
|
className="w-full flex flex-col gap-3"
|
||||||
<label className="font-montserrat text-[12px] text-black">
|
>
|
||||||
ФИО
|
{/* Имя */}
|
||||||
</label>
|
<div className="flex flex-col gap-1">
|
||||||
<input
|
<label className="font-montserrat text-[12px] text-black">
|
||||||
type="text"
|
Имя
|
||||||
value={fullName}
|
</label>
|
||||||
onChange={(e) => setFullName(e.target.value)}
|
<input
|
||||||
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
type="text"
|
||||||
placeholder="Введите ФИО"
|
value={firstName}
|
||||||
/>
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
</div>
|
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
||||||
|
placeholder="Введите имя"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Дата рождения */}
|
{/* Фамилия */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="font-montserrat text-[12px] text-black">
|
<label className="font-montserrat text-[12px] text-black">
|
||||||
Дата рождения
|
Фамилия
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="text"
|
||||||
value={birthDate}
|
value={lastName}
|
||||||
onChange={(e) => setBirthDate(e.target.value)}
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text-white outline-none border border-transparent focus:border-white/70"
|
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
||||||
/>
|
placeholder="Введите фамилию"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Почта */}
|
{/* Почта (только отображение) */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="font-montserrat text-[12px] text-black">
|
<label className="font-montserrat text-[12px] text-black">
|
||||||
Почта
|
Почта
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
disabled
|
||||||
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
className="w-full rounded-full bg-[#72B8E2]/60 px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent cursor-not-allowed"
|
||||||
placeholder="example@mail.com"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Телефон */}
|
{/* Телефон */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="font-montserrat text-[12px] text-black">
|
<label className="font-montserrat text-[12px] text-black">
|
||||||
Телефон
|
Телефон
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text:white.placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
||||||
placeholder="+7 (900) 000-00-00"
|
placeholder="+7 900 000 00 00"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопка сохранить */}
|
{/* Кнопка сохранить */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mt-2 w-full bg-[#94E067] rounded-full py-2.5 flex items-center justify-center"
|
disabled={saving}
|
||||||
>
|
className="mt-2 w-full bg-[#94E067] rounded-full py-2.5 flex items-center justify-center disabled:opacity-60"
|
||||||
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
>
|
||||||
Сохранить изменения
|
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
||||||
</span>
|
{saving ? "Сохранение..." : "Сохранить изменения"}
|
||||||
</button>
|
</span>
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<TabBar />
|
<TabBar />
|
||||||
@@ -150,4 +290,4 @@ const ValounterProfileSettingsPage = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ValounterProfileSettingsPage;
|
export default ModeratorProfileSettingsPage;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
|||||||
const statusMap = {
|
const statusMap = {
|
||||||
pending_moderation: { label: "На модерации", color: "#E9D171" },
|
pending_moderation: { label: "На модерации", color: "#E9D171" },
|
||||||
approved: { label: "Принята", color: "#94E067" },
|
approved: { label: "Принята", color: "#94E067" },
|
||||||
in_progress: { label: "В процессе", color: "#E971E1" },
|
inprogress: { label: "В процессе", color: "#E971E1" },
|
||||||
completed: { label: "Выполнена", color: "#71A5E9" },
|
completed: { label: "Выполнена", color: "#71A5E9" },
|
||||||
cancelled: { label: "Отменена", color: "#FF8282" },
|
cancelled: { label: "Отменена", color: "#FF8282" },
|
||||||
rejected: { label: "Отклонена", color: "#FF8282" },
|
rejected: { label: "Отклонена", color: "#FF8282" },
|
||||||
@@ -19,7 +19,7 @@ const statusMap = {
|
|||||||
const HistoryRequestPage = () => {
|
const HistoryRequestPage = () => {
|
||||||
const [userName, setUserName] = useState("Волонтёр");
|
const [userName, setUserName] = useState("Волонтёр");
|
||||||
|
|
||||||
const [requests, setRequests] = useState([]); // истории заявок волонтёра
|
const [requests, setRequests] = useState([]);
|
||||||
const [selectedRequest, setSelectedRequest] = useState(null);
|
const [selectedRequest, setSelectedRequest] = useState(null);
|
||||||
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -32,7 +32,7 @@ const HistoryRequestPage = () => {
|
|||||||
return authUser?.accessToken || null;
|
return authUser?.accessToken || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// подгружаем имя
|
// имя
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProfile = async () => {
|
const fetchProfile = async () => {
|
||||||
if (!API_BASE) return;
|
if (!API_BASE) return;
|
||||||
@@ -49,20 +49,18 @@ const HistoryRequestPage = () => {
|
|||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const fullName =
|
const fullName =
|
||||||
[data.first_name, data.last_name]
|
[data.first_name, data.last_name].filter(Boolean).join(" ").trim() ||
|
||||||
.filter(Boolean)
|
data.email;
|
||||||
.join(" ")
|
|
||||||
.trim() || data.email;
|
|
||||||
setUserName(fullName);
|
setUserName(fullName);
|
||||||
} catch {
|
} catch {
|
||||||
// оставляем дефолт
|
//
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// загружаем историю заявок волонтёра
|
// история: /requests/my
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchVolunteerRequests = async () => {
|
const fetchVolunteerRequests = async () => {
|
||||||
if (!API_BASE) {
|
if (!API_BASE) {
|
||||||
@@ -78,8 +76,12 @@ const HistoryRequestPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// вариант 1 (рекомендуется на бэке): отдельный эндпоинт, здесь предположим, что бек отдаёт RequestListItem[]
|
const params = new URLSearchParams({
|
||||||
const res = await fetch(`${API_BASE}/requests/my?role=volunteer`, {
|
limit: "50",
|
||||||
|
offset: "0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/requests/my?${params}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
@@ -100,10 +102,11 @@ const HistoryRequestPage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json(); // массив RequestListItem[file:519]
|
const data = await res.json(); // RequestListItem[][web:598]
|
||||||
|
|
||||||
const mapped = data.map((item) => {
|
const mapped = data.map((item) => {
|
||||||
const m = statusMap[item.status] || {
|
const key = String(item.status || "").toLowerCase();
|
||||||
|
const m = statusMap[key] || {
|
||||||
label: item.status,
|
label: item.status,
|
||||||
color: "#E2E2E2",
|
color: "#E2E2E2",
|
||||||
};
|
};
|
||||||
@@ -157,7 +160,7 @@ const HistoryRequestPage = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex items-center justify-between mb-4">
|
<header className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8 rounded-full border border-white flex items-center justify-center">
|
<div className="w-8 h-8 rounded-full border border-white flex items.center justify-center">
|
||||||
<FaUser className="text-white text-sm" />
|
<FaUser className="text-white text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-montserrat font-extrabold text-[20px] leading-[11px] text-white">
|
<p className="font-montserrat font-extrabold text-[20px] leading-[11px] text-white">
|
||||||
@@ -201,9 +204,8 @@ const HistoryRequestPage = () => {
|
|||||||
key={req.id}
|
key={req.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpen(req)}
|
onClick={() => handleOpen(req)}
|
||||||
className="w-full text-left bg-white rounded-xl px-3.py-2 flex flex-col gap-1"
|
className="w-full text-left bg-white rounded-xl px-3 py-2 flex flex-col gap-1"
|
||||||
>
|
>
|
||||||
{/* верхняя строка: статус + дата/время */}
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center px-2 py-0.5 rounded-full font-montserrat text-[12px] font-light text-black"
|
className="inline-flex items-center justify-center px-2 py-0.5 rounded-full font-montserrat text-[12px] font-light text-black"
|
||||||
@@ -221,13 +223,11 @@ const HistoryRequestPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Заголовок заявки */}
|
|
||||||
<p className="font-montserrat font-semibold text-[15px] leading-[18px] text-black mt-1">
|
<p className="font-montserrat font-semibold text-[15px] leading-[18px] text-black mt-1">
|
||||||
{req.title}
|
{req.title}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Кнопка "Развернуть" */}
|
<div className="mt-2 w-full bg-[#94E067] rounded-lg py-3 flex items-center justify-center">
|
||||||
<div className="mt-2 w-full bg-[#94E067] rounded-lg py-3 flex.items-center justify-center">
|
|
||||||
<span className="font-montserrat font-bold text-[15px] leading-[18px] text-white">
|
<span className="font-montserrat font-bold text-[15px] leading-[18px] text-white">
|
||||||
Развернуть
|
Развернуть
|
||||||
</span>
|
</span>
|
||||||
@@ -236,7 +236,6 @@ const HistoryRequestPage = () => {
|
|||||||
))}
|
))}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Попап */}
|
|
||||||
{selectedRequest && (
|
{selectedRequest && (
|
||||||
<RequestDetailsModal request={selectedRequest} onClose={handleClose} />
|
<RequestDetailsModal request={selectedRequest} onClose={handleClose} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
BIN
public/leaflet/marker-icon-2x.png
Normal file
BIN
public/leaflet/marker-icon-2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/leaflet/marker-icon.png
Normal file
BIN
public/leaflet/marker-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/leaflet/marker-shadow.png
Normal file
BIN
public/leaflet/marker-shadow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
Reference in New Issue
Block a user