Files
nahodka-front/src/components/CreateUpdatePost.jsx
2026-07-01 00:23:27 +03:00

312 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 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";
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({});
const {
images: uploadedImages,
imgError,
compressing,
fileInputRef,
handleFileChange,
removeImage,
getFilesForUpload,
getExistingUrlsToKeep,
} = useImageUpload(isEdit ? initialData.images : []);
// Единый хук для создания и обновления
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);
const files = getFilesForUpload();
files.forEach(f => data.append("images", f));
if (isEdit) {
const existing = getExistingUrlsToKeep();
if (existing.length > 0) {
data.append("existingImages", JSON.stringify(existing));
}
}
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>
{uploadedImages.length > 0 && (
<div className="f-img-grid">
{uploadedImages.map(img => (
<div key={img.id} className="f-img-grid-item">
<img src={img.preview} alt="" className="f-img-grid-preview"/>
<button
className="f-img-remove"
onClick={() => removeImage(img.id)}
type="button"
aria-label="Удалить фото"
>
<CloseIcon/>
</button>
</div>
))}
</div>
)}
<label className="f-img-upload-label"
aria-disabled={compressing}>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT}
multiple
capture={/Mobi|Android|iPhone/i.test(navigator.userAgent)
? "environment" : undefined}
onChange={handleFileChange}
className="f-img-input"
disabled={compressing}
/>
{compressing ? (
<span>Обработка...</span>
) : (
<span>{uploadedImages.length > 0 ? "➕ Добавить ещё" : "📷 Добавить фото"}</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>
);
}