Ver código fonte

Initial commit: PhotoPlaces MVP

foxtime 2 meses atrás
commit
375ea1940b
88 arquivos alterados com 6451 adições e 0 exclusões
  1. 15 0
      .editorconfig
  2. 36 0
      .gitignore
  3. 31 0
      Makefile
  4. 7 0
      backend/.dockerignore
  5. 17 0
      backend/.env.example
  6. 11 0
      backend/.golangci.yml
  7. 14 0
      backend/Dockerfile
  8. 151 0
      backend/cmd/api/main.go
  9. 312 0
      backend/docs/API_CONTRACT.md
  10. 308 0
      backend/docs/DB_SCHEMA.md
  11. 90 0
      backend/docs/MAP_PROVIDERS.md
  12. 135 0
      backend/docs/PAYMENT_INTEGRATION.md
  13. 127 0
      backend/docs/SPEC.md
  14. 16 0
      backend/go.mod
  15. 63 0
      backend/internal/config/config.go
  16. 128 0
      backend/internal/handlers/auth.go
  17. 140 0
      backend/internal/handlers/bookings.go
  18. 8 0
      backend/internal/handlers/helpers.go
  19. 253 0
      backend/internal/handlers/places.go
  20. 77 0
      backend/internal/handlers/reviews.go
  21. 172 0
      backend/internal/handlers/services.go
  22. 96 0
      backend/internal/handlers/tags.go
  23. 97 0
      backend/internal/handlers/upload.go
  24. 158 0
      backend/internal/handlers/users.go
  25. 68 0
      backend/internal/handlers/websocket.go
  26. 76 0
      backend/internal/middleware/auth.go
  27. 69 0
      backend/internal/middleware/ratelimit.go
  28. 17 0
      backend/internal/models/booking.go
  29. 70 0
      backend/internal/models/place.go
  30. 15 0
      backend/internal/models/review.go
  31. 45 0
      backend/internal/models/service.go
  32. 16 0
      backend/internal/models/tag.go
  33. 33 0
      backend/internal/models/user.go
  34. 133 0
      backend/internal/repository/bookings.go
  35. 29 0
      backend/internal/repository/db.go
  36. 258 0
      backend/internal/repository/places.go
  37. 103 0
      backend/internal/repository/reviews.go
  38. 161 0
      backend/internal/repository/services.go
  39. 105 0
      backend/internal/repository/tags.go
  40. 116 0
      backend/internal/repository/users.go
  41. 191 0
      backend/internal/services/auth.go
  42. 154 0
      backend/internal/services/places.go
  43. 2 0
      backend/migrations/000001_create_extensions.down.sql
  44. 2 0
      backend/migrations/000001_create_extensions.up.sql
  45. 2 0
      backend/migrations/000002_create_users.down.sql
  46. 31 0
      backend/migrations/000002_create_users.up.sql
  47. 2 0
      backend/migrations/000003_create_tags_features.down.sql
  48. 42 0
      backend/migrations/000003_create_tags_features.up.sql
  49. 4 0
      backend/migrations/000004_create_places.down.sql
  50. 55 0
      backend/migrations/000004_create_places.up.sql
  51. 5 0
      backend/migrations/000005_create_services_reviews_bookings.down.sql
  52. 72 0
      backend/migrations/000005_create_services_reviews_bookings.up.sql
  53. 4 0
      backend/migrations/000006_create_moderation_payments_visitors.down.sql
  54. 53 0
      backend/migrations/000006_create_moderation_payments_visitors.up.sql
  55. 67 0
      deploy/deploy.sh
  56. 104 0
      deploy/docker-compose.prod.yml
  57. 25 0
      deploy/env.prod
  58. 41 0
      deploy/setup.sh
  59. 95 0
      docker-compose.yml
  60. 6 0
      frontend/.dockerignore
  61. 3 0
      frontend/.env.local.example
  62. 7 0
      frontend/.eslintrc.json
  63. 7 0
      frontend/.prettierrc
  64. 25 0
      frontend/Dockerfile
  65. 12 0
      frontend/next.config.js
  66. 30 0
      frontend/package.json
  67. 6 0
      frontend/postcss.config.js
  68. 41 0
      frontend/src/app/admin/layout.tsx
  69. 84 0
      frontend/src/app/admin/page.tsx
  70. 103 0
      frontend/src/app/admin/tags/page.tsx
  71. 113 0
      frontend/src/app/admin/users/page.tsx
  72. 66 0
      frontend/src/app/auth/login/page.tsx
  73. 78 0
      frontend/src/app/auth/register/page.tsx
  74. 22 0
      frontend/src/app/layout.tsx
  75. 9 0
      frontend/src/app/page.tsx
  76. 11 0
      frontend/src/app/places/add/page.tsx
  77. 162 0
      frontend/src/app/services/add/page.tsx
  78. 11 0
      frontend/src/app/studios/add/page.tsx
  79. 45 0
      frontend/src/components/Header.tsx
  80. 140 0
      frontend/src/components/MapView.tsx
  81. 262 0
      frontend/src/components/PlaceForm.tsx
  82. 77 0
      frontend/src/hooks/useAuth.tsx
  83. 54 0
      frontend/src/lib/api.ts
  84. 165 0
      frontend/src/lib/map.ts
  85. 30 0
      frontend/src/styles/globals.css
  86. 124 0
      frontend/src/types/index.ts
  87. 8 0
      frontend/tailwind.config.js
  88. 23 0
      frontend/tsconfig.json

+ 15 - 0
.editorconfig

@@ -0,0 +1,15 @@
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.go]
+indent_size = 4
+
+[Makefile]
+indent_style = tab

+ 36 - 0
.gitignore

@@ -0,0 +1,36 @@
+# Dependencies
+node_modules/
+.pnp
+
+# Build
+.next/
+dist/
+out/
+*.exe
+*.exe~
+*.so
+
+# Env
+.env
+.env.local
+.env.*.local
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Go
+vendor/
+*.test
+*.out
+
+# Logs
+*.log
+npm-debug.log*

+ 31 - 0
Makefile

@@ -0,0 +1,31 @@
+.PHONY: dev prod deploy logs stop clean help
+
+dev:             ## Запустить локальную разработку
+	docker compose up -d
+	cd backend && go run ./cmd/api &
+	cd frontend && npm run dev
+
+prod:            ## Запустить продакшен (сборка + деплой)
+	bash deploy/deploy.sh
+
+deploy: prod     ## Алиас для prod
+
+logs:            ## Логи продакшена
+	docker compose -f deploy/docker-compose.prod.yml logs -f
+
+stop:            ## Остановить всё
+	docker compose down
+	docker compose -f deploy/docker-compose.prod.yml down
+
+clean:           ## Остановить + удалить volume
+	docker compose down -v
+	docker compose -f deploy/docker-compose.prod.yml down -v
+
+restart:         ## Перезапустить продакшен
+	docker compose -f deploy/docker-compose.prod.yml restart
+
+ps:              ## Статус контейнеров
+	docker compose -f deploy/docker-compose.prod.yml ps
+
+help:            ## Показать помощь
+	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'

+ 7 - 0
backend/.dockerignore

@@ -0,0 +1,7 @@
+node_modules/
+.next/
+.git/
+.env
+.env.local
+*.log
+vendor/

+ 17 - 0
backend/.env.example

@@ -0,0 +1,17 @@
+APP_ENV=development
+DATABASE_URL=postgres://photoplaces:photoplaces_dev@localhost:5432/photoplaces?sslmode=disable
+REDIS_URL=redis://localhost:6379/0
+
+S3_ENDPOINT=http://localhost:9000
+S3_ACCESS_KEY=photoplaces
+S3_SECRET_KEY=photoplaces_dev
+S3_BUCKET=photoplaces
+
+JWT_SECRET=change-me-in-production
+JWT_REFRESH_SECRET=change-me-too
+
+CLOUDPAYMENTS_PUBLIC_ID=
+CLOUDPAYMENTS_API_SECRET=
+
+SERVER_PORT=8080
+ALLOWED_ORIGINS=http://localhost:3000

+ 11 - 0
backend/.golangci.yml

@@ -0,0 +1,11 @@
+linters:
+  enable:
+    - gofmt
+    - govet
+    - errcheck
+    - staticcheck
+    - gosimple
+    - ineffassign
+    - unused
+run:
+  timeout: 5m

+ 14 - 0
backend/Dockerfile

@@ -0,0 +1,14 @@
+FROM golang:1.22-alpine AS builder
+
+WORKDIR /app
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 GOOS=linux go build -o /photoplaces-api ./cmd/api
+
+FROM alpine:3.19
+RUN apk add --no-cache ca-certificates tzdata
+COPY --from=builder /photoplaces-api /photoplaces-api
+COPY --from=builder /app/migrations /migrations
+EXPOSE 8080
+CMD ["/photoplaces-api"]

+ 151 - 0
backend/cmd/api/main.go

@@ -0,0 +1,151 @@
+package main
+
+import (
+	"context"
+	"log"
+	"net/http"
+
+	"github.com/go-chi/chi/v5"
+	chimiddleware "github.com/go-chi/chi/v5/middleware"
+	"github.com/go-chi/cors"
+	"golang.org/x/time/rate"
+
+	"github.com/photoplaces/backend/internal/config"
+	"github.com/photoplaces/backend/internal/handlers"
+	"github.com/photoplaces/backend/internal/middleware"
+	"github.com/photoplaces/backend/internal/repository"
+	"github.com/photoplaces/backend/internal/services"
+)
+
+func main() {
+	cfg := config.Load()
+	ctx := context.Background()
+
+	pool, err := repository.NewPool(ctx, cfg.DatabaseURL)
+	if err != nil {
+		log.Fatalf("database connection: %v", err)
+	}
+	defer pool.Close()
+
+	userRepo := repository.NewUserRepo(pool)
+	placeRepo := repository.NewPlaceRepo(pool)
+	serviceRepo := repository.NewServiceRepo(pool)
+	reviewRepo := repository.NewReviewRepo(pool)
+	bookingRepo := repository.NewBookingRepo(pool)
+	tagRepo := repository.NewTagRepo(pool)
+	featureRepo := repository.NewFeatureRepo(pool)
+
+	authSvc := services.NewAuthService(userRepo, cfg.JWTSecret, cfg.JWTRefreshSecret)
+	placeSvc := services.NewPlaceService(placeRepo)
+
+	authHandler := handlers.NewAuthHandler(authSvc)
+	userHandler := handlers.NewUserHandler(userRepo)
+	placeHandler := handlers.NewPlaceHandler(placeSvc)
+	serviceHandler := handlers.NewServiceHandler(serviceRepo, tagRepo)
+	reviewHandler := handlers.NewReviewHandler(reviewRepo)
+	bookingHandler := handlers.NewBookingHandler(bookingRepo, placeRepo)
+	tagHandler := handlers.NewTagHandler(tagRepo, featureRepo)
+	wsHub := handlers.NewWSHub()
+	uploadHandler, err := handlers.NewUploadHandler(cfg.S3Endpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, false)
+	if err != nil {
+		log.Fatalf("upload handler: %v", err)
+	}
+
+	rl := middleware.NewRateLimiter(rate.Limit(60), 60)
+
+	r := chi.NewRouter()
+
+	r.Use(chimiddleware.Logger)
+	r.Use(chimiddleware.Recoverer)
+	r.Use(chimiddleware.RequestID)
+	r.Use(cors.Handler(cors.Options{
+		AllowedOrigins:   cfg.AllowedOrigins,
+		AllowedMethods:   []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
+		AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-Refresh-Token"},
+		AllowCredentials: true,
+		MaxAge:           300,
+	}))
+	r.Use(rl.Middleware())
+
+	r.Route("/api/v1", func(r chi.Router) {
+		r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
+			w.Write([]byte(`{"status":"ok"}`))
+		})
+
+		// Public auth
+		r.Post("/auth/register", authHandler.Register)
+		r.Post("/auth/login", authHandler.Login)
+		r.Post("/auth/refresh", authHandler.Refresh)
+
+		// Public reviews (read)
+		r.Get("/reviews", reviewHandler.List)
+
+		// Public tags & features
+		r.Get("/tags", tagHandler.ListTags)
+		r.Get("/features", tagHandler.ListFeatures)
+
+		// Public services (read)
+		r.Get("/services", serviceHandler.List)
+		r.Get("/services/{id}", serviceHandler.GetByID)
+
+		// Public places (read)
+		r.Get("/places", placeHandler.List)
+		r.Get("/places/{id}", placeHandler.GetByID)
+
+		// WebSocket visitor dots
+		r.Get("/ws/visitors", wsHub.HandleWS)
+
+		// Authenticated
+		r.Group(func(r chi.Router) {
+			r.Use(middleware.AuthMiddleware(authSvc))
+			r.Get("/auth/me", authHandler.Me)
+
+			// Users
+			r.Patch("/users/me", userHandler.UpdateMe)
+
+			// Upload
+			r.Post("/upload/presigned-url", uploadHandler.PresignedURL)
+
+			// Bookings
+			r.Post("/bookings", bookingHandler.Create)
+			r.Get("/bookings/me", bookingHandler.ListMy)
+			r.Patch("/bookings/{id}/cancel", bookingHandler.Cancel)
+
+			// Reviews
+			r.Post("/reviews", reviewHandler.Create)
+
+			// Services
+			r.Post("/services", serviceHandler.Create)
+			r.Patch("/services/{id}", serviceHandler.Update)
+			r.Delete("/services/{id}", serviceHandler.Delete)
+
+			// Place creation
+			r.Post("/places", placeHandler.Create)
+			r.Patch("/places/{id}", placeHandler.Update)
+			r.Delete("/places/{id}", placeHandler.Delete)
+		})
+
+		// Public user profile
+		r.Get("/users/{id}", userHandler.GetProfile)
+
+		// Moderator / Admin
+		r.Group(func(r chi.Router) {
+			r.Use(middleware.AuthMiddleware(authSvc))
+			r.Use(middleware.RoleMiddleware("moderator", "superadmin"))
+			r.Post("/places/{id}/moderate", placeHandler.Moderate)
+			r.Get("/admin/users", userHandler.AdminListUsers)
+			r.Patch("/admin/users/{id}", userHandler.AdminUpdateUser)
+			r.Patch("/admin/bookings/{id}/confirm", bookingHandler.Confirm)
+			r.Post("/admin/tags", tagHandler.CreateTag)
+			r.Delete("/admin/tags", tagHandler.DeleteTag)
+			r.Post("/admin/features", tagHandler.CreateFeature)
+			r.Delete("/admin/features", tagHandler.DeleteFeature)
+		})
+	})
+
+	port := cfg.ServerPort
+	log.Printf("Starting server on :%s", port)
+	if err := http.ListenAndServe(":"+port, r); err != nil {
+		log.Fatal(err)
+	}
+}

+ 312 - 0
backend/docs/API_CONTRACT.md

@@ -0,0 +1,312 @@
+# API Контракт (OpenAPI 3.0)
+
+## Базовый URL
+- Локально: `http://localhost:8080/api/v1`
+- Стенд: `https://staging.api.photoplaces.ru/api/v1`
+- Прод: `https://api.photoplaces.ru/api/v1`
+
+## Аутентификация
+- **Access Token**: JWT, срок 15 мин, в заголовке `Authorization: Bearer <token>`
+- **Refresh Token**: HTTP-only cookie, срок 30 дней, ротация при использовании
+- **Роли**: `superadmin`, `moderator`, `landlord`, `executor`, `customer`
+
+## Формат ошибок (RFC 7807)
+```json
+{
+  "type": "https://api.photoplaces.ru/errors/validation-error",
+  "title": "Ошибка валидации",
+  "status": 422,
+  "detail": "Некорректные входные данные",
+  "instance": "/api/v1/places",
+  "errors": [
+    { "field": "title", "message": "Название обязательно" }
+  ]
+}
+```
+
+---
+
+## Эндпоинты авторизации
+
+### POST /auth/register
+```json
+// Запрос
+{
+  "email": "user@example.com",
+  "password": "securePass123",
+  "role": "customer"  // customer | landlord | executor
+}
+
+// Ответ 201
+{
+  "user": { "id": "uuid", "email": "...", "role": "customer", "created_at": "..." },
+  "access_token": "eyJ...",
+  "refresh_token": "eyJ..."  // также устанавливается HttpOnly cookie
+}
+```
+
+### POST /auth/login
+```json
+// Запрос
+{
+  "email": "user@example.com",
+  "password": "securePass123"
+}
+
+// Ответ 200
+{
+  "user": { ... },
+  "access_token": "eyJ...",
+  "refresh_token": "eyJ..."
+}
+```
+
+### POST /auth/refresh
+- На основе cookie, тело не требуется
+- Ответ 200: новый access_token + обновлённый refresh_token
+
+### POST /auth/logout
+- Инвалидирует refresh token
+- Ответ 204
+
+### GET /auth/me
+- Возвращает профиль текущего пользователя
+- Ответ 200: объект User
+
+---
+
+## Эндпоинты мест (Places)
+
+### GET /places
+**Параметры запроса:**
+- `type`: `place` | `studio` (по умолчанию оба)
+- `tags`: ID тегов через запятую
+- `min_rating`: число 0-5
+- `features`: ID характеристик через запятую
+- `price_min`, `price_max`: для студий
+- `bounds`: `sw_lat,sw_lng,ne_lat,ne_lng` (область карты)
+- `cursor`: курсор пагинации
+- `limit`: 1-100 (по умолчанию 20)
+- `sort`: `rating` | `created_at` | `distance` (требует `user_lat,user_lng`)
+
+**Ответ 200:**
+```json
+{
+  "data": [
+    {
+      "id": "uuid",
+      "type": "place",
+      "title": "Красивый парк",
+      "cover_image": "https://cdn.../uuid.jpg",
+      "coordinates": { "lat": 55.75, "lng": 37.61 },
+      "address": "Москва, Парковая 1",
+      "rating": 4.5,
+      "tags": ["nature", "architecture"],
+      "features": ["parking", "public_transport"],
+      "status": "published",
+      "created_at": "2024-01-15T10:30:00Z"
+    }
+  ],
+  "next_cursor": "eyJpZCI6InV1aWQifQ==",
+  "has_more": true
+}
+```
+
+### GET /places/:id
+**Ответ 200:** Полная карточка PlaceCard / StudioCard
+```json
+{
+  "id": "uuid",
+  "type": "studio",
+  "title": "Студия 'Свет'",
+  "description": "Профессиональная фотостудия...",
+  "address": "Москва, ул. Ленина 10, офис 5",
+  "coordinates": { "lat": 55.75, "lng": 37.61 },
+  "cover_image": "https://cdn.../cover.jpg",
+  "images": ["https://cdn.../1.jpg", "https://cdn.../2.jpg"],
+  "access_info": "Метро 'Площадь Революции', 5 мин пешком. Парковка во дворе.",
+  "tags": ["portrait", "fashion", "product"],
+  "features": [
+    { "id": "flash", "name": "Импульсные источники" },
+    { "id": "continuous", "name": "Постоянный свет" },
+    { "id": "cyclorama", "name": "Циклорама" }
+  ],
+  "pricing": {
+    "hourly_rate": 1500,
+    "currency": "RUB",
+    "min_hours": 2
+  },
+  "booking_url": "https://booking.photoplaces.ru/studio/uuid",
+  "executor_services": [
+    { "id": "uuid", "title": "Портретная съемка", "price": 5000, "executor_name": "Иван Фотограф" }
+  ],
+  "rating": 4.8,
+  "reviews_count": 24,
+  "owner": { "id": "uuid", "name": "Студия 'Свет'", "avatar": "..." },
+  "status": "published",
+  "created_at": "2024-01-15T10:30:00Z",
+  "updated_at": "2024-01-20T14:22:00Z"
+}
+```
+
+### POST /places
+**Роли:** `customer` (type=place), `landlord` (type=studio)
+```json
+// Запрос
+{
+  "type": "studio",
+  "title": "Новая студия",
+  "description": "Описание...",
+  "address": "Москва, ул. Новая 5",
+  "coordinates": { "lat": 55.76, "lng": 37.62 },
+  "access_info": "Как добраться...",
+  "tags": ["portrait", "fashion"],
+  "features": ["flash", "cyclorama"],
+  "pricing": { "hourly_rate": 2000, "currency": "RUB", "min_hours": 3 }
+}
+
+// Ответ 201
+{ "id": "uuid", "status": "pending_moderation", "message": "Отправлено на проверку" }
+```
+
+### PATCH /places/:id
+**Роли:** Владелец, `moderator`, `superadmin`
+- Редактирование владельцем переводит в статус `pending_moderation`
+
+### DELETE /places/:id
+**Роли:** Владелец, `moderator`, `superadmin`
+- Мягкое удаление
+
+### POST /places/:id/moderate
+**Роли:** `moderator`, `superadmin`
+```json
+// Запрос
+{ "action": "approve", "comment": "Всё хорошо" }
+// action: approve | reject | request_changes
+```
+
+---
+
+## Эндпоинты услуг исполнителей (Services)
+
+### GET /services
+**Параметры:** `tags`, `min_rating`, `price_min`, `price_max`, `cursor`, `limit`
+
+### GET /services/:id
+**Ответ 200:** Полная карточка ServiceCard
+
+### POST /services
+**Роль:** `executor`
+```json
+{
+  "title": "Портретная съемка в студии",
+  "description": "Профессиональная портретная съемка...",
+  "price": 5000,
+  "currency": "RUB",
+  "duration_minutes": 120,
+  "tags": ["portrait", "studio"],
+  "portfolio_images": ["https://cdn.../1.jpg"]
+}
+```
+
+### PATCH /services/:id
+**Роль:** Владелец, `moderator`, `superadmin`
+
+### DELETE /services/:id
+**Роль:** Владелец, `moderator`, `superadmin`
+
+---
+
+## Эндпоинты пользователей (Users)
+
+### GET /users/:id
+**Публичный профиль**
+
+### PATCH /users/me
+**Обновление своего профиля**
+
+### GET /users/me/places
+**Свои места (все статусы)**
+
+### GET /users/me/services
+**Свои услуги (для исполнителей)**
+
+---
+
+## Административные эндпоинты
+
+### GET /admin/users
+**Роли:** `moderator`, `superadmin`
+**Параметры:** `role`, `status`, `cursor`, `limit`
+
+### PATCH /admin/users/:id
+**Роли:** `superadmin` (все), `moderator` (кроме superadmin/moderator)
+```json
+{ "role": "customer", "status": "active" }
+// status: active | banned | pending_verification
+```
+
+### GET /admin/moderation/queue
+**Роли:** `moderator`, `superadmin`
+**Параметры:** `type`: place | studio | service, `status`: pending | rejected
+
+### POST /admin/moderation/:entityType/:id
+**Роли:** `moderator`, `superadmin`
+```json
+{ "action": "approve", "comment": "..." }
+```
+
+---
+
+## Теги и характеристики (справочные данные)
+
+### GET /tags
+**Ответ:** Список тегов стилей съёмки
+```json
+[{ "id": "portrait", "name": "Портрет", "category": "style" }, ...]
+```
+
+### GET /features
+**Ответ:** Список характеристик мест
+```json
+[{ "id": "flash", "name": "Импульсный свет", "category": "equipment" }, ...]
+```
+
+---
+
+## Загрузка файлов
+
+### POST /upload/presigned-url
+```json
+// Запрос
+{ "content_type": "image/jpeg", "max_size_mb": 10 }
+// Ответ
+{ "upload_url": "https://s3...", "file_url": "https://cdn...", "fields": { "key": "...", "policy": "...", "signature": "..." } }
+```
+- Прямая загрузка в S3/MinIO с фронтенда
+- Бэкенд возвращает политику presigned POST
+
+---
+
+## Вебхуки
+
+### CloudPayments Webhook
+`POST /webhooks/cloudpayments`
+- Проверка подписи
+- Обновление статуса подписки/платежа
+
+---
+
+## Лимиты запросов
+- Авторизация: 10 запросов/мин на IP
+- Чтение мест: 60 запросов/мин на пользователя
+- Запись мест: 10 запросов/мин на пользователя
+- Администрирование: 100 запросов/мин
+
+---
+
+## Пагинация
+Курсорная:
+- `cursor` = base64(JSON) ключа сортировки последнего элемента + ID
+- `limit` макс. 100
+- Ответ содержит `next_cursor` и `has_more`

+ 308 - 0
backend/docs/DB_SCHEMA.md

@@ -0,0 +1,308 @@
+# Схема базы данных
+
+## Общие правила
+- Первичные ключи: UUID (генерация на уровне БД: `gen_random_uuid()`)
+- Мягкое удаление: поле `deleted_at` (timestamp, null = не удалён)
+- Аудит: поля `created_at`, `updated_at` (автоматически через триггеры или на уровне приложения)
+- Все внешние ключи имеют индексы
+
+---
+
+## Таблицы
+
+### 1. users — пользователи
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| id | UUID PK | |
+| email | VARCHAR(255) UNIQUE NOT NULL | Email для входа |
+| password_hash | VARCHAR(255) NOT NULL | bcrypt хеш |
+| role | VARCHAR(20) NOT NULL DEFAULT 'customer' | superadmin, moderator, landlord, executor, customer |
+| status | VARCHAR(20) NOT NULL DEFAULT 'active' | active, banned, pending_verification |
+| name | VARCHAR(255) | Отображаемое имя |
+| avatar_url | TEXT | Аватар |
+| phone | VARCHAR(20) | Телефон |
+| bio | TEXT | Краткая биография (для исполнителей) |
+| country | VARCHAR(10) | Код страны (RFC) для провайдера карт/платежей |
+| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+| deleted_at | TIMESTAMPTZ | Дата мягкого удаления |
+
+**Индексы:**
+- `idx_users_email` UNIQUE on email
+- `idx_users_role` on role
+- `idx_users_status` on status
+
+### 2. refresh_tokens — refresh токены
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| id | UUID PK | |
+| user_id | UUID FK → users(id) | |
+| token_hash | VARCHAR(255) NOT NULL | SHA-256 хеш токена |
+| expires_at | TIMESTAMPTZ NOT NULL | 30 дней |
+| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+
+**Индексы:**
+- `idx_refresh_tokens_token_hash` UNIQUE on token_hash
+- `idx_refresh_tokens_user_id` on user_id
+
+### 3. tags — теги стилей съёмки (справочник)
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| id | VARCHAR(50) PK | Ключ: portrait, fashion, nature, product, wedding, street, interior |
+| name | VARCHAR(255) NOT NULL | Название: Портрет, Фэшн, Природа |
+| category | VARCHAR(50) | Группа: style, technique, setting |
+| sort_order | INT DEFAULT 0 | Порядок сортировки |
+| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+
+### 4. features — характеристики мест (справочник)
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| id | VARCHAR(50) PK | Ключ: parking, flash, cyclorama, makeup_room, wardrobe |
+| name | VARCHAR(255) NOT NULL | Название: Парковка, Импульсный свет, Циклорама |
+| category | VARCHAR(50) | Группа: equipment, amenity, infrastructure |
+| icon | VARCHAR(50) | Иконка |
+| sort_order | INT DEFAULT 0 | |
+| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+
+### 5. places — места и студии
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| id | UUID PK | |
+| type | VARCHAR(10) NOT NULL | place или studio |
+| owner_id | UUID FK → users(id) NOT NULL | |
+| title | VARCHAR(255) NOT NULL | Название |
+| description | TEXT | Описание |
+| address | VARCHAR(500) | Адрес |
+| coordinates | GEOGRAPHY(Point, 4326) NOT NULL | PostGIS точка (lat, lng) |
+| cover_image | TEXT | URL обложки |
+| access_info | TEXT | Как добраться, парковка |
+| status | VARCHAR(20) NOT NULL DEFAULT 'pending_moderation' | draft, pending_moderation, published, rejected, archived |
+| moderation_comment | TEXT | Комментарий модератора при отклонении |
+| rating | DECIMAL(2,1) DEFAULT 0 | Средний рейтинг (0.0-5.0) |
+| reviews_count | INT DEFAULT 0 | Количество отзывов |
+| hourly_rate | INT | Цена за час (только studio) |
+| currency | VARCHAR(3) DEFAULT 'RUB' | Валюта (только studio) |
+| min_hours | INT DEFAULT 1 | Минимальное количество часов (только studio) |
+| booking_url | TEXT | Ссылка на бронирование (только studio) |
+| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+| deleted_at | TIMESTAMPTZ | |
+| published_at | TIMESTAMPTZ | Дата публикации |
+
+**Индексы:**
+- `idx_places_coordinates` GIST on coordinates
+- `idx_places_type` on type
+- `idx_places_status` on status
+- `idx_places_owner_id` on owner_id
+- `idx_places_rating` on rating
+- `idx_places_published_at` on published_at
+
+### 6. place_images — изображения мест
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| id | UUID PK | |
+| place_id | UUID FK → places(id) NOT NULL | |
+| url | TEXT NOT NULL | URL изображения |
+| alt | VARCHAR(255) | Альтернативный текст |
+| sort_order | INT DEFAULT 0 | Порядок |
+| is_cover | BOOLEAN DEFAULT false | |
+| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+
+**Индексы:**
+- `idx_place_images_place_id` on place_id
+
+### 7. place_tags — теги мест (M2M)
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| place_id | UUID FK → places(id) | |
+| tag_id | VARCHAR(50) FK → tags(id) | |
+| PK | (place_id, tag_id) | |
+
+### 8. place_features — характеристики мест (M2M)
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| place_id | UUID FK → places(id) | |
+| feature_id | VARCHAR(50) FK → features(id) | |
+| PK | (place_id, feature_id) | |
+
+### 9. services — услуги исполнителей (ServiceCard)
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| id | UUID PK | |
+| executor_id | UUID FK → users(id) NOT NULL | |
+| title | VARCHAR(255) NOT NULL | Название услуги |
+| description | TEXT | Подробное описание |
+| price | INT NOT NULL | Цена |
+| currency | VARCHAR(3) DEFAULT 'RUB' | |
+| duration_minutes | INT | Длительность |
+| status | VARCHAR(20) NOT NULL DEFAULT 'published' | draft, published, archived |
+| rating | DECIMAL(2,1) DEFAULT 0 | |
+| reviews_count | INT DEFAULT 0 | |
+| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+| deleted_at | TIMESTAMPTZ | |
+
+**Индексы:**
+- `idx_services_executor_id` on executor_id
+- `idx_services_status` on status
+- `idx_services_rating` on rating
+
+### 10. service_images — портфолио услуг
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| id | UUID PK | |
+| service_id | UUID FK → services(id) NOT NULL | |
+| url | TEXT NOT NULL | |
+| alt | VARCHAR(255) | |
+| sort_order | INT DEFAULT 0 | |
+| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+
+### 11. service_tags — теги услуг (M2M)
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| service_id | UUID FK → services(id) | |
+| tag_id | VARCHAR(50) FK → tags(id) | |
+| PK | (service_id, tag_id) | |
+
+### 12. reviews — отзывы
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| id | UUID PK | |
+| user_id | UUID FK → users(id) NOT NULL | Кто оставил |
+| target_type | VARCHAR(10) NOT NULL | place или service |
+| target_id | UUID NOT NULL | ID места или услуги |
+| rating | SMALLINT NOT NULL CHECK (1-5) | |
+| text | TEXT | Текст отзыва |
+| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+| deleted_at | TIMESTAMPTZ | |
+
+**Индексы:**
+- `idx_reviews_target` on (target_type, target_id)
+- `idx_reviews_user_id` on user_id
+- UNIQUE on (user_id, target_type, target_id) — один отзыв на цель
+
+### 13. bookings — бронирования студий
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| id | UUID PK | |
+| place_id | UUID FK → places(id) NOT NULL | |
+| user_id | UUID FK → users(id) NOT NULL | Кто забронировал |
+| start_time | TIMESTAMPTZ NOT NULL | |
+| end_time | TIMESTAMPTZ NOT NULL | |
+| status | VARCHAR(20) NOT NULL DEFAULT 'pending' | pending, confirmed, cancelled, completed |
+| total_price | INT | Итоговая цена |
+| currency | VARCHAR(3) DEFAULT 'RUB' | |
+| comment | TEXT | Комментарий к брони |
+| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+
+**Индексы:**
+- `idx_bookings_place_id` on place_id
+- `idx_bookings_user_id` on user_id
+- `idx_bookings_time` on (start_time, end_time)
+
+### 14. moderation_log — лог модерации
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| id | UUID PK | |
+| moderator_id | UUID FK → users(id) NOT NULL | |
+| target_type | VARCHAR(20) NOT NULL | place, service |
+| target_id | UUID NOT NULL | |
+| action | VARCHAR(20) NOT NULL | approve, reject, request_changes, publish, archive |
+| comment | TEXT | Комментарий |
+| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+
+**Индексы:**
+- `idx_moderation_log_target` on (target_type, target_id)
+- `idx_moderation_log_moderator_id` on moderator_id
+
+### 15. payments — платежи и подписки
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| id | UUID PK | |
+| user_id | UUID FK → users(id) NOT NULL | |
+| type | VARCHAR(20) NOT NULL | subscription, donation, listing_fee, booking_commission |
+| provider | VARCHAR(20) NOT NULL | cloudpayments, stripe |
+| provider_payment_id | VARCHAR(255) | ID платежа у провайдера |
+| amount | INT NOT NULL | Сумма в копейках |
+| currency | VARCHAR(3) DEFAULT 'RUB' | |
+| status | VARCHAR(20) NOT NULL | pending, succeeded, failed, refunded |
+| metadata | JSONB | Доп. данные |
+| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+
+**Индексы:**
+- `idx_payments_user_id` on user_id
+- `idx_payments_status` on status
+- `idx_payments_provider_id` on (provider, provider_payment_id)
+
+### 16. subscriptions — подписки
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| id | UUID PK | |
+| user_id | UUID FK → users(id) NOT NULL | |
+| plan | VARCHAR(50) NOT NULL | studio_listing, executor_promo, premium_user |
+| status | VARCHAR(20) NOT NULL | active, cancelled, expired |
+| current_period_start | TIMESTAMPTZ | |
+| current_period_end | TIMESTAMPTZ | |
+| provider_subscription_id | VARCHAR(255) | |
+| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+| updated_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+
+### 17. visitor_sessions — сессии посетителей на карте
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| id | UUID PK | |
+| user_id | UUID FK → users(id) | NULL для гостей |
+| coordinates | GEOGRAPHY(Point, 4326) NOT NULL | |
+| last_active_at | TIMESTAMPTZ NOT NULL | |
+| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
+
+**Индексы:**
+- `idx_visitor_sessions_coordinates` GIST on coordinates
+- `idx_visitor_sessions_last_active` on last_active_at
+
+---
+
+## PostGIS
+
+Для работы с географическими данными необходимо расширение PostGIS:
+```sql
+CREATE EXTENSION IF NOT EXISTS postgis;
+```
+
+## Миграции
+
+Миграции будут выполняться через `golang-migrate/migrate`.
+Файлы миграций: `backend/migrations/`:
+
+```
+backend/migrations/
+  000001_create_extensions.up.sql
+  000001_create_extensions.down.sql
+  000002_create_users.up.sql
+  000002_create_users.down.sql
+  000003_create_tags_features.up.sql
+  000003_create_tags_features.down.sql
+  000004_create_places.up.sql
+  000004_create_places.down.sql
+  ...
+```

+ 90 - 0
backend/docs/MAP_PROVIDERS.md

@@ -0,0 +1,90 @@
+# Провайдеры онлайн-карт для РФ
+
+## Критерии выбора
+- Работоспособность в России (не заблокированы)
+- Схематический стиль (не спутник)
+- JavaScript API для встраивания в веб
+- Бесплатный тариф для старта
+- Производительность и кастомизация маркеров
+
+---
+
+## Варианты
+
+### 1. Яндекс Карты (Яндекс JS API v3)
+
+| Характеристика | Значение |
+|----------------|----------|
+| Статус в РФ | ✅ Работает |
+| Схематический стиль | ✅ `yandex#map` + кастомные стили |
+| Бесплатный тариф | ✅ 10 000 запросов/сутки, дольше 30 дней — от 300р/мес |
+| Гео-поиск | ✅ Встроенный поиск по адресам РФ |
+| Маркеры | ✅ Кастомные, кластеризация, балуны (PlaceCard) |
+| Ограничения | ❌ Работает только в РФ и СНГ |
+| API | JS API v3, HTTP API геокодера |
+
+**Вердикт: основной провайдер для РФ**
+- Нативная поддержка российских адресов
+- Хорошая документация на русском
+- Простая кастомизация балунов под PlaceCard
+- Определение региона пользователя через JS API
+
+### 2. MapLibre GL JS + OpenStreetMap
+
+| Характеристика | Значение |
+|----------------|----------|
+| Статус в РФ | ✅ Работает (через любые тайлы) |
+| Схематический стиль | ✅ Кастомный style.json (можно сделать схематичным) |
+| Бесплатный тариф | ✅ Open Source, бесплатно |
+| Маркеры | ✅ Кастомные, кластеризация |
+| Поиск адресов | ❌ Нужен отдельный сервис (Nominatim или DaData) |
+| Тайлы | MapTiler, Geoapify, или self-hosted |
+
+**Вердикт: резервный / международный провайдер**
+- Работает где угодно
+- Требует больше настройки
+- Для РФ нужен тайловый сервер с российскими тайлами (NextGIS, Geoapify)
+
+### 3. 2GIS Карты
+
+| Характеристика | Значение |
+|----------------|----------|
+| Статус в РФ | ✅ Работает |
+| Схематический стиль | ✅ |
+| Бесплатный тариф | ✅ До 50 000 запросов/мес |
+| API | JS API (онлайн), Map API (мобильные) |
+| Маркеры | ✅ Кастомные |
+| Детализация РФ | ✅ Отличная |
+
+**Вердикт: альтернатива для РФ, но API менее гибкий чем Яндекс**
+
+### 4. Leaflet + OpenStreetMap (через прокси)
+
+| Характеристика | Значение |
+|----------------|----------|
+| Статус в РФ | ✅ Работает |
+| Схематический стиль | ⚠️ Зависит от тайлов |
+| Бесплатный тариф | ✅ |
+| Маркеры | ✅ Через плагины |
+
+**Вердикт: слишком базовая, многое нужно делать руками**
+
+---
+
+## Итоговая стратегия
+
+```
+Определение страны пользователя
+├── РФ / СНГ → Яндекс Карты JS API v3
+└── Остальной мир → MapLibre GL JS + OSM (MapTiler)
+```
+
+### Определение региона (Geo-detection)
+1. **Backend**: заголовок Cloudflare `CF-IPCountry` или база GeoIP в Redis
+2. **Frontend**: `Intl.DateTimeFormat().resolvedOptions().timeZone` как запасной вариант
+3. **Ответ API** при загрузке страницы: `{ map_provider: "yandex" | "maplibre" }`
+
+### План внедрения
+1. **MVP**: только Яндекс Карты (первый запуск только в РФ)
+2. **V2**: добавить MapLibre для международных пользователей
+3. Держать оба провайдера под одним интерфейсом через абстракцию `MapAdapter`

+ 135 - 0
backend/docs/PAYMENT_INTEGRATION.md

@@ -0,0 +1,135 @@
+# Интеграция CloudPayments
+
+## Общие принципы
+- Для РФ — CloudPayments (агрегатор с поддержкой карт, СБП, ЮMoney, SberPay)
+- Для международных пользователей — Stripe (на будущее)
+- Выбор провайдера на основе `user.country` или IP
+
+---
+
+## CloudPayments: основные возможности
+
+| Возможность | Поддержка |
+|-------------|-----------|
+| Приём карт Visa/Mastercard/Мир | ✅ |
+| СБП (Система быстрых платежей) | ✅ |
+| ЮMoney, SberPay, Tinkoff Pay | ✅ |
+| Регулярные платежи (подписки) | ✅ |
+| Вебхуки | ✅ |
+| Тестовый режим | ✅ |
+| Хостинг виджета | CloudPayments арендует страницу приёма, или виджет на своей странице |
+| Чеки (54-ФЗ) | ✅ через онлайн-кассу |
+
+---
+
+## Способы оплаты
+
+### 1. Разовый платёж (донат, разовая услуга)
+- CloudPayments Widget (iframe на фронтенде)
+- PayForm (страница на стороне CP)
+- API Cryptogram (для бесшовного опыта)
+
+### 2. Регулярные подписки (аренда студии, премиум)
+- CloudPayments API: создание подписки через токен карты
+- Первый платёж — как разовый, сохраняем `Token` и `AccountId`
+- Далее — рекуррент по расписанию
+
+---
+
+## Поток платежа (через виджет)
+
+```
+1. Фронтенд запрашивает сумму и описание
+2. Бэкенд создаёт платёж в БД (status: pending)
+3. Фронтенд открывает CloudPayments Widget:
+   - publicId, amount, currency, invoiceId (наш ID), description, accountId (user ID)
+4. Пользователь вводит данные карты
+5. CloudPayments отправляет вебхук на /webhooks/cloudpayments
+6. Бэкенд:
+   - Проверяет подпись (HMAC-SHA256)
+   - Обновляет статус платежа в БД
+   - Начисляет услугу / активирует подписку
+7. Фронтенд опрашивает статус и показывает результат
+```
+
+---
+
+## API CloudPayments
+
+### Настройки
+- `PublicId` — публичный ID для виджета
+- `ApiSecret` — секретный ключ для API (на бэкенде)
+- Тестовые карты: `4111111111111112`, `5555555555554444`
+
+### Вебхук CloudPayments
+
+**URL:** `POST /api/v1/webhooks/cloudpayments`
+
+```json
+{
+  "TransactionId": 12345,
+  "Amount": 150000,
+  "Currency": "RUB",
+  "DateTime": "2024-01-15T10:30:00",
+  "InvoiceId": "our-payment-uuid",
+  "AccountId": "our-user-uuid",
+  "Email": "user@example.com",
+  "SubscriptionId": null,
+  "Token": "token-for-recurring",
+  "Name": "SAIPAY",
+  "CardType": 0,
+  "IpCountry": "RU",
+  "IpCity": "Moscow",
+  "IpRegion": "Moscow",
+  "Data": {}
+}
+```
+
+**Проверка подписи (HMAC-SHA256):**
+- Секрет: `ApiSecret`
+- Данные: тело запроса в формате JSON
+- Заголовок `Content-HMAC`
+
+---
+
+## Структура тарифов (MVP)
+
+### Бесплатно
+- Размещение мест (для Заказчиков): бесплатно
+- Просмотр карты и карточек: бесплатно
+- Размещение услуг (для Исполнителей): 1 услуга бесплатно
+
+### Платно
+| Тариф | Цена | Кому | Описание |
+|-------|------|------|----------|
+| Размещение студии | ~1000р/мес | Арендодателям | Студия видна на карте |
+| Премиум-исполнитель | ~500р/мес | Исполнителям | До 10 услуг, приоритет в выдаче |
+| Донат | Любая сумма | Всем | Поддержка проекта |
+| Продвижение места | ~300р/нед | Заказчикам | Место в топе выдачи |
+
+---
+
+## План реализации (MVP без платежей)
+
+1. **Этап 1 (MVP)**: запуск без платежей — все функции бесплатно
+2. **Этап 2**: CloudPayments — разовые донаты
+3. **Этап 3**: Подписки на размещение студий
+4. **Этап 4**: Продвижение мест и услуг
+5. **Будущее**: Stripe для международных пользователей
+
+---
+
+## Обработка ошибок
+
+| Ситуация | Действие |
+|----------|----------|
+| Платеж отклонён банком | Показать пользователю "Попробуйте другую карту" |
+| Вебхук с неверной подписью | Вернуть 401, не обновлять статус |
+| Платеж пришёл с неизвестным InvoiceId | Вернуть 200, залогировать |
+| Повторный вебхук (дубль) | Игнорировать, если статус уже succeeded |
+
+## Требования к бэкенду
+
+- Пакеты Go: `github.com/cloudpayments/sdk-go`
+- Эндпоинт вебхука: без аутентификации (проверка HMAC)
+- Таблица `payments` и `subscriptions` в БД (см. DB_SCHEMA.md)

+ 127 - 0
backend/docs/SPEC.md

@@ -0,0 +1,127 @@
+# PhotoPlaces — Техническое задание
+
+## Обзор проекта
+Сервис онлайн-карты с отметками мест для фотосъёмки, аренды фотостудий и найма фотографов.
+Цель: запуск в России, затем международная экспансия.
+
+## Стек технологий
+- **Фронтенд**: Next.js 14+ (App Router), React 18, TypeScript, Tailwind CSS
+- **Бэкенд**: Go 1.22+, Chi router, PostgreSQL (pgx), Redis
+- **Аутентификация**: JWT (access + refresh токены), bcrypt
+- **Карты**: Яндекс Карты API (основной для РФ), MapLibre GL + OSM (запасной/международный)
+- **Платежи**: CloudPayments (РФ), Stripe (международные)
+- **Файлы**: S3-совместимое хранилище (MinIO локально, Selectel/Cloudflare R2 в проде)
+- **Деплой**: Docker, docker-compose, GitHub Actions CI/CD
+
+## Архитектура
+
+```
+┌─────────────┐     ┌─────────────┐     ┌─────────────┐
+│   Next.js   │────▶│   Go API    │────▶│ PostgreSQL  │
+│  (Фронтенд) │     │  (Бэкенд)   │     │  (Основная) │
+└─────────────┘     └─────────────┘     └─────────────┘
+                           │                    │
+                           ▼                    ▼
+                    ┌─────────────┐     ┌─────────────┐
+                    │    Redis    │     │   MinIO/S3  │
+                    │  (Кеш,      │     │  (Картинки) │
+                    │   Сессии)   │     │             │
+                    └─────────────┘     └─────────────┘
+```
+
+## Роли пользователей
+
+| Роль | Ключ | Разрешения |
+|------|------|------------|
+| Суперадминистратор | `superadmin` | Полный доступ, управление всеми пользователями и системой |
+| Модератор | `moderator` | Управление местами, пользователями (кроме админов и модераторов), модерация |
+| Арендодатель | `landlord` | Добавление/редактирование своих студий (требует подтверждения) |
+| Исполнитель | `executor` | Добавление/редактирование своих услуг (фото/видеосъёмка) |
+| Заказчик | `customer` | Добавление/редактирование своих мест (требует подтверждения) |
+| Гость | `guest` | Только просмотр карты, карточки мест недоступны |
+
+## Типы мест
+
+1. **Место** (`place`) — публичные локации для самостоятельной съёмки
+2. **Студия** (`studio`) — арендуемые фотостудии с бронированием
+
+## Карточка места (PlaceCard)
+
+- Основная информация: название, описание, адрес, координаты
+- Информация о доступе: как добраться, парковка, общественный транспорт
+- Медиа: фотогалерея, обложка
+- Атрибуты: теги (стили съёмки), характеристики (освещение, пространство, оборудование)
+- Рейтинг и отзывы
+- **Только для студий**: цены, кнопка бронирования, ссылка на услуги исполнителей
+
+## Карточка услуги (ServiceCard) — для Исполнителей
+
+- Информация об услуге: название, описание, цена, длительность
+- Профиль исполнителя: биография, портфолио, рейтинг, отзывы
+- Теги: стили фотосъёмки
+- Контакты / интеграция бронирования
+
+## Пользовательские сценарии
+
+### Гость (неавторизован)
+1. Видит полноэкранную схематическую карту
+2. Видит своё местоположение (геолокация)
+3. Видит других посетителей как зелёные выпуклые точки (некликабельные)
+4. Не может нажимать на маркеры мест
+5. Кнопка: «Зарегистрируйтесь, чтобы исследовать места»
+
+### Авторизованный пользователь (любая роль, кроме Гостя)
+1. Видит все опубликованные маркеры на карте
+2. Может фильтровать: тип, теги, рейтинг, характеристики, ценовой диапазон
+3. Клик по маркеру → открывается PlaceCard / StudioCard
+4. StudioCard: кнопка «Забронировать» → переход к бронированию
+5. Может добавлять места (Заказчик) или студии (Арендодатель) → отправка на модерацию
+
+### Арендодатель
+1. Форма «Добавить студию» → создаётся черновик
+2. Модератор/Админ проверяет → публикует или отклоняет
+3. Редактирование требует повторного подтверждения
+
+### Заказчик
+1. Форма «Добавить место» → создаётся черновик
+2. Модератор/Админ проверяет → публикует или отклоняет
+
+### Исполнитель
+1. Создаёт карточку услуги (ServiceCard)
+2. Публикуется сразу или после лёгкой модерации
+3. Привязывается к карточкам студий
+
+## Провайдеры карт
+
+- **Россия**: Яндекс Карты JS API v3 (схематический стиль `yandex#map`)
+- **Международные**: MapLibre GL JS + тайлы OSM (MapTiler / Geoapify)
+- **Определение региона**: по IP (Cloudflare) или часовому поясу браузера
+
+## Платежи (CloudPayments)
+
+- Студии: ежемесячная плата за размещение или комиссия с бронирований
+- Исполнители: плата за продвижение
+- Пользователи: донаты / премиум-функции (расширенные фильтры, сохранённые места)
+- Виджет для разовых и регулярных платежей, вебхуки для статусов
+
+## Безопасность
+
+- Ограничение запросов (Redis)
+- CORS, Helmet/CSP заголовки
+- Валидация ввода (go-playground/validator)
+- Параметризованные SQL-запросы
+- Валидация загружаемых файлов (тип, размер)
+- Мягкое удаление (`deleted_at`)
+
+## Окружения
+
+- **Локальное**: docker-compose (PostgreSQL, Redis, MinIO, Бэкенд, Фронтенд)
+- **Стенд**: один сервер, Docker Compose, Let's Encrypt
+- **Продакшн**: Kubernetes или Docker Swarm, CDN, WAF
+
+## Планы на будущее
+
+- Мобильные приложения (React Native / Expo)
+- WebSocket для точек посетителей в реальном времени
+- ML-рекомендации мест
+- Партнёрская программа для фотографов

+ 16 - 0
backend/go.mod

@@ -0,0 +1,16 @@
+module github.com/photoplaces/backend
+
+go 1.22
+
+require (
+	github.com/go-chi/chi/v5 v5.0.12
+	github.com/go-chi/cors v1.2.1
+	github.com/golang-jwt/jwt/v5 v5.2.0
+	github.com/google/uuid v1.6.0
+	github.com/gorilla/websocket v1.5.1
+	github.com/jackc/pgx/v5 v5.5.3
+	github.com/joho/godotenv v1.5.1
+	github.com/minio/minio-go/v7 v7.0.69
+	golang.org/x/crypto v0.18.0
+	golang.org/x/time v0.5.0
+)

+ 63 - 0
backend/internal/config/config.go

@@ -0,0 +1,63 @@
+package config
+
+import (
+	"os"
+	"strings"
+
+	"github.com/joho/godotenv"
+)
+
+type Config struct {
+	AppEnv    string
+	ServerPort string
+
+	DatabaseURL string
+	RedisURL    string
+
+	S3Endpoint  string
+	S3AccessKey string
+	S3SecretKey string
+	S3Bucket    string
+
+	JWTSecret        string
+	JWTRefreshSecret string
+
+	CloudPaymentsPublicID string
+	CloudPaymentsAPISecret string
+
+	AllowedOrigins []string
+}
+
+func Load() *Config {
+	godotenv.Load()
+
+	cfg := &Config{
+		AppEnv:    getEnv("APP_ENV", "development"),
+		ServerPort: getEnv("SERVER_PORT", "8080"),
+
+		DatabaseURL: getEnv("DATABASE_URL", "postgres://photoplaces:photoplaces_dev@localhost:5432/photoplaces?sslmode=disable"),
+		RedisURL:    getEnv("REDIS_URL", "redis://localhost:6379/0"),
+
+		S3Endpoint:  getEnv("S3_ENDPOINT", "http://localhost:9000"),
+		S3AccessKey: getEnv("S3_ACCESS_KEY", "photoplaces"),
+		S3SecretKey: getEnv("S3_SECRET_KEY", "photoplaces_dev"),
+		S3Bucket:    getEnv("S3_BUCKET", "photoplaces"),
+
+		JWTSecret:        getEnv("JWT_SECRET", "dev-secret"),
+		JWTRefreshSecret: getEnv("JWT_REFRESH_SECRET", "dev-refresh-secret"),
+
+		CloudPaymentsPublicID: getEnv("CLOUDPAYMENTS_PUBLIC_ID", ""),
+		CloudPaymentsAPISecret: getEnv("CLOUDPAYMENTS_API_SECRET", ""),
+
+		AllowedOrigins: strings.Split(getEnv("ALLOWED_ORIGINS", "http://localhost:3000"), ","),
+	}
+
+	return cfg
+}
+
+func getEnv(key, fallback string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return fallback
+}

+ 128 - 0
backend/internal/handlers/auth.go

@@ -0,0 +1,128 @@
+package handlers
+
+import (
+	"encoding/json"
+	"errors"
+	"net/http"
+
+	"github.com/photoplaces/backend/internal/middleware"
+	"github.com/photoplaces/backend/internal/services"
+)
+
+type AuthHandler struct {
+	authSvc *services.AuthService
+}
+
+func NewAuthHandler(authSvc *services.AuthService) *AuthHandler {
+	return &AuthHandler{authSvc: authSvc}
+}
+
+type registerRequest struct {
+	Email    string `json:"email"`
+	Password string `json:"password"`
+	Role     string `json:"role"`
+	Name     string `json:"name"`
+}
+
+func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
+	var req registerRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	if req.Email == "" || req.Password == "" {
+		writeError(w, http.StatusBadRequest, "email and password are required")
+		return
+	}
+
+	validRoles := map[string]bool{"customer": true, "landlord": true, "executor": true}
+	if !validRoles[req.Role] {
+		req.Role = "customer"
+	}
+
+	result, err := h.authSvc.Register(r.Context(), services.RegisterInput{
+		Email:    req.Email,
+		Password: req.Password,
+		Role:     req.Role,
+		Name:     req.Name,
+	})
+	if err != nil {
+		if errors.Is(err, services.ErrEmailExists) {
+			writeError(w, http.StatusConflict, "email already exists")
+			return
+		}
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusCreated, result)
+}
+
+type loginRequest struct {
+	Email    string `json:"email"`
+	Password string `json:"password"`
+}
+
+func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
+	var req loginRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	result, err := h.authSvc.Login(r.Context(), services.LoginInput{
+		Email:    req.Email,
+		Password: req.Password,
+	})
+	if err != nil {
+		if errors.Is(err, services.ErrInvalidCreds) {
+			writeError(w, http.StatusUnauthorized, "invalid email or password")
+			return
+		}
+		if errors.Is(err, services.ErrUserBanned) {
+			writeError(w, http.StatusForbidden, "account is banned")
+			return
+		}
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, result)
+}
+
+func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
+	refreshToken := r.Header.Get("X-Refresh-Token")
+	if refreshToken == "" {
+		writeError(w, http.StatusBadRequest, "refresh token required")
+		return
+	}
+
+	result, err := h.authSvc.RefreshSession(r.Context(), refreshToken)
+	if err != nil {
+		writeError(w, http.StatusUnauthorized, "invalid or expired refresh token")
+		return
+	}
+
+	writeJSON(w, http.StatusOK, result)
+}
+
+func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
+	userID := middleware.GetUserID(r.Context())
+	if userID == "" {
+		writeError(w, http.StatusUnauthorized, "not authenticated")
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]string{"user_id": userID, "role": middleware.GetUserRole(r.Context())})
+}
+
+func writeJSON(w http.ResponseWriter, status int, v interface{}) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	json.NewEncoder(w).Encode(v)
+}
+
+func writeError(w http.ResponseWriter, status int, msg string) {
+	writeJSON(w, status, map[string]string{"error": msg})
+}

+ 140 - 0
backend/internal/handlers/bookings.go

@@ -0,0 +1,140 @@
+package handlers
+
+import (
+	"encoding/json"
+	"net/http"
+	"time"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/photoplaces/backend/internal/middleware"
+	"github.com/photoplaces/backend/internal/models"
+	"github.com/photoplaces/backend/internal/repository"
+)
+
+type BookingHandler struct {
+	bookingRepo *repository.BookingRepo
+	placeRepo   *repository.PlaceRepo
+}
+
+func NewBookingHandler(bookingRepo *repository.BookingRepo, placeRepo *repository.PlaceRepo) *BookingHandler {
+	return &BookingHandler{bookingRepo: bookingRepo, placeRepo: placeRepo}
+}
+
+type createBookingRequest struct {
+	PlaceID   string `json:"place_id"`
+	StartTime string `json:"start_time"`
+	EndTime   string `json:"end_time"`
+	Comment   string `json:"comment"`
+}
+
+func (h *BookingHandler) Create(w http.ResponseWriter, r *http.Request) {
+	userID := middleware.GetUserID(r.Context())
+
+	var req createBookingRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	start, err := time.Parse(time.RFC3339, req.StartTime)
+	if err != nil {
+		writeError(w, http.StatusBadRequest, "invalid start_time format (use RFC3339)")
+		return
+	}
+	end, err := time.Parse(time.RFC3339, req.EndTime)
+	if err != nil {
+		writeError(w, http.StatusBadRequest, "invalid end_time format (use RFC3339)")
+		return
+	}
+
+	if !end.After(start) {
+		writeError(w, http.StatusBadRequest, "end_time must be after start_time")
+		return
+	}
+
+	available, err := h.bookingRepo.IsTimeSlotAvailable(r.Context(), req.PlaceID, start, end)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	if !available {
+		writeError(w, http.StatusConflict, "time slot is not available")
+		return
+	}
+
+	totalPrice, err := h.bookingRepo.CalculateTotalPrice(r.Context(), req.PlaceID, start, end)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	booking := &models.Booking{
+		PlaceID:    req.PlaceID,
+		UserID:     userID,
+		StartTime:  start,
+		EndTime:    end,
+		TotalPrice: totalPrice,
+		Currency:   "RUB",
+		Comment:    strPtr(req.Comment),
+	}
+
+	if err := h.bookingRepo.Create(r.Context(), booking); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusCreated, booking)
+}
+
+func (h *BookingHandler) ListMy(w http.ResponseWriter, r *http.Request) {
+	userID := middleware.GetUserID(r.Context())
+
+	bookings, err := h.bookingRepo.ListByUser(r.Context(), userID)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	if bookings == nil {
+		bookings = []*models.Booking{}
+	}
+
+	writeJSON(w, http.StatusOK, map[string]interface{}{"data": bookings})
+}
+
+func (h *BookingHandler) Cancel(w http.ResponseWriter, r *http.Request) {
+	id := chi.URLParam(r, "id")
+	userID := middleware.GetUserID(r.Context())
+
+	booking, err := h.bookingRepo.GetByID(r.Context(), id)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	if booking == nil {
+		writeError(w, http.StatusNotFound, "booking not found")
+		return
+	}
+	if booking.UserID != userID {
+		writeError(w, http.StatusForbidden, "not your booking")
+		return
+	}
+
+	if err := h.bookingRepo.UpdateStatus(r.Context(), id, "cancelled"); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"})
+}
+
+func (h *BookingHandler) Confirm(w http.ResponseWriter, r *http.Request) {
+	id := chi.URLParam(r, "id")
+
+	if err := h.bookingRepo.UpdateStatus(r.Context(), id, "confirmed"); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]string{"status": "confirmed"})
+}

+ 8 - 0
backend/internal/handlers/helpers.go

@@ -0,0 +1,8 @@
+package handlers
+
+func strPtr(s string) *string {
+	if s == "" {
+		return nil
+	}
+	return &s
+}

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

@@ -0,0 +1,253 @@
+package handlers
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/photoplaces/backend/internal/middleware"
+	"github.com/photoplaces/backend/internal/models"
+	"github.com/photoplaces/backend/internal/services"
+)
+
+type PlaceHandler struct {
+	placeSvc *services.PlaceService
+}
+
+func NewPlaceHandler(placeSvc *services.PlaceService) *PlaceHandler {
+	return &PlaceHandler{placeSvc: placeSvc}
+}
+
+func (h *PlaceHandler) List(w http.ResponseWriter, r *http.Request) {
+	q := r.URL.Query()
+
+	filter := models.PlaceFilter{
+		Type:   q.Get("type"),
+		Status: "published",
+	}
+
+	if tags := q["tags"]; len(tags) > 0 {
+		filter.TagIDs = tags
+	}
+	if features := q["features"]; len(features) > 0 {
+		filter.FeatureIDs = features
+	}
+	if mr := q.Get("min_rating"); mr != "" {
+		if v, err := strconv.ParseFloat(mr, 64); err == nil {
+			filter.MinRating = v
+		}
+	}
+	if pmin := q.Get("price_min"); pmin != "" {
+		if v, err := strconv.Atoi(pmin); err == nil {
+			filter.PriceMin = &v
+		}
+	}
+	if pmax := q.Get("price_max"); pmax != "" {
+		if v, err := strconv.Atoi(pmax); err == nil {
+			filter.PriceMax = &v
+		}
+	}
+	if bounds := q.Get("bounds"); bounds != "" {
+		var b models.Bounds
+		if _, err := fmt.Sscanf(bounds, "%f,%f,%f,%f", &b.SWLat, &b.SWLng, &b.NELat, &b.NELng); err == nil {
+			filter.Bounds = &b
+		}
+	}
+	if limit := q.Get("limit"); limit != "" {
+		if v, err := strconv.Atoi(limit); err == nil {
+			filter.Limit_ = v
+		}
+	}
+	filter.Sort = q.Get("sort")
+	if q.Get("user_lat") != "" && q.Get("user_lng") != "" {
+		if lat, err := strconv.ParseFloat(q.Get("user_lat"), 64); err == nil {
+			filter.UserLat = &lat
+		}
+		if lng, err := strconv.ParseFloat(q.Get("user_lng"), 64); err == nil {
+			filter.UserLng = &lng
+		}
+	}
+
+	places, err := h.placeSvc.List(r.Context(), filter)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	if places == nil {
+		places = []*models.Place{}
+	}
+
+	writeJSON(w, http.StatusOK, map[string]interface{}{
+		"data": places,
+	})
+}
+
+func (h *PlaceHandler) GetByID(w http.ResponseWriter, r *http.Request) {
+	id := chi.URLParam(r, "id")
+
+	place, err := h.placeSvc.GetByID(r.Context(), id, true)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	if place == nil {
+		writeError(w, http.StatusNotFound, "place not found")
+		return
+	}
+
+	writeJSON(w, http.StatusOK, place)
+}
+
+type createPlaceRequest struct {
+	Title       string            `json:"title"`
+	Description *string           `json:"description"`
+	Address     *string           `json:"address"`
+	Lat         float64           `json:"lat"`
+	Lng         float64           `json:"lng"`
+	Type        string            `json:"type"`
+	AccessInfo  *string           `json:"access_info"`
+	CoverImage  *string           `json:"cover_image"`
+	Tags        []string          `json:"tags"`
+	Features    []string          `json:"features"`
+	HourlyRate  *int              `json:"hourly_rate"`
+	Currency    string            `json:"currency"`
+	MinHours    int               `json:"min_hours"`
+}
+
+func (h *PlaceHandler) Create(w http.ResponseWriter, r *http.Request) {
+	userID := middleware.GetUserID(r.Context())
+	role := middleware.GetUserRole(r.Context())
+
+	var req createPlaceRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	if req.Title == "" {
+		writeError(w, http.StatusBadRequest, "title is required")
+		return
+	}
+
+	if req.Type == "" {
+		writeError(w, http.StatusBadRequest, "type is required (place or studio)")
+		return
+	}
+
+	if req.Type == "studio" && role != "landlord" && role != "moderator" && role != "superadmin" {
+		writeError(w, http.StatusForbidden, "only landlords can create studios")
+		return
+	}
+	if req.Type == "place" && role != "customer" && role != "moderator" && role != "superadmin" {
+		writeError(w, http.StatusForbidden, "only customers can create places")
+		return
+	}
+
+	tags := make([]models.Tag, len(req.Tags))
+	for i, t := range req.Tags {
+		tags[i] = models.Tag{ID: t}
+	}
+	features := make([]models.Feature, len(req.Features))
+	for i, f := range req.Features {
+		features[i] = models.Feature{ID: f}
+	}
+
+	place, err := h.placeSvc.Create(r.Context(), services.CreatePlaceInput{
+		OwnerID:     userID,
+		Type:        req.Type,
+		Title:       req.Title,
+		Description: req.Description,
+		Address:     req.Address,
+		Lat:         req.Lat,
+		Lng:         req.Lng,
+		CoverImage:  req.CoverImage,
+		AccessInfo:  req.AccessInfo,
+		Tags:        tags,
+		Features:    features,
+		HourlyRate:  req.HourlyRate,
+		Currency:    req.Currency,
+		MinHours:    req.MinHours,
+	})
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusCreated, place)
+}
+
+func (h *PlaceHandler) Update(w http.ResponseWriter, r *http.Request) {
+	id := chi.URLParam(r, "id")
+	userID := middleware.GetUserID(r.Context())
+	role := middleware.GetUserRole(r.Context())
+
+	var req createPlaceRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	isModerator := role == "moderator" || role == "superadmin"
+
+	input := services.UpdatePlaceInput{
+		ID:      id,
+		OwnerID: userID,
+	}
+
+	if req.Title != "" { input.Title = &req.Title }
+	input.Description = req.Description
+	input.Address = req.Address
+	if req.Lat != 0 { input.Lat = &req.Lat }
+	if req.Lng != 0 { input.Lng = &req.Lng }
+	input.AccessInfo = req.AccessInfo
+	input.HourlyRate = req.HourlyRate
+	input.Currency = &req.Currency
+	input.MinHours = &req.MinHours
+
+	place, err := h.placeSvc.Update(r.Context(), input, isModerator)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	if place == nil {
+		writeError(w, http.StatusNotFound, "place not found")
+		return
+	}
+
+	writeJSON(w, http.StatusOK, place)
+}
+
+func (h *PlaceHandler) Moderate(w http.ResponseWriter, r *http.Request) {
+	id := chi.URLParam(r, "id")
+	userID := middleware.GetUserID(r.Context())
+
+	var req struct {
+		Action  string `json:"action"`
+		Comment string `json:"comment"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	if err := h.placeSvc.Moderate(r.Context(), id, req.Action, req.Comment, userID); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+func (h *PlaceHandler) Delete(w http.ResponseWriter, r *http.Request) {
+	id := chi.URLParam(r, "id")
+
+	if err := h.placeSvc.Delete(r.Context(), id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	w.WriteHeader(http.StatusNoContent)
+}

+ 77 - 0
backend/internal/handlers/reviews.go

@@ -0,0 +1,77 @@
+package handlers
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/photoplaces/backend/internal/middleware"
+	"github.com/photoplaces/backend/internal/models"
+	"github.com/photoplaces/backend/internal/repository"
+)
+
+type ReviewHandler struct {
+	reviewRepo *repository.ReviewRepo
+}
+
+func NewReviewHandler(reviewRepo *repository.ReviewRepo) *ReviewHandler {
+	return &ReviewHandler{reviewRepo: reviewRepo}
+}
+
+func (h *ReviewHandler) List(w http.ResponseWriter, r *http.Request) {
+	targetType := r.URL.Query().Get("target_type")
+	targetID := r.URL.Query().Get("target_id")
+
+	if targetType == "" || targetID == "" {
+		writeError(w, http.StatusBadRequest, "target_type and target_id are required")
+		return
+	}
+
+	reviews, err := h.reviewRepo.List(r.Context(), targetType, targetID)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	if reviews == nil {
+		reviews = []*models.Review{}
+	}
+
+	writeJSON(w, http.StatusOK, map[string]interface{}{"data": reviews})
+}
+
+type createReviewRequest struct {
+	TargetType string `json:"target_type"`
+	TargetID   string `json:"target_id"`
+	Rating     int    `json:"rating"`
+	Text       string `json:"text"`
+}
+
+func (h *ReviewHandler) Create(w http.ResponseWriter, r *http.Request) {
+	userID := middleware.GetUserID(r.Context())
+
+	var req createReviewRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	if req.TargetType == "" || req.TargetID == "" || req.Rating < 1 || req.Rating > 5 {
+		writeError(w, http.StatusBadRequest, "target_type, target_id and rating (1-5) are required")
+		return
+	}
+
+	review := &models.Review{
+		UserID:     userID,
+		TargetType: req.TargetType,
+		TargetID:   req.TargetID,
+		Rating:     req.Rating,
+		Text:       strPtr(req.Text),
+	}
+
+	if err := h.reviewRepo.Create(r.Context(), review); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusCreated, review)
+}

+ 172 - 0
backend/internal/handlers/services.go

@@ -0,0 +1,172 @@
+package handlers
+
+import (
+	"encoding/json"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/photoplaces/backend/internal/middleware"
+	"github.com/photoplaces/backend/internal/models"
+	"github.com/photoplaces/backend/internal/repository"
+)
+
+type ServiceHandler struct {
+	serviceRepo *repository.ServiceRepo
+	tagRepo     *repository.TagRepo
+}
+
+func NewServiceHandler(serviceRepo *repository.ServiceRepo, tagRepo *repository.TagRepo) *ServiceHandler {
+	return &ServiceHandler{serviceRepo: serviceRepo, tagRepo: tagRepo}
+}
+
+func (h *ServiceHandler) List(w http.ResponseWriter, r *http.Request) {
+	q := r.URL.Query()
+	filter := models.ServiceFilter{Status: "published"}
+
+	if tags := q["tags"]; len(tags) > 0 {
+		filter.Tags = tags
+	}
+	if mr := q.Get("min_rating"); mr != "" {
+		if v, err := strconv.ParseFloat(mr, 64); err == nil {
+			filter.MinRating = v
+		}
+	}
+	if pmin := q.Get("price_min"); pmin != "" {
+		if v, err := strconv.Atoi(pmin); err == nil {
+			filter.PriceMin = &v
+		}
+	}
+	if pmax := q.Get("price_max"); pmax != "" {
+		if v, err := strconv.Atoi(pmax); err == nil {
+			filter.PriceMax = &v
+		}
+	}
+	if limit := q.Get("limit"); limit != "" {
+		if v, err := strconv.Atoi(limit); err == nil {
+			filter.Limit_ = v
+		}
+	}
+
+	services, err := h.serviceRepo.List(r.Context(), filter)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	if services == nil {
+		services = []*models.Service{}
+	}
+
+	writeJSON(w, http.StatusOK, map[string]interface{}{"data": services})
+}
+
+func (h *ServiceHandler) GetByID(w http.ResponseWriter, r *http.Request) {
+	id := chi.URLParam(r, "id")
+
+	svc, err := h.serviceRepo.GetByID(r.Context(), id)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	if svc == nil {
+		writeError(w, http.StatusNotFound, "service not found")
+		return
+	}
+
+	tags, err := h.serviceRepo.GetTags(r.Context(), id)
+	if err == nil {
+		svc.Tags = tags
+	}
+
+	writeJSON(w, http.StatusOK, svc)
+}
+
+type createServiceRequest struct {
+	Title           string   `json:"title"`
+	Description     *string  `json:"description"`
+	Price           int      `json:"price"`
+	Currency        string   `json:"currency"`
+	DurationMinutes *int     `json:"duration_minutes"`
+	Tags            []string `json:"tags"`
+}
+
+func (h *ServiceHandler) Create(w http.ResponseWriter, r *http.Request) {
+	userID := middleware.GetUserID(r.Context())
+
+	var req createServiceRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	if req.Title == "" || req.Price <= 0 {
+		writeError(w, http.StatusBadRequest, "title and price are required")
+		return
+	}
+
+	tags := make([]models.Tag, len(req.Tags))
+	for i, t := range req.Tags {
+		tags[i] = models.Tag{ID: t}
+	}
+
+	svc := &models.Service{
+		ExecutorID:      userID,
+		Title:           req.Title,
+		Description:     req.Description,
+		Price:           req.Price,
+		Currency:        req.Currency,
+		DurationMinutes: req.DurationMinutes,
+		Status:          "published",
+		Tags:            tags,
+	}
+
+	if err := h.serviceRepo.Create(r.Context(), svc); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusCreated, svc)
+}
+
+func (h *ServiceHandler) Update(w http.ResponseWriter, r *http.Request) {
+	id := chi.URLParam(r, "id")
+
+	var req createServiceRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	svc, err := h.serviceRepo.GetByID(r.Context(), id)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	if svc == nil {
+		writeError(w, http.StatusNotFound, "service not found")
+		return
+	}
+
+	svc.Title = req.Title
+	svc.Description = req.Description
+	svc.Price = req.Price
+	svc.Currency = req.Currency
+	svc.DurationMinutes = req.DurationMinutes
+
+	if err := h.serviceRepo.Update(r.Context(), svc); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, svc)
+}
+
+func (h *ServiceHandler) Delete(w http.ResponseWriter, r *http.Request) {
+	id := chi.URLParam(r, "id")
+	if err := h.serviceRepo.SoftDelete(r.Context(), id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}

+ 96 - 0
backend/internal/handlers/tags.go

@@ -0,0 +1,96 @@
+package handlers
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/photoplaces/backend/internal/models"
+	"github.com/photoplaces/backend/internal/repository"
+)
+
+type TagHandler struct {
+	tagRepo     *repository.TagRepo
+	featureRepo *repository.FeatureRepo
+}
+
+func NewTagHandler(tagRepo *repository.TagRepo, featureRepo *repository.FeatureRepo) *TagHandler {
+	return &TagHandler{tagRepo: tagRepo, featureRepo: featureRepo}
+}
+
+func (h *TagHandler) ListTags(w http.ResponseWriter, r *http.Request) {
+	tags, err := h.tagRepo.GetAll(r.Context())
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, tags)
+}
+
+func (h *TagHandler) ListFeatures(w http.ResponseWriter, r *http.Request) {
+	features, err := h.featureRepo.GetAll(r.Context())
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, features)
+}
+
+func (h *TagHandler) CreateTag(w http.ResponseWriter, r *http.Request) {
+	var req struct {
+		ID   string `json:"id"`
+		Name string `json:"name"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+	tag := &models.Tag{ID: req.ID, Name: req.Name}
+	if err := h.tagRepo.Create(r.Context(), tag); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusCreated, tag)
+}
+
+func (h *TagHandler) DeleteTag(w http.ResponseWriter, r *http.Request) {
+	id := r.URL.Query().Get("id")
+	if id == "" {
+		writeError(w, http.StatusBadRequest, "id is required")
+		return
+	}
+	if err := h.tagRepo.Delete(r.Context(), id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *TagHandler) CreateFeature(w http.ResponseWriter, r *http.Request) {
+	var req struct {
+		ID   string `json:"id"`
+		Name string `json:"name"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+	feature := &models.Feature{ID: req.ID, Name: req.Name}
+	if err := h.featureRepo.Create(r.Context(), feature); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusCreated, feature)
+}
+
+func (h *TagHandler) DeleteFeature(w http.ResponseWriter, r *http.Request) {
+	id := r.URL.Query().Get("id")
+	if id == "" {
+		writeError(w, http.StatusBadRequest, "id is required")
+		return
+	}
+	if err := h.featureRepo.Delete(r.Context(), id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}

+ 97 - 0
backend/internal/handlers/upload.go

@@ -0,0 +1,97 @@
+package handlers
+
+import (
+	"encoding/json"
+	"net/http"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/minio/minio-go/v7"
+	"github.com/minio/minio-go/v7/pkg/credentials"
+)
+
+type UploadHandler struct {
+	minioClient *minio.Client
+	bucket      string
+	endpoint    string
+}
+
+func NewUploadHandler(endpoint, accessKey, secretKey, bucket string, useSSL bool) (*UploadHandler, error) {
+	client, err := minio.New(endpoint, &minio.Options{
+		Creds:  credentials.NewStaticV4(accessKey, secretKey, ""),
+		Secure: useSSL,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &UploadHandler{
+		minioClient: client,
+		bucket:      bucket,
+		endpoint:    endpoint,
+	}, nil
+}
+
+type presignedURLRequest struct {
+	ContentType string `json:"content_type"`
+	MaxSizeMB   int    `json:"max_size_mb"`
+}
+
+type presignedURLResponse struct {
+	UploadURL string            `json:"upload_url"`
+	FileURL   string            `json:"file_url"`
+	Fields    map[string]string `json:"fields"`
+}
+
+func (h *UploadHandler) PresignedURL(w http.ResponseWriter, r *http.Request) {
+	var req presignedURLRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	if req.MaxSizeMB <= 0 || req.MaxSizeMB > 50 {
+		req.MaxSizeMB = 10
+	}
+
+	validContentTypes := map[string]bool{
+		"image/jpeg": true, "image/png": true, "image/webp": true, "image/heic": true,
+	}
+	if !validContentTypes[req.ContentType] {
+		writeError(w, http.StatusBadRequest, "unsupported content type")
+		return
+	}
+
+	fileID := uuid.New().String()
+	ext := ".jpg"
+	switch req.ContentType {
+	case "image/png":
+		ext = ".png"
+	case "image/webp":
+		ext = ".webp"
+	case "image/heic":
+		ext = ".heic"
+	}
+	objectName := "uploads/" + fileID + ext
+
+	policy := minio.NewPostPolicy()
+	policy.SetBucket(h.bucket)
+	policy.SetKey(objectName)
+	policy.SetExpires(time.Now().Add(15 * time.Minute))
+	policy.SetContentType(req.ContentType)
+	policy.SetContentLengthRange(1, int64(req.MaxSizeMB)*1024*1024)
+
+	url, formData, err := h.minioClient.PresignedPostPolicy(r.Context(), policy)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, "failed to generate upload URL")
+		return
+	}
+
+	fileURL := "http://" + h.endpoint + "/" + h.bucket + "/" + objectName
+
+	writeJSON(w, http.StatusOK, presignedURLResponse{
+		UploadURL: url.String(),
+		FileURL:   fileURL,
+		Fields:    formData,
+	})
+}

+ 158 - 0
backend/internal/handlers/users.go

@@ -0,0 +1,158 @@
+package handlers
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/photoplaces/backend/internal/middleware"
+	"github.com/photoplaces/backend/internal/models"
+	"github.com/photoplaces/backend/internal/repository"
+)
+
+type UserHandler struct {
+	userRepo *repository.UserRepo
+}
+
+func NewUserHandler(userRepo *repository.UserRepo) *UserHandler {
+	return &UserHandler{userRepo: userRepo}
+}
+
+func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
+	userID := chi.URLParam(r, "id")
+	if userID == "" {
+		userID = middleware.GetUserID(r.Context())
+	}
+
+	user, err := h.userRepo.GetByID(r.Context(), userID)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	if user == nil {
+		writeError(w, http.StatusNotFound, "user not found")
+		return
+	}
+
+	writeJSON(w, http.StatusOK, user)
+}
+
+func (h *UserHandler) UpdateMe(w http.ResponseWriter, r *http.Request) {
+	userID := middleware.GetUserID(r.Context())
+
+	user, err := h.userRepo.GetByID(r.Context(), userID)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	var req struct {
+		Name      *string `json:"name"`
+		Phone     *string `json:"phone"`
+		Bio       *string `json:"bio"`
+		AvatarURL *string `json:"avatar_url"`
+		Country   *string `json:"country"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	if req.Name != nil { user.Name = req.Name }
+	if req.Phone != nil { user.Phone = req.Phone }
+	if req.Bio != nil { user.Bio = req.Bio }
+	if req.AvatarURL != nil { user.AvatarURL = req.AvatarURL }
+	if req.Country != nil { user.Country = req.Country }
+
+	if err := h.userRepo.Update(r.Context(), user); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, user)
+}
+
+// Admin endpoints
+
+type adminUpdateUserRequest struct {
+	Role   *string `json:"role"`
+	Status *string `json:"status"`
+}
+
+func (h *UserHandler) AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
+	id := chi.URLParam(r, "id")
+
+	var req adminUpdateUserRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	if req.Role != nil {
+		validRoles := map[string]bool{"superadmin": true, "moderator": true, "landlord": true, "executor": true, "customer": true}
+		if !validRoles[*req.Role] {
+			writeError(w, http.StatusBadRequest, "invalid role")
+			return
+		}
+		if err := h.userRepo.UpdateRole(r.Context(), id, *req.Role); err != nil {
+			writeError(w, http.StatusInternalServerError, err.Error())
+			return
+		}
+	}
+
+	if req.Status != nil {
+		validStatuses := map[string]bool{"active": true, "banned": true, "pending_verification": true}
+		if !validStatuses[*req.Status] {
+			writeError(w, http.StatusBadRequest, "invalid status")
+			return
+		}
+		if err := h.userRepo.UpdateStatus(r.Context(), id, *req.Status); err != nil {
+			writeError(w, http.StatusInternalServerError, err.Error())
+			return
+		}
+	}
+
+	user, err := h.userRepo.GetByID(r.Context(), id)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, user)
+}
+
+func (h *UserHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) {
+	filter := models.UserFilter{
+		Role:   r.URL.Query().Get("role"),
+		Status: r.URL.Query().Get("status"),
+	}
+	if limit := r.URL.Query().Get("limit"); limit != "" {
+		if v, err := parseInt(limit); err == nil {
+			filter.Limit_ = v
+		}
+	}
+
+	users, err := h.userRepo.List(r.Context(), filter)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	if users == nil {
+		users = []*models.User{}
+	}
+
+	writeJSON(w, http.StatusOK, map[string]interface{}{"data": users})
+}
+
+func parseInt(s string) (int, error) {
+	var n int
+	for _, c := range s {
+		if c < '0' || c > '9' {
+			return 0, fmt.Errorf("not a number")
+		}
+		n = n*10 + int(c-'0')
+	}
+	return n, nil
+}

+ 68 - 0
backend/internal/handlers/websocket.go

@@ -0,0 +1,68 @@
+package handlers
+
+import (
+	"log"
+	"net/http"
+	"sync"
+
+	"github.com/gorilla/websocket"
+)
+
+var upgrader = websocket.Upgrader{
+	CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+type VisitorDot struct {
+	UserID string  `json:"user_id,omitempty"`
+	Lat    float64 `json:"lat"`
+	Lng    float64 `json:"lng"`
+}
+
+type WSHub struct {
+	mu      sync.RWMutex
+	clients map[*websocket.Conn]VisitorDot
+}
+
+func NewWSHub() *WSHub {
+	return &WSHub{clients: make(map[*websocket.Conn]VisitorDot)}
+}
+
+func (h *WSHub) HandleWS(w http.ResponseWriter, r *http.Request) {
+	conn, err := upgrader.Upgrade(w, r, nil)
+	if err != nil {
+		log.Printf("ws upgrade: %v", err)
+		return
+	}
+
+	dot := VisitorDot{}
+
+	h.mu.Lock()
+	h.clients[conn] = dot
+	h.mu.Unlock()
+
+	defer func() {
+		h.mu.Lock()
+		delete(h.clients, conn)
+		h.mu.Unlock()
+		conn.Close()
+	}()
+
+	for {
+		var msg VisitorDot
+		if err := conn.ReadJSON(&msg); err != nil {
+			break
+		}
+
+		h.mu.Lock()
+		h.clients[conn] = msg
+		visitors := make([]VisitorDot, 0, len(h.clients))
+		for _, v := range h.clients {
+			visitors = append(visitors, v)
+		}
+		h.mu.Unlock()
+
+		if err := conn.WriteJSON(map[string]interface{}{"visitors": visitors}); err != nil {
+			break
+		}
+	}
+}

+ 76 - 0
backend/internal/middleware/auth.go

@@ -0,0 +1,76 @@
+package middleware
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"strings"
+
+	"github.com/photoplaces/backend/internal/services"
+)
+
+type contextKey string
+
+const (
+	UserIDKey contextKey = "user_id"
+	UserRoleKey contextKey = "user_role"
+)
+
+func AuthMiddleware(authSvc *services.AuthService) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			authHeader := r.Header.Get("Authorization")
+			if authHeader == "" {
+				writeAuthError(w, "missing authorization header")
+				return
+			}
+
+			parts := strings.SplitN(authHeader, " ", 2)
+			if len(parts) != 2 || parts[0] != "Bearer" {
+				writeAuthError(w, "invalid authorization format")
+				return
+			}
+
+			claims, err := authSvc.ValidateAccessToken(parts[1])
+			if err != nil {
+				writeAuthError(w, "invalid or expired token")
+				return
+			}
+
+			ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
+			ctx = context.WithValue(ctx, UserRoleKey, claims.Role)
+			next.ServeHTTP(w, r.WithContext(ctx))
+		})
+	}
+}
+
+func RoleMiddleware(roles ...string) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			role := r.Context().Value(UserRoleKey).(string)
+			for _, allowed := range roles {
+				if role == allowed {
+					next.ServeHTTP(w, r)
+					return
+				}
+			}
+			writeAuthError(w, "insufficient permissions")
+		})
+	}
+}
+
+func GetUserID(ctx context.Context) string {
+	v, _ := ctx.Value(UserIDKey).(string)
+	return v
+}
+
+func GetUserRole(ctx context.Context) string {
+	v, _ := ctx.Value(UserRoleKey).(string)
+	return v
+}
+
+func writeAuthError(w http.ResponseWriter, msg string) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusUnauthorized)
+	json.NewEncoder(w).Encode(map[string]string{"error": msg})
+}

+ 69 - 0
backend/internal/middleware/ratelimit.go

@@ -0,0 +1,69 @@
+package middleware
+
+import (
+	"net/http"
+	"sync"
+	"time"
+
+	"golang.org/x/time/rate"
+)
+
+type RateLimiter struct {
+	visitors map[string]*rate.Limiter
+	mu       sync.Mutex
+	rate     rate.Limit
+	burst    int
+	lastSeen map[string]time.Time
+}
+
+func NewRateLimiter(r rate.Limit, burst int) *RateLimiter {
+	rl := &RateLimiter{
+		visitors: make(map[string]*rate.Limiter),
+		rate:     r,
+		burst:    burst,
+		lastSeen: make(map[string]time.Time),
+	}
+	go rl.cleanup()
+	return rl
+}
+
+func (rl *RateLimiter) cleanup() {
+	for {
+		time.Sleep(10 * time.Minute)
+		rl.mu.Lock()
+		for ip, last := range rl.lastSeen {
+			if time.Since(last) > 30*time.Minute {
+				delete(rl.visitors, ip)
+				delete(rl.lastSeen, ip)
+			}
+		}
+		rl.mu.Unlock()
+	}
+}
+
+func (rl *RateLimiter) Middleware() func(http.Handler) http.Handler {
+	rl := &RateLimiter{
+		visitors: make(map[string]*rate.Limiter),
+		rate:     60,
+		burst:    60,
+	}
+
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			ip := r.RemoteAddr
+			rl.mu.Lock()
+			limiter, ok := rl.visitors[ip]
+			if !ok {
+				limiter = rate.NewLimiter(rl.rate, rl.burst)
+				rl.visitors[ip] = limiter
+			}
+			rl.mu.Unlock()
+
+			if !limiter.Allow() {
+				http.Error(w, `{"error":"rate limit exceeded"}`, http.StatusTooManyRequests)
+				return
+			}
+			next.ServeHTTP(w, r)
+		})
+	}
+}

+ 17 - 0
backend/internal/models/booking.go

@@ -0,0 +1,17 @@
+package models
+
+import "time"
+
+type Booking struct {
+	ID         string    `json:"id"`
+	PlaceID    string    `json:"place_id"`
+	UserID     string    `json:"user_id"`
+	StartTime  time.Time `json:"start_time"`
+	EndTime    time.Time `json:"end_time"`
+	Status     string    `json:"status"`
+	TotalPrice *int      `json:"total_price,omitempty"`
+	Currency   string    `json:"currency"`
+	Comment    *string   `json:"comment"`
+	CreatedAt  time.Time `json:"created_at"`
+	UpdatedAt  time.Time `json:"updated_at"`
+}

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

@@ -0,0 +1,70 @@
+package models
+
+import "time"
+
+type Place struct {
+	ID        string     `json:"id"`
+	Type      string     `json:"type"`
+	OwnerID   string     `json:"owner_id"`
+	Title     string     `json:"title"`
+	Description *string  `json:"description"`
+	Address   *string    `json:"address"`
+	Lat       float64    `json:"lat"`
+	Lng       float64    `json:"lng"`
+	CoverImage *string   `json:"cover_image"`
+	AccessInfo *string   `json:"access_info"`
+	Status    string     `json:"status"`
+	ModerationComment *string `json:"moderation_comment,omitempty"`
+	Rating    float64    `json:"rating"`
+	ReviewsCount int     `json:"reviews_count"`
+	HourlyRate *int      `json:"hourly_rate,omitempty"`
+	Currency  string     `json:"currency"`
+	MinHours  int        `json:"min_hours"`
+	BookingURL *string   `json:"booking_url,omitempty"`
+	Tags      []Tag      `json:"tags"`
+	Features  []Feature  `json:"features"`
+	Images    []PlaceImage `json:"images,omitempty"`
+	Owner     *User      `json:"owner,omitempty"`
+	CreatedAt time.Time  `json:"created_at"`
+	UpdatedAt time.Time  `json:"updated_at"`
+	PublishedAt *time.Time `json:"published_at,omitempty"`
+	DeletedAt *time.Time `json:"-"`
+}
+
+type PlaceImage struct {
+	ID        string `json:"id"`
+	PlaceID   string `json:"place_id"`
+	URL       string `json:"url"`
+	Alt       string `json:"alt"`
+	SortOrder int    `json:"sort_order"`
+	IsCover   bool   `json:"is_cover"`
+}
+
+type PlaceFilter struct {
+	Type     string
+	TagIDs   []string
+	FeatureIDs []string
+	MinRating float64
+	PriceMin *int
+	PriceMax *int
+	Bounds   *Bounds
+	Limit_   int
+	Sort     string
+	UserLat  *float64
+	UserLng  *float64
+	Status   string
+}
+
+type Bounds struct {
+	SWLat float64
+	SWLng float64
+	NELat float64
+	NELng float64
+}
+
+func (f PlaceFilter) Limit() int {
+	if f.Limit_ <= 0 || f.Limit_ > 100 {
+		return 20
+	}
+	return f.Limit_
+}

+ 15 - 0
backend/internal/models/review.go

@@ -0,0 +1,15 @@
+package models
+
+import "time"
+
+type Review struct {
+	ID         string    `json:"id"`
+	UserID     string    `json:"user_id"`
+	TargetType string    `json:"target_type"`
+	TargetID   string    `json:"target_id"`
+	Rating     int       `json:"rating"`
+	Text       *string   `json:"text"`
+	CreatedAt  time.Time `json:"created_at"`
+	UpdatedAt  time.Time `json:"updated_at"`
+	User       *User     `json:"user,omitempty"`
+}

+ 45 - 0
backend/internal/models/service.go

@@ -0,0 +1,45 @@
+package models
+
+import "time"
+
+type Service struct {
+	ID              string         `json:"id"`
+	ExecutorID      string         `json:"executor_id"`
+	Title           string         `json:"title"`
+	Description     *string        `json:"description"`
+	Price           int            `json:"price"`
+	Currency        string         `json:"currency"`
+	DurationMinutes *int           `json:"duration_minutes"`
+	Status          string         `json:"status"`
+	Rating          float64        `json:"rating"`
+	ReviewsCount    int            `json:"reviews_count"`
+	Tags            []Tag          `json:"tags"`
+	PortfolioImages []ServiceImage `json:"portfolio_images,omitempty"`
+	CreatedAt       time.Time      `json:"created_at"`
+	UpdatedAt       time.Time      `json:"updated_at"`
+	DeletedAt       *time.Time     `json:"-"`
+}
+
+type ServiceImage struct {
+	ID        string `json:"id"`
+	ServiceID string `json:"service_id"`
+	URL       string `json:"url"`
+	Alt       string `json:"alt"`
+	SortOrder int    `json:"sort_order"`
+}
+
+type ServiceFilter struct {
+	Tags     []string
+	MinRating float64
+	PriceMin *int
+	PriceMax *int
+	Status   string
+	Limit_   int
+}
+
+func (f ServiceFilter) Limit() int {
+	if f.Limit_ <= 0 || f.Limit_ > 100 {
+		return 20
+	}
+	return f.Limit_
+}

+ 16 - 0
backend/internal/models/tag.go

@@ -0,0 +1,16 @@
+package models
+
+type Tag struct {
+	ID        string `json:"id"`
+	Name      string `json:"name"`
+	Category  *string `json:"category,omitempty"`
+	SortOrder int    `json:"sort_order"`
+}
+
+type Feature struct {
+	ID        string `json:"id"`
+	Name      string `json:"name"`
+	Category  *string `json:"category,omitempty"`
+	Icon      *string `json:"icon,omitempty"`
+	SortOrder int    `json:"sort_order"`
+}

+ 33 - 0
backend/internal/models/user.go

@@ -0,0 +1,33 @@
+package models
+
+import "time"
+
+type User struct {
+	ID           string     `json:"id"`
+	Email        string     `json:"email"`
+	PasswordHash string     `json:"-"`
+	Role         string     `json:"role"`
+	Status       string     `json:"status"`
+	Name         *string    `json:"name"`
+	AvatarURL    *string    `json:"avatar_url"`
+	Phone        *string    `json:"phone"`
+	Bio          *string    `json:"bio"`
+	Country      *string    `json:"country"`
+	CreatedAt    time.Time  `json:"created_at"`
+	UpdatedAt    time.Time  `json:"updated_at"`
+	DeletedAt    *time.Time `json:"-"`
+}
+
+type UserFilter struct {
+	Role   string
+	Status string
+	Limit_ int
+	Cursor string
+}
+
+func (f UserFilter) Limit() int {
+	if f.Limit_ <= 0 || f.Limit_ > 100 {
+		return 20
+	}
+	return f.Limit_
+}

+ 133 - 0
backend/internal/repository/bookings.go

@@ -0,0 +1,133 @@
+package repository
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgxpool"
+	"github.com/photoplaces/backend/internal/models"
+)
+
+type BookingRepo struct {
+	pool *pgxpool.Pool
+}
+
+func NewBookingRepo(pool *pgxpool.Pool) *BookingRepo {
+	return &BookingRepo{pool: pool}
+}
+
+func (r *BookingRepo) Create(ctx context.Context, b *models.Booking) error {
+	return r.pool.QueryRow(ctx,
+		`INSERT INTO bookings (place_id, user_id, start_time, end_time, status, total_price, currency, comment)
+		 VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7)
+		 RETURNING id, created_at, updated_at`,
+		b.PlaceID, b.UserID, b.StartTime, b.EndTime, b.TotalPrice, b.Currency, b.Comment,
+	).Scan(&b.ID, &b.CreatedAt, &b.UpdatedAt)
+}
+
+func (r *BookingRepo) IsTimeSlotAvailable(ctx context.Context, placeID string, start, end time.Time) (bool, error) {
+	var count int
+	err := r.pool.QueryRow(ctx,
+		`SELECT COUNT(*) FROM bookings
+		 WHERE place_id = $1 AND status != 'cancelled'
+		 AND tsrange(start_time, end_time) && tsrange($2, $3)`,
+		placeID, start, end).Scan(&count)
+	if err != nil {
+		return false, err
+	}
+	return count == 0, nil
+}
+
+func (r *BookingRepo) GetByID(ctx context.Context, id string) (*models.Booking, error) {
+	row := r.pool.QueryRow(ctx,
+		`SELECT id, place_id, user_id, start_time, end_time, status, total_price, currency, comment,
+		        created_at, updated_at
+		 FROM bookings WHERE id = $1`, id)
+
+	var b models.Booking
+	err := row.Scan(&b.ID, &b.PlaceID, &b.UserID, &b.StartTime, &b.EndTime,
+		&b.Status, &b.TotalPrice, &b.Currency, &b.Comment, &b.CreatedAt, &b.UpdatedAt)
+	if err != nil {
+		if err == pgx.ErrNoRows {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &b, nil
+}
+
+func (r *BookingRepo) ListByUser(ctx context.Context, userID string) ([]*models.Booking, error) {
+	rows, err := r.pool.Query(ctx,
+		`SELECT id, place_id, user_id, start_time, end_time, status, total_price, currency, comment,
+		        created_at, updated_at
+		 FROM bookings WHERE user_id = $1 ORDER BY start_time DESC`, userID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	return scanBookings(rows)
+}
+
+func (r *BookingRepo) ListByPlace(ctx context.Context, placeID string) ([]*models.Booking, error) {
+	rows, err := r.pool.Query(ctx,
+		`SELECT id, place_id, user_id, start_time, end_time, status, total_price, currency, comment,
+		        created_at, updated_at
+		 FROM bookings WHERE place_id = $1 ORDER BY start_time DESC`, placeID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	return scanBookings(rows)
+}
+
+func (r *BookingRepo) UpdateStatus(ctx context.Context, id, status string) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE bookings SET status=$1, updated_at=now() WHERE id=$2`, status, id)
+	return err
+}
+
+func scanBookings(rows pgx.Rows) ([]*models.Booking, error) {
+	var bookings []*models.Booking
+	for rows.Next() {
+		var b models.Booking
+		if err := rows.Scan(&b.ID, &b.PlaceID, &b.UserID, &b.StartTime, &b.EndTime,
+			&b.Status, &b.TotalPrice, &b.Currency, &b.Comment, &b.CreatedAt, &b.UpdatedAt); err != nil {
+			return nil, err
+		}
+		bookings = append(bookings, &b)
+	}
+	return bookings, nil
+}
+
+func (r *BookingRepo) CancelOverlapping(ctx context.Context, placeID string, start, end time.Time) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE bookings SET status='cancelled', updated_at=now()
+		 WHERE place_id=$1 AND status='pending'
+		 AND tsrange(start_time, end_time) && tsrange($2, $3)`,
+		placeID, start, end)
+	return err
+}
+
+func (r *BookingRepo) CalculateTotalPrice(ctx context.Context, placeID string, start, end time.Time) (*int, error) {
+	var hourlyRate *int
+	err := r.pool.QueryRow(ctx,
+		`SELECT hourly_rate FROM places WHERE id=$1 AND deleted_at IS NULL AND type='studio'`,
+		placeID).Scan(&hourlyRate)
+	if err != nil {
+		return nil, fmt.Errorf("get studio rate: %w", err)
+	}
+	if hourlyRate == nil {
+		return nil, nil
+	}
+
+	hours := int(end.Sub(start).Hours())
+	if hours < 1 {
+		hours = 1
+	}
+	total := *hourlyRate * hours
+	return &total, nil
+}

+ 29 - 0
backend/internal/repository/db.go

@@ -0,0 +1,29 @@
+package repository
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/jackc/pgx/v5/pgxpool"
+)
+
+func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
+	cfg, err := pgxpool.ParseConfig(databaseURL)
+	if err != nil {
+		return nil, fmt.Errorf("parse config: %w", err)
+	}
+
+	cfg.MaxConns = 20
+	cfg.MinConns = 2
+
+	pool, err := pgxpool.NewWithConfig(ctx, cfg)
+	if err != nil {
+		return nil, fmt.Errorf("create pool: %w", err)
+	}
+
+	if err := pool.Ping(ctx); err != nil {
+		return nil, fmt.Errorf("ping db: %w", err)
+	}
+
+	return pool, nil
+}

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

@@ -0,0 +1,258 @@
+package repository
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgxpool"
+	"github.com/photoplaces/backend/internal/models"
+)
+
+type PlaceRepo struct {
+	pool *pgxpool.Pool
+}
+
+func NewPlaceRepo(pool *pgxpool.Pool) *PlaceRepo {
+	return &PlaceRepo{pool: pool}
+}
+
+func (r *PlaceRepo) Create(ctx context.Context, p *models.Place) error {
+	err := r.pool.QueryRow(ctx,
+		`INSERT INTO places (type, owner_id, title, description, address, coordinates,
+		                     cover_image, access_info, status, hourly_rate, currency, min_hours, booking_url)
+		 VALUES ($1, $2, $3, $4, $5, ST_SetSRID(ST_MakePoint($6, $7), 4326),
+		         $8, $9, $10, $11, $12, $13, $14)
+		 RETURNING id, created_at, updated_at`,
+		p.Type, p.OwnerID, p.Title, p.Description, p.Address,
+		p.Lng, p.Lat,
+		p.CoverImage, p.AccessInfo, p.Status,
+		p.HourlyRate, p.Currency, p.MinHours, p.BookingURL,
+	).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
+	if err != nil {
+		return fmt.Errorf("create place: %w", err)
+	}
+
+	if len(p.Tags) > 0 {
+		if err := r.SetTags(ctx, p.ID, p.Tags); err != nil {
+			return err
+		}
+	}
+	if len(p.Features) > 0 {
+		if err := r.SetFeatures(ctx, p.ID, p.Features); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (r *PlaceRepo) GetByID(ctx context.Context, id string) (*models.Place, error) {
+	row := r.pool.QueryRow(ctx,
+		`SELECT id, type, owner_id, title, description, address,
+		        ST_Y(coordinates::geometry), ST_X(coordinates::geometry),
+		        cover_image, access_info, status, moderation_comment,
+		        rating, reviews_count, hourly_rate, currency, min_hours, booking_url,
+		        created_at, updated_at, published_at, deleted_at
+		 FROM places WHERE id = $1 AND deleted_at IS NULL`, id)
+	return scanPlace(row)
+}
+
+func (r *PlaceRepo) List(ctx context.Context, filter models.PlaceFilter) ([]*models.Place, error) {
+	q := `SELECT p.id, p.type, p.owner_id, p.title, p.description, p.address,
+	             ST_Y(p.coordinates::geometry), ST_X(p.coordinates::geometry),
+	             p.cover_image, p.access_info, p.status, p.moderation_comment,
+	             p.rating, p.reviews_count, p.hourly_rate, p.currency, p.min_hours, p.booking_url,
+	             p.created_at, p.updated_at, p.published_at, p.deleted_at
+	      FROM places p`
+	args := pgx.NamedArgs{}
+	joins := ""
+
+	if len(filter.TagIDs) > 0 {
+		joins += ` JOIN place_tags pt ON pt.place_id = p.id`
+	}
+
+	if len(filter.FeatureIDs) > 0 {
+		joins += ` JOIN place_features pf ON pf.place_id = p.id`
+	}
+
+	q += joins + ` WHERE p.deleted_at IS NULL`
+
+	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.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 }
+	if filter.Bounds != nil {
+		q += ` AND ST_Intersects(p.coordinates, ST_MakeEnvelope(@sw_lng, @sw_lat, @ne_lng, @ne_lat, 4326))`
+		args["sw_lng"] = filter.Bounds.SWLng; args["sw_lat"] = filter.Bounds.SWLat
+		args["ne_lng"] = filter.Bounds.NELng; args["ne_lat"] = filter.Bounds.NELat
+	}
+	if len(filter.TagIDs) > 0 { q += ` AND pt.tag_id = ANY(@tag_ids)`; args["tag_ids"] = filter.TagIDs }
+	if len(filter.FeatureIDs) > 0 { q += ` AND pf.feature_id = ANY(@feature_ids)`; args["feature_ids"] = filter.FeatureIDs }
+
+	if filter.Sort == "rating" {
+		q += ` ORDER BY p.rating DESC`
+	} else if filter.Sort == "distance" && filter.UserLat != nil && filter.UserLng != nil {
+		q += ` ORDER BY p.coordinates <-> ST_SetSRID(ST_MakePoint(@user_lng, @user_lat), 4326)`
+		args["user_lng"] = *filter.UserLng; args["user_lat"] = *filter.UserLat
+	} else {
+		q += ` ORDER BY p.created_at DESC`
+	}
+	q += ` LIMIT @lim`
+	args["lim"] = filter.Limit()
+
+	rows, err := r.pool.Query(ctx, q, args)
+	if err != nil {
+		return nil, fmt.Errorf("list places: %w", err)
+	}
+	defer rows.Close()
+
+	var places []*models.Place
+	for rows.Next() {
+		p, err := scanPlace(rows)
+		if err != nil {
+			return nil, err
+		}
+		places = append(places, p)
+	}
+	return places, nil
+}
+
+func (r *PlaceRepo) Update(ctx context.Context, p *models.Place) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE places SET title=$1, description=$2, address=$3,
+		 coordinates=ST_SetSRID(ST_MakePoint($4, $5), 4326),
+		 cover_image=$6, access_info=$7, status=$8, hourly_rate=$9, currency=$10, min_hours=$11,
+		 booking_url=$12, updated_at=now()
+		 WHERE id=$13 AND deleted_at IS NULL`,
+		p.Title, p.Description, p.Address,
+		p.Lng, p.Lat,
+		p.CoverImage, p.AccessInfo, p.Status,
+		p.HourlyRate, p.Currency, p.MinHours, p.BookingURL,
+		p.ID)
+	return err
+}
+
+func (r *PlaceRepo) UpdateStatus(ctx context.Context, id, status, comment string) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE places SET status=$1, moderation_comment=$2, updated_at=now(),
+		 published_at = CASE WHEN $1 = 'published' THEN now() ELSE published_at END
+		 WHERE id=$3 AND deleted_at IS NULL`,
+		status, comment, id)
+	return err
+}
+
+func (r *PlaceRepo) SoftDelete(ctx context.Context, id string) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE places SET deleted_at=now() WHERE id=$1`, id)
+	return err
+}
+
+func (r *PlaceRepo) SetTags(ctx context.Context, placeID string, tags []models.Tag) error {
+	tx, err := r.pool.Begin(ctx)
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback(ctx)
+
+	_, err = tx.Exec(ctx, `DELETE FROM place_tags WHERE place_id = $1`, placeID)
+	if err != nil {
+		return err
+	}
+
+	for _, tag := range tags {
+		_, err = tx.Exec(ctx,
+			`INSERT INTO place_tags (place_id, tag_id) VALUES ($1, $2)`,
+			placeID, tag.ID)
+		if err != nil {
+			return err
+		}
+	}
+
+	return tx.Commit(ctx)
+}
+
+func (r *PlaceRepo) SetFeatures(ctx context.Context, placeID string, features []models.Feature) error {
+	tx, err := r.pool.Begin(ctx)
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback(ctx)
+
+	_, err = tx.Exec(ctx, `DELETE FROM place_features WHERE place_id = $1`, placeID)
+	if err != nil {
+		return err
+	}
+
+	for _, f := range features {
+		_, err = tx.Exec(ctx,
+			`INSERT INTO place_features (place_id, feature_id) VALUES ($1, $2)`,
+			placeID, f.ID)
+		if err != nil {
+			return err
+		}
+	}
+
+	return tx.Commit(ctx)
+}
+
+func (r *PlaceRepo) GetTags(ctx context.Context, placeID string) ([]models.Tag, error) {
+	rows, err := r.pool.Query(ctx,
+		`SELECT t.id, t.name, t.category FROM tags t
+		 JOIN place_tags pt ON pt.tag_id = t.id
+		 WHERE pt.place_id = $1`, placeID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var tags []models.Tag
+	for rows.Next() {
+		var t models.Tag
+		if err := rows.Scan(&t.ID, &t.Name, &t.Category); err != nil {
+			return nil, err
+		}
+		tags = append(tags, t)
+	}
+	return tags, nil
+}
+
+func (r *PlaceRepo) GetFeatures(ctx context.Context, placeID string) ([]models.Feature, error) {
+	rows, err := r.pool.Query(ctx,
+		`SELECT f.id, f.name, f.category, f.icon FROM features f
+		 JOIN place_features pf ON pf.feature_id = f.id
+		 WHERE pf.place_id = $1`, placeID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var features []models.Feature
+	for rows.Next() {
+		var f models.Feature
+		if err := rows.Scan(&f.ID, &f.Name, &f.Category, &f.Icon); err != nil {
+			return nil, err
+		}
+		features = append(features, f)
+	}
+	return features, nil
+}
+
+func scanPlace(row interface{ Scan(dest ...any) error }) (*models.Place, error) {
+	var p models.Place
+	err := row.Scan(
+		&p.ID, &p.Type, &p.OwnerID, &p.Title, &p.Description, &p.Address,
+		&p.Lat, &p.Lng,
+		&p.CoverImage, &p.AccessInfo, &p.Status, &p.ModerationComment,
+		&p.Rating, &p.ReviewsCount, &p.HourlyRate, &p.Currency, &p.MinHours, &p.BookingURL,
+		&p.CreatedAt, &p.UpdatedAt, &p.PublishedAt, &p.DeletedAt,
+	)
+	if err != nil {
+		if err == pgx.ErrNoRows {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &p, nil
+}

+ 103 - 0
backend/internal/repository/reviews.go

@@ -0,0 +1,103 @@
+package repository
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/jackc/pgx/v5/pgxpool"
+	"github.com/photoplaces/backend/internal/models"
+)
+
+type ReviewRepo struct {
+	pool *pgxpool.Pool
+}
+
+func NewReviewRepo(pool *pgxpool.Pool) *ReviewRepo {
+	return &ReviewRepo{pool: pool}
+}
+
+func (r *ReviewRepo) Create(ctx context.Context, review *models.Review) error {
+	tx, err := r.pool.Begin(ctx)
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback(ctx)
+
+	err = tx.QueryRow(ctx,
+		`INSERT INTO reviews (user_id, target_type, target_id, rating, text)
+		 VALUES ($1, $2, $3, $4, $5)
+		 RETURNING id, created_at, updated_at`,
+		review.UserID, review.TargetType, review.TargetID, review.Rating, review.Text,
+	).Scan(&review.ID, &review.CreatedAt, &review.UpdatedAt)
+	if err != nil {
+		if isUniqueViolation(err) {
+			return fmt.Errorf("you already reviewed this")
+		}
+		return fmt.Errorf("create review: %w", err)
+	}
+
+	tableName := "places"
+	if review.TargetType == "service" {
+		tableName = "services"
+	}
+
+	_, err = tx.Exec(ctx,
+		fmt.Sprintf(`UPDATE %s SET
+			reviews_count = (SELECT COUNT(*) FROM reviews WHERE target_type = $1 AND target_id = $2 AND deleted_at IS NULL),
+			rating = (SELECT COALESCE(AVG(rating), 0) FROM reviews WHERE target_type = $1 AND target_id = $2 AND deleted_at IS NULL)
+			WHERE id = $2`, tableName),
+		review.TargetType, review.TargetID)
+	if err != nil {
+		return fmt.Errorf("update rating: %w", err)
+	}
+
+	return tx.Commit(ctx)
+}
+
+func (r *ReviewRepo) List(ctx context.Context, targetType, targetID string) ([]*models.Review, error) {
+	rows, err := r.pool.Query(ctx,
+		`SELECT r.id, r.user_id, r.target_type, r.target_id, r.rating, r.text,
+		        r.created_at, r.updated_at,
+		        u.name, u.avatar_url
+		 FROM reviews r
+		 JOIN users u ON u.id = r.user_id
+		 WHERE r.target_type = $1 AND r.target_id = $2 AND r.deleted_at IS NULL
+		 ORDER BY r.created_at DESC`, targetType, targetID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var reviews []*models.Review
+	for rows.Next() {
+		var rev models.Review
+		var userName, userAvatar *string
+		if err := rows.Scan(
+			&rev.ID, &rev.UserID, &rev.TargetType, &rev.TargetID, &rev.Rating, &rev.Text,
+			&rev.CreatedAt, &rev.UpdatedAt,
+			&userName, &userAvatar,
+		); err != nil {
+			return nil, err
+		}
+		rev.User = &models.User{Name: userName, AvatarURL: userAvatar}
+		reviews = append(reviews, &rev)
+	}
+	return reviews, nil
+}
+
+func isUniqueViolation(err error) bool {
+	return err != nil && contains(err.Error(), "unique")
+}
+
+func contains(s, substr string) bool {
+	return len(s) >= len(substr) && searchString(s, substr)
+}
+
+func searchString(s, sub string) bool {
+	for i := 0; i <= len(s)-len(sub); i++ {
+		if s[i:i+len(sub)] == sub {
+			return true
+		}
+	}
+	return false
+}

+ 161 - 0
backend/internal/repository/services.go

@@ -0,0 +1,161 @@
+package repository
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgxpool"
+	"github.com/photoplaces/backend/internal/models"
+)
+
+type ServiceRepo struct {
+	pool *pgxpool.Pool
+}
+
+func NewServiceRepo(pool *pgxpool.Pool) *ServiceRepo {
+	return &ServiceRepo{pool: pool}
+}
+
+func (r *ServiceRepo) Create(ctx context.Context, s *models.Service) error {
+	err := r.pool.QueryRow(ctx,
+		`INSERT INTO services (executor_id, title, description, price, currency, duration_minutes, status)
+		 VALUES ($1, $2, $3, $4, $5, $6, $7)
+		 RETURNING id, created_at, updated_at`,
+		s.ExecutorID, s.Title, s.Description, s.Price, s.Currency, s.DurationMinutes, s.Status,
+	).Scan(&s.ID, &s.CreatedAt, &s.UpdatedAt)
+	if err != nil {
+		return fmt.Errorf("create service: %w", err)
+	}
+
+	if len(s.Tags) > 0 {
+		if err := r.SetTags(ctx, s.ID, s.Tags); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (r *ServiceRepo) GetByID(ctx context.Context, id string) (*models.Service, error) {
+	row := r.pool.QueryRow(ctx,
+		`SELECT id, executor_id, title, description, price, currency, duration_minutes,
+		        status, rating, reviews_count, created_at, updated_at, deleted_at
+		 FROM services WHERE id = $1 AND deleted_at IS NULL`, id)
+	return scanService(row)
+}
+
+func (r *ServiceRepo) List(ctx context.Context, filter models.ServiceFilter) ([]*models.Service, error) {
+	q := `SELECT s.id, s.executor_id, s.title, s.description, s.price, s.currency,
+	             s.duration_minutes, s.status, s.rating, s.reviews_count,
+	             s.created_at, s.updated_at, s.deleted_at
+	      FROM services s`
+	args := pgx.NamedArgs{}
+	joins := ""
+
+	if len(filter.Tags) > 0 {
+		joins += ` JOIN service_tags st ON st.service_id = s.id`
+	}
+
+	q += joins + ` WHERE s.deleted_at IS NULL`
+
+	if filter.Status != "" { q += ` AND s.status = @status`; args["status"] = filter.Status }
+	if filter.MinRating > 0 { q += ` AND s.rating >= @min_rating`; args["min_rating"] = filter.MinRating }
+	if filter.PriceMin != nil { q += ` AND s.price >= @price_min`; args["price_min"] = *filter.PriceMin }
+	if filter.PriceMax != nil { q += ` AND s.price <= @price_max`; args["price_max"] = *filter.PriceMax }
+	if len(filter.Tags) > 0 { q += ` AND st.tag_id = ANY(@tag_ids)`; args["tag_ids"] = filter.Tags }
+
+	q += ` ORDER BY s.created_at DESC LIMIT @lim`
+	args["lim"] = filter.Limit()
+
+	rows, err := r.pool.Query(ctx, q, args)
+	if err != nil {
+		return nil, fmt.Errorf("list services: %w", err)
+	}
+	defer rows.Close()
+
+	var services []*models.Service
+	for rows.Next() {
+		s, err := scanService(rows)
+		if err != nil {
+			return nil, err
+		}
+		services = append(services, s)
+	}
+	return services, nil
+}
+
+func (r *ServiceRepo) Update(ctx context.Context, s *models.Service) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE services SET title=$1, description=$2, price=$3, currency=$4,
+		 duration_minutes=$5, status=$6, updated_at=now()
+		 WHERE id=$7 AND deleted_at IS NULL`,
+		s.Title, s.Description, s.Price, s.Currency, s.DurationMinutes, s.Status, s.ID)
+	return err
+}
+
+func (r *ServiceRepo) SoftDelete(ctx context.Context, id string) error {
+	_, err := r.pool.Exec(ctx, `UPDATE services SET deleted_at=now() WHERE id=$1`, id)
+	return err
+}
+
+func (r *ServiceRepo) SetTags(ctx context.Context, serviceID string, tags []models.Tag) error {
+	tx, err := r.pool.Begin(ctx)
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback(ctx)
+
+	_, err = tx.Exec(ctx, `DELETE FROM service_tags WHERE service_id = $1`, serviceID)
+	if err != nil {
+		return err
+	}
+
+	for _, tag := range tags {
+		_, err = tx.Exec(ctx,
+			`INSERT INTO service_tags (service_id, tag_id) VALUES ($1, $2)`,
+			serviceID, tag.ID)
+		if err != nil {
+			return err
+		}
+	}
+
+	return tx.Commit(ctx)
+}
+
+func (r *ServiceRepo) GetTags(ctx context.Context, serviceID string) ([]models.Tag, error) {
+	rows, err := r.pool.Query(ctx,
+		`SELECT t.id, t.name, t.category FROM tags t
+		 JOIN service_tags st ON st.tag_id = t.id
+		 WHERE st.service_id = $1`, serviceID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var tags []models.Tag
+	for rows.Next() {
+		var t models.Tag
+		if err := rows.Scan(&t.ID, &t.Name, &t.Category); err != nil {
+			return nil, err
+		}
+		tags = append(tags, t)
+	}
+	return tags, nil
+}
+
+func scanService(row interface{ Scan(dest ...any) error }) (*models.Service, error) {
+	var s models.Service
+	err := row.Scan(
+		&s.ID, &s.ExecutorID, &s.Title, &s.Description, &s.Price, &s.Currency,
+		&s.DurationMinutes, &s.Status, &s.Rating, &s.ReviewsCount,
+		&s.CreatedAt, &s.UpdatedAt, &s.DeletedAt,
+	)
+	if err != nil {
+		if err == pgx.ErrNoRows {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &s, nil
+}

+ 105 - 0
backend/internal/repository/tags.go

@@ -0,0 +1,105 @@
+package repository
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5/pgxpool"
+	"github.com/photoplaces/backend/internal/models"
+)
+
+type TagRepo struct {
+	pool *pgxpool.Pool
+}
+
+func NewTagRepo(pool *pgxpool.Pool) *TagRepo {
+	return &TagRepo{pool: pool}
+}
+
+func (r *TagRepo) GetAll(ctx context.Context) ([]models.Tag, error) {
+	rows, err := r.pool.Query(ctx,
+		`SELECT id, name, category, sort_order FROM tags ORDER BY sort_order, name`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var tags []models.Tag
+	for rows.Next() {
+		var t models.Tag
+		if err := rows.Scan(&t.ID, &t.Name, &t.Category, &t.SortOrder); err != nil {
+			return nil, err
+		}
+		tags = append(tags, t)
+	}
+	return tags, nil
+}
+
+func (r *TagRepo) GetByIDs(ctx context.Context, ids []string) ([]models.Tag, error) {
+	rows, err := r.pool.Query(ctx,
+		`SELECT id, name, category, sort_order FROM tags WHERE id = ANY($1)`, ids)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var tags []models.Tag
+	for rows.Next() {
+		var t models.Tag
+		if err := rows.Scan(&t.ID, &t.Name, &t.Category, &t.SortOrder); err != nil {
+			return nil, err
+		}
+		tags = append(tags, t)
+	}
+	return tags, nil
+}
+
+func (r *TagRepo) Create(ctx context.Context, tag *models.Tag) error {
+	_, err := r.pool.Exec(ctx,
+		`INSERT INTO tags (id, name, category, sort_order) VALUES ($1, $2, $3, $4)`,
+		tag.ID, tag.Name, tag.Category, tag.SortOrder)
+	return err
+}
+
+func (r *TagRepo) Delete(ctx context.Context, id string) error {
+	_, err := r.pool.Exec(ctx, `DELETE FROM tags WHERE id = $1`, id)
+	return err
+}
+
+type FeatureRepo struct {
+	pool *pgxpool.Pool
+}
+
+func NewFeatureRepo(pool *pgxpool.Pool) *FeatureRepo {
+	return &FeatureRepo{pool: pool}
+}
+
+func (r *FeatureRepo) GetAll(ctx context.Context) ([]models.Feature, error) {
+	rows, err := r.pool.Query(ctx,
+		`SELECT id, name, category, icon, sort_order FROM features ORDER BY sort_order, name`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var features []models.Feature
+	for rows.Next() {
+		var f models.Feature
+		if err := rows.Scan(&f.ID, &f.Name, &f.Category, &f.Icon, &f.SortOrder); err != nil {
+			return nil, err
+		}
+		features = append(features, f)
+	}
+	return features, nil
+}
+
+func (r *FeatureRepo) Create(ctx context.Context, feature *models.Feature) error {
+	_, err := r.pool.Exec(ctx,
+		`INSERT INTO features (id, name, category, icon, sort_order) VALUES ($1, $2, $3, $4, $5)`,
+		feature.ID, feature.Name, feature.Category, feature.Icon, feature.SortOrder)
+	return err
+}
+
+func (r *FeatureRepo) Delete(ctx context.Context, id string) error {
+	_, err := r.pool.Exec(ctx, `DELETE FROM features WHERE id = $1`, id)
+	return err
+}

+ 116 - 0
backend/internal/repository/users.go

@@ -0,0 +1,116 @@
+package repository
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgxpool"
+	"github.com/photoplaces/backend/internal/models"
+)
+
+type UserRepo struct {
+	pool *pgxpool.Pool
+}
+
+func NewUserRepo(pool *pgxpool.Pool) *UserRepo {
+	return &UserRepo{pool: pool}
+}
+
+func (r *UserRepo) Create(ctx context.Context, u *models.User) error {
+	return r.pool.QueryRow(ctx,
+		`INSERT INTO users (email, password_hash, role, name)
+		 VALUES ($1, $2, $3, $4)
+		 RETURNING id, created_at, updated_at`,
+		u.Email, u.PasswordHash, u.Role, u.Name,
+	).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
+}
+
+func (r *UserRepo) GetByID(ctx context.Context, id string) (*models.User, error) {
+	row := r.pool.QueryRow(ctx,
+		`SELECT id, email, password_hash, role, status, name, avatar_url, phone, bio, country,
+		        created_at, updated_at, deleted_at
+		 FROM users WHERE id = $1 AND deleted_at IS NULL`, id)
+	return scanUser(row)
+}
+
+func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*models.User, error) {
+	row := r.pool.QueryRow(ctx,
+		`SELECT id, email, password_hash, role, status, name, avatar_url, phone, bio, country,
+		        created_at, updated_at, deleted_at
+		 FROM users WHERE email = $1 AND deleted_at IS NULL`, email)
+	return scanUser(row)
+}
+
+func (r *UserRepo) Update(ctx context.Context, u *models.User) error {
+	u.UpdatedAt = time.Now()
+	_, err := r.pool.Exec(ctx,
+		`UPDATE users SET name=$1, phone=$2, bio=$3, avatar_url=$4, country=$5, updated_at=$6
+		 WHERE id=$7 AND deleted_at IS NULL`,
+		u.Name, u.Phone, u.Bio, u.AvatarURL, u.Country, u.UpdatedAt, u.ID)
+	return err
+}
+
+func (r *UserRepo) UpdateRole(ctx context.Context, id, role string) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE users SET role=$1, updated_at=now() WHERE id=$2 AND deleted_at IS NULL`, role, id)
+	return err
+}
+
+func (r *UserRepo) UpdateStatus(ctx context.Context, id, status string) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE users SET status=$1, updated_at=now() WHERE id=$2 AND deleted_at IS NULL`, status, id)
+	return err
+}
+
+func (r *UserRepo) List(ctx context.Context, filter models.UserFilter) ([]*models.User, error) {
+	query := `SELECT id, email, password_hash, role, status, name, avatar_url, phone, bio, country,
+	                  created_at, updated_at, deleted_at
+	           FROM users WHERE deleted_at IS NULL`
+	args := pgx.NamedArgs{}
+
+	if filter.Role != "" {
+		query += ` AND role = @role`
+		args["role"] = filter.Role
+	}
+	if filter.Status != "" {
+		query += ` AND status = @status`
+		args["status"] = filter.Status
+	}
+
+	query += ` ORDER BY created_at DESC LIMIT @lim`
+	args["lim"] = filter.Limit()
+
+	rows, err := r.pool.Query(ctx, query, args)
+	if err != nil {
+		return nil, fmt.Errorf("list users: %w", err)
+	}
+	defer rows.Close()
+
+	var users []*models.User
+	for rows.Next() {
+		u, err := scanUser(rows)
+		if err != nil {
+			return nil, err
+		}
+		users = append(users, u)
+	}
+	return users, nil
+}
+
+func scanUser(row interface{ Scan(dest ...any) error }) (*models.User, error) {
+	var u models.User
+	err := row.Scan(
+		&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.Status,
+		&u.Name, &u.AvatarURL, &u.Phone, &u.Bio, &u.Country,
+		&u.CreatedAt, &u.UpdatedAt, &u.DeletedAt,
+	)
+	if err != nil {
+		if err == pgx.ErrNoRows {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &u, nil
+}

+ 191 - 0
backend/internal/services/auth.go

@@ -0,0 +1,191 @@
+package services
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"time"
+
+	"github.com/golang-jwt/jwt/v5"
+	"github.com/photoplaces/backend/internal/models"
+	"github.com/photoplaces/backend/internal/repository"
+	"golang.org/x/crypto/bcrypt"
+)
+
+var (
+	ErrEmailExists    = errors.New("email already exists")
+	ErrInvalidCreds   = errors.New("invalid email or password")
+	ErrUserBanned     = errors.New("account is banned")
+	ErrInvalidToken   = errors.New("invalid or expired token")
+)
+
+type AuthService struct {
+	userRepo      *repository.UserRepo
+	jwtSecret     []byte
+	refreshSecret []byte
+}
+
+func NewAuthService(userRepo *repository.UserRepo, jwtSecret, refreshSecret string) *AuthService {
+	return &AuthService{
+		userRepo:      userRepo,
+		jwtSecret:     []byte(jwtSecret),
+		refreshSecret: []byte(refreshSecret),
+	}
+}
+
+type AuthResult struct {
+	User         *models.User `json:"user"`
+	AccessToken  string       `json:"access_token"`
+	RefreshToken string       `json:"refresh_token"`
+}
+
+type RegisterInput struct {
+	Email    string
+	Password string
+	Role     string
+	Name     string
+}
+
+func (s *AuthService) Register(ctx context.Context, input RegisterInput) (*AuthResult, error) {
+	existing, _ := s.userRepo.GetByEmail(ctx, input.Email)
+	if existing != nil {
+		return nil, ErrEmailExists
+	}
+
+	hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
+	if err != nil {
+		return nil, fmt.Errorf("hash password: %w", err)
+	}
+
+	user := &models.User{
+		Email:        input.Email,
+		PasswordHash: string(hash),
+		Role:         input.Role,
+		Name:         strPtr(input.Name),
+	}
+
+	if err := s.userRepo.Create(ctx, user); err != nil {
+		return nil, fmt.Errorf("create user: %w", err)
+	}
+
+	return s.generateTokens(ctx, user)
+}
+
+type LoginInput struct {
+	Email    string
+	Password string
+}
+
+func (s *AuthService) Login(ctx context.Context, input LoginInput) (*AuthResult, error) {
+	user, err := s.userRepo.GetByEmail(ctx, input.Email)
+	if err != nil {
+		return nil, fmt.Errorf("get user: %w", err)
+	}
+	if user == nil {
+		return nil, ErrInvalidCreds
+	}
+
+	if user.Status == "banned" {
+		return nil, ErrUserBanned
+	}
+
+	if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(input.Password)); err != nil {
+		return nil, ErrInvalidCreds
+	}
+
+	return s.generateTokens(ctx, user)
+}
+
+type TokenClaims struct {
+	UserID string `json:"user_id"`
+	Role   string `json:"role"`
+	jwt.RegisteredClaims
+}
+
+func (s *AuthService) generateTokens(ctx context.Context, user *models.User) (*AuthResult, error) {
+	now := time.Now()
+
+	accessClaims := TokenClaims{
+		UserID: user.ID,
+		Role:   user.Role,
+		RegisteredClaims: jwt.RegisteredClaims{
+			ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
+			IssuedAt:  jwt.NewNumericDate(now),
+		},
+	}
+	accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString(s.jwtSecret)
+	if err != nil {
+		return nil, fmt.Errorf("sign access token: %w", err)
+	}
+
+	refreshClaims := jwt.RegisteredClaims{
+		ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
+		IssuedAt:  jwt.NewNumericDate(now),
+		Subject:   user.ID,
+	}
+	refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(s.refreshSecret)
+	if err != nil {
+		return nil, fmt.Errorf("sign refresh token: %w", err)
+	}
+
+	return &AuthResult{
+		User:         user,
+		AccessToken:  accessToken,
+		RefreshToken: refreshToken,
+	}, nil
+}
+
+func (s *AuthService) ValidateAccessToken(tokenString string) (*TokenClaims, error) {
+	token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, func(t *jwt.Token) (interface{}, error) {
+		return s.jwtSecret, nil
+	})
+	if err != nil {
+		return nil, ErrInvalidToken
+	}
+	claims, ok := token.Claims.(*TokenClaims)
+	if !ok || !token.Valid {
+		return nil, ErrInvalidToken
+	}
+	return claims, nil
+}
+
+func (s *AuthService) ValidateRefreshToken(tokenString string) (string, error) {
+	token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(t *jwt.Token) (interface{}, error) {
+		return s.refreshSecret, nil
+	})
+	if err != nil {
+		return "", ErrInvalidToken
+	}
+	claims, ok := token.Claims.(*jwt.RegisteredClaims)
+	if !ok || !token.Valid {
+		return "", ErrInvalidToken
+	}
+	return claims.Subject, nil
+}
+
+func (s *AuthService) RefreshSession(ctx context.Context, refreshToken string) (*AuthResult, error) {
+	userID, err := s.ValidateRefreshToken(refreshToken)
+	if err != nil {
+		return nil, err
+	}
+
+	user, err := s.userRepo.GetByID(ctx, userID)
+	if err != nil {
+		return nil, fmt.Errorf("get user: %w", err)
+	}
+	if user == nil {
+		return nil, ErrInvalidToken
+	}
+	if user.Status == "banned" {
+		return nil, ErrUserBanned
+	}
+
+	return s.generateTokens(ctx, user)
+}
+
+func strPtr(s string) *string {
+	if s == "" {
+		return nil
+	}
+	return &s
+}

+ 154 - 0
backend/internal/services/places.go

@@ -0,0 +1,154 @@
+package services
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/photoplaces/backend/internal/models"
+	"github.com/photoplaces/backend/internal/repository"
+)
+
+type PlaceService struct {
+	placeRepo *repository.PlaceRepo
+}
+
+func NewPlaceService(placeRepo *repository.PlaceRepo) *PlaceService {
+	return &PlaceService{placeRepo: placeRepo}
+}
+
+type CreatePlaceInput struct {
+	OwnerID    string
+	Type       string
+	Title      string
+	Description *string
+	Address    *string
+	Lat        float64
+	Lng        float64
+	CoverImage *string
+	AccessInfo *string
+	HourlyRate *int
+	Currency   string
+	MinHours   int
+	Tags       []models.Tag
+	Features   []models.Feature
+}
+
+func (s *PlaceService) Create(ctx context.Context, input CreatePlaceInput) (*models.Place, error) {
+	place := &models.Place{
+		Type:       input.Type,
+		OwnerID:    input.OwnerID,
+		Title:      input.Title,
+		Description: input.Description,
+		Address:    input.Address,
+		Lat:        input.Lat,
+		Lng:        input.Lng,
+		CoverImage: input.CoverImage,
+		AccessInfo: input.AccessInfo,
+		Status:     "pending_moderation",
+		HourlyRate: input.HourlyRate,
+		Currency:   input.Currency,
+		MinHours:   input.MinHours,
+		Tags:       input.Tags,
+		Features:   input.Features,
+	}
+
+	if err := s.placeRepo.Create(ctx, place); err != nil {
+		return nil, fmt.Errorf("create place: %w", err)
+	}
+
+	return place, nil
+}
+
+func (s *PlaceService) GetByID(ctx context.Context, id string, fetchTags bool) (*models.Place, error) {
+	place, err := s.placeRepo.GetByID(ctx, id)
+	if err != nil {
+		return nil, err
+	}
+	if place == nil {
+		return nil, nil
+	}
+
+	if fetchTags {
+		tags, err := s.placeRepo.GetTags(ctx, id)
+		if err != nil {
+			return nil, err
+		}
+		place.Tags = tags
+
+		features, err := s.placeRepo.GetFeatures(ctx, id)
+		if err != nil {
+			return nil, err
+		}
+		place.Features = features
+	}
+
+	return place, nil
+}
+
+func (s *PlaceService) List(ctx context.Context, filter models.PlaceFilter) ([]*models.Place, error) {
+	return s.placeRepo.List(ctx, filter)
+}
+
+type UpdatePlaceInput struct {
+	ID         string
+	OwnerID    string
+	Title      *string
+	Description *string
+	Address    *string
+	Lat        *float64
+	Lng        *float64
+	CoverImage *string
+	AccessInfo *string
+	HourlyRate *int
+	Currency   *string
+	MinHours   *int
+}
+
+func (s *PlaceService) Update(ctx context.Context, input UpdatePlaceInput, isModerator bool) (*models.Place, error) {
+	place, err := s.placeRepo.GetByID(ctx, input.ID)
+	if err != nil {
+		return nil, err
+	}
+	if place == nil {
+		return nil, nil
+	}
+
+	if input.Title != nil { place.Title = *input.Title }
+	if input.Description != nil { place.Description = input.Description }
+	if input.Address != nil { place.Address = input.Address }
+	if input.Lat != nil { place.Lat = *input.Lat }
+	if input.Lng != nil { place.Lng = *input.Lng }
+	if input.CoverImage != nil { place.CoverImage = input.CoverImage }
+	if input.AccessInfo != nil { place.AccessInfo = input.AccessInfo }
+	if input.HourlyRate != nil { place.HourlyRate = input.HourlyRate }
+	if input.Currency != nil { place.Currency = *input.Currency }
+	if input.MinHours != nil { place.MinHours = *input.MinHours }
+
+	if !isModerator {
+		place.Status = "pending_moderation"
+	}
+
+	if err := s.placeRepo.Update(ctx, place); err != nil {
+		return nil, err
+	}
+
+	return place, nil
+}
+
+func (s *PlaceService) Moderate(ctx context.Context, id, action, comment, moderatorID string) error {
+	var status string
+	switch action {
+	case "approve":
+		status = "published"
+	case "reject":
+		status = "rejected"
+	default:
+		return fmt.Errorf("unknown action: %s", action)
+	}
+
+	return s.placeRepo.UpdateStatus(ctx, id, status, comment)
+}
+
+func (s *PlaceService) Delete(ctx context.Context, id string) error {
+	return s.placeRepo.SoftDelete(ctx, id)
+}

+ 2 - 0
backend/migrations/000001_create_extensions.down.sql

@@ -0,0 +1,2 @@
+DROP EXTENSION IF EXISTS "postgis";
+DROP EXTENSION IF EXISTS "pgcrypto";

+ 2 - 0
backend/migrations/000001_create_extensions.up.sql

@@ -0,0 +1,2 @@
+CREATE EXTENSION IF NOT EXISTS "pgcrypto";
+CREATE EXTENSION IF NOT EXISTS "postgis";

+ 2 - 0
backend/migrations/000002_create_users.down.sql

@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS refresh_tokens;
+DROP TABLE IF EXISTS users;

+ 31 - 0
backend/migrations/000002_create_users.up.sql

@@ -0,0 +1,31 @@
+CREATE TABLE users (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    email VARCHAR(255) UNIQUE NOT NULL,
+    password_hash VARCHAR(255) NOT NULL,
+    role VARCHAR(20) NOT NULL DEFAULT 'customer'
+        CHECK (role IN ('superadmin', 'moderator', 'landlord', 'executor', 'customer')),
+    status VARCHAR(20) NOT NULL DEFAULT 'active'
+        CHECK (status IN ('active', 'banned', 'pending_verification')),
+    name VARCHAR(255),
+    avatar_url TEXT,
+    phone VARCHAR(20),
+    bio TEXT,
+    country VARCHAR(10),
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    deleted_at TIMESTAMPTZ
+);
+
+CREATE INDEX idx_users_role ON users(role);
+CREATE INDEX idx_users_status ON users(status);
+
+CREATE TABLE refresh_tokens (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    token_hash VARCHAR(255) NOT NULL,
+    expires_at TIMESTAMPTZ NOT NULL,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE UNIQUE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
+CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);

+ 2 - 0
backend/migrations/000003_create_tags_features.down.sql

@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS features;
+DROP TABLE IF EXISTS tags;

+ 42 - 0
backend/migrations/000003_create_tags_features.up.sql

@@ -0,0 +1,42 @@
+CREATE TABLE tags (
+    id VARCHAR(50) PRIMARY KEY,
+    name VARCHAR(255) NOT NULL,
+    category VARCHAR(50),
+    sort_order INT DEFAULT 0,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE features (
+    id VARCHAR(50) PRIMARY KEY,
+    name VARCHAR(255) NOT NULL,
+    category VARCHAR(50),
+    icon VARCHAR(50),
+    sort_order INT DEFAULT 0,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+INSERT INTO tags (id, name, category) VALUES
+    ('portrait', 'Портрет', 'style'),
+    ('fashion', 'Фэшн', 'style'),
+    ('nature', 'Природа', 'style'),
+    ('product', 'Предметная', 'style'),
+    ('wedding', 'Свадебная', 'style'),
+    ('street', 'Стрит', 'style'),
+    ('interior', 'Интерьер', 'style'),
+    ('children', 'Детская', 'style'),
+    ('food', 'Фуд', 'style'),
+    ('architecture', 'Архитектура', 'style'),
+    ('pets', 'Животные', 'style'),
+    ('film', 'Плёночная', 'technique');
+
+INSERT INTO features (id, name, category, icon) VALUES
+    ('parking', 'Парковка', 'amenity', 'car'),
+    ('public_transport', 'Общественный транспорт', 'amenity', 'bus'),
+    ('flash', 'Импульсные источники', 'equipment', 'flash'),
+    ('continuous', 'Постоянный свет', 'equipment', 'sun'),
+    ('cyclorama', 'Циклорама', 'equipment', 'curve'),
+    ('makeup_room', 'Гримёрка', 'amenity', 'mirror'),
+    ('wardrobe', 'Гардеробная', 'amenity', 'hanger'),
+    ('changing_room', 'Раздевалка', 'amenity', 'door'),
+    ('equipment_rent', 'Аренда оборудования', 'equipment', 'camera'),
+    ('wheelchair', 'Доступно для колясок', 'infrastructure', 'accessible');

+ 4 - 0
backend/migrations/000004_create_places.down.sql

@@ -0,0 +1,4 @@
+DROP TABLE IF EXISTS place_features;
+DROP TABLE IF EXISTS place_tags;
+DROP TABLE IF EXISTS place_images;
+DROP TABLE IF EXISTS places;

+ 55 - 0
backend/migrations/000004_create_places.up.sql

@@ -0,0 +1,55 @@
+CREATE TABLE places (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    type VARCHAR(10) NOT NULL CHECK (type IN ('place', 'studio')),
+    owner_id UUID NOT NULL REFERENCES users(id),
+    title VARCHAR(255) NOT NULL,
+    description TEXT,
+    address VARCHAR(500),
+    coordinates GEOGRAPHY(Point, 4326) NOT NULL,
+    cover_image TEXT,
+    access_info TEXT,
+    status VARCHAR(20) NOT NULL DEFAULT 'pending_moderation'
+        CHECK (status IN ('draft', 'pending_moderation', 'published', 'rejected', 'archived')),
+    moderation_comment TEXT,
+    rating DECIMAL(2,1) DEFAULT 0,
+    reviews_count INT DEFAULT 0,
+    hourly_rate INT,
+    currency VARCHAR(3) DEFAULT 'RUB',
+    min_hours INT DEFAULT 1,
+    booking_url TEXT,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    deleted_at TIMESTAMPTZ,
+    published_at TIMESTAMPTZ
+);
+
+CREATE INDEX idx_places_coordinates ON places USING GIST (coordinates);
+CREATE INDEX idx_places_type ON places(type);
+CREATE INDEX idx_places_status ON places(status);
+CREATE INDEX idx_places_owner_id ON places(owner_id);
+CREATE INDEX idx_places_rating ON places(rating);
+CREATE INDEX idx_places_published_at ON places(published_at);
+
+CREATE TABLE place_images (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    place_id UUID NOT NULL REFERENCES places(id) ON DELETE CASCADE,
+    url TEXT NOT NULL,
+    alt VARCHAR(255),
+    sort_order INT DEFAULT 0,
+    is_cover BOOLEAN DEFAULT false,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_place_images_place_id ON place_images(place_id);
+
+CREATE TABLE place_tags (
+    place_id UUID NOT NULL REFERENCES places(id) ON DELETE CASCADE,
+    tag_id VARCHAR(50) NOT NULL REFERENCES tags(id),
+    PRIMARY KEY (place_id, tag_id)
+);
+
+CREATE TABLE place_features (
+    place_id UUID NOT NULL REFERENCES places(id) ON DELETE CASCADE,
+    feature_id VARCHAR(50) NOT NULL REFERENCES features(id),
+    PRIMARY KEY (place_id, feature_id)
+);

+ 5 - 0
backend/migrations/000005_create_services_reviews_bookings.down.sql

@@ -0,0 +1,5 @@
+DROP TABLE IF EXISTS bookings;
+DROP TABLE IF EXISTS reviews;
+DROP TABLE IF EXISTS service_tags;
+DROP TABLE IF EXISTS service_images;
+DROP TABLE IF EXISTS services;

+ 72 - 0
backend/migrations/000005_create_services_reviews_bookings.up.sql

@@ -0,0 +1,72 @@
+CREATE TABLE services (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    executor_id UUID NOT NULL REFERENCES users(id),
+    title VARCHAR(255) NOT NULL,
+    description TEXT,
+    price INT NOT NULL,
+    currency VARCHAR(3) DEFAULT 'RUB',
+    duration_minutes INT,
+    status VARCHAR(20) NOT NULL DEFAULT 'published'
+        CHECK (status IN ('draft', 'published', 'archived')),
+    rating DECIMAL(2,1) DEFAULT 0,
+    reviews_count INT DEFAULT 0,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    deleted_at TIMESTAMPTZ
+);
+
+CREATE INDEX idx_services_executor_id ON services(executor_id);
+CREATE INDEX idx_services_status ON services(status);
+CREATE INDEX idx_services_rating ON services(rating);
+
+CREATE TABLE service_images (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
+    url TEXT NOT NULL,
+    alt VARCHAR(255),
+    sort_order INT DEFAULT 0,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_service_images_service_id ON service_images(service_id);
+
+CREATE TABLE service_tags (
+    service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
+    tag_id VARCHAR(50) NOT NULL REFERENCES tags(id),
+    PRIMARY KEY (service_id, tag_id)
+);
+
+CREATE TABLE reviews (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID NOT NULL REFERENCES users(id),
+    target_type VARCHAR(10) NOT NULL CHECK (target_type IN ('place', 'service')),
+    target_id UUID NOT NULL,
+    rating SMALLINT NOT NULL CHECK (rating >= 1 AND rating <= 5),
+    text TEXT,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    deleted_at TIMESTAMPTZ,
+    UNIQUE (user_id, target_type, target_id)
+);
+
+CREATE INDEX idx_reviews_target ON reviews(target_type, target_id);
+CREATE INDEX idx_reviews_user_id ON reviews(user_id);
+
+CREATE TABLE bookings (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    place_id UUID NOT NULL REFERENCES places(id),
+    user_id UUID NOT NULL REFERENCES users(id),
+    start_time TIMESTAMPTZ NOT NULL,
+    end_time TIMESTAMPTZ NOT NULL,
+    status VARCHAR(20) NOT NULL DEFAULT 'pending'
+        CHECK (status IN ('pending', 'confirmed', 'cancelled', 'completed')),
+    total_price INT,
+    currency VARCHAR(3) DEFAULT 'RUB',
+    comment TEXT,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_bookings_place_id ON bookings(place_id);
+CREATE INDEX idx_bookings_user_id ON bookings(user_id);
+CREATE INDEX idx_bookings_time ON bookings(start_time, end_time);

+ 4 - 0
backend/migrations/000006_create_moderation_payments_visitors.down.sql

@@ -0,0 +1,4 @@
+DROP TABLE IF EXISTS visitor_sessions;
+DROP TABLE IF EXISTS subscriptions;
+DROP TABLE IF EXISTS payments;
+DROP TABLE IF EXISTS moderation_log;

+ 53 - 0
backend/migrations/000006_create_moderation_payments_visitors.up.sql

@@ -0,0 +1,53 @@
+CREATE TABLE moderation_log (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    moderator_id UUID NOT NULL REFERENCES users(id),
+    target_type VARCHAR(20) NOT NULL CHECK (target_type IN ('place', 'service')),
+    target_id UUID NOT NULL,
+    action VARCHAR(20) NOT NULL CHECK (action IN ('approve', 'reject', 'request_changes', 'publish', 'archive')),
+    comment TEXT,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_moderation_log_target ON moderation_log(target_type, target_id);
+CREATE INDEX idx_moderation_log_moderator_id ON moderation_log(moderator_id);
+
+CREATE TABLE payments (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID NOT NULL REFERENCES users(id),
+    type VARCHAR(20) NOT NULL CHECK (type IN ('subscription', 'donation', 'listing_fee', 'booking_commission')),
+    provider VARCHAR(20) NOT NULL CHECK (provider IN ('cloudpayments', 'stripe')),
+    provider_payment_id VARCHAR(255),
+    amount INT NOT NULL,
+    currency VARCHAR(3) DEFAULT 'RUB',
+    status VARCHAR(20) NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed', 'refunded')),
+    metadata JSONB,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_payments_user_id ON payments(user_id);
+CREATE INDEX idx_payments_status ON payments(status);
+CREATE INDEX idx_payments_provider_id ON payments(provider, provider_payment_id);
+
+CREATE TABLE subscriptions (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID NOT NULL REFERENCES users(id),
+    plan VARCHAR(50) NOT NULL CHECK (plan IN ('studio_listing', 'executor_promo', 'premium_user')),
+    status VARCHAR(20) NOT NULL CHECK (status IN ('active', 'cancelled', 'expired')),
+    current_period_start TIMESTAMPTZ,
+    current_period_end TIMESTAMPTZ,
+    provider_subscription_id VARCHAR(255),
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE visitor_sessions (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID REFERENCES users(id),
+    coordinates GEOGRAPHY(Point, 4326) NOT NULL,
+    last_active_at TIMESTAMPTZ NOT NULL,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_visitor_sessions_coordinates ON visitor_sessions USING GIST (coordinates);
+CREATE INDEX idx_visitor_sessions_last_active ON visitor_sessions(last_active_at);

+ 67 - 0
deploy/deploy.sh

@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+ENV_FILE="${1:-deploy/env.prod}"
+
+if [ ! -f "$ENV_FILE" ]; then
+  echo "Ошибка: файл $ENV_FILE не найден"
+  echo "Создай его из шаблона: cp deploy/env.prod.example deploy/env.prod"
+  exit 1
+fi
+
+echo "=== PhotoPlaces — деплой ==="
+
+# 1. Генерация секретов, если не заменены
+check_secret() {
+  local var=$1
+  local val
+  val=$(grep "^${var}=" "$ENV_FILE" | cut -d'=' -f2-)
+  if [[ "$val" == *"CHANGE_ME"* ]] || [[ -z "$val" ]]; then
+    echo "!!! $var не настроен. Сгенерировать случайный? [y/N]"
+    read -r answer
+    if [[ "$answer" == "y" ]]; then
+      local secret
+      secret=$(openssl rand -hex 32)
+      if [[ "$OSTYPE" == "darwin"* ]]; then
+        sed -i '' "s/^${var}=.*/${var}=${secret}/" "$ENV_FILE"
+      else
+        sed -i "s/^${var}=.*/${var}=${secret}/" "$ENV_FILE"
+      fi
+      echo "✓ $var сгенерирован"
+    fi
+  fi
+}
+
+check_secret "DB_PASSWORD"
+check_secret "REDIS_PASSWORD"
+check_secret "JWT_SECRET"
+check_secret "JWT_REFRESH_SECRET"
+check_secret "S3_SECRET_KEY"
+
+# 2. Экспорт переменных
+set -a
+source "$ENV_FILE"
+set +a
+
+# 3. Билд и запуск
+echo ">>> Собираю и запускаю контейнеры..."
+docker compose -f deploy/docker-compose.prod.yml up -d --build
+
+# 4. Проверка
+echo ">>> Проверяю здоровье..."
+sleep 5
+
+HEALTH=$(curl -s http://localhost:8080/api/v1/health || echo '{"status":"fail"}')
+echo "API health: $HEALTH"
+
+if echo "$HEALTH" | grep -q '"ok"'; then
+  echo "✓ Деплой успешен!"
+  echo "API:         http://localhost:8080/api/v1"
+  echo "Фронтенд:    http://localhost:3000"
+  echo "MinIO консоль: http://localhost:9001"
+else
+  echo "✗ Что-то пошло не так. Логи:"
+  docker compose -f deploy/docker-compose.prod.yml logs --tail=20
+fi

+ 104 - 0
deploy/docker-compose.prod.yml

@@ -0,0 +1,104 @@
+version: "3.9"
+
+x-common: &common
+  restart: unless-stopped
+  networks:
+    - photoplaces
+
+services:
+  postgres:
+    <<: *common
+    image: postgis/postgis:16-3.4-alpine
+    environment:
+      POSTGRES_DB: photoplaces
+      POSTGRES_USER: photoplaces
+      POSTGRES_PASSWORD: ${DB_PASSWORD}
+    volumes:
+      - pgdata:/var/lib/postgresql/data
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U photoplaces"]
+      interval: 10s
+      retries: 5
+
+  redis:
+    <<: *common
+    image: redis:7-alpine
+    command: redis-server --requirepass ${REDIS_PASSWORD}
+    volumes:
+      - redisdata:/data
+    healthcheck:
+      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
+      interval: 10s
+      retries: 5
+
+  minio:
+    <<: *common
+    image: minio/minio:latest
+    command: server /data
+    volumes:
+      - miniodata:/data
+    environment:
+      MINIO_ROOT_USER: ${S3_ACCESS_KEY}
+      MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY}
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
+      interval: 10s
+      retries: 5
+
+  migrations:
+    <<: *common
+    image: migrate/migrate:v4.17
+    command:
+      -path=/migrations -database "postgres://photoplaces:${DB_PASSWORD}@postgres:5432/photoplaces?sslmode=disable" up
+    volumes:
+      - ./backend/migrations:/migrations
+    depends_on:
+      postgres:
+        condition: service_healthy
+
+  backend:
+    <<: *common
+    build:
+      context: ./backend
+      dockerfile: Dockerfile
+    ports:
+      - "127.0.0.1:8080:8080"
+    environment:
+      APP_ENV: production
+      SERVER_PORT: "8080"
+      DATABASE_URL: postgres://photoplaces:${DB_PASSWORD}@postgres:5432/photoplaces?sslmode=disable
+      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
+      S3_ENDPOINT: http://minio:9000
+      S3_ACCESS_KEY: ${S3_ACCESS_KEY}
+      S3_SECRET_KEY: ${S3_SECRET_KEY}
+      S3_BUCKET: photoplaces
+      JWT_SECRET: ${JWT_SECRET}
+      JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
+      CLOUDPAYMENTS_PUBLIC_ID: ${CLOUDPAYMENTS_PUBLIC_ID}
+      CLOUDPAYMENTS_API_SECRET: ${CLOUDPAYMENTS_API_SECRET}
+      ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}
+    depends_on:
+      migrations:
+        condition: service_completed_successfully
+
+  frontend:
+    <<: *common
+    build:
+      context: ./frontend
+      dockerfile: Dockerfile
+      args:
+        NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
+        NEXT_PUBLIC_MAP_PROVIDER: yandex
+        NEXT_PUBLIC_YANDEX_MAPS_API_KEY: ${YANDEX_MAPS_API_KEY}
+    ports:
+      - "127.0.0.1:3000:3000"
+    depends_on:
+      - backend
+
+volumes:
+  pgdata:
+  redisdata:
+  miniodata:
+
+networks:
+  photoplaces:

+ 25 - 0
deploy/env.prod

@@ -0,0 +1,25 @@
+# PostgreSQL
+DB_PASSWORD=CHANGE_ME_STRONG_PASSWORD
+
+# Redis
+REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD
+
+# JWT (сгенерировать: openssl rand -hex 32)
+JWT_SECRET=CHANGE_ME_JWT_SECRET_32_HEX
+JWT_REFRESH_SECRET=CHANGE_ME_REFRESH_SECRET_32_HEX
+
+# S3 / MinIO
+S3_ACCESS_KEY=photoplaces
+S3_SECRET_KEY=CHANGE_ME_MINIO_PASSWORD
+
+# CloudPayments (опционально, для платежей)
+CLOUDPAYMENTS_PUBLIC_ID=
+CLOUDPAYMENTS_API_SECRET=
+
+# Фронтенд
+NEXT_PUBLIC_API_URL=http://192.168.88.128:8080/api/v1
+NEXT_PUBLIC_MAP_PROVIDER=yandex
+YANDEX_MAPS_API_KEY=ВАШ_КЛЮЧ_ЯНДЕКС_КАРТ
+
+# CORS (домены, с которых приходят запросы)
+ALLOWED_ORIGINS=http://192.168.88.128:3000,https://photoplaces.ru

+ 41 - 0
deploy/setup.sh

@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+echo "=== PhotoPlaces — установка сервера ==="
+
+# 1. Docker
+if ! command -v docker &>/dev/null; then
+  echo ">>> Устанавливаю Docker..."
+  curl -fsSL https://get.docker.com | sh
+  sudo usermod -aG docker "$USER"
+  echo "!!! Перезайди в SSH (exit, затем снова ssh), чтобы применить группу docker"
+else
+  echo "✓ Docker уже установлен"
+fi
+
+# 2. Docker Compose plugin
+if ! docker compose version &>/dev/null; then
+  echo ">>> Устанавливаю Docker Compose..."
+  sudo apt-get update
+  sudo apt-get install -y docker-compose-plugin
+else
+  echo "✓ Docker Compose уже установлен"
+fi
+
+# 3. Системные пакеты
+echo ">>> Устанавливаю системные пакеты..."
+sudo apt-get update
+sudo apt-get install -y git curl ufw
+
+# 4. Firewall (только внутренние порты + SSH)
+echo ">>> Настраиваю firewall..."
+sudo ufw allow 22/tcp
+sudo ufw allow from 192.168.0.0/16 to any port 8080
+sudo ufw allow from 192.168.0.0/16 to any port 3000
+sudo ufw --force enable
+
+echo "=== Установка завершена ==="
+echo "Далее:"
+echo "  1. Скопируй проект на сервер: rsync -avz ./ photoplaces@server:~/photoplaces"
+echo "  2. Заполни deploy/env.prod"
+echo "  3. Запусти: cd ~/photoplaces && bash deploy/deploy.sh"

+ 95 - 0
docker-compose.yml

@@ -0,0 +1,95 @@
+version: "3.9"
+
+services:
+  postgres:
+    image: postgis/postgis:16-3.4-alpine
+    environment:
+      POSTGRES_DB: photoplaces
+      POSTGRES_USER: photoplaces
+      POSTGRES_PASSWORD: photoplaces_dev
+    ports:
+      - "5432:5432"
+    volumes:
+      - pgdata:/var/lib/postgresql/data
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U photoplaces"]
+      interval: 5s
+      retries: 5
+
+  redis:
+    image: redis:7-alpine
+    ports:
+      - "6379:6379"
+    volumes:
+      - redisdata:/data
+    healthcheck:
+      test: ["CMD", "redis-cli", "ping"]
+      interval: 5s
+      retries: 5
+
+  minio:
+    image: minio/minio:latest
+    command: server /data --console-address ":9001"
+    ports:
+      - "9000:9000"
+      - "9001:9001"
+    volumes:
+      - miniodata:/data
+    environment:
+      MINIO_ROOT_USER: photoplaces
+      MINIO_ROOT_PASSWORD: photoplaces_dev
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
+      interval: 5s
+      retries: 5
+
+  backend:
+    build:
+      context: ./backend
+      dockerfile: Dockerfile
+    ports:
+      - "8080:8080"
+    environment:
+      APP_ENV: development
+      DATABASE_URL: postgres://photoplaces:photoplaces_dev@postgres:5432/photoplaces?sslmode=disable
+      REDIS_URL: redis://redis:6379/0
+      S3_ENDPOINT: http://minio:9000
+      S3_ACCESS_KEY: photoplaces
+      S3_SECRET_KEY: photoplaces_dev
+      S3_BUCKET: photoplaces
+      JWT_SECRET: dev-secret-change-in-production
+      CLOUDPAYMENTS_PUBLIC_ID: ""
+      CLOUDPAYMENTS_API_SECRET: ""
+    depends_on:
+      postgres:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+      minio:
+        condition: service_healthy
+    volumes:
+      - ./backend:/app
+      - backend-modules:/app/node_modules
+
+  frontend:
+    build:
+      context: ./frontend
+      dockerfile: Dockerfile
+    ports:
+      - "3000:3000"
+    environment:
+      NEXT_PUBLIC_API_URL: http://localhost:8080/api/v1
+      NEXT_PUBLIC_MAP_PROVIDER: yandex
+      NEXT_PUBLIC_YANDEX_MAPS_API_KEY: ""
+    depends_on:
+      - backend
+    volumes:
+      - ./frontend:/app
+      - frontend-modules:/app/node_modules
+
+volumes:
+  pgdata:
+  redisdata:
+  miniodata:
+  backend-modules:
+  frontend-modules:

+ 6 - 0
frontend/.dockerignore

@@ -0,0 +1,6 @@
+node_modules/
+.next/
+.git/
+.env
+.env.local
+*.log

+ 3 - 0
frontend/.env.local.example

@@ -0,0 +1,3 @@
+NEXT_PUBLIC_API_URL=http://localhost:8080/api/v1
+NEXT_PUBLIC_MAP_PROVIDER=yandex
+NEXT_PUBLIC_YANDEX_MAPS_API_KEY=

+ 7 - 0
frontend/.eslintrc.json

@@ -0,0 +1,7 @@
+{
+  "extends": "next/core-web-vitals",
+  "rules": {
+    "no-unused-vars": "warn",
+    "no-console": "warn"
+  }
+}

+ 7 - 0
frontend/.prettierrc

@@ -0,0 +1,7 @@
+{
+  "semi": false,
+  "singleQuote": true,
+  "tabWidth": 2,
+  "trailingComma": "all",
+  "plugins": ["prettier-plugin-tailwindcss"]
+}

+ 25 - 0
frontend/Dockerfile

@@ -0,0 +1,25 @@
+FROM node:20-alpine AS builder
+
+ARG NEXT_PUBLIC_API_URL
+ARG NEXT_PUBLIC_MAP_PROVIDER
+ARG NEXT_PUBLIC_YANDEX_MAPS_API_KEY
+ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
+ENV NEXT_PUBLIC_MAP_PROVIDER=$NEXT_PUBLIC_MAP_PROVIDER
+ENV NEXT_PUBLIC_YANDEX_MAPS_API_KEY=$NEXT_PUBLIC_YANDEX_MAPS_API_KEY
+
+WORKDIR /app
+COPY package.json package-lock.json* ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+FROM node:20-alpine AS runner
+WORKDIR /app
+ENV NODE_ENV=production
+
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/.next/standalone ./
+COPY --from=builder /app/.next/static ./.next/static
+
+EXPOSE 3000
+CMD ["node", "server.js"]

+ 12 - 0
frontend/next.config.js

@@ -0,0 +1,12 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+  output: 'standalone',
+  images: {
+    remotePatterns: [
+      { protocol: 'https', hostname: 'cdn.photoplaces.ru' },
+      { protocol: 'http', hostname: 'localhost', port: '9000' },
+    ],
+  },
+}
+
+module.exports = nextConfig

+ 30 - 0
frontend/package.json

@@ -0,0 +1,30 @@
+{
+  "name": "photoplaces-frontend",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "dev": "next dev",
+    "build": "next build",
+    "start": "next start",
+    "lint": "next lint",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "next": "^14.1.0",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  },
+  "devDependencies": {
+    "@types/node": "^20.11.0",
+    "@types/react": "^18.2.0",
+    "@types/react-dom": "^18.2.0",
+    "typescript": "^5.3.0",
+    "eslint": "^8.56.0",
+    "eslint-config-next": "^14.1.0",
+    "tailwindcss": "^3.4.0",
+    "autoprefixer": "^10.4.0",
+    "postcss": "^8.4.0",
+    "prettier": "^3.2.0",
+    "prettier-plugin-tailwindcss": "^0.5.0"
+  }
+}

+ 6 - 0
frontend/postcss.config.js

@@ -0,0 +1,6 @@
+module.exports = {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+}

+ 41 - 0
frontend/src/app/admin/layout.tsx

@@ -0,0 +1,41 @@
+'use client'
+
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+
+export default function AdminLayout({ children }: { children: React.ReactNode }) {
+  const pathname = usePathname()
+
+  const navItems = [
+    { href: '/admin', label: 'Модерация', icon: '📋' },
+    { href: '/admin/users', label: 'Пользователи', icon: '👥' },
+    { href: '/admin/tags', label: 'Теги', icon: '🏷️' },
+  ]
+
+  return (
+    <div className="flex min-h-screen bg-[#0f172a] pt-16">
+      <aside className="w-56 border-r border-white/10 p-4">
+        <nav className="space-y-1">
+          {navItems.map((item) => (
+            <Link
+              key={item.href}
+              href={item.href}
+              className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition ${
+                pathname === item.href
+                  ? 'bg-green-600/20 text-green-400'
+                  : 'text-white/60 hover:bg-white/5 hover:text-white'
+              }`}
+            >
+              <span>{item.icon}</span>
+              {item.label}
+            </Link>
+          ))}
+        </nav>
+      </aside>
+
+      <main className="flex-1 overflow-y-auto p-6">
+        {children}
+      </main>
+    </div>
+  )
+}

+ 84 - 0
frontend/src/app/admin/page.tsx

@@ -0,0 +1,84 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import { api, ApiRequestError } from '@/lib/api'
+import type { Place } from '@/types'
+
+export default function AdminModerationPage() {
+  const [places, setPlaces] = useState<Place[]>([])
+  const [loading, setLoading] = useState(true)
+  const [actionMsg, setActionMsg] = useState('')
+
+  const fetchPending = useCallback(async () => {
+    try {
+      const res = await api.get<{ data: Place[] }>('/places?status=pending_moderation&limit=50')
+      setPlaces(res.data)
+    } catch (err) {
+      if (err instanceof ApiRequestError && err.status === 403) {
+        setActionMsg('Нет прав доступа')
+      }
+    } finally {
+      setLoading(false)
+    }
+  }, [])
+
+  useEffect(() => { fetchPending() }, [fetchPending])
+
+  const moderate = async (id: string, action: 'approve' | 'reject') => {
+    try {
+      await api.post(`/places/${id}/moderate`, { action, comment: '' })
+      setPlaces((prev) => prev.filter((p) => p.id !== id))
+      setActionMsg(action === 'approve' ? 'Одобрено' : 'Отклонено')
+    } catch (err: any) {
+      setActionMsg(err.message || 'Ошибка')
+    }
+  }
+
+  if (loading) return <p className="text-white/60">Загрузка...</p>
+
+  return (
+    <div>
+      <h1 className="mb-6 text-2xl font-bold text-white">Очередь модерации</h1>
+
+      {actionMsg && (
+        <p className="mb-4 rounded-lg bg-green-500/10 px-4 py-2 text-sm text-green-400">{actionMsg}</p>
+      )}
+
+      {places.length === 0 ? (
+        <p className="text-white/40">Нет мест на модерации</p>
+      ) : (
+        <div className="space-y-4">
+          {places.map((place) => (
+            <div key={place.id} className="rounded-lg border border-white/10 bg-white/5 p-4">
+              <div className="mb-2 flex items-center gap-3">
+                <span className={`rounded-full px-2 py-0.5 text-xs ${
+                  place.type === 'studio' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
+                }`}>
+                  {place.type === 'studio' ? 'Студия' : 'Место'}
+                </span>
+                <span className="text-xs text-white/40">ID: {place.id.slice(0, 8)}</span>
+              </div>
+              <h3 className="mb-1 text-lg font-medium text-white">{place.title}</h3>
+              {place.address && <p className="mb-2 text-sm text-white/40">{place.address}</p>}
+              {place.description && <p className="mb-3 text-sm text-white/60">{place.description}</p>}
+              <div className="flex items-center gap-2">
+                <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>
+                <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>
+              </div>
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  )
+}

+ 103 - 0
frontend/src/app/admin/tags/page.tsx

@@ -0,0 +1,103 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { api } from '@/lib/api'
+import type { Tag, Feature } from '@/types'
+
+export default function AdminTagsPage() {
+  const [tags, setTags] = useState<Tag[]>([])
+  const [features, setFeatures] = useState<Feature[]>([])
+  const [loading, setLoading] = useState(true)
+
+  const [newTagId, setNewTagId] = useState('')
+  const [newTagName, setNewTagName] = useState('')
+  const [newFeatureId, setNewFeatureId] = useState('')
+  const [newFeatureName, setNewFeatureName] = useState('')
+
+  const fetchData = async () => {
+    try {
+      const [t, f] = await Promise.all([
+        api.get<Tag[]>('/tags'),
+        api.get<Feature[]>('/features'),
+      ])
+      setTags(t as Tag[])
+      setFeatures(f as Feature[])
+    } catch {} finally { setLoading(false) }
+  }
+
+  useEffect(() => { fetchData() }, [])
+
+  const addTag = async () => {
+    if (!newTagId || !newTagName) return
+    try {
+      await api.post('/admin/tags', { id: newTagId, name: newTagName })
+      setNewTagId(''); setNewTagName('')
+      fetchData()
+    } catch {}
+  }
+
+  const addFeature = async () => {
+    if (!newFeatureId || !newFeatureName) return
+    try {
+      await api.post('/admin/features', { id: newFeatureId, name: newFeatureName })
+      setNewFeatureId(''); setNewFeatureName('')
+      fetchData()
+    } catch {}
+  }
+
+  if (loading) return <p className="text-white/60">Загрузка...</p>
+
+  return (
+    <div className="grid grid-cols-2 gap-8">
+      <div>
+        <h1 className="mb-6 text-2xl font-bold text-white">Теги</h1>
+        <div className="mb-4 flex gap-2">
+          <input value={newTagId} onChange={(e) => setNewTagId(e.target.value)}
+            placeholder="ID (напр. portrait)"
+            className="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none"
+          />
+          <input value={newTagName} onChange={(e) => setNewTagName(e.target.value)}
+            placeholder="Название (напр. Портрет)"
+            className="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none"
+          />
+          <button onClick={addTag} className="rounded-lg bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-500">
+            +
+          </button>
+        </div>
+        <div className="space-y-1">
+          {tags.map((tag) => (
+            <div key={tag.id} className="flex items-center justify-between rounded-lg bg-white/5 px-3 py-2">
+              <span className="text-sm text-white">{tag.name}</span>
+              <span className="text-xs text-white/40">{tag.id}</span>
+            </div>
+          ))}
+        </div>
+      </div>
+
+      <div>
+        <h1 className="mb-6 text-2xl font-bold text-white">Характеристики</h1>
+        <div className="mb-4 flex gap-2">
+          <input value={newFeatureId} onChange={(e) => setNewFeatureId(e.target.value)}
+            placeholder="ID (напр. parking)"
+            className="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none"
+          />
+          <input value={newFeatureName} onChange={(e) => setNewFeatureName(e.target.value)}
+            placeholder="Название (напр. Парковка)"
+            className="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none"
+          />
+          <button onClick={addFeature} className="rounded-lg bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-500">
+            +
+          </button>
+        </div>
+        <div className="space-y-1">
+          {features.map((feat) => (
+            <div key={feat.id} className="flex items-center justify-between rounded-lg bg-white/5 px-3 py-2">
+              <span className="text-sm text-white">{feat.name}</span>
+              <span className="text-xs text-white/40">{feat.id}</span>
+            </div>
+          ))}
+        </div>
+      </div>
+    </div>
+  )
+}

+ 113 - 0
frontend/src/app/admin/users/page.tsx

@@ -0,0 +1,113 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import { api } from '@/lib/api'
+import type { User } from '@/types'
+
+const roleLabels: Record<string, string> = {
+  superadmin: 'Суперадмин',
+  moderator: 'Модератор',
+  landlord: 'Арендодатель',
+  executor: 'Исполнитель',
+  customer: 'Заказчик',
+}
+
+const statusLabels: Record<string, string> = {
+  active: 'Активен',
+  banned: 'Заблокирован',
+  pending_verification: 'Ожидает проверки',
+}
+
+export default function AdminUsersPage() {
+  const [users, setUsers] = useState<User[]>([])
+  const [loading, setLoading] = useState(true)
+  const [roleFilter, setRoleFilter] = useState('')
+  const [msg, setMsg] = useState('')
+
+  const fetchUsers = useCallback(async () => {
+    const params = new URLSearchParams()
+    if (roleFilter) params.set('role', roleFilter)
+    params.set('limit', '100')
+    try {
+      const res = await api.get<{ data: User[] }>(`/admin/users?${params}`)
+      setUsers(res.data)
+    } catch {
+      setMsg('Ошибка загрузки')
+    } finally {
+      setLoading(false)
+    }
+  }, [roleFilter])
+
+  useEffect(() => { fetchUsers() }, [fetchUsers])
+
+  const updateUser = async (id: string, data: Record<string, string>) => {
+    try {
+      await api.patch(`/admin/users/${id}`, data)
+      setMsg('Обновлено')
+      fetchUsers()
+    } catch (err: any) {
+      setMsg(err.message || 'Ошибка')
+    }
+  }
+
+  if (loading) return <p className="text-white/60">Загрузка...</p>
+
+  return (
+    <div>
+      <h1 className="mb-6 text-2xl font-bold text-white">Управление пользователями</h1>
+      {msg && <p className="mb-4 rounded-lg bg-blue-500/10 px-4 py-2 text-sm text-blue-400">{msg}</p>}
+
+      <div className="mb-4 flex flex-wrap gap-2">
+        {['', 'customer', 'landlord', 'executor', 'moderator'].map((r) => (
+          <button key={r} onClick={() => setRoleFilter(r)}
+            className={`rounded-lg px-3 py-1.5 text-sm transition ${
+              roleFilter === r ? 'bg-green-600 text-white' : 'bg-white/10 text-white/60 hover:bg-white/20'
+            }`}
+          >
+            {r ? roleLabels[r] : 'Все'}
+          </button>
+        ))}
+      </div>
+
+      <div className="space-y-2">
+        {users.map((user) => (
+          <div key={user.id} className="flex items-center justify-between rounded-lg border border-white/10 bg-white/5 p-4">
+            <div className="flex-1">
+              <div className="flex items-center gap-2">
+                <span className="font-medium text-white">{user.name || user.email}</span>
+                <span className={`rounded-full px-2 py-0.5 text-xs ${
+                  user.status === 'active' ? 'bg-green-500/20 text-green-400' :
+                  user.status === 'banned' ? 'bg-red-500/20 text-red-400' :
+                  'bg-yellow-500/20 text-yellow-400'
+                }`}>
+                  {statusLabels[user.status]}
+                </span>
+              </div>
+              <p className="text-sm text-white/40">{user.email}</p>
+            </div>
+            <div className="flex items-center gap-2">
+              <select value={user.role}
+                onChange={(e) => updateUser(user.id, { role: e.target.value })}
+                className="rounded-lg border border-white/10 bg-white/5 px-2 py-1.5 text-sm text-white outline-none"
+              >
+                {Object.entries(roleLabels).map(([k, v]) => (
+                  <option key={k} value={k} className="bg-[#1e293b]">{v}</option>
+                ))}
+              </select>
+              <button onClick={() => updateUser(user.id, {
+                status: user.status === 'banned' ? 'active' : 'banned'
+              })}
+                className={`rounded-lg px-3 py-1.5 text-sm transition ${
+                  user.status === 'banned' ? 'bg-green-600/80 text-white hover:bg-green-500' :
+                  'bg-red-600/80 text-white hover:bg-red-500'
+                }`}
+              >
+                {user.status === 'banned' ? 'Разблокировать' : 'Заблокировать'}
+              </button>
+            </div>
+          </div>
+        ))}
+      </div>
+    </div>
+  )
+}

+ 66 - 0
frontend/src/app/auth/login/page.tsx

@@ -0,0 +1,66 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useAuth } from '@/hooks/useAuth'
+import Link from 'next/link'
+
+export default function LoginPage() {
+  const router = useRouter()
+  const { login } = useAuth()
+  const [email, setEmail] = useState('')
+  const [password, setPassword] = useState('')
+  const [error, setError] = useState('')
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    try {
+      await login(email, password)
+      router.push('/')
+    } catch (err: any) {
+      setError(err.message || 'Login failed')
+    }
+  }
+
+  return (
+    <div className="flex min-h-screen items-center justify-center bg-[#0f172a] p-4">
+      <form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
+        <h1 className="text-center text-2xl font-bold text-white">Вход</h1>
+
+        {error && <p className="text-sm text-red-400">{error}</p>}
+
+        <input
+          type="email"
+          placeholder="Email"
+          value={email}
+          onChange={(e) => setEmail(e.target.value)}
+          className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/40 outline-none focus:border-green-500"
+          required
+        />
+
+        <input
+          type="password"
+          placeholder="Пароль"
+          value={password}
+          onChange={(e) => setPassword(e.target.value)}
+          className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/40 outline-none focus:border-green-500"
+          required
+        />
+
+        <button
+          type="submit"
+          className="w-full rounded-lg bg-green-600 px-4 py-3 font-medium text-white hover:bg-green-500 transition"
+        >
+          Войти
+        </button>
+
+        <p className="text-center text-sm text-white/40">
+          Нет аккаунта?{' '}
+          <Link href="/auth/register" className="text-green-400 hover:text-green-300">
+            Зарегистрироваться
+          </Link>
+        </p>
+      </form>
+    </div>
+  )
+}

+ 78 - 0
frontend/src/app/auth/register/page.tsx

@@ -0,0 +1,78 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useAuth } from '@/hooks/useAuth'
+import Link from 'next/link'
+
+export default function RegisterPage() {
+  const router = useRouter()
+  const { register } = useAuth()
+  const [email, setEmail] = useState('')
+  const [password, setPassword] = useState('')
+  const [role, setRole] = useState('customer')
+  const [error, setError] = useState('')
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    try {
+      await register(email, password, role)
+      router.push('/')
+    } catch (err: any) {
+      setError(err.message || 'Registration failed')
+    }
+  }
+
+  return (
+    <div className="flex min-h-screen items-center justify-center bg-[#0f172a] p-4">
+      <form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
+        <h1 className="text-center text-2xl font-bold text-white">Регистрация</h1>
+
+        {error && <p className="text-sm text-red-400">{error}</p>}
+
+        <input
+          type="email"
+          placeholder="Email"
+          value={email}
+          onChange={(e) => setEmail(e.target.value)}
+          className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/40 outline-none focus:border-green-500"
+          required
+        />
+
+        <input
+          type="password"
+          placeholder="Пароль"
+          value={password}
+          onChange={(e) => setPassword(e.target.value)}
+          className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/40 outline-none focus:border-green-500"
+          required
+          minLength={6}
+        />
+
+        <select
+          value={role}
+          onChange={(e) => setRole(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"
+        >
+          <option value="customer">Заказчик (ищу места)</option>
+          <option value="landlord">Арендодатель (сдаю студию)</option>
+          <option value="executor">Исполнитель (предлагаю услуги)</option>
+        </select>
+
+        <button
+          type="submit"
+          className="w-full rounded-lg bg-green-600 px-4 py-3 font-medium text-white hover:bg-green-500 transition"
+        >
+          Зарегистрироваться
+        </button>
+
+        <p className="text-center text-sm text-white/40">
+          Уже есть аккаунт?{' '}
+          <Link href="/auth/login" className="text-green-400 hover:text-green-300">
+            Войти
+          </Link>
+        </p>
+      </form>
+    </div>
+  )
+}

+ 22 - 0
frontend/src/app/layout.tsx

@@ -0,0 +1,22 @@
+import type { Metadata } from 'next'
+import '@/styles/globals.css'
+import { AuthProvider } from '@/hooks/useAuth'
+import Header from '@/components/Header'
+
+export const metadata: Metadata = {
+  title: 'PhotoPlaces — карта мест для фотосъёмок',
+  description: 'Находите красивые места для фотосессий, бронируйте студии и нанимайте фотографов',
+}
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+  return (
+    <html lang="ru">
+      <body className="h-screen overflow-hidden bg-[#0f172a]">
+        <AuthProvider>
+          <Header />
+          {children}
+        </AuthProvider>
+      </body>
+    </html>
+  )
+}

+ 9 - 0
frontend/src/app/page.tsx

@@ -0,0 +1,9 @@
+import MapView from '@/components/MapView'
+
+export default function HomePage() {
+  return (
+    <main className="relative h-screen w-screen overflow-hidden">
+      <MapView />
+    </main>
+  )
+}

+ 11 - 0
frontend/src/app/places/add/page.tsx

@@ -0,0 +1,11 @@
+'use client'
+
+import PlaceForm from '@/components/PlaceForm'
+
+export default function AddPlacePage() {
+  return (
+    <div className="min-h-screen bg-[#0f172a] pt-16">
+      <PlaceForm type="place" />
+    </div>
+  )
+}

+ 162 - 0
frontend/src/app/services/add/page.tsx

@@ -0,0 +1,162 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { useRouter } from 'next/navigation'
+import { api } from '@/lib/api'
+import type { Tag } from '@/types'
+
+export default function AddServicePage() {
+  const router = useRouter()
+  const [tags, setTags] = useState<Tag[]>([])
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState('')
+
+  const [title, setTitle] = useState('')
+  const [description, setDescription] = useState('')
+  const [price, setPrice] = useState('')
+  const [duration, setDuration] = useState('')
+  const [selectedTags, setSelectedTags] = useState<string[]>([])
+  const [portfolioFiles, setPortfolioFiles] = useState<File[]>([])
+
+  useEffect(() => {
+    api.get<Tag[]>('/tags').then(setTags).catch(() => {})
+  }, [])
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    setLoading(true)
+    setError('')
+
+    try {
+      const portfolioImages: string[] = []
+
+      for (const file of portfolioFiles) {
+        const presigned = await api.post<{ upload_url: string; file_url: string; fields: Record<string, string> }>(
+          '/upload/presigned-url',
+          { content_type: file.type, max_size_mb: 10 }
+        )
+        const formData = new FormData()
+        Object.entries(presigned.fields).forEach(([k, v]) => formData.append(k, v))
+        formData.append('file', file)
+        await fetch(presigned.upload_url, { method: 'POST', body: formData })
+        portfolioImages.push(presigned.file_url)
+      }
+
+      await api.post('/services', {
+        title,
+        description: description || undefined,
+        price: parseInt(price),
+        currency: 'RUB',
+        duration_minutes: duration ? parseInt(duration) : undefined,
+        tags: selectedTags,
+        portfolio_images: portfolioImages,
+      })
+
+      router.push('/')
+    } catch (err: any) {
+      setError(err.message || 'Ошибка при создании')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const toggleTag = (id: string) => {
+    setSelectedTags((prev) =>
+      prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]
+    )
+  }
+
+  return (
+    <div className="min-h-screen bg-[#0f172a] pt-16">
+      <form onSubmit={handleSubmit} className="mx-auto max-w-2xl space-y-6 p-6">
+        <h1 className="text-2xl font-bold text-white">Добавить услугу</h1>
+
+        {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={4}
+            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-2 gap-4">
+          <div>
+            <label className="mb-1 block text-sm text-white/60">Цена (₽) *</label>
+            <input
+              type="number"
+              value={price}
+              onChange={(e) => setPrice(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>
+            <input
+              type="number"
+              value={duration}
+              onChange={(e) => setDuration(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-1 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-1 block text-sm text-white/60">Фото портфолио</label>
+          <input
+            type="file"
+            multiple
+            accept="image/*"
+            onChange={(e) => setPortfolioFiles(Array.from(e.target.files || []))}
+            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"
+          />
+          {portfolioFiles.length > 0 && (
+            <p className="mt-1 text-xs text-white/40">{portfolioFiles.length} файлов</p>
+          )}
+        </div>
+
+        <button
+          type="submit"
+          disabled={loading}
+          className="w-full rounded-lg bg-green-600 px-4 py-3 font-medium text-white hover:bg-green-500 transition disabled:opacity-50"
+        >
+          {loading ? 'Сохранение...' : 'Создать услугу'}
+        </button>
+      </form>
+    </div>
+  )
+}

+ 11 - 0
frontend/src/app/studios/add/page.tsx

@@ -0,0 +1,11 @@
+'use client'
+
+import PlaceForm from '@/components/PlaceForm'
+
+export default function AddStudioPage() {
+  return (
+    <div className="min-h-screen bg-[#0f172a] pt-16">
+      <PlaceForm type="studio" />
+    </div>
+  )
+}

+ 45 - 0
frontend/src/components/Header.tsx

@@ -0,0 +1,45 @@
+'use client'
+
+import Link from 'next/link'
+import { useAuth } from '@/hooks/useAuth'
+
+export default function Header() {
+  const { user, isLoading, logout } = useAuth()
+
+  return (
+    <header className="absolute left-0 right-0 top-0 z-30 flex items-center justify-between px-4 py-3">
+      <Link href="/" className="text-lg font-bold text-white">
+        PhotoPlaces
+      </Link>
+
+      <nav className="flex items-center gap-4">
+        {isLoading ? null : user ? (
+          <>
+            <span className="text-sm text-white/60">{user.email}</span>
+            <button
+              onClick={logout}
+              className="rounded-lg bg-white/10 px-4 py-2 text-sm text-white hover:bg-white/20 transition"
+            >
+              Выйти
+            </button>
+          </>
+        ) : (
+          <>
+            <Link
+              href="/auth/login"
+              className="rounded-lg bg-white/10 px-4 py-2 text-sm text-white hover:bg-white/20 transition"
+            >
+              Войти
+            </Link>
+            <Link
+              href="/auth/register"
+              className="rounded-lg bg-green-600 px-4 py-2 text-sm text-white hover:bg-green-500 transition"
+            >
+              Регистрация
+            </Link>
+          </>
+        )}
+      </nav>
+    </header>
+  )
+}

+ 140 - 0
frontend/src/components/MapView.tsx

@@ -0,0 +1,140 @@
+'use client'
+
+import { useEffect, useRef, useState, useCallback } from 'react'
+import { createYandexMapProvider, type MapProvider, type MapBounds } from '@/lib/map'
+import { useAuth } from '@/hooks/useAuth'
+import type { Place } from '@/types'
+import { api } from '@/lib/api'
+
+export default function MapView() {
+  const containerRef = useRef<HTMLDivElement>(null)
+  const mapRef = useRef<MapProvider | null>(null)
+  const { user, isLoading } = useAuth()
+  const [places, setPlaces] = useState<Place[]>([])
+  const [selectedPlace, setSelectedPlace] = useState<Place | null>(null)
+
+  const fetchPlaces = useCallback(async (bounds?: MapBounds) => {
+    if (!user) return
+
+    try {
+      const params = new URLSearchParams()
+      if (bounds) {
+        params.set('bounds', `${bounds.swLat},${bounds.swLng},${bounds.neLat},${bounds.neLng}`)
+      }
+      params.set('limit', '50')
+
+      const res = await api.get<{ data: Place[] }>(`/places?${params}`)
+      setPlaces(res.data)
+    } catch (err) {
+      console.error('fetch places:', err)
+    }
+  }, [user])
+
+  useEffect(() => {
+    if (!containerRef.current || mapRef.current) return
+
+    const provider = createYandexMapProvider()
+    provider.init(containerRef.current, [55.7558, 37.6173], 10)
+    mapRef.current = provider
+
+    if (navigator.geolocation) {
+      navigator.geolocation.getCurrentPosition(
+        (pos) => provider.setCenter(pos.coords.latitude, pos.coords.longitude),
+        () => {},
+      )
+    }
+
+    provider.onMove((center, zoom, bounds) => {
+      fetchPlaces(bounds)
+    })
+
+    return () => {
+      provider.destroy()
+      mapRef.current = null
+    }
+  }, [fetchPlaces])
+
+  useEffect(() => {
+    if (!mapRef.current) return
+
+    const provider = mapRef.current
+
+    places.forEach((place) => {
+      provider.addMarker(place.id, place.lat, place.lng, {
+        type: place.type as 'place' | 'studio',
+        title: place.title,
+        onClick: () => setSelectedPlace(place),
+      })
+    })
+
+    return () => {
+      places.forEach((place) => provider.removeMarker(place.id))
+    }
+  }, [places])
+
+  const handleAddVisitorDot = useCallback(() => {
+    if (!mapRef.current || !navigator.geolocation) return
+    navigator.geolocation.getCurrentPosition((pos) => {
+      mapRef.current?.addMarker('visitor-me', pos.coords.latitude, pos.coords.longitude, {
+        type: 'visitor',
+      })
+    })
+  }, [])
+
+  useEffect(() => {
+    if (!user) {
+      handleAddVisitorDot()
+      const interval = setInterval(handleAddVisitorDot, 60000)
+      return () => clearInterval(interval)
+    }
+  }, [user, handleAddVisitorDot])
+
+  return (
+    <div className="relative h-full w-full">
+      <div ref={containerRef} className="h-full w-full" />
+
+      {!user && !isLoading && (
+        <div className="absolute left-1/2 top-4 z-10 -translate-x-1/2 rounded-lg bg-white/10 px-6 py-3 text-center text-white backdrop-blur-md">
+          <p className="text-sm">Зарегистрируйтесь, чтобы исследовать места для фотосъёмок</p>
+        </div>
+      )}
+
+      {selectedPlace && (
+        <PlaceCardModal
+          place={selectedPlace}
+          onClose={() => setSelectedPlace(null)}
+        />
+      )}
+    </div>
+  )
+}
+
+function PlaceCardModal({ place, onClose }: { place: Place; onClose: () => void }) {
+  return (
+    <div className="absolute bottom-0 left-0 right-0 z-20 max-h-[60vh] overflow-y-auto rounded-t-2xl bg-[#1e293b] p-6 shadow-xl">
+      <button onClick={onClose} className="mb-4 text-white/60 hover:text-white">
+        ✕
+      </button>
+      <h2 className="mb-2 text-xl font-bold text-white">{place.title}</h2>
+      {place.address && (
+        <p className="mb-2 text-sm text-white/60">{place.address}</p>
+      )}
+      {place.description && (
+        <p className="mb-4 text-sm text-white/80">{place.description}</p>
+      )}
+      <div className="mb-4 flex flex-wrap gap-2">
+        {place.tags?.map((tag) => (
+          <span key={tag.id} className="rounded-full bg-white/10 px-3 py-1 text-xs text-white/80">
+            {tag.name}
+          </span>
+        ))}
+      </div>
+      <div className="flex items-center gap-4 text-sm text-white/60">
+        <span>★ {place.rating?.toFixed(1) || 'Нет оценок'}</span>
+        {place.type === 'studio' && place.hourly_rate && (
+          <span>{place.hourly_rate} ₽/час</span>
+        )}
+      </div>
+    </div>
+  )
+}

+ 262 - 0
frontend/src/components/PlaceForm.tsx

@@ -0,0 +1,262 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { useRouter } from 'next/navigation'
+import { api } from '@/lib/api'
+import type { Tag, Feature } from '@/types'
+
+interface PlaceFormProps {
+  type: 'place' | 'studio'
+}
+
+export default function PlaceForm({ type }: PlaceFormProps) {
+  const router = useRouter()
+  const [tags, setTags] = useState<Tag[]>([])
+  const [features, setFeatures] = useState<Feature[]>([])
+  const [loading, setLoading] = 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('55.7558')
+  const [lng, setLng] = useState('37.6173')
+  const [hourlyRate, setHourlyRate] = useState('')
+  const [minHours, setMinHours] = useState('1')
+  const [coverFile, setCoverFile] = useState<File | null>(null)
+  const [coverPreview, setCoverPreview] = useState('')
+
+  useEffect(() => {
+    api.get<Tag[]>('/tags').then(setTags).catch(() => {})
+    api.get<Feature[]>('/features').then(setFeatures).catch(() => {})
+  }, [])
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    setLoading(true)
+    setError('')
+
+    try {
+      let coverImage: string | undefined
+
+      if (coverFile) {
+        const presigned = await api.post<{ upload_url: string; file_url: string; fields: Record<string, string> }>(
+          '/upload/presigned-url',
+          { content_type: coverFile.type, max_size_mb: 10 }
+        )
+
+        const formData = new FormData()
+        Object.entries(presigned.fields).forEach(([k, v]) => formData.append(k, v))
+        formData.append('file', coverFile)
+        await fetch(presigned.upload_url, { method: 'POST', body: formData })
+
+        coverImage = presigned.file_url
+      }
+
+      const body: Record<string, unknown> = {
+        type,
+        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 (type === 'studio') {
+        body.hourly_rate = hourlyRate ? parseInt(hourlyRate) : undefined
+        body.min_hours = parseInt(minHours) || 1
+        body.currency = 'RUB'
+      }
+
+      await api.post('/places', body)
+      router.push('/')
+    } catch (err: any) {
+      setError(err.message || 'Ошибка при создании')
+    } finally {
+      setLoading(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 (
+    <form onSubmit={handleSubmit} className="mx-auto max-w-2xl space-y-6 p-6">
+      <h1 className="text-2xl font-bold text-white">
+        {type === 'studio' ? 'Добавить студию' : 'Добавить место'}
+      </h1>
+
+      {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={4}
+          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-2 gap-4">
+        <div>
+          <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-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"
+            step="any"
+            value={lng}
+            onChange={(e) => setLng(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-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)
+              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>
+
+      {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>
+
+      <button
+        type="submit"
+        disabled={loading}
+        className="w-full rounded-lg bg-green-600 px-4 py-3 font-medium text-white hover:bg-green-500 transition disabled:opacity-50"
+      >
+        {loading ? 'Сохранение...' : `Создать ${type === 'studio' ? 'студию' : 'место'}`}
+      </button>
+    </form>
+  )
+}

+ 77 - 0
frontend/src/hooks/useAuth.tsx

@@ -0,0 +1,77 @@
+'use client'
+
+import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
+import type { User } from '@/types'
+import { api, ApiRequestError } from '@/lib/api'
+
+interface AuthState {
+  user: User | null
+  isLoading: boolean
+  login: (email: string, password: string) => Promise<void>
+  register: (email: string, password: string, role: string) => Promise<void>
+  logout: () => Promise<void>
+  refreshSession: () => Promise<void>
+}
+
+const AuthContext = createContext<AuthState | null>(null)
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+  const [user, setUser] = useState<User | null>(null)
+  const [isLoading, setIsLoading] = useState(true)
+
+  const setTokens = useCallback((access: string) => {
+    localStorage.setItem('access_token', access)
+  }, [])
+
+  const clearTokens = useCallback(() => {
+    localStorage.removeItem('access_token')
+  }, [])
+
+  const refreshSession = useCallback(async () => {
+    try {
+      const res = await api.post<{ access_token: string }>('/auth/refresh')
+      setTokens(res.access_token)
+      const userRes = await api.get<User>('/auth/me')
+      setUser(userRes)
+    } catch {
+      clearTokens()
+      setUser(null)
+    }
+  }, [setTokens, clearTokens])
+
+  useEffect(() => {
+    refreshSession().finally(() => setIsLoading(false))
+  }, [refreshSession])
+
+  const login = useCallback(async (email: string, password: string) => {
+    const res = await api.post<{ user: User; access_token: string }>('/auth/login', { email, password })
+    setTokens(res.access_token)
+    setUser(res.user)
+  }, [setTokens])
+
+  const register = useCallback(async (email: string, password: string, role: string) => {
+    const res = await api.post<{ user: User; access_token: string }>('/auth/register', { email, password, role })
+    setTokens(res.access_token)
+    setUser(res.user)
+  }, [setTokens])
+
+  const logout = useCallback(async () => {
+    try {
+      await api.post('/auth/logout', {})
+    } catch {}
+    clearTokens()
+    setUser(null)
+  }, [clearTokens])
+
+  return (
+    <AuthContext.Provider value={{ user, isLoading, login, register, logout, refreshSession }}>
+      {children}
+    </AuthContext.Provider>
+  )
+}
+
+export function useAuth() {
+  const ctx = useContext(AuthContext)
+  if (!ctx) throw new Error('useAuth must be used within AuthProvider')
+  return ctx
+}

+ 54 - 0
frontend/src/lib/api.ts

@@ -0,0 +1,54 @@
+const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api/v1'
+
+async function request<T>(
+  path: string,
+  options: RequestInit = {},
+): Promise<T> {
+  const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null
+
+  const headers: HeadersInit = {
+    'Content-Type': 'application/json',
+    ...(token ? { Authorization: `Bearer ${token}` } : {}),
+    ...options.headers,
+  }
+
+  const res = await fetch(`${API_URL}${path}`, {
+    ...options,
+    headers,
+    credentials: 'include',
+  })
+
+  if (!res.ok) {
+    const error = await res.json().catch(() => ({ detail: res.statusText }))
+    throw new ApiRequestError(res.status, error.detail || 'Unknown error', error)
+  }
+
+  return res.json()
+}
+
+export class ApiRequestError extends Error {
+  constructor(
+    public status: number,
+    message: string,
+    public data?: unknown,
+  ) {
+    super(message)
+    this.name = 'ApiRequestError'
+  }
+}
+
+export const api = {
+  get: <T>(path: string, signal?: AbortSignal) =>
+    request<T>(path, { method: 'GET', signal }),
+
+  post: <T>(path: string, body: unknown) =>
+    request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
+
+  patch: <T>(path: string, body: unknown) =>
+    request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
+
+  delete: <T>(path: string) =>
+    request<T>(path, { method: 'DELETE' }),
+}
+
+export default api

+ 165 - 0
frontend/src/lib/map.ts

@@ -0,0 +1,165 @@
+'use client'
+
+import { useEffect, useRef, useCallback } from 'react'
+
+declare global {
+  interface Window {
+    ymaps3?: typeof import('@yandex/ymaps3-types')
+  }
+}
+
+export interface MapProvider {
+  init(container: HTMLElement, center: [number, number], zoom: number): void
+  destroy(): void
+  addMarker(id: string, lat: number, lng: number, options?: MarkerOptions): void
+  removeMarker(id: string): void
+  setCenter(lat: number, lng: number): void
+  fitBounds(bounds: [[number, number], [number, number]]): void
+  onMove(cb: (center: [number, number], zoom: number, bounds: MapBounds) => void): void
+}
+
+export interface MarkerOptions {
+  type?: 'place' | 'studio' | 'visitor'
+  title?: string
+  onClick?: () => void
+}
+
+export interface MapBounds {
+  swLat: number
+  swLng: number
+  neLat: number
+  neLng: number
+}
+
+export function createYandexMapProvider(): MapProvider {
+  let map: any = null
+  let ymaps3: any = null
+  let markers = new Map<string, any>()
+  let moveHandler: ((center: [number, number], zoom: number, bounds: MapBounds) => void) | null = null
+
+  return {
+    async init(container, center, zoom) {
+      await loadYandexScript()
+
+      ymaps3 = await (window as any).ymaps3.ready
+      await ymaps3.import('@yandex/ymaps3-markers@0.0.1')
+
+      map = new ymaps3.YMap(container, {
+        location: { center, zoom },
+        mode: 'vector',
+        theme: 'dark',
+      })
+
+      map.addChild(new ymaps3.YMapDefaultSchemeLayer({
+        customization: [
+          { tags: { any: ['poi', 'road', 'landscape'] }, elements: 'geometry', stylers: [{ saturation: -100 }] }
+        ]
+      }))
+
+      map.addChild(new ymaps3.YMapDefaultFeaturesLayer())
+
+      const updateBounds = () => {
+        if (!moveHandler) return
+        const loc = map.location
+        const bounds = map.getBounds()
+        moveHandler(
+          loc.center as [number, number],
+          loc.zoom,
+          {
+            swLat: bounds[0][1],
+            swLng: bounds[0][0],
+            neLat: bounds[1][1],
+            neLng: bounds[1][0],
+          }
+        )
+      }
+
+      map.updateHandler = ymaps3.YMapFeatureListener({
+        onUpdate: updateBounds,
+      })
+
+      updateBounds()
+    },
+
+    destroy() {
+      markers.clear()
+      map?.destroy()
+    },
+
+    addMarker(id, lat, lng, options) {
+      if (!ymaps3) return
+
+      const color = options?.type === 'visitor' ? '#22c55e'
+        : options?.type === 'studio' ? '#a855f7'
+        : '#3b82f6'
+
+      const marker = new ymaps3.YMapMarker({
+        coordinates: [lng, lat],
+        onClick: options?.onClick,
+        source: 'my',
+        zIndex: options?.type === 'visitor' ? 10 : 100,
+      })
+
+      const el = document.createElement('div')
+      el.className = `map-marker ${options?.type || 'place'}`
+      el.style.cssText = `
+        width: ${options?.type === 'visitor' ? '12px' : '32px'};
+        height: ${options?.type === 'visitor' ? '12px' : '32px'};
+        background: ${color};
+        border-radius: 50%;
+        border: ${options?.type === 'visitor' ? '2px' : '3px'} solid white;
+        box-shadow: 0 2px 8px rgba(0,0,0,0.3);
+        cursor: ${options?.type === 'visitor' ? 'default' : 'pointer'};
+        transform: translate(-50%, -50%);
+        transition: transform 0.2s;
+      `
+
+      el.addEventListener('click', (e) => {
+        e.stopPropagation()
+        options?.onClick?.()
+      })
+
+      marker.element = el
+      marker.addChild(new ymaps3.YMapMarkerContent({ element: el }))
+
+      map.addChild(marker)
+      markers.set(id, marker)
+    },
+
+    removeMarker(id) {
+      const marker = markers.get(id)
+      if (marker) {
+        map?.removeChild(marker)
+        markers.delete(id)
+      }
+    },
+
+    setCenter(lat, lng) {
+      map?.setLocation({ center: [lng, lat] })
+    },
+
+    fitBounds(bounds) {
+      map?.setLocation({
+        bounds: [[bounds[0][0], bounds[0][1]], [bounds[1][0], bounds[1][1]]]
+      })
+    },
+
+    onMove(cb) {
+      moveHandler = cb
+    },
+  }
+}
+
+async function loadYandexScript(): Promise<void> {
+  if (document.querySelector('script[src*="yandexmaps"]')) return
+
+  const apiKey = process.env.NEXT_PUBLIC_YANDEX_MAPS_API_KEY
+  return new Promise((resolve, reject) => {
+    const script = document.createElement('script')
+    script.src = `https://api-maps.yandex.ru/v3/?apikey=${apiKey}&lang=ru_RU`
+    script.async = true
+    script.onload = () => resolve()
+    script.onerror = () => reject(new Error('Failed to load Yandex Maps'))
+    document.head.appendChild(script)
+  })
+}

+ 30 - 0
frontend/src/styles/globals.css

@@ -0,0 +1,30 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+  --color-primary: #22c55e;
+  --color-primary-dark: #16a34a;
+  --color-bg: #0f172a;
+  --color-surface: #1e293b;
+  --color-text: #f1f5f9;
+  --color-text-muted: #94a3b8;
+  --color-border: #334155;
+}
+
+* {
+  box-sizing: border-box;
+  margin: 0;
+  padding: 0;
+}
+
+html, body {
+  height: 100%;
+  overflow: hidden;
+}
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+  background: var(--color-bg);
+  color: var(--color-text);
+}

+ 124 - 0
frontend/src/types/index.ts

@@ -0,0 +1,124 @@
+export type UserRole = 'superadmin' | 'moderator' | 'landlord' | 'executor' | 'customer'
+
+export type UserStatus = 'active' | 'banned' | 'pending_verification'
+
+export interface User {
+  id: string
+  email: string
+  role: UserRole
+  status: UserStatus
+  name?: string
+  avatar_url?: string
+  phone?: string
+  bio?: string
+  country?: string
+  created_at: string
+}
+
+export type PlaceType = 'place' | 'studio'
+
+export type PlaceStatus = 'draft' | 'pending_moderation' | 'published' | 'rejected' | 'archived'
+
+export interface Coordinates {
+  lat: number
+  lng: number
+}
+
+export interface Place {
+  id: string
+  type: PlaceType
+  title: string
+  description?: string
+  address?: string
+  coordinates: Coordinates
+  cover_image?: string
+  images: string[]
+  access_info?: string
+  rating: number
+  reviews_count: number
+  tags: Tag[]
+  features: Feature[]
+  status: PlaceStatus
+  owner: Pick<User, 'id' | 'name' | 'avatar_url'>
+  pricing?: {
+    hourly_rate: number
+    currency: string
+    min_hours: number
+  }
+  booking_url?: string
+  executor_services?: ExecutorService[]
+  created_at: string
+  updated_at?: string
+}
+
+export interface ExecutorService {
+  id: string
+  title: string
+  price: number
+  currency: string
+  duration_minutes?: number
+  executor_name: string
+}
+
+export interface Tag {
+  id: string
+  name: string
+  category?: string
+}
+
+export interface Feature {
+  id: string
+  name: string
+  category?: string
+  icon?: string
+}
+
+export interface Service {
+  id: string
+  executor_id: string
+  title: string
+  description?: string
+  price: number
+  currency: string
+  duration_minutes?: number
+  tags: Tag[]
+  rating: number
+  reviews_count: number
+  portfolio_images: string[]
+  status: 'draft' | 'published' | 'archived'
+  created_at: string
+}
+
+export interface Review {
+  id: string
+  user: Pick<User, 'id' | 'name' | 'avatar_url'>
+  rating: number
+  text?: string
+  created_at: string
+}
+
+export interface Booking {
+  id: string
+  place_id: string
+  start_time: string
+  end_time: string
+  status: 'pending' | 'confirmed' | 'cancelled' | 'completed'
+  total_price?: number
+  currency: string
+  comment?: string
+}
+
+export interface PaginatedResponse<T> {
+  data: T[]
+  next_cursor?: string
+  has_more: boolean
+}
+
+export interface ApiError {
+  type: string
+  title: string
+  status: number
+  detail: string
+  instance?: string
+  errors?: { field: string; message: string }[]
+}

+ 8 - 0
frontend/tailwind.config.js

@@ -0,0 +1,8 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+  content: ['./src/**/*.{ts,tsx}'],
+  theme: {
+    extend: {},
+  },
+  plugins: [],
+}

+ 23 - 0
frontend/tsconfig.json

@@ -0,0 +1,23 @@
+{
+  "compilerOptions": {
+    "target": "es2017",
+    "lib": ["dom", "dom.iterable", "esnext"],
+    "allowJs": true,
+    "skipLibCheck": true,
+    "strict": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "module": "esnext",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "jsx": "preserve",
+    "incremental": true,
+    "plugins": [{ "name": "next" }],
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}