From a3e45051943befb6974f337dae362c0d7e8ed75a Mon Sep 17 00:00:00 2001 From: pascal Date: Tue, 8 Sep 2026 16:59:53 +0200 Subject: [PATCH] emotki i profile --- .../pl/polskalokalnie/auth/AuthService.java | 16 +- .../polskalokalnie/auth/SecurityConfig.java | 2 + .../listing/ListingDetailResponse.java | 11 +- .../listing/ListingService.java | 4 +- .../pl/polskalokalnie/og/OgController.java | 233 ++++++++ .../java/pl/polskalokalnie/user/AppUser.java | 15 + .../user/PublicProfileController.java | 12 + .../user/PublicProfileResponse.java | 21 + .../pl/polskalokalnie/user/UserSettings.java | 83 +++ .../user/UserSettingsRequest.java | 7 + .../user/UserSettingsResponse.java | 14 + .../user/UserSettingsService.java | 49 ++ frontend/src/App.tsx | 521 +++++++++++++++++- frontend/src/auth.tsx | 8 + frontend/src/styles.css | 452 +++++++++++++++ nginx/default.conf | 21 + 16 files changed, 1445 insertions(+), 24 deletions(-) create mode 100644 backend/src/main/java/pl/polskalokalnie/og/OgController.java diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java index c916c62..cabcc3b 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java @@ -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); } /** diff --git a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java index ba39ec6..550de4d 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java @@ -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() diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java index a898a34..68f1e58 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java @@ -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(), diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java index da6ce99..14fff04 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -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); } /** diff --git a/backend/src/main/java/pl/polskalokalnie/og/OgController.java b/backend/src/main/java/pl/polskalokalnie/og/OgController.java new file mode 100644 index 0000000..2df2b07 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/og/OgController.java @@ -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 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 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 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("\n"); + head.append("\n"); + head.append("\n"); + head.append("\n"); + head.append("\n"); + head.append("\n"); + head.append("\n"); + head.append("\n"); + head.append("\n"); + if (imageUrl != null) { + String img = escapeHtml(imageUrl); + head.append("\n"); + head.append("\n"); + head.append("\n"); + } + return "\n" + + "\n\n" + + "\n" + + "\n" + + "" + t + "\n" + + "\n" + + "\n" + + head + // Czlowiek, ktory trafi tu z linku (a nie bot), zostaje od razu przeniesiony do aplikacji. + + "\n" + + "\n\n" + + "

" + t + "

\n" + + "\n" + + "\n\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 + "'"; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java index 7e71b40..acdd47c 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java +++ b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java @@ -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; + } } diff --git a/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java index b3ed0e0..9f92e66 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java +++ b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java @@ -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( diff --git a/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java index 4dd7003..c2aa076 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java @@ -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 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 diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettings.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettings.java index 2be6c19..4f2d993 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/UserSettings.java +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettings.java @@ -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; } diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsRequest.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsRequest.java index 3ab2d2f..f509b37 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsRequest.java +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsRequest.java @@ -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, diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsResponse.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsResponse.java index cee23cc..acf5a1d 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsResponse.java @@ -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(), diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java index 4db6882..df02372 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java @@ -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()) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 513fbf4..74d942e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(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(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, target: SettingsUploadTarget) => { const file = event.target.files?.[0]; if (!file) { @@ -12237,6 +12319,88 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences + {/* --- Profil firmy: dane pokazywane publicznie na rozbudowanym profilu (tylko COMPANY) --- */} + {isCompany && ( +
+
+

Profil firmy

+

Te dane widać na publicznym profilu Twojej firmy. Wszystkie pola są opcjonalne.

+
+ + {companyBanner &&

{companyBanner.text}

} + +
+ + + + + + +
+ + + +
+ +
+
+ )} + {/* --- Preferencje wyszukiwania: realne pola zapisywane na koncie --- */}
@@ -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]) => ( + +
+ + {listing.offerType === 'RENT' ? 'Na wynajem' : 'Na sprzedaż'} + +
+
+ {formatPrice(listing.price)}{listing.offerType === 'RENT' ? ' / mies.' : ''} +

{listing.title}

+ {[listing.city, listing.district].filter(Boolean).join(', ')} + +
+ {listingRelativeDate(listing.createdAt) || 'Dodane niedawno'} +
+
+ + ); + + // 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 = ( + + ); + + return ( +
+
+
+
+
+ +
+
+ {profile.verified && ( + Zweryfikowana firma + )} +

{profile.fullName}

+ {profile.companyTagline &&

{profile.companyTagline}

} +
+ {profile.publicAddress && {profile.publicAddress}} + {profile.publicPhone && ( + + + {phoneRevealed ? profile.publicPhone : ( + + )} + + )} + {profile.website && ( + {companyWebsiteLabel(profile.website)} + )} +
+
+ {socialLinks.length > 0 && ( +
+ {socialLinks.map((item) => ( + + + + ))} +
+ )} +
+ + +
+ + {activeTab === 'o-firmie' && ( +
+
+
+

O firmie

+

{profile.bio || 'Ta firma nie dodała jeszcze opisu swojej działalności.'}

+
+
{cityCount}{cityCount === 1 ? 'miasto' : 'miast(a)'}
+
{profile.listingsCount}ogłoszeń
+
{forSaleCount}na sprzedaż
+
{forRentCount}na wynajem
+
+
+ W serwisie od {memberSinceLabel} + {lastActiveLabel && {lastActiveLabel}} + {profile.verified && Konto zweryfikowane} +
+
+ +
+
+

Najnowsze ogłoszenia

+ {profile.listingsCount > 3 && ( + + )} +
+ {profile.listings.length === 0 ? ( +

Ta firma nie ma obecnie opublikowanych ogłoszeń.

+ ) : ( +
+ {profile.listings.slice(0, 3).map(renderListingCard)} +
+ )} +
+
+ {contactCard} +
+ )} + + {activeTab === 'ogloszenia' && ( +
+
+

Ogłoszenia ({profile.listingsCount})

+
+ {profile.listings.length === 0 ? ( +

Ta firma nie ma obecnie opublikowanych ogłoszeń.

+ ) : ( +
+ {profile.listings.map(renderListingCard)} +
+ )} +
+ )} + + {activeTab === 'kontakt' && ( +
+
+

Kontakt

+ {hasContactData ? ( +

Skorzystaj z danych kontaktowych firmy lub napisz wiadomość z poziomu jej ogłoszenia.

+ ) : ( +

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ść”.

+ )} +
+ W serwisie od {memberSinceLabel} + {lastActiveLabel && {lastActiveLabel}} +
+
+ {contactCard} +
+ )} +
+ ); +} + /** * 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() {
)} - {!loading && !error && profile && !profile.privateProfile && ( + {/* Konto firmowe dostaje rozbudowany profil (banner, dane firmy, statystyki, oferty). */} + {!loading && !error && profile && !profile.privateProfile && profile.accountType === 'COMPANY' && ( + + )} + + {!loading && !error && profile && !profile.privateProfile && profile.accountType !== 'COMPANY' && ( <>
{/* Zdjecie w tle ustawione przez wlasciciela; bez niego zostaje grafika serwisu. */} @@ -24760,7 +25190,7 @@ function PublicProfilePage() {
{profile.nick && @{profile.nick}} W serwisie od {memberSinceLabel} - {profile.accountType === 'COMPANY' ? 'Firma' : 'Osoba prywatna'} + Osoba prywatna
{/* Opis "o mnie" z ustawien konta - pole opcjonalne, wiec pokazujemy tylko gdy wypelnione. */} {profile.bio &&

{profile.bio}

} @@ -24823,7 +25253,7 @@ function PublicProfilePage() {

O użytkowniku

Ten użytkownik nie dodał jeszcze opisu swojego profilu.

- {profile.accountType === 'COMPANY' ? 'Konto firmowe' : 'Konto prywatne'} + Konto prywatne W serwisie od {memberSinceLabel} {profile.verified && Zweryfikowany użytkownik} Kontakt przez wiadomości @@ -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({
{sellerName} {sellerRole} + {sellerLastActiveText && {sellerLastActiveText}} + {sellerMemberSinceText && {sellerMemberSinceText}} {isPhoneRevealed && listing.contactPhone && ( {listing.contactPhone} )} @@ -27809,20 +28265,10 @@ function ListingDetailPage({ Udostępnij ofertę
- - - - + + +
@@ -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 ( + + ); + case 'whatsapp': + return ( + + ); + case 'linkedin': + return ( + + ); + case 'instagram': + return ( + + ); + case 'youtube': + return ( + + ); + case 'globe': + return ( + + ); case 'logo': return (