Преглед на файлове

fix: moderation, editing, image URLs, and Caddy proxy

- Fix Caddy /s3/* proxy: use handle_path instead of @s3 matcher
- Add api.photoplaces.ru to CSP img-src
- Add migration 000009: include 'revision' in places_status CHECK
- Admin: rework modal with comment field
- My Places: edit in modal with MapPicker, flat hourly_rate fields
- Fix edit page overflow (body has overflow-hidden)
- Fix Place type: move pricing to flat hourly_rate/min_hours/currency
- Update old cover_image URLs in DB (192.168.88.128:9000 -> api.photoplaces.ru/s3)
neyrogovnarik преди 1 месец
родител
ревизия
6bcd5622ef

+ 3 - 0
backend/migrations/000009_add_revision_status.down.sql

@@ -0,0 +1,3 @@
+ALTER TABLE places DROP CONSTRAINT IF EXISTS places_status_check;
+ALTER TABLE places ADD CONSTRAINT places_status_check
+    CHECK (status IN ('draft', 'pending_moderation', 'published', 'rejected', 'archived'));

+ 3 - 0
backend/migrations/000009_add_revision_status.up.sql

@@ -0,0 +1,3 @@
+ALTER TABLE places DROP CONSTRAINT IF EXISTS places_status_check;
+ALTER TABLE places ADD CONSTRAINT places_status_check
+    CHECK (status IN ('draft', 'pending_moderation', 'published', 'rejected', 'archived', 'revision'));

+ 6 - 7
deploy/Caddyfile

@@ -4,12 +4,11 @@
 
 # API subdomain — прокси на backend + MinIO (HTTP, SSL на Synology)
 http://api.{$DOMAIN} {
-	@s3 {
-		path /s3/*
-	}
-	reverse_proxy @s3 minio:9000 {
-		header_up Host {host}
-		header_up X-Real-IP {remote}
+	handle_path /s3/* {
+		reverse_proxy minio:9000 {
+			header_up Host {host}
+			header_up X-Real-IP {remote}
+		}
 	}
 
 	reverse_proxy backend:8080 {
@@ -37,7 +36,7 @@ http://{$DOMAIN} {
 		X-Frame-Options "DENY"
 		Referrer-Policy "strict-origin-when-cross-origin"
 		Permissions-Policy "geolocation=(self), microphone=(), camera=()"
-		Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com; font-src 'self' data:; connect-src 'self' wss://api.{$DOMAIN} https://api.{$DOMAIN}; frame-ancestors 'none';"
+		Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://api.{$DOMAIN}; font-src 'self' data:; connect-src 'self' wss://api.{$DOMAIN} https://api.{$DOMAIN}; frame-ancestors 'none';"
 	}
 
 	@static {

+ 44 - 26
frontend/src/app/admin/page.tsx

@@ -13,13 +13,41 @@ const FILTERS: { key: FilterTab; label: string }[] = [
   { key: 'revision', label: 'На доработке' },
 ]
 
+function ReworkModal({ place, onClose, onConfirm }: { place: Place; onClose: () => void; onConfirm: (id: string, comment: string) => void }) {
+  const [comment, setComment] = useState('')
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
+      <div className="w-full max-w-md rounded-xl bg-[#1e293b] p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
+        <h2 className="mb-2 text-lg font-bold text-white">Отправить на доработку</h2>
+        <p className="mb-4 text-sm text-white/60">{place.title}</p>
+
+        <textarea value={comment} onChange={(e) => setComment(e.target.value)}
+          placeholder="Что нужно доработать?"
+          rows={4}
+          className="mb-4 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-yellow-500"
+        />
+
+        <div className="flex items-center justify-end gap-3">
+          <button onClick={onClose}
+            className="rounded-lg px-4 py-2 text-sm text-white/60 hover:text-white transition"
+          >Отмена</button>
+          <button onClick={() => onConfirm(place.id, comment)}
+            disabled={!comment.trim()}
+            className="rounded-lg bg-yellow-600 px-4 py-2 text-sm text-white hover:bg-yellow-500 transition disabled:opacity-50"
+          >Отправить</button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
 export default function AdminModerationPage() {
   const [filter, setFilter] = useState<FilterTab>('pending_moderation')
   const [places, setPlaces] = useState<Place[]>([])
   const [loading, setLoading] = useState(true)
   const [actionMsg, setActionMsg] = useState('')
-  const [reworkId, setReworkId] = useState<string | null>(null)
-  const [reworkComment, setReworkComment] = useState('')
+  const [reworkPlace, setReworkPlace] = useState<Place | null>(null)
 
   const fetchPlaces = useCallback(async () => {
     setLoading(true)
@@ -37,18 +65,16 @@ export default function AdminModerationPage() {
 
   useEffect(() => { fetchPlaces() }, [fetchPlaces])
 
-  const moderate = async (id: string, action: 'approve' | 'reject' | 'rework' | 'revoke') => {
-    const comment = action === 'rework' ? reworkComment : ''
+  const moderate = async (id: string, action: 'approve' | 'reject' | 'rework' | 'revoke', comment?: string) => {
     try {
-      await api.post(`/places/${id}/moderate`, { action, comment })
+      await api.post(`/places/${id}/moderate`, { action, comment: comment || '' })
       setPlaces((prev) => prev.filter((p) => p.id !== id))
       setActionMsg(
         action === 'approve' ? 'Одобрено' :
         action === 'reject' ? 'Отклонено' :
         action === 'revoke' ? 'Отозвано' : 'Отправлено на доработку'
       )
-      setReworkId(null)
-      setReworkComment('')
+      setReworkPlace(null)
     } catch (err: any) {
       setActionMsg(err.message || 'Ошибка')
     }
@@ -105,25 +131,9 @@ export default function AdminModerationPage() {
                     <button onClick={() => moderate(place.id, 'approve')}
                       className="rounded-lg bg-green-600 px-4 py-2 text-sm text-white hover:bg-green-500 transition"
                     >Одобрить</button>
-                    {reworkId === place.id ? (
-                      <div className="flex items-center gap-2">
-                        <input value={reworkComment} onChange={(e) => setReworkComment(e.target.value)}
-                          placeholder="Что доработать?"
-                          className="w-48 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white outline-none focus:border-yellow-500"
-                        />
-                        <button onClick={() => moderate(place.id, 'rework')}
-                          disabled={!reworkComment.trim()}
-                          className="rounded-lg bg-yellow-600 px-4 py-2 text-sm text-white hover:bg-yellow-500 transition disabled:opacity-50"
-                        >Отправить</button>
-                        <button onClick={() => { setReworkId(null); setReworkComment('') }}
-                          className="text-sm text-white/40 hover:text-white"
-                        >Отмена</button>
-                      </div>
-                    ) : (
-                      <button onClick={() => { setReworkId(place.id); setReworkComment('') }}
-                        className="rounded-lg bg-yellow-600/80 px-4 py-2 text-sm text-white hover:bg-yellow-500 transition"
-                      >На доработку</button>
-                    )}
+                    <button onClick={() => setReworkPlace(place)}
+                      className="rounded-lg bg-yellow-600/80 px-4 py-2 text-sm text-white hover:bg-yellow-500 transition"
+                    >На доработку</button>
                     <button onClick={() => moderate(place.id, 'reject')}
                       className="rounded-lg bg-red-600/80 px-4 py-2 text-sm text-white hover:bg-red-500 transition"
                     >Отклонить</button>
@@ -139,6 +149,14 @@ export default function AdminModerationPage() {
           ))}
         </div>
       )}
+
+      {reworkPlace && (
+        <ReworkModal
+          place={reworkPlace}
+          onClose={() => setReworkPlace(null)}
+          onConfirm={(id, comment) => moderate(id, 'rework', comment)}
+        />
+      )}
     </div>
   )
 }

+ 1 - 1
frontend/src/app/places/edit/[id]/page.tsx

@@ -31,7 +31,7 @@ export default function EditPlacePage() {
   if (!place) return null
 
   return (
-    <div className="min-h-screen bg-[#0f172a] pt-20">
+    <div className="min-h-screen overflow-y-auto bg-[#0f172a] pt-20">
       <PlaceForm type={place.type} place={place} onSuccess={() => router.push('/places/my')} />
     </div>
   )

+ 252 - 20
frontend/src/app/places/my/page.tsx

@@ -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>
   )
 }

+ 2 - 2
frontend/src/components/MapView.tsx

@@ -189,8 +189,8 @@ function PlaceCardModal({ place, onClose }: { place: Place; onClose: () => void
       </div>
       <div className="flex items-center gap-4 text-sm text-white/60">
         <span>★ {place.rating?.toFixed(1) || 'Нет оценок'}</span>
-        {place.type === 'studio' && place.pricing?.hourly_rate && (
-          <span>{place.pricing.hourly_rate} ₽/час</span>
+        {place.type === 'studio' && place.hourly_rate && (
+          <span>{place.hourly_rate} ₽/час</span>
         )}
       </div>
     </div>

+ 4 - 4
frontend/src/components/PlaceForm.tsx

@@ -41,8 +41,8 @@ export default function PlaceForm({ type, onSuccess, place }: PlaceFormProps) {
   const [selectedFeatures, setSelectedFeatures] = useState<string[]>(place?.features?.map((f) => f.id) || [])
   const [lat, setLat] = useState(place?.lat?.toString() || '55.7558')
   const [lng, setLng] = useState(place?.lng?.toString() || '37.6173')
-  const [hourlyRate, setHourlyRate] = useState(place?.pricing?.hourly_rate?.toString() || '')
-  const [minHours, setMinHours] = useState(place?.pricing?.min_hours?.toString() || '1')
+  const [hourlyRate, setHourlyRate] = useState(place?.hourly_rate?.toString() || '')
+  const [minHours, setMinHours] = useState(place?.min_hours?.toString() || '1')
   const [coverFile, setCoverFile] = useState<File | null>(null)
   const [coverPreview, setCoverPreview] = useState(place?.cover_image || '')
   const [showMapPicker, setShowMapPicker] = useState(false)
@@ -84,7 +84,6 @@ export default function PlaceForm({ type, onSuccess, place }: PlaceFormProps) {
       }
 
       const body: Record<string, unknown> = {
-        type,
         title,
         description: description || undefined,
         address: address || undefined,
@@ -93,9 +92,10 @@ export default function PlaceForm({ type, onSuccess, place }: PlaceFormProps) {
         access_info: accessInfo || undefined,
         tags: selectedTags,
         features: selectedFeatures,
-        cover_image: coverImage,
       }
 
+      if (coverImage) body.cover_image = coverImage
+
       if (type === 'studio') {
         body.hourly_rate = hourlyRate ? parseInt(hourlyRate) : undefined
         body.min_hours = parseInt(minHours) || 1

+ 3 - 5
frontend/src/types/index.ts

@@ -78,11 +78,9 @@ export interface Place {
   status: PlaceStatus
   moderation_comment?: string
   owner: Pick<User, 'id' | 'name' | 'avatar_url'>
-  pricing?: {
-    hourly_rate: number
-    currency: string
-    min_hours: number
-  }
+  hourly_rate?: number
+  currency?: string
+  min_hours?: number
   booking_url?: string
   executor_services?: ExecutorService[]
   created_at: string