|
|
@@ -1,9 +1,11 @@
|
|
|
'use client'
|
|
|
|
|
|
import { useEffect, useState } from 'react'
|
|
|
-import Link from 'next/link'
|
|
|
-import { api } from '@/lib/api'
|
|
|
-import type { Place } from '@/types'
|
|
|
+import dynamic from 'next/dynamic'
|
|
|
+import { api, ApiRequestError } from '@/lib/api'
|
|
|
+import type { Place, Tag, Feature } from '@/types'
|
|
|
+
|
|
|
+const MapPicker = dynamic(() => import('@/components/MapPicker'), { ssr: false })
|
|
|
|
|
|
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
|
|
published: { label: 'Опубликовано', color: 'text-green-400' },
|
|
|
@@ -13,16 +15,253 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
|
|
draft: { label: 'Черновик', color: 'text-white/40' },
|
|
|
}
|
|
|
|
|
|
+function PlaceEditModal({ placeId, onClose, onSaved }: { placeId: string; onClose: () => void; onSaved: () => void }) {
|
|
|
+ const [place, setPlace] = useState<Place | null>(null)
|
|
|
+ const [loadingPlace, setLoadingPlace] = useState(true)
|
|
|
+ const [tags, setTags] = useState<Tag[]>([])
|
|
|
+ const [features, setFeatures] = useState<Feature[]>([])
|
|
|
+ const [saving, setSaving] = useState(false)
|
|
|
+ const [error, setError] = useState('')
|
|
|
+
|
|
|
+ const [title, setTitle] = useState('')
|
|
|
+ const [description, setDescription] = useState('')
|
|
|
+ const [address, setAddress] = useState('')
|
|
|
+ const [accessInfo, setAccessInfo] = useState('')
|
|
|
+ const [selectedTags, setSelectedTags] = useState<string[]>([])
|
|
|
+ const [selectedFeatures, setSelectedFeatures] = useState<string[]>([])
|
|
|
+ const [lat, setLat] = useState('')
|
|
|
+ const [lng, setLng] = useState('')
|
|
|
+ const [coverFile, setCoverFile] = useState<File | null>(null)
|
|
|
+ const [coverPreview, setCoverPreview] = useState('')
|
|
|
+ const [hourlyRate, setHourlyRate] = useState('')
|
|
|
+ const [minHours, setMinHours] = useState('1')
|
|
|
+ const [showMapPicker, setShowMapPicker] = useState(false)
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ api.get<Place>(`/places/${placeId}`)
|
|
|
+ .then((p) => {
|
|
|
+ setPlace(p)
|
|
|
+ setTitle(p.title || '')
|
|
|
+ setDescription(p.description || '')
|
|
|
+ setAddress(p.address || '')
|
|
|
+ setAccessInfo(p.access_info || '')
|
|
|
+ setSelectedTags(p.tags?.map((t) => t.id) || [])
|
|
|
+ setSelectedFeatures(p.features?.map((f) => f.id) || [])
|
|
|
+ setLat(p.lat?.toString() || '')
|
|
|
+ setLng(p.lng?.toString() || '')
|
|
|
+ setCoverPreview(p.cover_image || '')
|
|
|
+ setHourlyRate(p.hourly_rate?.toString() || '')
|
|
|
+ setMinHours(p.min_hours?.toString() || '1')
|
|
|
+ })
|
|
|
+ .catch(() => onClose())
|
|
|
+ .finally(() => setLoadingPlace(false))
|
|
|
+
|
|
|
+ api.get<Tag[]>('/tags').then(setTags).catch(() => {})
|
|
|
+ api.get<Feature[]>('/features').then(setFeatures).catch(() => {})
|
|
|
+ }, [placeId, onClose])
|
|
|
+
|
|
|
+ const handleMapPick = (pickLat: number, pickLng: number, pickAddress?: string) => {
|
|
|
+ setLat(pickLat.toFixed(6))
|
|
|
+ setLng(pickLng.toFixed(6))
|
|
|
+ if (pickAddress) setAddress(pickAddress)
|
|
|
+ setShowMapPicker(false)
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleSubmit = async (e: React.FormEvent) => {
|
|
|
+ e.preventDefault()
|
|
|
+ setSaving(true)
|
|
|
+ setError('')
|
|
|
+
|
|
|
+ try {
|
|
|
+ let coverImage: string | undefined
|
|
|
+ if (coverFile) {
|
|
|
+ const formData = new FormData()
|
|
|
+ formData.append('file', coverFile)
|
|
|
+ const res = await api.upload<{ file_url: string }>('/upload', formData)
|
|
|
+ coverImage = res.file_url
|
|
|
+ }
|
|
|
+
|
|
|
+ const body: Record<string, unknown> = {
|
|
|
+ title,
|
|
|
+ description: description || undefined,
|
|
|
+ address: address || undefined,
|
|
|
+ lat: parseFloat(lat),
|
|
|
+ lng: parseFloat(lng),
|
|
|
+ access_info: accessInfo || undefined,
|
|
|
+ tags: selectedTags,
|
|
|
+ features: selectedFeatures,
|
|
|
+ cover_image: coverImage,
|
|
|
+ }
|
|
|
+
|
|
|
+ if (place?.type === 'studio') {
|
|
|
+ body.hourly_rate = hourlyRate ? parseInt(hourlyRate) : undefined
|
|
|
+ body.min_hours = parseInt(minHours) || 1
|
|
|
+ body.currency = 'RUB'
|
|
|
+ }
|
|
|
+
|
|
|
+ await api.patch(`/places/${placeId}`, body)
|
|
|
+ onSaved()
|
|
|
+ } catch (err: any) {
|
|
|
+ setError(err instanceof ApiRequestError ? err.message : 'Ошибка при сохранении')
|
|
|
+ } finally {
|
|
|
+ setSaving(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const toggleTag = (id: string) => {
|
|
|
+ setSelectedTags((prev) =>
|
|
|
+ prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ const toggleFeature = (id: string) => {
|
|
|
+ setSelectedFeatures((prev) =>
|
|
|
+ prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id]
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 pt-10 pb-10" onClick={onClose}>
|
|
|
+ <div className="w-full max-w-2xl rounded-xl bg-[#1e293b] shadow-xl" onClick={(e) => e.stopPropagation()}>
|
|
|
+ {loadingPlace ? (
|
|
|
+ <div className="flex items-center justify-center p-8">
|
|
|
+ <p className="text-white/40">Загрузка...</p>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <form onSubmit={handleSubmit} className="space-y-5 p-6">
|
|
|
+ <h2 className="text-2xl font-bold text-white">Редактировать</h2>
|
|
|
+
|
|
|
+ {error && <p className="rounded-lg bg-red-500/10 p-3 text-sm text-red-400">{error}</p>}
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label className="mb-1 block text-sm text-white/60">Название *</label>
|
|
|
+ <input value={title} onChange={(e) => setTitle(e.target.value)}
|
|
|
+ className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none focus:border-green-500" required />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label className="mb-1 block text-sm text-white/60">Описание</label>
|
|
|
+ <textarea value={description} onChange={(e) => setDescription(e.target.value)}
|
|
|
+ rows={3} className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none focus:border-green-500" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label className="mb-1 block text-sm text-white/60">Адрес</label>
|
|
|
+ <input value={address} onChange={(e) => setAddress(e.target.value)}
|
|
|
+ className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none focus:border-green-500" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="grid grid-cols-5 gap-4">
|
|
|
+ <div className="col-span-2">
|
|
|
+ <label className="mb-1 block text-sm text-white/60">Широта</label>
|
|
|
+ <input type="number" step="any" value={lat} onChange={(e) => setLat(e.target.value)}
|
|
|
+ className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-3 text-white outline-none focus:border-green-500" />
|
|
|
+ </div>
|
|
|
+ <div className="col-span-2">
|
|
|
+ <label className="mb-1 block text-sm text-white/60">Долгота</label>
|
|
|
+ <input type="number" step="any" value={lng} onChange={(e) => setLng(e.target.value)}
|
|
|
+ className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-3 text-white outline-none focus:border-green-500" />
|
|
|
+ </div>
|
|
|
+ <div className="col-span-1 flex items-end">
|
|
|
+ <button type="button" onClick={() => setShowMapPicker(true)}
|
|
|
+ className="h-[50px] w-auto rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white/80 hover:bg-white/10 transition text-center leading-tight"
|
|
|
+ >Указать<br />на карте</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label className="mb-1 block text-sm text-white/60">Информация о доступе</label>
|
|
|
+ <textarea value={accessInfo} onChange={(e) => setAccessInfo(e.target.value)}
|
|
|
+ rows={2} className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none focus:border-green-500"
|
|
|
+ placeholder="Как добраться, парковка..." />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label className="mb-1 block text-sm text-white/60">Обложка</label>
|
|
|
+ <input type="file" accept="image/*" onChange={(e) => {
|
|
|
+ const file = e.target.files?.[0]
|
|
|
+ if (file) {
|
|
|
+ setCoverFile(file)
|
|
|
+ if (coverPreview) URL.revokeObjectURL(coverPreview)
|
|
|
+ setCoverPreview(URL.createObjectURL(file))
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ className="w-full text-sm text-white/60 file:mr-4 file:rounded-lg file:border-0 file:bg-green-600 file:px-4 file:py-2 file:text-white file:hover:bg-green-500" />
|
|
|
+ {coverPreview && (
|
|
|
+ <img src={coverPreview} alt="preview" className="mt-2 h-32 rounded-lg object-cover" />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {place?.type === 'studio' && (
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
+ <div>
|
|
|
+ <label className="mb-1 block text-sm text-white/60">Цена за час (₽)</label>
|
|
|
+ <input type="number" value={hourlyRate} onChange={(e) => setHourlyRate(e.target.value)}
|
|
|
+ className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none focus:border-green-500" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="mb-1 block text-sm text-white/60">Мин. часов</label>
|
|
|
+ <input type="number" min="1" value={minHours} onChange={(e) => setMinHours(e.target.value)}
|
|
|
+ className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none focus:border-green-500" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label className="mb-2 block text-sm text-white/60">Теги (стили съёмки)</label>
|
|
|
+ <div className="flex flex-wrap gap-2">
|
|
|
+ {tags.map((tag) => (
|
|
|
+ <button key={tag.id} type="button" onClick={() => toggleTag(tag.id)}
|
|
|
+ className={`rounded-full px-3 py-1.5 text-sm transition ${selectedTags.includes(tag.id) ? 'bg-green-600 text-white' : 'bg-white/10 text-white/60 hover:bg-white/20'}`}
|
|
|
+ >{tag.name}</button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label className="mb-2 block text-sm text-white/60">Характеристики</label>
|
|
|
+ <div className="flex flex-wrap gap-2">
|
|
|
+ {features.map((feat) => (
|
|
|
+ <button key={feat.id} type="button" onClick={() => toggleFeature(feat.id)}
|
|
|
+ className={`rounded-full px-3 py-1.5 text-sm transition ${selectedFeatures.includes(feat.id) ? 'bg-green-600 text-white' : 'bg-white/10 text-white/60 hover:bg-white/20'}`}
|
|
|
+ >{feat.name}</button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex items-center gap-3 pt-2">
|
|
|
+ <button type="button" onClick={onClose}
|
|
|
+ className="rounded-lg bg-white/10 px-6 py-3 text-white hover:bg-white/20 transition"
|
|
|
+ >Отмена</button>
|
|
|
+ <button type="submit" disabled={saving}
|
|
|
+ className="flex-1 rounded-lg bg-green-600 px-6 py-3 font-medium text-white hover:bg-green-500 transition disabled:opacity-50"
|
|
|
+ >{saving ? 'Сохранение...' : 'Сохранить'}</button>
|
|
|
+ </div>
|
|
|
+ </form>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {showMapPicker && <MapPicker onSelect={handleMapPick} onClose={() => setShowMapPicker(false)} />}
|
|
|
+ </>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
export default function MyPlacesPage() {
|
|
|
const [places, setPlaces] = useState<Place[]>([])
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
+ const [editId, setEditId] = useState<string | null>(null)
|
|
|
|
|
|
- useEffect(() => {
|
|
|
+ const fetchPlaces = () => {
|
|
|
+ setLoading(true)
|
|
|
api.get<{ data: Place[] }>('/places/my')
|
|
|
.then((res) => setPlaces(res.data))
|
|
|
.catch(() => {})
|
|
|
.finally(() => setLoading(false))
|
|
|
- }, [])
|
|
|
+ }
|
|
|
+
|
|
|
+ useEffect(() => { fetchPlaces() }, [])
|
|
|
|
|
|
return (
|
|
|
<div className="min-h-screen bg-[#0f172a] pt-20">
|
|
|
@@ -34,12 +273,7 @@ export default function MyPlacesPage() {
|
|
|
) : places.length === 0 ? (
|
|
|
<div className="rounded-xl bg-white/5 p-8 text-center">
|
|
|
<p className="text-white/60">У вас пока нет добавленных мест</p>
|
|
|
- <Link
|
|
|
- href="/"
|
|
|
- className="mt-4 inline-block rounded-lg bg-green-600 px-6 py-2 text-white hover:bg-green-500 transition"
|
|
|
- >
|
|
|
- На карту
|
|
|
- </Link>
|
|
|
+ <a href="/" className="mt-4 inline-block rounded-lg bg-green-600 px-6 py-2 text-white hover:bg-green-500 transition">На карту</a>
|
|
|
</div>
|
|
|
) : (
|
|
|
<div className="space-y-4">
|
|
|
@@ -49,30 +283,28 @@ export default function MyPlacesPage() {
|
|
|
<div key={place.id} className="rounded-xl bg-white/5 p-4 text-white">
|
|
|
<h2 className="text-lg font-semibold">{place.title}</h2>
|
|
|
{place.address && <p className="mt-1 text-sm text-white/60">{place.address}</p>}
|
|
|
-
|
|
|
<div className="mt-2 flex items-center gap-3 text-sm">
|
|
|
<span className={st.color}>{st.label}</span>
|
|
|
{place.type === 'studio' && <span className="text-white/40">Студия</span>}
|
|
|
</div>
|
|
|
-
|
|
|
{place.moderation_comment && (
|
|
|
<p className="mt-2 rounded bg-yellow-500/10 px-3 py-2 text-sm text-yellow-400">
|
|
|
Комментарий модератора: {place.moderation_comment}
|
|
|
</p>
|
|
|
)}
|
|
|
-
|
|
|
- <Link
|
|
|
- href={`/places/edit/${place.id}`}
|
|
|
- className="mt-3 inline-block rounded-lg bg-white/10 px-4 py-1.5 text-sm text-white hover:bg-white/20 transition"
|
|
|
- >
|
|
|
- Редактировать
|
|
|
- </Link>
|
|
|
+ <button onClick={() => setEditId(place.id)}
|
|
|
+ className="mt-3 rounded-lg bg-white/10 px-4 py-1.5 text-sm text-white hover:bg-white/20 transition"
|
|
|
+ >Редактировать</button>
|
|
|
</div>
|
|
|
)
|
|
|
})}
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
+
|
|
|
+ {editId && (
|
|
|
+ <PlaceEditModal placeId={editId} onClose={() => setEditId(null)} onSaved={() => { setEditId(null); fetchPlaces() }} />
|
|
|
+ )}
|
|
|
</div>
|
|
|
)
|
|
|
}
|