WIPVOLONT

This commit is contained in:
fullofempt
2025-12-14 21:14:55 +05:00
parent 433b9e896c
commit 0df52352a8
12 changed files with 893 additions and 440 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ? "Оставить отзыв" : "Сохранить прогресс"}

View File

@@ -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">

View File

@@ -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="&copy; OpenStreetMap contributors" attribution="&copy; 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>
); );

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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;

View File

@@ -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} />
)} )}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B