emotki i profile
Deploy Polska Lokalnie / deploy (push) Successful in 15s

This commit is contained in:
2026-09-08 16:59:53 +02:00
parent 6b334cc1b5
commit a3e4505194
16 changed files with 1445 additions and 24 deletions
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
// 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
View File
@@ -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">
+8
View File
@@ -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;
+452
View File
@@ -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;
}
}
+21
View File
@@ -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;