poprawki
Deploy Polska Lokalnie / deploy (push) Successful in 12s

This commit is contained in:
2026-09-03 16:49:33 +02:00
parent 52e1f2cb96
commit d973b44e47
8 changed files with 531 additions and 119 deletions
@@ -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()) {
@@ -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
) {
@@ -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<String, String> 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<String, String> 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 = """
<ns:DanePobierzPelnyRaport>
<ns:pRegon>%s</ns:pRegon>
<ns:pNazwaRaportu>%s</ns:pNazwaRaportu>
</ns:DanePobierzPelnyRaport>""".formatted(regon, report);
String response = call(endpoint, "DanePobierzPelnyRaport", body, session(endpoint, key));
Map<String, String> raw = firstDane(resultOf(response, "DanePobierzPelnyRaportResult"));
if (raw == null) {
return Map.of();
}
Map<String, String> 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<String, String> 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.
@@ -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(),
@@ -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);
}
/**
+245 -61
View File
@@ -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({
<div className="account-main">
<div className="account-heading">
<div>
<h1>Witaj, {getFirstName(getDisplayName(user))}! <span aria-hidden="true">👋</span></h1>
{/* Firma nie ma imienia - witamy pelna nazwa firmy zamiast pierwszego slowa. */}
<h1>Witaj, {user?.accountType === 'COMPANY' ? getDisplayName(user) : getFirstName(getDisplayName(user))}! <span aria-hidden="true">👋</span></h1>
<p>Zarządzaj swoimi wyszukiwaniami, ofertami i ulubionymi ogłoszeniami.</p>
</div>
<button
@@ -10750,6 +10763,10 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
const { applySettings: applyDisplaySettings } = useDisplay();
const navigate = useNavigate();
// Konta firmowe maja jedno pole "Nazwa firmy" zamiast imienia i nazwiska, bez daty urodzenia,
// a adres opisuje siedzibe firmy. Konta prywatne dzialaja bez zmian.
const isCompany = user?.accountType === 'COMPANY';
type Tone = 'success' | 'error' | 'info';
type Banner = { text: string; tone: Tone } | null;
@@ -10762,6 +10779,8 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
const [identity, setIdentity] = useState(() => ({
firstName: '',
lastName: '',
companyName: '',
nip: '',
nick: '',
phonePrefix: DEFAULT_PHONE_COUNTRY.code as string,
phoneDigits: '',
@@ -10773,6 +10792,7 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
}));
const [identityBanner, setIdentityBanner] = useState<Banner>(null);
const [isSavingIdentity, setIsSavingIdentity] = useState(false);
const [isGusLoading, setIsGusLoading] = useState(false);
const [isSuggestingNick, setIsSuggestingNick] = useState(false);
const [isPhoneTouched, setIsPhoneTouched] = useState(false);
const [isPhoneCountryOpen, setIsPhoneCountryOpen] = useState(false);
@@ -10874,6 +10894,9 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
...current,
firstName: getFirstName(user.fullName || ''),
lastName: getLastName(user.fullName || ''),
// Dla firmy cala nazwa siedzi w fullName - nie rozbijamy jej na imie/nazwisko.
companyName: user.fullName || '',
nip: user.nip ?? '',
nick: user.nick ?? '',
phonePrefix: phone.prefix,
phoneDigits: phone.digits,
@@ -10949,14 +10972,42 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
setIdentity((current) => ({ ...current, [field]: value }));
};
// Pobranie danych firmy z rejestru REGON po NIP-ie - uzupelnia nazwe firmy i adres siedziby,
// tak samo jak przy rejestracji konta firmowego.
const fetchCompanyFromGus = async () => {
if (isGusLoading) {
return;
}
setIsGusLoading(true);
setIdentityBanner(null);
try {
const company = await apiFetch<{ name: string | null; nip: string; regon: string | null; address: string | null }>(
`/gus/company?nip=${encodeURIComponent(identity.nip.trim())}`,
);
setIdentity((current) => ({
...current,
nip: company.nip,
companyName: company.name ?? current.companyName,
address: company.address ?? current.address,
}));
setIdentityBanner({ text: `Pobrano dane z rejestru REGON${company.regon ? ` (REGON ${company.regon})` : ''}. Sprawdź je i zapisz.`, tone: 'success' });
} catch (error) {
setIdentityBanner({ text: error instanceof Error ? error.message : 'Nie udało się pobrać danych z GUS.', tone: 'error' });
} finally {
setIsGusLoading(false);
}
};
/**
* Prosi serwer o wolna nazwe uzytkownika dla imienia i nazwiska z formularza. Uzytkownik nie wpisuje
* nicka recznie - przyjmuje propozycje, a wolne miejsce w puli sprawdza baza, nie przegladarka.
*/
const suggestNickFromName = async () => {
const fullName = `${identity.firstName.trim()} ${identity.lastName.trim()}`.trim();
const fullName = isCompany
? identity.companyName.trim()
: `${identity.firstName.trim()} ${identity.lastName.trim()}`.trim();
if (!fullName) {
setIdentityBanner({ text: 'Podaj imię i nazwisko, żeby dopasować nazwę użytkownika.', tone: 'error' });
setIdentityBanner({ text: isCompany ? 'Podaj nazwę firmy, żeby dopasować nazwę użytkownika.' : 'Podaj imię i nazwisko, żeby dopasować nazwę użytkownika.', tone: 'error' });
return;
}
setIsSuggestingNick(true);
@@ -11051,9 +11102,11 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
setIdentityBanner({ text: `Numer telefonu dla kraju ${selectedCountry.country} musi mieć ${selectedCountry.digits} cyfr.`, tone: 'error' });
return;
}
const fullName = `${identity.firstName.trim()} ${identity.lastName.trim()}`.trim();
const fullName = isCompany
? identity.companyName.trim()
: `${identity.firstName.trim()} ${identity.lastName.trim()}`.trim();
if (!fullName) {
setIdentityBanner({ text: 'Podaj imię i nazwisko.', tone: 'error' });
setIdentityBanner({ text: isCompany ? 'Podaj nazwę firmy.' : 'Podaj imię i nazwisko.', tone: 'error' });
return;
}
@@ -11064,7 +11117,10 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
fullName,
nick: identity.nick.trim() || undefined,
phone: composedPhone || undefined,
birthDate: identity.birthDate || undefined,
// NIP wysylamy tylko dla firmy (dla osoby prywatnej undefined = backend go nie zmienia).
nip: isCompany ? identity.nip.trim() : undefined,
// Firma nie ma daty urodzenia - nie wysylamy jej z konta firmowego.
birthDate: isCompany ? undefined : (identity.birthDate || undefined),
address: identity.address.trim() || undefined,
contactPreference: identity.contactPreference,
preferredLanguage: identity.preferredLanguage,
@@ -11390,16 +11446,48 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
<div className="settings-separator" />
<div className="profile-form-grid two-col">
<label>
<span>Imię</span>
<input value={identity.firstName} onChange={(event) => setIdentityField('firstName', event.target.value)} />
</label>
<label>
<span>Nazwisko</span>
<input value={identity.lastName} onChange={(event) => setIdentityField('lastName', event.target.value)} />
</label>
</div>
{isCompany ? (
<div className="profile-form-grid settings-company-grid">
<label>
<span>Nazwa firmy</span>
<input
value={identity.companyName}
placeholder="Wpisz nazwę firmy"
onChange={(event) => setIdentityField('companyName', event.target.value)}
/>
</label>
<label className="login-field-gus">
<span>NIP</span>
<div className="input-action-row">
<input
value={identity.nip}
inputMode="numeric"
placeholder="123-456-78-90"
onChange={(event) => setIdentityField('nip', event.target.value)}
/>
<button
type="button"
onClick={() => { void fetchCompanyFromGus(); }}
disabled={isGusLoading || identity.nip.replace(/\D/g, '').length !== 10}
>
{isGusLoading ? 'Pobieram...' : 'Pobierz z GUS'}
</button>
</div>
<small>Wpisz 10 cyfr numeru NIP, aby pobrać nazwę i adres firmy z rejestru REGON.</small>
</label>
</div>
) : (
<div className="profile-form-grid two-col">
<label>
<span>Imię</span>
<input value={identity.firstName} onChange={(event) => setIdentityField('firstName', event.target.value)} />
</label>
<label>
<span>Nazwisko</span>
<input value={identity.lastName} onChange={(event) => setIdentityField('lastName', event.target.value)} />
</label>
</div>
)}
<div className="profile-form-grid">
<label>
@@ -11409,7 +11497,7 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
jest polem do swobodnej edycji (podszywanie sie, zajmowanie nazw). */}
<input value={identity.nick} readOnly />
<button type="button" onClick={() => { void suggestNickFromName(); }} disabled={isSuggestingNick}>
{isSuggestingNick ? 'Sprawdzam...' : 'Dopasuj do imienia i nazwiska'}
{isSuggestingNick ? 'Sprawdzam...' : (isCompany ? 'Dopasuj do nazwy firmy' : 'Dopasuj do imienia i nazwiska')}
</button>
</div>
<small>
@@ -11505,22 +11593,25 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
)}
</label>
<label>
<span>Data urodzenia (opcjonalnie)</span>
<input
type="date"
max={todayIsoDate}
value={identity.birthDate}
onChange={(event) => setIdentityField('birthDate', event.target.value)}
/>
<small>Nie pokazujemy jej innym użytkownikom.</small>
</label>
{/* Data urodzenia dotyczy tylko osob prywatnych - firma jej nie ma. */}
{!isCompany && (
<label>
<span>Data urodzenia (opcjonalnie)</span>
<input
type="date"
max={todayIsoDate}
value={identity.birthDate}
onChange={(event) => setIdentityField('birthDate', event.target.value)}
/>
<small>Nie pokazujemy jej innym użytkownikom.</small>
</label>
)}
<label>
<span>Adres zamieszkania (opcjonalnie)</span>
<span>{isCompany ? 'Adres siedziby firmy (opcjonalnie)' : 'Adres zamieszkania (opcjonalnie)'}</span>
<input
value={identity.address}
placeholder="Np. Łódź, ul. Piotrkowska 12"
placeholder={isCompany ? 'Np. Warszawa, ul. Marszałkowska 1' : 'Np. Łódź, ul. Piotrkowska 12'}
onChange={(event) => setIdentityField('address', event.target.value)}
/>
</label>
@@ -13267,10 +13358,10 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
<div>
<h2>Dodaj ogłoszenie za darmo</h2>
<p>Dotrzyj do tysięcy potencjalnych kupujących. Bez prowizji. Bez zobowiązań.</p>
<button>
<Link className="free-card-cta" to={ROUTES.add}>
<span>Dodaj ogłoszenie</span>
<strong>0 zł</strong>
</button>
</Link>
<small>Zajmie Ci to tylko 2 minuty!</small>
</div>
<div className="house-graphic" aria-hidden="true">
@@ -13608,6 +13699,9 @@ function apiDetailToWizardState(detail: ApiListingDetail) {
function AddListingPage({ editListingId }: { editListingId?: number }) {
const navigate = useNavigate();
const { user } = useAuth();
// Konta firmowe podaja w kontakcie nazwe firmy / osobe kontaktowa zamiast samego imienia.
const isCompanyAccount = user?.accountType === 'COMPANY';
const isEdit = typeof editListingId === 'number';
const [editLoadError, setEditLoadError] = useState('');
const [currentStep, setCurrentStep] = useState(0);
@@ -14301,7 +14395,7 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
<section className="add-form-card">
<h2>Dane kontaktowe</h2>
<div className="add-three-grid">
<AddInput label="Imię" value={listingDetails.contactName} placeholder="Np. Jan" onChange={(value) => updateListingDetails('contactName', value)} />
<AddInput label={isCompanyAccount ? 'Nazwa firmy / osoba kontaktowa' : 'Imię'} value={listingDetails.contactName} placeholder={isCompanyAccount ? 'Np. Biuro Nieruchomości XYZ' : 'Np. Jan'} onChange={(value) => updateListingDetails('contactName', value)} />
<label className="add-field add-phone-field">
<span>Telefon komórkowy</span>
<div className="add-phone-row">
@@ -20506,7 +20600,6 @@ function MegaSearch({ filters, onOpenMap, onSearch, mode = 'buy' }: {
<div className="mega-bar">
<div className="mega-cell">
<div className={`mega-field ${city ? 'has-value' : ''} ${openField === 'lokalizacja' ? 'active' : ''}`} role="button" tabIndex={0} onClick={() => toggleField('lokalizacja')}>
<Icon name="pin" />
<span>
<small>Lokalizacja</small>
<strong>{cityInput || 'Cała Polska'}</strong>
@@ -20607,7 +20700,6 @@ function MegaSearch({ filters, onOpenMap, onSearch, mode = 'buy' }: {
<div className="mega-cell">
<button type="button" className={`mega-field ${openField === 'rodzaj' ? 'active' : ''}`} onClick={() => toggleField('rodzaj')}>
<Icon name="building" />
<span>
<small>Rodzaj nieruchomości</small>
<strong>{propertyType}</strong>
@@ -21676,6 +21768,65 @@ const OFFER_VIEW_OPTIONS: { mode: string; icon: string; label: string }[] = [
{ mode: 'kafelki', icon: 'grid', label: 'Kafelki' },
];
/**
* Slider zdjec na karcie oferty - pozwala przegladac galerie bez wchodzenia w oferte (jak Otodom).
* Zdjecia sa juz pobrane w useLiveListings (razem z lat/lng), wiec nic dodatkowego nie sciagamy.
* Wlasny stan indeksu wymaga osobnego komponentu - w .map() karty nie mozna uzyc hooka.
*/
function ResultCardGallery({ photos, cover, alt }: { photos: string[]; cover: string | null; alt: string }) {
const images = photos.length > 0 ? photos : [cover ?? heroImage];
const total = images.length;
const [index, setIndex] = useState(0);
const current = Math.min(index, total - 1);
// Karta jest linkiem (nakladka ::after) - strzalki i kropki nie moga go wyzwalac.
const step = (event: ReactMouseEvent, delta: number) => {
event.preventDefault();
event.stopPropagation();
setIndex((prev) => (prev + delta + total) % total);
};
const jump = (event: ReactMouseEvent, target: number) => {
event.preventDefault();
event.stopPropagation();
setIndex(target);
};
return (
<>
{images.map((src, i) => (
<span
key={i}
className={`result-photo-slide ${i === current ? 'is-active' : ''}`}
style={{ backgroundImage: `url(${src})` }}
aria-hidden={i === current ? undefined : true}
role="img"
aria-label={i === current ? alt : undefined}
/>
))}
{total > 1 && (
<>
<button type="button" className="result-photo-nav prev" aria-label="Poprzednie zdjęcie" onClick={(event) => step(event, -1)}>
<Icon name="chevron" />
</button>
<button type="button" className="result-photo-nav next" aria-label="Następne zdjęcie" onClick={(event) => step(event, 1)}>
<Icon name="chevron" />
</button>
<span className="result-photo-counter"><Icon name="camera" /> {current + 1} / {total}</span>
<span className="result-photo-dots">
{images.slice(0, 8).map((_, i) => (
<button
type="button"
key={i}
className={i === current ? 'is-active' : ''}
aria-label={`Zdjęcie ${i + 1}`}
onClick={(event) => jump(event, i)}
/>
))}
</span>
</>
)}
</>
);
}
function RealListingsBand({
offerType,
onOpenListing,
@@ -21814,12 +21965,17 @@ function RealListingsBand({
floor ? `${floor} piętro` : null,
item.yearBuilt ? `${item.yearBuilt} r.` : null,
].filter(Boolean).join(' • ');
// Etykieta jak na Otodom: konto firmowe pokazuje nazwe firmy, kazde inne (prywatne lub
// ogloszenie bez konta w serwisie) - "Oferta prywatna".
const isCompanyOffer = item.ownerAccountType === 'COMPANY';
const sellerTypeLabel = isCompanyOffer ? (item.ownerName?.trim() || 'Oferta firmowa') : 'Oferta prywatna';
return (
// Karta prowadzi do stron oferty. Tytul jest linkiem rozciagnietym na cala
// karte (::after), dzieki czemu mamy prawdziwy <a href> - do skopiowania i
// otwarcia w nowej karcie - bez zagniezdzania przyciskow w <a>.
<article key={item.id} className="result-card real-result-card">
<div className="result-photo" style={{ backgroundImage: `url(${item.coverPhoto ?? heroImage})` }}>
<div className="result-photo">
<ResultCardGallery photos={item.photos} cover={item.coverPhoto} alt={item.title} />
{offerType === 'SALE' && (
<div className="real-photo-badges">
<span className="green-tag">{listingMarketBadge(item.market)}</span>
@@ -21846,6 +22002,21 @@ function RealListingsBand({
<h3><Link className="card-stretched-link" to={listingPath(item.id)}>{item.title}</Link></h3>
<p>{[item.city, item.district].filter(Boolean).join(', ')}</p>
<small>{meta}</small>
{isCompanyOffer && item.ownerId != null ? (
// Oferta firmowa z kontem w serwisie - klik w nazwe firmy otwiera jej profil publiczny.
// stopPropagation, bo cala karta jest linkiem do oferty (nakladka ::after).
<Link
className="result-seller-type company is-link"
to={publicProfilePath(item.ownerId)}
onClick={(event) => event.stopPropagation()}
>
<Icon name="building" /> <span className="seller-name">{sellerTypeLabel}</span>
</Link>
) : (
<span className={`result-seller-type ${isCompanyOffer ? 'company' : 'private'}`}>
<Icon name={isCompanyOffer ? 'building' : 'user'} /> {sellerTypeLabel}
</span>
)}
</div>
<div className="result-side">
@@ -23607,6 +23778,8 @@ type BusinessCard = {
fullName: string;
phone: string | null;
email: string;
// true = wizytowka firmy (etykieta "Nazwa firmy" w zapisie i podpis "Firma" na karcie).
isCompany?: boolean;
};
// Watek z administracja jest trzymany na serwerze jako zwykly tekst wiadomosci, wiec wizytowke
@@ -23614,10 +23787,14 @@ type BusinessCard = {
const BUSINESS_CARD_MARKER = '[wizytówka]';
const BUSINESS_CARD_TEXT = 'Przesyłam wizytówkę kontaktową.';
// Etykieta nazwy w wizytowce: firma ma "Nazwa firmy", osoba prywatna "Imię i nazwisko".
const BUSINESS_CARD_COMPANY_LABEL = 'Nazwa firmy';
const BUSINESS_CARD_PERSON_LABEL = 'Imię i nazwisko';
function formatBusinessCard(card: BusinessCard): string {
const lines = [
`${BUSINESS_CARD_MARKER} Wizytówka kontaktowa`,
`Imię i nazwisko: ${card.fullName}`,
`${card.isCompany ? BUSINESS_CARD_COMPANY_LABEL : BUSINESS_CARD_PERSON_LABEL}: ${card.fullName}`,
`E-mail: ${card.email}`,
];
if (card.phone) {
@@ -23641,7 +23818,11 @@ function parseBusinessCard(content: string): BusinessCard | null {
if (!email) {
return null;
}
return { fullName: readLine('Imię i nazwisko'), email, phone: readLine('Telefon') || null };
// Stare wizytowki maja tylko "Imię i nazwisko" - firmowa etykieta jest opcjonalna i wstecznie zgodna.
const companyName = readLine(BUSINESS_CARD_COMPANY_LABEL);
const isCompany = companyName.length > 0;
const fullName = isCompany ? companyName : readLine(BUSINESS_CARD_PERSON_LABEL);
return { fullName, email, phone: readLine('Telefon') || null, isCompany };
}
// Tresc wiadomosci z serwera zamieniona na czesci widoku: wizytowka renderuje sie jako karta,
@@ -23768,10 +23949,6 @@ function PublicProfilePage() {
</div>
{/* Opis "o mnie" z ustawien konta - pole opcjonalne, wiec pokazujemy tylko gdy wypelnione. */}
{profile.bio && <p className="public-profile-bio">{profile.bio}</p>}
{/* Ocen jeszcze nie zbieramy - slot zostaje, zeby uklad byl ten sam co na wlasnym profilu. */}
<div className="public-profile-rating standalone-profile-rating">
<small>Brak ocen</small>
</div>
</div>
<blockquote>
{profile.verified
@@ -23786,8 +23963,7 @@ function PublicProfilePage() {
</div>
</section>
<div className="public-profile-grid">
<section className="settings-card public-profile-listings">
<section className="settings-card public-profile-listings">
<div className="settings-card-head">
<h2>Ogłoszenia ({profile.listingsCount})</h2>
</div>
@@ -23823,15 +23999,7 @@ function PublicProfilePage() {
))}
</div>
)}
</section>
<section className="settings-card public-profile-reviews">
<div className="settings-card-head">
<h2>Opinie o użytkowniku</h2>
</div>
<p className="admin-empty">Ten użytkownik nie ma jeszcze opinii.</p>
</section>
</div>
</section>
<section className="settings-card public-profile-about">
<h2>O użytkowniku</h2>
@@ -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 && (
<div className="message-business-card" role="group" aria-label="Wizytówka kontaktowa">
<div className="message-business-card-top">
<span className="message-business-card-badge"><Icon name="user" /></span>
<span className="message-business-card-badge"><Icon name={message.businessCard.isCompany ? 'building' : 'user'} /></span>
<div>
<em>Wizytówka kontaktowa</em>
<strong>{message.businessCard.fullName}</strong>
{message.businessCard.isCompany && <small className="message-business-card-type">Firma</small>}
</div>
</div>
<div className="message-business-card-grid">
@@ -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({
<div>
<strong>{sellerName}</strong>
<small>{sellerRole}</small>
<small>Na PolskaLokalnie.pl od 2023</small>
{isPhoneRevealed && listing.contactPhone && (
<a href={`tel:${listing.contactPhone.replace(/\s+/g, '')}`}><Icon name="phone" /> {listing.contactPhone}</a>
)}
+2
View File
@@ -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;
};
+197 -53
View File
@@ -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 <button> wewnatrz .result-photo - globalna regula
.result-photo button (kolko w prawym gornym rogu) je psuje, wiec nadpisujemy. */
.real-result-card .result-photo-nav {
align-items: center;
background: rgba(255, 255, 255, 0.92);
border: 0;
border-radius: 50%;
color: #14223a;
cursor: pointer;
display: flex;
height: 32px;
justify-content: center;
opacity: 0;
padding: 0;
position: absolute;
top: 50%;
transform: translateY(-50%);
transition: opacity 0.16s ease, background 0.16s ease;
width: 32px;
z-index: 4;
}
.real-result-card .result-photo-nav.prev { left: 12px; right: auto; }
.real-result-card .result-photo-nav.next { right: 12px; }
.real-result-card .result-photo-nav:hover { background: #ffffff; }
.real-result-card .result-photo-nav svg {
height: 16px;
stroke: currentColor;
stroke-width: 2.2;
width: 16px;
}
.real-result-card .result-photo-nav.prev svg { transform: rotate(90deg); }
.real-result-card .result-photo-nav.next svg { transform: rotate(-90deg); }
/* Strzalki pokazujemy przy najechaniu na karte (desktop); na dotyku - zawsze. */
.real-result-card:hover .result-photo-nav { opacity: 1; }
@media (hover: none) {
.real-result-card .result-photo-nav { opacity: 1; }
}
.result-photo-counter {
align-items: center;
background: rgba(20, 34, 58, 0.6);
border-radius: 999px;
bottom: 12px;
color: #ffffff;
display: inline-flex;
font-size: 12px;
font-weight: 800;
gap: 5px;
padding: 4px 9px;
position: absolute;
right: 12px;
z-index: 4;
}
.result-photo-counter svg { height: 13px; width: 13px; }
.result-photo-dots {
bottom: 14px;
display: flex;
gap: 6px;
justify-content: center;
left: 0;
position: absolute;
right: 0;
z-index: 4;
}
.real-result-card .result-photo-dots button {
background: rgba(255, 255, 255, 0.55);
border: 0;
border-radius: 50%;
cursor: pointer;
height: 7px;
padding: 0;
position: static;
transition: background 0.16s ease, transform 0.16s ease;
width: 7px;
}
.real-result-card .result-photo-dots button.is-active {
background: #ffffff;
transform: scale(1.25);
}
/* ===== Strzalki galerii w widoku szczegolow ===== */
.ld-gallery-arrow {
@@ -32496,13 +32647,6 @@ a.listing-detail-back {
transform: translateY(-2px);
}
/* Slot ocen zostaje na swoim miejscu, dopoki nie zbieramy opinii o uzytkownikach. */
.standalone-profile-rating small {
color: #7e8fa3;
font-size: 13px;
font-weight: 700;
}
.standalone-profile .settings-footer-note {
margin-top: 4px;
}