Jelajahi Sumber

feat: GET /places/my + role-based toolbar + AddLocationModal

neyrogovnarik 1 bulan lalu
induk
melakukan
76d20b3751

+ 2 - 1
backend/cmd/api/main.go

@@ -227,7 +227,8 @@ func main() {
 			r.Patch("/services/{id}", serviceHandler.Update)
 			r.Delete("/services/{id}", serviceHandler.Delete)
 
-			// Place creation
+			// Places
+			r.Get("/places/my", placeHandler.ListMy)
 			r.Post("/places", placeHandler.Create)
 			r.Patch("/places/{id}", placeHandler.Update)
 			r.Delete("/places/{id}", placeHandler.Delete)

+ 15 - 0
backend/internal/handlers/places.go

@@ -125,6 +125,21 @@ func (h *PlaceHandler) GetByID(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, place)
 }
 
+func (h *PlaceHandler) ListMy(w http.ResponseWriter, r *http.Request) {
+	userID := middleware.GetUserID(r.Context())
+
+	places, err := h.placeSvc.List(r.Context(), models.PlaceFilter{
+		OwnerID:             userID,
+		IncludeTagsFeatures: true,
+	})
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, "failed to list places", err)
+		return
+	}
+
+	writeJSON(w, http.StatusOK, places)
+}
+
 type createPlaceRequest struct {
 	Title       string   `json:"title" validate:"required,min=1,max=255"`
 	Description *string  `json:"description" validate:"omitempty,max=5000"`

+ 1 - 0
backend/internal/models/place.go

@@ -43,6 +43,7 @@ type PlaceImage struct {
 
 type PlaceFilter struct {
 	Type              string
+	OwnerID           string
 	TagIDs            []string
 	FeatureIDs        []string
 	MinRating         float64

+ 1 - 0
backend/internal/repository/places.go

@@ -85,6 +85,7 @@ func (r *PlaceRepo) List(ctx context.Context, filter models.PlaceFilter) ([]*mod
 
 	if filter.Status != "" { q += ` AND p.status = @status`; args["status"] = filter.Status }
 	if filter.Type != "" { q += ` AND p.type = @type`; args["type"] = filter.Type }
+	if filter.OwnerID != "" { q += ` AND p.owner_id = @owner_id`; args["owner_id"] = filter.OwnerID }
 	if filter.MinRating > 0 { q += ` AND p.rating >= @min_rating`; args["min_rating"] = filter.MinRating }
 	if filter.PriceMin != nil { q += ` AND p.hourly_rate >= @price_min`; args["price_min"] = *filter.PriceMin }
 	if filter.PriceMax != nil { q += ` AND p.hourly_rate <= @price_max`; args["price_max"] = *filter.PriceMax }

+ 69 - 0
frontend/src/app/places/my/page.tsx

@@ -0,0 +1,69 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import Link from 'next/link'
+import { api } from '@/lib/api'
+import type { Place } from '@/types'
+
+export default function MyPlacesPage() {
+  const [places, setPlaces] = useState<Place[]>([])
+  const [loading, setLoading] = useState(true)
+
+  useEffect(() => {
+    api.get<{ data: Place[] }>('/places/my')
+      .then((res) => setPlaces(res.data))
+      .catch(() => {})
+      .finally(() => setLoading(false))
+  }, [])
+
+  return (
+    <div className="min-h-screen bg-[#0f172a] pt-20">
+      <div className="mx-auto max-w-3xl px-4">
+        <h1 className="mb-6 text-2xl font-bold text-white">Мои места</h1>
+
+        {loading ? (
+          <p className="text-white/40">Загрузка...</p>
+        ) : 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>
+          </div>
+        ) : (
+          <div className="space-y-4">
+            {places.map((place) => (
+              <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 text-white/40">
+                  <span className={
+                    place.status === 'published' ? 'text-green-400' :
+                    place.status === 'pending_moderation' ? 'text-yellow-400' :
+                    place.status === 'rejected' ? 'text-red-400' :
+                    'text-white/40'
+                  }>
+                    {place.status === 'published' ? 'Опубликовано' :
+                     place.status === 'pending_moderation' ? 'На модерации' :
+                     place.status === 'rejected' ? 'Отклонено' :
+                     place.status === 'draft' ? 'Черновик' :
+                     place.status}
+                  </span>
+                  {place.type === 'studio' && <span>Студия</span>}
+                </div>
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}

+ 19 - 0
frontend/src/components/AddLocationModal.tsx

@@ -0,0 +1,19 @@
+'use client'
+
+import PlaceForm from './PlaceForm'
+
+export default function AddLocationModal({ onClose }: { onClose: () => void }) {
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
+      <div className="relative max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-2xl bg-[#1e293b]">
+        <button
+          onClick={onClose}
+          className="absolute right-4 top-4 z-10 text-white/60 hover:text-white"
+        >
+          ✕
+        </button>
+        <PlaceForm type="place" onSuccess={onClose} />
+      </div>
+    </div>
+  )
+}

+ 20 - 16
frontend/src/components/Header.tsx

@@ -5,17 +5,13 @@ import Link from 'next/link'
 import { useAuth } from '@/hooks/useAuth'
 import LoginModal from './LoginModal'
 import RegisterModal from './RegisterModal'
-
-const roleLinks: Record<string, { href: string; label: string }> = {
-  customer: { href: '/places/add', label: 'Мои места' },
-  executor: { href: '/services/add', label: 'Мои услуги' },
-  landlord: { href: '/studios/add', label: 'Мои студии' },
-}
+import AddLocationModal from './AddLocationModal'
 
 export default function Header() {
   const { user, isLoading, logout } = useAuth()
   const [showLogin, setShowLogin] = useState(false)
   const [showRegister, setShowRegister] = useState(false)
+  const [showAddLocation, setShowAddLocation] = useState(false)
 
   return (
     <>
@@ -27,23 +23,31 @@ export default function Header() {
 
       {showLogin && <LoginModal onClose={() => setShowLogin(false)} />}
       {showRegister && <RegisterModal onClose={() => setShowRegister(false)} />}
+      {showAddLocation && <AddLocationModal onClose={() => setShowAddLocation(false)} />}
 
       <div className="absolute right-4 top-4 z-30 inline-flex items-center gap-3 rounded-lg bg-white/10 backdrop-blur-md px-4 py-2">
         {isLoading ? null : user ? (
           <>
+            <Link
+              href="/places/my"
+              className="text-sm text-white hover:text-green-400 transition"
+            >
+              Мои места
+            </Link>
             <span className="text-sm text-white/60">{user.name || user.email}</span>
-            {(user.role === 'moderator' || user.role === 'superadmin') ? (
-              <Link href="/admin" className="text-sm text-white hover:text-green-400 transition">
-                Админка
-              </Link>
-            ) : roleLinks[user.role] ? (
-              <Link href={roleLinks[user.role].href} className="text-sm text-white hover:text-green-400 transition">
-                {roleLinks[user.role].label}
-              </Link>
-            ) : null}
-            <button onClick={logout} className="text-sm text-white/60 hover:text-white transition">
+            <button
+              onClick={logout}
+              className="rounded-md px-3 py-1 text-sm text-white/80 hover:bg-[#ff5656] hover:text-white transition-colors"
+            >
               Выйти
             </button>
+            <button
+              onClick={() => setShowAddLocation(true)}
+              className="flex h-7 w-7 items-center justify-center rounded-md bg-green-600 text-lg font-bold text-white hover:bg-green-500 transition"
+              title="Добавить локацию"
+            >
+              +
+            </button>
           </>
         ) : (
           <>

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

@@ -9,6 +9,8 @@ import type { Tag, Feature } from '@/types'
 interface PlaceFormProps {
   /** Тип создаваемого объекта: место или студия */
   type: 'place' | 'studio'
+  /** Колбэк после успешного создания (заменяет редирект на /) */
+  onSuccess?: () => void
 }
 
 /**
@@ -18,7 +20,7 @@ interface PlaceFormProps {
  * Перед сохранением загружает обложку через presigned URL.
  * @param type - Тип объекта: 'place' — место, 'studio' — студия
  */
-export default function PlaceForm({ type }: PlaceFormProps) {
+export default function PlaceForm({ type, onSuccess }: PlaceFormProps) {
   const router = useRouter()
   const [tags, setTags] = useState<Tag[]>([])
   const [features, setFeatures] = useState<Feature[]>([])
@@ -94,7 +96,7 @@ export default function PlaceForm({ type }: PlaceFormProps) {
       }
 
       await api.post('/places', body)
-      router.push('/')
+      if (onSuccess) { onSuccess() } else { router.push('/') }
     } catch (err: any) {
       setError(err.message || 'Ошибка при создании')
     } finally {