Files
nahodka-front/src/pages/CreateUpdatePost.jsx

437 lines
16 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 "../components/icons/CloseIcon";
import RulesModal from "./RulesModal";
import { EMPTY_FORM, CATEGORY_KEYS } from "../config/feedConstants";
import { useTranslation } from 'react-i18next';
import { usePostMutation } from "../hooks/usePostMutation";
import { useImageUpload, ACCEPT, MAX_SIZE_MB } from "../hooks/useImageUpload";
import { validatePostForm } from "../utils/validatePostForm";
import { formatPhone } from "../utils/formatPhone";
import { useYandexMap } from "../hooks/useYandexMap";
import MapModal from "./MapModal";
import ClearableField from "../components/ClearableField";
/**
* CreateUpdatePost — модальная форма создания и редактирования объявления.
*
* @param {function} onClose — закрыть модалку без сохранения
* @param {function} onSuccess — колбэк после успешного сохранения (перезагружает ленту)
* @param {object} [initialData] — если передан, форма работает в режиме редактирования
*/
export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
const isEdit = !!initialData;
const { t } = useTranslation();
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 || "",
rulesAccepted: true,
reward: initialData.reward ?? false
}
: EMPTY_FORM
);
const [rulesModalOpen, setRulesModalOpen] = useState(false);
const [mapModalOpen, setMapModalOpen] = useState(false);
const [errors, setErrors] = useState({});
const [step, setStep] = useState(1); // 1 — первая часть, 2 — вторая часть
const {
images: uploadedImages,
imgError,
compressing,
fileInputRef,
handleFileChange,
removeImage,
getFilesForUpload,
getExistingUrlsToKeep,
} = useImageUpload(isEdit ? initialData.images : []);
const { save, loading: saving, error: apiError } = usePostMutation();
const [resultModal, setResultModal] = useState(null); // null | "success" | "blocked"
const initialCoords = isEdit && initialData.latitude && initialData.longitude
? { latitude: initialData.latitude, longitude: initialData.longitude }
: null;
const { mapRef, coords, address, addressRef, loading: mapLoading } = useYandexMap(
mapModalOpen,
initialCoords,
isEdit ? (initialData.location || "") : ""
);
useEffect(() => {
if (address) {
setForm(prev => ({ ...prev, address }));
// Адрес пришёл — очищаем и ошибку поля, и "карта грузится",
// иначе последняя может остаться висеть на экране навсегда.
setErrors(prev => ({ ...prev, address: "", map: "" }));
}
}, [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: t('post.mapLoading') }));
return;
}
const data = new FormData();
data.append("type", form.type.toUpperCase());
data.append("title", form.title.trim());
data.append("description", form.description.trim());
data.append("category", form.category);
data.append("phone", form.phone.trim());
data.append("reward", form.reward ? "true" : "false");
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());
}
data.append("rulesAccepted", form.rulesAccepted ? "true" : "false");
const result = await save(isEdit ? initialData.id : null, data);
if (!result) return;
if (result.blocked) {
setResultModal("blocked");
return;
}
if (result.data) {
setResultModal("success");
}
};
return (
<>
<div
className="f-modal-bg"
onClick={e => e.target === e.currentTarget && !loading && onClose()}
>
<div className="f-modal">
<div className="f-modal-head">
<span className="f-modal-title">
{isEdit ? t('post.editTitle') : t('post.newTitle')}
</span>
<button className="f-close-btn" type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
{step === 1 && (
<>
{/* Тип: Потеря / Находка */}
<div className="f-m-group">
<label className="f-m-label">{t('post.typeLabel')}</label>
<div className="f-type-toggle">
<button
type="button"
className={`f-type-opt${form.type === "loss" ? " loss" : ""}`}
onClick={() => set("type", "loss")}
>
{t('post.loss')}
</button>
<button
type="button"
className={`f-type-opt${form.type === "found" ? " found" : ""}`}
onClick={() => set("type", "found")}
>
{t('post.found')}
</button>
</div>
</div>
{/* Название */}
<div className="f-m-group">
<label className="f-m-label">{t('post.titleLabel')}</label>
<ClearableField onClear={() => set("title", "")} visible={!!form.title}>
<input
className={`f-m-field${errors.title ? " err" : ""}`}
type="text"
placeholder={t('post.titlePlaceholder')}
value={form.title}
onChange={e => set("title", e.target.value)}
/>
</ClearableField>
{errors.title && <p className="f-err-msg">{errors.title}</p>}
</div>
{/* Описание */}
<div className="f-m-group">
<label className="f-m-label">{t('post.descriptionLabel')}</label>
<ClearableField onClear={() => set("description", "")} visible={!!form.description}>
<textarea
className={`f-m-field${errors.description ? " err" : ""}`}
rows={3}
placeholder={t('post.descriptionPlaceholder')}
value={form.description}
onChange={e => set("description", e.target.value)}
/>
</ClearableField>
{errors.description && <p className="f-err-msg">{errors.description}</p>}
</div>
{/* Фотографии */}
<div className="f-m-group">
<label className="f-m-label">{t('post.photosLabel')}</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={t('post.deletePhoto')}
>
<CloseIcon />
</button>
</div>
))}
</div>
)}
<label className="f-img-upload-label">
<input
ref={fileInputRef}
type="file"
accept={ACCEPT}
multiple
onChange={handleFileChange}
className="f-img-input"
disabled={compressing}
/>
{compressing ? (
<span>{t('post.processing')}</span>
) : (
<span>{uploadedImages.length > 0 ? t('post.addMore') : t('post.addPhoto')}</span>
)}
</label>
{imgError && <p className="f-err-msg">{imgError}</p>}
<p className="f-hint">{t('post.photoHint', { maxSize: MAX_SIZE_MB })}</p>
</div>
<button
className="f-m-submit"
type="button"
disabled={loading}
onClick={() => setStep(2)}
>
<div className="f-m-submit-row">{t('post.nextStep')}</div>
</button>
</>
)}
{step === 2 && (
<>
{/* Категория */}
<div className="f-m-group">
<label className="f-m-label">{t('post.categoryLabel')}</label>
<select
className={`f-m-field${errors.category ? " err" : ""}`}
value={form.category}
onChange={e => set("category", e.target.value)}
>
<option value="">{t('categories.select')}</option>
{CATEGORY_KEYS.map(k => <option key={k} value={t(k)}>{t(k)}</option>)}
</select>
{errors.category && <p className="f-err-msg">{errors.category}</p>}
</div>
{/* Место на карте */}
<div className="f-m-group">
<label className="f-m-label">{t('post.mapLabel')}</label>
<button
className="f-map-pick"
type="button"
onClick={() => setMapModalOpen(true)}
>
{form.address ? `📍 ${form.address}` : t('post.mapPickLabel')}
</button>
{!coords && <p className="f-hint">{t('post.mapHint')}</p>}
{errors.map && <p className="f-err-msg">{errors.map}</p>}
</div>
<div className="f-m-group">
<label className="f-m-label">{t('post.addressLabel')}</label>
<ClearableField onClear={() => set("address", "")} visible={!!form.address}>
<input
className={`f-m-field${errors.address ? " err" : ""}`}
type="text"
placeholder={t('post.addressPlaceholder')}
value={form.address}
onChange={e => set("address", e.target.value)}
/>
</ClearableField>
{errors.address && <p className="f-err-msg">{errors.address}</p>}
</div>
{/* Телефон */}
<div className="f-m-group">
<label className="f-m-label">{t('post.phoneLabel')}</label>
<ClearableField onClear={() => set("phone", "")} visible={!!form.phone}>
<input
className={`f-m-field${errors.phone ? " err" : ""}`}
type="tel"
placeholder={t('post.phonePlaceholder')}
value={form.phone}
onChange={e => set("phone", formatPhone(e.target.value))}
/>
</ClearableField>
{errors.phone && <p className="f-err-msg">{errors.phone}</p>}
</div>
{/* Вознаграждение */}
<div className="f-m-group">
<label className="f-reward-checkbox">
<input
type="checkbox"
checked={form.reward}
onChange={e => set("reward", e.target.checked)}
/>
<span className="f-reward-checkmark"/>
<span className="f-reward-text">
{t("post.rewardCheckbox")}
</span>
</label>
</div>
{/* Чекбокс согласия с правилами */}
<div className="f-m-group">
<label className="f-rules-checkbox">
<input
type="checkbox"
className="f-rules-input"
checked={form.rulesAccepted}
onChange={e => set("rulesAccepted", e.target.checked)}
/>
<span className="f-rules-checkmark" />
<span className="f-rules-text">
{t('post.rulesCheckboxPrefix')}{' '}
<span
className="f-rules-link"
onClick={e => {
e.preventDefault();
setRulesModalOpen(true);
}}
>
{t('post.rulesLinkText')}
</span>
</span>
</label>
{errors.rulesAccepted && <p className="f-err-msg">{errors.rulesAccepted}</p>}
</div>
{apiError && <p className="f-err-msg">{apiError}</p>}
<button className="f-m-back" type="button" onClick={() => setStep(1)} disabled={loading}>
{t('post.backStep')}
</button>
<button className="f-m-submit" type="button" onClick={handleSubmit} disabled={loading}>
<div className="f-m-submit-row">
{loading && <div className="f-m-spinner" />}
{isEdit ? t('post.saveChanges') : t('post.publish')}
</div>
</button>
</>
)}
</div>
</div>
{rulesModalOpen && (
<RulesModal onClose={() => setRulesModalOpen(false)} />
)}
{mapModalOpen && (
<MapModal
mapRef={mapRef}
address={address}
mapLoading={mapLoading}
onClose={() => setMapModalOpen(false)}
onSave={() => setMapModalOpen(false)}
/>
)}
{resultModal && (
<div
className="f-modal-bg"
onClick={e => {
if (e.target === e.currentTarget) {
if (resultModal === "success") onSuccess?.();
onClose();
}
}}
>
<div className="f-modal f-modal-result">
<div className={`f-result-icon ${resultModal === "blocked" ? "blocked" : "success"}`}>
{resultModal === "blocked" ? "✕" : "✓"}
</div>
<h3 className="f-result-title">
{resultModal === "blocked" ? t('post.blockedTitle') : t('post.successTitle')}
</h3>
<p className="f-result-text">
{resultModal === "blocked" ? t('post.blockedText') : t('post.successText')}
</p>
<button
className={`f-result-btn ${resultModal === "blocked" ? "err" : "ok"}`}
type="button"
onClick={() => {
if (resultModal === "success") onSuccess?.();
onClose();
}}
>
{t('post.resultClose')}
</button>
</div>
</div>
)}
</>
);
}