Files
nahodka-front/src/components/CreateUpdatePost.jsx
2026-06-11 00:50:48 +03:00

302 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {useCallback, useEffect, useState} from "react";
import PinIcon from "./icons/PinIcon";
import CloseIcon from "./icons/CloseIcon";
import { EMPTY_FORM, CATEGORIES } from "../config/feedConstants";
import { usePostMutation } from "../hooks/usePostMutation";
import { useImageUpload, ACCEPT } from "../hooks/useImageUpload";
import { validatePostForm } from "../utils/validatePostForm";
import { formatPhone } from "../utils/formatPhone";
import { useYandexMap } from "../hooks/useYandexMap";
import { API_URL } from "../config/authConstants";
const MAX_SIZE_MB = 5;
/**
* CreateUpdatePost — модальная форма создания и редактирования объявления.
*
* @param {function} onClose — закрыть модалку без сохранения
* @param {function} onSuccess — колбэк после успешного сохранения (перезагружает ленту)
* @param {object} [initialData] — если передан, форма работает в режиме редактирования
*/
export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
// Режим: редактирование (true) или создание нового (false)
const isEdit = !!initialData;
// Поля формы; при редактировании заполняем из initialData
const [form, setForm] = useState(
isEdit
? {
type: (initialData.type || "loss").toLowerCase(),
title: initialData.title || "",
description: initialData.description || "",
category: initialData.category || "",
date: initialData.date || "",
phone: initialData.phone || "",
address: initialData.city || ""
}
: EMPTY_FORM
);
// Ошибки валидации — ключ совпадает с именем поля
const [errors, setErrors] = useState({});
// URL существующей картинки с сервера (только при редактировании)
const existingImageUrl = isEdit && initialData.images?.[0]
? `${API_URL}${initialData.images[0]}`
: null;
// Вся логика работы с фото вынесена в хук
const {
imageFile,
displayImage,
imgError,
compressing,
fileInputRef,
handleFileChange,
removeImage,
} = useImageUpload(existingImageUrl);
// Единый хук для создания и обновления
const { save, loading: saving, error: apiError } = usePostMutation();
// Внутри компонента, рядом с другими хуками
const initialCoords = isEdit && initialData.latitude && initialData.longitude
? { latitude: initialData.latitude, longitude: initialData.longitude }
: null;
// CreateUpdatePost.jsx
const { mapRef, coords, address, addressRef } = useYandexMap(
true,
initialCoords,
isEdit ? (initialData.location || "") : ""
);
// После объявления хука useYandexMap:
useEffect(() => {
if (address) {
setForm(prev => ({ ...prev, address }));
setErrors(prev => ({ ...prev, address: "" }));
}
}, [address]);
// Единый флаг загрузки — блокирует кнопку отправки
const loading = saving || compressing;
/** Обновляет одно поле формы и сбрасывает его ошибку валидации. */
const set = useCallback((key, value) => {
setForm(prev => ({ ...prev, [key]: value }));
setErrors(prev => ({ ...prev, [key]: "" }));
}, []);
/** Собирает FormData и отправляет запрос на создание или обновление. */
const handleSubmit = async () => {
const validationErrors = validatePostForm(form);
if (Object.keys(validationErrors).length) {
setErrors(validationErrors);
return;
}
if (coords && !addressRef.current) {
setErrors(prev => ({ ...prev, map: "Подождите, адрес загружается..." }));
return;
}
const data = new FormData();
data.append("type", form.type.toUpperCase()); // "loss" → "LOSS", "found" → "FOUND" для enum на бэкенде
data.append("title", form.title.trim());
data.append("description", form.description.trim());
data.append("category", form.category);
data.append("phone", form.phone.trim());
if (form.date) data.append("date", form.date);
if (imageFile) data.append("image", imageFile);
if (coords) {
data.append("latitude", coords.latitude);
data.append("longitude", coords.longitude);
data.append("address", form.address.trim()); // ← из поля формы
}
// Адрес без координат тоже отправляем:
if (!coords && form.address.trim()) {
data.append("address", form.address.trim());
}
// save(id, data): id = null → POST (создание), id = string → PUT (обновление)
const result = await save(isEdit ? initialData.id : null, data);
if (result) {
onSuccess(result);
onClose();
}
};
return (
// Клик по фону (не по самой модалке) закрывает её
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="f-modal">
{/* Шапка модалки */}
<div className="f-modal-head">
<span className="f-modal-title">
{isEdit ? "Редактировать объявление" : "Новое объявление"}
</span>
<button className="f-close-btn" onClick={onClose}>
<CloseIcon/>
</button>
</div>
{/* Тип: Потеря / Находка */}
<div className="f-m-group">
<label className="f-m-label">Тип объявления</label>
<div className="f-type-toggle">
<button
className={`f-type-opt${form.type === "loss" ? " loss" : ""}`}
onClick={() => set("type", "loss")}
>
Потеря
</button>
<button
className={`f-type-opt${form.type === "found" ? " found"
: ""}`}
onClick={() => set("type", "found")}
>
Находка
</button>
</div>
</div>
{/* Название */}
<div className="f-m-group">
<label className="f-m-label">Название</label>
<input
className={`f-m-field${errors.title ? " err" : ""}`}
type="text"
placeholder="Например: Кошелёк чёрный кожаный"
value={form.title}
onChange={e => set("title", e.target.value)}
/>
{errors.title && <p className="f-err-msg">{errors.title}</p>}
</div>
{/* Описание */}
<div className="f-m-group">
<label className="f-m-label">Описание</label>
<textarea
className={`f-m-field${errors.description ? " err" : ""}`}
rows={3}
placeholder="Подробно опишите предмет — цвет, размер, особые приметы..."
value={form.description}
onChange={e => set("description", e.target.value)}
/>
{errors.description && <p
className="f-err-msg">{errors.description}</p>}
</div>
{/* Фотография: превью + удаление или загрузчик */}
<div className="f-m-group">
<label className="f-m-label">Фотография</label>
{displayImage ? (
<div className="f-img-preview-wrap">
<img src={displayImage} alt="preview"
className="f-img-preview"/>
<button
className="f-img-remove"
onClick={removeImage}
type="button"
aria-label="Удалить фото"
>
<CloseIcon/>
</button>
</div>
) : (
<label className="f-img-upload-label"
aria-disabled={compressing}>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT}
// На мобильных открываем камеру напрямую
capture={/Mobi|Android|iPhone/i.test(navigator.userAgent)
? "environment" : undefined}
onChange={handleFileChange}
className="f-img-input"
disabled={compressing}
/>
{compressing ? <span>Обработка...</span> : <span>📷 Добавить фото</span>}
</label>
)}
{imgError && <p className="f-err-msg">{imgError}</p>}
<p className="f-hint">JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ</p>
</div>
{/* Категория */}
<div className="f-m-row">
<div className="f-m-group">
<label className="f-m-label">Категория</label>
<select
className={`f-m-field${errors.category ? " err" : ""}`}
value={form.category}
onChange={e => set("category", e.target.value)}
>
<option value="">Выбрать...</option>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
{errors.category && <p
className="f-err-msg">{errors.category}</p>}
</div>
</div>
{/* Карта */}
<div className="f-m-group">
<label className="f-m-label">Место на карте</label>
<div style={{
width: "100%",
height: 260,
borderRadius: 8,
overflow: "hidden"
}}>
<div ref={mapRef} style={{width: "100%", height: "100%"}}/>
</div>
{!coords && <p className="f-hint">Нажмите на карту, чтобы отметить
место</p>}
{errors.map && <p className="f-err-msg">{errors.map}</p>}
</div>
<div className="f-m-group">
<label className="f-m-label">Адрес</label>
<input
className={`f-m-field${errors.address ? " err" : ""}`}
type="text"
placeholder="Например: Москва, ул. Ленина, 5"
value={form.address}
onChange={e => set("address", e.target.value)}
/>
{errors.address && <p className="f-err-msg">{errors.address}</p>}
</div>
{/* Телефон с автоматической маской */}
<div className="f-m-group">
<label className="f-m-label">Номер телефона</label>
<input
className={`f-m-field${errors.phone ? " err" : ""}`}
type="tel"
placeholder="+7 (___) ___-__-__"
value={form.phone}
onChange={e => set("phone", formatPhone(e.target.value))}
/>
{errors.phone && <p className="f-err-msg">{errors.phone}</p>}
</div>
{/* Глобальная ошибка API (не связана с конкретным полем) */}
{apiError && <p className="f-err-msg">{apiError}</p>}
<button className="f-m-submit" onClick={handleSubmit}
disabled={loading}>
<div className="f-m-submit-row">
{loading && <div className="f-m-spinner"/>}
{isEdit ? "Сохранить изменения" : "Разместить объявление"}
</div>
</button>
</div>
</div>
);
}