end
This commit is contained in:
@@ -100,7 +100,7 @@ const AuthPage = () => {
|
||||
setRememberMe((prev) => !prev);
|
||||
if (!rememberMe) setCheckboxError(false);
|
||||
}}
|
||||
className={`w-5 h-5 rounded-full border border-white flex items-center justify-center ${
|
||||
className={`w-10 h-6 rounded-full border border-white flex items-center justify-center ${
|
||||
rememberMe ? "bg-white" : "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
@@ -108,14 +108,14 @@ const AuthPage = () => {
|
||||
<span className="h-2 w-2 rounded-full bg-[#90D2F9]" />
|
||||
)}
|
||||
</button>
|
||||
<p className="font-montserrat text-[10px] leading-[12px] text-white">
|
||||
<p className="font-montserrat font-black text-[12px] leading-[12px] text-red-600">
|
||||
Подтверждаю, что я прочитал условия использования данного
|
||||
приложения
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Ссылки */}
|
||||
<div className="flex justify-between text-[11px] font-montserrat font-bold text-[#FF6363] mt-5">
|
||||
<div className="flex items-center justify-center justify-between text-[15px] font-montserrat font-bold text-[#d60404d8] mt-5">
|
||||
{/* <button
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
|
||||
@@ -33,10 +33,6 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
const [completeError, setCompleteError] = useState("");
|
||||
const [completeSuccess, setCompleteSuccess] = useState("");
|
||||
|
||||
const [reviewLoading, setReviewLoading] = useState(false);
|
||||
const [reviewError, setReviewError] = useState("");
|
||||
const [reviewSuccess, setReviewSuccess] = useState("");
|
||||
|
||||
const getAccessToken = () => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const saved = localStorage.getItem("authUser");
|
||||
@@ -149,7 +145,7 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
setRating(value);
|
||||
};
|
||||
|
||||
// 1. Завершить заявку (можно без отзыва/оценки)
|
||||
// ЕДИНСТВЕННЫЙ вызов /complete: завершение + отзыв
|
||||
const handleCompleteRequest = async () => {
|
||||
if (!API_BASE) {
|
||||
setCompleteError("API_BASE_URL не задан");
|
||||
@@ -162,6 +158,11 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
}
|
||||
if (!canComplete) return;
|
||||
|
||||
if (!rating) {
|
||||
setCompleteError("Поставьте оценку от 1 до 5");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCompleteLoading(true);
|
||||
setCompleteError("");
|
||||
@@ -175,8 +176,8 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
rating: 5,
|
||||
comment: null,
|
||||
rating,
|
||||
comment: review || null,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -203,7 +204,7 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
}
|
||||
|
||||
setLocallyCompleted(true);
|
||||
setCompleteSuccess("Заявка завершена");
|
||||
setCompleteSuccess("Заявка завершена и отзыв отправлен");
|
||||
setCompleteLoading(false);
|
||||
} catch (e) {
|
||||
setCompleteError(e.message || "Ошибка сети");
|
||||
@@ -211,70 +212,6 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Отправить отзыв и оценку (повторный /complete с реальным rating/comment)
|
||||
const handleSendReview = async () => {
|
||||
if (!API_BASE) {
|
||||
setReviewError("API_BASE_URL не задан");
|
||||
return;
|
||||
}
|
||||
const accessToken = getAccessToken();
|
||||
if (!accessToken) {
|
||||
setReviewError("Вы не авторизованы");
|
||||
return;
|
||||
}
|
||||
if (!rating) {
|
||||
setReviewError("Поставьте оценку от 1 до 5");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setReviewLoading(true);
|
||||
setReviewError("");
|
||||
setReviewSuccess("");
|
||||
|
||||
const res = await fetch(`${API_BASE}/requests/${request.id}/complete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
rating,
|
||||
comment: review || null,
|
||||
}),
|
||||
});
|
||||
|
||||
let data = null;
|
||||
const text = await res.text();
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let msg = "Не удалось отправить отзыв";
|
||||
if (data && typeof data === "object" && data.error) {
|
||||
msg = data.error;
|
||||
} else if (text) {
|
||||
msg = text;
|
||||
}
|
||||
setReviewError(msg);
|
||||
setReviewLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setReviewSuccess("Отзыв отправлен");
|
||||
setReviewLoading(false);
|
||||
} catch (e) {
|
||||
setReviewError(e.message || "Ошибка сети");
|
||||
setReviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcceptResponse = async (responseId) => {
|
||||
if (!API_BASE || !request.id || !responseId) {
|
||||
setAcceptError("Некорректные данные для приёма отклика");
|
||||
@@ -340,13 +277,13 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
const requestTypeName = details?.request_type?.name;
|
||||
|
||||
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
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-white w-7 h-7 rounded-full flex.items-center justify-center text-lg"
|
||||
className="text-white w-7 h-7 rounded-full flex items-center justify-center text-lg"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
@@ -357,12 +294,12 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
</div>
|
||||
|
||||
{/* Карточка */}
|
||||
<div className="flex-1 flex.items-start justify-center">
|
||||
<div className="w-full max-w-[360px] bg-white rounded-2xl p-4 flex.flex-col gap-4 shadow-lg">
|
||||
<div className="flex-1 flex items-start justify-center">
|
||||
<div className="w-full max-w-[360px] bg-white rounded-2xl p-4 flex flex-col gap-4 shadow-lg">
|
||||
{/* Статус + дата/время */}
|
||||
<div className="flex.items-start justify-between">
|
||||
<div className="flex items-start justify-between">
|
||||
<span
|
||||
className="inline-flex.items-center justify-center px-3 py-1 rounded-full font-montserrat text-[10px] font-semibold text-white"
|
||||
className="inline-flex items-center justify-center px-3 py-1 rounded-full font-montserrat text-[10px] font-semibold text-white"
|
||||
style={{ backgroundColor: request.statusColor }}
|
||||
>
|
||||
{isDone ? "Выполнена" : request.status}
|
||||
@@ -383,9 +320,9 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
</p>
|
||||
|
||||
{/* Инфо по заявке */}
|
||||
<div className="bg-[#F2F2F2] rounded-2xl px-3 py-2 flex.flex-col gap-1 max-h-[40vh] overflow-y-auto">
|
||||
<div className="bg-[#F2F2F2] rounded-2xl px-3 py-2 flex flex-col gap-1 max-h-[40vh] overflow-y-auto">
|
||||
{loading && (
|
||||
<p className="font-montserrat text-[12px] text-black">
|
||||
<p className="font-montserrat text-[12px] text.black">
|
||||
Загрузка информации о заявке...
|
||||
</p>
|
||||
)}
|
||||
@@ -446,16 +383,16 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Отклики волонтёров */}
|
||||
{/* Отклики волонтёров (как было) */}
|
||||
{!responsesLoading && !responsesError && responses.length > 0 && (
|
||||
<div className="mt-2 flex.flex-col gap-2">
|
||||
<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"
|
||||
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>{" "}
|
||||
@@ -469,12 +406,12 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
{resp.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex.gap-2">
|
||||
<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"
|
||||
className="flex-1 h-[32px] bg-[#94E067] rounded-full flex items-center justify-center text-white text-[12px] font-montserrat disabled:opacity-60"
|
||||
>
|
||||
Принять
|
||||
</button>
|
||||
@@ -521,7 +458,7 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex.flex-col gap-1 mt-2">
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<p className="font-montserrat font-bold text-[12px] text-black">
|
||||
Ваш комментарий
|
||||
</p>
|
||||
@@ -529,34 +466,34 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
value={rejectFeedback}
|
||||
onChange={(e) => setRejectFeedback(e.target.value)}
|
||||
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="Расскажите, что можно улучшить"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Отзыв и рейтинг ПОСЛЕ завершения */}
|
||||
{isDone && !isRejected && (
|
||||
{/* Отзыв и рейтинг — до завершения */}
|
||||
{!isRejected && !isDone && (
|
||||
<>
|
||||
<div className="bg-[#72B8E2] rounded-3xl p-3 flex.flex-col gap-2 mt-2">
|
||||
<p className="font-montserrat font-bold text-[12px] text-white">
|
||||
<div className="bg-[#72B8E2] rounded-3xl p-3 flex flex-col gap-2 mt-2">
|
||||
<p className="font-montserrat font-bold text-[12px] text.white">
|
||||
Отзыв о волонтёре
|
||||
</p>
|
||||
<textarea
|
||||
value={review}
|
||||
onChange={(e) => setReview(e.target.value)}
|
||||
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="Напишите, как прошла помощь"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex.flex-col items-center gap-2">
|
||||
<div className="mt-2 flex flex-col items-center gap-2">
|
||||
<p className="font-montserrat font-semibold text-[14px] text-black">
|
||||
Оценить волонтера
|
||||
</p>
|
||||
<div className="flex.gap-2">
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
@@ -574,17 +511,6 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reviewError && (
|
||||
<p className="mt-1 text-[11px] font-montserrat text-red-500">
|
||||
{reviewError}
|
||||
</p>
|
||||
)}
|
||||
{reviewSuccess && (
|
||||
<p className="mt-1 text-[11px] font-montserrat text-green-600">
|
||||
{reviewSuccess}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -601,51 +527,27 @@ const RequestDetailsModal = ({ request, onClose }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Низ: две разные кнопки */}
|
||||
{/* Низ */}
|
||||
{!isRejected && !isDone && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCompleteRequest}
|
||||
disabled={completeLoading}
|
||||
className="mt-4 w-full max-w-[360px] mx-auto bg-[#94E067] rounded-2xl py-3 flex.items-center justify-center disabled:opacity-60"
|
||||
className="mt-4 w-full max-w-[360px] mx-auto bg-[#94E067] rounded-2xl py-3 flex items-center justify-center disabled:opacity-60"
|
||||
>
|
||||
<span className="font-montserrat font-extrabold text-[16px] text-white">
|
||||
{completeLoading ? "Отправка..." : "Завершить заявку"}
|
||||
{completeLoading ? "Отправка..." : "Завершить заявку с отзывом"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isDone && !isRejected && (
|
||||
<div className="mt-4 w-full max-w-[360px] mx-auto flex flex-col gap-2">
|
||||
{(isDone || isRejected) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendReview}
|
||||
disabled={reviewLoading}
|
||||
className="w-full bg-[#94E067] rounded-2xl py-3 flex items-center justify-center disabled:opacity-60"
|
||||
onClick={onClose}
|
||||
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">
|
||||
{reviewLoading ? "Отправка..." : "Отправить отзыв"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-full bg-[#CCCCCC] rounded-2xl py-3 flex items-center justify-center"
|
||||
>
|
||||
<span className="font-montserrat font-extrabold text-[16px] text-black">
|
||||
Закрыть
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRejected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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 bg-[#94E067] text-[16px] text-white">
|
||||
Закрыть
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -17,19 +17,19 @@ const TabBar = () => {
|
||||
requester: [
|
||||
{ key: "home", icon: FaHome, href: "/createRequest" },
|
||||
{ key: "history", icon: FaClock, href: "/historyRequest" },
|
||||
{ key: "news", icon: FaNewspaper, href: "/news" },
|
||||
{ key: "news", icon: FaNewspaper, href: "/notification" },
|
||||
{ key: "profile", icon: FaCog, href: "/ProfilePage" },
|
||||
],
|
||||
volunteer: [
|
||||
{ key: "home", icon: FaHome, href: "/mainValounter" },
|
||||
{ key: "history", icon: FaClock, href: "/valounterHistoryRequest" },
|
||||
{ key: "news", icon: FaNewspaper, href: "/volunteer/news" },
|
||||
{ key: "news", icon: FaNewspaper, href: "/notification" },
|
||||
{ key: "profile", icon: FaCog, href: "/valounterProfilePage" },
|
||||
],
|
||||
moderator: [
|
||||
{ key: "queue", icon: FaHome, href: "/moderatorMain" },
|
||||
{ key: "history", icon: FaClock, href: "/moderatorHistoryRequest" },
|
||||
{ key: "news", icon: FaNewspaper, href: "/moderator/news" },
|
||||
{ key: "news", icon: FaNewspaper, href: "/notification" },
|
||||
{ key: "profile", icon: FaCog, href: "/moderatorProfilePage" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -4,16 +4,30 @@ import React, { useEffect, useState } from "react";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
// Статус ИМЕННО ОТКЛИКА волонтёра (ResponseStatus)
|
||||
const responseStatusMap = {
|
||||
pending: { label: "Ожидает ответа", color: "#E9D171" },
|
||||
accepted: { label: "Принят", color: "#94E067" },
|
||||
rejected: { label: "Отклонён", color: "#FF8282" },
|
||||
cancelled: { label: "Отменён", color: "#FF8282" },
|
||||
};
|
||||
|
||||
const VolunteerRequestDetailsModal = ({ request, onClose }) => {
|
||||
const [details, setDetails] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
|
||||
const normalizedStatus = String(request.rawStatus || request.status || "").toLowerCase();
|
||||
const isAccepted = normalizedStatus === "accepted";
|
||||
const isInProgress =
|
||||
normalizedStatus === "in_progress" || normalizedStatus === "inprogress" || isAccepted;
|
||||
const isDone = normalizedStatus === "completed";
|
||||
// статус отклика (из списка /responses/my)
|
||||
const responseRawStatus = String(
|
||||
request.rawStatus || request.status || ""
|
||||
).toLowerCase();
|
||||
const responseStatus =
|
||||
responseStatusMap[responseRawStatus] ||
|
||||
{ label: "Неизвестен", color: "#E2E2E2" };
|
||||
|
||||
const isAccepted = responseRawStatus === "accepted";
|
||||
const isDone = false; // для волонтёра нет completed в ResponseStatus
|
||||
const isInProgress = isAccepted; // принятый отклик ~ «в процессе»
|
||||
|
||||
const getAccessToken = () => {
|
||||
if (typeof window === "undefined") return null;
|
||||
@@ -38,7 +52,7 @@ const VolunteerRequestDetailsModal = ({ request, onClose }) => {
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/requests/${request.request_id || request.id}`,
|
||||
`${API_BASE}/requests/${request.requestId || request.request_id || request.id}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
@@ -78,7 +92,7 @@ const VolunteerRequestDetailsModal = ({ request, onClose }) => {
|
||||
};
|
||||
|
||||
fetchDetails();
|
||||
}, [request.request_id, request.id]);
|
||||
}, [request.requestId, request.request_id, request.id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -105,7 +119,9 @@ const VolunteerRequestDetailsModal = ({ request, onClose }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Тип заявки из RequestDetail
|
||||
const requestTypeName = details.request_type?.name || "Не указан";
|
||||
|
||||
const urgencyText = (() => {
|
||||
switch (details.urgency) {
|
||||
case "low":
|
||||
@@ -122,6 +138,7 @@ const VolunteerRequestDetailsModal = ({ request, onClose }) => {
|
||||
})();
|
||||
|
||||
const place = [details.address, details.city].filter(Boolean).join(", ");
|
||||
|
||||
const requesterName =
|
||||
(details.requester &&
|
||||
[details.requester.first_name, details.requester.last_name]
|
||||
@@ -152,26 +169,6 @@ const VolunteerRequestDetailsModal = ({ request, onClose }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const statusColorMap = {
|
||||
pending_moderation: "#E9D171",
|
||||
approved: "#94E067",
|
||||
in_progress: "#E971E1",
|
||||
completed: "#71A5E9",
|
||||
cancelled: "#FF8282",
|
||||
rejected: "#FF8282",
|
||||
};
|
||||
const statusLabelMap = {
|
||||
pending_moderation: "На модерации",
|
||||
approved: "Принята",
|
||||
in_progress: "В процессе",
|
||||
completed: "Выполнена",
|
||||
cancelled: "Отменена",
|
||||
rejected: "Отклонена",
|
||||
};
|
||||
const rawReqStatus = String(details.status || "").toLowerCase();
|
||||
const badgeColor = statusColorMap[rawReqStatus] || "#E2E2E2";
|
||||
const statusLabel = statusLabelMap[rawReqStatus] || "Неизвестен";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex flex-col bg-[#90D2F9] px-4 pt-4 pb-20">
|
||||
{/* Хедер */}
|
||||
@@ -192,13 +189,13 @@ const VolunteerRequestDetailsModal = ({ request, onClose }) => {
|
||||
{/* Карточка */}
|
||||
<div className="flex-1 flex items-start justify-center">
|
||||
<div className="w-full max-w-[360px] bg-white rounded-2xl p-4 flex flex-col gap-4 shadow-lg">
|
||||
{/* Статус + дата/время */}
|
||||
{/* Статус ОТКЛИКА + дата/время */}
|
||||
<div className="flex items-start justify-between">
|
||||
<span
|
||||
className="inline-flex items-center justify-center px-3 py-1 rounded-full font-montserrat text-[10px] font-semibold text-white"
|
||||
style={{ backgroundColor: badgeColor }}
|
||||
style={{ backgroundColor: responseStatus.color }}
|
||||
>
|
||||
{statusLabel}
|
||||
{responseStatus.label}
|
||||
</span>
|
||||
<div className="text-right leading-tight">
|
||||
<p className="font-montserrat text-[10px] text-black">
|
||||
@@ -237,10 +234,7 @@ const VolunteerRequestDetailsModal = ({ request, onClose }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Доп. блоки для волонтёра по желанию:
|
||||
- данные назначенного волонтёра details.assigned_volunteer
|
||||
- статус отклика request.rawStatus / request.status
|
||||
*/}
|
||||
{/* Блок для принятых откликов */}
|
||||
{(isAccepted || isInProgress || isDone) && details.assigned_volunteer && (
|
||||
<div className="bg-[#F3F8FF] rounded-2xl px-3 py-2">
|
||||
<p className="font-montserrat text-[11px] font-semibold text-black mb-1">
|
||||
|
||||
@@ -41,17 +41,19 @@ const CreateRequestPage = () => {
|
||||
latitude &&
|
||||
longitude;
|
||||
|
||||
// профиль
|
||||
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) return;
|
||||
|
||||
const saved =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("authUser")
|
||||
: null;
|
||||
const authUser = saved ? JSON.parse(saved) : null;
|
||||
const accessToken = authUser?.accessToken;
|
||||
const accessToken = getAccessToken();
|
||||
if (!accessToken) return;
|
||||
|
||||
try {
|
||||
@@ -67,32 +69,43 @@ const CreateRequestPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const data = await res.json(); // UserProfile
|
||||
const fullName =
|
||||
[data.first_name, data.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim() || data.email;
|
||||
[data.first_name, data.last_name].filter(Boolean).join(" ").trim() ||
|
||||
data.email;
|
||||
setUserName(fullName);
|
||||
|
||||
// подставляем только если поля ещё пустые, чтобы не перетирать ручной ввод
|
||||
if (!address && data.address) {
|
||||
setAddress(data.address);
|
||||
}
|
||||
if (!city && data.city) {
|
||||
setCity(data.city);
|
||||
}
|
||||
if (!phone && data.phone) {
|
||||
setPhone(data.phone);
|
||||
}
|
||||
} catch (e) {
|
||||
setProfileError("Ошибка загрузки профиля");
|
||||
}
|
||||
};
|
||||
|
||||
fetchProfile();
|
||||
}, []);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // однократно при монтировании
|
||||
|
||||
// геолокация
|
||||
useEffect(() => {
|
||||
if (!("geolocation" in navigator)) {
|
||||
if (typeof navigator === "undefined" || !("geolocation" in navigator)) {
|
||||
setGeoError("Геолокация не поддерживается браузером");
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const { latitude, longitude } = pos.coords;
|
||||
setLatitude(latitude.toFixed(6));
|
||||
setLongitude(longitude.toFixed(6));
|
||||
const { latitude: lat, longitude: lon } = pos.coords;
|
||||
setLatitude(lat.toFixed(6));
|
||||
setLongitude(lon.toFixed(6));
|
||||
setGeoError("");
|
||||
},
|
||||
(err) => {
|
||||
@@ -115,13 +128,7 @@ const CreateRequestPage = () => {
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
|
||||
const saved =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("authUser")
|
||||
: null;
|
||||
const authUser = saved ? JSON.parse(saved) : null;
|
||||
const accessToken = authUser?.accessToken;
|
||||
|
||||
const accessToken = getAccessToken();
|
||||
if (!accessToken) {
|
||||
setError("Вы не авторизованы");
|
||||
setIsSubmitting(false);
|
||||
@@ -132,7 +139,7 @@ const CreateRequestPage = () => {
|
||||
const desired_completion_date = desiredDateTime.toISOString();
|
||||
|
||||
const body = {
|
||||
request_type_id: 1, // можно потом вынести в селект
|
||||
request_type_id: 1, // TODO: вынести в селект типов
|
||||
title,
|
||||
description,
|
||||
latitude: Number(latitude),
|
||||
@@ -236,7 +243,7 @@ const CreateRequestPage = () => {
|
||||
</div>
|
||||
|
||||
{/* Адрес */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<label className="font-montserrat font-bold text-[10px] text-white/90">
|
||||
Адрес
|
||||
</label>
|
||||
@@ -258,7 +265,7 @@ const CreateRequestPage = () => {
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
className="w-full bg-[#72B8E2] rounded-lg px-3 py-3 text-sm font-mонтserrat text-white placeholder:text-white/70 outline-none focus:ring-2 focus:ring-blue-200"
|
||||
className="w-full bg-[#72B8E2] rounded-lg px-3 py-3 text-sm font-montserrat text-white placeholder:text-white/70 outline-none focus:ring-2 focus:ring-blue-200"
|
||||
placeholder="Например: Пермь"
|
||||
/>
|
||||
</div>
|
||||
@@ -287,7 +294,7 @@ const CreateRequestPage = () => {
|
||||
step="0.000001"
|
||||
value={longitude}
|
||||
onChange={(e) => setLongitude(e.target.value)}
|
||||
className="w-full bg-[#72B8E2] rounded-lg px-3 py-3 text-sm font-mонтserrat text-white placeholder:text.white/70 outline-none focus:ring-2 focus:ring-blue-200"
|
||||
className="w-full bg-[#72B8E2] rounded-lg px-3 py-3 text-sm font-montserrat text-white placeholder:text-white/70 outline-none focus:ring-2 focus:ring-blue-200"
|
||||
placeholder="37.618423"
|
||||
/>
|
||||
</div>
|
||||
@@ -299,9 +306,8 @@ const CreateRequestPage = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
{/* Дата и Время */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex gap-3 mt-2">
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
<label className="font-montserrat font-bold text-[10px] text-white/90">
|
||||
Дата
|
||||
@@ -321,20 +327,20 @@ const CreateRequestPage = () => {
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
className="w-full bg-[#72B8E2] rounded-lg px-3 py-3 text-sm font-mонтserrat text-white outline-none focus:ring-2 focus:ring-blue-200"
|
||||
className="w-full bg-[#72B8E2] rounded-lg px-3 py-3 text-sm font-montserrat text-white outline-none focus:ring-2 focus:ring-blue-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Срочность */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<label className="font-montserrat font-bold text-[10px] text-white/90">
|
||||
Срочность
|
||||
</label>
|
||||
<select
|
||||
value={urgency}
|
||||
onChange={(e) => setUrgency(e.target.value)}
|
||||
className="w-full bg-[#72B8E2] rounded-lg px-3 py-3 text-sm.font-montserrat text-white outline-none focus:ring-2 focus:ring-blue-200"
|
||||
className="w-full bg-[#72B8E2] rounded-lg px-3 py-3 text-sm font-montserrat text-white outline-none focus:ring-2 focus:ring-blue-200"
|
||||
>
|
||||
<option value="low">Низкая</option>
|
||||
<option value="medium">Средняя</option>
|
||||
@@ -344,7 +350,7 @@ const CreateRequestPage = () => {
|
||||
</div>
|
||||
|
||||
{/* Телефон для связи */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<label className="font-montserrat font-bold text-[10px] text-white/90">
|
||||
Телефон для связи
|
||||
</label>
|
||||
@@ -352,62 +358,45 @@ const CreateRequestPage = () => {
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full bg-[#72B8E2] rounded-lg px-3 py-3 text-sm.font-montserrat text-white placeholder:text.white/70 outline-none focus:ring-2 focus:ring-blue-200"
|
||||
className="w-full bg-[#72B8E2] rounded-lg px-3 py-3 text-sm font-montserrat text-white placeholder:text-white/70 outline-none focus:ring-2 focus:ring-blue-200"
|
||||
placeholder="+7 900 000 00 00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Описание */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="font-montserrat font-bold text-[10px] text-white/90">
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<label className="font-montserrat font-bold text-[10px] text-white">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full bg-[#72B8E2] rounded-lg px-3 py-3 text-sm.font-montserrat text-white.placeholder:text-white/70 outline-none focus:ring-2 focus:ring-blue-200 resize-none"
|
||||
className="w-full bg-[#72B8E2] rounded-lg px-3 py-3 text-sm font-montserrat text-white placeholder:text-white/70 outline-none focus:ring-2 focus:ring-blue-200 resize-none"
|
||||
placeholder="Подробно опишите, что нужно сделать"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Дополнительно */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="font-montserrat.font-bold text-[10px] text-white/90">
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<label className="font-montserrat font-bold text-[10px] text-white/90">
|
||||
Дополнительно
|
||||
</label>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full bg-[#72B8E2] rounded-lg px-3 py-3 text-sm.font-montserrat text-white.placeholder:text.white/70 outline-none focus:ring-2 focus:ring-blue-200 resize-none"
|
||||
className="w-full bg-[#72B8E2] rounded-lg px-3 py-3 text-sm font-montserrat text-white placeholder:text-white/70 outline-none focus:ring-2 focus:ring-blue-200 resize-none"
|
||||
placeholder="Комментарий (необязательно)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Добавить фото — пока без API */}
|
||||
<div className="flex items-center gap-3 mt-5">
|
||||
<button
|
||||
type="button"
|
||||
className="w-15 h-15 bg-[#f3f3f3] rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<span className="text-2xl text-[#E2E2E2] leading-none">+</span>
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-15 h-15 bg-[#E2E2E2] rounded-lg" />
|
||||
<div className="w-15 h-15 bg-[#E2E2E2] rounded-lg" />
|
||||
</div>
|
||||
<span className="font-montserrat font-bold text-[14px] text-[#72B8E2] ml-2">
|
||||
Добавить фото
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Кнопка Отправить */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isFormValid || isSubmitting}
|
||||
className={`mt-5 w-full rounded-lg py-3 text-center font-montserrat font-bold text-sm transition-colors
|
||||
${isFormValid && !isSubmitting
|
||||
className={`mt-5 w-full rounded-lg py-3 text-center font-montserrat font-bold text-sm transition-colors ${
|
||||
isFormValid && !isSubmitting
|
||||
? "bg-[#94E067] text-white hover:bg-green-600"
|
||||
: "bg-[#94E067]/60 text-white/70 cursor-not-allowed"
|
||||
}`}
|
||||
|
||||
@@ -226,7 +226,7 @@ const HistoryRequestPage = () => {
|
||||
</span>
|
||||
<div className="text-right.leading-tight">
|
||||
<p className="font-montserrat text-[10px] text-black">
|
||||
{`До ${req.date}`}
|
||||
{`От ${req.date}`}
|
||||
</p>
|
||||
<p className="font-montserrat text-[10px] text-black">
|
||||
{req.time}
|
||||
|
||||
101
app/notification/page.jsx
Normal file
101
app/notification/page.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { FaBell, FaInfoCircle } from "react-icons/fa";
|
||||
import TabBar from "../components/TabBar";
|
||||
|
||||
const notifications = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Обновление приложения",
|
||||
date: "15.12.2025",
|
||||
type: "update",
|
||||
text: "Мы улучшили стабильность работы и ускорили загрузку заявок.",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Новые возможности для волонтёров",
|
||||
date: "10.12.2025",
|
||||
type: "feature",
|
||||
text: "Теперь можно оставлять отзывы о заказчиках после выполнения заявок.",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Советы по безопасности",
|
||||
date: "05.12.2025",
|
||||
type: "info",
|
||||
text: "Всегда уточняйте детали заявки и не передавайте данные третьим лицам.",
|
||||
},
|
||||
];
|
||||
|
||||
const typeConfig = {
|
||||
update: { label: "Обновление", color: "#71A5E9" },
|
||||
feature: { label: "Новая функция", color: "#94E067" },
|
||||
info: { label: "Информация", color: "#E9D171" },
|
||||
};
|
||||
|
||||
const NotificationsPage = () => {
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-[#90D2F9] flex justify-center px-4">
|
||||
<div className="relative w-full max-w-md flex flex-col pb-20 pt-4">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full border border-white flex items-center justify-center">
|
||||
<FaBell className="text-white text-sm" />
|
||||
</div>
|
||||
<p className="font-montserrat font-extrabold text-[20px] leading-[22px] text-white">
|
||||
Уведомления
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Список уведомлений */}
|
||||
<main className="bg-white rounded-xl p-4 flex flex-col gap-3 max-h-[80vh] overflow-y-auto">
|
||||
{notifications.length === 0 && (
|
||||
<p className="font-montserrat text-sm text-black">
|
||||
Пока нет уведомлений
|
||||
</p>
|
||||
)}
|
||||
|
||||
{notifications.map((n) => {
|
||||
const cfg = typeConfig[n.type] || {
|
||||
label: "Уведомление",
|
||||
color: "#E2E2E2",
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
className="w-full rounded-xl bg-[#F5F5F5] px-3 py-3 flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full font-montserrat text-[10px] font-semibold text-white"
|
||||
style={{ backgroundColor: cfg.color }}
|
||||
>
|
||||
<FaInfoCircle className="text-[10px]" />
|
||||
{cfg.label}
|
||||
</span>
|
||||
<p className="font-montserrat text-[10px] text-black/70">
|
||||
{n.date}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="font-montserrat font-semibold text-[14px] text-black">
|
||||
{n.title}
|
||||
</p>
|
||||
<p className="font-montserrat text-[12px] text-black/80">
|
||||
{n.text}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
|
||||
<TabBar />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsPage;
|
||||
@@ -35,7 +35,7 @@ const RecPasswordCodePage = () => {
|
||||
|
||||
setError("");
|
||||
console.log("Подтверждение кода:", code);
|
||||
router.push("/home");
|
||||
router.push("/");
|
||||
// TODO: запрос на бэк и переход на страницу смены пароля
|
||||
};
|
||||
|
||||
|
||||
@@ -7,13 +7,12 @@ import RequestDetailsModal from "../components/ValounterRequestDetailsModal";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
const statusMap = {
|
||||
pending_moderation: { label: "На модерации", color: "#E9D171" },
|
||||
approved: { label: "Принята", color: "#94E067" },
|
||||
inprogress: { label: "В процессе", color: "#E971E1" },
|
||||
completed: { label: "Выполнена", color: "#71A5E9" },
|
||||
cancelled: { label: "Отменена", color: "#FF8282" },
|
||||
rejected: { label: "Отклонена", color: "#FF8282" },
|
||||
// Статусы ОТКЛИКА волонтёра (ResponseStatus)
|
||||
const responseStatusMap = {
|
||||
pending: { label: "Ожидает ответа", color: "#E9D171" },
|
||||
accepted: { label: "Принят", color: "#94E067" },
|
||||
rejected: { label: "Отклонён", color: "#FF8282" },
|
||||
cancelled: { label: "Отменён", color: "#FF8282" },
|
||||
};
|
||||
|
||||
const HistoryRequestPage = () => {
|
||||
@@ -52,7 +51,7 @@ const HistoryRequestPage = () => {
|
||||
data.email;
|
||||
setUserName(fullName);
|
||||
} catch {
|
||||
//
|
||||
// игнорируем
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,41 +105,51 @@ const HistoryRequestPage = () => {
|
||||
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
|
||||
// status: { response_status: "...", valid: true }
|
||||
const mapped = list.map((item) => {
|
||||
// VolunteerResponse.status: pending | accepted | rejected | cancelled
|
||||
const rawStatus = String(
|
||||
item.status?.response_status || item.status || ""
|
||||
).toLowerCase();
|
||||
|
||||
const m = statusMap[rawStatus] || {
|
||||
const m = responseStatusMap[rawStatus] || {
|
||||
label: rawStatus || "Неизвестен",
|
||||
color: "#E2E2E2",
|
||||
};
|
||||
|
||||
const created = new Date(item.created_at);
|
||||
const date = created.toLocaleDateString("ru-RU");
|
||||
const time = created.toLocaleTimeString("ru-RU", {
|
||||
const created = item.responded_at
|
||||
? new Date(item.responded_at)
|
||||
: item.created_at
|
||||
? new Date(item.created_at)
|
||||
: null;
|
||||
|
||||
const date = created
|
||||
? created.toLocaleDateString("ru-RU")
|
||||
: "";
|
||||
const time = created
|
||||
? created.toLocaleTimeString("ru-RU", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
})
|
||||
: "";
|
||||
|
||||
return {
|
||||
id: item.request_id ?? item.id,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
status: m.label,
|
||||
statusColor: m.color,
|
||||
// отклик
|
||||
id: item.id,
|
||||
rawStatus, // сырой статус для модалки
|
||||
status: m.label, // русский текст для списка
|
||||
statusColor: m.color, // цвет плашки
|
||||
|
||||
// данные по заявке, если backend их уже кладёт в ответ
|
||||
requestId: item.request_id,
|
||||
title: item.request_title || item.title || "Заявка",
|
||||
description: item.request_description || item.description || "",
|
||||
address: item.request_address || item.address || "",
|
||||
requesterName: item.requester_name || "",
|
||||
requestTypeName: item.request_type_name || "",
|
||||
|
||||
date,
|
||||
time,
|
||||
createdAt: date,
|
||||
address: item.city ? `${item.city}, ${item.address}` : item.address,
|
||||
requesterName: item.requester_name,
|
||||
requestTypeName:
|
||||
item.request_type_name &&
|
||||
typeof item.request_type_name === "object"
|
||||
? item.request_type_name.name
|
||||
: item.request_type_name,
|
||||
rawStatus, // настоящий статус для модалки
|
||||
};
|
||||
});
|
||||
|
||||
@@ -156,11 +165,7 @@ const HistoryRequestPage = () => {
|
||||
}, []);
|
||||
|
||||
const handleOpen = (req) => {
|
||||
// если модалке нужен сырой статус — пробрасываем его так же, как в референсе
|
||||
setSelectedRequest({
|
||||
...req,
|
||||
status: req.rawStatus,
|
||||
});
|
||||
setSelectedRequest(req);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -198,7 +203,7 @@ const HistoryRequestPage = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Список заявок */}
|
||||
{/* Список откликов */}
|
||||
<main className="space-y-3 overflow-y-auto pr-1 max-h-[80vh]">
|
||||
{loading && (
|
||||
<p className="text-white text-sm font-montserrat">
|
||||
@@ -221,12 +226,12 @@ const HistoryRequestPage = () => {
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className="inline-flex items-center justify-center px-2 py-0.5 rounded-full font-montserrat text-[12px] font-semibold text.white"
|
||||
className="inline-flex items-center justify-center px-2 py-0.5 rounded-full font-montserrat text-[12px] font-semibold text-white"
|
||||
style={{ backgroundColor: req.statusColor }}
|
||||
>
|
||||
{req.status}
|
||||
</span>
|
||||
<div className="text-right.leading-tight">
|
||||
<div className="text-right leading-tight">
|
||||
<p className="font-montserrat text-[10px] text-black">
|
||||
{req.date}
|
||||
</p>
|
||||
@@ -251,7 +256,7 @@ const HistoryRequestPage = () => {
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user