Zadania 1, 2, 9

This commit is contained in:
2026-08-18 15:33:53 +02:00
parent 571ec05d76
commit c52bbabec0
8 changed files with 441 additions and 27 deletions
@@ -78,10 +78,17 @@ public class SchemaFixer {
"ALTER TABLE IF EXISTS user_reports ADD COLUMN IF NOT EXISTS reported_user_deleted BOOLEAN NOT NULL DEFAULT FALSE"
);
// Blokowanie konta ze zgloszenia zostalo usuniete - encja nie mapuje juz tej kolumny,
// wiec stare wiersze zostaja, a nowe wpisy wypelnia wartosc domyslna.
// Blokowanie konta ze zgloszenia zostalo usuniete - encja nie mapuje juz tej kolumny.
// W starych bazach kolumna zostaje, wiec nadajemy jej wartosc domyslna; w nowych jej nie ma,
// a ALTER COLUMN nie przyjmuje IF EXISTS - stad sprawdzenie w bloku DO.
jdbcTemplate.execute(
"ALTER TABLE IF EXISTS user_reports ALTER COLUMN reported_user_blocked SET DEFAULT FALSE"
"DO $$ "
+ "BEGIN "
+ " IF EXISTS (SELECT 1 FROM information_schema.columns "
+ " WHERE table_name = 'user_reports' AND column_name = 'reported_user_blocked') THEN "
+ " ALTER TABLE user_reports ALTER COLUMN reported_user_blocked SET DEFAULT FALSE; "
+ " END IF; "
+ "END $$"
);
// Widocznosc profilu ma juz tylko dwie wartosci - wariant "tylko kontakty" zostal usuniety.
@@ -43,6 +43,8 @@ public record ListingCreateRequest(
List<String> media,
List<String> amenities,
List<String> photos,
String virtualTourUrl
String virtualTourUrl,
// true = publikujemy tylko przyblizona lokalizacje (mapa rysuje obszar zamiast punktu)
Boolean approximateLocation
) {
}
@@ -54,9 +54,30 @@ public record ListingDetailResponse(
ListingStatus status,
Instant createdAt,
Long viewsCount,
Instant promotedUntil
Instant promotedUntil,
// true = wlasciciel nie publikuje dokladnego adresu; mapa rysuje obszar zamiast punktu
boolean approximateLocation
) {
// Promien obszaru, w ktorym mieszcza sie przesuniete wspolrzedne - mapa rysuje kolo tej wielkosci.
private static final double APPROXIMATE_RADIUS_M = 300d;
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);
}
/**
* Gdy {@code masked} jest true (ogloszenie z ukryta lokalizacja ogladane przez kogos innego niz
* wlasciciel i administracja), z odpowiedzi znikaja ulica oraz numer budynku, a wspolrzedne sa
* 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) {
String street = masked ? null : listing.getStreet();
String building = masked ? null : listing.getBuilding();
String address = masked ? approximateAddress(listing) : listing.getAddress();
Double lat = masked ? blurLat(listing) : listing.getLat();
Double lng = masked ? blurLng(listing) : listing.getLng();
return new ListingDetailResponse(
listing.getId(),
listing.getTitle(),
@@ -65,9 +86,9 @@ public record ListingDetailResponse(
listing.getPropertyType(),
listing.getCity(),
listing.getDistrict(),
listing.getStreet(),
listing.getBuilding(),
listing.getAddress(),
street,
building,
address,
listing.getPrice(),
listing.getArea(),
listing.getRooms(),
@@ -88,8 +109,8 @@ public record ListingDetailResponse(
listing.getWindows(),
listing.getExposure(),
listing.getRoomHeight(),
listing.getLat(),
listing.getLng(),
lat,
lng,
List.copyOf(listing.getMedia()),
List.copyOf(listing.getAmenities()),
List.copyOf(listing.getPhotos()),
@@ -100,7 +121,42 @@ public record ListingDetailResponse(
listing.getStatus(),
listing.getCreatedAt(),
listing.getViewsCount(),
listing.getPromotedUntil()
listing.getPromotedUntil(),
listing.isApproximateLocation()
);
}
// Bez ulicy i numeru zostaje sama okolica - dzielnica i miasto.
private static String approximateAddress(PropertyListing listing) {
String district = listing.getDistrict();
return district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity();
}
private static Double blurLat(PropertyListing listing) {
if (listing.getLat() == null) {
return null;
}
return listing.getLat() + (offsetMeters(listing) * Math.cos(offsetAngle(listing))) / METERS_PER_DEGREE;
}
private static Double blurLng(PropertyListing listing) {
if (listing.getLng() == null || listing.getLat() == null) {
return null;
}
double parallel = METERS_PER_DEGREE * Math.cos(Math.toRadians(listing.getLat()));
if (parallel == 0) {
return listing.getLng();
}
return listing.getLng() + (offsetMeters(listing) * Math.sin(offsetAngle(listing))) / parallel;
}
// Kierunek i dlugosc przesuniecia sa stale dla ogloszenia, a punkt zostaje w polowie promienia,
// wiec rzeczywisty adres zawsze miesci sie wewnatrz rysowanego kola.
private static double offsetAngle(PropertyListing listing) {
return Math.toRadians((listing.getId() * 137L) % 360);
}
private static double offsetMeters(PropertyListing listing) {
return (listing.getId() * 53L) % (long) (APPROXIMATE_RADIUS_M / 2);
}
}
@@ -31,6 +31,18 @@ public record ListingResponse(
Instant createdAt
) {
public static ListingResponse from(PropertyListing listing) {
return from(listing, false);
}
/**
* @param masked pokaz sama okolice zamiast adresu - dla ogloszen z ukryta dokladna lokalizacja
* ogladanych spoza panelu wlasciciela i administracji.
*/
public static ListingResponse from(PropertyListing listing, boolean masked) {
String district = listing.getDistrict();
String address = masked
? (district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity())
: listing.getAddress();
return new ListingResponse(
listing.getId(),
listing.getTitle(),
@@ -38,8 +50,8 @@ public record ListingResponse(
listing.getOfferType(),
listing.getPropertyType(),
listing.getCity(),
listing.getDistrict(),
listing.getAddress(),
district,
address,
listing.getPrice(),
listing.getArea(),
listing.getRooms(),
@@ -51,11 +51,19 @@ public class ListingService {
* Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste.
*/
private ListingDetailResponse detailOf(PropertyListing listing) {
return detailOf(listing, false);
}
/**
* @param masked ukryj dokladny adres (ulica, numer, wspolrzedne) - dla ogloszen, ktorych
* wlasciciel wybral publikowanie samej przyblizonej lokalizacji.
*/
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked) {
AppUser owner = ownerOf(listing);
if (owner == null) {
return ListingDetailResponse.from(listing, null, null);
return ListingDetailResponse.from(listing, null, null, masked);
}
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()));
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()), masked);
}
/**
@@ -85,7 +93,7 @@ public class ListingService {
.filter(listing -> offerType == null || listing.getOfferType() == offerType)
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
.sorted(promotedFirst())
.map(ListingResponse::from)
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
.toList();
}
@@ -110,7 +118,8 @@ public class ListingService {
listing = listingRepository.save(listing);
}
return detailOf(listing);
// Wlasciciel i administracja widza pelny adres - reszta tylko przyblizona lokalizacje.
return detailOf(listing, listing.isApproximateLocation() && !isOwner && !viewerIsAdmin);
}
public List<ListingResponse> findMine(String ownerEmail) {
@@ -222,6 +231,7 @@ public class ListingService {
listing.setRoomHeight(trimToNull(request.roomHeight()));
listing.setLat(request.lat());
listing.setLng(request.lng());
listing.setApproximateLocation(Boolean.TRUE.equals(request.approximateLocation()));
listing.setMedia(cleanList(request.media()));
listing.setAmenities(cleanList(request.amenities()));
@@ -17,6 +17,7 @@ import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.annotations.ColumnDefault;
@Entity
@Table(name = "property_listings")
@@ -121,6 +122,12 @@ public class PropertyListing {
private Double lng;
// Wlasciciel nie chce publikowac dokladnego adresu - mapa pokazuje wtedy przyblizony obszar,
// a odpowiedzi publiczne nie zawieraja ulicy ani numeru budynku.
@Column(nullable = false)
@ColumnDefault("false")
private boolean approximateLocation = false;
@ElementCollection
@CollectionTable(name = "listing_media", joinColumns = @JoinColumn(name = "listing_id"))
@Column(name = "value", length = 60)
@@ -420,6 +427,14 @@ public class PropertyListing {
this.lng = lng;
}
public boolean isApproximateLocation() {
return approximateLocation;
}
public void setApproximateLocation(boolean approximateLocation) {
this.approximateLocation = approximateLocation;
}
public List<String> getMedia() {
return media;
}
+201 -10
View File
@@ -128,6 +128,8 @@ export type ApiListingDetail = {
createdAt: string;
viewsCount?: number;
promotedUntil?: string | null;
// true = wlasciciel publikuje tylko przyblizona lokalizacje (mapa rysuje obszar zamiast punktu).
approximateLocation?: boolean;
};
// Ostatnio przegladane oferty - trzymane lokalnie (per urzadzenie) do slajdera na stronie glownej.
@@ -2020,10 +2022,74 @@ const INITIAL_LISTING_DETAILS = {
condition: 'Wybierz',
ownership: 'Wybierz',
contactName: '',
// Prefiks kraju trzymamy osobno od numeru krajowego - razem trafiaja do pola contactPhone oferty.
contactPhoneCountry: 'PL',
contactPhone: '',
contactEmail: '',
};
/**
* Prefiksy telefoniczne dostepne przy ogloszeniu - Polska i kraje najczestsze na naszym rynku.
* Liczba cyfr dotyczy numeru komorkowego bez prefiksu (dla Polski 9, np. +48 600 123 456).
*/
const PHONE_COUNTRIES = [
{ code: 'PL', dial: '+48', label: 'Polska', minDigits: 9, maxDigits: 9, example: '600 123 456' },
{ code: 'UA', dial: '+380', label: 'Ukraina', minDigits: 9, maxDigits: 9, example: '501 234 567' },
{ code: 'DE', dial: '+49', label: 'Niemcy', minDigits: 10, maxDigits: 11, example: '1512 3456789' },
{ code: 'GB', dial: '+44', label: 'Wielka Brytania', minDigits: 10, maxDigits: 10, example: '7700 900123' },
{ code: 'BY', dial: '+375', label: 'Białoruś', minDigits: 9, maxDigits: 9, example: '291 234 567' },
{ code: 'CZ', dial: '+420', label: 'Czechy', minDigits: 9, maxDigits: 9, example: '601 123 456' },
{ code: 'SK', dial: '+421', label: 'Słowacja', minDigits: 9, maxDigits: 9, example: '901 123 456' },
{ code: 'LT', dial: '+370', label: 'Litwa', minDigits: 8, maxDigits: 8, example: '612 34567' },
{ code: 'RO', dial: '+40', label: 'Rumunia', minDigits: 9, maxDigits: 9, example: '712 345 678' },
{ code: 'NL', dial: '+31', label: 'Holandia', minDigits: 9, maxDigits: 9, example: '612 345 678' },
{ code: 'IE', dial: '+353', label: 'Irlandia', minDigits: 9, maxDigits: 9, example: '851 234 567' },
{ code: 'IT', dial: '+39', label: 'Włochy', minDigits: 9, maxDigits: 10, example: '312 345 6789' },
] as const;
const phoneCountryOf = (code: string) => PHONE_COUNTRIES.find((country) => country.code === code) ?? PHONE_COUNTRIES[0];
// Komunikat bledu albo null, gdy numer jest poprawny. Puste pole jest dozwolone - telefon jest opcjonalny.
function validateContactPhone(countryCode: string, value: string): string | null {
const country = phoneCountryOf(countryCode);
const digits = value.replace(/\D/g, '');
if (!digits) {
return null;
}
if (digits.length < country.minDigits || digits.length > country.maxDigits) {
const expected = country.minDigits === country.maxDigits
? `${country.minDigits} cyfr`
: `od ${country.minDigits} do ${country.maxDigits} cyfr`;
return `Numer komórkowy dla kraju ${country.label} (${country.dial}) ma ${expected}. Podano ${digits.length}.`;
}
return null;
}
// Zapis do oferty: "+48 600 123 456". Dziewieciocyfrowe numery grupujemy po trzy dla czytelnosci.
function composeContactPhone(countryCode: string, value: string): string | undefined {
const country = phoneCountryOf(countryCode);
const digits = value.replace(/\D/g, '');
if (!digits) {
return undefined;
}
const grouped = digits.length === 9 ? digits.replace(/(\d{3})(\d{3})(\d{3})/, '$1 $2 $3') : digits;
return `${country.dial} ${grouped}`;
}
// Odwrotnosc composeContactPhone przy wczytywaniu oferty do edycji (najdluzszy pasujacy prefiks wygrywa).
function splitContactPhone(stored: string | null | undefined): { country: string; number: string } {
const raw = (stored ?? '').replace(/[\s-]/g, '');
const match = [...PHONE_COUNTRIES]
.sort((a, b) => b.dial.length - a.dial.length)
.find((country) => raw.startsWith(country.dial));
if (match) {
return { country: match.code, number: match.dial === '+48' ? formatPolishPhone(raw.slice(match.dial.length)) : raw.slice(match.dial.length) };
}
return { country: 'PL', number: formatPolishPhone(raw.replace(/^\+/, '')) };
}
const formatPolishPhone = (digits: string) => (digits.length === 9 ? digits.replace(/(\d{3})(\d{3})(\d{3})/, '$1 $2 $3') : digits);
const INITIAL_EXTRA_INFO = {
rent: '',
deposit: '',
@@ -2623,6 +2689,8 @@ type ListingLocation = {
street: string;
building: string;
unit: string;
// Zaznaczone = na mapie pokazujemy przyblizony obszar zamiast dokladnego punktu.
approximateLocation: boolean;
};
type ListingPhoto = {
@@ -2638,8 +2706,12 @@ const INITIAL_LISTING_LOCATION: ListingLocation = {
street: '',
building: '',
unit: '',
approximateLocation: false,
};
// Promien obszaru pokazywanego zamiast dokladnego punktu (zgodny z rozrzutem wspolrzednych z API).
const APPROXIMATE_LOCATION_RADIUS_M = 300;
const INITIAL_LISTING_COORDS: LatLng = { lat: 50.0491, lng: 19.9617 };
function locationSearchQuery(location: ListingLocation) {
@@ -13268,7 +13340,8 @@ function apiDetailToWizardState(detail: ApiListingDetail) {
condition: orWybierz(detail.condition),
ownership: orWybierz(detail.ownership),
contactName: detail.contactName ?? '',
contactPhone: detail.contactPhone ?? '',
contactPhoneCountry: splitContactPhone(detail.contactPhone).country,
contactPhone: splitContactPhone(detail.contactPhone).number,
contactEmail: detail.contactEmail ?? '',
};
const extraInfo: typeof INITIAL_EXTRA_INFO = {
@@ -13289,6 +13362,7 @@ function apiDetailToWizardState(detail: ApiListingDetail) {
district: detail.district ?? '',
street: detail.street ?? '',
building: detail.building ?? '',
approximateLocation: detail.approximateLocation ?? false,
};
const photos: ListingPhoto[] = (detail.photos ?? []).map((url, index) => ({
id: `edit-photo-${index}`,
@@ -13313,6 +13387,7 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
const [editLoadError, setEditLoadError] = useState('');
const [currentStep, setCurrentStep] = useState(0);
const [listingDetails, setListingDetails] = useState(INITIAL_LISTING_DETAILS);
const [contactPhoneTouched, setContactPhoneTouched] = useState(false);
const [location, setLocation] = useState<ListingLocation>(INITIAL_LISTING_LOCATION);
const [photos, setPhotos] = useState<ListingPhoto[]>([]);
const [extraInfo, setExtraInfo] = useState(INITIAL_EXTRA_INFO);
@@ -13334,6 +13409,8 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
const isFinalPreviewStep = currentStep === 4;
const showListingPreviewDetails = isPhotosStep || isExtraInfoStep;
const mainPhoto = photos[0];
// Komunikat o zlym numerze pokazujemy po opuszczeniu pola, zeby nie krzyczec przy pierwszej cyfrze.
const contactPhoneError = validateContactPhone(listingDetails.contactPhoneCountry, listingDetails.contactPhone);
const isRentTransaction = listingDetails.transaction.trim().toLowerCase() === 'wynajem';
const numericPrice = Number(listingDetails.price.replace(',', '.'));
const numericArea = Number(listingDetails.area.replace(',', '.'));
@@ -13387,6 +13464,11 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
setPublishError('Uzupełnij poprawną cenę, powierzchnię i liczbę pokoi.');
return;
}
if (contactPhoneError) {
setPublishState('error');
setPublishError(`${contactPhoneError} (krok „Szczegóły”).`);
return;
}
const addressValue = [location.street, location.building, location.district]
.map((part) => part.trim())
@@ -13427,7 +13509,7 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
condition: clean(listingDetails.condition),
ownership: clean(listingDetails.ownership),
contactName: clean(listingDetails.contactName),
contactPhone: clean(listingDetails.contactPhone),
contactPhone: composeContactPhone(listingDetails.contactPhoneCountry, listingDetails.contactPhone),
contactEmail: clean(listingDetails.contactEmail),
rentExtra: Number.isFinite(rentNum) && rentNum > 0 ? rentNum : undefined,
availableFrom: isRentTransaction ? clean(extraInfo.availableFrom) : undefined,
@@ -13443,6 +13525,7 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
amenities: selectedAmenities,
photos: photoData,
virtualTourUrl: normalizedVirtualTour,
approximateLocation: location.approximateLocation,
};
if (isEdit) {
@@ -13994,7 +14077,30 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
<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="Telefon" value={listingDetails.contactPhone} placeholder="Np. 600 123 456" onChange={(value) => updateListingDetails('contactPhone', value)} />
<label className="add-field add-phone-field">
<span>Telefon komórkowy</span>
<div className="add-phone-row">
<select
value={listingDetails.contactPhoneCountry}
onChange={(event) => updateListingDetails('contactPhoneCountry', event.target.value)}
aria-label="Prefiks kraju"
>
{PHONE_COUNTRIES.map((country) => (
<option key={country.code} value={country.code}>{country.dial} {country.label}</option>
))}
</select>
<input
type="tel"
inputMode="numeric"
autoComplete="tel-national"
value={listingDetails.contactPhone}
placeholder={`Np. ${phoneCountryOf(listingDetails.contactPhoneCountry).example}`}
onChange={(event) => updateListingDetails('contactPhone', event.target.value.replace(/[^\d ]/g, ''))}
onBlur={() => setContactPhoneTouched(true)}
/>
</div>
{contactPhoneTouched && contactPhoneError && <small className="add-field-error">{contactPhoneError}</small>}
</label>
<AddInput label="E-mail" value={listingDetails.contactEmail} type="email" placeholder="Np. jan.kowalski@email.pl" onChange={(value) => updateListingDetails('contactEmail', value)} />
</div>
<div className="contact-actions">
@@ -14049,6 +14155,21 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
</label>
</div>
<label className="add-location-privacy">
<input
type="checkbox"
checked={location.approximateLocation}
onChange={(event) => setLocation((current) => ({ ...current, approximateLocation: event.target.checked }))}
/>
<span>
<strong>Nie pokazuj dokładnego adresu na mapie</strong>
<small>
Zamiast punktu kupujący zobaczą przybliżony obszar - koło o promieniu ok. {APPROXIMATE_LOCATION_RADIUS_M} m,
a w ogłoszeniu pojawi się tylko dzielnica i miasto. Ulica i numer pozostaną widoczne wyłącznie dla Ciebie i administracji.
</small>
</span>
</label>
<LocationPickerMap
center={locationMapCenter}
marker={locationCenter}
@@ -20044,6 +20165,7 @@ function SatelliteMap({
onMapClick,
onMarkerClick,
size,
areaRadiusMeters,
}: {
markers: string[][];
center?: LatLng;
@@ -20055,6 +20177,8 @@ function SatelliteMap({
onMapClick?: () => void;
onMarkerClick?: (offerId: number) => void;
size?: number;
// Podany promien (w metrach) zastepuje pinezke kolem - uzywany dla przyblizonej lokalizacji.
areaRadiusMeters?: number;
}) {
const baseCenter = center ?? CITY_COORDS.Warszawa;
const [mapCenter, setMapCenter] = useState(baseCenter);
@@ -20121,6 +20245,22 @@ function SatelliteMap({
}, [geoMarkers, mapCenter.lat, mapCenter.lng, currentZoom, viewW, viewH]);
const shouldRenderStaticMarkers = geoMarkers == null;
// Kolo przyblizonej lokalizacji: srednica wynika ze skali kafelkow (zoom i szerokosc geograficzna),
// a pozycja z rzutowania punktu ogloszenia, dzieki czemu obszar trzyma sie mapy przy przesuwaniu.
const areaCircle = useMemo(() => {
if (!areaRadiusMeters) {
return null;
}
const metersPerPixel = (156543.03392 * Math.cos((baseCenter.lat * Math.PI) / 180)) / 2 ** currentZoom;
const worldCenter = projectToWorld(mapCenter.lat, mapCenter.lng, currentZoom);
const worldPoint = projectToWorld(baseCenter.lat, baseCenter.lng, currentZoom);
return {
diameter: (2 * areaRadiusMeters) / metersPerPixel,
left: viewW / 2 + (worldPoint.x - worldCenter.x),
top: viewH / 2 + (worldPoint.y - worldCenter.y),
};
}, [areaRadiusMeters, baseCenter.lat, baseCenter.lng, mapCenter.lat, mapCenter.lng, currentZoom, viewW, viewH]);
const changeZoom = (delta: number) => {
setCurrentZoom((current) => Math.max(6, Math.min(19, current + delta)));
};
@@ -20227,9 +20367,17 @@ function SatelliteMap({
<span />
Szukaj na bieżącej mapie
</button>
{areaCircle && (
<span
className="map-area-circle"
style={{ left: `${areaCircle.left}px`, top: `${areaCircle.top}px`, width: areaCircle.diameter, height: areaCircle.diameter }}
aria-hidden="true"
/>
)}
{centerMarkerLabel && (
<>
<span className="map-center-pin" style={{ left: '50%', top: '52%' }} aria-hidden="true" />
{/* Przy przyblizonej lokalizacji nie rysujemy pinezki - dokladny punkt zastepuje obszar. */}
{!areaCircle && <span className="map-center-pin" style={{ left: '50%', top: '52%' }} aria-hidden="true" />}
<span className="map-price map-price-center-label" style={{ left: '50%', top: '44%' }}>{centerMarkerLabel}</span>
</>
)}
@@ -23824,6 +23972,9 @@ function useLiveListings(offerType: ApiOfferType, refreshMs = LIVE_LISTINGS_REFR
return { items, loading };
}
// Podpowiedz przy narzedziach wylaczonych we wlasnym ogloszeniu (kontakt, ulubione, alert ceny, zgloszenie).
const OWNER_TOOL_HINT = 'To Twoje ogłoszenie - ta opcja jest dostępna tylko dla innych użytkowników.';
function ListingDetailPage({
listingId,
onStartChat,
@@ -23987,6 +24138,7 @@ function ListingDetailPage({
street: listing.street ?? '',
building: listing.building ?? '',
unit: '',
approximateLocation: false,
}, controller.signal)
.then((point) => {
if (point) {
@@ -24068,7 +24220,9 @@ function ListingDetailPage({
.filter(Boolean)
.join(', ');
const sellerName = listing.contactName?.trim() || 'Administracja Polska Lokalnie';
const isOwner = Boolean(user && listing.ownerEmail && user.email.toLowerCase() === listing.ownerEmail.toLowerCase());
// 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';
const favorite = isFavorite(listing.id);
const listingAlertEnabled = isListingAlertEnabled(listing.id);
@@ -24125,7 +24279,11 @@ function ListingDetailPage({
const priceSuffix = listing.offerType === 'RENT' ? ' /mc' : '';
const listingMapCenter = resolvedMapCenter ?? CITY_COORDS[listing.city] ?? POLAND_CENTER;
const listingMapZoom = listing.street && listing.building ? 16 : listing.street ? 15 : listing.district ? 14 : 12;
// Przy ukrytym adresie cofamy zoom, zeby cale kolo przyblizonego obszaru zmiescilo sie w kadrze.
const approximateLocation = Boolean(listing.approximateLocation);
const listingMapZoom = approximateLocation
? 14
: listing.street && listing.building ? 16 : listing.street ? 15 : listing.district ? 14 : 12;
const openLocationInMaps = () => {
const query = `${listingMapCenter.lat},${listingMapCenter.lng}`;
window.open(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`, '_blank', 'noopener,noreferrer');
@@ -24462,9 +24620,14 @@ function ListingDetailPage({
initialZoom={listingMapZoom}
showCityTitle={false}
onMapClick={openLocationInMaps}
areaRadiusMeters={approximateLocation ? APPROXIMATE_LOCATION_RADIUS_M : undefined}
/>
</div>
<p className="listing-detail-map-hint">Kliknij mapę, aby otworzyć dokładny punkt w Google Maps.</p>
<p className="listing-detail-map-hint">
{approximateLocation
? `Sprzedający nie udostępnia dokładnego adresu - zaznaczony obszar to okolica nieruchomości (promień ok. ${APPROXIMATE_LOCATION_RADIUS_M} m).`
: 'Kliknij mapę, aby otworzyć dokładny punkt w Google Maps.'}
</p>
<div className="listing-detail-meta-actions">
<div className="listing-detail-meta">
<span><Icon name="clock" /> {listingRelativeDate(listing.createdAt)}</span>
@@ -24474,7 +24637,13 @@ function ListingDetailPage({
<span className={`ld-status ld-status-${listing.status.toLowerCase()}`}>{LISTING_STATUS_LABELS[listing.status]}</span>
)}
</div>
<button type="button" className="listing-detail-report-btn" onClick={openReportModal}>
<button
type="button"
className="listing-detail-report-btn"
onClick={openReportModal}
disabled={isOwner}
title={isOwner ? OWNER_TOOL_HINT : undefined}
>
<Icon name="warning" /> Zgłoś
</button>
</div>
@@ -24495,7 +24664,19 @@ function ListingDetailPage({
{listing.yearBuilt && <div><Icon name="calendar" /><strong>{listing.yearBuilt}</strong><span>Rok budowy</span></div>}
</div>
<button type="button" className="listing-detail-primary" onClick={revealContact}>
{isOwner && (
<p className="listing-detail-owner-note">
<Icon name="user" /> To Twoje ogłoszenie. Widzisz je dokładnie tak jak inni, ale narzędzia dla kupujących - kontakt, ulubione, alert ceny i zgłoszenie - są wyłączone.
</p>
)}
<button
type="button"
className="listing-detail-primary"
onClick={revealContact}
disabled={isOwner}
title={isOwner ? OWNER_TOOL_HINT : undefined}
>
{isPhoneRevealed
? (listing.contactPhone
? <><Icon name="phone" /> {listing.contactPhone}</>
@@ -24503,7 +24684,13 @@ function ListingDetailPage({
: <><Icon name="phone" /> Skontaktuj się ze sprzedającym</>}
</button>
<button type="button" className="listing-detail-secondary" onClick={startChatWithSeller}>
<button
type="button"
className="listing-detail-secondary"
onClick={startChatWithSeller}
disabled={isOwner}
title={isOwner ? OWNER_TOOL_HINT : undefined}
>
<Icon name="message" /> Napisz wiadomość
</button>
@@ -24555,6 +24742,8 @@ function ListingDetailPage({
type="button"
className={`listing-detail-fav ${favorite ? 'active' : ''}`}
onClick={() => onToggleFavorite(listing)}
disabled={isOwner}
title={isOwner ? OWNER_TOOL_HINT : undefined}
>
<Icon name={favorite ? 'heart-filled' : 'heart'} /> {favorite ? 'W ulubionych' : 'Dodaj do ulubionych'}
</button>
@@ -24563,6 +24752,8 @@ function ListingDetailPage({
type="button"
className={`listing-detail-alert ${listingAlertEnabled ? 'active' : ''}`}
onClick={() => onToggleListingAlert(listing)}
disabled={isOwner}
title={isOwner ? OWNER_TOOL_HINT : undefined}
>
<Icon name="clock" /> {listingAlertEnabled ? 'Alert aktywny' : 'Utwórz alert ceny'}
</button>
+121
View File
@@ -30493,3 +30493,124 @@ a.listing-detail-back {
margin: 0;
padding: 10px 12px;
}
/* Wlasne ogloszenie: narzedzia przeznaczone dla innych uzytkownikow sa wylaczone i opisane w karcie */
.listing-detail-owner-note {
align-items: flex-start;
background: #f6fbf8;
border: 1px solid #cfe8d8;
border-radius: 10px;
color: #17653e;
display: flex;
font-size: 13px;
font-weight: 700;
gap: 8px;
line-height: 1.45;
margin: 0;
padding: 10px 12px;
}
.listing-detail-owner-note svg {
flex-shrink: 0;
height: 16px;
margin-top: 1px;
width: 16px;
}
.listing-detail-primary:disabled,
.listing-detail-secondary:disabled,
.listing-detail-fav:disabled,
.listing-detail-alert:disabled,
.listing-detail-report-btn:disabled {
cursor: default;
opacity: 0.5;
}
.listing-detail-primary:disabled:hover {
background: #129357;
}
.listing-detail-report-btn:disabled:hover {
background: #ffffff;
}
/* Przyblizona lokalizacja: zamiast pinezki mapa rysuje obszar, w ktorym znajduje sie nieruchomosc */
.map-area-circle {
background: rgba(18, 167, 100, 0.22);
border: 2px solid rgba(18, 167, 100, 0.85);
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.35) inset;
pointer-events: none;
position: absolute;
transform: translate(-50%, -50%);
z-index: 2;
}
/* Krok lokalizacji w dodawaniu ogloszenia: zgoda na publikowanie samej okolicy */
.add-location-privacy {
align-items: flex-start;
background: #f6fbf8;
border: 1px solid #cfe8d8;
border-radius: 10px;
cursor: pointer;
display: flex;
gap: 10px;
margin-bottom: 16px;
padding: 12px 14px;
}
.add-location-privacy input {
accent-color: #12a764;
flex-shrink: 0;
height: 18px;
margin-top: 1px;
width: 18px;
}
.add-location-privacy span {
display: grid;
gap: 4px;
}
.add-location-privacy strong {
color: #14223a;
font-size: 13px;
font-weight: 800;
}
.add-location-privacy small {
color: #5b6b82;
font-size: 12px;
line-height: 1.5;
}
/* Telefon w ogloszeniu: prefiks kraju obok numeru krajowego */
.add-phone-row {
display: flex;
gap: 8px;
}
.add-phone-row select {
flex: 0 0 auto;
max-width: 152px;
padding-left: 10px;
padding-right: 6px;
}
.add-phone-row input {
flex: 1 1 auto;
min-width: 0;
}
/* Komunikat bledu jest zwyklym wierszem pod polem - w odroznieniu od podpowiedzi
(.add-field > small), ktore sa pozycjonowane absolutnie wewnatrz pola. */
.add-field > small.add-field-error {
bottom: auto;
color: #d5493c;
display: block;
font-size: 12px;
font-weight: 800;
line-height: 1.4;
position: static;
right: auto;
}