alery cenowe dokonczyc
This commit is contained in:
@@ -2,8 +2,10 @@ package pl.polskalokalnie.admin;
|
|||||||
|
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.security.SecureRandom;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -11,9 +13,11 @@ import org.springframework.web.bind.annotation.PathVariable;
|
|||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import java.time.LocalDate;
|
||||||
import pl.polskalokalnie.moderation.ForbiddenWordResponse;
|
import pl.polskalokalnie.moderation.ForbiddenWordResponse;
|
||||||
import pl.polskalokalnie.moderation.TextModerationService;
|
import pl.polskalokalnie.moderation.TextModerationService;
|
||||||
import pl.polskalokalnie.auth.dto.UserResponse;
|
import pl.polskalokalnie.auth.dto.UserResponse;
|
||||||
@@ -36,23 +40,33 @@ import pl.polskalokalnie.user.UserRepository;
|
|||||||
@RequestMapping("/api/admin")
|
@RequestMapping("/api/admin")
|
||||||
public class AdminController {
|
public class AdminController {
|
||||||
|
|
||||||
|
private static final String PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%";
|
||||||
|
private static final int TEMP_PASSWORD_LENGTH = 12;
|
||||||
|
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final BlockedEmailRepository blockedEmailRepository;
|
private final BlockedEmailRepository blockedEmailRepository;
|
||||||
private final ListingService listingService;
|
private final ListingService listingService;
|
||||||
private final ListingReportService listingReportService;
|
private final ListingReportService listingReportService;
|
||||||
private final MessageService messageService;
|
private final MessageService messageService;
|
||||||
private final TextModerationService textModerationService;
|
private final TextModerationService textModerationService;
|
||||||
|
private final AdminStatsService adminStatsService;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
|
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
|
||||||
ListingService listingService, ListingReportService listingReportService,
|
ListingService listingService, ListingReportService listingReportService,
|
||||||
MessageService messageService,
|
MessageService messageService,
|
||||||
TextModerationService textModerationService) {
|
TextModerationService textModerationService,
|
||||||
|
AdminStatsService adminStatsService,
|
||||||
|
PasswordEncoder passwordEncoder) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.blockedEmailRepository = blockedEmailRepository;
|
this.blockedEmailRepository = blockedEmailRepository;
|
||||||
this.listingService = listingService;
|
this.listingService = listingService;
|
||||||
this.listingReportService = listingReportService;
|
this.listingReportService = listingReportService;
|
||||||
this.messageService = messageService;
|
this.messageService = messageService;
|
||||||
this.textModerationService = textModerationService;
|
this.textModerationService = textModerationService;
|
||||||
|
this.adminStatsService = adminStatsService;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Uzytkownicy ---
|
// --- Uzytkownicy ---
|
||||||
@@ -82,6 +96,50 @@ public class AdminController {
|
|||||||
return UserResponse.from(userRepository.save(user));
|
return UserResponse.from(userRepository.save(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{id}/grant-admin")
|
||||||
|
public UserResponse grantAdmin(@PathVariable Long id) {
|
||||||
|
AppUser user = requireUser(id);
|
||||||
|
if (user.getRole() == Role.ADMIN) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "Użytkownik już ma rolę administratora");
|
||||||
|
}
|
||||||
|
user.setRole(Role.ADMIN);
|
||||||
|
user.setVerified(true);
|
||||||
|
return UserResponse.from(userRepository.save(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{id}/revoke-admin")
|
||||||
|
public UserResponse revokeAdmin(@PathVariable Long id, Authentication authentication) {
|
||||||
|
AppUser user = requireUser(id);
|
||||||
|
if (user.getRole() != Role.ADMIN) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "Użytkownik nie ma roli administratora");
|
||||||
|
}
|
||||||
|
|
||||||
|
AppUser currentAdmin = requireAdmin(authentication);
|
||||||
|
if (currentAdmin.getId().equals(user.getId())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie możesz odebrać roli administratora samemu sobie");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRepository.countByRole(Role.ADMIN) <= 1) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można odebrać roli ostatniemu administratorowi");
|
||||||
|
}
|
||||||
|
|
||||||
|
user.setRole(Role.USER);
|
||||||
|
return UserResponse.from(userRepository.save(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{id}/reset-password")
|
||||||
|
public ResetPasswordResponse resetPassword(@PathVariable Long id) {
|
||||||
|
AppUser user = requireUser(id);
|
||||||
|
if (user.getRole() == Role.ADMIN) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można resetować hasła konta administratora z tego widoku");
|
||||||
|
}
|
||||||
|
|
||||||
|
String temporaryPassword = generateTemporaryPassword();
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
|
||||||
|
userRepository.save(user);
|
||||||
|
return new ResetPasswordResponse(temporaryPassword);
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/users/{id}")
|
@DeleteMapping("/users/{id}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
public void deleteUser(@PathVariable Long id) {
|
public void deleteUser(@PathVariable Long id) {
|
||||||
@@ -205,4 +263,27 @@ public class AdminController {
|
|||||||
public void deleteForbiddenWord(@PathVariable Long id) {
|
public void deleteForbiddenWord(@PathVariable Long id) {
|
||||||
textModerationService.removeForbiddenWord(id);
|
textModerationService.removeForbiddenWord(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Statystyki ---
|
||||||
|
|
||||||
|
@GetMapping("/stats")
|
||||||
|
public AdminStatsResponse stats(
|
||||||
|
@RequestParam(name = "days", defaultValue = "1") int days,
|
||||||
|
@RequestParam(name = "from", required = false) LocalDate from,
|
||||||
|
@RequestParam(name = "to", required = false) LocalDate to
|
||||||
|
) {
|
||||||
|
if (from != null || to != null) {
|
||||||
|
return adminStatsService.getStats(from, to);
|
||||||
|
}
|
||||||
|
return adminStatsService.getStats(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateTemporaryPassword() {
|
||||||
|
StringBuilder password = new StringBuilder(TEMP_PASSWORD_LENGTH);
|
||||||
|
for (int index = 0; index < TEMP_PASSWORD_LENGTH; index++) {
|
||||||
|
int randomIndex = SECURE_RANDOM.nextInt(PASSWORD_ALPHABET.length());
|
||||||
|
password.append(PASSWORD_ALPHABET.charAt(randomIndex));
|
||||||
|
}
|
||||||
|
return password.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package pl.polskalokalnie.admin;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record AdminStatsResponse(
|
||||||
|
int periodDays,
|
||||||
|
LocalDate fromDate,
|
||||||
|
LocalDate toDate,
|
||||||
|
Instant generatedAt,
|
||||||
|
Summary summary,
|
||||||
|
Period period,
|
||||||
|
List<DailyPoint> daily
|
||||||
|
) {
|
||||||
|
public record Summary(
|
||||||
|
long activeListings,
|
||||||
|
long pendingListings,
|
||||||
|
long totalUsers,
|
||||||
|
long pendingVerificationUsers,
|
||||||
|
long openReports,
|
||||||
|
long messagesToAdminTotal,
|
||||||
|
long blockedUsers
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Period(
|
||||||
|
long messagesToAdmin,
|
||||||
|
long newListings,
|
||||||
|
long newReports,
|
||||||
|
long newPersonalAccounts,
|
||||||
|
long newCompanyAccounts
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DailyPoint(
|
||||||
|
LocalDate date,
|
||||||
|
long messagesToAdmin,
|
||||||
|
long newListings,
|
||||||
|
long newReports,
|
||||||
|
long newPersonalAccounts,
|
||||||
|
long newCompanyAccounts
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package pl.polskalokalnie.admin;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import pl.polskalokalnie.listing.ListingRepository;
|
||||||
|
import pl.polskalokalnie.listing.ListingStatus;
|
||||||
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
|
import pl.polskalokalnie.message.Message;
|
||||||
|
import pl.polskalokalnie.message.MessageRepository;
|
||||||
|
import pl.polskalokalnie.report.ListingReport;
|
||||||
|
import pl.polskalokalnie.report.ListingReportRepository;
|
||||||
|
import pl.polskalokalnie.report.ListingReportStatus;
|
||||||
|
import pl.polskalokalnie.user.AccountType;
|
||||||
|
import pl.polskalokalnie.user.AppUser;
|
||||||
|
import pl.polskalokalnie.user.Role;
|
||||||
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AdminStatsService {
|
||||||
|
|
||||||
|
private static final int MIN_PERIOD_DAYS = 1;
|
||||||
|
private static final int MAX_PERIOD_DAYS = 60;
|
||||||
|
private static final ZoneId ZONE_ID = ZoneId.of("Europe/Warsaw");
|
||||||
|
|
||||||
|
private final ListingRepository listingRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final ListingReportRepository listingReportRepository;
|
||||||
|
private final MessageRepository messageRepository;
|
||||||
|
|
||||||
|
public AdminStatsService(
|
||||||
|
ListingRepository listingRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
ListingReportRepository listingReportRepository,
|
||||||
|
MessageRepository messageRepository
|
||||||
|
) {
|
||||||
|
this.listingRepository = listingRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.listingReportRepository = listingReportRepository;
|
||||||
|
this.messageRepository = messageRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdminStatsResponse getStats(int requestedPeriodDays) {
|
||||||
|
int periodDays = Math.max(MIN_PERIOD_DAYS, Math.min(MAX_PERIOD_DAYS, requestedPeriodDays));
|
||||||
|
Instant now = Instant.now();
|
||||||
|
LocalDate today = LocalDate.now(ZONE_ID);
|
||||||
|
LocalDate fromDate = today.minusDays(periodDays - 1L);
|
||||||
|
return buildStats(fromDate, today, periodDays, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdminStatsResponse getStats(LocalDate requestedFromDate, LocalDate requestedToDate) {
|
||||||
|
LocalDate today = LocalDate.now(ZONE_ID);
|
||||||
|
LocalDate toDate = requestedToDate == null ? today : requestedToDate;
|
||||||
|
LocalDate fromDate = requestedFromDate == null ? toDate : requestedFromDate;
|
||||||
|
|
||||||
|
if (fromDate.isAfter(toDate)) {
|
||||||
|
LocalDate swapped = fromDate;
|
||||||
|
fromDate = toDate;
|
||||||
|
toDate = swapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toDate.isAfter(today)) {
|
||||||
|
toDate = today;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromDate.isAfter(toDate)) {
|
||||||
|
fromDate = toDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
long rawDays = java.time.temporal.ChronoUnit.DAYS.between(fromDate, toDate) + 1L;
|
||||||
|
int periodDays = (int) Math.max(MIN_PERIOD_DAYS, Math.min(MAX_PERIOD_DAYS, rawDays));
|
||||||
|
if (rawDays > MAX_PERIOD_DAYS) {
|
||||||
|
fromDate = toDate.minusDays(MAX_PERIOD_DAYS - 1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
Instant now = Instant.now();
|
||||||
|
return buildStats(fromDate, toDate, periodDays, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminStatsResponse buildStats(LocalDate fromDate, LocalDate toDate, int periodDays, Instant generatedAt) {
|
||||||
|
Instant periodStart = fromDate.atStartOfDay(ZONE_ID).toInstant();
|
||||||
|
Instant periodEndExclusive = toDate.plusDays(1L).atStartOfDay(ZONE_ID).toInstant();
|
||||||
|
Instant periodEndInclusive = generatedAt.isBefore(periodEndExclusive) ? generatedAt : periodEndExclusive.minusNanos(1L);
|
||||||
|
|
||||||
|
List<PropertyListing> listings = listingRepository.findAll();
|
||||||
|
List<AppUser> users = userRepository.findAll();
|
||||||
|
List<ListingReport> reports = listingReportRepository.findAll();
|
||||||
|
List<Message> messages = messageRepository.findAll();
|
||||||
|
|
||||||
|
Long adminId = users.stream()
|
||||||
|
.filter(user -> user.getRole() == Role.ADMIN)
|
||||||
|
.map(AppUser::getId)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
List<AppUser> regularUsers = users.stream()
|
||||||
|
.filter(user -> user.getRole() != Role.ADMIN)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<Message> messagesToAdmin = adminId == null
|
||||||
|
? List.of()
|
||||||
|
: messages.stream()
|
||||||
|
.filter(message -> message.getRecipientId().equals(adminId) && !message.getSenderId().equals(adminId))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
AdminStatsResponse.Summary summary = new AdminStatsResponse.Summary(
|
||||||
|
listings.stream().filter(item -> item.getStatus() == ListingStatus.APPROVED).count(),
|
||||||
|
listings.stream().filter(item -> item.getStatus() == ListingStatus.PENDING).count(),
|
||||||
|
regularUsers.size(),
|
||||||
|
regularUsers.stream().filter(item -> !item.isVerified()).count(),
|
||||||
|
reports.stream().filter(item -> item.getStatus() == ListingReportStatus.OPEN).count(),
|
||||||
|
messagesToAdmin.size(),
|
||||||
|
regularUsers.stream().filter(AppUser::isBlocked).count()
|
||||||
|
);
|
||||||
|
|
||||||
|
AdminStatsResponse.Period period = new AdminStatsResponse.Period(
|
||||||
|
messagesToAdmin.stream().filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive)).count(),
|
||||||
|
listings.stream().filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive)).count(),
|
||||||
|
reports.stream().filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive)).count(),
|
||||||
|
regularUsers.stream()
|
||||||
|
.filter(item -> item.getAccountType() == AccountType.PERSONAL)
|
||||||
|
.filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive))
|
||||||
|
.count(),
|
||||||
|
regularUsers.stream()
|
||||||
|
.filter(item -> item.getAccountType() == AccountType.COMPANY)
|
||||||
|
.filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive))
|
||||||
|
.count()
|
||||||
|
);
|
||||||
|
|
||||||
|
List<AdminStatsResponse.DailyPoint> daily = new ArrayList<>();
|
||||||
|
for (LocalDate current = fromDate; !current.isAfter(toDate); current = current.plusDays(1)) {
|
||||||
|
LocalDate nextDate = current.plusDays(1);
|
||||||
|
Instant dayStart = current.atStartOfDay(ZONE_ID).toInstant();
|
||||||
|
Instant dayEnd = nextDate.atStartOfDay(ZONE_ID).toInstant();
|
||||||
|
|
||||||
|
daily.add(new AdminStatsResponse.DailyPoint(
|
||||||
|
current,
|
||||||
|
messagesToAdmin.stream().filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd)).count(),
|
||||||
|
listings.stream().filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd)).count(),
|
||||||
|
reports.stream().filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd)).count(),
|
||||||
|
regularUsers.stream()
|
||||||
|
.filter(item -> item.getAccountType() == AccountType.PERSONAL)
|
||||||
|
.filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd))
|
||||||
|
.count(),
|
||||||
|
regularUsers.stream()
|
||||||
|
.filter(item -> item.getAccountType() == AccountType.COMPANY)
|
||||||
|
.filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd))
|
||||||
|
.count()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
daily.sort(Comparator.comparing(AdminStatsResponse.DailyPoint::date));
|
||||||
|
|
||||||
|
return new AdminStatsResponse(periodDays, fromDate, toDate, generatedAt, summary, period, daily);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean inRange(Instant timestamp, Instant startInclusive, Instant endInclusive) {
|
||||||
|
return timestamp != null
|
||||||
|
&& !timestamp.isBefore(startInclusive)
|
||||||
|
&& !timestamp.isAfter(endInclusive);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean inRangeExclusiveEnd(Instant timestamp, Instant startInclusive, Instant endExclusive) {
|
||||||
|
return timestamp != null
|
||||||
|
&& !timestamp.isBefore(startInclusive)
|
||||||
|
&& timestamp.isBefore(endExclusive);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package pl.polskalokalnie.admin;
|
||||||
|
|
||||||
|
public record ResetPasswordResponse(
|
||||||
|
String temporaryPassword
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ public record ListingCreateRequest(
|
|||||||
Double lng,
|
Double lng,
|
||||||
List<String> media,
|
List<String> media,
|
||||||
List<String> amenities,
|
List<String> amenities,
|
||||||
List<String> photos
|
List<String> photos,
|
||||||
|
String virtualTourUrl
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ public record ListingDetailResponse(
|
|||||||
List<String> media,
|
List<String> media,
|
||||||
List<String> amenities,
|
List<String> amenities,
|
||||||
List<String> photos,
|
List<String> photos,
|
||||||
|
String virtualTourUrl,
|
||||||
String ownerEmail,
|
String ownerEmail,
|
||||||
ListingStatus status,
|
ListingStatus status,
|
||||||
Instant createdAt,
|
Instant createdAt,
|
||||||
@@ -86,6 +87,7 @@ public record ListingDetailResponse(
|
|||||||
List.copyOf(listing.getMedia()),
|
List.copyOf(listing.getMedia()),
|
||||||
List.copyOf(listing.getAmenities()),
|
List.copyOf(listing.getAmenities()),
|
||||||
List.copyOf(listing.getPhotos()),
|
List.copyOf(listing.getPhotos()),
|
||||||
|
listing.getVirtualTourUrl(),
|
||||||
listing.getOwnerEmail(),
|
listing.getOwnerEmail(),
|
||||||
listing.getStatus(),
|
listing.getStatus(),
|
||||||
listing.getCreatedAt(),
|
listing.getCreatedAt(),
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package pl.polskalokalnie.listing;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -100,6 +102,7 @@ public class ListingService {
|
|||||||
List<String> photos = limitPhotos(request.photos());
|
List<String> photos = limitPhotos(request.photos());
|
||||||
listing.setPhotos(photos);
|
listing.setPhotos(photos);
|
||||||
listing.setCoverPhoto(photos.isEmpty() ? null : photos.get(0));
|
listing.setCoverPhoto(photos.isEmpty() ? null : photos.get(0));
|
||||||
|
listing.setVirtualTourUrl(normalizeVirtualTourUrl(request.virtualTourUrl()));
|
||||||
|
|
||||||
listing.setOwnerEmail(ownerEmail);
|
listing.setOwnerEmail(ownerEmail);
|
||||||
listing.setStatus(ListingStatus.PENDING);
|
listing.setStatus(ListingStatus.PENDING);
|
||||||
@@ -165,4 +168,29 @@ public class ListingService {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String normalizeVirtualTourUrl(String value) {
|
||||||
|
String normalized = trimToNull(value);
|
||||||
|
if (normalized == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (normalized.length() > 1200) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link do wirtualnego spaceru jest zbyt długi");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
URI uri = new URI(normalized);
|
||||||
|
String scheme = uri.getScheme();
|
||||||
|
String host = uri.getHost();
|
||||||
|
if (scheme == null || host == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj poprawny link URL do wirtualnego spaceru");
|
||||||
|
}
|
||||||
|
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link do wirtualnego spaceru musi zaczynać się od http:// lub https://");
|
||||||
|
}
|
||||||
|
return uri.toString();
|
||||||
|
} catch (URISyntaxException ex) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj poprawny link URL do wirtualnego spaceru");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,9 @@ public class PropertyListing {
|
|||||||
@Column(columnDefinition = "text")
|
@Column(columnDefinition = "text")
|
||||||
private String coverPhoto;
|
private String coverPhoto;
|
||||||
|
|
||||||
|
@Column(length = 1200)
|
||||||
|
private String virtualTourUrl;
|
||||||
|
|
||||||
@Column(length = 180)
|
@Column(length = 180)
|
||||||
private String ownerEmail;
|
private String ownerEmail;
|
||||||
|
|
||||||
@@ -446,6 +449,14 @@ public class PropertyListing {
|
|||||||
this.coverPhoto = coverPhoto;
|
this.coverPhoto = coverPhoto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getVirtualTourUrl() {
|
||||||
|
return virtualTourUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVirtualTourUrl(String virtualTourUrl) {
|
||||||
|
this.virtualTourUrl = virtualTourUrl;
|
||||||
|
}
|
||||||
|
|
||||||
public String getOwnerEmail() {
|
public String getOwnerEmail() {
|
||||||
return ownerEmail;
|
return ownerEmail;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ public interface UserRepository extends JpaRepository<AppUser, Long> {
|
|||||||
boolean existsByEmailIgnoreCase(String email);
|
boolean existsByEmailIgnoreCase(String email);
|
||||||
|
|
||||||
Optional<AppUser> findFirstByRole(Role role);
|
Optional<AppUser> findFirstByRole(Role role);
|
||||||
|
|
||||||
|
long countByRole(Role role);
|
||||||
}
|
}
|
||||||
|
|||||||
+1757
-96
File diff suppressed because it is too large
Load Diff
+818
-1
@@ -4627,6 +4627,79 @@ svg {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.photo-tour-card {
|
||||||
|
background: #f8fbff;
|
||||||
|
border: 1px solid #dbe7f3;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 16px 0 22px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tour-head {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tour-badge {
|
||||||
|
align-items: center;
|
||||||
|
background: #e6f1ff;
|
||||||
|
border: 1px solid #b8d3f5;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #245593;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 50px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tour-head strong,
|
||||||
|
.photo-tour-head small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tour-head strong {
|
||||||
|
color: #13243d;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tour-head small {
|
||||||
|
color: #5f748a;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tour-input {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tour-input small {
|
||||||
|
color: #5f748a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tour-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tour-provider {
|
||||||
|
color: #1f4f88;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tour-error {
|
||||||
|
color: #b3261e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.added-photos-head h3 {
|
.added-photos-head h3 {
|
||||||
color: #14223a;
|
color: #14223a;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -7589,6 +7662,22 @@ svg {
|
|||||||
padding: 18px 24px;
|
padding: 18px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-alert-feedback {
|
||||||
|
align-items: center;
|
||||||
|
background: #eaf8ef;
|
||||||
|
border: 1px solid #cfead9;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #1f5a3e;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
grid-column: 2 / 4;
|
||||||
|
justify-self: start;
|
||||||
|
margin-top: -6px;
|
||||||
|
max-width: 720px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.history-alert-card button,
|
.history-alert-card button,
|
||||||
.history-side-card button {
|
.history-side-card button {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -14163,6 +14252,38 @@ svg {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-contact-form-card {
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-contact-form-card.is-highlighted {
|
||||||
|
animation: helpContactFormHighlight 3s ease;
|
||||||
|
border-color: #1ab069;
|
||||||
|
box-shadow: 0 0 0 3px rgba(17, 163, 95, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes helpContactFormHighlight {
|
||||||
|
0% {
|
||||||
|
border-color: #1ab069;
|
||||||
|
box-shadow: 0 0 0 0 rgba(17, 163, 95, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
25% {
|
||||||
|
border-color: #14a661;
|
||||||
|
box-shadow: 0 0 0 6px rgba(17, 163, 95, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
border-color: #28b975;
|
||||||
|
box-shadow: 0 0 0 4px rgba(17, 163, 95, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
border-color: #dfe7f0;
|
||||||
|
box-shadow: 0 0 0 0 rgba(17, 163, 95, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.help-contact-faq-card header {
|
.help-contact-faq-card header {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -14281,6 +14402,32 @@ svg {
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-contact-form-card button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-contact-form-feedback {
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 820;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-contact-form-feedback.success {
|
||||||
|
background: #e8f8ef;
|
||||||
|
border: 1px solid #ccebd9;
|
||||||
|
color: #0b8d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-contact-form-feedback.error {
|
||||||
|
background: #fff1f3;
|
||||||
|
border: 1px solid #ffd8de;
|
||||||
|
color: #bd2f54;
|
||||||
|
}
|
||||||
|
|
||||||
.help-contact-more-grid {
|
.help-contact-more-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -17354,6 +17501,13 @@ svg {
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-searches-empty {
|
||||||
|
color: #6f7f92;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 6px 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.account-searches article > button {
|
.account-searches article > button {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -17365,6 +17519,270 @@ svg {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-searches .account-search-remove {
|
||||||
|
color: #d34d4d;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-empty-state {
|
||||||
|
align-items: center;
|
||||||
|
border: 1px dashed #cfd9e5;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
justify-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 26px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-empty-state strong {
|
||||||
|
color: #1f3047;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-empty-state p {
|
||||||
|
color: #62748a;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page-head {
|
||||||
|
align-items: start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page-head h1 {
|
||||||
|
color: #12233e;
|
||||||
|
font-size: 42px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.02;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page-head p {
|
||||||
|
color: #607288;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 760;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-stats {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-stats article {
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||||
|
border: 1px solid #dce6f2;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 74px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-stats small {
|
||||||
|
color: #617388;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-stats strong {
|
||||||
|
color: #12233e;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-toolbar {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: minmax(240px, 1fr) auto auto;
|
||||||
|
margin: 6px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-input {
|
||||||
|
align-items: center;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d5dfeb;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: 16px minmax(0, 1fr);
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-input svg {
|
||||||
|
color: #7f8ea2;
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-input input {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #1c2d45;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-input input::placeholder {
|
||||||
|
color: #8a97a9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-mode-tabs {
|
||||||
|
background: #f0f4f9;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-mode-tabs button {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #5c6f86;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
height: 34px;
|
||||||
|
min-width: 88px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-mode-tabs button.active {
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 4px 10px rgba(38, 59, 91, 0.12);
|
||||||
|
color: #12233e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-sort {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-sort span {
|
||||||
|
color: #667a90;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-sort select {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d3ddea;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #243850;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 820;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches article {
|
||||||
|
align-items: center;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e1e8f2;
|
||||||
|
border-radius: 12px;
|
||||||
|
grid-template-columns: 32px minmax(0, 1fr) auto auto auto auto;
|
||||||
|
min-height: 74px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches article > span {
|
||||||
|
height: 30px;
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches small {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches em {
|
||||||
|
align-items: center;
|
||||||
|
color: #6b7d92;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
gap: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches em svg {
|
||||||
|
color: #8a99aa;
|
||||||
|
height: 11px;
|
||||||
|
width: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches .account-search-mode-pill {
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 7px 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches .account-search-mode-pill.buy {
|
||||||
|
background: #ecf7ff;
|
||||||
|
color: #1a5d99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches .account-search-mode-pill.rent {
|
||||||
|
background: #edf9f1;
|
||||||
|
color: #16844d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches .account-search-open {
|
||||||
|
align-items: center;
|
||||||
|
background: #f5f8fc;
|
||||||
|
border: 1px solid #d8e3ef;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #1f3552;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
gap: 6px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches .account-search-open svg {
|
||||||
|
height: 12px;
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches .account-search-remove {
|
||||||
|
align-items: center;
|
||||||
|
color: #c44b4b;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches .account-search-remove svg {
|
||||||
|
height: 12px;
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-empty-state.filtered {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.account-new-search,
|
.account-new-search,
|
||||||
.account-tools-more {
|
.account-tools-more {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -17490,6 +17908,39 @@ svg {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches article {
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: 26px minmax(0, 1fr);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches article > button,
|
||||||
|
.account-searches-page .account-searches .account-search-open {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page .account-searches em,
|
||||||
|
.account-searches-page .account-searches b {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-toolbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-sort {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-searches-page-head h1 {
|
||||||
|
font-size: 33px;
|
||||||
|
}
|
||||||
|
|
||||||
.account-tools article:last-child {
|
.account-tools article:last-child {
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
@@ -21242,6 +21693,247 @@ svg {
|
|||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-stats-live {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-top-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-top-card {
|
||||||
|
align-items: center;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e3e9f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: 48px minmax(0, 1fr);
|
||||||
|
min-height: 86px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-top-card span {
|
||||||
|
align-items: center;
|
||||||
|
background: #edf3ff;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #1b63de;
|
||||||
|
display: grid;
|
||||||
|
font-size: 20px;
|
||||||
|
height: 44px;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-top-card strong {
|
||||||
|
color: #13243d;
|
||||||
|
display: block;
|
||||||
|
font-size: 38px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-top-card small {
|
||||||
|
color: #5f748a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: 260px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-sidebar,
|
||||||
|
.admin-stats-main {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e3e9f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-sidebar {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-sidebar h3 {
|
||||||
|
color: #13243d;
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-period-buttons {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-period-buttons button {
|
||||||
|
align-items: center;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #dbe5ef;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #33475f;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 780;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-period-buttons button.active {
|
||||||
|
background: #eef3fb;
|
||||||
|
border-color: #c7d7ea;
|
||||||
|
color: #1b63de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-side-summary {
|
||||||
|
border-top: 1px solid #e6ecf3;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-custom-range {
|
||||||
|
background: #f8fbff;
|
||||||
|
border: 1px solid #e1e9f3;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-custom-range label {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-custom-range span {
|
||||||
|
color: #4f6682;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-custom-range input {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d9e3ef;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #13243d;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 760;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-custom-range select {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d9e3ef;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #13243d;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 760;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-side-summary strong,
|
||||||
|
.admin-stats-side-summary p {
|
||||||
|
color: #33475f;
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 760;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-main-head {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-main-head h2 {
|
||||||
|
color: #13243d;
|
||||||
|
font-size: 36px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-cards-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e4eaf2;
|
||||||
|
border-radius: 12px;
|
||||||
|
min-height: 110px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-card h4 {
|
||||||
|
align-items: center;
|
||||||
|
color: #4f6682;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 780;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-card h4 svg {
|
||||||
|
color: #1b63de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-card strong {
|
||||||
|
color: #13243d;
|
||||||
|
display: block;
|
||||||
|
font-size: 44px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-history {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e4eaf2;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-history header {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-history h3 {
|
||||||
|
color: #13243d;
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-history small {
|
||||||
|
color: #7186a0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-stats-history .admin-row {
|
||||||
|
grid-template-columns: 1.1fr repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.admin-stats-grid {
|
.admin-stats-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -21250,6 +21942,14 @@ svg {
|
|||||||
.admin-dashboard-grid {
|
.admin-dashboard-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-stats-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-cards-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
@@ -21268,6 +21968,11 @@ svg {
|
|||||||
.admin-quick-actions-grid {
|
.admin-quick-actions-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-stats-top-grid,
|
||||||
|
.admin-stats-cards-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-head {
|
.admin-head {
|
||||||
@@ -21380,7 +22085,15 @@ svg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-table-users .admin-row {
|
.admin-table-users .admin-row {
|
||||||
grid-template-columns: 1.7fr 0.8fr 1fr 0.9fr 1fr 1.1fr 0.9fr 1.6fr;
|
grid-template-columns: 1.7fr 0.8fr 1fr 0.9fr 1fr 1.1fr 0.9fr 0.95fr 1.15fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-message-inbox .admin-row {
|
||||||
|
grid-template-columns: 1.25fr 1.9fr 1.2fr 0.8fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-message-inbox .admin-cell-main small {
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-row:last-child {
|
.admin-row:last-child {
|
||||||
@@ -21655,6 +22368,25 @@ svg {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-account-actions-cell {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-account-actions-table {
|
||||||
|
border: 1px solid #e3e9f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-account-actions-table .admin-btn {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-btn {
|
.admin-btn {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #d8e3ef;
|
border: 1px solid #d8e3ef;
|
||||||
@@ -21666,6 +22398,11 @@ svg {
|
|||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-btn-compact {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-btn:disabled {
|
.admin-btn:disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -21906,6 +22643,10 @@ svg {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-account-actions-cell {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-forbidden-add {
|
.admin-forbidden-add {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -22330,6 +23071,36 @@ svg {
|
|||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.listing-detail-tour-embed {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border: 1px solid #d9e4f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-detail-tour-embed iframe {
|
||||||
|
border: 0;
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-detail-tour-link {
|
||||||
|
align-items: center;
|
||||||
|
color: #0d7d47;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
gap: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-detail-tour-link svg {
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.listing-detail-specs {
|
.listing-detail-specs {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
@@ -22846,6 +23617,32 @@ svg {
|
|||||||
color: #d5493c;
|
color: #d5493c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.listing-detail-alert {
|
||||||
|
align-items: center;
|
||||||
|
background: #f6fbf8;
|
||||||
|
border: 1px solid #cfe8d8;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #17653e;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-detail-alert svg {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-detail-alert.active {
|
||||||
|
background: #129357;
|
||||||
|
border-color: #129357;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.listing-detail-seller-card {
|
.listing-detail-seller-card {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #f7fafd;
|
background: #f7fafd;
|
||||||
@@ -22940,6 +23737,26 @@ svg {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.final-virtual-tour-block {
|
||||||
|
background: #eef5ff;
|
||||||
|
border: 1px solid #d6e6fb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-virtual-tour-block h3 {
|
||||||
|
color: #13243d;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-virtual-tour-block p {
|
||||||
|
color: #4e647d;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.publish-success-icon {
|
.publish-success-icon {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #129357;
|
background: #129357;
|
||||||
|
|||||||
Reference in New Issue
Block a user