This commit is contained in:
@@ -145,6 +145,8 @@ public class AuthService {
|
|||||||
"Konto nie zostało aktywowane. Sprawdź skrzynkę e-mail i kliknij link aktywacyjny.");
|
"Konto nie zostało aktywowane. Sprawdź skrzynkę e-mail i kliknij link aktywacyjny.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user.setLastActiveAt(Instant.now());
|
||||||
|
userRepository.save(user);
|
||||||
return buildAuthResponse(user);
|
return buildAuthResponse(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,10 +300,20 @@ public class AuthService {
|
|||||||
return buildAuthResponse(user);
|
return buildAuthResponse(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Odstep, po ktorym wejscie do aplikacji (odswiezenie /auth/me) na nowo odnotowuje aktywnosc.
|
||||||
|
// Bez tego zapisywalibysmy do bazy przy kazdym zadaniu profilu - a wystarczy zgrubna dokladnosc.
|
||||||
|
private static final Duration LAST_ACTIVE_REFRESH = Duration.ofMinutes(2);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public UserResponse currentUser(String email) {
|
public UserResponse currentUser(String email) {
|
||||||
return userRepository.findByEmailIgnoreCase(email)
|
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||||
.map(this::withAvatar)
|
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (user.getLastActiveAt() == null || user.getLastActiveAt().isBefore(now.minus(LAST_ACTIVE_REFRESH))) {
|
||||||
|
user.setLastActiveAt(now);
|
||||||
|
userRepository.save(user);
|
||||||
|
}
|
||||||
|
return withAvatar(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/auth/me").authenticated()
|
.requestMatchers("/api/auth/me").authenticated()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
|
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
|
||||||
|
// Podglad ogloszenia dla crawlerow social media (Open Graph) - publiczny, tylko GET.
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/og/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/articles").authenticated()
|
.requestMatchers(HttpMethod.POST, "/api/articles").authenticated()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ public record ListingDetailResponse(
|
|||||||
String ownerAccountType,
|
String ownerAccountType,
|
||||||
// Nazwa wlasciciela do etykiety oferty firmowej (dla konta firmowego). Dla oferty prywatnej pomijana.
|
// Nazwa wlasciciela do etykiety oferty firmowej (dla konta firmowego). Dla oferty prywatnej pomijana.
|
||||||
String ownerName,
|
String ownerName,
|
||||||
|
// Data zalozenia konta wlasciciela - na karcie sprzedajacego pokazujemy "Na Polska Lokalnie od...".
|
||||||
|
Instant ownerCreatedAt,
|
||||||
|
// Ostatnia aktywnosc wlasciciela - na karcie sprzedajacego "Ostatnio aktywny/a...". Null = brak konta.
|
||||||
|
Instant ownerLastActiveAt,
|
||||||
ListingStatus status,
|
ListingStatus status,
|
||||||
Instant createdAt,
|
Instant createdAt,
|
||||||
Long viewsCount,
|
Long viewsCount,
|
||||||
@@ -68,7 +72,7 @@ public record ListingDetailResponse(
|
|||||||
private static final double METERS_PER_DEGREE = 111_320d;
|
private static final double METERS_PER_DEGREE = 111_320d;
|
||||||
|
|
||||||
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) {
|
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) {
|
||||||
return from(listing, ownerId, ownerAvatar, null, null, false);
|
return from(listing, ownerId, ownerAvatar, null, null, null, null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,7 +82,8 @@ public record ListingDetailResponse(
|
|||||||
* oferty jest zawsze takie samo - inaczej kolo skakaloby po mapie przy kazdym odswiezeniu.
|
* oferty jest zawsze takie samo - inaczej kolo skakaloby po mapie przy kazdym odswiezeniu.
|
||||||
*/
|
*/
|
||||||
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar,
|
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar,
|
||||||
String ownerAccountType, String ownerName, boolean masked) {
|
String ownerAccountType, String ownerName, Instant ownerCreatedAt, Instant ownerLastActiveAt,
|
||||||
|
boolean masked) {
|
||||||
String street = masked ? null : listing.getStreet();
|
String street = masked ? null : listing.getStreet();
|
||||||
String building = masked ? null : listing.getBuilding();
|
String building = masked ? null : listing.getBuilding();
|
||||||
String address = masked ? approximateAddress(listing) : listing.getAddress();
|
String address = masked ? approximateAddress(listing) : listing.getAddress();
|
||||||
@@ -126,6 +131,8 @@ public record ListingDetailResponse(
|
|||||||
ownerAvatar,
|
ownerAvatar,
|
||||||
ownerAccountType,
|
ownerAccountType,
|
||||||
ownerName,
|
ownerName,
|
||||||
|
ownerCreatedAt,
|
||||||
|
ownerLastActiveAt,
|
||||||
listing.getStatus(),
|
listing.getStatus(),
|
||||||
listing.getCreatedAt(),
|
listing.getCreatedAt(),
|
||||||
listing.getViewsCount(),
|
listing.getViewsCount(),
|
||||||
|
|||||||
@@ -62,11 +62,11 @@ public class ListingService {
|
|||||||
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked) {
|
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked) {
|
||||||
AppUser owner = ownerOf(listing);
|
AppUser owner = ownerOf(listing);
|
||||||
if (owner == null) {
|
if (owner == null) {
|
||||||
return ListingDetailResponse.from(listing, null, null, null, null, masked);
|
return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked);
|
||||||
}
|
}
|
||||||
String ownerAccountType = owner.getAccountType() == null ? null : owner.getAccountType().name();
|
String ownerAccountType = owner.getAccountType() == null ? null : owner.getAccountType().name();
|
||||||
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()),
|
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()),
|
||||||
ownerAccountType, owner.getFullName(), masked);
|
ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package pl.polskalokalnie.og;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.text.NumberFormat;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import pl.polskalokalnie.listing.ListingRepository;
|
||||||
|
import pl.polskalokalnie.listing.ListingStatus;
|
||||||
|
import pl.polskalokalnie.listing.OfferType;
|
||||||
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serwerowe renderowanie strony ogloszenia dla crawlerow social media (Facebook, WhatsApp,
|
||||||
|
* Messenger, Twitter/X...). Te boty nie wykonuja JavaScriptu, wiec meta tagi ustawiane dynamicznie
|
||||||
|
* w Reakcie sa dla nich niewidoczne - podglad linku bylby pusty. Nginx wykrywa crawlera po
|
||||||
|
* User-Agent i dla adresu /oferta/{id} kieruje go tutaj zamiast do aplikacji SPA; zwykly uzytkownik
|
||||||
|
* dostaje niezmieniona aplikacje. Zwracany HTML zawiera Open Graph/Twitter Card z tytulem, cena,
|
||||||
|
* metrazem, lokalizacja i glownym zdjeciem, a nastepnie przekierowuje do kanonicznego adresu SPA.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/og")
|
||||||
|
public class OgController {
|
||||||
|
|
||||||
|
private final ListingRepository listingRepository;
|
||||||
|
private final String baseUrl;
|
||||||
|
|
||||||
|
public OgController(ListingRepository listingRepository,
|
||||||
|
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
|
||||||
|
this.listingRepository = listingRepository;
|
||||||
|
// Bez ukosnika na koncu - laczymy przez konkatenacje ze sciezka zaczynajaca sie od "/".
|
||||||
|
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@GetMapping(value = "/listing/{id}", produces = MediaType.TEXT_HTML_VALUE + ";charset=UTF-8")
|
||||||
|
public ResponseEntity<String> listingPreview(@PathVariable Long id) {
|
||||||
|
String canonicalUrl = baseUrl + "/oferta/" + id;
|
||||||
|
PropertyListing listing = listingRepository.findById(id).orElse(null);
|
||||||
|
|
||||||
|
// Nieopublikowane ogloszenia (oczekujace, wstrzymane, odrzucone) i nieistniejace nie wyciekaja
|
||||||
|
// danych - crawler dostaje ogolna wizytowke serwisu z przekierowaniem do aplikacji.
|
||||||
|
if (listing == null || listing.getStatus() != ListingStatus.APPROVED) {
|
||||||
|
String html = renderHtml(
|
||||||
|
"Polska Lokalnie",
|
||||||
|
"Ogloszenia sprzedazy i wynajmu mieszkan, domow oraz apartamentow.",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
canonicalUrl);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.cacheControl(CacheControl.noCache())
|
||||||
|
.contentType(MediaType.valueOf("text/html;charset=UTF-8"))
|
||||||
|
.body(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
String location = formatLocation(listing);
|
||||||
|
// Opis podgladu = cena · metraz · lokalizacja - dokladnie to, co ma zobaczyc odbiorca linku.
|
||||||
|
String description = String.join(" · ", nonBlank(
|
||||||
|
formatPrice(listing),
|
||||||
|
formatArea(listing.getArea()),
|
||||||
|
location));
|
||||||
|
|
||||||
|
String imageUrl = hasImage(listing) ? baseUrl + "/api/og/listing/" + id + "/image" : null;
|
||||||
|
|
||||||
|
String html = renderHtml(listing.getTitle(), description, imageUrl, listing.getTitle(), canonicalUrl);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.cacheControl(CacheControl.noCache())
|
||||||
|
.contentType(MediaType.valueOf("text/html;charset=UTF-8"))
|
||||||
|
.body(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glowne zdjecie ogloszenia jako realny obraz pod stabilnym adresem. Zdjecia trzymamy jako
|
||||||
|
* data URL (base64) - crawler nie potrafi ich uzyc jako og:image, wiec dekodujemy je tutaj do
|
||||||
|
* bajtow z prawidlowym Content-Type.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@GetMapping("/listing/{id}/image")
|
||||||
|
public ResponseEntity<byte[]> listingImage(@PathVariable Long id) {
|
||||||
|
PropertyListing listing = listingRepository.findById(id).orElse(null);
|
||||||
|
if (listing == null || listing.getStatus() != ListingStatus.APPROVED || !hasImage(listing)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
String cover = listing.getPhotos().get(0);
|
||||||
|
DecodedImage image = decodeDataUrl(cover);
|
||||||
|
if (image == null) {
|
||||||
|
// Zdjecie zapisane jako zwykly URL (nie data URL) - odsylamy crawlera pod ten adres.
|
||||||
|
return ResponseEntity.status(HttpStatus.FOUND)
|
||||||
|
.header("Location", cover)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.valueOf(image.mimeType()))
|
||||||
|
.cacheControl(CacheControl.maxAge(java.time.Duration.ofHours(1)).cachePublic())
|
||||||
|
.body(image.data());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasImage(PropertyListing listing) {
|
||||||
|
List<String> photos = listing.getPhotos();
|
||||||
|
return photos != null && !photos.isEmpty() && photos.get(0) != null && !photos.get(0).isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record DecodedImage(String mimeType, byte[] data) {}
|
||||||
|
|
||||||
|
private static DecodedImage decodeDataUrl(String value) {
|
||||||
|
if (value == null || !value.startsWith("data:")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int comma = value.indexOf(',');
|
||||||
|
if (comma < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String meta = value.substring(5, comma); // np. "image/jpeg;base64"
|
||||||
|
if (!meta.contains(";base64")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String mime = meta.substring(0, meta.indexOf(';'));
|
||||||
|
if (mime.isBlank()) {
|
||||||
|
mime = MediaType.IMAGE_JPEG_VALUE;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] data = Base64.getDecoder().decode(value.substring(comma + 1));
|
||||||
|
return new DecodedImage(mime, data);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Formatowanie zgodne z prezentacja na froncie (formatPln, "X m²", "district, city") ---
|
||||||
|
|
||||||
|
private static String formatPrice(PropertyListing listing) {
|
||||||
|
BigDecimal price = listing.getPrice();
|
||||||
|
if (price == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
NumberFormat format = NumberFormat.getIntegerInstance(new Locale("pl", "PL"));
|
||||||
|
String amount = format.format(price.setScale(0, RoundingMode.HALF_UP)).replace(' ', ' ');
|
||||||
|
String base = amount + " zł";
|
||||||
|
return listing.getOfferType() == OfferType.RENT ? base + " / mies." : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatArea(Double area) {
|
||||||
|
if (area == null || area <= 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (area == Math.floor(area)) {
|
||||||
|
return (long) area.doubleValue() + " m²";
|
||||||
|
}
|
||||||
|
return String.format(new Locale("pl", "PL"), "%.1f m²", area);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatLocation(PropertyListing listing) {
|
||||||
|
String city = listing.getCity() == null ? "" : listing.getCity().trim();
|
||||||
|
String district = listing.getDistrict() == null ? "" : listing.getDistrict().trim();
|
||||||
|
if (!district.isEmpty() && !city.isEmpty()) {
|
||||||
|
return district + ", " + city;
|
||||||
|
}
|
||||||
|
return city.isEmpty() ? district : city;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String[] nonBlank(String... values) {
|
||||||
|
return java.util.Arrays.stream(values).filter(v -> v != null && !v.isBlank()).toArray(String[]::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String renderHtml(String title, String description, String imageUrl, String imageAlt, String canonicalUrl) {
|
||||||
|
String t = escapeHtml(title);
|
||||||
|
String d = escapeHtml(description);
|
||||||
|
StringBuilder head = new StringBuilder();
|
||||||
|
head.append("<meta property=\"og:type\" content=\"website\"/>\n");
|
||||||
|
head.append("<meta property=\"og:site_name\" content=\"Polska Lokalnie\"/>\n");
|
||||||
|
head.append("<meta property=\"og:locale\" content=\"pl_PL\"/>\n");
|
||||||
|
head.append("<meta property=\"og:title\" content=\"").append(t).append("\"/>\n");
|
||||||
|
head.append("<meta property=\"og:description\" content=\"").append(d).append("\"/>\n");
|
||||||
|
head.append("<meta property=\"og:url\" content=\"").append(escapeHtml(canonicalUrl)).append("\"/>\n");
|
||||||
|
head.append("<meta name=\"twitter:card\" content=\"").append(imageUrl != null ? "summary_large_image" : "summary").append("\"/>\n");
|
||||||
|
head.append("<meta name=\"twitter:title\" content=\"").append(t).append("\"/>\n");
|
||||||
|
head.append("<meta name=\"twitter:description\" content=\"").append(d).append("\"/>\n");
|
||||||
|
if (imageUrl != null) {
|
||||||
|
String img = escapeHtml(imageUrl);
|
||||||
|
head.append("<meta property=\"og:image\" content=\"").append(img).append("\"/>\n");
|
||||||
|
head.append("<meta property=\"og:image:alt\" content=\"").append(escapeHtml(imageAlt)).append("\"/>\n");
|
||||||
|
head.append("<meta name=\"twitter:image\" content=\"").append(img).append("\"/>\n");
|
||||||
|
}
|
||||||
|
return "<!doctype html>\n"
|
||||||
|
+ "<html lang=\"pl\">\n<head>\n"
|
||||||
|
+ "<meta charset=\"utf-8\"/>\n"
|
||||||
|
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n"
|
||||||
|
+ "<title>" + t + "</title>\n"
|
||||||
|
+ "<meta name=\"description\" content=\"" + d + "\"/>\n"
|
||||||
|
+ "<link rel=\"canonical\" href=\"" + escapeHtml(canonicalUrl) + "\"/>\n"
|
||||||
|
+ head
|
||||||
|
// Czlowiek, ktory trafi tu z linku (a nie bot), zostaje od razu przeniesiony do aplikacji.
|
||||||
|
+ "<meta http-equiv=\"refresh\" content=\"0; url=" + escapeHtml(canonicalUrl) + "\"/>\n"
|
||||||
|
+ "</head>\n<body>\n"
|
||||||
|
+ "<p><a href=\"" + escapeHtml(canonicalUrl) + "\">" + t + "</a></p>\n"
|
||||||
|
+ "<script>window.location.replace(" + jsString(canonicalUrl) + ");</script>\n"
|
||||||
|
+ "</body>\n</html>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escapeHtml(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bezpieczne osadzenie adresu w literale JS (redirect) - adres pochodzi z konfiguracji, ale
|
||||||
|
// trzymamy sie zasady, ze nic nie trafia do skryptu bez ucieczki.
|
||||||
|
private static String jsString(String value) {
|
||||||
|
String escaped = value == null ? "" : value
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("'", "\\'")
|
||||||
|
.replace("<", "\\u003C")
|
||||||
|
.replace(">", "\\u003E");
|
||||||
|
return "'" + escaped + "'";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,11 +95,18 @@ public class AppUser {
|
|||||||
// odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta.
|
// odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta.
|
||||||
private Instant passwordChangedAt;
|
private Instant passwordChangedAt;
|
||||||
|
|
||||||
|
// Ostatnia aktywnosc uzytkownika (logowanie oraz kazde wejscie do aplikacji odswiezajace /auth/me).
|
||||||
|
// Na karcie sprzedajacego pokazujemy z tego "ostatnio aktywny/a"; null = jeszcze nie odnotowano.
|
||||||
|
private Instant lastActiveAt;
|
||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
void onCreate() {
|
void onCreate() {
|
||||||
if (createdAt == null) {
|
if (createdAt == null) {
|
||||||
createdAt = Instant.now();
|
createdAt = Instant.now();
|
||||||
}
|
}
|
||||||
|
if (lastActiveAt == null) {
|
||||||
|
lastActiveAt = createdAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
@@ -253,4 +260,12 @@ public class AppUser {
|
|||||||
public void setPasswordChangedAt(Instant passwordChangedAt) {
|
public void setPasswordChangedAt(Instant passwordChangedAt) {
|
||||||
this.passwordChangedAt = passwordChangedAt;
|
this.passwordChangedAt = passwordChangedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Instant getLastActiveAt() {
|
||||||
|
return lastActiveAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastActiveAt(Instant lastActiveAt) {
|
||||||
|
this.lastActiveAt = lastActiveAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ public class PublicProfileController {
|
|||||||
? List.of()
|
? List.of()
|
||||||
: listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED);
|
: listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED);
|
||||||
|
|
||||||
|
// Telefon i adres z konta pokazujemy tylko, gdy wlasciciel wlaczyl publiczny kontakt.
|
||||||
|
boolean showContact = settings != null && settings.isPublicContactVisible();
|
||||||
|
|
||||||
return new PublicProfileResponse(
|
return new PublicProfileResponse(
|
||||||
user.getId(),
|
user.getId(),
|
||||||
user.getFullName(),
|
user.getFullName(),
|
||||||
@@ -80,6 +83,15 @@ public class PublicProfileController {
|
|||||||
user.getAccountType(),
|
user.getAccountType(),
|
||||||
user.isVerified(),
|
user.isVerified(),
|
||||||
user.getCreatedAt(),
|
user.getCreatedAt(),
|
||||||
|
user.getLastActiveAt(),
|
||||||
|
settings == null ? null : settings.getCompanyTagline(),
|
||||||
|
settings == null ? null : settings.getWebsite(),
|
||||||
|
settings == null ? null : settings.getSocialLinkedin(),
|
||||||
|
settings == null ? null : settings.getSocialFacebook(),
|
||||||
|
settings == null ? null : settings.getSocialInstagram(),
|
||||||
|
settings == null ? null : settings.getSocialYoutube(),
|
||||||
|
showContact ? user.getPhone() : null,
|
||||||
|
showContact ? user.getAddress() : null,
|
||||||
listings.size(),
|
listings.size(),
|
||||||
listings.stream()
|
listings.stream()
|
||||||
.map(listing -> new PublicProfileResponse.PublicProfileListing(
|
.map(listing -> new PublicProfileResponse.PublicProfileListing(
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ public record PublicProfileResponse(
|
|||||||
AccountType accountType,
|
AccountType accountType,
|
||||||
boolean verified,
|
boolean verified,
|
||||||
Instant memberSince,
|
Instant memberSince,
|
||||||
|
// Ostatnia aktywnosc wlasciciela profilu - "Ostatnio aktywny/a"; null, gdy nieodnotowana.
|
||||||
|
Instant lastActiveAt,
|
||||||
|
// --- Dane firmowe (konto COMPANY) - null dla kont prywatnych albo gdy nieuzupelnione ---
|
||||||
|
String companyTagline,
|
||||||
|
String website,
|
||||||
|
String socialLinkedin,
|
||||||
|
String socialFacebook,
|
||||||
|
String socialInstagram,
|
||||||
|
String socialYoutube,
|
||||||
|
// Telefon i adres pokazujemy tylko, gdy wlasciciel wlaczyl "publiczny kontakt"; inaczej null.
|
||||||
|
String publicPhone,
|
||||||
|
String publicAddress,
|
||||||
int listingsCount,
|
int listingsCount,
|
||||||
List<PublicProfileListing> listings,
|
List<PublicProfileListing> listings,
|
||||||
// Profil ustawiony jako prywatny, ogladany przez kogos innego niz wlasciciel. Pozostale pola
|
// Profil ustawiony jako prywatny, ogladany przez kogos innego niz wlasciciel. Pozostale pola
|
||||||
@@ -41,6 +53,15 @@ public record PublicProfileResponse(
|
|||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
0,
|
0,
|
||||||
List.of(),
|
List.of(),
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -45,6 +45,33 @@ public class UserSettings {
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String coverImage;
|
private String coverImage;
|
||||||
|
|
||||||
|
// --- Dane firmowe na profilu publicznym (konto typu COMPANY) ---
|
||||||
|
|
||||||
|
// Krotkie haslo pod nazwa firmy, np. "Deweloper mieszkaniowy".
|
||||||
|
@Column(length = 80)
|
||||||
|
private String companyTagline;
|
||||||
|
|
||||||
|
@Column(length = 200)
|
||||||
|
private String website;
|
||||||
|
|
||||||
|
@Column(length = 200)
|
||||||
|
private String socialLinkedin;
|
||||||
|
|
||||||
|
@Column(length = 200)
|
||||||
|
private String socialFacebook;
|
||||||
|
|
||||||
|
@Column(length = 200)
|
||||||
|
private String socialInstagram;
|
||||||
|
|
||||||
|
@Column(length = 200)
|
||||||
|
private String socialYoutube;
|
||||||
|
|
||||||
|
// true = firma zgadza sie pokazac telefon i adres (z konta) na profilu publicznym. Domyslnie
|
||||||
|
// dane kontaktowe zostaja prywatne - kontakt idzie przez wiadomosci.
|
||||||
|
@Column(nullable = false)
|
||||||
|
@ColumnDefault("false")
|
||||||
|
private boolean publicContactVisible = false;
|
||||||
|
|
||||||
// --- Preferencje prezentacji ---
|
// --- Preferencje prezentacji ---
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@@ -155,6 +182,62 @@ public class UserSettings {
|
|||||||
this.coverImage = coverImage;
|
this.coverImage = coverImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getCompanyTagline() {
|
||||||
|
return companyTagline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCompanyTagline(String companyTagline) {
|
||||||
|
this.companyTagline = companyTagline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getWebsite() {
|
||||||
|
return website;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWebsite(String website) {
|
||||||
|
this.website = website;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSocialLinkedin() {
|
||||||
|
return socialLinkedin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSocialLinkedin(String socialLinkedin) {
|
||||||
|
this.socialLinkedin = socialLinkedin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSocialFacebook() {
|
||||||
|
return socialFacebook;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSocialFacebook(String socialFacebook) {
|
||||||
|
this.socialFacebook = socialFacebook;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSocialInstagram() {
|
||||||
|
return socialInstagram;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSocialInstagram(String socialInstagram) {
|
||||||
|
this.socialInstagram = socialInstagram;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSocialYoutube() {
|
||||||
|
return socialYoutube;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSocialYoutube(String socialYoutube) {
|
||||||
|
this.socialYoutube = socialYoutube;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPublicContactVisible() {
|
||||||
|
return publicContactVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPublicContactVisible(boolean publicContactVisible) {
|
||||||
|
this.publicContactVisible = publicContactVisible;
|
||||||
|
}
|
||||||
|
|
||||||
public Currency getCurrency() {
|
public Currency getCurrency() {
|
||||||
return currency;
|
return currency;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ public record UserSettingsRequest(
|
|||||||
ProfileVisibility profileVisibility,
|
ProfileVisibility profileVisibility,
|
||||||
String avatarImage,
|
String avatarImage,
|
||||||
String coverImage,
|
String coverImage,
|
||||||
|
String companyTagline,
|
||||||
|
String website,
|
||||||
|
String socialLinkedin,
|
||||||
|
String socialFacebook,
|
||||||
|
String socialInstagram,
|
||||||
|
String socialYoutube,
|
||||||
|
Boolean publicContactVisible,
|
||||||
Currency currency,
|
Currency currency,
|
||||||
AreaUnit areaUnit,
|
AreaUnit areaUnit,
|
||||||
Boolean directOffersOnly,
|
Boolean directOffersOnly,
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ public record UserSettingsResponse(
|
|||||||
ProfileVisibility profileVisibility,
|
ProfileVisibility profileVisibility,
|
||||||
String avatarImage,
|
String avatarImage,
|
||||||
String coverImage,
|
String coverImage,
|
||||||
|
String companyTagline,
|
||||||
|
String website,
|
||||||
|
String socialLinkedin,
|
||||||
|
String socialFacebook,
|
||||||
|
String socialInstagram,
|
||||||
|
String socialYoutube,
|
||||||
|
boolean publicContactVisible,
|
||||||
Currency currency,
|
Currency currency,
|
||||||
AreaUnit areaUnit,
|
AreaUnit areaUnit,
|
||||||
boolean directOffersOnly,
|
boolean directOffersOnly,
|
||||||
@@ -29,6 +36,13 @@ public record UserSettingsResponse(
|
|||||||
settings.getProfileVisibility(),
|
settings.getProfileVisibility(),
|
||||||
settings.getAvatarImage(),
|
settings.getAvatarImage(),
|
||||||
settings.getCoverImage(),
|
settings.getCoverImage(),
|
||||||
|
settings.getCompanyTagline(),
|
||||||
|
settings.getWebsite(),
|
||||||
|
settings.getSocialLinkedin(),
|
||||||
|
settings.getSocialFacebook(),
|
||||||
|
settings.getSocialInstagram(),
|
||||||
|
settings.getSocialYoutube(),
|
||||||
|
settings.isPublicContactVisible(),
|
||||||
settings.getCurrency(),
|
settings.getCurrency(),
|
||||||
settings.getAreaUnit(),
|
settings.getAreaUnit(),
|
||||||
settings.isDirectOffersOnly(),
|
settings.isDirectOffersOnly(),
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public class UserSettingsService {
|
|||||||
|
|
||||||
private static final int MAX_BIO_LENGTH = 160;
|
private static final int MAX_BIO_LENGTH = 160;
|
||||||
|
|
||||||
|
private static final int MAX_TAGLINE_LENGTH = 80;
|
||||||
|
|
||||||
|
private static final int MAX_LINK_LENGTH = 200;
|
||||||
|
|
||||||
private final UserSettingsRepository repository;
|
private final UserSettingsRepository repository;
|
||||||
private final TextModerationService textModerationService;
|
private final TextModerationService textModerationService;
|
||||||
|
|
||||||
@@ -78,6 +82,32 @@ public class UserSettingsService {
|
|||||||
if (request.coverImage() != null) {
|
if (request.coverImage() != null) {
|
||||||
settings.setCoverImage(cleanImage(request.coverImage(), "Zdjęcie w tle"));
|
settings.setCoverImage(cleanImage(request.coverImage(), "Zdjęcie w tle"));
|
||||||
}
|
}
|
||||||
|
if (request.companyTagline() != null) {
|
||||||
|
String tagline = request.companyTagline().trim();
|
||||||
|
if (tagline.length() > MAX_TAGLINE_LENGTH) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Hasło firmy może mieć maksymalnie " + MAX_TAGLINE_LENGTH + " znaków");
|
||||||
|
}
|
||||||
|
textModerationService.validateOrThrow(tagline);
|
||||||
|
settings.setCompanyTagline(tagline.isEmpty() ? null : tagline);
|
||||||
|
}
|
||||||
|
if (request.website() != null) {
|
||||||
|
settings.setWebsite(cleanLink(request.website()));
|
||||||
|
}
|
||||||
|
if (request.socialLinkedin() != null) {
|
||||||
|
settings.setSocialLinkedin(cleanLink(request.socialLinkedin()));
|
||||||
|
}
|
||||||
|
if (request.socialFacebook() != null) {
|
||||||
|
settings.setSocialFacebook(cleanLink(request.socialFacebook()));
|
||||||
|
}
|
||||||
|
if (request.socialInstagram() != null) {
|
||||||
|
settings.setSocialInstagram(cleanLink(request.socialInstagram()));
|
||||||
|
}
|
||||||
|
if (request.socialYoutube() != null) {
|
||||||
|
settings.setSocialYoutube(cleanLink(request.socialYoutube()));
|
||||||
|
}
|
||||||
|
if (request.publicContactVisible() != null) {
|
||||||
|
settings.setPublicContactVisible(request.publicContactVisible());
|
||||||
|
}
|
||||||
if (request.currency() != null) {
|
if (request.currency() != null) {
|
||||||
settings.setCurrency(request.currency());
|
settings.setCurrency(request.currency());
|
||||||
}
|
}
|
||||||
@@ -136,6 +166,25 @@ public class UserSettingsService {
|
|||||||
return incoming < 0 ? null : incoming;
|
return incoming < 0 ? null : incoming;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizacja adresu www / linku social: pusty = wyczyszczone (null). Brak schematu dopisuje
|
||||||
|
* https://, dzieki czemu link renderowany jako href jest zawsze http(s) - odcina to proby
|
||||||
|
* wstrzykniecia "javascript:" i innych schematow, bo trafiaja za "https://".
|
||||||
|
*/
|
||||||
|
private String cleanLink(String value) {
|
||||||
|
String trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!trimmed.matches("(?i)^https?://.*")) {
|
||||||
|
trimmed = "https://" + trimmed;
|
||||||
|
}
|
||||||
|
if (trimmed.length() > MAX_LINK_LENGTH) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link może mieć maksymalnie " + MAX_LINK_LENGTH + " znaków");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
private String cleanImage(String dataUrl, String label) {
|
private String cleanImage(String dataUrl, String label) {
|
||||||
String trimmed = dataUrl.trim();
|
String trimmed = dataUrl.trim();
|
||||||
if (trimmed.isEmpty()) {
|
if (trimmed.isEmpty()) {
|
||||||
|
|||||||
+503
-18
@@ -131,6 +131,10 @@ export type ApiListingDetail = {
|
|||||||
ownerAccountType: AccountType | null;
|
ownerAccountType: AccountType | null;
|
||||||
// Nazwa wlasciciela - uzywana jako etykieta oferty firmowej.
|
// Nazwa wlasciciela - uzywana jako etykieta oferty firmowej.
|
||||||
ownerName: string | null;
|
ownerName: string | null;
|
||||||
|
// Data zalozenia konta wlasciciela ("Na Polska Lokalnie od...") - null, gdy ogloszenie nie ma konta.
|
||||||
|
ownerCreatedAt: string | null;
|
||||||
|
// Ostatnia aktywnosc wlasciciela ("Ostatnio aktywny/a...") - null, gdy ogloszenie nie ma konta.
|
||||||
|
ownerLastActiveAt: string | null;
|
||||||
status: ApiListingStatus;
|
status: ApiListingStatus;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
viewsCount?: number;
|
viewsCount?: number;
|
||||||
@@ -8111,6 +8115,41 @@ function priceAlertRelativeTime(iso: string): string {
|
|||||||
return days === 1 ? 'wczoraj' : `${days} dni temu`;
|
return days === 1 ? 'wczoraj' : `${days} dni temu`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "W serwisie od sierpień 2026" - miesiac i rok zalozenia konta sprzedajacego. Puste, gdy ogloszenie
|
||||||
|
// nie ma konta w serwisie (dane demonstracyjne) albo data jest nieczytelna.
|
||||||
|
function sellerMemberSince(iso: string | null): string {
|
||||||
|
if (!iso) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return `W serwisie od ${date.toLocaleDateString('pl-PL', { month: 'long', year: 'numeric' })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Ostatnia aktywność: 2 godz. temu"; do 5 minut od aktywnosci pokazujemy "teraz online". Sformulowanie
|
||||||
|
// bez rodzaju gramatycznego, bo dziala tak samo dla osoby, jak i dla firmy.
|
||||||
|
function sellerLastActive(iso: string | null): string {
|
||||||
|
if (!iso) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const time = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(time)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const minutes = Math.max(0, Math.round((Date.now() - time) / 60000));
|
||||||
|
if (minutes < 5) {
|
||||||
|
return 'Aktywność: teraz online';
|
||||||
|
}
|
||||||
|
return `Ostatnia aktywność: ${priceAlertRelativeTime(iso)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Czytelna etykieta adresu www - bez schematu i koncowego ukosnika (np. "www.matexi.pl").
|
||||||
|
function companyWebsiteLabel(url: string): string {
|
||||||
|
return url.replace(/^https?:\/\//i, '').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
// Procentowa zmiana miedzy dwiema cenami; brak punktu odniesienia = 0%.
|
// Procentowa zmiana miedzy dwiema cenami; brak punktu odniesienia = 0%.
|
||||||
function priceAlertChangePercent(previous: number, current: number): number {
|
function priceAlertChangePercent(previous: number, current: number): number {
|
||||||
return previous > 0 ? ((current - previous) / previous) * 100 : 0;
|
return previous > 0 ? ((current - previous) / previous) * 100 : 0;
|
||||||
@@ -11323,6 +11362,18 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
|
|||||||
}));
|
}));
|
||||||
const [identityBanner, setIdentityBanner] = useState<Banner>(null);
|
const [identityBanner, setIdentityBanner] = useState<Banner>(null);
|
||||||
const [isSavingIdentity, setIsSavingIdentity] = useState(false);
|
const [isSavingIdentity, setIsSavingIdentity] = useState(false);
|
||||||
|
// Dane firmowe pokazywane na profilu publicznym (tylko konto COMPANY). Ladowane z ustawien konta.
|
||||||
|
const [companyProfile, setCompanyProfile] = useState({
|
||||||
|
companyTagline: '',
|
||||||
|
website: '',
|
||||||
|
socialLinkedin: '',
|
||||||
|
socialFacebook: '',
|
||||||
|
socialInstagram: '',
|
||||||
|
socialYoutube: '',
|
||||||
|
publicContactVisible: false,
|
||||||
|
});
|
||||||
|
const [companyBanner, setCompanyBanner] = useState<Banner>(null);
|
||||||
|
const [isSavingCompany, setIsSavingCompany] = useState(false);
|
||||||
const [isGusLoading, setIsGusLoading] = useState(false);
|
const [isGusLoading, setIsGusLoading] = useState(false);
|
||||||
const [isSuggestingNick, setIsSuggestingNick] = useState(false);
|
const [isSuggestingNick, setIsSuggestingNick] = useState(false);
|
||||||
const [isPhoneTouched, setIsPhoneTouched] = useState(false);
|
const [isPhoneTouched, setIsPhoneTouched] = useState(false);
|
||||||
@@ -11455,6 +11506,15 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
|
|||||||
setSettings(loaded);
|
setSettings(loaded);
|
||||||
setConsent({ emailConsent: marketing.emailConsent, smsConsent: marketing.smsConsent });
|
setConsent({ emailConsent: marketing.emailConsent, smsConsent: marketing.smsConsent });
|
||||||
setIdentity((current) => ({ ...current, bio: loaded.bio ?? '' }));
|
setIdentity((current) => ({ ...current, bio: loaded.bio ?? '' }));
|
||||||
|
setCompanyProfile({
|
||||||
|
companyTagline: loaded.companyTagline ?? '',
|
||||||
|
website: loaded.website ?? '',
|
||||||
|
socialLinkedin: loaded.socialLinkedin ?? '',
|
||||||
|
socialFacebook: loaded.socialFacebook ?? '',
|
||||||
|
socialInstagram: loaded.socialInstagram ?? '',
|
||||||
|
socialYoutube: loaded.socialYoutube ?? '',
|
||||||
|
publicContactVisible: loaded.publicContactVisible ?? false,
|
||||||
|
});
|
||||||
setSearchPrefs({
|
setSearchPrefs({
|
||||||
locations: loaded.searchLocations ?? '',
|
locations: loaded.searchLocations ?? '',
|
||||||
propertyType: loaded.searchPropertyType ?? '',
|
propertyType: loaded.searchPropertyType ?? '',
|
||||||
@@ -11665,6 +11725,28 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Zapis danych firmowych profilu publicznego (haslo, www, social media, publiczny kontakt).
|
||||||
|
const saveCompanyProfile = async () => {
|
||||||
|
setIsSavingCompany(true);
|
||||||
|
setCompanyBanner(null);
|
||||||
|
try {
|
||||||
|
await patchSettings({
|
||||||
|
companyTagline: companyProfile.companyTagline.trim(),
|
||||||
|
website: companyProfile.website.trim(),
|
||||||
|
socialLinkedin: companyProfile.socialLinkedin.trim(),
|
||||||
|
socialFacebook: companyProfile.socialFacebook.trim(),
|
||||||
|
socialInstagram: companyProfile.socialInstagram.trim(),
|
||||||
|
socialYoutube: companyProfile.socialYoutube.trim(),
|
||||||
|
publicContactVisible: companyProfile.publicContactVisible,
|
||||||
|
});
|
||||||
|
flashBanner('company', setCompanyBanner, { text: 'Dane firmy zapisane.', tone: 'success' });
|
||||||
|
} catch (error) {
|
||||||
|
setCompanyBanner({ text: error instanceof Error ? error.message : 'Nie udało się zapisać danych firmy.', tone: 'error' });
|
||||||
|
} finally {
|
||||||
|
setIsSavingCompany(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleImageUpload = async (event: ReactChangeEvent<HTMLInputElement>, target: SettingsUploadTarget) => {
|
const handleImageUpload = async (event: ReactChangeEvent<HTMLInputElement>, target: SettingsUploadTarget) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -12237,6 +12319,88 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* --- Profil firmy: dane pokazywane publicznie na rozbudowanym profilu (tylko COMPANY) --- */}
|
||||||
|
{isCompany && (
|
||||||
|
<section className="settings-card settings-card-wide" aria-label="Profil firmy">
|
||||||
|
<div className="settings-card-head">
|
||||||
|
<h2>Profil firmy</h2>
|
||||||
|
<p>Te dane widać na publicznym profilu Twojej firmy. Wszystkie pola są opcjonalne.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{companyBanner && <p className={`settings-banner ${companyBanner.tone}`} role="status">{companyBanner.text}</p>}
|
||||||
|
|
||||||
|
<div className="profile-form-grid">
|
||||||
|
<label className="profile-form-wide">
|
||||||
|
<span>Hasło firmy</span>
|
||||||
|
<input
|
||||||
|
value={companyProfile.companyTagline}
|
||||||
|
maxLength={80}
|
||||||
|
placeholder="np. Deweloper mieszkaniowy"
|
||||||
|
onChange={(event) => setCompanyProfile((current) => ({ ...current, companyTagline: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="profile-form-wide">
|
||||||
|
<span>Strona www</span>
|
||||||
|
<input
|
||||||
|
value={companyProfile.website}
|
||||||
|
placeholder="np. www.twojafirma.pl"
|
||||||
|
onChange={(event) => setCompanyProfile((current) => ({ ...current, website: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>LinkedIn</span>
|
||||||
|
<input
|
||||||
|
value={companyProfile.socialLinkedin}
|
||||||
|
placeholder="Link do profilu LinkedIn"
|
||||||
|
onChange={(event) => setCompanyProfile((current) => ({ ...current, socialLinkedin: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Facebook</span>
|
||||||
|
<input
|
||||||
|
value={companyProfile.socialFacebook}
|
||||||
|
placeholder="Link do profilu Facebook"
|
||||||
|
onChange={(event) => setCompanyProfile((current) => ({ ...current, socialFacebook: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Instagram</span>
|
||||||
|
<input
|
||||||
|
value={companyProfile.socialInstagram}
|
||||||
|
placeholder="Link do profilu Instagram"
|
||||||
|
onChange={(event) => setCompanyProfile((current) => ({ ...current, socialInstagram: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>YouTube</span>
|
||||||
|
<input
|
||||||
|
value={companyProfile.socialYoutube}
|
||||||
|
placeholder="Link do kanału YouTube"
|
||||||
|
onChange={(event) => setCompanyProfile((current) => ({ ...current, socialYoutube: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="settings-toggle-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={companyProfile.publicContactVisible}
|
||||||
|
onChange={(event) => setCompanyProfile((current) => ({ ...current, publicContactVisible: event.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Pokazuj telefon i adres na profilu</strong>
|
||||||
|
<small>Numer i adres z sekcji „Moje dane” będą widoczne publicznie (numer po kliknięciu „Pokaż numer”).</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="settings-card-actions">
|
||||||
|
<button type="button" className="primary" onClick={() => { void saveCompanyProfile(); }} disabled={isSavingCompany}>
|
||||||
|
{isSavingCompany ? 'Zapisywanie...' : 'Zapisz dane firmy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* --- Preferencje wyszukiwania: realne pola zapisywane na koncie --- */}
|
{/* --- Preferencje wyszukiwania: realne pola zapisywane na koncie --- */}
|
||||||
<section className="settings-card" aria-label="Preferencje wyszukiwania">
|
<section className="settings-card" aria-label="Preferencje wyszukiwania">
|
||||||
<div className="settings-card-head">
|
<div className="settings-card-head">
|
||||||
@@ -24657,6 +24821,18 @@ type PublicProfile = {
|
|||||||
accountType: AccountType;
|
accountType: AccountType;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
memberSince: string;
|
memberSince: string;
|
||||||
|
// Ostatnia aktywnosc wlasciciela profilu - null, gdy nieodnotowana.
|
||||||
|
lastActiveAt: string | null;
|
||||||
|
// Dane firmowe (konto COMPANY) - null dla kont prywatnych albo gdy nieuzupelnione.
|
||||||
|
companyTagline: string | null;
|
||||||
|
website: string | null;
|
||||||
|
socialLinkedin: string | null;
|
||||||
|
socialFacebook: string | null;
|
||||||
|
socialInstagram: string | null;
|
||||||
|
socialYoutube: string | null;
|
||||||
|
// Telefon i adres - tylko gdy firma wlaczyla publiczny kontakt; inaczej null.
|
||||||
|
publicPhone: string | null;
|
||||||
|
publicAddress: string | null;
|
||||||
// Profil prywatny ogladany przez kogos innego niz wlasciciel - reszta pol jest wtedy pusta.
|
// Profil prywatny ogladany przez kogos innego niz wlasciciel - reszta pol jest wtedy pusta.
|
||||||
privateProfile: boolean;
|
privateProfile: boolean;
|
||||||
listingsCount: number;
|
listingsCount: number;
|
||||||
@@ -24676,6 +24852,255 @@ type PublicProfile = {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rozbudowany profil konta firmowego (deweloper/agencja) pod /profil/:id oraz /u/:nick. W odroznieniu
|
||||||
|
* od profilu osoby prywatnej pokazuje baner z logo, dane firmy (haslo, www, social media, opcjonalny
|
||||||
|
* kontakt), statystyki policzone z realnych ogloszen oraz zakladki. Zakladki trzymamy w query stringu
|
||||||
|
* (?sekcja=), zgodnie ze standardem routingu - adres da sie skopiowac, odswiezyc i cofnac.
|
||||||
|
*/
|
||||||
|
function CompanyProfile({ profile }: { profile: PublicProfile }) {
|
||||||
|
const { formatPrice, formatArea } = useDisplay();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [phoneRevealed, setPhoneRevealed] = useState(false);
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: 'o-firmie', label: 'O firmie' },
|
||||||
|
{ key: 'ogloszenia', label: `Ogłoszenia ${profile.listingsCount}` },
|
||||||
|
{ key: 'kontakt', label: 'Kontakt' },
|
||||||
|
];
|
||||||
|
const activeTab = searchParams.get('sekcja') ?? 'o-firmie';
|
||||||
|
const changeTab = (key: string) => {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (key === 'o-firmie') {
|
||||||
|
next.delete('sekcja');
|
||||||
|
} else {
|
||||||
|
next.set('sekcja', key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}, { replace: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const memberSinceLabel = new Date(profile.memberSince).toLocaleDateString('pl-PL', { month: 'long', year: 'numeric' });
|
||||||
|
const lastActiveLabel = sellerLastActive(profile.lastActiveAt);
|
||||||
|
// Statystyki wylacznie z realnych ogloszen firmy - zadnych zmyslonych liczb.
|
||||||
|
const forSaleCount = profile.listings.filter((listing) => listing.offerType === 'SALE').length;
|
||||||
|
const forRentCount = profile.listings.filter((listing) => listing.offerType === 'RENT').length;
|
||||||
|
const cityCount = new Set(
|
||||||
|
profile.listings.map((listing) => listing.city.trim().toLowerCase()).filter(Boolean),
|
||||||
|
).size;
|
||||||
|
|
||||||
|
const socialLinks = [
|
||||||
|
{ key: 'linkedin', url: profile.socialLinkedin, label: 'LinkedIn' },
|
||||||
|
{ key: 'facebook', url: profile.socialFacebook, label: 'Facebook' },
|
||||||
|
{ key: 'instagram', url: profile.socialInstagram, label: 'Instagram' },
|
||||||
|
{ key: 'youtube', url: profile.socialYoutube, label: 'YouTube' },
|
||||||
|
].filter((item): item is { key: string; url: string; label: string } => Boolean(item.url));
|
||||||
|
|
||||||
|
const hasContactData = Boolean(profile.website || profile.publicPhone || profile.publicAddress);
|
||||||
|
|
||||||
|
const renderListingCard = (listing: PublicProfile['listings'][number]) => (
|
||||||
|
<Link className="public-listing-card" key={listing.id} to={listingPath(listing.id)}>
|
||||||
|
<div
|
||||||
|
className="public-listing-photo"
|
||||||
|
style={{ backgroundImage: `url(${listing.coverPhoto || heroImage})`, backgroundPosition: 'center center' }}
|
||||||
|
>
|
||||||
|
<span className={`public-listing-badge ${listing.offerType === 'RENT' ? 'rent' : 'sale'}`}>
|
||||||
|
{listing.offerType === 'RENT' ? 'Na wynajem' : 'Na sprzedaż'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="public-listing-body">
|
||||||
|
<strong>{formatPrice(listing.price)}{listing.offerType === 'RENT' ? ' / mies.' : ''}</strong>
|
||||||
|
<h3>{listing.title}</h3>
|
||||||
|
<small><Icon name="pin" /> {[listing.city, listing.district].filter(Boolean).join(', ')}</small>
|
||||||
|
<SpecTiles
|
||||||
|
className="compact"
|
||||||
|
items={[
|
||||||
|
...(listing.area !== null ? [{ icon: 'area', value: formatArea(listing.area), caption: 'Powierzchnia' }] : []),
|
||||||
|
...(listing.rooms !== null ? [{ icon: 'plan', value: `${listing.rooms}`, caption: listing.rooms === 1 ? 'Pokój' : 'Pokoje' }] : []),
|
||||||
|
...(listing.floor ? [{ icon: 'stairs', value: `${listingFloorText(listing.floor, listing.buildingFloors)}`, caption: 'Piętro' }] : []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="public-listing-foot">
|
||||||
|
<em>{listingRelativeDate(listing.createdAt) || 'Dodane niedawno'}</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Karta "Szybki kontakt" - pokazywana obok zakladek "O firmie" i "Kontakt". Zawiera wylacznie
|
||||||
|
// realnie dzialajace elementy: strone www, telefon (z odslanianiem numeru) i adres.
|
||||||
|
const contactCard = (
|
||||||
|
<aside className="company-profile-contact-card">
|
||||||
|
<h2>Szybki kontakt</h2>
|
||||||
|
{hasContactData ? (
|
||||||
|
<>
|
||||||
|
<p>Masz pytania? Skontaktuj się z firmą.</p>
|
||||||
|
<ul className="company-contact-list">
|
||||||
|
{profile.publicPhone && (
|
||||||
|
<li>
|
||||||
|
<Icon name="phone" />
|
||||||
|
{phoneRevealed ? (
|
||||||
|
<a href={`tel:${profile.publicPhone.replace(/\s+/g, '')}`}>{profile.publicPhone}</a>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="company-phone-reveal" onClick={() => setPhoneRevealed(true)}>
|
||||||
|
Pokaż numer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{profile.publicAddress && (
|
||||||
|
<li><Icon name="pin" /> <span>{profile.publicAddress}</span></li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
{profile.website && (
|
||||||
|
<a className="company-contact-www" href={profile.website} target="_blank" rel="noopener noreferrer nofollow">
|
||||||
|
<Icon name="globe" /> Zobacz stronę www
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="company-contact-empty">
|
||||||
|
Aby napisać do firmy, otwórz jedno z jej ogłoszeń i użyj przycisku „Napisz wiadomość”.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="company-profile">
|
||||||
|
<section className="company-profile-header">
|
||||||
|
<div
|
||||||
|
className="company-profile-banner"
|
||||||
|
style={{ backgroundImage: `url(${profile.coverImage || cityImage})` }}
|
||||||
|
/>
|
||||||
|
<div className="company-profile-head-row">
|
||||||
|
<div className="company-profile-logo">
|
||||||
|
<Avatar className="company-profile-logo-img" name={profile.fullName} image={profile.avatarImage} singleLetter />
|
||||||
|
</div>
|
||||||
|
<div className="company-profile-identity">
|
||||||
|
{profile.verified && (
|
||||||
|
<span className="company-verified-badge"><Icon name="shield" /> Zweryfikowana firma</span>
|
||||||
|
)}
|
||||||
|
<h1>{profile.fullName}</h1>
|
||||||
|
{profile.companyTagline && <p className="company-profile-tagline">{profile.companyTagline}</p>}
|
||||||
|
<div className="company-profile-quicklinks">
|
||||||
|
{profile.publicAddress && <span><Icon name="pin" /> {profile.publicAddress}</span>}
|
||||||
|
{profile.publicPhone && (
|
||||||
|
<span>
|
||||||
|
<Icon name="phone" />
|
||||||
|
{phoneRevealed ? profile.publicPhone : (
|
||||||
|
<button type="button" className="company-phone-reveal" onClick={() => setPhoneRevealed(true)}>Pokaż numer</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{profile.website && (
|
||||||
|
<a href={profile.website} target="_blank" rel="noopener noreferrer nofollow"><Icon name="globe" /> {companyWebsiteLabel(profile.website)}</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{socialLinks.length > 0 && (
|
||||||
|
<div className="company-profile-socials">
|
||||||
|
{socialLinks.map((item) => (
|
||||||
|
<a key={item.key} href={item.url} target="_blank" rel="noopener noreferrer nofollow" aria-label={item.label} title={item.label}>
|
||||||
|
<Icon name={item.key} />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="company-profile-tabs" aria-label="Sekcje profilu firmy">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
className={activeTab === tab.key ? 'active' : ''}
|
||||||
|
onClick={() => changeTab(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{activeTab === 'o-firmie' && (
|
||||||
|
<div className="company-profile-body">
|
||||||
|
<div className="company-profile-main">
|
||||||
|
<section className="settings-card company-about">
|
||||||
|
<h2>O firmie</h2>
|
||||||
|
<p>{profile.bio || 'Ta firma nie dodała jeszcze opisu swojej działalności.'}</p>
|
||||||
|
<div className="company-stats">
|
||||||
|
<div><Icon name="pin" /><strong>{cityCount}</strong><small>{cityCount === 1 ? 'miasto' : 'miast(a)'}</small></div>
|
||||||
|
<div><Icon name="house" /><strong>{profile.listingsCount}</strong><small>ogłoszeń</small></div>
|
||||||
|
<div><Icon name="tag" /><strong>{forSaleCount}</strong><small>na sprzedaż</small></div>
|
||||||
|
<div><Icon name="key" /><strong>{forRentCount}</strong><small>na wynajem</small></div>
|
||||||
|
</div>
|
||||||
|
<div className="company-profile-badges">
|
||||||
|
<span><Icon name="calendar" /> W serwisie od {memberSinceLabel}</span>
|
||||||
|
{lastActiveLabel && <span><Icon name="clock" /> {lastActiveLabel}</span>}
|
||||||
|
{profile.verified && <span><Icon name="shield" /> Konto zweryfikowane</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-card">
|
||||||
|
<div className="settings-card-head company-listings-head">
|
||||||
|
<h2>Najnowsze ogłoszenia</h2>
|
||||||
|
{profile.listingsCount > 3 && (
|
||||||
|
<button type="button" className="company-see-all" onClick={() => changeTab('ogloszenia')}>
|
||||||
|
Zobacz wszystkie <Icon name="arrow" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{profile.listings.length === 0 ? (
|
||||||
|
<p className="admin-empty">Ta firma nie ma obecnie opublikowanych ogłoszeń.</p>
|
||||||
|
) : (
|
||||||
|
<div className="public-listing-grid">
|
||||||
|
{profile.listings.slice(0, 3).map(renderListingCard)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{contactCard}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'ogloszenia' && (
|
||||||
|
<section className="settings-card">
|
||||||
|
<div className="settings-card-head">
|
||||||
|
<h2>Ogłoszenia ({profile.listingsCount})</h2>
|
||||||
|
</div>
|
||||||
|
{profile.listings.length === 0 ? (
|
||||||
|
<p className="admin-empty">Ta firma nie ma obecnie opublikowanych ogłoszeń.</p>
|
||||||
|
) : (
|
||||||
|
<div className="public-listing-grid">
|
||||||
|
{profile.listings.map(renderListingCard)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'kontakt' && (
|
||||||
|
<div className="company-profile-body">
|
||||||
|
<section className="settings-card company-about">
|
||||||
|
<h2>Kontakt</h2>
|
||||||
|
{hasContactData ? (
|
||||||
|
<p>Skorzystaj z danych kontaktowych firmy lub napisz wiadomość z poziomu jej ogłoszenia.</p>
|
||||||
|
) : (
|
||||||
|
<p>Ta firma nie udostępniła publicznie danych kontaktowych. Aby się z nią skontaktować, otwórz jedno z jej ogłoszeń i użyj przycisku „Napisz wiadomość”.</p>
|
||||||
|
)}
|
||||||
|
<div className="company-profile-badges">
|
||||||
|
<span><Icon name="calendar" /> W serwisie od {memberSinceLabel}</span>
|
||||||
|
{lastActiveLabel && <span><Icon name="clock" /> {lastActiveLabel}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{contactCard}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Profil innego uzytkownika (/profil/:id) - otwierany z rozmowy w wiadomosciach.
|
* Profil innego uzytkownika (/profil/:id) - otwierany z rozmowy w wiadomosciach.
|
||||||
* Pokazuje wylacznie dane jawne; kontakt odbywa sie przez wiadomosci, nie przez e-mail.
|
* Pokazuje wylacznie dane jawne; kontakt odbywa sie przez wiadomosci, nie przez e-mail.
|
||||||
@@ -24745,7 +25170,12 @@ function PublicProfilePage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && profile && !profile.privateProfile && (
|
{/* Konto firmowe dostaje rozbudowany profil (banner, dane firmy, statystyki, oferty). */}
|
||||||
|
{!loading && !error && profile && !profile.privateProfile && profile.accountType === 'COMPANY' && (
|
||||||
|
<CompanyProfile profile={profile} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && profile && !profile.privateProfile && profile.accountType !== 'COMPANY' && (
|
||||||
<>
|
<>
|
||||||
<section className="public-profile-cover-card">
|
<section className="public-profile-cover-card">
|
||||||
{/* Zdjecie w tle ustawione przez wlasciciela; bez niego zostaje grafika serwisu. */}
|
{/* Zdjecie w tle ustawione przez wlasciciela; bez niego zostaje grafika serwisu. */}
|
||||||
@@ -24760,7 +25190,7 @@ function PublicProfilePage() {
|
|||||||
<div className="public-profile-meta">
|
<div className="public-profile-meta">
|
||||||
{profile.nick && <span><Icon name="user" /> @{profile.nick}</span>}
|
{profile.nick && <span><Icon name="user" /> @{profile.nick}</span>}
|
||||||
<span><Icon name="calendar" /> W serwisie od {memberSinceLabel}</span>
|
<span><Icon name="calendar" /> W serwisie od {memberSinceLabel}</span>
|
||||||
<span><Icon name="house" /> {profile.accountType === 'COMPANY' ? 'Firma' : 'Osoba prywatna'}</span>
|
<span><Icon name="house" /> Osoba prywatna</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Opis "o mnie" z ustawien konta - pole opcjonalne, wiec pokazujemy tylko gdy wypelnione. */}
|
{/* Opis "o mnie" z ustawien konta - pole opcjonalne, wiec pokazujemy tylko gdy wypelnione. */}
|
||||||
{profile.bio && <p className="public-profile-bio">{profile.bio}</p>}
|
{profile.bio && <p className="public-profile-bio">{profile.bio}</p>}
|
||||||
@@ -24823,7 +25253,7 @@ function PublicProfilePage() {
|
|||||||
<h2>O użytkowniku</h2>
|
<h2>O użytkowniku</h2>
|
||||||
<p>Ten użytkownik nie dodał jeszcze opisu swojego profilu.</p>
|
<p>Ten użytkownik nie dodał jeszcze opisu swojego profilu.</p>
|
||||||
<div className="public-profile-badges">
|
<div className="public-profile-badges">
|
||||||
<span><Icon name="user" /> {profile.accountType === 'COMPANY' ? 'Konto firmowe' : 'Konto prywatne'}</span>
|
<span><Icon name="user" /> Konto prywatne</span>
|
||||||
<span><Icon name="calendar" /> W serwisie od {memberSinceLabel}</span>
|
<span><Icon name="calendar" /> W serwisie od {memberSinceLabel}</span>
|
||||||
{profile.verified && <span><Icon name="shield" /> Zweryfikowany użytkownik</span>}
|
{profile.verified && <span><Icon name="shield" /> Zweryfikowany użytkownik</span>}
|
||||||
<span><Icon name="message" /> Kontakt przez wiadomości</span>
|
<span><Icon name="message" /> Kontakt przez wiadomości</span>
|
||||||
@@ -27316,6 +27746,10 @@ function ListingDetailPage({
|
|||||||
: listing.ownerAccountType === 'PERSONAL'
|
: listing.ownerAccountType === 'PERSONAL'
|
||||||
? 'Osoba prywatna'
|
? 'Osoba prywatna'
|
||||||
: 'Oficjalny kontakt zespołem serwisu Polska Lokalnie';
|
: 'Oficjalny kontakt zespołem serwisu Polska Lokalnie';
|
||||||
|
// Dodatkowe wiersze na karcie sprzedajacego - realne dane konta wlasciciela (nie pokazujemy ich
|
||||||
|
// przy wlasnym ogloszeniu ani dla wpisow bez konta w serwisie, gdzie oba pola sa puste).
|
||||||
|
const sellerLastActiveText = isOwner ? '' : sellerLastActive(listing.ownerLastActiveAt);
|
||||||
|
const sellerMemberSinceText = isOwner ? '' : sellerMemberSince(listing.ownerCreatedAt);
|
||||||
const favorite = isFavorite(listing.id);
|
const favorite = isFavorite(listing.id);
|
||||||
const listingAlertEnabled = isListingAlertEnabled(listing.id);
|
const listingAlertEnabled = isListingAlertEnabled(listing.id);
|
||||||
const virtualTourEmbed = listing.virtualTourUrl ? getVirtualTourEmbed(listing.virtualTourUrl) : null;
|
const virtualTourEmbed = listing.virtualTourUrl ? getVirtualTourEmbed(listing.virtualTourUrl) : null;
|
||||||
@@ -27324,7 +27758,7 @@ function ListingDetailPage({
|
|||||||
const copyShareLink = async () => {
|
const copyShareLink = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
setShareHint('Skopiowano link');
|
setShareHint('Link został skopiowany');
|
||||||
window.setTimeout(() => setShareHint(null), 1800);
|
window.setTimeout(() => setShareHint(null), 1800);
|
||||||
} catch {
|
} catch {
|
||||||
setShareHint('Nie udało się skopiować');
|
setShareHint('Nie udało się skopiować');
|
||||||
@@ -27336,6 +27770,26 @@ function ListingDetailPage({
|
|||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Udostepnianie e-mailem: domyslny klient poczty z tytulem oferty w temacie i linkiem w tresci.
|
||||||
|
const shareByEmail = () => {
|
||||||
|
const subject = encodeURIComponent(listing.title);
|
||||||
|
const body = encodeURIComponent(`${listing.title}\n${shareUrl}`);
|
||||||
|
window.location.href = `mailto:?subject=${subject}&body=${body}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Systemowe udostepnianie (telefon) - a gdy niedostepne albo uzytkownik anuluje, kopiujemy link.
|
||||||
|
const shareViaSystem = async () => {
|
||||||
|
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
|
||||||
|
try {
|
||||||
|
await navigator.share({ title: listing.title, text: listing.city, url: shareUrl });
|
||||||
|
} catch {
|
||||||
|
// Uzytkownik zamknal panel udostepniania - nic nie robimy.
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await copyShareLink();
|
||||||
|
};
|
||||||
|
|
||||||
// Przycisk glowny pokazuje numer telefonu (wiadomosci obsluguje osobny przycisk "Napisz wiadomosc").
|
// Przycisk glowny pokazuje numer telefonu (wiadomosci obsluguje osobny przycisk "Napisz wiadomosc").
|
||||||
const revealContact = () => {
|
const revealContact = () => {
|
||||||
setIsPhoneRevealed(true);
|
setIsPhoneRevealed(true);
|
||||||
@@ -27791,6 +28245,8 @@ function ListingDetailPage({
|
|||||||
<div>
|
<div>
|
||||||
<strong>{sellerName}</strong>
|
<strong>{sellerName}</strong>
|
||||||
<small>{sellerRole}</small>
|
<small>{sellerRole}</small>
|
||||||
|
{sellerLastActiveText && <small className="listing-detail-seller-meta">{sellerLastActiveText}</small>}
|
||||||
|
{sellerMemberSinceText && <small className="listing-detail-seller-meta">{sellerMemberSinceText}</small>}
|
||||||
{isPhoneRevealed && listing.contactPhone && (
|
{isPhoneRevealed && listing.contactPhone && (
|
||||||
<a href={`tel:${listing.contactPhone.replace(/\s+/g, '')}`}><Icon name="phone" /> {listing.contactPhone}</a>
|
<a href={`tel:${listing.contactPhone.replace(/\s+/g, '')}`}><Icon name="phone" /> {listing.contactPhone}</a>
|
||||||
)}
|
)}
|
||||||
@@ -27809,20 +28265,10 @@ function ListingDetailPage({
|
|||||||
<strong>Udostępnij ofertę</strong>
|
<strong>Udostępnij ofertę</strong>
|
||||||
<div className="listing-detail-share-actions">
|
<div className="listing-detail-share-actions">
|
||||||
<button type="button" onClick={copyShareLink} aria-label="Kopiuj link"><Icon name="link" /></button>
|
<button type="button" onClick={copyShareLink} aria-label="Kopiuj link"><Icon name="link" /></button>
|
||||||
<button type="button" onClick={() => openShareWindow(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`)} aria-label="Udostępnij na Facebooku">f</button>
|
<button type="button" onClick={() => openShareWindow(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`)} aria-label="Udostępnij na Facebooku"><Icon name="facebook" /></button>
|
||||||
<button type="button" onClick={() => openShareWindow(`https://m.me/?link=${encodeURIComponent(shareUrl)}`)} aria-label="Udostępnij w Messengerze"><Icon name="message" /></button>
|
<button type="button" onClick={shareByEmail} aria-label="Udostępnij przez e-mail"><Icon name="message" /></button>
|
||||||
<button type="button" onClick={() => openShareWindow(`https://wa.me/?text=${encodeURIComponent(`${listing.title} ${shareUrl}`)}`)} aria-label="Udostępnij na WhatsApp">wa</button>
|
<button type="button" onClick={() => openShareWindow(`https://wa.me/?text=${encodeURIComponent(`${listing.title} ${shareUrl}`)}`)} aria-label="Udostępnij na WhatsApp"><Icon name="whatsapp" /></button>
|
||||||
<button
|
<button type="button" onClick={shareViaSystem} aria-label="Więcej opcji">
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
if (navigator.share) {
|
|
||||||
await navigator.share({ title: listing.title, text: listing.city, url: shareUrl });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await copyShareLink();
|
|
||||||
}}
|
|
||||||
aria-label="Więcej opcji"
|
|
||||||
>
|
|
||||||
<Icon name="dots" />
|
<Icon name="dots" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -28346,6 +28792,45 @@ function AdminListingPreviewModal({ listingId, busy, onClose, onApprove, onRejec
|
|||||||
|
|
||||||
function Icon({ name }: { name: string }) {
|
function Icon({ name }: { name: string }) {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
|
// Logotypy marek - wypelniane (fill), a nie obrysowane jak reszta ikon, wiec inline style
|
||||||
|
// nadpisuje globalna regule `svg { fill:none; stroke:currentColor }`. Kolory oryginalne marek.
|
||||||
|
case 'facebook':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" style={{ fill: '#1877F2', stroke: 'none' }}>
|
||||||
|
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'whatsapp':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" style={{ fill: '#25D366', stroke: 'none' }}>
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.71.306 1.263.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.885-9.885 9.885M20.52 3.449C18.24 1.245 15.24 0 12.045 0 5.463 0 .104 5.359.101 11.892c0 2.096.547 4.142 1.588 5.945L0 24l6.335-1.652a11.882 11.882 0 005.71 1.446h.006c6.585 0 11.946-5.359 11.949-11.893a11.821 11.821 0 00-3.481-8.413Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'linkedin':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" style={{ fill: 'currentColor', stroke: 'none' }}>
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'instagram':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" style={{ fill: 'currentColor', stroke: 'none' }}>
|
||||||
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'youtube':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" style={{ fill: 'currentColor', stroke: 'none' }}>
|
||||||
|
<path d="M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'globe':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M3 12h18M12 3c2.6 2.7 2.6 15.3 0 18M12 3c-2.6 2.7-2.6 15.3 0 18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case 'logo':
|
case 'logo':
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 28 28" aria-hidden="true">
|
<svg viewBox="0 0 28 28" aria-hidden="true">
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export type UserSettings = {
|
|||||||
profileVisibility: ProfileVisibility;
|
profileVisibility: ProfileVisibility;
|
||||||
avatarImage: string | null;
|
avatarImage: string | null;
|
||||||
coverImage: string | null;
|
coverImage: string | null;
|
||||||
|
// Dane firmowe na profilu publicznym (konto COMPANY).
|
||||||
|
companyTagline: string | null;
|
||||||
|
website: string | null;
|
||||||
|
socialLinkedin: string | null;
|
||||||
|
socialFacebook: string | null;
|
||||||
|
socialInstagram: string | null;
|
||||||
|
socialYoutube: string | null;
|
||||||
|
publicContactVisible: boolean;
|
||||||
currency: Currency;
|
currency: Currency;
|
||||||
areaUnit: AreaUnit;
|
areaUnit: AreaUnit;
|
||||||
directOffersOnly: boolean;
|
directOffersOnly: boolean;
|
||||||
|
|||||||
@@ -25960,6 +25960,13 @@ a.city-card:hover {
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wiersze z aktywnoscia i data zalozenia konta sprzedajacego - drobniejsze niz podpis roli,
|
||||||
|
z lekkim odstepem od "Firma"/"Osoba prywatna". */
|
||||||
|
.listing-detail-seller-card .listing-detail-seller-meta {
|
||||||
|
color: #8595ab;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
.listing-detail-seller a {
|
.listing-detail-seller a {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: #129357;
|
color: #129357;
|
||||||
@@ -26072,6 +26079,8 @@ a.city-card:hover {
|
|||||||
color: #304760;
|
color: #304760;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
/* Przyciski rozkladaja sie rowno na cala szerokosc sekcji - bez pustego miejsca po prawej. */
|
||||||
|
flex: 1;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
@@ -34629,3 +34638,446 @@ a.listing-detail-back {
|
|||||||
position: static;
|
position: static;
|
||||||
right: auto;
|
right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Profil firmy (rozbudowany widok konta COMPANY) + pola firmowe w ustawieniach ===== */
|
||||||
|
|
||||||
|
/* Pole formularza rozciagniete na obie kolumny (haslo, www). */
|
||||||
|
.settings-card .profile-form-grid .profile-form-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firmowa karta ustawien: social media w dwoch kolumnach na szerszych ekranach. */
|
||||||
|
.settings-card[aria-label="Profil firmy"] .profile-form-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-row input {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-row span {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-row strong {
|
||||||
|
color: #1b2b44;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-row small {
|
||||||
|
color: #6f7f92;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-actions .primary {
|
||||||
|
background: #12a764;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-actions .primary:hover {
|
||||||
|
background: #0f8f54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-actions .primary:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Naglowek profilu firmy: baner + logo + tozsamosc + social + zakladki --- */
|
||||||
|
.company-profile {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-header {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8edf4;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-banner {
|
||||||
|
background-color: #dce7f2;
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
height: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-head-row {
|
||||||
|
align-items: flex-end;
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 0 24px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-logo {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8edf4;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 24px rgba(20, 40, 70, 0.12);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: -46px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-logo-img {
|
||||||
|
border-radius: 12px;
|
||||||
|
height: 92px;
|
||||||
|
width: 92px;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-identity {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-identity h1 {
|
||||||
|
color: #12233d;
|
||||||
|
font-size: 26px;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-verified-badge {
|
||||||
|
align-items: center;
|
||||||
|
background: #e7f7ee;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #0f8f54;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-verified-badge svg {
|
||||||
|
height: 13px;
|
||||||
|
width: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-tagline {
|
||||||
|
color: #5a6b82;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-quicklinks {
|
||||||
|
color: #4a5c76;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 18px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-quicklinks > span,
|
||||||
|
.company-profile-quicklinks > a {
|
||||||
|
align-items: center;
|
||||||
|
color: #4a5c76;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
gap: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-quicklinks a {
|
||||||
|
color: #0f8f54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-quicklinks svg {
|
||||||
|
color: #12a764;
|
||||||
|
height: 15px;
|
||||||
|
width: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-phone-reveal {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #0f8f54;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-socials {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-socials a {
|
||||||
|
align-items: center;
|
||||||
|
background: #f2f6fb;
|
||||||
|
border: 1px solid #e1e9f2;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #34495f;
|
||||||
|
display: inline-flex;
|
||||||
|
height: 34px;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-socials a:hover {
|
||||||
|
background: #e7eef6;
|
||||||
|
color: #12233d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-socials svg {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-tabs {
|
||||||
|
border-top: 1px solid #eef2f7;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-tabs button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: #5a6b82;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 14px 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-tabs button:hover {
|
||||||
|
color: #12233d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-tabs button.active {
|
||||||
|
border-bottom-color: #12a764;
|
||||||
|
color: #0f8f54;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tresc: dwie kolumny (glowna + karta kontaktu), zwijane na waskich ekranach --- */
|
||||||
|
.company-profile-body {
|
||||||
|
align-items: start;
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-about p {
|
||||||
|
color: #40506a;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-stats {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
margin: 16px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-stats > div {
|
||||||
|
background: #f6faf7;
|
||||||
|
border: 1px solid #e4f0e8;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-stats svg {
|
||||||
|
color: #12a764;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-stats strong {
|
||||||
|
color: #12233d;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-stats small {
|
||||||
|
color: #6f7f92;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-badges span {
|
||||||
|
align-items: center;
|
||||||
|
background: #f2f6fb;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #46586f;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-badges svg {
|
||||||
|
color: #12a764;
|
||||||
|
height: 13px;
|
||||||
|
width: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-listings-head {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-see-all {
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #0f8f54;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-see-all svg {
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Karta "Szybki kontakt" (aside) --- */
|
||||||
|
.company-profile-contact-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8edf4;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
position: sticky;
|
||||||
|
top: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-contact-card h2 {
|
||||||
|
color: #12233d;
|
||||||
|
font-size: 17px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-contact-card p {
|
||||||
|
color: #5a6b82;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-contact-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-contact-list li {
|
||||||
|
align-items: center;
|
||||||
|
color: #34495f;
|
||||||
|
display: flex;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-contact-list a {
|
||||||
|
color: #0f8f54;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-contact-list svg {
|
||||||
|
color: #12a764;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-contact-www {
|
||||||
|
align-items: center;
|
||||||
|
background: #12a764;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 11px 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-contact-www:hover {
|
||||||
|
background: #0f8f54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-contact-www svg {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-contact-empty {
|
||||||
|
color: #6f7f92;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.company-profile-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-head-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-socials {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-stats {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-profile-contact-card {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
# Crawlery social media (Facebook, WhatsApp, Messenger, Twitter/X, LinkedIn...) nie wykonuja
|
||||||
|
# JavaScriptu, wiec meta tagi ustawiane w aplikacji React sa dla nich niewidoczne. Po User-Agent
|
||||||
|
# rozpoznajemy bota i dla adresu /oferta/{id} kierujemy go na serwerowy podglad z tagami Open Graph
|
||||||
|
# (backend), zamiast na aplikacje SPA. Zwykly uzytkownik dostaje niezmieniona aplikacje.
|
||||||
|
map $http_user_agent $pl_is_crawler {
|
||||||
|
default 0;
|
||||||
|
"~*facebookexternalhit|Facebot|Twitterbot|WhatsApp|Slackbot|LinkedInBot|TelegramBot|Discordbot|Pinterest|redditbot|Applebot|SkypeUriPreview|vkShare|Google-InspectionTool|Googlebot|bingbot|Embedly|Iframely|OpenGraph" 1;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
@@ -5,6 +14,10 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Wbudowany DNS Dockera - potrzebny, bo proxy_pass ze zmienna ($listing_id) rozwiazuje host
|
||||||
|
# "backend" dopiero przy zadaniu, a nie przy starcie nginx.
|
||||||
|
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8080/api/;
|
proxy_pass http://backend:8080/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -14,6 +27,14 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Szczegoly ogloszenia: crawler dostaje serwerowy podglad Open Graph, czlowiek - aplikacje SPA.
|
||||||
|
location ~ ^/oferta/(?<listing_id>\d+) {
|
||||||
|
if ($pl_is_crawler) {
|
||||||
|
proxy_pass http://backend:8080/api/og/listing/$listing_id;
|
||||||
|
}
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
# Pliki z hashem w nazwie (assets/index-XXXX.js) sa niezmienne - mozna je trzymac dlugo.
|
# Pliki z hashem w nazwie (assets/index-XXXX.js) sa niezmienne - mozna je trzymac dlugo.
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
|
|||||||
Reference in New Issue
Block a user