отработано создание организации в профиле

This commit is contained in:
Lobstervova
2026-04-06 23:04:23 +03:00
parent 52b93038c4
commit 21d76314ae
13 changed files with 1676 additions and 729 deletions

View File

@@ -4,10 +4,8 @@ import com.example.dateplanner.models.entities.AppUser;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@ControllerAdvice

View File

@@ -1,17 +1,17 @@
package com.example.dateplanner.controllers.web;
import ch.qos.logback.core.model.Model;
import com.example.dateplanner.models.entities.AppUser;
import com.example.dateplanner.services.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.reactive.result.view.Rendering;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@@ -21,15 +21,27 @@ import java.util.Map;
@RequestMapping("/account")
public class AccountController {
private final UserService userService;
@GetMapping("/profile")
public Mono<Rendering> profile() {
Map<String, Object> model = new HashMap<>();
model.put("title", "Profile");
model.put("index", "profile");
return Mono.just(
Rendering.view("template")
.model(model)
.build()
);
return Mono.just(Rendering.view("template").model(model).build());
}
@PutMapping("/profile")
@ResponseBody
public Mono<ResponseEntity<AppUser>> updateProfile(
@AuthenticationPrincipal AppUser currentUser,
@RequestBody UpdateProfileRequest request) {
if (currentUser == null) return Mono.just(ResponseEntity.status(401).build());
currentUser.setFirstName(request.firstName());
currentUser.setLastName(request.lastName());
currentUser.setUpdatedAt(LocalDateTime.now());
return userService.save(currentUser).map(ResponseEntity::ok);
}
public record UpdateProfileRequest(String firstName, String lastName) {}
}

View File

@@ -1,16 +1,16 @@
package com.example.dateplanner.models.entities;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
import java.util.UUID;
@Data
@NoArgsConstructor
@Table(name = "organizations")
@Table("organizations")
public class Organization {
@Id
private UUID uuid;

View File

@@ -2,9 +2,10 @@ package com.example.dateplanner.repositories;
import com.example.dateplanner.models.entities.Organization;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import reactor.core.publisher.Flux;
import java.util.UUID;
public interface OrganizationRepository extends R2dbcRepository<Organization, UUID> {
Flux<Organization> findAllByOwnerUuid(UUID ownerUuid);
}

View File

@@ -1,12 +1,27 @@
package com.example.dateplanner.services;
import com.example.dateplanner.models.entities.Organization;
import com.example.dateplanner.repositories.OrganizationRepository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.UUID;
@Service
@AllArgsConstructor
public class OrganizationService {
private final OrganizationRepository organizationRepository;
public Flux<Organization> getByOwner(UUID ownerUuid) {
return organizationRepository.findAllByOwnerUuid(ownerUuid);
}
public Mono<Organization> create(Organization organization) {
organization.setCreatedAt(LocalDateTime.now());
organization.setUpdatedAt(LocalDateTime.now());
return organizationRepository.save(organization);
}
}

View File

@@ -1,5 +1,6 @@
package com.example.dateplanner.services;
import com.example.dateplanner.models.entities.AppUser;
import com.example.dateplanner.repositories.AppUserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -20,4 +21,8 @@ public class UserService implements ReactiveUserDetailsService {
log.info("username {}", username);
return userRepository.findByPhone(username).flatMap(Mono::just).cast(UserDetails.class);
}
public Mono<AppUser> save(AppUser user) {
return userRepository.save(user);
}
}

View File

@@ -1,10 +1,14 @@
/* ═══════════════════════════════════════════════════════════════
LAYOUT — сайдбар и основной контент
═══════════════════════════════════════════════════════════════ */
.sidebar {
background: white;
border-radius: 20px;
padding: 25px;
box-shadow: 0 10px 30px rgba(0,0,0,0.08);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
position: sticky;
top: 30px;
top: 80px; /* ниже fixed-header */
height: fit-content;
}
@@ -12,22 +16,45 @@
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 10px 30px rgba(0,0,0,0.08);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
min-height: 600px;
}
/* ═══════════════════════════════════════════════════════════════
SIDEBAR — аватар, имя, прогресс
═══════════════════════════════════════════════════════════════ */
/* Аватар-инициалы в сайдбаре */
.profile-sidebar-avatar {
width: 90px;
height: 90px;
border-radius: 50%;
background: linear-gradient(135deg, #e74c3c 0%, #fd79a8 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 700;
box-shadow: 0 8px 20px rgba(231, 76, 60, 0.3);
border: 4px solid white;
}
/* Устаревший аватар на основе фото (оставлен для совместимости) */
.profile-user-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 5px solid white;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
/* Бейдж «Профиль на X%» */
.match-badge {
background: linear-gradient(45deg, #E74C3CFF, #FD79A8FF);
display: inline-block;
background: linear-gradient(45deg, #e74c3c, #fd79a8);
color: white;
padding: 5px 15px;
border-radius: 20px;
@@ -35,8 +62,9 @@
font-weight: 600;
}
/* Блок заполненности профиля */
.profile-completion {
background: #F9F7F7FF;
background: #f9f7f7;
border-radius: 15px;
padding: 20px;
margin: 20px 0;
@@ -45,37 +73,280 @@
.completion-percentage {
font-size: 2.5rem;
font-weight: 700;
color: #E74C3CFF;
color: #e74c3c;
line-height: 1;
}
/* Прогресс-бар */
.progress {
height: 10px !important;
border-radius: 5px !important;
}
.progress-bar {
background-color: #E74C3CFF !important;
background-color: #e74c3c !important;
border-radius: 5px;
}
/* ═══════════════════════════════════════════════════════════════
SIDEBAR — навигация по разделам
═══════════════════════════════════════════════════════════════ */
.profile-nav-link-custom {
color: #2D3436FF;
padding: 15px 20px;
border-radius: 15px;
margin-bottom: 10px;
transition: all 0.3s;
display: flex;
align-items: center;
color: #2d3436;
padding: 13px 18px;
border-radius: 12px;
margin-bottom: 6px;
transition: all 0.25s;
font-weight: 500;
text-decoration: none;
position: relative;
}
.profile-nav-link-custom:hover {
background: rgba(231, 76, 60, 0.1) !important;
color: #E74C3CFF !important;
transform: translateX(5px) !important;
background: rgba(231, 76, 60, 0.08);
color: #e74c3c;
transform: translateX(4px);
}
.profile-nav-link-custom.active {
background: rgba(231, 76, 60, 0.1) !important;
color: #E74C3CFF !important;
border-left: 4px solid #E74C3CFF !important;
}
background: rgba(231, 76, 60, 0.1);
color: #e74c3c;
border-left: 4px solid #e74c3c;
padding-left: 14px;
}
/* Счётчик уведомлений на пункте навигации */
.notification-badge {
margin-left: auto;
background: #e74c3c;
color: white;
font-size: 0.72rem;
font-weight: 700;
padding: 2px 7px;
border-radius: 20px;
line-height: 1.4;
}
/* ═══════════════════════════════════════════════════════════════
SECTION TITLE — заголовок внутри секции
═══════════════════════════════════════════════════════════════ */
.section-title {
border-bottom: 3px solid #f9f7f7;
padding-bottom: 15px;
margin-bottom: 30px;
position: relative;
font-weight: 700;
}
/* Красная черта под заголовком */
.section-title::after {
content: '';
position: absolute;
bottom: -3px;
left: 0;
width: 80px;
height: 3px;
background: #e74c3c;
border-radius: 3px;
}
/* ═══════════════════════════════════════════════════════════════
МОЙ ПРОФИЛЬ — карточка и поля
═══════════════════════════════════════════════════════════════ */
/* Контейнер блока профиля */
.profile-info-card {
background: #f9f7f7;
border-radius: 16px;
padding: 28px;
}
/* Большой аватар-инициалы в блоке профиля */
.profile-big-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #e74c3c 0%, #fd79a8 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
font-weight: 700;
flex-shrink: 0;
box-shadow: 0 5px 20px rgba(231, 76, 60, 0.3);
}
/* Бейдж роли в блоке профиля */
.profile-role-badge {
display: inline-block;
background: rgba(231, 76, 60, 0.1);
color: #e74c3c;
padding: 3px 14px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Сетка информационных полей */
.profile-info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
}
/* Один элемент поля */
.profile-info-item {
display: flex;
align-items: flex-start;
gap: 12px;
}
/* Иконка поля */
.profile-info-icon {
color: #e74c3c;
font-size: 17px;
margin-top: 2px;
flex-shrink: 0;
}
/* Подпись поля */
.profile-info-label {
font-size: 0.75rem;
color: #aaa;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
/* Значение поля */
.profile-info-value {
font-weight: 600;
color: #2d3436;
font-size: 0.95rem;
}
/* ═══════════════════════════════════════════════════════════════
МОИ ОРГАНИЗАЦИИ — карточки и пустое состояние
═══════════════════════════════════════════════════════════════ */
/* Карточка организации */
.org-card {
background: #f9f7f7;
border-radius: 16px;
padding: 20px;
height: 100%;
transition: box-shadow 0.2s, transform 0.2s;
}
.org-card:hover {
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
/* Шапка карточки: лого + мета */
.org-card-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 12px;
}
/* Обёртка логотипа */
.org-card-logo-wrap {
width: 56px;
height: 56px;
border-radius: 14px;
overflow: hidden;
flex-shrink: 0;
background: white;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
}
/* Логотип-изображение */
.org-card-logo-img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Логотип-инициалы (если нет картинки) */
.org-card-logo-initials {
background: linear-gradient(135deg, #e74c3c 0%, #fd79a8 100%);
color: white;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
}
/* Название организации */
.org-card-title {
font-size: 1rem;
font-weight: 700;
color: #2d3436;
margin-bottom: 2px;
}
/* Дата создания */
.org-card-date {
font-size: 0.8rem;
color: #aaa;
}
/* Описание */
.org-card-desc {
font-size: 0.88rem;
color: #636e72;
line-height: 1.5;
margin-bottom: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Пустое состояние (нет организаций) */
.org-empty-state {
text-align: center;
padding: 60px 30px;
background: #f9f7f7;
border-radius: 20px;
}
/* ═══════════════════════════════════════════════════════════════
МОДАЛКА СОЗДАНИЯ ОРГАНИЗАЦИИ — загрузчик логотипа
═══════════════════════════════════════════════════════════════ */
/* Область загрузки логотипа */
.org-logo-upload-area {
width: 100%;
aspect-ratio: 1;
border: 2px dashed #ddd;
border-radius: 14px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f9f7f7;
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.org-logo-upload-area:hover {
border-color: #e74c3c;
background: rgba(231, 76, 60, 0.03);
}

View File

@@ -1,6 +1,5 @@
function initHeader($header){
console.log("init header date")
let authSection
if(!auth) {
authSection = `

View File

@@ -0,0 +1,37 @@
// function initModalMap() {
// let map = new ymaps3.map('map-container', {
// center: [55.756806, 37.622727],
// zoom: 11
// })
// }
// await ymaps3.ready(initModalMap)
//
initMap();
async function initMap() {
// Промис `ymaps3.ready` будет зарезолвлен, когда загрузятся все компоненты основного модуля API
await ymaps3.ready;
const {YMap, YMapDefaultSchemeLayer} = ymaps3;
// Иницилиазируем карту
const map = new YMap(
// Передаём ссылку на HTMLElement контейнера
document.getElementById('map-container'),
// Передаём параметры инициализации карты
{
location: {
// Координаты центра карты
center: [37.588144, 55.733842],
// Уровень масштабирования
zoom: 10
}
}
);
// Добавляем слой для отображения схематической карты
map.addChild(new YMapDefaultSchemeLayer());
}

View File

@@ -0,0 +1,607 @@
function renderMyOrganizations($organizationsBlockContainer) {
// ── Модалка создания организации ──────────────────────────────────────────
if (!$('#createOrganizationModal').length) {
$('body').append(`
<div class="modal fade" id="createOrganizationModal" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content rounded-4 overflow-hidden border-0 shadow-lg">
<!-- Градиентная шапка -->
<div class="modal-header border-0 p-4"
style="background: linear-gradient(135deg, #e74c3c 0%, #fd79a8 100%)">
<div class="text-white">
<h4 class="fw-bold mb-1">
<i class="fas fa-building me-2"></i>Новая организация
</h4>
<p class="mb-0 small" style="opacity: 0.8">
Расскажите о вашей организации, чтобы привлечь больше пар
</p>
</div>
<button type="button" class="btn-close btn-close-white ms-auto"
data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-4">
<form id="createOrganizationForm">
<div class="row g-4">
<!-- Логотип -->
<div class="col-md-4">
<label class="form-label fw-bold">Логотип</label>
<div class="org-logo-upload-area" id="orgLogoPreviewArea">
<i class="fas fa-building" style="font-size:40px; color:#ddd"></i>
<p class="text-muted small mt-2 mb-0">Нажмите для выбора</p>
</div>
<input type="file" class="form-control mt-2" id="orgLogoFile"
accept="image/*" style="display:none">
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-2"
id="orgLogoPickBtn">
<i class="fas fa-upload me-1"></i>Загрузить логотип
</button>
</div>
<!-- Поля -->
<div class="col-md-8">
<div class="mb-3">
<label class="form-label fw-bold">
Название организации <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="orgTitle"
placeholder="Например: Ресторан «Романтик»" required>
<div class="form-text">Это имя будут видеть пользователи</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Описание</label>
<textarea class="form-control" id="orgDescription" rows="5"
placeholder="Расскажите об атмосфере, уникальных предложениях, особенностях вашего заведения..."></textarea>
<div class="form-text">Поддерживается HTML-разметка</div>
</div>
</div>
</div>
<!-- Информационный блок -->
<div class="alert border mt-3 mb-0 d-flex align-items-center gap-3"
style="background:#fff9f9; border-color:#f8d7da !important; border-radius:12px">
<i class="fas fa-info-circle fs-5" style="color:#e74c3c; flex-shrink:0"></i>
<div class="small text-muted">
После создания организации вы сможете добавлять места для свиданий и
управлять расписанием прямо из личного кабинета.
</div>
</div>
</form>
</div>
<div class="modal-footer border-0 px-4 pb-4 pt-0">
<button type="button" class="btn btn-outline-secondary rounded-pill px-4"
data-bs-dismiss="modal">
Отмена
</button>
<button type="button" class="btn btn-heart" id="saveOrganizationBtn">
<i class="fas fa-check me-2"></i>Создать организацию
</button>
</div>
</div>
</div>
</div>
`);
}
// ── Модалка создания места для свидания ───────────────────────────────────
if (!$('#createDatingPlaceModal').length) {
$('body').append(`
<div class="modal fade" id="createDatingPlaceModal" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content rounded-4 border-0 shadow-lg">
<div class="modal-header border-0 p-4"
style="background: linear-gradient(135deg, #9b59b6 0%, #3498db 100%)">
<div class="text-white">
<h4 class="fw-bold mb-1">
<i class="fas fa-map-marker-alt me-2"></i>Новое место для свидания
</h4>
<p class="mb-0 small" style="opacity:0.8">
Заполните информацию о вашем предложении
</p>
</div>
<button type="button" class="btn-close btn-close-white ms-auto"
data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-4">
<form id="createDatingPlaceForm">
<!-- Основная информация -->
<div class="card mb-4 border-0" style="background:#f9f7f7; border-radius:16px">
<div class="card-body p-4">
<h6 class="fw-bold mb-3 text-muted">
<i class="bi bi-info-circle me-2" style="color:#e74c3c"></i>Основная информация
</h6>
<div class="row g-3">
<div class="col-md-8">
<label class="form-label fw-bold">Название места <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="placeTitle"
placeholder="Например: Романтический ужин на крыше" required>
</div>
<div class="col-md-4">
<label class="form-label fw-bold">Тип свидания <span class="text-danger">*</span></label>
<select class="form-select" id="datingType" required>
<option value="" disabled selected>Выберите тип</option>
<option value="ROMANTIC">💕 Романтическое</option>
<option value="ACTIVE">⚡ Активное</option>
<option value="INTELLECTUAL">📚 Интеллектуальное</option>
<option value="HORROR">👻 Хоррор</option>
<option value="COZY">🕯️ Уют</option>
<option value="MYSTERY">🔮 Тайна</option>
<option value="CREATIVE">🎨 Творческое</option>
<option value="OPENAIR">🌳 На открытом воздухе</option>
<option value="GASTRONOMIC">🍷 Гастрономия</option>
</select>
</div>
<div class="col-12">
<label class="form-label fw-bold">Описание <span class="text-danger">*</span></label>
<textarea class="form-control" id="placeDescription" rows="4"
placeholder="Подробно опишите, что ждет пару..." required></textarea>
</div>
<div class="col-12">
<label class="form-label fw-bold">Фото</label>
<input type="file" class="form-control" id="placePhoto" accept="image/*">
<div id="photoPreview" class="mt-2 d-none">
<img src="" alt="Preview" class="rounded-3"
style="max-height:150px; object-fit:cover">
</div>
</div>
</div>
</div>
</div>
<!-- Местоположение -->
<div class="card mb-4 border-0" style="background:#f9f7f7; border-radius:16px">
<div class="card-body p-4">
<h6 class="fw-bold mb-3 text-muted">
<i class="bi bi-geo-alt me-2" style="color:#e74c3c"></i>Местоположение
</h6>
<div class="row g-3">
<div class="col-md-6 p-1" id="map-container" style="height:50vh; border-radius:12px; overflow:hidden"></div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label fw-bold">Адрес <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="placeLocation"
placeholder="ул. Пушкина, д. 10" required>
</div>
<div>
<label class="form-label fw-bold">Координаты</label>
<input type="text" class="form-control" id="placeCoordinates"
placeholder="55.7558, 37.6173">
<div class="form-text">Широта, долгота (можно выбрать на карте)</div>
</div>
</div>
</div>
</div>
</div>
<!-- Цена -->
<div class="card mb-4 border-0" style="background:#f9f7f7; border-radius:16px">
<div class="card-body p-4">
<h6 class="fw-bold mb-3 text-muted">
<i class="bi bi-currency-ruble me-2" style="color:#e74c3c"></i>Стоимость
</h6>
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label fw-bold">Точная цена (₽)</label>
<div class="input-group">
<input type="number" class="form-control" id="exactPrice"
placeholder="5000" min="0" step="100">
<span class="input-group-text">₽</span>
</div>
<div class="form-text">Категория определится автоматически</div>
</div>
<div class="col-md-4">
<label class="form-label fw-bold">Или ценовая категория</label>
<select class="form-select" id="priceType">
<option value="" selected>Не выбрано</option>
<option value="ECONOMY">💰 Эконом (до 2000₽)</option>
<option value="AVERAGE">💵 Средний (20005000₽)</option>
<option value="PREMIUM">💎 Премиум (5000+₽)</option>
</select>
</div>
<div class="col-md-4">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="freeEvent">
<label class="form-check-label" for="freeEvent">Бесплатное мероприятие</label>
</div>
</div>
</div>
<div id="priceInfo" class="alert alert-info mt-3 d-none py-2">
<i class="bi bi-info-circle me-1"></i>
Категория: <strong id="selectedPriceCategory"></strong>
</div>
</div>
</div>
<!-- Расписание -->
<div class="card mb-4 border-0" style="background:#f9f7f7; border-radius:16px">
<div class="card-body p-4">
<h6 class="fw-bold mb-3 text-muted">
<i class="bi bi-clock me-2" style="color:#e74c3c"></i>Время проведения
</h6>
<div class="mb-3 d-flex gap-4">
<div class="form-check">
<input class="form-check-input" type="radio" name="scheduleType"
id="scheduleOneTime" value="onetime" checked>
<label class="form-check-label" for="scheduleOneTime">Разовое событие</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="scheduleType"
id="scheduleRegular" value="regular">
<label class="form-check-label" for="scheduleRegular">Регулярное</label>
</div>
</div>
<div id="oneTimeSchedule">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-bold">Дата и время начала</label>
<input type="datetime-local" class="form-control" id="eventStartTime">
</div>
<div class="col-md-6">
<label class="form-label fw-bold">Длительность (минут)</label>
<input type="number" class="form-control" id="eventDuration"
placeholder="120" min="15" step="15">
</div>
<div class="col-md-6">
<label class="form-label fw-bold">Дата окончания актуальности</label>
<input type="date" class="form-control" id="expirationDate">
<div class="form-text">Если это временное предложение</div>
</div>
</div>
</div>
<div id="regularSchedule" class="d-none">
<div id="scheduleItems"></div>
<button type="button" class="btn btn-outline-secondary btn-sm mt-2"
id="addScheduleItem">
<i class="bi bi-plus-circle me-1"></i>Добавить день
</button>
<template id="scheduleItemTemplate">
<div class="row g-2 mb-2 schedule-item align-items-center">
<div class="col-md-5">
<select class="form-select">
<option value="MONDAY">Понедельник</option>
<option value="TUESDAY">Вторник</option>
<option value="WEDNESDAY">Среда</option>
<option value="THURSDAY">Четверг</option>
<option value="FRIDAY">Пятница</option>
<option value="SATURDAY">Суббота</option>
<option value="SUNDAY">Воскресенье</option>
</select>
</div>
<div class="col-md-3">
<input type="time" class="form-control" placeholder="Начало">
</div>
<div class="col-md-3">
<input type="time" class="form-control" placeholder="Конец">
</div>
<div class="col-md-1">
<button type="button" class="btn btn-outline-danger btn-sm remove-schedule">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</template>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer border-0 px-4 pb-4 pt-0">
<button type="button" class="btn btn-outline-secondary rounded-pill px-4"
data-bs-dismiss="modal">Отмена</button>
<button type="submit" form="createDatingPlaceForm" class="btn btn-heart">
<i class="fas fa-check me-2"></i>Создать место
</button>
</div>
</div>
</div>
</div>
`);
}
// ── Секция "Мои организации" ──────────────────────────────────────────────
$organizationsBlockContainer.append(`
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="section-title mb-0">Мои организации</h2>
<button type="button" class="btn btn-heart"
data-bs-toggle="modal" data-bs-target="#createOrganizationModal">
<i class="fas fa-plus me-2"></i>Создать организацию
</button>
</div>
<p class="text-muted mb-4">
Управляйте своими организациями — добавляйте места и мероприятия для свиданий
</p>
<div id="orgCardsList" class="row g-4">
<div class="col-12 text-center py-5 text-muted" id="orgLoadingSpinner">
<div class="spinner-border" style="color:#e74c3c" role="status"></div>
<p class="mt-2">Загружаем организации...</p>
</div>
</div>
`);
// ── Загрузка организаций ──────────────────────────────────────────────────
function loadOrganizations() {
fetch('/api/organizations/my')
.then(r => r.json())
.then(orgs => {
const $list = $('#orgCardsList');
$list.empty();
if (!orgs.length) {
$list.append(`
<div class="col-12">
<div class="org-empty-state">
<i class="fas fa-building" style="font-size:48px; color:#ddd"></i>
<h5 class="mt-3 mb-2">У вас пока нет организаций</h5>
<p class="text-muted mb-4">
Создайте первую организацию и начните добавлять места для свиданий
</p>
<button class="btn btn-heart"
data-bs-toggle="modal" data-bs-target="#createOrganizationModal">
<i class="fas fa-plus me-2"></i>Создать первую организацию
</button>
</div>
</div>
`);
return;
}
orgs.forEach(org => {
const initials = org.title
? org.title.substring(0, 2).toUpperCase()
: 'ОГ';
const logo = org.logo
? `<img src="${org.logo}" alt="${org.title}" class="org-card-logo-img">`
: `<div class="org-card-logo-initials">${initials}</div>`;
const desc = org.description
? org.description.replace(/<[^>]*>/g, '').substring(0, 100) + '...'
: 'Описание не добавлено';
const date = org.createdAt
? new Date(org.createdAt).toLocaleDateString('ru-RU')
: '';
$list.append(`
<div class="col-md-6">
<div class="org-card">
<div class="org-card-header">
<div class="org-card-logo-wrap">${logo}</div>
<div class="org-card-meta">
<h5 class="org-card-title">${org.title}</h5>
${date ? `<span class="org-card-date"><i class="fas fa-calendar-alt me-1"></i>${date}</span>` : ''}
</div>
</div>
<p class="org-card-desc">${desc}</p>
<div class="d-flex gap-2 mt-3">
<button class="btn btn-heart btn-sm rounded-pill px-3"
data-bs-toggle="modal" data-bs-target="#createDatingPlaceModal">
<i class="fas fa-plus me-1"></i>Добавить место
</button>
<button class="btn btn-outline-secondary btn-sm rounded-pill px-3">
<i class="fas fa-edit me-1"></i>Редактировать
</button>
</div>
</div>
</div>
`);
});
})
.catch(err => {
console.error('Ошибка загрузки организаций:', err);
$('#orgCardsList').html(`
<div class="col-12 text-center py-4 text-muted">
<i class="fas fa-exclamation-triangle text-warning fs-3"></i>
<p class="mt-2">Не удалось загрузить организации</p>
</div>
`);
});
}
// ── Обработчики модалки создания организации ──────────────────────────────
// Превью логотипа
$(document).on('change', '#orgLogoFile', function () {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
$('#orgLogoPreviewArea').html(`
<img src="${e.target.result}" alt="Logo" style="width:100%;height:100%;object-fit:cover">
`);
};
reader.readAsDataURL(file);
});
$(document).on('click', '#orgLogoPickBtn', () => $('#orgLogoFile').trigger('click'));
$(document).on('click', '#orgLogoPreviewArea', () => $('#orgLogoFile').trigger('click'));
// Создание организации
$(document).on('click', '#saveOrganizationBtn', async function () {
const title = $('#orgTitle').val().trim();
if (!title) {
$('#orgTitle').addClass('is-invalid').focus();
return;
}
$('#orgTitle').removeClass('is-invalid');
const $btn = $(this).prop('disabled', true)
.html('<span class="spinner-border spinner-border-sm me-2"></span>Создаём...');
try {
const response = await fetch('/api/organizations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': csrf?.token ?? ''
},
body: JSON.stringify({
title,
description: $('#orgDescription').val().trim() || null
})
});
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('createOrganizationModal')).hide();
$('#createOrganizationForm')[0].reset();
$('#orgLogoPreviewArea').html(`
<i class="fas fa-building" style="font-size:40px; color:#ddd"></i>
<p class="text-muted small mt-2 mb-0">Нажмите для выбора</p>
`);
loadOrganizations();
showToast('info', `Организация «${title}» успешно создана`);
} else {
showToast('error', 'Не удалось создать организацию');
}
} catch (err) {
console.error('Org create error:', err);
showToast('error', 'Ошибка соединения с сервером');
} finally {
$btn.prop('disabled', false).html('<i class="fas fa-check me-2"></i>Создать организацию');
}
});
// ── Обработчики модалки создания места ───────────────────────────────────
// Переключение расписания
$(document).on('change', 'input[name="scheduleType"]', function () {
if (this.value === 'onetime') {
$('#oneTimeSchedule').removeClass('d-none');
$('#regularSchedule').addClass('d-none');
} else {
$('#oneTimeSchedule').addClass('d-none');
$('#regularSchedule').removeClass('d-none');
}
});
// Добавление дня расписания
let scheduleItemIndex = 0;
$(document).on('click', '#addScheduleItem', function () {
const template = document.getElementById('scheduleItemTemplate');
const clone = template.content.cloneNode(true);
const item = clone.querySelector('.schedule-item');
item.dataset.index = scheduleItemIndex++;
item.querySelector('.remove-schedule').addEventListener('click', function () {
this.closest('.schedule-item').remove();
});
document.getElementById('scheduleItems').appendChild(clone);
});
// Превью фото места
$(document).on('change', '#placePhoto', function () {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
$('#photoPreview').find('img').attr('src', e.target.result);
$('#photoPreview').removeClass('d-none');
};
reader.readAsDataURL(file);
});
// Авто-категория по цене
$(document).on('input', '#exactPrice', function () {
const price = parseInt(this.value);
if (isNaN(price) || price <= 0) {
$('#priceInfo').addClass('d-none');
return;
}
let cat, val;
if (price < 2000) { cat = 'Эконом'; val = 'ECONOMY'; }
else if (price <= 5000){ cat = 'Средний'; val = 'AVERAGE'; }
else { cat = 'Премиум'; val = 'PREMIUM'; }
$('#priceType').val(val);
$('#selectedPriceCategory').text(`${cat} (${price}₽)`);
$('#priceInfo').removeClass('d-none');
});
// Бесплатное событие
$(document).on('change', '#freeEvent', function () {
if (this.checked) {
$('#exactPrice').val(0).prop('disabled', true);
$('#priceType').val('ECONOMY');
$('#selectedPriceCategory').text('Бесплатно');
$('#priceInfo').removeClass('d-none');
} else {
$('#exactPrice').val('').prop('disabled', false);
$('#priceType').val('');
$('#priceInfo').addClass('d-none');
}
});
// Отправка формы создания места
$(document).on('submit', '#createDatingPlaceForm', async function (e) {
e.preventDefault();
let price = 0, priceType = null;
if ($('#freeEvent').is(':checked')) {
priceType = 'ECONOMY';
} else {
const exactPrice = $('#exactPrice').val();
if (exactPrice) {
price = parseInt(exactPrice);
if (price < 2000) priceType = 'ECONOMY';
else if (price <= 5000) priceType = 'AVERAGE';
else priceType = 'PREMIUM';
} else {
priceType = $('#priceType').val();
}
}
const scheduleType = $('input[name="scheduleType"]:checked').val();
const formData = {
title: $('#placeTitle').val(),
description: $('#placeDescription').val(),
location: $('#placeLocation').val(),
coordinates: $('#placeCoordinates').val() || null,
datingType: $('#datingType').val(),
price, priceType,
startTime: null,
duration: null,
scheduleUuids: [],
expirationDate: null
};
if (scheduleType === 'onetime') {
const st = $('#eventStartTime').val();
if (st) formData.startTime = new Date(st).toISOString();
formData.duration = parseInt($('#eventDuration').val()) || null;
const exp = $('#expirationDate').val();
if (exp) formData.expirationDate = new Date(exp).toISOString();
}
try {
const response = await fetch('/api/dating-places', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': csrf?.token ?? ''
},
body: JSON.stringify(formData)
});
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('createDatingPlaceModal')).hide();
showToast('info', 'Место успешно создано!');
} else {
showToast('error', 'Ошибка при создании места');
}
} catch (err) {
console.error('Dating place create error:', err);
showToast('error', 'Ошибка соединения с сервером');
}
});
// Инициализация
loadOrganizations();
}
renderMyOrganizations($('#organizationsSection'));

View File

@@ -1,85 +1,86 @@
<div class="container py-5" id="mainPageContainer">
<div class="row">
<div class="col-lg-4 mb-4">
<!-- Левая панель: аватар + навигация -->
<div class="col-lg-4 mb-4" id="left-panel">
<div class="sidebar">
<div class="text-center">
<img src="https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80"
class="profile-user-avatar" alt="Анна">
<h4 class="mb-1">Анна, 26</h4>
<p class="text-muted mb-3">Москва</p>
<div class="match-badge">Профиль на 85%</div>
<div class="profile-sidebar-avatar mx-auto mb-3" id="sidebarAvatarInitials"></div>
<h4 class="mb-1 fw-bold" id="sidebarFullName"></h4>
<p class="text-muted mb-3" id="sidebarPhone"></p>
<div class="match-badge" id="sidebarRoleBadge"></div>
</div>
<div class="profile-completion">
<div class="d-flex justify-content-between mb-2">
<span>Заполненность профиля:</span>
<span class="completion-percentage">85%</span>
</div>
<div class="progress">
<div class="progress-bar" style="width: 85%"></div>
</div>
<p class="small text-muted mt-2 mb-0">Полный профиль привлекает на 60% больше откликов</p>
</div>
<nav class="nav flex-column" id="profile-sections-navigator">
<a class="profile-nav-link-custom active" data-section="profileSection" href="#"
onclick="showSection('profile')">
<nav class="nav flex-column mt-4" id="profile-sections-navigator">
<a class="profile-nav-link-custom active" data-section="profileSection" href="#">
<i class="fas fa-user me-3"></i>Мой профиль
</a>
<a class="profile-nav-link-custom" data-section="proposalsSection" href="#"
onclick="showSection('proposals')">
<a class="profile-nav-link-custom" data-section="proposalsSection" href="#">
<i class="fas fa-heart me-3"></i>Мои предложения
<span class="notification-badge">3</span>
</a>
<a class="profile-nav-link-custom" data-section="organizationsSection" href="#"
onclick="showSection('organizations')">
<a class="profile-nav-link-custom" data-section="organizationsSection" href="#">
<i class="fas fa-building me-3"></i>Мои организации
</a>
<a class="profile-nav-link-custom" data-section="eventsSection" href="#"
onclick="showSection('events')">
<a class="profile-nav-link-custom" data-section="eventsSection" href="#">
<i class="fas fa-calendar-alt me-3"></i>Мероприятия
</a>
<a class="profile-nav-link-custom" data-section="settingsSection" href="#"
onclick="showSection('settings')">
<a class="profile-nav-link-custom" data-section="settingsSection" href="#">
<i class="fas fa-cog me-3"></i>Настройки
</a>
</nav>
<div class="mt-4">
<button class="btn btn-heart w-100" onclick="createNewProposal()">
<button class="btn btn-heart w-100">
<i class="fas fa-plus me-2"></i>Предложить свидание
</button>
</div>
</div>
</div>
<!-- Правая панель: контент секций -->
<div class="col-lg-8">
<div class="profile-main-content">
<div class="section-content d-block" id="profileSection">
1
</div>
<div class="section-content d-block" id="profileSection"></div>
<div class="section-content d-none" id="proposalsSection">
2
</div>
<div class="section-content d-none" id="organizationsSection">
3
<h2 class="section-title">Мои предложения</h2>
<p class="text-muted">Раздел в разработке</p>
</div>
<div class="section-content d-none" id="organizationsSection"
style="max-height: 2000px; overflow-x: auto;"></div>
<div class="section-content d-none" id="eventsSection">
4
<h2 class="section-title">Мероприятия</h2>
<p class="text-muted">Раздел в разработке</p>
</div>
<div class="section-content d-none" id="settingsSection">
5
<h2 class="section-title">Настройки</h2>
<p class="text-muted">Раздел в разработке</p>
</div>
</div>
</div>
</div>
</div>
<script>
$('#profile-sections-navigator').on('click', '.profile-nav-link-custom', function () {
$('#profile-sections-navigator').find('.profile-nav-link-custom').removeClass('active');
// Навигация по секциям
$('#profile-sections-navigator').on('click', '.profile-nav-link-custom', function (e) {
e.preventDefault();
$('#profile-sections-navigator .profile-nav-link-custom').removeClass('active');
$(this).addClass('active');
const selectedSectionId = $(this).data('section');
console.log(selectedSectionId);
const sectionId = $(this).data('section');
$('.section-content').removeClass('d-block').addClass('d-none');
$(`#${selectedSectionId}`).removeClass('d-none').addClass('d-block');
})
</script>
$(`#${sectionId}`).removeClass('d-none').addClass('d-block');
});
// Заполняем сайдбар данными из переменной user (инжектированной сервером)
if (user) {
const initials = `${user.firstName.charAt(0)}${user.lastName.charAt(0)}`;
$('#sidebarAvatarInitials').text(initials);
$('#sidebarFullName').text(`${user.firstName} ${user.lastName}`);
$('#sidebarPhone').text(user.phone);
$('#sidebarRoleBadge').text(user.role || 'USER');
}
</script>
<script src="/js/site/pages/account/my-profile-block.js"></script>
<script src="/js/site/pages/account/my-organizations-block.js"></script>
<script src="/js/site/pages/account/init-modal-map.js"></script>

View File

@@ -6,6 +6,7 @@
<link href="/css/style.css" rel="stylesheet">
<link href="/js/fw/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/js/fw/bootstrap-icons-1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<script src="https://api-maps.yandex.ru/v3/?apikey=7c13cf7d-c937-4ca6-8021-145ac16bdcae&lang=ru_RU"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="/js/fw/jquery/dist/jquery.js"></script>
<script src="/js/fw/apexcharts/loader.js"></script>