Compare commits

..

6 Commits

Author SHA1 Message Date
fullofempt
e4bfbd30cc end 2025-12-15 13:18:18 +05:00
fullofempt
b5575b8e47 Доделать уведомления + историю волонтера + пофиксить визуал 2025-12-14 22:15:33 +05:00
fullofempt
0df52352a8 WIPVOLONT 2025-12-14 21:14:55 +05:00
fullofempt
433b9e896c WIP API 2025-12-14 18:47:14 +05:00
fullofempt
bb833d956e +-good 2025-12-13 19:33:57 +05:00
fullofempt
b1cab4a2ab DoneAuthContext 2025-12-13 18:30:48 +05:00
26 changed files with 4630 additions and 586 deletions

View File

@@ -2,56 +2,63 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "../app/context/AuthContext";
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const AuthPage = () => { const AuthPage = () => {
const router = useRouter(); const router = useRouter();
const { login } = useAuth();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const [checkboxError, setCheckboxError] = useState(false); const [checkboxError, setCheckboxError] = useState(false);
const [authError, setAuthError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const isEmailValid = emailRegex.test(email); const isEmailValid = emailRegex.test(email);
const isFormValid = isEmailValid && password.length > 0; const isFormValid = isEmailValid && password.length > 0;
const handleSubmit = (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
// проверка чекбокса
if (!rememberMe) { if (!rememberMe) {
setCheckboxError(true); setCheckboxError(true);
return; return;
} }
setCheckboxError(false); setCheckboxError(false);
if (!isFormValid) return; if (!isFormValid) return;
console.log("Email:", email, "Password:", password, "Remember:", rememberMe); try {
router.push('./home'); setAuthError("");
setIsSubmitting(true);
await login(email, password); // теперь это async
} catch (err) {
setAuthError(err.message || "Неверный логин или пароль");
setIsSubmitting(false);
}
}; };
return ( return (
<div className="min-h-screen w-full bg-[#90D2F9] flex items-center justify-center px-4"> <div className="min-h-screen w-full bg-[#90D2F9] flex items-center justify-center px-4">
<div className="w-full max-w-md bg-white/10 rounded-2xl p-6 sm:p-8 shadow-lg relative"> <div className="w-full max-w-md bg-white/10 rounded-2xl p-6 sm:p-8 shadow-lg relative">
{/* Красный баннер ошибки по чекбоксу */} {/* Красный баннер ошибок */}
{checkboxError && ( {(checkboxError || authError) && (
<div <div
className="absolute -top-10 left-0 w-full bg-red-500 text-white text-xs sm:text-sm font-montserrat px-3 py-2 rounded-t-2xl flex items-center justify-center shadow-md" className="absolute -top-10 left-0 w-full bg-red-500 text-white text-xs sm:text-sm font-montserrat px-3 py-2 rounded-t-2xl flex items-center justify-center shadow-md"
role="alert" role="alert"
> >
Вы не согласны с условиями использования {checkboxError
? "Вы не согласны с условиями использования"
: authError || "Неверный логин или пароль"}
</div> </div>
)} )}
<h1 className="font-montserrat text-white font-extrabold text-2xl text-center"> <h1 className="font-montserrat text-white font-extrabold text-2xl text-center">
Авторизация Авторизация
</h1> </h1>
{/* <p className="font-montserrat text-white text-sm text-center mt-1">
как пользователь
</p> */}
<form onSubmit={handleSubmit} className="mt-6 space-y-4"> <form onSubmit={handleSubmit} className="mt-6 space-y-4">
{/* Почта */} {/* Почта */}
@@ -93,24 +100,29 @@ const AuthPage = () => {
setRememberMe((prev) => !prev); setRememberMe((prev) => !prev);
if (!rememberMe) setCheckboxError(false); if (!rememberMe) setCheckboxError(false);
}} }}
className={`w-5 h-5 rounded-full border border-white flex items-center justify-center ${rememberMe ? "bg-white" : "bg-transparent" className={`w-10 h-6 rounded-full border border-white flex items-center justify-center ${
}`} rememberMe ? "bg-white" : "bg-transparent"
}`}
> >
{rememberMe && ( {rememberMe && (
<span className="h-2 w-2 rounded-full bg-[#90D2F9]" /> <span className="h-2 w-2 rounded-full bg-[#90D2F9]" />
)} )}
</button> </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> </p>
</div> </div>
{/* Ссылки */} {/* Ссылки */}
<div className="flex justify-between text-[11px] font-montserrat font-bold text-[#FF6363] mt-1"> <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" onClick={() => router.push("/recPassword")}> {/* <button
type="button"
className="hover:underline"
onClick={() => router.push("/recPassword")}
>
Забыли пароль? Забыли пароль?
</button> </button> */}
<button <button
type="button" type="button"
className="hover:underline" className="hover:underline"
@@ -123,16 +135,25 @@ const AuthPage = () => {
{/* Кнопка Войти */} {/* Кнопка Войти */}
<button <button
type="submit" type="submit"
disabled={!isFormValid} disabled={!isFormValid || isSubmitting}
className={`mt-4 w-full rounded-full py-2 text-center font-montserrat font-extrabold text-sm transition-colors className={` w-full rounded-full py-2 text-center font-montserrat font-extrabold text-sm transition-colors
${isFormValid ${
? "bg-green-500 text-white hover:bg-green-600" isFormValid && !isSubmitting
: "bg-white text-[#C4C4C4] cursor-not-allowed" ? "bg-green-500 text-white hover:bg-green-600"
: "bg-white text-[#C4C4C4] cursor-not-allowed"
}`} }`}
> >
Войти {isSubmitting ? "Входим..." : "Войти"}
</button> </button>
</form> </form>
{/* Подсказка по тестовым логинам — можно убрать, когда перейдёшь на реальные аккаунты */}
<div className="mt-4 text-[15px] text-white font-montserrat space-y-1">
<p>Тестовые аккаунты (если настроены на бэке):</p>
<p>Пользователь: user@mail.com / user123123</p>
<p>Волонтёр: vol@mail.com / vol123123</p>
<p>Модератор: mod@mail.com / mod123123</p>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,16 +1,88 @@
"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 ProfilePage = () => { const ProfilePage = () => {
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("");
useEffect(() => {
const fetchProfile = async () => {
if (!API_BASE) {
setError("API_BASE_URL не задан");
setLoading(false);
return;
}
const saved =
typeof window !== "undefined"
? localStorage.getItem("authUser")
: null;
const authUser = saved ? JSON.parse(saved) : null;
const accessToken = authUser?.accessToken;
if (!accessToken) {
setError("Вы не авторизованы");
setLoading(false);
return;
}
try {
const res = await fetch(`${API_BASE}/users/me`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) {
let msg = "Не удалось загрузить профиль";
try {
const data = await res.json();
if (data.error) msg = data.error;
} catch {
const text = await res.text();
if (text) msg = text;
}
setError(msg);
setLoading(false);
return;
}
const data = await res.json(); // UserProfile[file:519]
setProfile(data);
setLoading(false);
} catch (e) {
setError(e.message || "Ошибка сети");
setLoading(false);
}
};
fetchProfile();
}, []);
const handleLogout = () => {
// опционально: запрос /auth/logout, если используешь[file:519]
localStorage.removeItem("authUser");
router.push("/");
};
const fullName =
profile &&
[profile.first_name, profile.last_name].filter(Boolean).join(" ").trim();
const rating = profile?.volunteer_rating ?? 0;
const email = profile?.email || "—";
const phone = profile?.phone || "—";
const address = profile?.address || "Адрес не указан";
const city = profile?.city || "";
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,16 +104,25 @@ const ProfilePage = () => {
{/* Карточка профиля */} {/* Карточка профиля */}
<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">
{/* Ошибка / загрузка */}
{error && (
<p className="w-full text-center text-xs font-montserrat text-red-500">
{error}
</p>
)}
{loading && !error && (
<p className="w-full text-center text-xs font-montserrat text-black">
Загрузка профиля...
</p>
)}
{/* Аватар */} {/* Аватар */}
<FaUserCircle className="text-[#72B8E2] w-20 h-20" /> <FaUserCircle className="text-[#72B8E2] w-20 h-20" />
{/* ФИО и рейтинг */} {/* ФИО и рейтинг */}
<div className="text-center space-y-1"> <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"> <p className="font-montserrat font-bold text-[20px] text-black">
{fullName} {fullName || email}
</p> </p>
{/* Рейтинг + звезды */} {/* Рейтинг + звезды */}
@@ -65,16 +146,17 @@ const ProfilePage = () => {
</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} Почта: {email}
</p> </p>
<p className="font-montserrat text-[12px]"> <p className="font-montserrat text-[12px]">
Почта: example@mail.com Телефон: {phone}
</p> </p>
<p className="font-montserrat text-[12px]"> <p className="font-montserrat text-[12px]">
Телефон: +7 (900) 000-00-00 Адрес: {address}
{city ? `, ${city}` : ""}
</p> </p>
</div> </div>
@@ -91,6 +173,7 @@ const ProfilePage = () => {
</button> </button>
<button <button
type="button" type="button"
onClick={handleLogout}
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"
> >
<span className="font-montserrat font-extrabold text-[14px] text-white"> <span className="font-montserrat font-extrabold text-[14px] text-white">

View File

@@ -0,0 +1,393 @@
"use client";
import React, { useState } from "react";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
const ModeratorRequestModal = ({ request, onClose, onModerated }) => {
const [showRejectPopup, setShowRejectPopup] = useState(false);
const [rejectReason, setRejectReason] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
// request.status: "pending_moderation" | "approved" | "rejected"
const isApproved = request.status === "approved";
const isRejected = request.status === "rejected";
const isPending = request.status === "На модерации";
const getAccessToken = () => {
if (typeof window === "undefined") return null;
const saved = localStorage.getItem("authUser");
const authUser = saved ? JSON.parse(saved) : null;
return authUser?.accessToken || null;
};
const formatDate = (iso) => {
if (!iso) return "";
const d = new Date(iso);
return d.toLocaleDateString("ru-RU");
};
const formatTime = (iso) => {
if (!iso) return "";
const d = new Date(iso);
return d.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
};
const createdDate = request.date || "";
const createdTime = request.time || "";
const deadlineDate = request.deadlineDate || request.date || "";
const deadlineTime = request.deadlineTime || request.time || "";
const handleApprove = async () => {
if (!API_BASE || submitting) return;
const token = getAccessToken();
if (!token) {
setError("Вы не авторизованы");
return;
}
console.log("[MODERATION] APPROVE start", {
requestId: request.id,
statusBefore: request.status,
});
try {
setSubmitting(true);
setError("");
const res = await fetch(
`${API_BASE}/moderation/requests/${request.id}/approve`,
{
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ comment: null }),
}
);
console.log("[MODERATION] APPROVE response status", res.status);
const text = await res.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = null;
}
}
console.log("[MODERATION] APPROVE response body", data || text);
if (!res.ok) {
let msg = "Не удалось одобрить заявку";
if (data && typeof data === "object" && data.error) {
msg = data.error;
} else if (text) {
msg = text;
}
console.log("[MODERATION] APPROVE error", msg);
setError(msg);
setSubmitting(false);
return;
}
onModerated?.({
...request,
status: "approved",
moderationResult: data,
});
console.log("[MODERATION] APPROVE success", {
requestId: request.id,
newStatus: "approved",
});
setSubmitting(false);
onClose();
} catch (e) {
console.log("[MODERATION] APPROVE exception", e);
setError(e.message || "Ошибка сети");
setSubmitting(false);
}
};
const handleRejectConfirm = async () => {
if (!API_BASE || submitting) return;
const token = getAccessToken();
if (!token) {
setError("Вы не авторизованы");
return;
}
if (!rejectReason.trim()) {
setError("Укажите причину отклонения");
return;
}
console.log("[MODERATION] REJECT start", {
requestId: request.id,
statusBefore: request.status,
reason: rejectReason,
});
try {
setSubmitting(true);
setError("");
const res = await fetch(
`${API_BASE}/moderation/requests/${request.id}/reject`,
{
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ comment: rejectReason }),
}
);
console.log("[MODERATION] REJECT response status", res.status);
const text = await res.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = null;
}
}
console.log("[MODERATION] REJECT response body", data || text);
if (!res.ok) {
let msg = "Не удалось отклонить заявку";
if (data && typeof data === "object" && data.error) {
msg = data.error;
} else if (text) {
msg = text;
}
console.log("[MODERATION] REJECT error", msg);
setError(msg);
setSubmitting(false);
return;
}
onModerated?.({
...request,
status: "rejected",
rejectReason: (data && data.moderation_comment) || rejectReason,
moderationResult: data,
});
console.log("[MODERATION] REJECT success", {
requestId: request.id,
newStatus: "rejected",
});
setShowRejectPopup(false);
setSubmitting(false);
onClose();
} catch (e) {
console.log("[MODERATION] REJECT exception", e);
setError(e.message || "Ошибка сети");
setSubmitting(false);
}
};
return (
<>
<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">
<button
type="button"
onClick={onClose}
className="text-white w-8 h-8 rounded-full flex items-center justify-center text-xl"
>
</button>
<p className="flex-1 text-center font-montserrat font-extrabold text-[20px] leading-[24px] text-white">
Заявка от {createdDate || "—"}
</p>
<span className="w-8" />
</div>
<div className="flex-1 flex items-start justify-center">
<div className="w-full max-w-[400px] bg-white rounded-2xl p-4 flex flex-col gap-4 shadow-lg">
<div className="flex flex-col gap-2">
<div className="w-full bg-[#72B8E2] rounded-[10px] px-3 py-2 flex items-center justify-between">
<span className="text-[14px] font-montserrat font-bold text-white">
Описание
</span>
<div className="flex gap-2">
<div className="min-w-[80px] bg-[#72B8E2] rounded-[10px] flex flex-col items-center justify-center border border-white/30 px-2 py-1">
<span className="text-[12px] font-montserrat font-bold text-white">
Дата
</span>
<span className="text-[10px] font-montserrat text-white">
{createdDate || "—"}
</span>
</div>
<div className="min-w-[80px] bg-[#72B8E2] rounded-[10px] flex flex-col items-center justify-center border border-white/30 px-2 py-1">
<span className="text-[12px] font-montserrat font-bold text-white">
Время
</span>
<span className="text-[10px] font-montserrat text-white">
{createdTime || "—"}
</span>
</div>
</div>
</div>
</div>
<div className="w-full bg-[#72B8E2] rounded-[10px] px-3 py-2 flex flex-col gap-1">
<span className="text-[14px] font-montserrat font-bold text-white">
ФИО
</span>
<p className="text-[12px] font-montserrat text-white leading-[16px]">
{request.requesterName || "Заявитель"}
</p>
<p className="text-[12px] font-montserrat text-white leading-[14px]">
{request.address
? `${request.city ? request.city + ", " : ""}${request.address}`
: "Адрес не указан"}
</p>
</div>
<div className="flex items-center justify-between">
<div
className={`px-3 py-1 rounded-[10px] flex items-center justify-center ${isApproved
? "bg-[#94E067]"
: isRejected
? "bg-[#E06767]"
: "bg-[#E9D171]"
}`}
>
<span className="text-[12px] font-montserrat font-semibold text-white">
{isApproved
? "Принята"
: isRejected
? "Отклонена"
: "Модерация"}
</span>
</div>
<div className="flex flex-col items-end text-[12px] font-montserrat font-light text-black leading-[14px]">
<span>До {deadlineDate || "—"}</span>
<span>{deadlineTime || "—"}</span>
</div>
</div>
<p className="text-[16px] leading-[20px] font-montserrat font-semibold text-black">
{request.title || "Задача"}
</p>
{request.description && (
<div className="flex-1 bg-[#F2F2F2] rounded-[10px] px-3 py-2 overflow-y-auto">
<p className="text-[12px] leading-[16px] font-montserrat text-black whitespace-pre-line">
{request.description}
</p>
</div>
)}
{isRejected && (
<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>
<p className="text-[12px] font-montserrat text-black whitespace-pre-line">
{(request.rejectReason && request.rejectReason.trim().length > 0
? request.rejectReason
: request.moderation_comment) || "Причина не указана"}
</p>
</div>
)}
{error && (
<p className="text-[12px] font-montserrat text-red-500">
{error}
</p>
)}
</div>
</div>
{/* Кнопки показываем только для pending_moderation */}
{isPending && (
<div className="mt-4 w-full max-w-[400px] mx-auto flex items-center justify-between gap-3">
<button
type="button"
onClick={handleApprove}
disabled={submitting}
className="flex-1 h-10 bg-[#94E067] rounded-[10px] flex items-center justify-center disabled:opacity-60"
>
<span className="text-[14px] font-montserrat font-bold text-white">
{submitting ? "Сохранение..." : "Принять"}
</span>
</button>
<button
type="button"
onClick={() => setShowRejectPopup(true)}
disabled={submitting}
className="flex-1 h-10 bg-[#E06767] rounded-[10px] flex items-center justify-center disabled:opacity-60"
>
<span className="text-[14px] font-montserrat font-bold text-white">
Отклонить
</span>
</button>
</div>
)}
</div>
{showRejectPopup && (
<div className="fixed inset-0 z-50 flex flex-col bg-[rgba(101,101,101,0.72)] px-4 pt-8 pb-6">
<div className="w-full max-w-[400px] mx-auto bg-white rounded-t-[15px] flex flex-col items-center px-4 pt-4 pb-4">
<p className="font-montserrat font-bold text-[20px] leading-[24px] text-black mb-3">
Причина
</p>
<div className="w-full bg-[#72B8E2] rounded-[10px] px-3 py-3 mb-4 max-h-[50vh]">
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
className="w-full h-full bg-transparent text-[14px] leading-[18px] font-montserrat text-black placeholder:text-black/60 outline-none resize-none"
placeholder="Опишите причину отклонения заявки"
/>
</div>
<div className="w-full flex flex-col gap-2">
<button
type="button"
onClick={handleRejectConfirm}
disabled={submitting}
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">
{submitting ? "Сохранение..." : "Подтвердить"}
</span>
</button>
<button
type="button"
onClick={() => setShowRejectPopup(false)}
disabled={submitting}
className="w-full h-10 bg-white rounded-[10px] border border-[#E06767] flex items-center justify-center"
>
<span className="text-[14px] font-montserrat font-semibold text-[#E06767]">
Отмена
</span>
</button>
</div>
</div>
</div>
)}
</>
);
};
export default ModeratorRequestModal;

View File

@@ -1,161 +1,559 @@
import React, { useState } from "react"; "use client";
import React, { useState, useEffect } from "react";
import { FaStar } from "react-icons/fa"; import { FaStar } from "react-icons/fa";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
const RequestDetailsModal = ({ request, onClose }) => { const RequestDetailsModal = ({ request, onClose }) => {
const isDone = request.status === "Выполнена"; const [details, setDetails] = useState(null);
const isRejected = request.status === "Отклонена"; const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [rating, setRating] = useState(0); const [locallyCompleted, setLocallyCompleted] = useState(
const [review, setReview] = useState(""); request.status === "Выполнена"
const [rejectFeedback, setRejectFeedback] = useState(""); );
const isRejected = request.status === "Отклонена";
const isDone = request.status === "Выполнена" || locallyCompleted;
const canComplete = !isRejected && !isDone;
const handleStarClick = (value) => { const [rating, setRating] = useState(0);
setRating(value); const [review, setReview] = useState("");
}; const [rejectFeedback, setRejectFeedback] = useState("");
const handleSubmit = () => { const [responses, setResponses] = useState([]);
console.log("Оставить отзыв:", { const [responsesLoading, setResponsesLoading] = useState(true);
id: request.id, const [responsesError, setResponsesError] = useState("");
status: request.status,
rating, const [acceptLoading, setAcceptLoading] = useState(false);
review, const [acceptError, setAcceptError] = useState("");
rejectFeedback, const [acceptSuccess, setAcceptSuccess] = useState("");
const [completeLoading, setCompleteLoading] = useState(false);
const [completeError, setCompleteError] = useState("");
const [completeSuccess, setCompleteSuccess] = 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 fetchDetails = async () => {
if (!API_BASE) {
setLoadError("API_BASE_URL не задан");
setLoading(false);
return;
}
const accessToken = getAccessToken();
if (!accessToken) {
setLoadError("Вы не авторизованы");
setLoading(false);
return;
}
try {
const res = await fetch(`${API_BASE}/requests/${request.id}`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
}); });
onClose();
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;
}
setLoadError(msg);
setLoading(false);
return;
}
const data = await res.json();
setDetails(data);
setLoading(false);
} catch (e) {
setLoadError(e.message || "Ошибка сети");
setLoading(false);
}
}; };
return ( fetchDetails();
<div className="fixed inset-0 z-40 flex flex-col bg-[#90D2F9] px-4 pt-4 pb-20"> }, [request.id]);
{/* Заголовок */}
<div className="flex items-center gap-2 mb-3"> useEffect(() => {
<button const fetchResponses = async () => {
type="button" if (!API_BASE) {
onClick={onClose} setResponsesError("API_BASE_URL не задан");
className="text-white w-7 h-7 rounded-full flex items-center justify-center text-lg" setResponsesLoading(false);
> return;
}
</button>
<p className="flex-1 text-center font-montserrat font-extrabold text-[20px] leading-[24px] text-white"> const accessToken = getAccessToken();
Заявка от {request.createdAt} if (!accessToken) {
</p> setResponsesError("Вы не авторизованы");
<span className="w-7" /> setResponsesLoading(false);
return;
}
try {
const res = await fetch(
`${API_BASE}/requests/${request.id}/responses`,
{
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;
}
setResponsesError(msg);
setResponsesLoading(false);
return;
}
const data = await res.json();
setResponses(data);
setResponsesLoading(false);
} catch (e) {
setResponsesError(e.message || "Ошибка сети");
setResponsesLoading(false);
}
};
fetchResponses();
}, [request.id]);
const handleStarClick = (value) => {
setRating(value);
};
// ЕДИНСТВЕННЫЙ вызов /complete: завершение + отзыв
const handleCompleteRequest = async () => {
if (!API_BASE) {
setCompleteError("API_BASE_URL не задан");
return;
}
const accessToken = getAccessToken();
if (!accessToken) {
setCompleteError("Вы не авторизованы");
return;
}
if (!canComplete) return;
if (!rating) {
setCompleteError("Поставьте оценку от 1 до 5");
return;
}
try {
setCompleteLoading(true);
setCompleteError("");
setCompleteSuccess("");
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;
}
setCompleteError(msg);
setCompleteLoading(false);
return;
}
setLocallyCompleted(true);
setCompleteSuccess("Заявка завершена и отзыв отправлен");
setCompleteLoading(false);
} catch (e) {
setCompleteError(e.message || "Ошибка сети");
setCompleteLoading(false);
}
};
const handleAcceptResponse = async (responseId) => {
if (!API_BASE || !request.id || !responseId) {
setAcceptError("Некорректные данные для приёма отклика");
return;
}
const accessToken = getAccessToken();
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();
setAcceptSuccess("Волонтёр принят на заявку");
setAcceptLoading(false);
} catch (e) {
setAcceptError(e.message || "Ошибка сети");
setAcceptLoading(false);
}
};
const fullDescription =
details?.description || request.description || "Описание отсутствует";
const addressLine = details
? [details.address, details.city].filter(Boolean).join(", ")
: null;
const requesterName = details?.requester?.first_name
? `${details.requester.first_name} ${details.requester.last_name || ""}`.trim()
: details?.requester?.email;
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="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"
>
</button>
<p className="flex-1 text-center font-montserrat font-extrabold text-[20px] leading-[24px] text-white">
Заявка от {request.createdAt}
</p>
<span className="w-7" />
</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 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: request.statusColor }}
>
{isDone ? "Выполнена" : request.status}
</span>
<div className="text-right leading-tight">
<p className="font-montserrat text-[10px] text-black">
{request.date}
</p>
<p className="font-montserrat text-[10px] text-black">
{request.time}
</p>
</div> </div>
</div>
{/* Белая карточка как на макете */} {/* Название */}
<div className="flex-1 flex items-start justify-center"> <p className="font-montserrat font-semibold text-[16px] leading-[20px] text-black">
<div className="w-full max-w-[360px] bg-white rounded-2xl p-4 flex flex-col gap-4 shadow-lg"> {request.title}
{/* Статус + срок (берём цвет и текст из заявки) */} </p>
<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: request.statusColor }}
>
{request.status}
</span>
<div className="text-right leading-tight">
<p className="font-montserrat text-[10px] text-black">
{request.date}
</p>
<p className="font-montserrat text-[10px] text-black">
{request.time}
</p>
</div>
</div>
{/* Название задачи */} {/* Инфо по заявке */}
<p className="font-montserrat font-semibold text-[16px] leading-[20px] text.black"> <div className="bg-[#F2F2F2] rounded-2xl px-3 py-2 flex flex-col gap-1 max-h-[40vh] overflow-y-auto">
{request.title} {loading && (
</p> <p className="font-montserrat text-[12px] text.black">
Загрузка информации о заявке...
{/* ВЫПОЛНЕНА: голубой блок с отзывом как было */} </p>
{isDone && (
<div className="bg-[#72B8E2] rounded-3xl p-3 flex flex-col gap-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"
placeholder="Напишите, как прошла помощь"
/>
</div>
)}
{/* ОТКЛОНЕНА: причина отказа + комментарий, без изменения размеров */}
{isRejected && (
<>
{request.rejectReason && (
<div className="bg-[#FF8282] rounded-2xl p-3">
<p className="font-montserrat font-bold text-[12px] text-white mb-1">
Причина отказа
</p>
<p className="font-montserrat text-[12px] text-white">
{request.rejectReason}
</p>
</div>
)}
<div className="flex flex-col gap-1">
<p className="font-montserrat font-bold text-[12px] text-black">
Ваш комментарий
</p>
<textarea
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]"
placeholder="Расскажите, что можно улучшить"
/>
</div>
</>
)}
{/* Оценка волонтёра — только для выполненной */}
{/* Оценка волонтера */}
{isDone && (
<div className="mt-1 flex flex-col items-center gap-2">
<p className="font-montserrat font-semibold text-[14px] text-black">
Оценить волонтера
</p>
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => handleStarClick(star)}
className="text-[#F6E168]"
>
<FaStar
size={26}
className={
star <= rating ? "fill-[#F6E168]" : "fill-[#F6E168]/40"
}
/>
</button>
))}
</div>
</div>
)}
</div>
</div>
{/* Кнопка внизу */}
{(isDone || isRejected) && (
<button
type="button"
onClick={handleSubmit}
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">
{isRejected ? "Отправить комментарий" : "Оставить отзыв"}
</span>
</button>
)} )}
{loadError && !loading && (
<p className="font-montserrat text-[12px] text-red-600">
{loadError}
</p>
)}
{!loading && !loadError && (
<>
{requestTypeName && (
<p className="font-montserrat text-[12px] text-black">
<span className="font-semibold">Тип:</span> {requestTypeName}
</p>
)}
{addressLine && (
<p className="font-montserrat text-[12px] text-black">
<span className="font-semibold">Адрес:</span> {addressLine}
</p>
)}
{details?.urgency && (
<p className="font-montserrat text-[12px] text-black">
<span className="font-semibold">Срочность:</span>{" "}
{details.urgency}
</p>
)}
{requesterName && (
<p className="font-montserrat text-[12px] text-black">
<span className="font-semibold">Заявитель:</span>{" "}
{requesterName}
</p>
)}
{details?.contact_phone && (
<p className="font-montserrat text-[12px] text-black">
<span className="font-semibold">Телефон:</span>{" "}
{details.contact_phone}
</p>
)}
{details?.contact_notes && (
<p className="font-montserrat text-[12px] text-black">
<span className="font-semibold">Комментарий к контакту:</span>{" "}
{details.contact_notes}
</p>
)}
<p className="font-montserrat text-[12px] text-black mt-1 whitespace-pre-line">
<span className="font-semibold">Описание:</span>{" "}
{fullDescription}
</p>
</>
)}
</div>
{/* Отклики волонтёров (как было) */}
{!responsesLoading && !responsesError && 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>
</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>
)}
{responsesLoading && !responsesError && (
<p className="mt-2 text-[11px] font-montserrat text-black">
Загрузка откликов волонтёров...
</p>
)}
{responsesError && (
<p className="mt-2 text-[11px] font-montserrat text-red-500">
{responsesError}
</p>
)}
{/* Отклонена */}
{isRejected && (
<>
{request.rejectReason && (
<div className="bg-[#FF8282] rounded-2xl p-3 mt-2">
<p className="font-montserrat font-bold text-[12px] text-white mb-1">
Причина отказа
</p>
<p className="font-montserrat text-[12px] text-white">
{request.rejectReason}
</p>
</div>
)}
<div className="flex flex-col gap-1 mt-2">
<p className="font-montserrat font-bold text-[12px] text-black">
Ваш комментарий
</p>
<textarea
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]"
placeholder="Расскажите, что можно улучшить"
/>
</div>
</>
)}
{/* Отзыв и рейтинг — до завершения */}
{!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">
Отзыв о волонтёре
</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"
placeholder="Напишите, как прошла помощь"
/>
</div>
<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">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => handleStarClick(star)}
className="text-[#F6E168]"
>
<FaStar
size={26}
className={
star <= rating ? "fill-[#F6E168]" : "fill-[#F6E168]/40"
}
/>
</button>
))}
</div>
</div>
</>
)}
{completeError && (
<p className="mt-1 text-[11px] font-montserrat text-red-500">
{completeError}
</p>
)}
{completeSuccess && (
<p className="mt-1 text-[11px] font-montserrat text-green-600">
{completeSuccess}
</p>
)}
</div> </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"
>
<span className="font-montserrat font-extrabold text-[16px] text-white">
{completeLoading ? "Отправка..." : "Завершить заявку с отзывом"}
</span>
</button>
)}
{(isDone || 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 text-[16px] text-white">
Закрыть
</span>
</button>
)}
</div>
);
}; };
export default RequestDetailsModal; export default RequestDetailsModal;

View File

@@ -2,41 +2,59 @@
import React from "react"; import React from "react";
import { FaClock, FaNewspaper, FaHome, FaCog } from "react-icons/fa"; import { FaClock, FaNewspaper, FaHome, FaCog } from "react-icons/fa";
import { useRouter } from "next/navigation"; import { useRouter, usePathname } from "next/navigation";
import { useAuth } from "../context/AuthContext";
const TabBar = () => { const TabBar = () => {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const { user } = useAuth();
if (!user) return null; // не показываем таббар, если не залогинен
// маршруты по ролям
const routesByRole = {
requester: [
{ key: "home", icon: FaHome, href: "/createRequest" },
{ key: "history", icon: FaClock, href: "/historyRequest" },
{ 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: "/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: "/notification" },
{ key: "profile", icon: FaCog, href: "/moderatorProfilePage" },
],
};
const tabs = routesByRole[user.role] || [];
return ( return (
<nav className="fixed bottom-0 left-0 right-0 flex justify-center"> <nav className="fixed bottom-0 left-0 right-0 flex justify-center">
<div className="w-full max-w-md bg-white rounded-t-xl flex items-center justify-around py-4 shadow-inner"> <div className="w-full max-w-md bg-white rounded-t-xl flex items-center justify-around py-4 shadow-inner">
<button {tabs.map((tab) => {
type="button" const Icon = tab.icon;
onClick={() => router.push("/createRequest")} const active = pathname === tab.href;
className="flex flex-col items-center gap-1 text-[#90D2F9]" return (
> <button
<FaHome size={20} /> key={tab.key}
</button> type="button"
<button onClick={() => router.push(tab.href)}
type="button" className={`flex flex-col items-center gap-1 ${
onClick={() => router.push("/historyRequest")} active ? "text-[#90D2F9]" : "text-gray-400"
className="flex flex-col items-center gap-1 text-[#90D2F9]" }`}
> >
<FaClock size={20} /> <Icon size={20} />
</button> </button>
<button );
type="button" })}
className="flex flex-col items-center gap-1 text-[#90D2F9]"
>
<FaNewspaper size={20} />
</button>
<button
type="button"
onClick={() => router.push("/ProfilePage")}
className="flex flex-col items-center gap-1 text-[#90D2F9]"
>
<FaCog size={20} />
</button>
</div> </div>
</nav> </nav>
); );

View File

@@ -0,0 +1,254 @@
"use client";
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("");
// статус отклика (из списка /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;
const saved = localStorage.getItem("authUser");
const authUser = saved ? JSON.parse(saved) : null;
return authUser?.accessToken || null;
};
useEffect(() => {
const fetchDetails = async () => {
if (!API_BASE) {
setLoadError("API_BASE_URL не задан");
setLoading(false);
return;
}
const token = getAccessToken();
if (!token) {
setLoadError("Вы не авторизованы");
setLoading(false);
return;
}
try {
const res = await fetch(
`${API_BASE}/requests/${request.requestId || request.request_id || request.id}`,
{
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
}
);
const text = await res.text();
let data = null;
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;
}
setLoadError(msg);
setLoading(false);
return;
}
setDetails(data);
setLoading(false);
} catch (e) {
setLoadError(e.message || "Ошибка сети");
setLoading(false);
}
};
fetchDetails();
}, [request.requestId, request.request_id, request.id]);
if (loading) {
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-[#90D2F9]/80">
<p className="text-white font-montserrat text-sm">Загрузка заявки...</p>
</div>
);
}
if (loadError || !details) {
return (
<div className="fixed inset-0 z-40 flex flex-col items-center justify-center bg-[#90D2F9]/80 px-4">
<p className="text-white font-montserrat text-sm mb-3">
{loadError || "Заявка не найдена"}
</p>
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-white rounded-xl font-montserrat text-sm"
>
Закрыть
</button>
</div>
);
}
// Тип заявки из RequestDetail
const requestTypeName = details.request_type?.name || "Не указан";
const urgencyText = (() => {
switch (details.urgency) {
case "low":
return "Низкая";
case "medium":
return "Средняя";
case "high":
return "Высокая";
case "urgent":
return "Срочно";
default:
return null;
}
})();
const place = [details.address, details.city].filter(Boolean).join(", ");
const requesterName =
(details.requester &&
[details.requester.first_name, details.requester.last_name]
.filter(Boolean)
.join(" ")
.trim()) ||
details.requester?.email ||
"Заявитель";
const created = details.created_at ? new Date(details.created_at) : null;
const createdDate = created ? created.toLocaleDateString("ru-RU") : "";
const createdTime = created
? created.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
})
: "";
let deadlineText = "Не указано";
if (details.desired_completion_date) {
const d = new Date(details.desired_completion_date);
if (!Number.isNaN(d.getTime())) {
deadlineText = d.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
}
return (
<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">
<button
type="button"
onClick={onClose}
className="text-white w-7 h-7 rounded-full flex items-center justify-center text-lg"
>
</button>
<p className="flex-1 text-center font-montserrat font-extrabold text-[20px] leading-[24px] text-white">
Заявка от {createdDate}
</p>
<span className="w-7" />
</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 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: responseStatus.color }}
>
{responseStatus.label}
</span>
<div className="text-right leading-tight">
<p className="font-montserrat text-[10px] text-black">
{createdDate}
</p>
<p className="font-montserrat text-[10px] text-black">
{createdTime}
</p>
</div>
</div>
{/* Название задачи */}
<p className="font-montserrat font-semibold text-[16px] leading-[20px] text-black">
{details.title}
</p>
{/* Полная информация о заявке */}
<div className="flex flex-col gap-1 text-[12px] font-montserrat text-black">
<p>Тип: {requestTypeName}</p>
<p>Заявитель: {requesterName}</p>
<p>Адрес: {place || "Не указан"}</p>
{urgencyText && <p>Срочность: {urgencyText}</p>}
{details.contact_phone && <p>Телефон: {details.contact_phone}</p>}
{details.contact_notes && (
<p>Комментарий к контакту: {details.contact_notes}</p>
)}
<p>Выполнить до: {deadlineText}</p>
</div>
{/* Описание */}
{details.description && (
<div className="bg-[#E4E4E4] rounded-2xl px-3 py-2 max-h-[160px] overflow-y-auto">
<p className="text-[11px] leading-[13px] font-montserrat whitespace-pre-line">
{details.description}
</p>
</div>
)}
{/* Блок для принятых откликов */}
{(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">
Вы назначены волонтёром
</p>
<p className="font-montserrat text-[11px] text-black">
Контакты заявителя: {details.contact_phone || "не указаны"}
</p>
</div>
)}
</div>
</div>
</div>
);
};
export default VolunteerRequestDetailsModal;

View File

@@ -1,21 +1,132 @@
"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 }) => { 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 description =
request.description ||
"Описание недоступно. Откройте заявку для подробностей.";
const baseAddress = request.address || "Адрес не указан";
const city = request.city ? `, ${request.city}` : "";
const place = `${baseAddress}${city}`;
let deadline = "Не указано";
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",
month: "2-digit",
});
const timePart = d.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
deadline = `${datePart}, ${timePart}`;
}
}
const phone = request.contact_phone || request.phone;
const contactNotes = request.contact_notes || request.contactNotes;
const urgencyText = (() => {
switch (request.urgency) {
case "low":
return "Низкая";
case "medium":
return "Средняя";
case "high":
return "Высокая";
case "urgent":
return "Срочно";
default:
return null;
}
})();
const getAccessToken = () => {
if (typeof window === "undefined") return null;
const saved = localStorage.getItem("authUser");
const authUser = saved ? JSON.parse(saved) : null;
return authUser?.accessToken || null;
};
// тут полностью логика отклика
const 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"
@@ -30,68 +141,71 @@ const AcceptPopup = ({ request, isOpen, onClose, onAccept }) => {
Задача Задача
</h2> </h2>
<p className="text-[20px] leading-[14px] mt-5 font-montserrat mb-5"> <p className="text-[20px] leading-[14px] mt-5 font-montserrat mb-5">
{request.title} {title}
</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 className="text-[15px] leading-[13px] text-white font-semibold">
{request.amount || "2000 ₽"}
</span>
</div>
<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> </span>
<span className="text-[15px] leading-[13px] text-white font-semibold"> <span className="text-[15px] leading-[13px] text-white font-semibold">
{request.deadline || "17:00"} {deadline}
</span> </span>
</div> </div>
</div> </div>
{/* Список покупок / описание */} {/* Описание + доп.инфа */}
<div className="w-full bg-[#E4E4E4] rounded-[20px] px-3 py-3 mb-3 max-h-[40vh] overflow-y-auto"> <div className="w-full bg-[#E4E4E4] rounded-[20px] px-3 py-3 mb-3 max-h-[40vh] overflow-y-auto">
<p className="text-[15px] leading-[20px] font-montserrat text-black whitespace-pre-line"> <p className="text-[15px] leading-[20px] font-montserrat text-black whitespace-pre-line">
{request.description || {description}
"Необходимо приобрести:\n1. Белый хлеб\n2. Молоко\n3. Колбаса\n4. Фрукты"}
</p> </p>
{urgencyText && (
<p className="mt-2 text-[12px] leading-[16px] font-montserrat text-black">
<span className="font-semibold">Срочность: </span>
{urgencyText}
</p>
)}
{phone && (
<p className="text-[12px] leading-[16px] font-montserrat text-black">
<span className="font-semibold">Телефон: </span>
{phone}
</p>
)}
{contactNotes && (
<p className="text-[12px] leading-[16px] font-montserrat text-black">
<span className="font-semibold">Комментарий к контакту: </span>
{contactNotes}
</p>
)}
{error && (
<p className="mt-2 text-[12px] leading-[16px] font-montserrat text-red-500">
{error}
</p>
)}
</div> </div>
{/* Данные человека */} {/* Данные места */}
<div className="w-full flex flex-col gap-3 mb-4"> <div className="w-full flex flex-col gap-3 mb-4">
<p className="font-montserrat text-[20px] leading-[19px] font-medium"> <p className="font-montserrat text-[20px] leading-[19px] font-medium">
Данные: Данные:
</p> </p>
<p className="text-[20px] leading-[12px] font-montserrat">
ФИО: {request.fullName || "Клавдия Березова"}
</p>
<p className="text-[15px] leading-[12px] font-montserrat"> <p className="text-[15px] leading-[12px] font-montserrat">
Место: {request.address} Место: {place}
</p> </p>
{request.flat && (
<p className="text-[10px] leading-[12px] font-montserrat">
кв: {request.flat}
</p>
)}
{request.floor && (
<p className="text-[10px] leading-[12px] font-montserrat">
Этаж: {request.floor}
</p>
)}
</div> </div>
{/* Кнопка отклика внизу */} {/* Кнопка отклика внизу */}
<button <button
type="button" type="button"
onClick={() => onAccept(request)} onClick={handleClick}
className="mt-auto w-full h-[40px] bg-[#94E067] rounded-[10px] flex items-center justify-center" disabled={loading}
className="mt-auto w-full h-[40px] bg-[#94E067] rounded-[10px] flex items-center justify-center disabled:opacity-60"
> >
<span className="font-montserrat font-bold text-[16px] leading-[19px] text-white"> <span className="font-montserrat font-bold text-[16px] leading-[19px] text-white">
Откликнуться {loading ? "Отправка..." : "Откликнуться"}
</span> </span>
</button> </button>
</div> </div>

151
app/context/AuthContext.jsx Normal file
View File

@@ -0,0 +1,151 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
import { useRouter } from "next/navigation";
const AuthContext = createContext(null);
// базовый URL из YAML (у себя можешь вынести в .env)
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null); // {id, email, role, name, accessToken, refreshToken}
const [loading, setLoading] = useState(true);
const router = useRouter();
// поднимаем пользователя из localStorage
useEffect(() => {
const saved =
typeof window !== "undefined"
? localStorage.getItem("authUser")
: null;
if (saved) {
setUser(JSON.parse(saved));
}
setLoading(false);
}, []);
// основная авторизация: запрос на /auth/login
const login = async (email, password) => {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ email, password }),
});
// удобно смотреть в Postman: этот же URL, метод, тело из JSON[file:519]
// в Postman просто скопируй URL и тело — увидишь точный JSON-ответ
if (!res.ok) {
// читаем тело как текст, чтобы в консоли / Postman было понятно
let errorMessage = "Неверный логин или пароль";
try {
const data = await res.json();
if (data.error) {
errorMessage = data.error;
}
} catch {
const text = await res.text();
if (text) errorMessage = text;
}
throw new Error(errorMessage);
}
const data = await res.json();
// ожидаемый формат по YAML: AuthResponse[file:519]
// Примерно:
// {
// "access_token": "...",
// "refresh_token": "...",
// "token_type": "bearer",
// "user": { "id": 1, "email": "...", ... }
// }
const authUser = {
id: data.user?.id,
email: data.user?.email,
name: data.user?.first_name || data.user?.email,
// роль пока не знаем наверняка — вытащим отдельным запросом
role: null,
accessToken: data.access_token,
refreshToken: data.refresh_token,
};
// 1) сохраняем токены/пользователя
setUser(authUser);
localStorage.setItem("authUser", JSON.stringify(authUser));
// 2) тянем роли пользователя (GET /users/me/roles)[file:519]
try {
const rolesRes = await fetch(`${API_BASE}/users/me/roles`, {
method: "GET",
headers: {
Authorization: `Bearer ${data.access_token}`,
Accept: "application/json",
},
});
if (rolesRes.ok) {
const roles = await rolesRes.json(); // массив объектов Role[file:519]
// ищем первую подходящую роль
const roleNames = roles.map((r) => r.name);
let appRole = null;
if (roleNames.includes("requester")) appRole = "requester";
if (roleNames.includes("volunteer")) appRole = "volunteer";
if (roleNames.includes("moderator")) appRole = "moderator";
if (roleNames.includes("admin")) appRole = "moderator"; // можно перекинуть в модераторский интерфейс
const updatedUser = { ...authUser, role: appRole };
setUser(updatedUser);
localStorage.setItem("authUser", JSON.stringify(updatedUser));
// 3) редирект по роли (как у тебя было)
if (appRole === "requester") router.push("/home");
else if (appRole === "volunteer") router.push("/mainValounter");
else if (appRole === "moderator") router.push("/moderatorMain");
else router.push("/home"); // запасной вариант
} else {
// если роли не достали, всё равно пускаем как обычного пользователя
router.push("/home");
}
} catch (e) {
console.error("Ошибка получения ролей:", e);
router.push("/home");
}
};
const logout = async () => {
try {
if (user?.accessToken) {
await fetch(`${API_BASE}/auth/logout`, {
method: "POST",
headers: {
Authorization: `Bearer ${user.accessToken}`,
Accept: "application/json",
},
});
}
} catch (e) {
console.error("Ошибка logout:", e);
}
setUser(null);
localStorage.removeItem("authUser");
router.push("/login");
};
const value = {
user,
loading,
isAuthenticated: !!user,
login,
logout,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => useContext(AuthContext);

View File

@@ -1,38 +1,194 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { FaClock, FaNewspaper, FaHome, FaCog, FaBell, FaUser } from "react-icons/fa"; import { FaBell, FaUser } 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 CreateRequestPage = () => { const CreateRequestPage = () => {
const router = useRouter(); const router = useRouter();
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [date, setDate] = useState(""); const [date, setDate] = useState(""); // desired_completion_date
const [time, setTime] = useState(""); const [time, setTime] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [note, setNote] = useState(""); const [note, setNote] = useState(""); // contact_notes
const isFormValid = title && date && time && description; const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [phone, setPhone] = useState("");
const [urgency, setUrgency] = useState("medium");
const [latitude, setLatitude] = useState("");
const [longitude, setLongitude] = useState("");
const [geoError, setGeoError] = useState("");
const handleSubmit = (e) => { const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [userName, setUserName] = useState("Пользователь");
const [profileError, setProfileError] = useState("");
const isFormValid =
title &&
date &&
time &&
description &&
address &&
city &&
urgency &&
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 accessToken = getAccessToken();
if (!accessToken) return;
try {
const res = await fetch(`${API_BASE}/users/me`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) {
setProfileError("Не удалось загрузить профиль");
return;
}
const data = await res.json(); // UserProfile
const fullName =
[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 (typeof navigator === "undefined" || !("geolocation" in navigator)) {
setGeoError("Геолокация не поддерживается браузером");
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude: lat, longitude: lon } = pos.coords;
setLatitude(lat.toFixed(6));
setLongitude(lon.toFixed(6));
setGeoError("");
},
(err) => {
console.error("Geolocation error:", err);
setGeoError("Не удалось получить геолокацию, введите координаты вручную");
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 60000,
}
);
}, []);
const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
if (!isFormValid) return; if (!isFormValid || !API_BASE) return;
console.log({ try {
title, setError("");
date, setIsSubmitting(true);
time,
description, const accessToken = getAccessToken();
note, if (!accessToken) {
}); setError("Вы не авторизованы");
// TODO: запрос на бэк setIsSubmitting(false);
return;
}
const desiredDateTime = new Date(`${date}T${time}:00`);
const desired_completion_date = desiredDateTime.toISOString();
const body = {
request_type_id: 1, // TODO: вынести в селект типов
title,
description,
latitude: Number(latitude),
longitude: Number(longitude),
address,
city,
desired_completion_date,
urgency, // low | medium | high | urgent
contact_phone: phone || null,
contact_notes: note || null,
};
const res = await fetch(`${API_BASE}/requests`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
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);
setIsSubmitting(false);
return;
}
const created = await res.json();
console.log("Заявка создана:", created);
router.push("/home");
} catch (err) {
setError(err.message || "Ошибка сети");
setIsSubmitting(false);
}
}; };
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">
{/* 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">
@@ -41,8 +197,13 @@ const CreateRequestPage = () => {
</div> </div>
<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">
Александр {userName}
</p> </p>
{profileError && (
<p className="text-[10px] text-red-200 font-montserrat mt-1">
{profileError}
</p>
)}
</div> </div>
</div> </div>
<button <button
@@ -57,12 +218,19 @@ const CreateRequestPage = () => {
Создать заявку Создать заявку
</h1> </h1>
{/* Ошибка */}
{error && (
<div className="mb-2 bg-red-500 text-white text-xs font-montserrat px-3 py-2 rounded-lg">
{error}
</div>
)}
{/* Карточка с формой */} {/* Карточка с формой */}
<main className="bg-white rounded-xl p-4 flex flex-col gap-3"> <main className="bg-white rounded-xl p-4 flex flex-col gap-3">
<form onSubmit={handleSubmit} className="flex flex-col"> <form onSubmit={handleSubmit} className="flex flex-col">
{/* Что сделать */} {/* Что сделать */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="font-montserrat font-bold text-[10px] text-white/90"> <label className="font-montserrat font-bold text-[10px] text-white/90">
Что сделать Что сделать
</label> </label>
<input <input
@@ -74,8 +242,72 @@ const CreateRequestPage = () => {
/> />
</div> </div>
{/* Дата и Время */} {/* Адрес */}
<div className="flex flex-col gap-1 mt-2">
<label className="font-montserrat font-bold text-[10px] text-white/90">
Адрес
</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(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"
placeholder="ул. Ленина, д. 10, кв. 5"
/>
</div>
{/* Город */}
<div className="flex flex-col gap-1">
<label className="font-montserrat font-bold text-[10px] text-white/90">
Город
</label>
<input
type="text"
value={city}
onChange={(e) => setCity(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"
placeholder="Например: Пермь"
/>
</div>
{/* Координаты */}
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex-1 flex flex-col gap-1">
<label className="font-montserrat font-bold text-[10px] text-white/90">
Широта (lat)
</label>
<input
type="number"
step="0.000001"
value={latitude}
onChange={(e) => setLatitude(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"
placeholder="55.751244"
/>
</div>
<div className="flex-1 flex flex-col gap-1">
<label className="font-montserrat font-bold text-[10px] text-white/90">
Долгота (lon)
</label>
<input
type="number"
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-montserrat text-white placeholder:text-white/70 outline-none focus:ring-2 focus:ring-blue-200"
placeholder="37.618423"
/>
</div>
</div>
{geoError && (
<p className="mt-1 text-[10px] text-yellow-200 font-montserrat">
{geoError}
</p>
)}
{/* Дата и Время */}
<div className="flex gap-3 mt-2">
<div className="flex-1 flex flex-col gap-1"> <div className="flex-1 flex flex-col gap-1">
<label className="font-montserrat font-bold text-[10px] text-white/90"> <label className="font-montserrat font-bold text-[10px] text-white/90">
Дата Дата
@@ -100,9 +332,40 @@ const CreateRequestPage = () => {
</div> </div>
</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 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"
>
<option value="low">Низкая</option>
<option value="medium">Средняя</option>
<option value="high">Высокая</option>
<option value="urgent">Срочно</option>
</select>
</div>
{/* Телефон для связи */}
<div className="flex flex-col gap-1 mt-2">
<label className="font-montserrat font-bold text-[10px] text-white/90">
Телефон для связи
</label>
<input
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"
placeholder="+7 900 000 00 00"
/>
</div>
{/* Описание */}
<div className="flex flex-col gap-1 mt-2">
<label className="font-montserrat font-bold text-[10px] text-white">
Описание Описание
</label> </label>
<textarea <textarea
@@ -114,8 +377,8 @@ const CreateRequestPage = () => {
/> />
</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 className="font-montserrat font-bold text-[10px] text-white/90">
Дополнительно Дополнительно
</label> </label>
@@ -128,40 +391,21 @@ const CreateRequestPage = () => {
/> />
</div> </div>
{/* Добавить фото */}
<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 <button
type="submit" type="submit"
disabled={!isFormValid} disabled={!isFormValid || isSubmitting}
className={`mt-5 w-full rounded-lg py-3 text-center font-montserrat font-bold text-sm transition-colors className={`mt-5 w-full rounded-lg py-3 text-center font-montserrat font-bold text-sm transition-colors ${
${ isFormValid && !isSubmitting
isFormValid ? "bg-[#94E067] text-white hover:bg-green-600"
? "bg-[#94E067] text-white hover:bg-green-600" : "bg-[#94E067]/60 text-white/70 cursor-not-allowed"
: "bg-[#94E067]/60 text-white/70 cursor-not-allowed" }`}
}`}
> >
Отправить {isSubmitting ? "Отправка..." : "Отправить"}
</button> </button>
</form> </form>
</main> </main>
{/* TabBar снизу, во всю ширину */}
<TabBar /> <TabBar />
</div> </div>
</div> </div>

View File

@@ -1,94 +1,163 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { FaBell, FaUser, FaStar } from "react-icons/fa"; import { FaBell, FaUser } from "react-icons/fa";
import TabBar from "../components/TabBar"; import TabBar from "../components/TabBar";
import RequestDetailsModal from "../components/RequestDetailsModal"; import RequestDetailsModal from "../components/RequestDetailsModal";
const requests = [ const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
{
id: 1, // маппинг статусов API -> текст/цвет для UI
title: "Приобрести продукты пенсионерке", const statusMap = {
status: "Отклонена", pending_moderation: { label: "На модерации", color: "#E9D171" },
statusColor: "#FF8282", approved: { label: "Принята", color: "#94E067" },
date: "До 28.11.2025", in_progress: { label: "В процессе", color: "#E971E1" },
time: "13:00", completed: { label: "Выполнена", color: "#71A5E9" },
createdAt: "28.11.2025", cancelled: { label: "Отменена", color: "#FF8282" },
rejectReason: "Адрес вне зоны обслуживания", rejected: { label: "Отклонена", color: "#FF8282" },
description: "Купить продукты и принести по адресу.", };
},
{
id: 2,
title: "Приобрести продукты пенсионерке",
status: "Принята",
statusColor: "#94E067",
date: "До 28.11.2025",
time: "13:00",
createdAt: "28.11.2025",
description: "Купить продукты и принести по адресу.",
},
{
id: 3,
title: "Приобрести продукты пенсионерке",
status: "На модерации",
statusColor: "#E9D171",
date: "До 28.11.2025",
time: "13:00",
createdAt: "28.11.2025",
description: "Купить продукты и принести по адресу.",
},
{
id: 4,
title: "Приобрести продукты пенсионерке",
status: "Выполнена",
statusColor: "#71A5E9",
date: "До 28.11.2025",
time: "13:00",
createdAt: "28.11.2025",
description: "Купить продукты и принести по адресу.",
},
{
id: 5,
title: "Приобрести продукты пенсионерке",
status: "В процессе",
statusColor: "#E971E1",
date: "До 28.11.2025",
time: "13:00",
createdAt: "28.11.2025",
description: "Купить продукты и принести по адресу.",
},
{
id: 6,
title: "Приобрести продукты пенсионерке",
status: "В процессе",
statusColor: "#E971E1",
date: "До 28.11.2025",
time: "13:00",
createdAt: "28.11.2025",
description: "Купить продукты и принести по адресу.",
},
{
id: 7,
title: "Приобрести продукты пенсионерке",
status: "В процессе",
statusColor: "#E971E1",
date: "До 28.11.2025",
time: "13:00",
createdAt: "28.11.2025",
description: "Купить продукты и принести по адресу.",
},
];
const HistoryRequestPage = () => { const HistoryRequestPage = () => {
const [requests, setRequests] = useState([]);
const [selectedRequest, setSelectedRequest] = useState(null); const [selectedRequest, setSelectedRequest] = useState(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const handleOpen = (req) => { const [userName, setUserName] = useState("Пользователь");
setSelectedRequest(req); const [profileError, setProfileError] = useState("");
};
const handleClose = () => { // профиль: /users/me
setSelectedRequest(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;
if (!accessToken) return;
try {
const res = await fetch(`${API_BASE}/users/me`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) {
setProfileError("Не удалось загрузить профиль");
return;
}
const data = await res.json();
const fullName =
[data.first_name, data.last_name]
.filter(Boolean)
.join(" ")
.trim() || data.email;
setUserName(fullName);
} catch {
setProfileError("Ошибка загрузки профиля");
}
};
fetchProfile();
}, []);
// заявки: /requests/my
useEffect(() => {
const fetchRequests = async () => {
if (!API_BASE) {
setError("API_BASE_URL не задан");
setLoading(false);
return;
}
const saved =
typeof window !== "undefined"
? localStorage.getItem("authUser")
: null;
const authUser = saved ? JSON.parse(saved) : null;
const accessToken = authUser?.accessToken;
if (!accessToken) {
setError("Вы не авторизованы");
setLoading(false);
return;
}
try {
const res = await fetch(`${API_BASE}/requests/my`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) {
let msg = "Не удалось загрузить заявки";
try {
const data = await res.json();
if (data.error) msg = data.error;
} catch {
const text = await res.text();
if (text) msg = text;
}
setError(msg);
setLoading(false);
return;
}
const data = await res.json();
console.log("requests/my data:", data);
const mapped = data.map((item) => {
const rawStatus =
typeof item.status === "string"
? item.status
: item.status?.request_status;
const m = statusMap[rawStatus] || {
label: rawStatus || "unknown",
color: "#E2E2E2",
};
const created = new Date(item.created_at);
const createdAt = created.toLocaleDateString("ru-RU");
const time = created.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
return {
id: item.id,
title: item.title,
status: m.label,
statusColor: m.color,
createdAt,
date: createdAt,
time,
description: item.description,
// если позже появятся причина/оценка — можно добавить сюда
};
});
setRequests(mapped);
setLoading(false);
} catch (e) {
setError(e.message || "Ошибка сети");
setLoading(false);
}
};
fetchRequests();
}, []);
const handleOpen = (req) => setSelectedRequest(req);
const handleClose = () => setSelectedRequest(null);
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">
@@ -99,9 +168,16 @@ const HistoryRequestPage = () => {
<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"> <div>
Александр <p className="font-montserrat font-extrabold text-[20px] leading-[11px] text-white">
</p> {userName}
</p>
{profileError && (
<p className="text-[10px] text-red-200 font-montserrat mt-1">
{profileError}
</p>
)}
</div>
</div> </div>
<button <button
type="button" type="button"
@@ -115,8 +191,24 @@ const HistoryRequestPage = () => {
История заявок История заявок
</h1> </h1>
{error && (
<div className="mb-2 bg-red-500 text-white text-xs font-montserrat px-3 py-2 rounded-lg">
{error}
</div>
)}
{/* Список заявок */} {/* Список заявок */}
<main className="space-y-3 overflow-y-auto pr-1 max-h-[80vh]"> <main className="space-y-3 overflow-y-auto pr-1 max-h-[80vh]">
{loading && (
<p className="text-white text-sm font-montserrat">Загрузка...</p>
)}
{!loading && requests.length === 0 && !error && (
<p className="text-white text-sm font-montserrat">
У вас пока нет заявок
</p>
)}
{requests.map((req) => ( {requests.map((req) => (
<button <button
key={req.id} key={req.id}
@@ -132,9 +224,9 @@ const HistoryRequestPage = () => {
> >
{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>
<p className="font-montserrat text-[10px] text-black"> <p className="font-montserrat text-[10px] text-black">
{req.time} {req.time}
@@ -157,7 +249,7 @@ const HistoryRequestPage = () => {
))} ))}
</main> </main>
{/* Попап */} {/* Попап деталей */}
{selectedRequest && ( {selectedRequest && (
<RequestDetailsModal request={selectedRequest} onClose={handleClose} /> <RequestDetailsModal request={selectedRequest} onClose={handleClose} />
)} )}
@@ -169,91 +261,3 @@ const HistoryRequestPage = () => {
}; };
export default HistoryRequestPage; export default HistoryRequestPage;
// const RequestDetailsModal = ({ request, onClose }) => {
// const isDone = request.status === "Выполнена";
// return (
// <div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4">
// <div className="w-full max-w-sm bg-[#90D2F9] rounded-2xl p-3 relative">
// {/* Белая карточка */}
// <div className="bg-white rounded-xl p-3 flex flex-col gap-3">
// {/* Шапка попапа */}
// <div className="flex items-center justify-between mb-1">
// <button
// type="button"
// onClick={onClose}
// className="text-white bg-[#90D2F9] w-7 h-7 rounded-full flex items-center justify-center text-sm"
// >
// ←
// </button>
// <p className="flex-1 text-center font-montserrat font-extrabold text-[15px] text-white">
// Заявка от {request.createdAt}
// </p>
// <span className="w-7" />
// </div>
// {/* Статус + срок */}
// <div className="flex items-center justify-between">
// <span
// className="inline-flex items-center justify-center px-2 py-0.5 rounded-full font-montserrat text-[8px] font-light text-black"
// style={{ backgroundColor: "#71A5E9" }}
// >
// Выполнена
// </span>
// <div className="text-right leading-tight">
// <p className="font-montserrat text-[8px] text-black">
// До {request.date.replace("До ", "")}
// </p>
// <p className="font-montserrat text-[8px] text-black">
// {request.time}
// </p>
// </div>
// </div>
// {/* Название задачи */}
// <p className="font-montserrat font-semibold text-[12px] leading-[15px] text-black">
// {request.title}
// </p>
// {/* Блок отзыва */}
// {isDone && (
// <div className="bg-[#72B8E2] rounded-lg p-2 flex flex-col gap-2">
// <p className="font-montserrat font-bold text-[10px] text-white">
// Отзыв
// </p>
// <p className="font-montserrat text-[10px] text-white">
// Здесь будет текст отзыва с бэка.
// </p>
// </div>
// )}
// {/* Оценка волонтера */}
// <div className="mt-1">
// <p className="font-montserrat font-semibold text-[12px] text-black mb-1">
// Оценить волонтера
// </p>
// <div className="flex gap-1">
// {[1, 2, 3, 4, 5].map((star) => (
// <FaStar key={star} className="text-[#F6E168]" size={20} />
// ))}
// </div>
// </div>
// {/* Кнопка оставить отзыв */}
// {isDone && (
// <button
// type="button"
// className="mt-3 w-full bg-[#94E067] rounded-lg py-2 flex items-center justify-center"
// >
// <span className="font-montserrat font-bold text-[14px] text-white">
// Оставить отзыв
// </span>
// </button>
// )}
// </div>
// </div>
// </div>
// );
// };

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { AuthProvider } from "../app/context/AuthContext";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Create Next App",
@@ -14,7 +15,7 @@ export default function RootLayout({
return ( return (
<html lang="en"> <html lang="en">
<body className="antialiased"> <body className="antialiased">
{children} <AuthProvider>{children}</AuthProvider>
</body> </body>
</html> </html>
); );

View File

@@ -6,7 +6,9 @@ import { FaUser, FaCog } from "react-icons/fa";
import TabBar from "../components/TabBar"; import TabBar from "../components/TabBar";
import AcceptPopup from "../components/acceptPopUp"; import AcceptPopup from "../components/acceptPopUp";
// динамический импорт карты, чтобы не падало на сервере 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 }
@@ -25,63 +27,64 @@ const Popup = dynamic(
); );
// центр Перми // центр Перми
const DEFAULT_POSITION = [58.0105, 56.2294]; const DEFAULT_POSITION = [57.997962, 56.147201];
const requests = [
{
id: 1,
title: "Приобрести продукты пенсионерке",
address: "г. Пермь, ул. Ленина 50, кв. 24, этаж 3",
coords: [58.0109, 56.2478], // район ул. Ленина
distance: "1.2 км",
},
{
id: 2,
title: "Приобрести медикаменты бабушке",
address: "г. Пермь, ул. Пушкина 24, кв. 12, этаж 1",
coords: [58.0135, 56.2320], // район ул. Пушкина
distance: "2.0 км",
},
{
id: 3,
title: "Сопроводить до поликлиники",
address: "г. Пермь, ул. Куйбышева 95, кв. 7, этаж 2",
coords: [58.0068, 56.2265], // район ул. Куйбышева
distance: "3.4 км",
},
{
id: 4,
title: "Сопроводить до поликлиники",
address: "г. Пермь, ул. Куйбышева 95, кв. 7, этаж 2",
coords: [58.0068, 56.2265], // район ул. Куйбышева
distance: "3.4 км",
},
{
id: 5,
title: "Сопроводить до поликлиники",
address: "г. Пермь, ул. Куйбышева 95, кв. 7, этаж 2",
coords: [58.0068, 56.2265], // район ул. Куйбышева
distance: "3.4 км",
},
];
const MainVolunteerPage = () => { const MainVolunteerPage = () => {
const [position, setPosition] = useState(DEFAULT_POSITION); const [position, setPosition] = useState(DEFAULT_POSITION);
const [hasLocation, setHasLocation] = useState(false); const [hasLocation, setHasLocation] = useState(false);
const [userName, setUserName] = useState("Волонтёр");
const [requests, setRequests] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selectedRequest, setSelectedRequest] = useState(null); const [selectedRequest, setSelectedRequest] = useState(null);
const [isPopupOpen, setIsPopupOpen] = useState(false); const [isPopupOpen, setIsPopupOpen] = useState(false);
const [acceptLoading, setAcceptLoading] = useState(false);
const [acceptError, setAcceptError] = useState("");
// фикс иконок leaflet: только на клиенте
useEffect(() => {
const setupLeafletIcons = async () => {
if (typeof window === "undefined") return;
const L = (await import("leaflet")).default;
await import("leaflet/dist/leaflet.css");
// @ts-ignore
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: "/leaflet/marker-icon-2x.png",
iconUrl: "/leaflet/marker-icon.png",
shadowUrl: "/leaflet/marker-shadow.png",
});
};
setupLeafletIcons();
}, []);
const getAccessToken = () => {
if (typeof window === "undefined") return null;
const saved = localStorage.getItem("authUser");
const authUser = saved ? JSON.parse(saved) : null;
return authUser?.accessToken || null;
};
const openPopup = (req) => { const openPopup = (req) => {
setSelectedRequest(req); setSelectedRequest(req);
setIsPopupOpen(true); setIsPopupOpen(true);
setAcceptError("");
}; };
const closePopup = () => { const closePopup = () => {
setIsPopupOpen(false); setIsPopupOpen(false);
setSelectedRequest(null); setSelectedRequest(null);
setAcceptError("");
}; };
// геолокация
useEffect(() => { useEffect(() => {
if (!navigator.geolocation) return; if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
@@ -95,11 +98,169 @@ const MainVolunteerPage = () => {
); );
}, []); }, []);
const handleAccept = (req) => { // профиль
console.log("Откликнуться на заявку:", req.id); useEffect(() => {
// TODO: запрос на бэк const fetchProfile = async () => {
if (!API_BASE) return;
const accessToken = getAccessToken();
if (!accessToken) return;
try {
const res = await fetch(`${API_BASE}/users/me`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) return;
const data = await res.json();
const fullName =
[data.first_name, data.last_name].filter(Boolean).join(" ").trim() ||
data.email;
setUserName(fullName);
} catch {
//
}
};
fetchProfile();
}, []);
// заявки рядом
useEffect(() => {
const fetchNearbyRequests = async () => {
if (!API_BASE) {
setError("API_BASE_URL не задан");
setLoading(false);
return;
}
const accessToken = getAccessToken();
if (!accessToken) {
setError("Вы не авторизованы");
setLoading(false);
return;
}
const [lat, lon] = position;
try {
const params = new URLSearchParams({
lat: String(lat),
lon: String(lon),
radius: "5000",
limit: "50",
offset: "0",
});
const res = await fetch(
`${API_BASE}/requests/nearby?${params.toString()}`,
{
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!res.ok) {
let msg = "Не удалось загрузить заявки рядом";
try {
const data = await res.json();
if (data.error) msg = data.error;
} catch {
const text = await res.text();
if (text) msg = text;
}
setError(msg);
setLoading(false);
return;
}
const data = await res.json();
const mapped = data.map((item) => ({
id: item.id,
title: item.title,
description: item.description,
address: item.address,
city: item.city,
urgency: item.urgency,
contact_phone: item.contact_phone,
contact_notes: item.contact_notes,
desired_completion_date: item.desired_completion_date,
coords: [item.latitude ?? lat, item.longitude ?? lon],
distance: item.distance_meters
? `${(item.distance_meters / 1000).toFixed(1)} км`
: null,
}));
setRequests(mapped);
setLoading(false);
} catch (e) {
setError(e.message || "Ошибка сети");
setLoading(false);
}
};
fetchNearbyRequests();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [position, hasLocation]);
// отклик
// волонтёр СОДАЁТ отклик
const handleAccept = async (req, message = "") => {
if (!API_BASE || !req || !req.id) {
setAcceptError("Некорректная заявка (нет id)");
return;
}
const accessToken = getAccessToken();
if (!accessToken) {
setAcceptError("Вы не авторизованы");
return;
}
try {
setAcceptLoading(true);
setAcceptError("");
const res = await fetch(`${API_BASE}/requests/${req.id}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(
message ? { message } : {} // message опционален по схеме
),
});
if (!res.ok) {
let msg = "Не удалось отправить отклик";
try {
const data = await res.json();
if (data.error) msg = data.error;
} catch {
const text = await res.text();
if (text) msg = text;
}
console.error("Ошибка отклика:", msg);
setAcceptError(msg);
setAcceptLoading(false);
return;
}
await res.json(); // VolunteerResponse
setAcceptLoading(false);
closePopup();
} catch (e) {
setAcceptError(e.message || "Ошибка сети");
setAcceptLoading(false);
}
}; };
return ( 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">
@@ -109,8 +270,8 @@ const MainVolunteerPage = () => {
<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-[11px] leading-[11px] text-white"> <p className="font-montserrat font-extrabold text-[20px] leading-[11px] text-white">
Александр {userName}
</p> </p>
</div> </div>
<button <button
@@ -121,10 +282,16 @@ const MainVolunteerPage = () => {
</button> </button>
</header> </header>
<h1 className="font-montserrat font-extrabold text-[16px] leading-[20px] text-white mb-2"> <h1 className="font-montserrat font-extrabold text-[20px] leading-[20px] text-white mb-2">
Кому нужна помощь Кому нужна помощь
</h1> </h1>
{error && (
<p className="mb-2 text-xs font-montserrat text-red-200">
{error}
</p>
)}
{/* Карта */} {/* Карта */}
<div className="w-full bg-transparent mb-3"> <div className="w-full bg-transparent mb-3">
<div className="w-full h-[250px] bg-[#D9D9D9] rounded-2xl overflow-hidden"> <div className="w-full h-[250px] bg-[#D9D9D9] rounded-2xl overflow-hidden">
@@ -134,16 +301,14 @@ const MainVolunteerPage = () => {
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
> >
<TileLayer <TileLayer
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>
@@ -155,30 +320,45 @@ const MainVolunteerPage = () => {
{/* Заявки ниже карты */} {/* Заявки ниже карты */}
<main className="space-y-3"> <main className="space-y-3">
{loading && (
<p className="text-white text-sm font-montserrat">
Загрузка заявок...
</p>
)}
{!loading && requests.length === 0 && !error && (
<p className="text-white text-sm font-montserrat">
Рядом пока нет заявок
</p>
)}
{requests.map((req) => ( {requests.map((req) => (
<div <div
key={req.id} key={req.id}
className="bg-white rounded-xl px-3 py-2 flex flex-col gap-1" className="bg-white rounded-xl px-3 py-2 flex flex-col gap-1"
onClick={() => openPopup(req)} onClick={() => openPopup(req)}
> >
<p className="font-montserrat font-semibold text-[12px] leading-[14px] text-black"> <p className="font-montserrat font-semibold text-[15px] leading-[14px] text-black">
{req.title} {req.title}
</p> </p>
<p className="font-montserrat text-[10px] text-black"> <p className="font-montserrat text-[15px] text-black">
{req.address} {req.address}
</p> </p>
{req.distance && ( {req.distance && (
<p className="font-montserrat text-[9px] text-gray-500"> <p className="font-montserrat text-[12px] text-gray-500">
Расстояние: {req.distance} Расстояние: {req.distance}
</p> </p>
)} )}
<button <button
type="button" type="button"
onClick={() => handleAccept(req)} onClick={(e) => {
e.stopPropagation();
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>
@@ -187,16 +367,17 @@ const MainVolunteerPage = () => {
<TabBar /> <TabBar />
</div> </div>
<AcceptPopup <AcceptPopup
request={selectedRequest} request={selectedRequest}
isOpen={isPopupOpen} isOpen={isPopupOpen}
onClose={closePopup} onClose={closePopup}
onAccept={handleAccept} // onAccept={handleAccept}
// loading={acceptLoading}
// error={acceptError}
/> />
</div> </div>
); );
}; };
export default MainVolunteerPage; export default MainVolunteerPage;

View File

@@ -0,0 +1,277 @@
"use client";
import React, { useEffect, useState } from "react";
import { FaBell, FaUser } from "react-icons/fa";
import TabBar from "../components/TabBar";
import ModeratorRequestModal from "../components/ModeratorRequestDetailsModal";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
const statusMap = {
approved: { label: "Принята", color: "#94E067" },
rejected: { label: "Отклонена", color: "#E06767" },
};
const HistoryRequestModeratorPage = () => {
const [requests, setRequests] = useState([]);
const [selectedRequest, setSelectedRequest] = useState(null);
const [moderatorName, setModeratorName] = useState("Модератор");
const [loading, setLoading] = useState(true);
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) return;
const token = getAccessToken();
if (!token) return;
try {
const res = await fetch(`${API_BASE}/users/me`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) return;
const data = await res.json();
const fullName =
[data.first_name, data.last_name].filter(Boolean).join(" ").trim() ||
data.email;
setModeratorName(fullName);
} catch {
// дефолт остаётся
}
};
fetchProfile();
}, []);
// история модерации: только approved / rejected
useEffect(() => {
const fetchHistory = 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}/moderation/requests/my`, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
});
const text = await res.text();
let data = null;
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;
}
setError(msg);
setLoading(false);
return;
}
const list = Array.isArray(data) ? data : [];
// status: { request_status: "approved" | "rejected", valid: true }
const filtered = list.filter((item) => {
const s = String(item.status?.request_status || "").toLowerCase();
return s === "approved" || s === "rejected";
});
const mapped = filtered.map((item) => {
const rawStatus = String(
item.status?.request_status || ""
).toLowerCase();
const m = statusMap[rawStatus] || {
label: rawStatus || "Неизвестен",
color: "#E2E2E2",
};
const created = new Date(item.created_at);
const date = created.toLocaleDateString("ru-RU");
const time = created.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
return {
id: item.id,
title: item.title,
description: item.description,
status: m.label,
statusColor: m.color,
date,
time,
createdAt: date,
fullName: item.requester_name,
rejectReason: item.moderation_comment || "",
address: item.city ? `${item.city}, ${item.address}` : item.address,
rawStatus, // "approved" | "rejected"
};
});
setRequests(mapped);
setLoading(false);
} catch (e) {
setError(e.message || "Ошибка сети");
setLoading(false);
}
};
fetchHistory();
}, []);
const handleOpen = (req) => {
// пробрасываем rawStatus, чтобы модалка знала настоящий статус
setSelectedRequest({
...req,
status: req.rawStatus,
});
};
const handleClose = () => {
setSelectedRequest(null);
};
const handleModeratedUpdate = (updated) => {
setRequests((prev) =>
prev.map((r) =>
r.id === updated.id ? { ...r, rawStatus: updated.status } : r
)
);
};
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">
<FaUser className="text-white text-sm" />
</div>
<p className="font-montserrat font-extrabold text-[20px] leading-[22px] text-white">
{moderatorName}
</p>
</div>
<button
type="button"
className="w-8 h-8 rounded-full border border-white flex items-center justify-center"
>
<FaBell className="text-white text-sm" />
</button>
</header>
<h1 className="font-montserrat font-extrabold text-[20px] leading-[22px] text-white mb-3">
История заявок
</h1>
{error && (
<p className="mb-2 text-xs font-montserrat text-red-200">
{error}
</p>
)}
{/* Список заявок */}
<main className="space-y-3 overflow-y-auto pr-1 max-h-[80vh]">
{loading && (
<p className="text-white text-sm font-montserrat">
Загрузка истории...
</p>
)}
{!loading && requests.length === 0 && !error && (
<p className="text-white text-sm font-montserrat">
История модерации пуста
</p>
)}
{requests.map((req) => (
<button
key={req.id}
type="button"
onClick={() => handleOpen(req)}
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">
<span
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">
<p className="font-montserrat text-[10px] text-black">
{req.date}
</p>
<p className="font-montserrat text-[10px] text-black">
{req.time}
</p>
</div>
</div>
<p className="font-montserrat font-semibold text-[15px] leading-[18px] text-black mt-1">
{req.title}
</p>
<p className="font-montserrat text-[11px] text-black/80">
{req.fullName}
</p>
<p className="font-montserrat text-[10px] text-black/70">
{req.address}
</p>
<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>
</div>
</button>
))}
</main>
{selectedRequest && (
<ModeratorRequestModal
request={selectedRequest}
onClose={handleClose}
onModerated={handleModeratedUpdate}
/>
)}
<TabBar />
</div>
</div>
);
};
export default HistoryRequestModeratorPage;

257
app/moderatorMain/page.jsx Normal file
View File

@@ -0,0 +1,257 @@
"use client";
import React, { useEffect, useState } from "react";
import { FaBell, FaUser } from "react-icons/fa";
import TabBar from "../components/TabBar";
import RequestDetailsModal from "../components/ModeratorRequestDetailsModal";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
const statusMap = {
pending_moderation: { label: "На модерации", color: "#E9D171" },
approved: { label: "Принята", color: "#94E067" },
in_progress: { label: "В процессе", color: "#E971E1" },
completed: { label: "Выполнена", color: "#71A5E9" },
cancelled: { label: "Отменена", color: "#FF8282" },
rejected: { label: "Отклонена", color: "#FF8282" },
};
const HistoryRequestPage = () => {
const [requests, setRequests] = useState([]);
const [selectedRequest, setSelectedRequest] = useState(null);
const [moderatorName, setModeratorName] = useState("Модератор");
const [loading, setLoading] = useState(true);
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) return;
const token = getAccessToken();
if (!token) return;
try {
const res = await fetch(`${API_BASE}/users/me`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) return;
const data = await res.json();
const fullName =
[data.first_name, data.last_name].filter(Boolean).join(" ").trim() ||
data.email;
setModeratorName(fullName);
} catch {
// дефолт остаётся
}
};
fetchProfile();
}, []);
// список заявок на модерации
useEffect(() => {
const fetchRequestsForModeration = 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}/moderation/requests/pending`, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
});
const text = await res.text();
let data = null;
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;
}
setError(msg);
setLoading(false);
return;
}
const list = Array.isArray(data) ? data : [];
// только pending_moderation
const pending = list.filter(
(item) =>
item.status &&
item.status.request_status === "pending_moderation"
);
const mapped = pending.map((item) => {
const rawStatus = item.status?.request_status || "pending_moderation";
const m = statusMap[rawStatus] || {
label: rawStatus,
color: "#E2E2E2",
};
const created = new Date(item.created_at);
const createdAt = created.toLocaleDateString("ru-RU");
const time = created.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
return {
id: item.id,
title: item.title,
description: item.description,
status: m.label,
statusColor: m.color,
createdAt,
date: createdAt,
time,
address: item.address,
city: item.city,
urgency: item.urgency,
rawStatus,
};
});
setRequests(mapped);
setLoading(false);
} catch (e) {
setError(e.message || "Ошибка сети");
setLoading(false);
}
};
fetchRequestsForModeration();
}, []);
const handleOpen = (req) => setSelectedRequest(req);
const handleClose = () => setSelectedRequest(null);
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">
<FaUser className="text-white text-sm" />
</div>
<p className="font-montserrat font-extrabold text-[20px] leading-[11px] text-white">
{moderatorName}
</p>
</div>
<button
type="button"
className="w-8 h-8 rounded-full border border-white flex items-center justify-center"
>
<FaBell className="text-white text-sm" />
</button>
</header>
<h1 className="font-montserrat font-extrabold text-[20px] leading-[22px] text-white mb-3">
Активные Заявки
</h1>
{error && (
<p className="mb-2 text-xs font-montserrat text-red-200">
{error}
</p>
)}
{/* Список заявок */}
<main className="space-y-3 overflow-y-auto pr-1 max-h-[80vh]">
{loading && (
<p className="text-white text-sm font-montserrat">
Загрузка заявок...
</p>
)}
{!loading && requests.length === 0 && !error && (
<p className="text-white text-sm font-montserrat">
Заявок на модерации пока нет
</p>
)}
{requests.map((req) => (
<button
key={req.id}
type="button"
onClick={() => handleOpen(req)}
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">
<span
className="inline-flex.items-center justify-center px-2 py-0.5 rounded-full font-montserrat text-[12px] font-light text-black"
style={{ backgroundColor: req.statusColor }}
>
{req.status}
</span>
<div className="text-right leading-tight">
<p className="font-montserrat text-[10px] text-black">
{req.date}
</p>
<p className="font-montserrat text-[10px] text-black">
{req.time}
</p>
</div>
</div>
<p className="font-montserrat font-semibold text-[15px] leading-[18px] text-black mt-1">
{req.title}
</p>
<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>
</div>
</button>
))}
</main>
{selectedRequest && (
<RequestDetailsModal
request={selectedRequest}
onClose={handleClose}
/>
)}
<TabBar />
</div>
</div>
);
};
export default HistoryRequestPage;

View File

@@ -0,0 +1,210 @@
"use client";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { FaUserCircle, FaStar } from "react-icons/fa";
import TabBar from "../components/TabBar";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
const ModeratorProfilePage = () => {
const router = useRouter();
const [profile, setProfile] = useState(null);
const [loading, setLoading] = useState(true);
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 (
<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 mb-4">
<button
type="button"
onClick={() => router.back()}
className="text-white w-8 h-8 rounded-full flex items-center justify-center text-lg"
>
</button>
<h1 className="flex-1 text-center font-montserrat font-extrabold text-[20px] leading-[24px] text-white">
Профиль
</h1>
<span className="w-8" />
</header>
{/* Карточка профиля */}
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
{loading && (
<p className="font-montserrat text-[14px] text-black">
Загрузка профиля...
</p>
)}
{error && !loading && (
<p className="font-montserrat text-[12px] text-red-500">
{error}
</p>
)}
{!loading && profile && (
<>
{/* Аватар */}
{profile.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={profile.avatar_url}
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 className="w-full bg-[#72B8E2] rounded-2xl p-3 text-white space-y-1">
<p className="font-montserrat text-[12px]">
Дата регистрации: {birthDateText}
</p>
<p className="font-montserrat text-[12px]">Почта: {email}</p>
<p className="font-montserrat text-[12px]">
Телефон: {phone}
</p>
</div>
{/* Кнопки */}
<div className="w-full flex flex-col gap-2 mt-2">
<button
type="button"
onClick={() => router.push("/moderatorProfileSettings")}
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>
</button>
<button
type="button"
className="w-full bg-[#E07567] rounded-full py-2 flex items-center justify-center"
onClick={() => {
if (typeof window !== "undefined") {
localStorage.removeItem("authUser");
}
router.push("/");
}}
>
<span className="font-montserrat font-extrabold text-[14px] text-white">
Выйти из аккаунта
</span>
</button>
</div>
</>
)}
</main>
<TabBar />
</div>
</div>
);
};
export default ModeratorProfilePage;

View File

@@ -0,0 +1,293 @@
"use client";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { FaUserCircle } from "react-icons/fa";
import TabBar from "../components/TabBar";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
const ModeratorProfileSettingsPage = () => {
const router = useRouter();
const [avatarUrl, setAvatarUrl] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [saveMessage, setSaveMessage] = 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]
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 (
<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 mb-4">
<button
type="button"
onClick={() => router.back()}
className="text-white w-8 h-8 rounded-full flex items-center justify-center text-lg"
>
</button>
<h1 className="flex-1 text-center font-montserrat font-extrabold text-[20px] leading-[24px] text-white">
Настройки профиля
</h1>
<span className="w-8" />
</header>
{/* Карточка настроек */}
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
{loading && (
<p className="font-montserrat text-[14px] text-black">
Загрузка профиля...
</p>
)}
{!loading && (
<>
{/* Аватар */}
<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">
{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>
)}
<form
onSubmit={handleSave}
className="w-full flex flex-col gap-3"
>
{/* Имя */}
<div className="flex flex-col gap-1">
<label className="font-montserrat text-[12px] text-black">
Имя
</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(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"
placeholder="Введите имя"
/>
</div>
{/* Фамилия */}
<div className="flex flex-col gap-1">
<label className="font-montserrat text-[12px] text-black">
Фамилия
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(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"
placeholder="Введите фамилию"
/>
</div>
{/* Почта (только отображение) */}
<div className="flex flex-col gap-1">
<label className="font-montserrat text-[12px] text-black">
Почта
</label>
<input
type="email"
value={email}
disabled
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"
/>
</div>
{/* Телефон */}
<div className="flex flex-col gap-1">
<label className="font-montserrat text-[12px] text-black">
Телефон
</label>
<input
type="tel"
value={phone}
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"
placeholder="+7 900 000 00 00"
/>
</div>
{/* Кнопка сохранить */}
<button
type="submit"
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">
{saving ? "Сохранение..." : "Сохранить изменения"}
</span>
</button>
</form>
</>
)}
</main>
<TabBar />
</div>
</div>
);
};
export default ModeratorProfileSettingsPage;

101
app/notification/page.jsx Normal file
View 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;

View File

@@ -1,31 +1,155 @@
"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 API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
const ProfileSettingsPage = () => { const ProfileSettingsPage = () => {
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 [address, setAddress] = useState("");
const [city, setCity] = useState("");
const handleSave = (e) => { const [bio, setBio] = useState("");
const [loading, setLoading] = useState(true);
const [saveLoading, setSaveLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
const fetchProfile = async () => {
if (!API_BASE) {
setError("API_BASE_URL не задан");
setLoading(false);
return;
}
const saved =
typeof window !== "undefined"
? localStorage.getItem("authUser")
: null;
const authUser = saved ? JSON.parse(saved) : null;
const accessToken = authUser?.accessToken;
if (!accessToken) {
setError("Вы не авторизованы");
setLoading(false);
return;
}
try {
const res = await fetch(`${API_BASE}/users/me`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) {
let msg = "Не удалось загрузить профиль";
try {
const data = await res.json();
if (data.error) msg = data.error;
} catch {
const text = await res.text();
if (text) msg = text;
}
setError(msg);
setLoading(false);
return;
}
const data = await res.json(); // UserProfile[file:519]
setFirstName(data.first_name || "");
setLastName(data.last_name || "");
setEmail(data.email || "");
setPhone(data.phone || "");
setAddress(data.address || "");
setCity(data.city || "");
setBio(data.bio || "");
setAvatarUrl(data.avatar_url || "");
setLoading(false);
} catch (e) {
setError(e.message || "Ошибка сети");
setLoading(false);
}
};
fetchProfile();
}, []);
const handleSave = async (e) => {
e.preventDefault(); e.preventDefault();
console.log("Сохранить профиль:", { if (!API_BASE) return;
avatarUrl,
fullName, setError("");
birthDate, setSuccess("");
email, setSaveLoading(true);
phone,
}); const saved =
// здесь будет запрос на бэк typeof window !== "undefined"
? localStorage.getItem("authUser")
: null;
const authUser = saved ? JSON.parse(saved) : null;
const accessToken = authUser?.accessToken;
if (!accessToken) {
setError("Вы не авторизованы");
setSaveLoading(false);
return;
}
const body = {
first_name: firstName || undefined,
last_name: lastName || undefined,
phone: phone || undefined,
bio: bio || undefined,
address: address || undefined,
city: city || undefined,
};
try {
const res = await fetch(`${API_BASE}/users/me`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
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);
setSaveLoading(false);
return;
}
setSuccess("Профиль успешно сохранён");
setSaveLoading(false);
} catch (err) {
setError(err.message || "Ошибка сети");
setSaveLoading(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,7 +170,23 @@ const ProfileSettingsPage = () => {
{/* Карточка настроек */} {/* Карточка настроек */}
<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">
{/* Аватар */} {error && (
<p className="w-full text-center text-xs font-montserrat text-red-500">
{error}
</p>
)}
{success && (
<p className="w-full text-center text-xs font-montserrat text-green-600">
{success}
</p>
)}
{loading && !error && (
<p className="w-full text-center text-xs font-montserrat text-black">
Загрузка профиля...
</p>
)}
{/* Аватар (пока локально, без API) */}
<div className="flex flex-col items-center gap-2"> <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"> <div className="w-24 h-24 rounded-full bg-[#E5F3FB] flex items-center justify-center overflow-hidden">
{avatarUrl ? ( {avatarUrl ? (
@@ -77,34 +217,35 @@ const ProfileSettingsPage = () => {
</div> </div>
<form onSubmit={handleSave} className="w-full flex flex-col gap-3"> <form onSubmit={handleSave} className="w-full flex flex-col gap-3">
{/* ФИО */} {/* Имя */}
<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="text" type="text"
value={fullName} value={firstName}
onChange={(e) => setFullName(e.target.value)} onChange={(e) => setFirstName(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="Введите ФИО" 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="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">
Почта Почта
@@ -112,9 +253,8 @@ const ProfileSettingsPage = () => {
<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] px-4 py-2 text-sm font-montserrat text-white opacity-70 cursor-not-allowed"
placeholder="example@mail.com"
/> />
</div> </div>
@@ -127,18 +267,61 @@ const ProfileSettingsPage = () => {
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>
{/* Адрес */}
<div className="flex flex-col gap-1">
<label className="font-montserrat text-[12px] text-black">
Адрес
</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(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"
placeholder="Улица, дом, квартира"
/>
</div>
{/* Город */}
<div className="flex flex-col gap-1">
<label className="font-montserrat text-[12px] text-black">
Город
</label>
<input
type="text"
value={city}
onChange={(e) => setCity(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"
placeholder="Например: Пермь"
/>
</div>
{/* О себе */}
<div className="flex flex-col gap-1">
<label className="font-montserrat text-[12px] text-black">
О себе
</label>
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={3}
className="w-full rounded-3xl 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 resize-none"
placeholder="Расскажите о себе"
/>
</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={saveLoading}
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">
Сохранить изменения {saveLoading ? "Сохранение..." : "Сохранить изменения"}
</span> </span>
</button> </button>
</form> </form>

View File

@@ -4,48 +4,94 @@ import React, { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
const RegPage = () => { const RegPage = () => {
const router = useRouter(); const router = useRouter();
const [firstName, setFirstName] = useState(""); // имя
const [lastName, setLastName] = useState(""); // фамилия
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const [checkboxError, setCheckboxError] = useState(false); const [checkboxError, setCheckboxError] = useState(false);
const [authError, setAuthError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const isEmailValid = emailRegex.test(email); const isEmailValid = emailRegex.test(email);
const isFormValid = isEmailValid && password.length > 0; const isFormValid =
isEmailValid &&
password.length > 0 &&
firstName.trim().length > 0 &&
lastName.trim().length > 0;
const handleSubmit = (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
if (!rememberMe) { if (!rememberMe) {
setCheckboxError(true); setCheckboxError(true);
return; return;
} }
setCheckboxError(false); setCheckboxError(false);
if (!isFormValid) return; if (!isFormValid || !API_BASE) return;
console.log("Email:", email, "Password:", password, "Remember:", rememberMe); try {
router.push("/regCode"); setAuthError("");
setIsSubmitting(true);
const res = await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
email,
password,
first_name: firstName,
last_name: lastName,
// остальные поля можно не отправлять, если не обязательные
}),
});
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;
}
setAuthError(msg);
setIsSubmitting(false);
return;
}
const data = await res.json();
console.log("Регистрация успешна, ответ API:", data);
router.push("/regCode");
} catch (err) {
setAuthError(err.message || "Ошибка сети");
setIsSubmitting(false);
}
}; };
return ( return (
<div className="min-h-screen w-full bg-[#90D2F9] flex items-center justify-center px-4"> <div className="min-h-screen w-full bg-[#90D2F9] flex items-center justify-center px-4">
<div className="w-full max-w-md bg-white/10 rounded-2xl p-6 sm:p-8 shadow-lg relative"> <div className="w-full max-w-md bg-white/10 rounded-2xl p-6 sm:p-8 shadow-lg relative">
{/* Красный баннер ошибки по чекбоксу */} {(checkboxError || authError) && (
{checkboxError && (
<div <div
className="absolute -top-10 left-0 w-full bg-red-500 text-white text-xs sm:text-sm font-montserrat px-3 py-2 rounded-t-2xl flex items-center justify-center shadow-md" className="absolute -top-10 left-0 w-full bg-red-500 text-white text-xs sm:text-sm font-montserrat px-3 py-2 rounded-t-2xl flex items-center justify-center shadow-md"
role="alert" role="alert"
> >
Вы не согласны с условиями использования {checkboxError
? "Вы не согласны с условиями использования"
: authError}
</div> </div>
)} )}
{/* Кнопка Назад */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<button <button
type="button" type="button"
@@ -57,11 +103,46 @@ const RegPage = () => {
<span className="flex-1 text-center font-montserrat text-white font-extrabold text-2xl"> <span className="flex-1 text-center font-montserrat text-white font-extrabold text-2xl">
Регистрация Регистрация
</span> </span>
{/* Пустой блок для выравнивания по центру заголовка */}
<span className="w-[48px]" /> <span className="w-[48px]" />
</div> </div>
<form onSubmit={handleSubmit} className="mt-2 space-y-4"> <form onSubmit={handleSubmit} className="mt-2 space-y-4">
{/* Имя */}
<div className="space-y-1">
<label className="block font-montserrat font-extrabold text-xs text-white">
Имя
</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="w-full rounded-full bg-white px-4 py-2 text-sm font-montserrat text-black outline-none focus:ring-2 focus:ring-blue-200"
/>
{firstName.trim().length === 0 && (
<p className="text-[11px] text-red-600 font-montserrat">
Введите имя
</p>
)}
</div>
{/* Фамилия */}
<div className="space-y-1">
<label className="block font-montserrat font-extrabold text-xs text-white">
Фамилия
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="w-full rounded-full bg-white px-4 py-2 text-sm font-montserrat text-black outline-none focus:ring-2 focus:ring-blue-200"
/>
{lastName.trim().length === 0 && (
<p className="text-[11px] text-red-600 font-montserrat">
Введите фамилию
</p>
)}
</div>
{/* Почта */} {/* Почта */}
<div className="space-y-1"> <div className="space-y-1">
<label className="block font-montserrat font-extrabold text-xs text-white"> <label className="block font-montserrat font-extrabold text-xs text-white">
@@ -101,29 +182,32 @@ const RegPage = () => {
setRememberMe((prev) => !prev); setRememberMe((prev) => !prev);
if (!rememberMe) setCheckboxError(false); if (!rememberMe) setCheckboxError(false);
}} }}
className={`w-5 h-5 rounded-full border border-white flex items-center justify-center ${rememberMe ? "bg-white" : "bg-transparent" className={`w-5 h-5 rounded-full border border-white flex items-center justify-center ${
}`} rememberMe ? "bg-white" : "bg-transparent"
}`}
> >
{rememberMe && ( {rememberMe && (
<span className="h-2 w-2 rounded-full bg-[#90D2F9]" /> <span className="h-2 w-2 rounded-full bg-[#90D2F9]" />
)} )}
</button> </button>
<p className="font-montserrat text-[10px] leading-[12px] text-white"> <p className="font-montserrat text-[10px] leading-[12px] text-white">
Подтверждаю, что я прочитал условия использования данного приложения Подтверждаю, что я прочитал условия использования данного
приложения
</p> </p>
</div> </div>
{/* Кнопка Войти */} {/* Кнопка Регистрация */}
<button <button
type="submit" type="submit"
disabled={!isFormValid} disabled={!isFormValid || isSubmitting}
className={`mt-4 w-full rounded-full py-2 text-center font-montserrat font-extrabold text-sm transition-colors className={`mt-4 w-full rounded-full py-2 text-center font-montserrat font-extrabold text-sm transition-colors
${isFormValid ${
? "bg-green-500 text-white hover:bg-green-600" isFormValid && !isSubmitting
: "bg-white text-[#C4C4C4] cursor-not-allowed" ? "bg-green-500 text-white hover:bg-green-600"
: "bg-white text-[#C4C4C4] cursor-not-allowed"
}`} }`}
> >
Регистрация {isSubmitting ? "Отправка..." : "Регистрация"}
</button> </button>
</form> </form>
</div> </div>

View File

@@ -35,7 +35,7 @@ const RecPasswordCodePage = () => {
setError(""); setError("");
console.log("Подтверждение кода:", code); console.log("Подтверждение кода:", code);
router.push("/home"); router.push("/");
// TODO: запрос на бэк и переход на страницу смены пароля // TODO: запрос на бэк и переход на страницу смены пароля
}; };

View File

@@ -0,0 +1,278 @@
"use client";
import React, { useEffect, useState } from "react";
import { FaBell, FaUser } from "react-icons/fa";
import TabBar from "../components/TabBar";
import RequestDetailsModal from "../components/ValounterRequestDetailsModal";
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 HistoryRequestPage = () => {
const [userName, setUserName] = useState("Волонтёр");
const [requests, setRequests] = useState([]);
const [selectedRequest, setSelectedRequest] = useState(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
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 token = getAccessToken();
if (!token) return;
try {
const res = await fetch(`${API_BASE}/users/me`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) return;
const data = await res.json();
const fullName =
[data.first_name, data.last_name].filter(Boolean).join(" ").trim() ||
data.email;
setUserName(fullName);
} catch {
// игнорируем
}
};
fetchProfile();
}, []);
// история откликов волонтёра: /responses/my
useEffect(() => {
const fetchVolunteerRequests = 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}/responses/my`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
});
const text = await res.text();
let data = null;
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;
}
setError(msg);
setLoading(false);
return;
}
const list = Array.isArray(data) ? data : [];
const mapped = list.map((item) => {
// VolunteerResponse.status: pending | accepted | rejected | cancelled
const rawStatus = String(
item.status?.response_status || item.status || ""
).toLowerCase();
const m = responseStatusMap[rawStatus] || {
label: rawStatus || "Неизвестен",
color: "#E2E2E2",
};
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.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,
};
});
setRequests(mapped);
setLoading(false);
} catch (e) {
setError(e.message || "Ошибка сети");
setLoading(false);
}
};
fetchVolunteerRequests();
}, []);
const handleOpen = (req) => {
setSelectedRequest(req);
};
const handleClose = () => {
setSelectedRequest(null);
};
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">
<FaUser className="text-white text-sm" />
</div>
<p className="font-montserrat font-extrabold text-[20px] leading-[22px] text-white">
{userName}
</p>
</div>
<button
type="button"
className="w-8 h-8 rounded-full border border-white flex items-center justify-center"
>
<FaBell className="text-white text-sm" />
</button>
</header>
<h1 className="font-montserrat font-extrabold text-[20px] leading-[22px] text-white mb-3">
История заявок
</h1>
{error && (
<p className="mb-2 text-xs font-montserrat text-red-200">
{error}
</p>
)}
{/* Список откликов */}
<main className="space-y-3 overflow-y-auto pr-1 max-h-[80vh]">
{loading && (
<p className="text-white text-sm font-montserrat">
Загрузка заявок...
</p>
)}
{!loading && requests.length === 0 && !error && (
<p className="text-white text-sm font-montserrat">
У вас пока нет заявок
</p>
)}
{requests.map((req) => (
<button
key={req.id}
type="button"
onClick={() => handleOpen(req)}
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">
<span
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">
<p className="font-montserrat text-[10px] text-black">
{req.date}
</p>
<p className="font-montserrat text-[10px] text-black">
{req.time}
</p>
</div>
</div>
<p className="font-montserrat font-semibold text-[15px] leading-[18px] text-black mt-1">
{req.title}
</p>
{req.requesterName && (
<p className="font-montserrat text-[11px] text-black/80">
{req.requesterName}
</p>
)}
{req.address && (
<p className="font-montserrat text-[10px] text-black/70">
{req.address}
</p>
)}
<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>
</div>
</button>
))}
</main>
{selectedRequest && (
<RequestDetailsModal request={selectedRequest} onClose={handleClose} />
)}
<TabBar />
</div>
</div>
);
};
export default HistoryRequestPage;

View File

@@ -0,0 +1,209 @@
"use client";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { FaUserCircle, FaStar } from "react-icons/fa";
import TabBar from "../components/TabBar";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
const ValounterProfilePage = () => {
const router = useRouter();
const [profile, setProfile] = useState(null);
const [loading, setLoading] = useState(true);
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[file:519]
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 (
<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 mb-4">
<button
type="button"
onClick={() => router.back()}
className="text-white w-8 h-8 rounded-full flex items-center justify-center text-lg"
>
</button>
<h1 className="flex-1 text-center font-montserrat font-extrabold text-[20px] leading-[24px] text-white">
Профиль
</h1>
<span className="w-8" />
</header>
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
{loading && (
<p className="font-montserrat text-[14px] text-black">
Загрузка профиля...
</p>
)}
{error && !loading && (
<p className="font-montserrat text-[12px] text-red-500">
{error}
</p>
)}
{!loading && profile && (
<>
{/* Аватар */}
{profile.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={profile.avatar_url}
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 className="w-full bg-[#72B8E2] rounded-2xl p-3 text-white space-y-1">
<p className="font-montserrat text-[12px]">
Дата регистрации: {birthDateText}
</p>
<p className="font-montserrat text-[12px]">Почта: {email}</p>
<p className="font-montserrat text-[12px]">
Телефон: {phone}
</p>
</div>
{/* Кнопки */}
<div className="w-full flex flex-col gap-2 mt-2">
<button
type="button"
onClick={() => router.push("/valounterProfileSettings")}
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>
</button>
<button
type="button"
className="w-full bg-[#E07567] rounded-full py-2 flex items-center justify-center"
onClick={() => {
if (typeof window !== "undefined") {
localStorage.removeItem("authUser");
}
router.push("/");
}}
>
<span className="font-montserrat font-extrabold text-[14px] text-white">
Выйти из аккаунта
</span>
</button>
</div>
</>
)}
</main>
<TabBar />
</div>
</div>
);
};
export default ValounterProfilePage;

View File

@@ -0,0 +1,290 @@
"use client";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { FaUserCircle } from "react-icons/fa";
import TabBar from "../components/TabBar";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
const ValounterProfileSettingsPage = () => {
const router = useRouter();
const [avatarUrl, setAvatarUrl] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [saveMessage, setSaveMessage] = 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[file:519]
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,
// email обычно не меняют через этот эндпоинт, но если бек разрешает — можно добавить
};
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 (
<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 mb-4">
<button
type="button"
onClick={() => router.back()}
className="text-white w-8 h-8 rounded-full flex items-center justify-center text-lg"
>
</button>
<h1 className="flex-1 text-center font-montserrat font-extrabold text-[20px] leading-[24px] text-white">
Настройки профиля
</h1>
<span className="w-8" />
</header>
{/* Карточка настроек */}
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
{loading && (
<p className="font-montserrat text-[14px] text-black">
Загрузка профиля...
</p>
)}
{!loading && (
<>
{/* Аватар */}
<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">
{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>
)}
<form onSubmit={handleSave} className="w-full flex flex-col gap-3">
{/* ФИО -> first_name + last_name */}
<div className="flex flex-col gap-1">
<label className="font-montserrat text-[12px] text-black">
Имя
</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(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"
placeholder="Введите имя"
/>
</div>
<div className="flex flex-col gap-1">
<label className="font-montserrat text-[12px] text-black">
Фамилия
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(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"
placeholder="Введите фамилию"
/>
</div>
{/* Почта (только показ, без сохранения, если бэк не даёт менять) */}
<div className="flex flex-col gap-1">
<label className="font-montserrat text-[12px] text-black">
Почта
</label>
<input
type="email"
value={email}
disabled
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"
/>
</div>
{/* Телефон */}
<div className="flex flex-col gap-1">
<label className="font-montserrat text-[12px] text-black">
Телефон
</label>
<input
type="tel"
value={phone}
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"
placeholder="+7 900 000 00 00"
/>
</div>
{/* Кнопка сохранить */}
<button
type="submit"
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">
{saving ? "Сохранение..." : "Сохранить изменения"}
</span>
</button>
</form>
</>
)}
</main>
<TabBar />
</div>
</div>
);
};
export default ValounterProfileSettingsPage;

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