Files
nahodka-front/src/pages/CreateUpdatePost.jsx
SlimusMinus 7f59ee1f38 fixed code
2026-08-11 00:24:07 +03:00

395 lines
15 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 "../components/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 { isMobileDevice } from "../utils/isMobileDevice";
/**
* 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 [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 [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 } = useYandexMap(
true,
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>
{/* Тип: Потеря / Находка */}
<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>
<input
className={`f-m-field${errors.title ? " err" : ""}`}
type="text"
placeholder={t('post.titlePlaceholder')}
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">{t('post.descriptionLabel')}</label>
<textarea
className={`f-m-field${errors.description ? " err" : ""}`}
rows={3}
placeholder={t('post.descriptionPlaceholder')}
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">{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
capture={isMobileDevice() ? "environment" : undefined}
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>
{/* Категория */}
<div className="f-m-row">
<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>
{/* Карта */}
<div className="f-m-group">
<label className="f-m-label">{t('post.mapLabel')}</label>
<div className="f-map-frame">
<div ref={mapRef} className="f-map-inner" />
</div>
{!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>
<input
className={`f-m-field${errors.address ? " err" : ""}`}
type="text"
placeholder={t('post.addressPlaceholder')}
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">{t('post.phoneLabel')}</label>
<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))}
/>
{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-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)} />
)}
{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>
)}
</>
);
}