Compare commits
4 Commits
bb833d956e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4bfbd30cc | ||
|
|
b5575b8e47 | ||
|
|
0df52352a8 | ||
|
|
433b9e896c |
@@ -15,11 +15,12 @@ const AuthPage = () => {
|
|||||||
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 [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) {
|
||||||
@@ -32,9 +33,11 @@ const AuthPage = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setAuthError("");
|
setAuthError("");
|
||||||
login(email, password);
|
setIsSubmitting(true);
|
||||||
|
await login(email, password); // теперь это async
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAuthError(err.message || "Неверный логин или пароль");
|
setAuthError(err.message || "Неверный логин или пароль");
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,7 +100,7 @@ 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 ${
|
className={`w-10 h-6 rounded-full border border-white flex items-center justify-center ${
|
||||||
rememberMe ? "bg-white" : "bg-transparent"
|
rememberMe ? "bg-white" : "bg-transparent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -105,33 +108,51 @@ const AuthPage = () => {
|
|||||||
<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 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> */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:underline"
|
||||||
|
onClick={() => router.push("/reg")}
|
||||||
|
>
|
||||||
|
Регистрация
|
||||||
|
</button>
|
||||||
|
</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={` w-full rounded-full py-2 text-center font-montserrat font-extrabold text-sm transition-colors
|
||||||
${
|
${
|
||||||
isFormValid
|
isFormValid && !isSubmitting
|
||||||
? "bg-green-500 text-white hover:bg-green-600"
|
? "bg-green-500 text-white hover:bg-green-600"
|
||||||
: "bg-white text-[#C4C4C4] cursor-not-allowed"
|
: "bg-white text-[#C4C4C4] cursor-not-allowed"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Войти
|
{isSubmitting ? "Входим..." : "Войти"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Подсказка по тестовым логинам */}
|
{/* Подсказка по тестовым логинам — можно убрать, когда перейдёшь на реальные аккаунты */}
|
||||||
<div className="mt-4 text-[15px] text-white font-mонтserrat space-y-1">
|
<div className="mt-4 text-[15px] text-white font-montserrat space-y-1">
|
||||||
<p>Тестовые аккаунты:</p>
|
<p>Тестовые аккаунты (если настроены на бэке):</p>
|
||||||
<p>Пользователь: user@mail.com / user123</p>
|
<p>Пользователь: user@mail.com / user123123</p>
|
||||||
<p>Волонтёр: vol@mail.com / vol123</p>
|
<p>Волонтёр: vol@mail.com / vol123123</p>
|
||||||
<p>Модератор: mod@mail.com / mod123</p>
|
<p>Модератор: mod@mail.com / mod123123</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -2,34 +2,210 @@
|
|||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
const ModeratorRequestModal = ({ request, onClose, onApprove, onReject }) => {
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
const ModeratorRequestModal = ({ request, onClose, onModerated }) => {
|
||||||
const [showRejectPopup, setShowRejectPopup] = useState(false);
|
const [showRejectPopup, setShowRejectPopup] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState("");
|
const [rejectReason, setRejectReason] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const isApproved = request.status === "Принята";
|
// request.status: "pending_moderation" | "approved" | "rejected"
|
||||||
const isRejected = request.status === "Отклонена";
|
const isApproved = request.status === "approved";
|
||||||
const isPending = !isApproved && !isRejected; // на модерации
|
const isRejected = request.status === "rejected";
|
||||||
|
const isPending = request.status === "На модерации";
|
||||||
|
|
||||||
const handleApprove = () => {
|
const getAccessToken = () => {
|
||||||
onApprove?.({ ...request, status: "Принята" });
|
if (typeof window === "undefined") return null;
|
||||||
onClose();
|
const saved = localStorage.getItem("authUser");
|
||||||
|
const authUser = saved ? JSON.parse(saved) : null;
|
||||||
|
return authUser?.accessToken || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRejectConfirm = () => {
|
const formatDate = (iso) => {
|
||||||
onReject?.({
|
if (!iso) return "";
|
||||||
...request,
|
const d = new Date(iso);
|
||||||
status: "Отклонена",
|
return d.toLocaleDateString("ru-RU");
|
||||||
rejectReason: rejectReason,
|
};
|
||||||
|
|
||||||
|
const formatTime = (iso) => {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleTimeString("ru-RU", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
setShowRejectPopup(false);
|
};
|
||||||
onClose();
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* основной экран модерации во весь экран */}
|
|
||||||
<div className="fixed inset-0 z-40 flex flex-col bg-[#90D2F9] px-4 pt-4 pb-20">
|
<div className="fixed inset-0 z-40 flex flex-col bg-[#90D2F9] px-4 pt-4 pb-20">
|
||||||
{/* хедер */}
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -39,15 +215,13 @@ const ModeratorRequestModal = ({ request, onClose, onApprove, onReject }) => {
|
|||||||
←
|
←
|
||||||
</button>
|
</button>
|
||||||
<p className="flex-1 text-center font-montserrat font-extrabold text-[20px] leading-[24px] text-white">
|
<p className="flex-1 text-center font-montserrat font-extrabold text-[20px] leading-[24px] text-white">
|
||||||
Заявка от {request.date || "28.11.25"}
|
Заявка от {createdDate || "—"}
|
||||||
</p>
|
</p>
|
||||||
<span className="w-8" />
|
<span className="w-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* белая карточка во всю ширину контейнера */}
|
|
||||||
<div className="flex-1 flex items-start justify-center">
|
<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="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="flex flex-col gap-2">
|
||||||
<div className="w-full bg-[#72B8E2] rounded-[10px] px-3 py-2 flex items-center justify-between">
|
<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 className="text-[14px] font-montserrat font-bold text-white">
|
||||||
@@ -59,7 +233,7 @@ const ModeratorRequestModal = ({ request, onClose, onApprove, onReject }) => {
|
|||||||
Дата
|
Дата
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-montserrat text-white">
|
<span className="text-[10px] font-montserrat text-white">
|
||||||
{request.date || "28.11.2025"}
|
{createdDate || "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="min-w-[80px] bg-[#72B8E2] rounded-[10px] flex flex-col items-center justify-center border border-white/30 px-2 py-1">
|
||||||
@@ -67,27 +241,27 @@ const ModeratorRequestModal = ({ request, onClose, onApprove, onReject }) => {
|
|||||||
Время
|
Время
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-montserrat text-white">
|
<span className="text-[10px] font-montserrat text-white">
|
||||||
{request.time || "13:00"}
|
{createdTime || "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* блок ФИО / адрес */}
|
|
||||||
<div className="w-full bg-[#72B8E2] rounded-[10px] px-3 py-2 flex flex-col gap-1">
|
<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 className="text-[14px] font-montserrat font-bold text-white">
|
||||||
ФИО
|
ФИО
|
||||||
</span>
|
</span>
|
||||||
<p className="text-[12px] font-montserrat text-white leading-[16px]">
|
<p className="text-[12px] font-montserrat text-white leading-[16px]">
|
||||||
{request.fullName || "Клавдия Березова"}
|
{request.requesterName || "Заявитель"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[12px] font-montserrat text-white leading-[14px]">
|
<p className="text-[12px] font-montserrat text-white leading-[14px]">
|
||||||
{request.address || "г. Пермь, ул. Ленина 50"}
|
{request.address
|
||||||
|
? `${request.city ? request.city + ", " : ""}${request.address}`
|
||||||
|
: "Адрес не указан"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* статус + сроки */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div
|
<div
|
||||||
className={`px-3 py-1 rounded-[10px] flex items-center justify-center ${isApproved
|
className={`px-3 py-1 rounded-[10px] flex items-center justify-center ${isApproved
|
||||||
@@ -97,7 +271,7 @@ const ModeratorRequestModal = ({ request, onClose, onApprove, onReject }) => {
|
|||||||
: "bg-[#E9D171]"
|
: "bg-[#E9D171]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-[12px] font-montserrat font-semibold text-black">
|
<span className="text-[12px] font-montserrat font-semibold text-white">
|
||||||
{isApproved
|
{isApproved
|
||||||
? "Принята"
|
? "Принята"
|
||||||
: isRejected
|
: isRejected
|
||||||
@@ -106,17 +280,15 @@ const ModeratorRequestModal = ({ request, onClose, onApprove, onReject }) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end text-[12px] font-montserrat font-light text-black leading-[14px]">
|
<div className="flex flex-col items-end text-[12px] font-montserrat font-light text-black leading-[14px]">
|
||||||
<span>До {request.deadline || "28.11.2025"}</span>
|
<span>До {deadlineDate || "—"}</span>
|
||||||
<span>{request.deadlineTime || "13:00"}</span>
|
<span>{deadlineTime || "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Заголовок задачи */}
|
|
||||||
<p className="text-[16px] leading-[20px] font-montserrat font-semibold text-black">
|
<p className="text-[16px] leading-[20px] font-montserrat font-semibold text-black">
|
||||||
{request.title || "Приобрести продукты пенсионерке"}
|
{request.title || "Задача"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* краткое описание / товары */}
|
|
||||||
{request.description && (
|
{request.description && (
|
||||||
<div className="flex-1 bg-[#F2F2F2] rounded-[10px] px-3 py-2 overflow-y-auto">
|
<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">
|
<p className="text-[12px] leading-[16px] font-montserrat text-black whitespace-pre-line">
|
||||||
@@ -125,38 +297,45 @@ const ModeratorRequestModal = ({ request, onClose, onApprove, onReject }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* если заявка уже отклонена — показать причину */}
|
|
||||||
{isRejected && (
|
{isRejected && (
|
||||||
<div className="w-full bg-[#FFE2E2] rounded-[10px] px-3 py-2">
|
<div className="w-full bg-[#FFE2E2] rounded-[10px] px-3 py-2">
|
||||||
<p className="text-[14px] font-montserrat font-bold text-[#E06767] mb-1">
|
<p className="text-[14px] font-montserrat font-bold text-[#E06767] mb-1">
|
||||||
Причина отклонения
|
Причина отклонения
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[12px] font-montserrat text-black whitespace-pre-line">
|
<p className="text-[12px] font-montserrat text-black whitespace-pre-line">
|
||||||
{request.rejectReason && request.rejectReason.trim().length > 0
|
{(request.rejectReason && request.rejectReason.trim().length > 0
|
||||||
? request.rejectReason
|
? request.rejectReason
|
||||||
: "Причина не указана"}
|
: request.moderation_comment) || "Причина не указана"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-[12px] font-montserrat text-red-500">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* нижняя панель с кнопками — только если заявка ещё на модерации */}
|
{/* Кнопки показываем только для pending_moderation */}
|
||||||
{isPending && (
|
{isPending && (
|
||||||
<div className="mt-4 w-full max-w-[400px] mx-auto flex items-center justify-between gap-3">
|
<div className="mt-4 w-full max-w-[400px] mx-auto flex items-center justify-between gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleApprove}
|
onClick={handleApprove}
|
||||||
className="flex-1 h-10 bg-[#94E067] rounded-[10px] flex items-center justify-center"
|
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">
|
<span className="text-[14px] font-montserrat font-bold text-white">
|
||||||
Принять
|
{submitting ? "Сохранение..." : "Принять"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowRejectPopup(true)}
|
onClick={() => setShowRejectPopup(true)}
|
||||||
className="flex-1 h-10 bg-[#E06767] rounded-[10px] flex items-center justify-center"
|
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 className="text-[14px] font-montserrat font-bold text-white">
|
||||||
Отклонить
|
Отклонить
|
||||||
@@ -166,16 +345,13 @@ const ModeratorRequestModal = ({ request, onClose, onApprove, onReject }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* попап причины отказа во весь экран */}
|
|
||||||
{showRejectPopup && (
|
{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="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">
|
<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 className="font-montserrat font-bold text-[20px] leading-[24px] text-black mb-3">
|
||||||
Причина
|
Причина
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* голубой блок с текстом */}
|
|
||||||
<div className="w-full bg-[#72B8E2] rounded-[10px] px-3 py-3 mb-4 max-h-[50vh]">
|
<div className="w-full bg-[#72B8E2] rounded-[10px] px-3 py-3 mb-4 max-h-[50vh]">
|
||||||
<textarea
|
<textarea
|
||||||
value={rejectReason}
|
value={rejectReason}
|
||||||
@@ -185,20 +361,21 @@ const ModeratorRequestModal = ({ request, onClose, onApprove, onReject }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* кнопки */}
|
|
||||||
<div className="w-full flex flex-col gap-2">
|
<div className="w-full flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleRejectConfirm}
|
onClick={handleRejectConfirm}
|
||||||
className="w-full h-10 bg-[#E06767] rounded-[10px] flex items-center justify-center"
|
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">
|
<span className="text-[16px] font-montserrat font-bold text.white">
|
||||||
Подтвердить
|
{submitting ? "Сохранение..." : "Подтвердить"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowRejectPopup(false)}
|
onClick={() => setShowRejectPopup(false)}
|
||||||
|
disabled={submitting}
|
||||||
className="w-full h-10 bg-white rounded-[10px] border border-[#E06767] flex items-center justify-center"
|
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 className="text-[14px] font-montserrat font-semibold text-[#E06767]">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -14,22 +14,22 @@ const TabBar = () => {
|
|||||||
|
|
||||||
// маршруты по ролям
|
// маршруты по ролям
|
||||||
const routesByRole = {
|
const routesByRole = {
|
||||||
user: [
|
requester: [
|
||||||
{ key: "home", icon: FaHome, href: "/createRequest" },
|
{ key: "home", icon: FaHome, href: "/createRequest" },
|
||||||
{ key: "history", icon: FaClock, href: "/historyRequest" },
|
{ key: "history", icon: FaClock, href: "/historyRequest" },
|
||||||
{ key: "news", icon: FaNewspaper, href: "/news" },
|
{ key: "news", icon: FaNewspaper, href: "/notification" },
|
||||||
{ key: "profile", icon: FaCog, href: "/ProfilePage" },
|
{ key: "profile", icon: FaCog, href: "/ProfilePage" },
|
||||||
],
|
],
|
||||||
volunteer: [
|
volunteer: [
|
||||||
{ key: "home", icon: FaHome, href: "/mainValounter" },
|
{ key: "home", icon: FaHome, href: "/mainValounter" },
|
||||||
{ key: "history", icon: FaClock, href: "/valounterHistoryRequest" },
|
{ key: "history", icon: FaClock, href: "/valounterHistoryRequest" },
|
||||||
{ key: "news", icon: FaNewspaper, href: "/volunteer/news" },
|
{ key: "news", icon: FaNewspaper, href: "/notification" },
|
||||||
{ key: "profile", icon: FaCog, href: "/valounterProfilePage" },
|
{ key: "profile", icon: FaCog, href: "/valounterProfilePage" },
|
||||||
],
|
],
|
||||||
moderator: [
|
moderator: [
|
||||||
{ key: "queue", icon: FaHome, href: "/moderatorMain" },
|
{ key: "queue", icon: FaHome, href: "/moderatorMain" },
|
||||||
{ key: "history", icon: FaClock, href: "/moderatorHistoryRequest" },
|
{ key: "history", icon: FaClock, href: "/moderatorHistoryRequest" },
|
||||||
{ key: "news", icon: FaNewspaper, href: "/moderator/news" },
|
{ key: "news", icon: FaNewspaper, href: "/notification" },
|
||||||
{ key: "profile", icon: FaCog, href: "/moderatorProfilePage" },
|
{ key: "profile", icon: FaCog, href: "/moderatorProfilePage" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,153 +1,254 @@
|
|||||||
import React, { useState } from "react";
|
"use client";
|
||||||
import { FaStar } from "react-icons/fa";
|
|
||||||
|
|
||||||
const RequestDetailsModal = ({ request, onClose }) => {
|
import React, { useEffect, useState } from "react";
|
||||||
const isDone = request.status === "Выполнена";
|
|
||||||
const isInProgress = request.status === "В процессе";
|
|
||||||
|
|
||||||
const [rating, setRating] = useState(0);
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
const [review, setReview] = useState("");
|
|
||||||
|
|
||||||
const handleStarClick = (value) => {
|
// Статус ИМЕННО ОТКЛИКА волонтёра (ResponseStatus)
|
||||||
setRating(value);
|
const responseStatusMap = {
|
||||||
};
|
pending: { label: "Ожидает ответа", color: "#E9D171" },
|
||||||
|
accepted: { label: "Принят", color: "#94E067" },
|
||||||
const handleSubmit = () => {
|
rejected: { label: "Отклонён", color: "#FF8282" },
|
||||||
console.log("Отправить отзыв:", {
|
cancelled: { label: "Отменён", color: "#FF8282" },
|
||||||
id: request.id,
|
|
||||||
status: request.status,
|
|
||||||
rating,
|
|
||||||
review,
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
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 }}
|
|
||||||
>
|
|
||||||
{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">
|
|
||||||
{request.title}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Полная информация о заявке */}
|
|
||||||
<div className="flex flex-col gap-1 text-[12px] font-montserrat text-black">
|
|
||||||
<p>ФИО: {request.fullName}</p>
|
|
||||||
<p>Адрес: {request.address}</p>
|
|
||||||
{request.flat && <p>Квартира: {request.flat}</p>}
|
|
||||||
{request.floor && <p>Этаж: {request.floor}</p>}
|
|
||||||
{request.phone && <p>Телефон: {request.phone}</p>}
|
|
||||||
{request.amount && <p>Сумма: {request.amount}</p>}
|
|
||||||
{request.deadline && <p>Выполнить до: {request.deadline}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Описание / список покупок */}
|
|
||||||
{request.description && (
|
|
||||||
<div className="bg-[#E4E4E4] rounded-2xl px-3 py-2 max-h-[140px] overflow-y-auto">
|
|
||||||
<p className="text-[11px] leading-[13px] font-montserrat whitespace-pre-line">
|
|
||||||
{request.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Блок отзыва + рейтинг — и для Выполнена, и для В процессе */}
|
|
||||||
{(isDone || isInProgress) && (
|
|
||||||
<>
|
|
||||||
<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={
|
|
||||||
isDone
|
|
||||||
? "Напишите, как прошла помощь"
|
|
||||||
: "Напишите, как сейчас идёт выполнение"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 || isInProgress) && (
|
|
||||||
<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">
|
|
||||||
{isDone ? "Оставить отзыв" : "Сохранить прогресс"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RequestDetailsModal;
|
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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -5,71 +5,133 @@ import { useRouter } from "next/navigation";
|
|||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
// фейковые пользователи (3 логина/пароля)
|
// базовый URL из YAML (у себя можешь вынести в .env)
|
||||||
const USERS = [
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
role: "user", // обычный пользователь
|
|
||||||
name: "Пользователь",
|
|
||||||
login: "user@mail.com",
|
|
||||||
password: "user123",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
role: "volunteer",
|
|
||||||
name: "Волонтёр",
|
|
||||||
login: "vol@mail.com",
|
|
||||||
password: "vol123",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
role: "moderator",
|
|
||||||
name: "Модератор",
|
|
||||||
login: "mod@mail.com",
|
|
||||||
password: "mod123",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [user, setUser] = useState(null); // {id, role, name, login}
|
const [user, setUser] = useState(null); // {id, email, role, name, accessToken, refreshToken}
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Поднимаем пользователя из localStorage, чтобы контекст сохранялся между перезагрузками
|
// поднимаем пользователя из localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = typeof window !== "undefined" ? localStorage.getItem("authUser") : null;
|
const saved =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? localStorage.getItem("authUser")
|
||||||
|
: null;
|
||||||
if (saved) {
|
if (saved) {
|
||||||
setUser(JSON.parse(saved));
|
setUser(JSON.parse(saved));
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = async (login, password) => {
|
// основная авторизация: запрос на /auth/login
|
||||||
// имитация запроса на бэк
|
const login = async (email, password) => {
|
||||||
const found = USERS.find(
|
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||||
(u) => u.login === login && u.password === password
|
method: "POST",
|
||||||
);
|
headers: {
|
||||||
if (!found) {
|
"Content-Type": "application/json",
|
||||||
throw new Error("Неверный логин или пароль");
|
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 = {
|
const authUser = {
|
||||||
id: found.id,
|
id: data.user?.id,
|
||||||
role: found.role,
|
email: data.user?.email,
|
||||||
name: found.name,
|
name: data.user?.first_name || data.user?.email,
|
||||||
login: found.login,
|
// роль пока не знаем наверняка — вытащим отдельным запросом
|
||||||
|
role: null,
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 1) сохраняем токены/пользователя
|
||||||
setUser(authUser);
|
setUser(authUser);
|
||||||
localStorage.setItem("authUser", JSON.stringify(authUser));
|
localStorage.setItem("authUser", JSON.stringify(authUser));
|
||||||
|
|
||||||
// после логина перенаправляем на стартовую страницу по роли
|
// 2) тянем роли пользователя (GET /users/me/roles)[file:519]
|
||||||
if (found.role === "user") router.push("/home");
|
try {
|
||||||
if (found.role === "volunteer") router.push("/mainValounter");
|
const rolesRes = await fetch(`${API_BASE}/users/me/roles`, {
|
||||||
if (found.role === "moderator") router.push("/moderatorMain");
|
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 = () => {
|
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);
|
setUser(null);
|
||||||
localStorage.removeItem("authUser");
|
localStorage.removeItem("authUser");
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -110,7 +271,7 @@ const MainVolunteerPage = () => {
|
|||||||
<FaUser className="text-white text-sm" />
|
<FaUser className="text-white text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-montserrat font-extrabold text-[20px] leading-[11px] text-white">
|
<p className="font-montserrat font-extrabold text-[20px] leading-[11px] text-white">
|
||||||
Александр
|
{userName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -125,6 +286,12 @@ const MainVolunteerPage = () => {
|
|||||||
Кому нужна помощь
|
Кому нужна помощь
|
||||||
</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='© OpenStreetMap contributors'
|
attribution="© OpenStreetMap contributors"
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
{/* Маркер волонтёра */}
|
|
||||||
{hasLocation && (
|
{hasLocation && (
|
||||||
<Marker position={position}>
|
<Marker position={position}>
|
||||||
<Popup>Вы здесь</Popup>
|
<Popup>Вы здесь</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
)}
|
)}
|
||||||
{/* Маркеры заявок */}
|
|
||||||
{requests.map((req) => (
|
{requests.map((req) => (
|
||||||
<Marker key={req.id} position={req.coords}>
|
<Marker key={req.id} position={req.coords}>
|
||||||
<Popup>{req.title}</Popup>
|
<Popup>{req.title}</Popup>
|
||||||
@@ -155,6 +320,18 @@ 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}
|
||||||
@@ -174,11 +351,14 @@ const MainVolunteerPage = () => {
|
|||||||
)}
|
)}
|
||||||
<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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +1,174 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { FaBell, FaUser } from "react-icons/fa";
|
import { FaBell, FaUser } from "react-icons/fa";
|
||||||
import TabBar from "../components/TabBar";
|
import TabBar from "../components/TabBar";
|
||||||
import ModeratorRequestModal from "../components/ModeratorRequestDetailsModal";
|
import ModeratorRequestModal from "../components/ModeratorRequestDetailsModal";
|
||||||
|
|
||||||
// история для модератора: только Принята / Отклонена
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
const requests = [
|
|
||||||
{
|
const statusMap = {
|
||||||
id: 1,
|
approved: { label: "Принята", color: "#94E067" },
|
||||||
title: "Приобрести продукты пенсионерке",
|
rejected: { label: "Отклонена", color: "#E06767" },
|
||||||
status: "Принята",
|
};
|
||||||
statusColor: "#94E067",
|
|
||||||
date: "28.11.2025",
|
|
||||||
time: "13:00",
|
|
||||||
createdAt: "28.11.2025",
|
|
||||||
fullName: "Клавдия Березова",
|
|
||||||
address: "г. Пермь, ул. Ленина 50",
|
|
||||||
description: "Купить продукты и принести по указанному адресу.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: "Приобрести медикаменты",
|
|
||||||
status: "Отклонена",
|
|
||||||
statusColor: "#E06767",
|
|
||||||
date: "27.11.2025",
|
|
||||||
time: "15:30",
|
|
||||||
createdAt: "27.11.2025",
|
|
||||||
fullName: "Иванова Анна Петровна",
|
|
||||||
address: "г. Пермь, ул. Пушкина 24",
|
|
||||||
description: "Приобрести необходимые лекарства в ближайшей аптеке.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Сопроводить до поликлиники",
|
|
||||||
status: "Принята",
|
|
||||||
statusColor: "#94E067",
|
|
||||||
date: "26.11.2025",
|
|
||||||
time: "10:00",
|
|
||||||
createdAt: "26.11.2025",
|
|
||||||
fullName: "Сидоров Николай",
|
|
||||||
address: "г. Пермь, ул. Куйбышева 95",
|
|
||||||
description: "Помочь добраться до поликлиники и обратно.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const HistoryRequestModeratorPage = () => {
|
const HistoryRequestModeratorPage = () => {
|
||||||
|
const [requests, setRequests] = useState([]);
|
||||||
const [selectedRequest, setSelectedRequest] = useState(null);
|
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) => {
|
const handleOpen = (req) => {
|
||||||
setSelectedRequest(req);
|
// пробрасываем rawStatus, чтобы модалка знала настоящий статус
|
||||||
|
setSelectedRequest({
|
||||||
|
...req,
|
||||||
|
status: req.rawStatus,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setSelectedRequest(null);
|
setSelectedRequest(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApprove = (req) => {
|
const handleModeratedUpdate = (updated) => {
|
||||||
console.log("Подтверждение принятой заявки (история):", req.id);
|
setRequests((prev) =>
|
||||||
};
|
prev.map((r) =>
|
||||||
|
r.id === updated.id ? { ...r, rawStatus: updated.status } : r
|
||||||
const handleReject = ({ request, reason }) => {
|
)
|
||||||
console.log("Просмотр отклонённой заявки (история):", request.id, reason);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,7 +181,7 @@ const HistoryRequestModeratorPage = () => {
|
|||||||
<FaUser className="text-white text-sm" />
|
<FaUser className="text-white text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-montserrat font-extrabold text-[20px] leading-[22px] text-white">
|
<p className="font-montserrat font-extrabold text-[20px] leading-[22px] text-white">
|
||||||
Модератор
|
{moderatorName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -89,8 +196,26 @@ const HistoryRequestModeratorPage = () => {
|
|||||||
История заявок
|
История заявок
|
||||||
</h1>
|
</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]">
|
<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}
|
||||||
@@ -98,7 +223,6 @@ const HistoryRequestModeratorPage = () => {
|
|||||||
onClick={() => handleOpen(req)}
|
onClick={() => handleOpen(req)}
|
||||||
className="w-full text-left bg-white rounded-xl px-3 py-2 flex flex-col gap-1"
|
className="w-full text-left bg-white rounded-xl px-3 py-2 flex flex-col gap-1"
|
||||||
>
|
>
|
||||||
{/* верхняя строка: статус + дата/время */}
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center px-2 py-0.5 rounded-full font-montserrat text-[12px] font-semibold text-white"
|
className="inline-flex items-center justify-center px-2 py-0.5 rounded-full font-montserrat text-[12px] font-semibold text-white"
|
||||||
@@ -116,12 +240,10 @@ const HistoryRequestModeratorPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Заголовок заявки */}
|
|
||||||
<p className="font-montserrat font-semibold text-[15px] leading-[18px] text-black mt-1">
|
<p className="font-montserrat font-semibold text-[15px] leading-[18px] text-black mt-1">
|
||||||
{req.title}
|
{req.title}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Краткое ФИО/адрес */}
|
|
||||||
<p className="font-montserrat text-[11px] text-black/80">
|
<p className="font-montserrat text-[11px] text-black/80">
|
||||||
{req.fullName}
|
{req.fullName}
|
||||||
</p>
|
</p>
|
||||||
@@ -129,7 +251,6 @@ const HistoryRequestModeratorPage = () => {
|
|||||||
{req.address}
|
{req.address}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Кнопка "Развернуть" */}
|
|
||||||
<div className="mt-2 w-full bg-[#94E067] rounded-lg py-3 flex items-center justify-center">
|
<div className="mt-2 w-full bg-[#94E067] rounded-lg py-3 flex items-center justify-center">
|
||||||
<span className="font-montserrat font-bold text-[15px] leading-[18px] text-white">
|
<span className="font-montserrat font-bold text-[15px] leading-[18px] text-white">
|
||||||
Развернуть
|
Развернуть
|
||||||
@@ -139,13 +260,11 @@ const HistoryRequestModeratorPage = () => {
|
|||||||
))}
|
))}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Попап модератора */}
|
|
||||||
{selectedRequest && (
|
{selectedRequest && (
|
||||||
<ModeratorRequestModal
|
<ModeratorRequestModal
|
||||||
request={selectedRequest}
|
request={selectedRequest}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onApprove={handleApprove}
|
onModerated={handleModeratedUpdate}
|
||||||
onReject={handleReject}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +1,163 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } 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/ModeratorRequestDetailsModal";
|
import RequestDetailsModal from "../components/ModeratorRequestDetailsModal";
|
||||||
|
|
||||||
const requests = [
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
{
|
|
||||||
id: 1,
|
const statusMap = {
|
||||||
title: "Приобрести продукты пенсионерке",
|
pending_moderation: { label: "На модерации", color: "#E9D171" },
|
||||||
status: "На модерации",
|
approved: { label: "Принята", color: "#94E067" },
|
||||||
statusColor: "#E9D171",
|
in_progress: { label: "В процессе", color: "#E971E1" },
|
||||||
date: "До 28.11.2025",
|
completed: { label: "Выполнена", color: "#71A5E9" },
|
||||||
time: "13:00",
|
cancelled: { label: "Отменена", color: "#FF8282" },
|
||||||
createdAt: "28.11.2025",
|
rejected: { label: "Отклонена", color: "#FF8282" },
|
||||||
description: "Купить продукты и принести по адресу.",
|
};
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: "Приобрести продукты пенсионерке",
|
|
||||||
status: "На модерации",
|
|
||||||
statusColor: "#E9D171",
|
|
||||||
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: "#E9D171",
|
|
||||||
date: "До 28.11.2025",
|
|
||||||
time: "13:00",
|
|
||||||
createdAt: "28.11.2025",
|
|
||||||
description: "Купить продукты и принести по адресу.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: "Приобрести продукты пенсионерке",
|
|
||||||
status: "На модерации",
|
|
||||||
statusColor: "#E9D171",
|
|
||||||
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 handleOpen = (req) => {
|
const [moderatorName, setModeratorName] = useState("Модератор");
|
||||||
setSelectedRequest(req);
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
// профиль модератора
|
||||||
setSelectedRequest(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 (
|
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">
|
||||||
@@ -79,7 +169,7 @@ const HistoryRequestPage = () => {
|
|||||||
<FaUser className="text-white text-sm" />
|
<FaUser className="text-white text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-montserrat font-extrabold text-[20px] leading-[11px] text-white">
|
<p className="font-montserrat font-extrabold text-[20px] leading-[11px] text-white">
|
||||||
Александр
|
{moderatorName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -91,11 +181,29 @@ const HistoryRequestPage = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<h1 className="font-montserrat font-extrabold text-[20px] leading-[22px] text-white mb-3">
|
<h1 className="font-montserrat font-extrabold text-[20px] leading-[22px] text-white mb-3">
|
||||||
История заявок
|
Активные Заявки
|
||||||
</h1>
|
</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]">
|
<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}
|
||||||
@@ -103,10 +211,9 @@ const HistoryRequestPage = () => {
|
|||||||
onClick={() => handleOpen(req)}
|
onClick={() => handleOpen(req)}
|
||||||
className="w-full text-left bg-white rounded-xl px-3 py-2 flex flex-col gap-1"
|
className="w-full text-left bg-white rounded-xl px-3 py-2 flex flex-col gap-1"
|
||||||
>
|
>
|
||||||
{/* верхняя строка: статус + дата/время */}
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center px-2 py-0.5 rounded-full font-montserrat text-[12px] font-light text-black"
|
className="inline-flex.items-center justify-center px-2 py-0.5 rounded-full font-montserrat text-[12px] font-light text-black"
|
||||||
style={{ backgroundColor: req.statusColor }}
|
style={{ backgroundColor: req.statusColor }}
|
||||||
>
|
>
|
||||||
{req.status}
|
{req.status}
|
||||||
@@ -121,12 +228,10 @@ const HistoryRequestPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Заголовок заявки */}
|
|
||||||
<p className="font-montserrat font-semibold text-[15px] leading-[18px] text-black mt-1">
|
<p className="font-montserrat font-semibold text-[15px] leading-[18px] text-black mt-1">
|
||||||
{req.title}
|
{req.title}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Кнопка "Развернуть" */}
|
|
||||||
<div className="mt-2 w-full bg-[#94E067] rounded-lg py-3 flex items-center justify-center">
|
<div className="mt-2 w-full bg-[#94E067] rounded-lg py-3 flex items-center justify-center">
|
||||||
<span className="font-montserrat font-bold text-[15px] leading-[18px] text-white">
|
<span className="font-montserrat font-bold text-[15px] leading-[18px] text-white">
|
||||||
Развернуть
|
Развернуть
|
||||||
@@ -136,9 +241,11 @@ const HistoryRequestPage = () => {
|
|||||||
))}
|
))}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Попап */}
|
|
||||||
{selectedRequest && (
|
{selectedRequest && (
|
||||||
<RequestDetailsModal request={selectedRequest} onClose={handleClose} />
|
<RequestDetailsModal
|
||||||
|
request={selectedRequest}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TabBar />
|
<TabBar />
|
||||||
@@ -148,91 +255,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>
|
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,90 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { FaUserCircle, FaStar } from "react-icons/fa";
|
import { FaUserCircle, FaStar } from "react-icons/fa";
|
||||||
import TabBar from "../components/TabBar";
|
import TabBar from "../components/TabBar";
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
const ModeratorProfilePage = () => {
|
const ModeratorProfilePage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const fullName = "Иванов Александр Сергеевич";
|
const [profile, setProfile] = useState(null);
|
||||||
const birthDate = "12.03.1990";
|
const [loading, setLoading] = useState(true);
|
||||||
const rating = 4.8;
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const getAccessToken = () => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const saved = localStorage.getItem("authUser");
|
||||||
|
const authUser = saved ? JSON.parse(saved) : null;
|
||||||
|
return authUser?.accessToken || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
if (!API_BASE) {
|
||||||
|
setError("API_BASE_URL не задан");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
setError("Вы не авторизованы");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/users/me`, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = "Не удалось загрузить профиль";
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) msg = data.error;
|
||||||
|
} catch {
|
||||||
|
const text = await res.text();
|
||||||
|
if (text) msg = text;
|
||||||
|
}
|
||||||
|
setError(msg);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json(); // UserProfile[web:598]
|
||||||
|
setProfile(data);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || "Ошибка сети");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fullName =
|
||||||
|
profile &&
|
||||||
|
([profile.first_name, profile.last_name].filter(Boolean).join(" ") ||
|
||||||
|
profile.email);
|
||||||
|
|
||||||
|
const rating =
|
||||||
|
profile && profile.volunteer_rating != null
|
||||||
|
? Number(profile.volunteer_rating)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const birthDateText = profile?.created_at
|
||||||
|
? new Date(profile.created_at).toLocaleDateString("ru-RU")
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
const email = profile?.email || "—";
|
||||||
|
const phone = profile?.phone || "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full bg-[#90D2F9] flex justify-center px-4">
|
<div className="min-h-screen w-full bg-[#90D2F9] flex justify-center px-4">
|
||||||
@@ -32,72 +106,99 @@ const ModeratorProfilePage = () => {
|
|||||||
|
|
||||||
{/* Карточка профиля */}
|
{/* Карточка профиля */}
|
||||||
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
|
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
|
||||||
{/* Аватар */}
|
{loading && (
|
||||||
<FaUserCircle className="text-[#72B8E2] w-20 h-20" />
|
<p className="font-montserrat text-[14px] text-black">
|
||||||
|
Загрузка профиля...
|
||||||
{/* ФИО и рейтинг */}
|
|
||||||
<div className="text-center space-y-1">
|
|
||||||
{/* <p className="font-montserrat font-extrabold text-[16px] text-black">
|
|
||||||
ФИО
|
|
||||||
</p> */}
|
|
||||||
<p className="font-montserrat font-bold text-[20px] text-black">
|
|
||||||
{fullName}
|
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Рейтинг + звезды */}
|
{error && !loading && (
|
||||||
<div className="mt-2 flex items-center justify-center gap-2">
|
<p className="font-montserrat text-[12px] text-red-500">
|
||||||
<span className="font-montserrat font-semibold text-[14px] text-black">
|
{error}
|
||||||
Рейтинг: {rating.toFixed(1)}
|
</p>
|
||||||
</span>
|
)}
|
||||||
<div className="flex gap-1">
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
{!loading && profile && (
|
||||||
<FaStar
|
<>
|
||||||
key={star}
|
{/* Аватар */}
|
||||||
size={18}
|
{profile.avatar_url ? (
|
||||||
className={
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
star <= Math.round(rating)
|
<img
|
||||||
? "text-[#F6E168] fill-[#F6E168]"
|
src={profile.avatar_url}
|
||||||
: "text-[#F6E168] fill-[#F6E168]/30"
|
alt="Аватар"
|
||||||
}
|
className="w-20 h-20 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
))}
|
) : (
|
||||||
|
<FaUserCircle className="text-[#72B8E2] w-20 h-20" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ФИО и рейтинг */}
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<p className="font-montserrat font-bold text-[20px] text-black">
|
||||||
|
{fullName}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{rating != null && (
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-2">
|
||||||
|
<span className="font-montserrat font-semibold text-[14px] text-black">
|
||||||
|
Рейтинг: {rating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<FaStar
|
||||||
|
key={star}
|
||||||
|
size={18}
|
||||||
|
className={
|
||||||
|
star <= Math.round(rating)
|
||||||
|
? "text-[#F6E168] fill-[#F6E168]"
|
||||||
|
: "text-[#F6E168] fill-[#F6E168]/30"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Контакты и день рождения */}
|
{/* Контакты и «дата рождения» как дата регистрации */}
|
||||||
<div className="w-full bg-[#72B8E2] rounded-2xl p-3 text-white space-y-1">
|
<div className="w-full bg-[#72B8E2] rounded-2xl p-3 text-white space-y-1">
|
||||||
<p className="font-montserrat text-[12px]">
|
<p className="font-montserrat text-[12px]">
|
||||||
Дата рождения: {birthDate}
|
Дата регистрации: {birthDateText}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-montserrat text-[12px]">
|
<p className="font-montserrat text-[12px]">Почта: {email}</p>
|
||||||
Почта: example@mail.com
|
<p className="font-montserrat text-[12px]">
|
||||||
</p>
|
Телефон: {phone}
|
||||||
<p className="font-montserrat text-[12px]">
|
</p>
|
||||||
Телефон: +7 (900) 000-00-00
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Кнопки */}
|
{/* Кнопки */}
|
||||||
<div className="w-full flex flex-col gap-2 mt-2">
|
<div className="w-full flex flex-col gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push("/valounterProfileSettings")}
|
onClick={() => router.push("/moderatorProfileSettings")}
|
||||||
className="w-full bg-[#E0B267] rounded-full py-2 flex items-center justify-center"
|
className="w-full bg-[#E0B267] rounded-full py-2 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
||||||
Редактировать профиль
|
Редактировать профиль
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full bg-[#E07567] rounded-full py-2 flex items-center justify-center"
|
className="w-full bg-[#E07567] rounded-full py-2 flex items-center justify-center"
|
||||||
>
|
onClick={() => {
|
||||||
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
if (typeof window !== "undefined") {
|
||||||
Выйти из аккаунта
|
localStorage.removeItem("authUser");
|
||||||
</span>
|
}
|
||||||
</button>
|
router.push("/");
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
||||||
|
Выйти из аккаунта
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<TabBar />
|
<TabBar />
|
||||||
|
|||||||
@@ -1,31 +1,145 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { FaUserCircle } from "react-icons/fa";
|
import { FaUserCircle } from "react-icons/fa";
|
||||||
import TabBar from "../components/TabBar";
|
import TabBar from "../components/TabBar";
|
||||||
|
|
||||||
const ValounterProfileSettingsPage = () => {
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
const ModeratorProfileSettingsPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [avatarUrl, setAvatarUrl] = useState("");
|
const [avatarUrl, setAvatarUrl] = useState("");
|
||||||
const [fullName, setFullName] = useState("Иванов Александр Сергеевич");
|
const [firstName, setFirstName] = useState("");
|
||||||
const [birthDate, setBirthDate] = useState("1990-03-12");
|
const [lastName, setLastName] = useState("");
|
||||||
const [email, setEmail] = useState("example@mail.com");
|
const [email, setEmail] = useState("");
|
||||||
const [phone, setPhone] = useState("+7 (900) 000-00-00");
|
const [phone, setPhone] = useState("");
|
||||||
|
|
||||||
const handleSave = (e) => {
|
const [loading, setLoading] = useState(true);
|
||||||
e.preventDefault();
|
const [saving, setSaving] = useState(false);
|
||||||
console.log("Сохранить профиль:", {
|
const [error, setError] = useState("");
|
||||||
avatarUrl,
|
const [saveMessage, setSaveMessage] = useState("");
|
||||||
fullName,
|
|
||||||
birthDate,
|
const getAccessToken = () => {
|
||||||
email,
|
if (typeof window === "undefined") return null;
|
||||||
phone,
|
const saved = localStorage.getItem("authUser");
|
||||||
});
|
const authUser = saved ? JSON.parse(saved) : null;
|
||||||
// здесь будет запрос на бэк
|
return authUser?.accessToken || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// загрузить профиль
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
if (!API_BASE) {
|
||||||
|
setError("API_BASE_URL не задан");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
setError("Вы не авторизованы");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/users/me`, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = "Не удалось загрузить профиль";
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) msg = data.error;
|
||||||
|
} catch {
|
||||||
|
const text = await res.text();
|
||||||
|
if (text) msg = text;
|
||||||
|
}
|
||||||
|
setError(msg);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json(); // UserProfile[web:598]
|
||||||
|
setFirstName(data.first_name || "");
|
||||||
|
setLastName(data.last_name || "");
|
||||||
|
setEmail(data.email || "");
|
||||||
|
setPhone(data.phone || "");
|
||||||
|
setAvatarUrl(data.avatar_url || "");
|
||||||
|
setLoading(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || "Ошибка сети");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!API_BASE) return;
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
setError("Вы не авторизованы");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
setSaveMessage("");
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
first_name: firstName || null,
|
||||||
|
last_name: lastName || null,
|
||||||
|
phone: phone || null,
|
||||||
|
}; // UpdateProfileInput[web:598]
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/users/me`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = "Не удалось сохранить профиль";
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) msg = data.error;
|
||||||
|
} catch {
|
||||||
|
const text = await res.text();
|
||||||
|
if (text) msg = text;
|
||||||
|
}
|
||||||
|
setError(msg);
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await res.json();
|
||||||
|
setSaveMessage("Изменения сохранены");
|
||||||
|
setSaving(false);
|
||||||
|
|
||||||
|
setFirstName(updated.first_name || "");
|
||||||
|
setLastName(updated.last_name || "");
|
||||||
|
setPhone(updated.phone || "");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || "Ошибка сети");
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fullName = [firstName, lastName].filter(Boolean).join(" ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full bg-[#90D2F9] flex justify-center px-4">
|
<div className="min-h-screen w-full bg-[#90D2F9] flex justify-center px-4">
|
||||||
<div className="relative w-full max-w-md flex flex-col pb-20 pt-4">
|
<div className="relative w-full max-w-md flex flex-col pb-20 pt-4">
|
||||||
@@ -46,102 +160,128 @@ const ValounterProfileSettingsPage = () => {
|
|||||||
|
|
||||||
{/* Карточка настроек */}
|
{/* Карточка настроек */}
|
||||||
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
|
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
|
||||||
{/* Аватар */}
|
{loading && (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<p className="font-montserrat text-[14px] text-black">
|
||||||
<div className="w-24 h-24 rounded-full bg-[#E5F3FB] flex items-center justify-center overflow-hidden">
|
Загрузка профиля...
|
||||||
{avatarUrl ? (
|
</p>
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
)}
|
||||||
<img
|
|
||||||
src={avatarUrl}
|
{!loading && (
|
||||||
alt="Аватар"
|
<>
|
||||||
className="w-full h-full object-cover"
|
{/* Аватар */}
|
||||||
/>
|
<div className="flex flex-col items-center gap-2">
|
||||||
) : (
|
<div className="w-24 h-24 rounded-full bg-[#E5F3FB] flex items-center justify-center overflow-hidden">
|
||||||
<FaUserCircle className="text-[#72B8E2] w-20 h-20" />
|
{avatarUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="Аватар"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FaUserCircle className="text-[#72B8E2] w-20 h-20" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="font-montserrat text-[12px] text-[#72B8E2] underline cursor-pointer">
|
||||||
|
Загрузить аватар
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setAvatarUrl(url);
|
||||||
|
// загрузку файла на бэк можно добавить отдельно
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="font-montserrat text-[12px] text-red-500">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saveMessage && (
|
||||||
|
<p className="font-montserrat text-[12px] text-green-600">
|
||||||
|
{saveMessage}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<label className="font-montserrat text-[12px] text-[#72B8E2] underline cursor-pointer">
|
|
||||||
Загрузить аватар
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
setAvatarUrl(url);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSave} className="w-full flex flex-col gap-3">
|
<form
|
||||||
{/* ФИО */}
|
onSubmit={handleSave}
|
||||||
<div className="flex flex-col gap-1">
|
className="w-full flex flex-col gap-3"
|
||||||
<label className="font-montserrat text-[12px] text-black">
|
>
|
||||||
ФИО
|
{/* Имя */}
|
||||||
</label>
|
<div className="flex flex-col gap-1">
|
||||||
<input
|
<label className="font-montserrat text-[12px] text-black">
|
||||||
type="text"
|
Имя
|
||||||
value={fullName}
|
</label>
|
||||||
onChange={(e) => setFullName(e.target.value)}
|
<input
|
||||||
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
type="text"
|
||||||
placeholder="Введите ФИО"
|
value={firstName}
|
||||||
/>
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
</div>
|
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
||||||
|
placeholder="Введите имя"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Дата рождения */}
|
{/* Фамилия */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="font-montserrat text-[12px] text-black">
|
<label className="font-montserrat text-[12px] text-black">
|
||||||
Дата рождения
|
Фамилия
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="text"
|
||||||
value={birthDate}
|
value={lastName}
|
||||||
onChange={(e) => setBirthDate(e.target.value)}
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text-white outline-none border border-transparent focus:border-white/70"
|
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
||||||
/>
|
placeholder="Введите фамилию"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Почта */}
|
{/* Почта (только отображение) */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="font-montserrat text-[12px] text-black">
|
<label className="font-montserrat text-[12px] text-black">
|
||||||
Почта
|
Почта
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
disabled
|
||||||
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
className="w-full rounded-full bg-[#72B8E2]/60 px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent cursor-not-allowed"
|
||||||
placeholder="example@mail.com"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Телефон */}
|
{/* Телефон */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="font-montserrat text-[12px] text-black">
|
<label className="font-montserrat text-[12px] text-black">
|
||||||
Телефон
|
Телефон
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text:white.placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
||||||
placeholder="+7 (900) 000-00-00"
|
placeholder="+7 900 000 00 00"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопка сохранить */}
|
{/* Кнопка сохранить */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mt-2 w-full bg-[#94E067] rounded-full py-2.5 flex items-center justify-center"
|
disabled={saving}
|
||||||
>
|
className="mt-2 w-full bg-[#94E067] rounded-full py-2.5 flex items-center justify-center disabled:opacity-60"
|
||||||
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
>
|
||||||
Сохранить изменения
|
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
||||||
</span>
|
{saving ? "Сохранение..." : "Сохранить изменения"}
|
||||||
</button>
|
</span>
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<TabBar />
|
<TabBar />
|
||||||
@@ -150,4 +290,4 @@ const ValounterProfileSettingsPage = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ValounterProfileSettingsPage;
|
export default ModeratorProfileSettingsPage;
|
||||||
|
|||||||
101
app/notification/page.jsx
Normal file
101
app/notification/page.jsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { FaBell, FaInfoCircle } from "react-icons/fa";
|
||||||
|
import TabBar from "../components/TabBar";
|
||||||
|
|
||||||
|
const notifications = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Обновление приложения",
|
||||||
|
date: "15.12.2025",
|
||||||
|
type: "update",
|
||||||
|
text: "Мы улучшили стабильность работы и ускорили загрузку заявок.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: "Новые возможности для волонтёров",
|
||||||
|
date: "10.12.2025",
|
||||||
|
type: "feature",
|
||||||
|
text: "Теперь можно оставлять отзывы о заказчиках после выполнения заявок.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Советы по безопасности",
|
||||||
|
date: "05.12.2025",
|
||||||
|
type: "info",
|
||||||
|
text: "Всегда уточняйте детали заявки и не передавайте данные третьим лицам.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const typeConfig = {
|
||||||
|
update: { label: "Обновление", color: "#71A5E9" },
|
||||||
|
feature: { label: "Новая функция", color: "#94E067" },
|
||||||
|
info: { label: "Информация", color: "#E9D171" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotificationsPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen w-full bg-[#90D2F9] flex justify-center px-4">
|
||||||
|
<div className="relative w-full max-w-md flex flex-col pb-20 pt-4">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full border border-white flex items-center justify-center">
|
||||||
|
<FaBell className="text-white text-sm" />
|
||||||
|
</div>
|
||||||
|
<p className="font-montserrat font-extrabold text-[20px] leading-[22px] text-white">
|
||||||
|
Уведомления
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Список уведомлений */}
|
||||||
|
<main className="bg-white rounded-xl p-4 flex flex-col gap-3 max-h-[80vh] overflow-y-auto">
|
||||||
|
{notifications.length === 0 && (
|
||||||
|
<p className="font-montserrat text-sm text-black">
|
||||||
|
Пока нет уведомлений
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notifications.map((n) => {
|
||||||
|
const cfg = typeConfig[n.type] || {
|
||||||
|
label: "Уведомление",
|
||||||
|
color: "#E2E2E2",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
className="w-full rounded-xl bg-[#F5F5F5] px-3 py-3 flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full font-montserrat text-[10px] font-semibold text-white"
|
||||||
|
style={{ backgroundColor: cfg.color }}
|
||||||
|
>
|
||||||
|
<FaInfoCircle className="text-[10px]" />
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
<p className="font-montserrat text-[10px] text-black/70">
|
||||||
|
{n.date}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="font-montserrat font-semibold text-[14px] text-black">
|
||||||
|
{n.title}
|
||||||
|
</p>
|
||||||
|
<p className="font-montserrat text-[12px] text-black/80">
|
||||||
|
{n.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<TabBar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationsPage;
|
||||||
@@ -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>
|
||||||
|
|||||||
124
app/reg/page.jsx
124
app/reg/page.jsx
@@ -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>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const RecPasswordCodePage = () => {
|
|||||||
|
|
||||||
setError("");
|
setError("");
|
||||||
console.log("Подтверждение кода:", code);
|
console.log("Подтверждение кода:", code);
|
||||||
router.push("/home");
|
router.push("/");
|
||||||
// TODO: запрос на бэк и переход на страницу смены пароля
|
// TODO: запрос на бэк и переход на страницу смены пароля
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +1,169 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } 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/ValounterRequestDetailsModal";
|
import RequestDetailsModal from "../components/ValounterRequestDetailsModal";
|
||||||
|
|
||||||
const requests = [
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
{
|
|
||||||
id: 4,
|
// Статусы ОТКЛИКА волонтёра (ResponseStatus)
|
||||||
title: "Приобрести продукты пенсионерке",
|
const responseStatusMap = {
|
||||||
status: "Выполнена",
|
pending: { label: "Ожидает ответа", color: "#E9D171" },
|
||||||
statusColor: "#71A5E9",
|
accepted: { label: "Принят", color: "#94E067" },
|
||||||
date: "До 28.11.2025",
|
rejected: { label: "Отклонён", color: "#FF8282" },
|
||||||
time: "13:00",
|
cancelled: { label: "Отменён", color: "#FF8282" },
|
||||||
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 [userName, setUserName] = useState("Волонтёр");
|
||||||
|
const [requests, setRequests] = useState([]);
|
||||||
const [selectedRequest, setSelectedRequest] = useState(null);
|
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) => {
|
const handleOpen = (req) => {
|
||||||
setSelectedRequest(req);
|
setSelectedRequest(req);
|
||||||
};
|
};
|
||||||
@@ -68,8 +181,8 @@ 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">
|
<p className="font-montserrat font-extrabold text-[20px] leading-[22px] text-white">
|
||||||
Александр
|
{userName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -84,8 +197,26 @@ const HistoryRequestPage = () => {
|
|||||||
История заявок
|
История заявок
|
||||||
</h1>
|
</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]">
|
<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}
|
||||||
@@ -93,10 +224,9 @@ const HistoryRequestPage = () => {
|
|||||||
onClick={() => handleOpen(req)}
|
onClick={() => handleOpen(req)}
|
||||||
className="w-full text-left bg-white rounded-xl px-3 py-2 flex flex-col gap-1"
|
className="w-full text-left bg-white rounded-xl px-3 py-2 flex flex-col gap-1"
|
||||||
>
|
>
|
||||||
{/* верхняя строка: статус + дата/время */}
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center px-2 py-0.5 rounded-full font-montserrat text-[12px] font-light text-black"
|
className="inline-flex items-center justify-center px-2 py-0.5 rounded-full font-montserrat text-[12px] font-semibold text-white"
|
||||||
style={{ backgroundColor: req.statusColor }}
|
style={{ backgroundColor: req.statusColor }}
|
||||||
>
|
>
|
||||||
{req.status}
|
{req.status}
|
||||||
@@ -111,13 +241,22 @@ const HistoryRequestPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Заголовок заявки */}
|
|
||||||
<p className="font-montserrat font-semibold text-[15px] leading-[18px] text-black mt-1">
|
<p className="font-montserrat font-semibold text-[15px] leading-[18px] text-black mt-1">
|
||||||
{req.title}
|
{req.title}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Кнопка "Развернуть" */}
|
{req.requesterName && (
|
||||||
<div className="mt-2 w-full bg-[#94E067] rounded-lg py-3 flex items-center justify-center">
|
<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 className="font-montserrat font-bold text-[15px] leading-[18px] text-white">
|
||||||
Развернуть
|
Развернуть
|
||||||
</span>
|
</span>
|
||||||
@@ -126,7 +265,6 @@ const HistoryRequestPage = () => {
|
|||||||
))}
|
))}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Попап */}
|
|
||||||
{selectedRequest && (
|
{selectedRequest && (
|
||||||
<RequestDetailsModal request={selectedRequest} onClose={handleClose} />
|
<RequestDetailsModal request={selectedRequest} onClose={handleClose} />
|
||||||
)}
|
)}
|
||||||
@@ -138,91 +276,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>
|
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,95 @@
|
|||||||
"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 ValounterProfilePage = () => {
|
const ValounterProfilePage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const fullName = "Иванов Александр Сергеевич";
|
const [profile, setProfile] = useState(null);
|
||||||
const birthDate = "12.03.1990";
|
const [loading, setLoading] = useState(true);
|
||||||
const rating = 4.8;
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const getAccessToken = () => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const saved = localStorage.getItem("authUser");
|
||||||
|
const authUser = saved ? JSON.parse(saved) : null;
|
||||||
|
return authUser?.accessToken || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
if (!API_BASE) {
|
||||||
|
setError("API_BASE_URL не задан");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
setError("Вы не авторизованы");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/users/me`, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = "Не удалось загрузить профиль";
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) msg = data.error;
|
||||||
|
} catch {
|
||||||
|
const text = await res.text();
|
||||||
|
if (text) msg = text;
|
||||||
|
}
|
||||||
|
setError(msg);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json(); // UserProfile[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 (
|
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 mb-4">
|
<header className="flex items-center mb-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -30,74 +104,100 @@ const ValounterProfilePage = () => {
|
|||||||
<span className="w-8" />
|
<span className="w-8" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Карточка профиля */}
|
|
||||||
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
|
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
|
||||||
{/* Аватар */}
|
{loading && (
|
||||||
<FaUserCircle className="text-[#72B8E2] w-20 h-20" />
|
<p className="font-montserrat text-[14px] text-black">
|
||||||
|
Загрузка профиля...
|
||||||
{/* ФИО и рейтинг */}
|
|
||||||
<div className="text-center space-y-1">
|
|
||||||
{/* <p className="font-montserrat font-extrabold text-[16px] text-black">
|
|
||||||
ФИО
|
|
||||||
</p> */}
|
|
||||||
<p className="font-montserrat font-bold text-[20px] text-black">
|
|
||||||
{fullName}
|
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Рейтинг + звезды */}
|
{error && !loading && (
|
||||||
<div className="mt-2 flex items-center justify-center gap-2">
|
<p className="font-montserrat text-[12px] text-red-500">
|
||||||
<span className="font-montserrat font-semibold text-[14px] text-black">
|
{error}
|
||||||
Рейтинг: {rating.toFixed(1)}
|
</p>
|
||||||
</span>
|
)}
|
||||||
<div className="flex gap-1">
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
{!loading && profile && (
|
||||||
<FaStar
|
<>
|
||||||
key={star}
|
{/* Аватар */}
|
||||||
size={18}
|
{profile.avatar_url ? (
|
||||||
className={
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
star <= Math.round(rating)
|
<img
|
||||||
? "text-[#F6E168] fill-[#F6E168]"
|
src={profile.avatar_url}
|
||||||
: "text-[#F6E168] fill-[#F6E168]/30"
|
alt="Аватар"
|
||||||
}
|
className="w-20 h-20 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
))}
|
) : (
|
||||||
|
<FaUserCircle className="text-[#72B8E2] w-20 h-20" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ФИО и рейтинг */}
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<p className="font-montserrat font-bold text-[20px] text-black">
|
||||||
|
{fullName}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{rating != null && (
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-2">
|
||||||
|
<span className="font-montserrat font-semibold text-[14px] text-black">
|
||||||
|
Рейтинг: {rating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<FaStar
|
||||||
|
key={star}
|
||||||
|
size={18}
|
||||||
|
className={
|
||||||
|
star <= Math.round(rating)
|
||||||
|
? "text-[#F6E168] fill-[#F6E168]"
|
||||||
|
: "text-[#F6E168] fill-[#F6E168]/30"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Контакты и день рождения */}
|
{/* Контакты и «дата рождения» (условно) */}
|
||||||
<div className="w-full bg-[#72B8E2] rounded-2xl p-3 text-white space-y-1">
|
<div className="w-full bg-[#72B8E2] rounded-2xl p-3 text-white space-y-1">
|
||||||
<p className="font-montserrat text-[12px]">
|
<p className="font-montserrat text-[12px]">
|
||||||
Дата рождения: {birthDate}
|
Дата регистрации: {birthDateText}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-montserrat text-[12px]">
|
<p className="font-montserrat text-[12px]">Почта: {email}</p>
|
||||||
Почта: example@mail.com
|
<p className="font-montserrat text-[12px]">
|
||||||
</p>
|
Телефон: {phone}
|
||||||
<p className="font-montserrat text-[12px]">
|
</p>
|
||||||
Телефон: +7 (900) 000-00-00
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Кнопки */}
|
{/* Кнопки */}
|
||||||
<div className="w-full flex flex-col gap-2 mt-2">
|
<div className="w-full flex flex-col gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push("/valounterProfileSettings")}
|
onClick={() => router.push("/valounterProfileSettings")}
|
||||||
className="w-full bg-[#E0B267] rounded-full py-2 flex items-center justify-center"
|
className="w-full bg-[#E0B267] rounded-full py-2 flex.items-center justify-center"
|
||||||
>
|
>
|
||||||
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
||||||
Редактировать профиль
|
Редактировать профиль
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full bg-[#E07567] rounded-full py-2 flex items-center justify-center"
|
className="w-full bg-[#E07567] rounded-full py-2 flex items-center justify-center"
|
||||||
>
|
onClick={() => {
|
||||||
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
if (typeof window !== "undefined") {
|
||||||
Выйти из аккаунта
|
localStorage.removeItem("authUser");
|
||||||
</span>
|
}
|
||||||
</button>
|
router.push("/");
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
||||||
|
Выйти из аккаунта
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<TabBar />
|
<TabBar />
|
||||||
|
|||||||
@@ -1,31 +1,146 @@
|
|||||||
"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 ValounterProfileSettingsPage = () => {
|
const ValounterProfileSettingsPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [avatarUrl, setAvatarUrl] = useState("");
|
const [avatarUrl, setAvatarUrl] = useState("");
|
||||||
const [fullName, setFullName] = useState("Иванов Александр Сергеевич");
|
const [firstName, setFirstName] = useState("");
|
||||||
const [birthDate, setBirthDate] = useState("1990-03-12");
|
const [lastName, setLastName] = useState("");
|
||||||
const [email, setEmail] = useState("example@mail.com");
|
const [email, setEmail] = useState("");
|
||||||
const [phone, setPhone] = useState("+7 (900) 000-00-00");
|
const [phone, setPhone] = useState("");
|
||||||
|
|
||||||
const handleSave = (e) => {
|
const [loading, setLoading] = useState(true);
|
||||||
e.preventDefault();
|
const [saving, setSaving] = useState(false);
|
||||||
console.log("Сохранить профиль:", {
|
const [error, setError] = useState("");
|
||||||
avatarUrl,
|
const [saveMessage, setSaveMessage] = useState("");
|
||||||
fullName,
|
|
||||||
birthDate,
|
const getAccessToken = () => {
|
||||||
email,
|
if (typeof window === "undefined") return null;
|
||||||
phone,
|
const saved = localStorage.getItem("authUser");
|
||||||
});
|
const authUser = saved ? JSON.parse(saved) : null;
|
||||||
// здесь будет запрос на бэк
|
return authUser?.accessToken || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// загрузить профиль
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
if (!API_BASE) {
|
||||||
|
setError("API_BASE_URL не задан");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
setError("Вы не авторизованы");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/users/me`, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = "Не удалось загрузить профиль";
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) msg = data.error;
|
||||||
|
} catch {
|
||||||
|
const text = await res.text();
|
||||||
|
if (text) msg = text;
|
||||||
|
}
|
||||||
|
setError(msg);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json(); // UserProfile[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 (
|
return (
|
||||||
<div className="min-h-screen w-full bg-[#90D2F9] flex justify-center px-4">
|
<div className="min-h-screen w-full bg-[#90D2F9] flex justify-center px-4">
|
||||||
<div className="relative w-full max-w-md flex flex-col pb-20 pt-4">
|
<div className="relative w-full max-w-md flex flex-col pb-20 pt-4">
|
||||||
@@ -46,102 +161,124 @@ const ValounterProfileSettingsPage = () => {
|
|||||||
|
|
||||||
{/* Карточка настроек */}
|
{/* Карточка настроек */}
|
||||||
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
|
<main className="bg-white rounded-3xl p-4 flex flex-col items-center gap-4 shadow-lg">
|
||||||
{/* Аватар */}
|
{loading && (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<p className="font-montserrat text-[14px] text-black">
|
||||||
<div className="w-24 h-24 rounded-full bg-[#E5F3FB] flex items-center justify-center overflow-hidden">
|
Загрузка профиля...
|
||||||
{avatarUrl ? (
|
</p>
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
)}
|
||||||
<img
|
|
||||||
src={avatarUrl}
|
{!loading && (
|
||||||
alt="Аватар"
|
<>
|
||||||
className="w-full h-full object-cover"
|
{/* Аватар */}
|
||||||
/>
|
<div className="flex flex-col items-center gap-2">
|
||||||
) : (
|
<div className="w-24 h-24 rounded-full bg-[#E5F3FB] flex items-center justify-center overflow-hidden">
|
||||||
<FaUserCircle className="text-[#72B8E2] w-20 h-20" />
|
{avatarUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="Аватар"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FaUserCircle className="text-[#72B8E2] w-20 h-20" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="font-montserrat text-[12px] text-[#72B8E2] underline cursor-pointer">
|
||||||
|
Загрузить аватар
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setAvatarUrl(url);
|
||||||
|
// загрузку файла на бэк можно добавить отдельно
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="font-montserrat text-[12px] text-red-500">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saveMessage && (
|
||||||
|
<p className="font-montserrat text-[12px] text-green-600">
|
||||||
|
{saveMessage}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<label className="font-montserrat text-[12px] text-[#72B8E2] underline cursor-pointer">
|
|
||||||
Загрузить аватар
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
setAvatarUrl(url);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSave} className="w-full flex flex-col gap-3">
|
<form onSubmit={handleSave} className="w-full flex flex-col gap-3">
|
||||||
{/* ФИО */}
|
{/* ФИО -> first_name + last_name */}
|
||||||
<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="text"
|
||||||
type="date"
|
value={lastName}
|
||||||
value={birthDate}
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
onChange={(e) => setBirthDate(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 outline-none border border-transparent focus:border-white/70"
|
placeholder="Введите фамилию"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Почта */}
|
{/* Почта (только показ, без сохранения, если бэк не даёт менять) */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="font-montserrat text-[12px] text-black">
|
<label className="font-montserrat text-[12px] text-black">
|
||||||
Почта
|
Почта
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
disabled
|
||||||
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
className="w-full rounded-full bg-[#72B8E2]/60 px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent cursor-not-allowed"
|
||||||
placeholder="example@mail.com"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Телефон */}
|
{/* Телефон */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="font-montserrat text-[12px] text-black">
|
<label className="font-montserrat text-[12px] text-black">
|
||||||
Телефон
|
Телефон
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text:white.placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
className="w-full rounded-full bg-[#72B8E2] px-4 py-2 text-sm font-montserrat text-white placeholder:text-white/70 outline-none border border-transparent focus:border-white/70"
|
||||||
placeholder="+7 (900) 000-00-00"
|
placeholder="+7 900 000 00 00"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопка сохранить */}
|
{/* Кнопка сохранить */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mt-2 w-full bg-[#94E067] rounded-full py-2.5 flex items-center justify-center"
|
disabled={saving}
|
||||||
>
|
className="mt-2 w-full bg-[#94E067] rounded-full py-2.5 flex.items-center justify-center disabled:opacity-60"
|
||||||
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
>
|
||||||
Сохранить изменения
|
<span className="font-montserrat font-extrabold text-[14px] text-white">
|
||||||
</span>
|
{saving ? "Сохранение..." : "Сохранить изменения"}
|
||||||
</button>
|
</span>
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<TabBar />
|
<TabBar />
|
||||||
|
|||||||
BIN
public/leaflet/marker-icon-2x.png
Normal file
BIN
public/leaflet/marker-icon-2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/leaflet/marker-icon.png
Normal file
BIN
public/leaflet/marker-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/leaflet/marker-shadow.png
Normal file
BIN
public/leaflet/marker-shadow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
Reference in New Issue
Block a user