Pascal zmiany Alerty cenowe + Pomoc i kontakt #1

Merged
pascal merged 3 commits from pascal-zmiany into main 2026-07-13 01:22:21 +02:00
11 changed files with 2932 additions and 106 deletions
Showing only changes of commit 08f8058efc - Show all commits
@@ -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
View File
File diff suppressed because it is too large Load Diff
+818 -1
View File
@@ -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;