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.");
|
||||
}
|
||||
|
||||
user.setLastActiveAt(Instant.now());
|
||||
userRepository.save(user);
|
||||
return buildAuthResponse(user);
|
||||
}
|
||||
|
||||
@@ -298,10 +300,20 @@ public class AuthService {
|
||||
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) {
|
||||
return userRepository.findByEmailIgnoreCase(email)
|
||||
.map(this::withAvatar)
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||
.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(HttpMethod.GET, "/api/listings/mine").authenticated()
|
||||
.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.POST, "/api/articles").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
|
||||
|
||||
@@ -56,6 +56,10 @@ public record ListingDetailResponse(
|
||||
String ownerAccountType,
|
||||
// Nazwa wlasciciela do etykiety oferty firmowej (dla konta firmowego). Dla oferty prywatnej pomijana.
|
||||
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,
|
||||
Instant createdAt,
|
||||
Long viewsCount,
|
||||
@@ -68,7 +72,7 @@ public record ListingDetailResponse(
|
||||
private static final double METERS_PER_DEGREE = 111_320d;
|
||||
|
||||
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.
|
||||
*/
|
||||
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 building = masked ? null : listing.getBuilding();
|
||||
String address = masked ? approximateAddress(listing) : listing.getAddress();
|
||||
@@ -126,6 +131,8 @@ public record ListingDetailResponse(
|
||||
ownerAvatar,
|
||||
ownerAccountType,
|
||||
ownerName,
|
||||
ownerCreatedAt,
|
||||
ownerLastActiveAt,
|
||||
listing.getStatus(),
|
||||
listing.getCreatedAt(),
|
||||
listing.getViewsCount(),
|
||||
|
||||
@@ -62,11 +62,11 @@ public class ListingService {
|
||||
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked) {
|
||||
AppUser owner = ownerOf(listing);
|
||||
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();
|
||||
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.
|
||||
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
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
if (lastActiveAt == null) {
|
||||
lastActiveAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
@@ -253,4 +260,12 @@ public class AppUser {
|
||||
public void setPasswordChangedAt(Instant 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()
|
||||
: 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(
|
||||
user.getId(),
|
||||
user.getFullName(),
|
||||
@@ -80,6 +83,15 @@ public class PublicProfileController {
|
||||
user.getAccountType(),
|
||||
user.isVerified(),
|
||||
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.stream()
|
||||
.map(listing -> new PublicProfileResponse.PublicProfileListing(
|
||||
|
||||
@@ -20,6 +20,18 @@ public record PublicProfileResponse(
|
||||
AccountType accountType,
|
||||
boolean verified,
|
||||
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,
|
||||
List<PublicProfileListing> listings,
|
||||
// Profil ustawiony jako prywatny, ogladany przez kogos innego niz wlasciciel. Pozostale pola
|
||||
@@ -41,6 +53,15 @@ public record PublicProfileResponse(
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
List.of(),
|
||||
true
|
||||
|
||||
@@ -45,6 +45,33 @@ public class UserSettings {
|
||||
@Column(columnDefinition = "TEXT")
|
||||
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 ---
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@@ -155,6 +182,62 @@ public class UserSettings {
|
||||
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() {
|
||||
return currency;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,13 @@ public record UserSettingsRequest(
|
||||
ProfileVisibility profileVisibility,
|
||||
String avatarImage,
|
||||
String coverImage,
|
||||
String companyTagline,
|
||||
String website,
|
||||
String socialLinkedin,
|
||||
String socialFacebook,
|
||||
String socialInstagram,
|
||||
String socialYoutube,
|
||||
Boolean publicContactVisible,
|
||||
Currency currency,
|
||||
AreaUnit areaUnit,
|
||||
Boolean directOffersOnly,
|
||||
|
||||
@@ -6,6 +6,13 @@ public record UserSettingsResponse(
|
||||
ProfileVisibility profileVisibility,
|
||||
String avatarImage,
|
||||
String coverImage,
|
||||
String companyTagline,
|
||||
String website,
|
||||
String socialLinkedin,
|
||||
String socialFacebook,
|
||||
String socialInstagram,
|
||||
String socialYoutube,
|
||||
boolean publicContactVisible,
|
||||
Currency currency,
|
||||
AreaUnit areaUnit,
|
||||
boolean directOffersOnly,
|
||||
@@ -29,6 +36,13 @@ public record UserSettingsResponse(
|
||||
settings.getProfileVisibility(),
|
||||
settings.getAvatarImage(),
|
||||
settings.getCoverImage(),
|
||||
settings.getCompanyTagline(),
|
||||
settings.getWebsite(),
|
||||
settings.getSocialLinkedin(),
|
||||
settings.getSocialFacebook(),
|
||||
settings.getSocialInstagram(),
|
||||
settings.getSocialYoutube(),
|
||||
settings.isPublicContactVisible(),
|
||||
settings.getCurrency(),
|
||||
settings.getAreaUnit(),
|
||||
settings.isDirectOffersOnly(),
|
||||
|
||||
@@ -22,6 +22,10 @@ public class UserSettingsService {
|
||||
|
||||
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 TextModerationService textModerationService;
|
||||
|
||||
@@ -78,6 +82,32 @@ public class UserSettingsService {
|
||||
if (request.coverImage() != null) {
|
||||
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) {
|
||||
settings.setCurrency(request.currency());
|
||||
}
|
||||
@@ -136,6 +166,25 @@ public class UserSettingsService {
|
||||
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) {
|
||||
String trimmed = dataUrl.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
|
||||
+503
-18
@@ -131,6 +131,10 @@ export type ApiListingDetail = {
|
||||
ownerAccountType: AccountType | null;
|
||||
// Nazwa wlasciciela - uzywana jako etykieta oferty firmowej.
|
||||
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;
|
||||
createdAt: string;
|
||||
viewsCount?: number;
|
||||
@@ -8111,6 +8115,41 @@ function priceAlertRelativeTime(iso: string): string {
|
||||
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%.
|
||||
function priceAlertChangePercent(previous: number, current: number): number {
|
||||
return previous > 0 ? ((current - previous) / previous) * 100 : 0;
|
||||
@@ -11323,6 +11362,18 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
|
||||
}));
|
||||
const [identityBanner, setIdentityBanner] = useState<Banner>(null);
|
||||
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 [isSuggestingNick, setIsSuggestingNick] = useState(false);
|
||||
const [isPhoneTouched, setIsPhoneTouched] = useState(false);
|
||||
@@ -11455,6 +11506,15 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
|
||||
setSettings(loaded);
|
||||
setConsent({ emailConsent: marketing.emailConsent, smsConsent: marketing.smsConsent });
|
||||
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({
|
||||
locations: loaded.searchLocations ?? '',
|
||||
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 file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
@@ -12237,6 +12319,88 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
|
||||
</div>
|
||||
</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 --- */}
|
||||
<section className="settings-card" aria-label="Preferencje wyszukiwania">
|
||||
<div className="settings-card-head">
|
||||
@@ -24657,6 +24821,18 @@ type PublicProfile = {
|
||||
accountType: AccountType;
|
||||
verified: boolean;
|
||||
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.
|
||||
privateProfile: boolean;
|
||||
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.
|
||||
* Pokazuje wylacznie dane jawne; kontakt odbywa sie przez wiadomosci, nie przez e-mail.
|
||||
@@ -24745,7 +25170,12 @@ function PublicProfilePage() {
|
||||
</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">
|
||||
{/* Zdjecie w tle ustawione przez wlasciciela; bez niego zostaje grafika serwisu. */}
|
||||
@@ -24760,7 +25190,7 @@ function PublicProfilePage() {
|
||||
<div className="public-profile-meta">
|
||||
{profile.nick && <span><Icon name="user" /> @{profile.nick}</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>
|
||||
{/* Opis "o mnie" z ustawien konta - pole opcjonalne, wiec pokazujemy tylko gdy wypelnione. */}
|
||||
{profile.bio && <p className="public-profile-bio">{profile.bio}</p>}
|
||||
@@ -24823,7 +25253,7 @@ function PublicProfilePage() {
|
||||
<h2>O użytkowniku</h2>
|
||||
<p>Ten użytkownik nie dodał jeszcze opisu swojego profilu.</p>
|
||||
<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>
|
||||
{profile.verified && <span><Icon name="shield" /> Zweryfikowany użytkownik</span>}
|
||||
<span><Icon name="message" /> Kontakt przez wiadomości</span>
|
||||
@@ -27316,6 +27746,10 @@ function ListingDetailPage({
|
||||
: listing.ownerAccountType === 'PERSONAL'
|
||||
? 'Osoba prywatna'
|
||||
: '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 listingAlertEnabled = isListingAlertEnabled(listing.id);
|
||||
const virtualTourEmbed = listing.virtualTourUrl ? getVirtualTourEmbed(listing.virtualTourUrl) : null;
|
||||
@@ -27324,7 +27758,7 @@ function ListingDetailPage({
|
||||
const copyShareLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
setShareHint('Skopiowano link');
|
||||
setShareHint('Link został skopiowany');
|
||||
window.setTimeout(() => setShareHint(null), 1800);
|
||||
} catch {
|
||||
setShareHint('Nie udało się skopiować');
|
||||
@@ -27336,6 +27770,26 @@ function ListingDetailPage({
|
||||
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").
|
||||
const revealContact = () => {
|
||||
setIsPhoneRevealed(true);
|
||||
@@ -27791,6 +28245,8 @@ function ListingDetailPage({
|
||||
<div>
|
||||
<strong>{sellerName}</strong>
|
||||
<small>{sellerRole}</small>
|
||||
{sellerLastActiveText && <small className="listing-detail-seller-meta">{sellerLastActiveText}</small>}
|
||||
{sellerMemberSinceText && <small className="listing-detail-seller-meta">{sellerMemberSinceText}</small>}
|
||||
{isPhoneRevealed && listing.contactPhone && (
|
||||
<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>
|
||||
<div className="listing-detail-share-actions">
|
||||
<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://m.me/?link=${encodeURIComponent(shareUrl)}`)} aria-label="Udostępnij w Messengerze"><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={async () => {
|
||||
if (navigator.share) {
|
||||
await navigator.share({ title: listing.title, text: listing.city, url: shareUrl });
|
||||
return;
|
||||
}
|
||||
await copyShareLink();
|
||||
}}
|
||||
aria-label="Więcej opcji"
|
||||
>
|
||||
<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={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"><Icon name="whatsapp" /></button>
|
||||
<button type="button" onClick={shareViaSystem} aria-label="Więcej opcji">
|
||||
<Icon name="dots" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -28346,6 +28792,45 @@ function AdminListingPreviewModal({ listingId, busy, onClose, onApprove, onRejec
|
||||
|
||||
function Icon({ name }: { name: string }) {
|
||||
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':
|
||||
return (
|
||||
<svg viewBox="0 0 28 28" aria-hidden="true">
|
||||
|
||||
@@ -21,6 +21,14 @@ export type UserSettings = {
|
||||
profileVisibility: ProfileVisibility;
|
||||
avatarImage: 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;
|
||||
areaUnit: AreaUnit;
|
||||
directOffersOnly: boolean;
|
||||
|
||||
@@ -25960,6 +25960,13 @@ a.city-card:hover {
|
||||
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 {
|
||||
align-items: center;
|
||||
color: #129357;
|
||||
@@ -26072,6 +26079,8 @@ a.city-card:hover {
|
||||
color: #304760;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
/* Przyciski rozkladaja sie rowno na cala szerokosc sekcji - bez pustego miejsca po prawej. */
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
height: 30px;
|
||||
@@ -34629,3 +34638,446 @@ a.listing-detail-back {
|
||||
position: static;
|
||||
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 {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -5,6 +14,10 @@ server {
|
||||
root /usr/share/nginx/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/ {
|
||||
proxy_pass http://backend:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
@@ -14,6 +27,14 @@ server {
|
||||
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.
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
|
||||
Reference in New Issue
Block a user