diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java index 156b4c7..c916c62 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java @@ -332,6 +332,10 @@ public class AuthService { } user.setPhone(phone); user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null); + // NIP: null = nie zmieniaj (zapis czesciowy nie moze skasowac NIP-u firmy), pusty = wyczysc. + if (request.nip() != null) { + user.setNip(request.nip().isBlank() ? null : request.nip().trim()); + } user.setContactPreference(request.contactPreference() != null ? request.contactPreference() : ContactPreference.EMAIL_AND_PHONE); user.setPreferredLanguage(request.preferredLanguage() != null ? request.preferredLanguage() : PreferredLanguage.PL); if (request.birthDate() != null && !request.birthDate().isBlank()) { diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java index 022f3ce..919bf9a 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java @@ -11,6 +11,9 @@ public record UpdateProfileRequest( String phone, String birthDate, String address, + // NIP firmy. null = nie zmieniaj (zapisy czesciowe, np. jezyka, nie kasuja NIP-u); + // pusty ciag = wyczysc. Dotyczy kont firmowych. + String nip, ContactPreference contactPreference, PreferredLanguage preferredLanguage ) { diff --git a/backend/src/main/java/pl/polskalokalnie/gus/GusService.java b/backend/src/main/java/pl/polskalokalnie/gus/GusService.java index 5028eb0..a9f185a 100644 --- a/backend/src/main/java/pl/polskalokalnie/gus/GusService.java +++ b/backend/src/main/java/pl/polskalokalnie/gus/GusService.java @@ -94,7 +94,72 @@ public class GusService { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono firmy o podanym numerze NIP w rejestrze REGON."); } - return new GusCompanyResponse(dane.get("Nazwa"), nip, dane.get("Regon"), buildAddress(dane)); + + String name = dane.get("Nazwa"); + String regon = dane.get("Regon"); + String address = buildAddress(dane); + + // Wyszukiwanie (DaneSzukajPodmioty) zwraca skrocony rekord - dla czesci podmiotow (czesto + // jednoosobowych dzialalnosci) nazwa lub adres sa puste i sa dostepne dopiero w pelnym + // raporcie. Dociagamy go tylko wtedy, gdy czegos brakuje. Blad raportu nie moze zepsuc + // dzialajacych przypadkow, wiec zostajemy przy danych z wyszukiwania. + if ((isBlank(name) || isBlank(address)) && !isBlank(regon)) { + try { + Map full = fullReport(endpoint, key, regon, dane.get("Typ")); + if (isBlank(name)) { + name = full.get("Nazwa"); + } + if (isBlank(address)) { + String fullAddress = buildAddress(full); + if (!isBlank(fullAddress)) { + address = fullAddress; + } + } + } catch (ResponseStatusException ignored) { + // Pelny raport jest tylko uzupelnieniem - brak odpowiedzi nie przerywa rejestracji. + } + } + + return new GusCompanyResponse(name, nip, regon, address); + } + + /** + * Pelny raport podmiotu (DanePobierzPelnyRaport) - uzupelnia nazwe i adres, gdy nie ma ich w + * wyniku wyszukiwania. Nazwa raportu i nazwy pol zaleza od typu podmiotu (osoba prawna vs fizyczna). + * Zwrocone pola mapujemy na te same klucze co wyszukiwanie, zeby uzyc wspolnego buildAddress. + */ + private Map fullReport(String endpoint, String key, String regon, String typ) { + boolean legal = typ != null && typ.trim().toUpperCase().startsWith("P"); + String report = legal ? "BIR11OsPrawna" : "BIR11OsFizycznaDaneOgolne"; + String prefix = legal ? "praw_" : "fiz_"; + String body = """ + + %s + %s + """.formatted(regon, report); + String response = call(endpoint, "DanePobierzPelnyRaport", body, session(endpoint, key)); + Map raw = firstDane(resultOf(response, "DanePobierzPelnyRaportResult")); + if (raw == null) { + return Map.of(); + } + Map mapped = new HashMap<>(); + putIfPresent(mapped, "Nazwa", raw.get(prefix + "nazwa")); + putIfPresent(mapped, "Ulica", raw.get(prefix + "adSiedzUlica_Nazwa")); + putIfPresent(mapped, "NrNieruchomosci", raw.get(prefix + "adSiedzNumerNieruchomosci")); + putIfPresent(mapped, "NrLokalu", raw.get(prefix + "adSiedzNumerLokalu")); + putIfPresent(mapped, "KodPocztowy", raw.get(prefix + "adSiedzKodPocztowy")); + putIfPresent(mapped, "Miejscowosc", raw.get(prefix + "adSiedzMiejscowosc_Nazwa")); + return mapped; + } + + private static void putIfPresent(Map target, String key, String value) { + if (!isBlank(value)) { + target.put(key, value); + } + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); } // Pusty wynik oznacza wygasla sesje - wtedy logujemy sie ponownie i probujemy raz jeszcze. diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java index 1f978a2..a898a34 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java @@ -51,6 +51,11 @@ public record ListingDetailResponse( Long ownerId, // Zdjecie profilowe wlasciciela (data URL) albo null - wtedy karta sprzedajacego rysuje inicjaly. String ownerAvatar, + // Typ konta wlasciciela ("PERSONAL"/"COMPANY") albo null, gdy ogloszenie nie ma konta w serwisie. + // Po tym front rozroznia oferte prywatna od firmowej (etykieta na karcie, jak na Otodom). + String ownerAccountType, + // Nazwa wlasciciela do etykiety oferty firmowej (dla konta firmowego). Dla oferty prywatnej pomijana. + String ownerName, ListingStatus status, Instant createdAt, Long viewsCount, @@ -63,7 +68,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, false); + return from(listing, ownerId, ownerAvatar, null, null, false); } /** @@ -72,7 +77,8 @@ public record ListingDetailResponse( * przesuwane w obrebie obszaru. Przesuniecie liczymy z identyfikatora ogloszenia, wiec dla danej * oferty jest zawsze takie samo - inaczej kolo skakaloby po mapie przy kazdym odswiezeniu. */ - public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar, boolean masked) { + public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar, + String ownerAccountType, String ownerName, boolean masked) { String street = masked ? null : listing.getStreet(); String building = masked ? null : listing.getBuilding(); String address = masked ? approximateAddress(listing) : listing.getAddress(); @@ -118,6 +124,8 @@ public record ListingDetailResponse( listing.getOwnerEmail(), ownerId, ownerAvatar, + ownerAccountType, + ownerName, 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 55f48e9..da6ce99 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -62,9 +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, masked); + return ListingDetailResponse.from(listing, null, null, null, null, masked); } - return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()), masked); + String ownerAccountType = owner.getAccountType() == null ? null : owner.getAccountType().name(); + return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()), + ownerAccountType, owner.getFullName(), masked); } /** diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2141be8..8f9880b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -127,6 +127,10 @@ export type ApiListingDetail = { ownerId: number | null; // Zdjecie profilowe wlasciciela - null, gdy go nie ustawil albo ogloszenie nie ma konta w serwisie. ownerAvatar: string | null; + // Typ konta wlasciciela - po nim rozrozniamy oferte prywatna od firmowej. null = brak konta w serwisie. + ownerAccountType: AccountType | null; + // Nazwa wlasciciela - uzywana jako etykieta oferty firmowej. + ownerName: string | null; status: ApiListingStatus; createdAt: string; viewsCount?: number; @@ -181,6 +185,14 @@ function pushRecentlyViewed(item: RecentlyViewedItem): void { type LiveListingSummary = ApiListingSummary & { lat: number | null; lng: number | null; + // Pelna galeria z detalu - pobierana i tak dla lat/lng, wiec karta moze pokazac slider zdjec + // bez wchodzenia w oferte (jak na Otodom). + photos: string[]; + // Typ konta wlasciciela i jego nazwa - do etykiety "Oferta prywatna" / oferty firmowej na karcie. + ownerAccountType: AccountType | null; + ownerName: string | null; + // Konto wlasciciela - etykieta oferty firmowej linkuje do jego profilu publicznego (/profil/:id). + ownerId: number | null; }; const LIVE_LISTINGS_REFRESH_MS = 15000; @@ -829,11 +841,11 @@ const insightCards = [ path: ROUTES.districtRanking, }, { - icon: 'coin', - title: 'Ile możesz utargować?', - text: 'Poznaj realistyczną cenę transakcyjną', + icon: 'percent', + title: 'Zdolność kredytowa', + text: 'Sprawdź na jaki kredyt Cię stać', action: 'Sprawdź teraz', - path: ROUTES.negotiation, + path: ROUTES.creditCalculator, }, ]; @@ -6833,7 +6845,8 @@ function AccountDashboardPage({
-

Witaj, {getFirstName(getDisplayName(user))}!

+ {/* Firma nie ma imienia - witamy pelna nazwa firmy zamiast pierwszego slowa. */} +

Witaj, {user?.accountType === 'COMPANY' ? getDisplayName(user) : getFirstName(getDisplayName(user))}!

Zarządzaj swoimi wyszukiwaniami, ofertami i ulubionymi ogłoszeniami.

+
+ Wpisz 10 cyfr numeru NIP, aby pobrać nazwę i adres firmy z rejestru REGON. + +
+ ) : ( +
+ + +
+ )}
@@ -11505,22 +11593,25 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences )} - + {/* Data urodzenia dotyczy tylko osob prywatnych - firma jej nie ma. */} + {!isCompany && ( + + )} @@ -13267,10 +13358,10 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci

Dodaj ogłoszenie za darmo

Dotrzyj do tysięcy potencjalnych kupujących. Bez prowizji. Bez zobowiązań.

- + Zajmie Ci to tylko 2 minuty!
+

O użytkowniku

@@ -24366,7 +24534,7 @@ function MessagesPage({ // Wizytowka zawsze z aktualnego profilu konta. Telefon trafia do niej tylko wtedy, // gdy uzytkownik go poda - inaczej wysylamy sam adres e-mail. const myBusinessCard: BusinessCard | null = user - ? { fullName: user.fullName, email: user.email, phone: user.phone } + ? { fullName: user.fullName, email: user.email, phone: user.phone, isCompany: user.accountType === 'COMPANY' } : null; const onChooseAttachment = () => { @@ -24624,7 +24792,7 @@ function MessagesPage({ }); setIsPhonePromptOpen(false); - deliverBusinessCard({ fullName: updated.fullName, email: updated.email, phone: updated.phone }); + deliverBusinessCard({ fullName: updated.fullName, email: updated.email, phone: updated.phone, isCompany: user.accountType === 'COMPANY' }); } catch (error) { setPhonePromptError(error instanceof Error ? error.message : 'Nie udało się zapisać numeru telefonu.'); } finally { @@ -24982,10 +25150,11 @@ function MessagesPage({ {message.businessCard && (
- +
Wizytówka kontaktowa {message.businessCard.fullName} + {message.businessCard.isCompany && Firma}
@@ -25984,12 +26153,20 @@ function useLiveListings(offerType: ApiOfferType, refreshMs = LIVE_LISTINGS_REFR ...summary, lat: detail.lat, lng: detail.lng, + photos: detail.photos ?? [], + ownerAccountType: detail.ownerAccountType ?? null, + ownerName: detail.ownerName ?? null, + ownerId: detail.ownerId ?? null, } as LiveListingSummary; } catch { return { ...summary, lat: null, lng: null, + photos: [], + ownerAccountType: null, + ownerName: null, + ownerId: null, } as LiveListingSummary; } }), @@ -26278,7 +26455,15 @@ function ListingDetailPage({ // Wlasciciela rozpoznajemy po koncie powiazanym z ogloszeniem, a dla wpisow bez konta - po adresie e-mail. const isOwner = Boolean(user && ((listing.ownerId != null && listing.ownerId === user.id) || (listing.ownerEmail && user.email.toLowerCase() === listing.ownerEmail.toLowerCase()))); - const sellerRole = isOwner ? 'To Twoje ogłoszenie' : 'Oficjalny kontakt zespołem serwisu Polska Lokalnie'; + // Podpis pod nazwa sprzedajacego: dla firmy "Firma", dla osoby "Osoba prywatna", a dla ogloszen + // bez konta w serwisie (dane demo) - dotychczasowy kontakt zespolu serwisu. + const sellerRole = isOwner + ? 'To Twoje ogłoszenie' + : listing.ownerAccountType === 'COMPANY' + ? 'Firma' + : listing.ownerAccountType === 'PERSONAL' + ? 'Osoba prywatna' + : 'Oficjalny kontakt zespołem serwisu Polska Lokalnie'; const favorite = isFavorite(listing.id); const listingAlertEnabled = isListingAlertEnabled(listing.id); const virtualTourEmbed = listing.virtualTourUrl ? getVirtualTourEmbed(listing.virtualTourUrl) : null; @@ -26754,7 +26939,6 @@ function ListingDetailPage({
{sellerName} {sellerRole} - Na PolskaLokalnie.pl od 2023 {isPhoneRevealed && listing.contactPhone && ( {listing.contactPhone} )} diff --git a/frontend/src/auth.tsx b/frontend/src/auth.tsx index e4a36a0..109f7de 100644 --- a/frontend/src/auth.tsx +++ b/frontend/src/auth.tsx @@ -125,6 +125,8 @@ export type ProfileUpdate = { phone?: string; birthDate?: string; address?: string; + // NIP firmy - pomijamy dla kont prywatnych. Pominiete (undefined) = backend nie zmienia NIP-u. + nip?: string; contactPreference?: ContactPreference; preferredLanguage?: PreferredLanguage; }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 82f5eda..e565382 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -860,12 +860,14 @@ svg { max-width: 440px; } -.free-card button { +.free-card button, +.free-card-cta { align-items: center; background: #0b9f5c; border: 0; border-radius: 6px; color: #ffffff; + cursor: pointer; display: flex; font-size: 13px; font-weight: 900; @@ -875,9 +877,11 @@ svg { min-height: 42px; min-width: 250px; padding: 0 17px; + text-decoration: none; } -.free-card button strong { +.free-card button strong, +.free-card-cta strong { font-size: 13px; } @@ -3104,10 +3108,55 @@ a.city-card:hover { } .result-info { + display: flex; + flex-direction: column; min-width: 0; padding: 21px 0 18px; } +/* Etykieta typu oferty (prywatna/firmowa) - stopka karty jak na Otodom. + margin-top:auto zsuwa ja na sam dol karty w widoku listy; linia oddziela od reszty. */ +.result-seller-type { + align-items: center; + border-top: 1px solid #e9eef4; + color: #8793a3; + display: flex; + font-size: 12px; + font-weight: 800; + gap: 6px; + margin-top: auto; + padding-top: 12px; +} + +.result-seller-type.company { + color: #0f7a48; +} + +.result-seller-type svg { + height: 14px; + opacity: 0.85; + width: 14px; +} + +/* Klikalna nazwa firmy - musi lezec nad nakladka linku karty (::after, z-index 1), + inaczej klik trafialby w link do oferty zamiast w profil firmy. Pasek (border-top) zostaje + pelnej szerokosci jak na ofertach prywatnych - podkreslamy tylko sama nazwe firmy. */ +.result-seller-type.is-link { + cursor: pointer; + position: relative; + text-decoration: none; + transition: color 0.15s ease; + z-index: 2; +} + +.result-seller-type.is-link:hover { + color: #0a6b3f; +} + +.result-seller-type.is-link:hover .seller-name { + text-decoration: underline; +} + .tag-row { display: flex; flex-wrap: wrap; @@ -10066,6 +10115,31 @@ a.city-card:hover { padding: 5px 10px; } +.map-offer-cta { + align-items: center; + background: #0b8f52; + border-radius: 10px; + color: #ffffff; + display: flex; + font-size: 13px; + font-weight: 850; + gap: 8px; + justify-content: center; + margin-top: 14px; + padding: 11px 14px; + text-decoration: none; + transition: background 0.15s ease, transform 0.15s ease; +} + +.map-offer-cta:hover { + background: #0a7a46; + transform: translateY(-1px); +} + +.map-offer-cta svg { + font-size: 16px; +} + .district-ranking-page { background: #f7fafc; border-top: 1px solid #e6edf3; @@ -12410,7 +12484,8 @@ a.city-card:hover { top: 610px; } - .free-card button { + .free-card button, + .free-card .free-card-cta { min-width: 0; width: 100%; } @@ -16398,6 +16473,14 @@ a.city-card:hover { margin-top: 2px; } +.message-business-card-type { + color: #0f7a48; + display: block; + font-size: 11px; + font-weight: 800; + margin-top: 2px; +} + .message-business-card-grid { display: grid; gap: 6px; @@ -21217,6 +21300,12 @@ a.city-card:hover { margin-bottom: 10px; } +/* Sekcja danych firmy (nazwa + NIP) - odstep od nastepnej sekcji, zeby tekst pod NIP + nie przyklejal sie do etykiety "Nazwa uzytkownika". */ +.profile-form-grid.settings-company-grid { + margin-bottom: 16px; +} + .profile-extra-form { margin-bottom: 8px; } @@ -22225,37 +22314,6 @@ a.city-card:hover { width: 13px; } -.public-profile-rating { - align-items: center; - display: flex; - gap: 8px; - margin-top: 6px; -} - -.public-profile-rating strong { - color: #18293f; - font-size: 16px; -} - -.public-profile-rating span { - color: #f2b51e; - display: inline-flex; - gap: 2px; -} - -.public-profile-rating span svg { - fill: currentColor; - height: 13px; - stroke: currentColor; - width: 13px; -} - -.public-profile-rating small { - color: #6f7f92; - font-size: 11px; - font-weight: 750; -} - .public-profile-summary blockquote { color: #3a4d66; font-size: 12px; @@ -22288,13 +22346,6 @@ a.city-card:hover { font-weight: 750; } -.public-profile-grid { - align-items: start; - display: grid; - gap: 12px; - grid-template-columns: 1.35fr 0.95fr; - margin-bottom: 12px; -} .public-profile-listings { align-self: start; @@ -22469,14 +22520,12 @@ a.city-card:hover { width: 14px; } -.public-profile-reviews .settings-card-head, .public-profile-listings .settings-card-head { align-items: center; display: flex; justify-content: space-between; } -.public-profile-reviews .settings-card-head .ghost, .public-profile-listings .settings-card-head .ghost { border: 0; color: #109253; @@ -22596,9 +22645,6 @@ a.city-card:hover { grid-column: 1 / -1; } - .public-profile-grid { - grid-template-columns: 1fr; - } } @media (max-width: 900px) { @@ -25814,6 +25860,111 @@ a.city-card:hover { stroke-width: 1.75; } +/* Slider zdjec na karcie oferty (galeria bez wchodzenia w oferte, jak Otodom). + Kolejne zdjecia leza na sobie i przelaczamy je krzyzowym zanikaniem. */ +.result-photo-slide { + background-position: center; + background-size: cover; + inset: 0; + opacity: 0; + position: absolute; + transition: opacity 0.35s ease; + z-index: 0; +} + +.result-photo-slide.is-active { + opacity: 1; +} + +/* Strzalki i kropki to