From c52bbabec0cded4c473c95f604332980ac1fa8cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Zatorski?= Date: Tue, 18 Aug 2026 15:33:53 +0200 Subject: [PATCH] Zadania 1, 2, 9 --- .../pl/polskalokalnie/config/SchemaFixer.java | 13 +- .../listing/ListingCreateRequest.java | 4 +- .../listing/ListingDetailResponse.java | 70 +++++- .../listing/ListingResponse.java | 16 +- .../listing/ListingService.java | 18 +- .../listing/PropertyListing.java | 15 ++ frontend/src/App.tsx | 211 +++++++++++++++++- frontend/src/styles.css | 121 ++++++++++ 8 files changed, 441 insertions(+), 27 deletions(-) diff --git a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java index d367707..139532c 100644 --- a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java +++ b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java @@ -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. diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingCreateRequest.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingCreateRequest.java index d8e21c0..e00e026 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingCreateRequest.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingCreateRequest.java @@ -43,6 +43,8 @@ public record ListingCreateRequest( List media, List amenities, List photos, - String virtualTourUrl + String virtualTourUrl, + // true = publikujemy tylko przyblizona lokalizacje (mapa rysuje obszar zamiast punktu) + Boolean approximateLocation ) { } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java index 6f4e7a8..1f978a2 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java @@ -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); + } } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java index 31e8f1e..2a128ed 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java @@ -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(), diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java index 8d07cbb..20e54ea 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -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 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())); diff --git a/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java b/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java index 38a473e..3c319b3 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java @@ -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 getMedia() { return media; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0979c64..09052aa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(INITIAL_LISTING_LOCATION); const [photos, setPhotos] = useState([]); 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 }) {

Dane kontaktowe

updateListingDetails('contactName', value)} /> - updateListingDetails('contactPhone', value)} /> + updateListingDetails('contactEmail', value)} />
@@ -14049,6 +14155,21 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
+ + 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({ Szukaj na bieżącej mapie + {areaCircle && ( +