diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 56c77f3..f6c16ef 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1978,8 +1978,196 @@ const extraInfoSelectOptions = { fencing: ['Wybierz', 'Tak', 'Częściowe', 'Nie'], accessRoad: ['Wybierz', 'Asfalt', 'Kostka', 'Utwardzony', 'Szutrowy', 'Polny'], sewageType: ['Wybierz', 'Kanalizacja', 'Szambo', 'Przydomowa oczyszczalnia'], + // Nowe slowniki dla dynamicznej sekcji "Dodatkowe informacje" (per kategoria). + landType: ['Wybierz', 'Budowlana', 'Rolna', 'Rekreacyjna', 'Leśna', 'Inwestycyjna', 'Siedliskowa', 'Rolno-budowlana'], + landPurpose: ['Wybierz', 'Mieszkaniowa', 'Usługowa', 'Przemysłowa', 'Rolnicza', 'Rekreacyjna', 'Mieszana'], + planningStatus: ['Wybierz', 'MPZP', 'Warunki zabudowy (WZ)', 'Brak / w trakcie'], + plotShape: ['Wybierz', 'Prostokąt', 'Kwadrat', 'Trapez', 'Wąska i długa', 'Nieregularny'], + localPurpose: ['Wybierz', 'Handlowy', 'Usługowy', 'Biurowy', 'Gastronomiczny', 'Magazynowy', 'Medyczny', 'Produkcyjny', 'Inny'], + buildingKind: ['Wybierz', 'Biurowy', 'Handlowy', 'Magazynowy', 'Produkcyjny', 'Usługowy', 'Magazynowo-biurowy', 'Hotelowy'], + garageType: ['Wybierz', 'Garaż murowany', 'Hala garażowa', 'Miejsce postojowe', 'Blaszak', 'Podziemny'], + garageLevel: ['Wybierz', 'Naziemny', 'Podziemny (-1)', 'Podziemny (-2)', 'Wielopoziomowy'], + legalStatus: ['Wybierz', 'Uregulowany', 'Do uregulowania', 'Z obciążeniem hipotecznym'], + landRegister: ['Wybierz', 'Tak', 'Nie', 'W trakcie zakładania'], + roommates: ['Wybierz', 'Brak', '1', '2', '3', '4+'], + roommatePreference: ['Wybierz', 'Bez znaczenia', 'Kobiety', 'Mężczyźni', 'Studenci', 'Osoby pracujące'], }; +// --------------------------------------------------------------------------------------------- +// Dynamiczna sekcja "Dodatkowe informacje" (krok 4 kreatora). +// Dla kazdej kategorii pokazujemy tylko pola, ktore realnie maja sens dla kupujacego/najemcy. +// Kafelki (checkboxy) zapisuja sie przez tablice media[]/amenities[] i sa widoczne na ogloszeniu. +// Selecty/pola tekstowe zapisuja sie tam, gdzie istnieje kolumna w bazie (furnishing, buildingType, +// buildingMaterial, windows, exposure, roomHeight, availableFrom); pozostale zyja w podgladzie kreatora. +// --------------------------------------------------------------------------------------------- + +// Ikony kafelkow: najpierw z istniejacych list opcji, potem uzupelnienie dla nowych etykiet. +const EXTRA_TILE_ICONS: Record = { + ...Object.fromEntries([...mediaOptions, ...amenityOptions, ...houseAmenityOptions].map((option) => [option.label, option.icon])), + Klimatyzacja: 'snow', + 'Wentylacja mechaniczna': 'snow', + 'Wejście z ulicy': 'pin', + Witryna: 'screen', + 'Zaplecze socjalne': 'sofa', + 'Dostęp 24/7': 'gear', + 'Plac manewrowy': 'truck', + 'Rampa rozładunkowa': 'truck', + 'Brama automatyczna': 'car', + Oświetlenie: 'sun', + 'Kanał samochodowy': 'car', + 'Media przy granicy': 'link', + 'Dostęp do drogi publicznej': 'pin', + 'Widok na las/wodę': 'tree', + 'Dostęp do kuchni': 'flame', + 'Dostęp do łazienki': 'sun', + 'Osobna łazienka': 'shield', + Pralka: 'snow', + Ogród: 'tree', +}; +const tileIcon = (label: string): string => EXTRA_TILE_ICONS[label] ?? 'spark'; + +type ExtraStore = 'extra' | 'details'; +type ExtraFieldDef = { + kind: 'select' | 'input' | 'date'; + store: ExtraStore; + key: string; + label: string; + options?: string[]; + suffix?: string; + placeholder?: string; + numeric?: 'integer' | 'decimal'; +}; +const O = extraInfoSelectOptions; +const sel = (key: string, label: string, options: string[]): ExtraFieldDef => ({ kind: 'select', store: 'extra', key, label, options }); +const numField = (key: string, label: string, suffix?: string, placeholder?: string, numeric: 'integer' | 'decimal' = 'decimal'): ExtraFieldDef => ({ kind: 'input', store: 'extra', key, label, suffix, placeholder, numeric }); +const txtField = (key: string, label: string, placeholder?: string): ExtraFieldDef => ({ kind: 'input', store: 'extra', key, label, placeholder }); +const dateField = (key: string, label: string): ExtraFieldDef => ({ kind: 'date', store: 'extra', key, label }); + +// Pola najmu wspolne dla wiekszosci kategorii (prepend przy transakcji "Wynajem"). +const RENT_LEAD_FIELDS: ExtraFieldDef[] = [ + sel('rentalPeriod', 'Okres najmu', O.rentalPeriod), + numField('deposit', 'Kaucja', 'zł', 'Np. 6000'), + dateField('availableFrom', 'Dostępne od'), +]; +// Pola prawne wazne przy zakupie (append przy transakcji "Sprzedaż"). +const SALE_LEGAL_FIELDS: ExtraFieldDef[] = [ + sel('legalStatus', 'Stan prawny', O.legalStatus), + sel('landRegister', 'Księga wieczysta', O.landRegister), +]; + +type CategoryExtra = { + fields: ExtraFieldDef[]; + media: string[]; + amenities: string[]; + rentAmenities?: string[]; + legal?: boolean; // dolacz pola prawne przy sprzedazy + rentLead?: boolean; // dolacz pola najmu przy wynajmie (domyslnie true) +}; + +const CATEGORY_EXTRA: Record = { + Mieszkanie: { + legal: true, + fields: [ + sel('buildingType', 'Typ zabudowy', O.buildingType), + sel('furnishing', 'Umeblowanie', O.furnishing), + sel('buildingMaterial', 'Materiał budynku', O.buildingMaterial), + sel('windows', 'Okna', O.windows), + sel('exposure', 'Ekspozycja okien', O.exposure), + numField('roomHeight', 'Wysokość pomieszczeń', 'cm', 'Np. 270', 'integer'), + ], + media: ['Ogrzewanie miejskie', 'Ogrzewanie gazowe', 'Ogrzewanie elektryczne', 'Ogrzewanie podłogowe', 'Woda miejska', 'Prąd', 'Gaz', 'Internet', 'Światłowód', 'Telewizja kablowa', 'Klimatyzacja'], + amenities: ['Balkon', 'Taras', 'Winda', 'Parking', 'Garaż', 'Piwnica', 'Komórka lokatorska', 'Monitoring', 'Ochrona', 'Dostęp dla niepełnosprawnych'], + rentAmenities: ['Dla studentów', 'Zwierzęta mile widziane'], + }, + Dom: { + legal: true, + fields: [ + numField('plotArea', 'Powierzchnia działki', 'm²', 'Np. 650'), + sel('buildingMaterial', 'Materiał', O.buildingMaterial), + sel('roofType', 'Dach', O.roofType), + sel('roofCover', 'Pokrycie dachu', O.roofCover), + sel('furnishing', 'Umeblowanie', O.furnishing), + sel('windows', 'Okna', O.windows), + sel('exposure', 'Ekspozycja', O.exposure), + sel('accessRoad', 'Dojazd', O.accessRoad), + sel('fencing', 'Ogrodzenie', O.fencing), + sel('sewageType', 'Kanalizacja / szambo', O.sewageType), + ], + media: ['Ogrzewanie gazowe', 'Ogrzewanie elektryczne', 'Ogrzewanie podłogowe', 'Woda miejska', 'Prąd', 'Gaz', 'Internet', 'Światłowód', 'Pompa ciepła', 'Fotowoltaika', 'Rekuperacja', 'Klimatyzacja'], + amenities: ['Ogród', 'Kominek', 'Basen', 'Sauna', 'Ogród zimowy', 'Altana', 'Automatyczna brama', 'Rolety zewnętrzne', 'Monitoring zewnętrzny', 'System alarmowy', 'Ładowarka do samochodu elektrycznego', 'Grill murowany', 'Plac zabaw'], + rentAmenities: ['Dla studentów', 'Zwierzęta mile widziane'], + }, + Działka: { + legal: true, + fields: [ + sel('landType', 'Typ działki', O.landType), + sel('landPurpose', 'Przeznaczenie', O.landPurpose), + sel('planningStatus', 'Plan / warunki zabudowy', O.planningStatus), + sel('plotShape', 'Kształt działki', O.plotShape), + txtField('plotDimensions', 'Wymiary', 'Np. 20 × 40 m'), + sel('accessRoad', 'Dojazd', O.accessRoad), + sel('fencing', 'Ogrodzenie', O.fencing), + ], + media: ['Prąd', 'Woda miejska', 'Gaz', 'Kanalizacja'], + amenities: ['Media przy granicy', 'Dostęp do drogi publicznej', 'Widok na las/wodę'], + }, + 'Lokal użytkowy': { + legal: true, + fields: [ + sel('localPurpose', 'Przeznaczenie lokalu', O.localPurpose), + numField('roomHeight', 'Wysokość pomieszczeń', 'cm', 'Np. 320', 'integer'), + sel('furnishing', 'Wyposażenie', O.furnishing), + ], + media: ['Ogrzewanie miejskie', 'Ogrzewanie gazowe', 'Ogrzewanie elektryczne', 'Woda miejska', 'Prąd', 'Gaz', 'Internet', 'Światłowód', 'Klimatyzacja', 'Wentylacja mechaniczna', 'Alarm'], + amenities: ['Wejście z ulicy', 'Witryna', 'Zaplecze socjalne', 'Parking', 'Dostęp 24/7', 'Monitoring', 'Ochrona', 'Dostęp dla niepełnosprawnych'], + }, + 'Budynek użytkowy': { + legal: true, + fields: [ + sel('buildingKind', 'Rodzaj obiektu', O.buildingKind), + numField('plotArea', 'Powierzchnia działki', 'm²', 'Np. 1500'), + numField('roomHeight', 'Wysokość', 'cm', 'Np. 450', 'integer'), + ], + media: ['Ogrzewanie gazowe', 'Ogrzewanie elektryczne', 'Woda miejska', 'Prąd', 'Gaz', 'Kanalizacja', 'Internet', 'Światłowód', 'Klimatyzacja', 'Wentylacja mechaniczna'], + amenities: ['Plac manewrowy', 'Rampa rozładunkowa', 'Parking', 'Brama automatyczna', 'Monitoring', 'Ochrona', 'Dostęp 24/7'], + }, + Garaż: { + legal: true, + fields: [ + sel('garageType', 'Typ garażu', O.garageType), + sel('garageLevel', 'Poziom', O.garageLevel), + ], + media: ['Prąd'], + amenities: ['Oświetlenie', 'Brama automatyczna', 'Monitoring', 'Ochrona', 'Dostęp 24/7', 'Kanał samochodowy'], + }, + Pokój: { + rentLead: true, + fields: [ + sel('roommates', 'Liczba współlokatorów', O.roommates), + sel('roommatePreference', 'Preferencje współlokatorów', O.roommatePreference), + sel('furnishing', 'Umeblowanie', O.furnishing), + ], + media: ['Internet', 'Światłowód', 'Telewizja kablowa'], + amenities: ['Dostęp do kuchni', 'Dostęp do łazienki', 'Osobna łazienka', 'Balkon', 'Pralka', 'Parking', 'Dla studentów', 'Zwierzęta mile widziane'], + }, +}; + +// Zbiera pola/kafelki dla wybranej kategorii i transakcji. Nieznana kategoria -> jak mieszkanie. +function resolveCategoryExtra(category: string, isRent: boolean): { fields: ExtraFieldDef[]; mediaTiles: string[]; amenityTiles: string[] } { + const cfg = CATEGORY_EXTRA[category] ?? CATEGORY_EXTRA.Mieszkanie; + const fields: ExtraFieldDef[] = [ + ...(isRent && cfg.rentLead !== false ? RENT_LEAD_FIELDS : []), + ...cfg.fields, + ...(!isRent && cfg.legal ? SALE_LEGAL_FIELDS : []), + ]; + const amenityTiles = [...cfg.amenities, ...(isRent ? (cfg.rentAmenities ?? []) : [])]; + return { fields, mediaTiles: cfg.media, amenityTiles }; +} + +// Klucze extraInfo z wlasna kolumna w bazie - tylko te czyscimy przy zmianie kategorii, bo tylko +// one moglyby wyciec do zapisanego ogloszenia (reszta pol zyje wylacznie w podgladzie kreatora). +const PERSISTED_EXTRA_SELECTS = ['furnishing', 'buildingType', 'buildingMaterial', 'windows', 'exposure', 'roomHeight'] as const; + // Ekran startowy dodawania oferty: dwie karty (sprzedaż/wynajem) z listą kategorii. Wybór ustawia // transakcje i rodzaj nieruchomosci w formularzu. Rodzaje spoza "Mieszkanie"/"Dom" backend zapisuje // jako APARTMENT (enum ma tylko APARTMENT/HOUSE) - etykieta zyje w formularzu, logika sie zgadza. @@ -2420,6 +2608,22 @@ const INITIAL_EXTRA_INFO = { windows: 'Wybierz', exposure: 'Wybierz', roomHeight: '', + // Pola kategorii-specyficzne dodane do dynamicznej sekcji "Dodatkowe informacje". Trzymane na + // froncie (podglad kreatora); do bazy trafiaja tylko te, ktore maja swoja kolumne (furnishing, + // buildingType, buildingMaterial, windows, exposure, roomHeight, availableFrom) oraz tagi media/amenities. + landType: 'Wybierz', + landPurpose: 'Wybierz', + planningStatus: 'Wybierz', + plotShape: 'Wybierz', + plotDimensions: '', + localPurpose: 'Wybierz', + buildingKind: 'Wybierz', + garageType: 'Wybierz', + garageLevel: 'Wybierz', + legalStatus: 'Wybierz', + landRegister: 'Wybierz', + roommates: 'Wybierz', + roommatePreference: 'Wybierz', virtualTourUrl: '', }; @@ -12557,9 +12761,6 @@ function AccountPublicProfilePage() { {myListings.map((listing) => { const isRent = listing.offerType === 'RENT'; const location = [listing.district, listing.city].filter(Boolean).join(', ') || listing.city; - const floorLabel = listing.floor - ? (listing.buildingFloors ? `${listing.floor}/${listing.buildingFloors} piętro` : `${listing.floor} piętro`) - : null; return (
{isRent ? formatMonthlyPrice(listing.price) : formatPrice(listing.price)}

{listing.title}

{location} -
- {formatArea(listing.area)} - {listing.rooms} {listing.rooms === 1 ? 'pokój' : 'pokoje'} - {floorLabel && {floorLabel}} -
+
{listingRelativeDate(listing.createdAt) || 'Dodane niedawno'}
@@ -14086,7 +14290,6 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { const isPhotosStep = currentStep === 2; const isExtraInfoStep = currentStep === 3; 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); @@ -14223,32 +14426,28 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { }; const isHouseProperty = listingDetails.propertyType === 'Dom'; - const isSaleHouse = isHouseProperty && !isRentTransaction; const isLandProperty = listingDetails.propertyType === 'Działka'; const showFlatFields = !isHouseProperty && !isLandProperty; const showRoomFields = !isLandProperty; const showBuildingFields = !isLandProperty; - const showBuildingExtraFields = !isLandProperty; - const visibleMediaOptions = mediaOptions.filter((option) => { - if (isSaleHouse && option.label === 'Kanalizacja') { - return false; + // Dynamiczna zawartosc kroku "Dodatkowe informacje" - pola i kafelki zalezne od kategorii i transakcji. + const extraSection = resolveCategoryExtra(listingDetails.propertyType, isRentTransaction); + const activeExtraFields = extraSection.fields; + const visibleMediaOptions = extraSection.mediaTiles.map((label) => ({ label, icon: tileIcon(label) })); + const visibleAmenityOptions = extraSection.amenityTiles.map((label) => ({ label, icon: tileIcon(label) })); + // Odczyt/zapis pola z konfiguracji do wlasciwego stanu (extraInfo albo listingDetails). + const extraFieldValue = (field: ExtraFieldDef): string => { + const source = field.store === 'details' ? listingDetails : extraInfo; + const raw = (source as unknown as Record)[field.key]; + return typeof raw === 'string' ? raw : ''; + }; + const setExtraField = (field: ExtraFieldDef, value: string) => { + if (field.store === 'details') { + updateListingDetails(field.key as keyof typeof INITIAL_LISTING_DETAILS, value); + } else { + updateExtraInfo(field.key as keyof typeof INITIAL_EXTRA_INFO, value); } - return !isLandProperty || ['Prąd', 'Gaz', 'Woda miejska', 'Kanalizacja'].includes(option.label); - }); - const hiddenAmenityLabels = isLandProperty - ? ['Balkon', 'Taras', 'Winda', 'Piwnica', 'Komórka lokatorska', 'Dostęp dla niepełnosprawnych', 'Monitoring'] - : isHouseProperty - ? ['Winda', 'Komórka lokatorska', 'Garaż', 'Piwnica', 'Strych'] - : ['Strych', 'Ogrodzenie']; - const availableAmenityOptions = isHouseProperty - ? [...amenityOptions, ...houseAmenityOptions] - : amenityOptions; - const visibleAmenityOptions = availableAmenityOptions.filter((option) => { - if (!isRentTransaction && (option.label === 'Dla studentów' || option.label === 'Zwierzęta mile widziane')) { - return false; - } - return !hiddenAmenityLabels.includes(option.label); - }); + }; const mediaBillingEntries = isRentTransaction ? [ ...selectedMedia @@ -14266,20 +14465,21 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { : []), ] : []; - const previewArea = listingDetails.area.trim() ? `${listingDetails.area.replace('.', ',')} m²` : 'Powierzchnia'; - const previewRooms = listingDetails.rooms !== 'Wybierz' ? `${listingDetails.rooms} ${listingDetails.rooms === '1' ? 'pokój' : ['2', '3', '4'].includes(listingDetails.rooms) ? 'pokoje' : 'pokoi'}` : 'Pokoje'; - const previewFloor = listingDetails.floor !== 'Wybierz' ? `Piętro ${listingDetails.floor}${listingDetails.buildingFloors !== 'Wybierz' ? `/${listingDetails.buildingFloors}` : ''}` : 'Piętro'; const previewLocation = [ location.city, location.district, location.estate, [location.street, location.building].filter(Boolean).join(' '), ].filter((part) => part.trim()).join(', ') || 'Lokalizacja'; - const previewMetaItems = [ - { icon: 'building', label: previewArea }, - ...(showRoomFields ? [{ icon: 'house', label: previewRooms }] : []), - ...(showFlatFields ? [{ icon: 'city', label: previewFloor }] : []), - { icon: 'check', label: listingDetails.market }, + // Rdzenne parametry jako kafelki (ikona -> wartosc -> podpis), tak jak na stronie ogloszenia. + const previewSpecTiles: SpecTile[] = [ + { icon: 'area', value: listingDetails.area.trim() ? `${listingDetails.area.replace('.', ',')} m²` : '—', caption: 'Powierzchnia' }, + ...(showRoomFields ? [{ icon: 'plan', value: listingDetails.rooms !== 'Wybierz' ? listingDetails.rooms : '—', caption: 'Pokoje' }] : []), + ...(showFlatFields ? [{ icon: 'stairs', value: listingDetails.floor !== 'Wybierz' ? `${listingDetails.floor}${listingDetails.buildingFloors !== 'Wybierz' ? `/${listingDetails.buildingFloors}` : ''}` : '—', caption: 'Piętro' }] : []), + { icon: 'check', value: listingDetails.market, caption: 'Rynek' }, + ]; + // Dodatkowe cechy (umeblowanie, udogodnienia, media) jako drobne chipy pod kafelkami - w kroku "Dodatkowe informacje". + const previewExtraChips = [ ...(isExtraInfoStep && extraInfo.furnishing !== 'Wybierz' ? [{ icon: 'sofa', label: extraInfo.furnishing === 'Tak' ? 'Umeblowane' : `Umeblowanie: ${extraInfo.furnishing}` }] : []), ...(isExtraInfoStep && extraInfo.buildingType !== 'Wybierz' ? [{ icon: 'building', label: extraInfo.buildingType }] : []), ...(['Balkon', 'Taras', 'Ogródek', 'Winda', 'Parking', 'Garaż', 'Piwnica'] @@ -14288,7 +14488,7 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { ...(['Internet', 'Klimatyzacja', 'Ogrzewanie miejskie', 'Gaz'] .filter((label) => isExtraInfoStep && selectedMedia.includes(label)) .map((label) => ({ icon: label === 'Internet' ? 'wifi' : label === 'Klimatyzacja' ? 'snow' : label === 'Gaz' || label === 'Ogrzewanie miejskie' ? 'flame' : 'spark', label }))), - ].slice(0, isExtraInfoStep ? 8 : 4); + ].slice(0, 6); const finalGalleryImages = photos.length > 0 ? photos.map((photo) => photo.url) : [heroImage, heroImage, cityImage, heroImage, cityImage]; @@ -14297,24 +14497,32 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { ...selectedAmenities.filter((label) => visibleAmenityOptions.some((option) => option.label === label)).map((label) => ({ icon: label === 'Parking' || label === 'Garaż' ? 'car' : label === 'Ogródek' ? 'wifi' : 'building', label })), ...selectedMedia.filter((label) => visibleMediaOptions.some((option) => option.label === label)).map((label) => ({ icon: label === 'Internet' ? 'wifi' : label === 'Klimatyzacja' ? 'snow' : label === 'Gaz' || label.includes('Ogrzewanie') ? 'flame' : label === 'Telefon' ? 'phone' : 'spark', label })), ]; + // Podglad "Szczegoly" budujemy z: stalych pol kroku 1 (rynek, forma wlasnosci, rok, stan, dom), + // dynamicznych pol kroku "Dodatkowe informacje" (te same, ktore widzi uzytkownik w formularzu - + // dzieki temu podglad zawsze zgadza sie z formularzem) oraz rozliczen najmu. const finalDetails = [ { label: 'Rynek', value: listingDetails.market }, - { label: 'Forma własności', value: listingDetails.ownership }, + ...(!isRentTransaction ? [{ label: 'Forma własności', value: listingDetails.ownership }] : []), ...(showBuildingFields ? [{ label: 'Rok budowy', value: listingDetails.yearBuilt || 'Nie podano' }] : []), + ...(showBuildingFields ? [{ label: 'Stan nieruchomości', value: listingDetails.condition }] : []), ...(isHouseProperty ? [{ label: 'Typ domu', value: listingDetails.houseType !== 'Wybierz' ? listingDetails.houseType : 'Nie podano' }] : []), ...(isHouseProperty ? [{ label: 'Liczba kondygnacji', value: listingDetails.houseFloors !== 'Wybierz' ? listingDetails.houseFloors : 'Nie podano' }] : []), - ...(isSaleHouse ? [{ label: 'Powierzchnia działki', value: extraInfo.plotArea ? `${extraInfo.plotArea} m²` : 'Nie podano' }] : []), ...(isHouseProperty ? [{ label: 'Garaż', value: listingDetails.houseGarage !== 'Wybierz' ? listingDetails.houseGarage : 'Nie podano' }] : []), ...(isHouseProperty ? [{ label: 'Podpiwniczenie', value: listingDetails.houseBasement !== 'Wybierz' ? listingDetails.houseBasement : 'Nie podano' }] : []), ...(isHouseProperty ? [{ label: 'Strych', value: listingDetails.houseAttic !== 'Wybierz' ? listingDetails.houseAttic : 'Nie podano' }] : []), - ...(isSaleHouse ? [{ label: 'Dach', value: extraInfo.roofType !== 'Wybierz' ? extraInfo.roofType : 'Nie podano' }] : []), - ...(isSaleHouse ? [{ label: 'Pokrycie dachu', value: extraInfo.roofCover !== 'Wybierz' ? extraInfo.roofCover : 'Nie podano' }] : []), - ...(isSaleHouse && selectedAmenities.includes('Ogrodzenie') ? [{ label: 'Ogrodzenie', value: extraInfo.fencing !== 'Wybierz' ? extraInfo.fencing : 'Nie podano' }] : []), - ...(isSaleHouse ? [{ label: 'Dojazd', value: extraInfo.accessRoad !== 'Wybierz' ? extraInfo.accessRoad : 'Nie podano' }] : []), - ...(isSaleHouse && selectedMedia.includes('Gospodarka ściekowa') ? [{ label: 'Gospodarka ściekowa', value: extraInfo.sewageType !== 'Wybierz' ? extraInfo.sewageType : 'Nie podano' }] : []), ...(isRentTransaction ? [{ label: 'Czynsz administracyjny', value: extraInfo.rent ? `${extraInfo.rent} zł` : 'Nie podano' }] : []), + // Dynamiczne pola sekcji "Dodatkowe informacje" - tylko wypelnione (kaucja pokazana osobno nizej). + ...activeExtraFields + .filter((field) => field.key !== 'deposit') + .map((field) => { + const raw = extraFieldValue(field); + if (!raw || raw === 'Wybierz') { + return null; + } + return { label: field.label, value: field.suffix ? `${raw} ${field.suffix}` : raw }; + }) + .filter((row): row is { label: string; value: string } => row !== null), ...(isRentTransaction ? [{ label: 'Kaucja', value: formattedSecurityDeposit }] : []), - ...(isRentTransaction ? [{ label: 'Okres najmu', value: extraInfo.rentalPeriod !== 'Wybierz' ? extraInfo.rentalPeriod : 'Nie podano' }] : []), ...(isRentTransaction ? [...mediaBillingEntries, ...amenityBillingEntries].map((item) => { const mode = extraInfo.mediaBilling[item.key] ?? item.options[0]; const amount = (extraInfo.mediaBillingAmounts[item.key] ?? '').trim(); @@ -14326,9 +14534,6 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { value, }; }) : []), - ...(showBuildingFields ? [{ label: 'Stan nieruchomości', value: listingDetails.condition }] : []), - ...(showBuildingExtraFields ? [{ label: 'Umeblowanie', value: extraInfo.furnishing }] : []), - ...(!isSaleHouse ? [{ label: 'Dostępne od', value: extraInfo.availableFrom || 'Do uzgodnienia' }] : []), { label: 'Ogłoszenie dodane', value: 'Dzisiaj' }, ]; @@ -14528,17 +14733,21 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { yearBuilt: showBuildingFields ? current.yearBuilt : '', condition: showBuildingFields ? current.condition : 'Wybierz', })); - if (!showBuildingExtraFields) { - setExtraInfo((current) => ({ - ...current, - furnishing: 'Wybierz', - buildingType: 'Wybierz', - buildingMaterial: 'Wybierz', - windows: 'Wybierz', - exposure: 'Wybierz', - roomHeight: '', - })); - } + // Wyczysc te zapisywane pola extra, ktorych nowa kategoria nie uzywa - inaczej wartosc ustawiona + // przy poprzednim rodzaju nieruchomosci (np. "Typ zabudowy" mieszkania) wycieklaby do zapisu. + const activeKeys = new Set([ + ...resolveCategoryExtra(listingDetails.propertyType, true).fields, + ...resolveCategoryExtra(listingDetails.propertyType, false).fields, + ].map((field) => field.key)); + setExtraInfo((current) => { + const patch: Partial = {}; + for (const key of PERSISTED_EXTRA_SELECTS) { + if (!activeKeys.has(key) && current[key] !== INITIAL_EXTRA_INFO[key]) { + patch[key] = INITIAL_EXTRA_INFO[key]; + } + } + return Object.keys(patch).length ? { ...current, ...patch } : current; + }); }, [listingDetails.propertyType]); useEffect(() => { @@ -14735,8 +14944,8 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
@@ -14828,7 +15037,9 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
{contactPhoneTouched && contactPhoneError && {contactPhoneError}} - updateListingDetails('contactEmail', value)} /> + {/* E-mail kontaktowy jest na stale adresem konta (z rejestracji) - pokazujemy go, + ale nie pozwalamy edytowac, zeby kontakt zawsze trafial na wlasciwe konto. */} +
@@ -14975,8 +15186,8 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { {index === 0 && Zdjęcie główne}
- - + +
))} @@ -15044,33 +15255,33 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { {isExtraInfoStep && ( <> -
-

Dodatkowe informacje

-

Podaj więcej szczegółów, które pomogą wyróżnić Twoje ogłoszenie.

-
-
+ {/* Naglowek kroku wewnatrz bialej karty - taki sam styl jak "Dodaj zdjecia"/"Lokalizacja". */} +
+

Dodatkowe informacje

+

Podaj więcej szczegółów, które pomogą wyróżnić Twoje ogłoszenie.

+
+

Informacje uzupełniające

- {isRentTransaction && updateExtraInfo('rentalPeriod', value)} />} - {isRentTransaction && updateExtraInfo('deposit', value)} />} - {!isSaleHouse && updateExtraInfo('availableFrom', value)} />} - {isSaleHouse && updateExtraInfo('plotArea', value)} />} - {showBuildingExtraFields && updateExtraInfo('furnishing', value)} />} - {showBuildingExtraFields && !isHouseProperty && updateExtraInfo('buildingType', value)} />} - {isSaleHouse && updateExtraInfo('roofType', value)} />} - {isSaleHouse && updateExtraInfo('roofCover', value)} />} - {showBuildingExtraFields && updateExtraInfo('buildingMaterial', value)} />} - {showBuildingExtraFields && updateExtraInfo('windows', value)} />} - {showBuildingExtraFields && updateExtraInfo('exposure', value)} />} - {isSaleHouse && updateExtraInfo('accessRoad', value)} />} - {showBuildingExtraFields && updateExtraInfo('roomHeight', value)} />} + {/* Zestaw pol zalezny od kategorii i transakcji - patrz CATEGORY_EXTRA / resolveCategoryExtra. */} + {activeExtraFields.map((field) => { + const value = extraFieldValue(field); + const onChange = (next: string) => setExtraField(field, next); + if (field.kind === 'select') { + return ; + } + if (field.kind === 'date') { + return ; + } + return ; + })}
-

{isSaleHouse ? 'Media' : 'Media i opłaty'}

+

{isRentTransaction ? 'Media i opłaty' : 'Media'}

{visibleMediaOptions.map((option) => (
- {isSaleHouse && selectedMedia.includes('Gospodarka ściekowa') && ( -
- updateExtraInfo('sewageType', value)} /> -
- )} - {isRentTransaction && mediaBillingEntries.length > 0 && (

Sposób rozliczenia wybranych opłat

@@ -15132,12 +15337,6 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { ))}
- {isSaleHouse && ( -
- {selectedAmenities.includes('Ogrodzenie') && updateExtraInfo('fencing', value)} />} -
- )} - {isRentTransaction && amenityBillingEntries.length > 0 && (

Sposób rozliczenia udogodnień

@@ -15187,8 +15386,13 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { {isFinalPreviewStep && ( <>
-

Podgląd ogłoszenia

-

Sprawdź, jak będzie wyglądać Twoje ogłoszenie.

+

+ Tak będzie wyglądać Twoje ogłoszenie + +

+

Sprawdź wszystkie informacje przed publikacją. Możesz jeszcze wrócić i coś zmienić.

@@ -15217,11 +15421,7 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { {!isRentTransaction && {formattedPricePerMeter}} {isRentTransaction && {formattedDeposit}} -
- {previewMetaItems.slice(0, 4).map((item) => ( - {item.label} - ))} -
+

Opis ogłoszenia

@@ -15371,14 +15571,14 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {

Podgląd ogłoszenia

- {showListingPreviewDetails ? `${Math.max(photos.length, 1)}/20` : '1/8'} + {Math.max(photos.length, 1)}/20

{previewTitle}

@@ -15387,26 +15587,16 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { {!isRentTransaction && {formattedPricePerMeter}} {isRentTransaction && {formattedDeposit}} - {showListingPreviewDetails ? ( - <> - {previewLocation} -
- {previewMetaItems.map((item) => ( - {item.label} - ))} -
- - ) : ( - <> -
- {listingDetails.propertyType} - {previewArea} - {showRoomFields && {previewRooms}} - {showFlatFields && {previewFloor}} -
- {isLocationStep && {previewLocation}} - Dzisiaj - + {/* Podglad wyglada tak samo na kazdym kroku kreatora - kafelki (ikona/wartosc/podpis) + plus lokalizacja i ewentualne dodatkowe cechy. */} + {previewLocation} + + {previewExtraChips.length > 0 && ( +
+ {previewExtraChips.map((item) => ( + {item.label} + ))} +
)}
@@ -22286,7 +22476,15 @@ function RealListingsBand({ const viewMode = viewParam === 'kompaktowa' || viewParam === 'kafelki' ? viewParam : 'lista'; const sortParam = (searchParams.get('sortuj') ?? 'default') as SortKey; const sort: SortKey = SORT_OPTIONS.some((option) => option.value === sortParam) ? sortParam : 'default'; + // Ogloszenia wyroznione sa platne za pozycje - zawsze ida na gore listy, niezaleznie od wybranego + // sortowania. Dopiero w obrebie tej samej grupy (wyroznione / zwykle) dziala wybrany porzadek. + const isPromotedOffer = (offer: typeof visible[number]) => + !!offer.promotedUntil && new Date(offer.promotedUntil).getTime() > Date.now(); const sorted = [...visible].sort((a, b) => { + const promoDiff = (isPromotedOffer(b) ? 1 : 0) - (isPromotedOffer(a) ? 1 : 0); + if (promoDiff !== 0) { + return promoDiff; + } const at = new Date(a.createdAt).getTime(); const bt = new Date(b.createdAt).getTime(); switch (sort) { @@ -22372,12 +22570,12 @@ function RealListingsBand({ {pageItems.map((item) => { const favorite = isFavorite(item.id); const floor = listingFloorText(item.floor, item.buildingFloors); - const meta = [ - formatArea(item.area), - listingRoomsText(item.rooms), - floor ? `${floor} piętro` : null, - item.yearBuilt ? `${item.yearBuilt} r.` : null, - ].filter(Boolean).join(' • '); + const resultSpecTiles: SpecTile[] = [ + { icon: 'area', value: formatArea(item.area), caption: 'Powierzchnia' }, + { icon: 'plan', value: `${item.rooms}`, caption: item.rooms === 1 ? 'Pokój' : 'Pokoje' }, + ...(floor ? [{ icon: 'stairs', value: `${floor}`, caption: 'Piętro' }] : []), + ...(item.yearBuilt ? [{ icon: 'calendar', value: `${item.yearBuilt}`, caption: 'Rok budowy' }] : []), + ]; // 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'; @@ -22387,6 +22585,9 @@ function RealListingsBand({ // karte (::after), dzieki czemu mamy prawdziwy - do skopiowania i // otwarcia w nowej karcie - bez zagniezdzania przyciskow w .
+ {!!item.promotedUntil && new Date(item.promotedUntil).getTime() > Date.now() && ( + Wyróżnione + )}
{offerType === 'SALE' && ( @@ -22394,9 +22595,6 @@ function RealListingsBand({ {listingMarketBadge(item.market)}
)} - {!!item.promotedUntil && new Date(item.promotedUntil).getTime() > Date.now() && ( - Wyróżnione - )}
-
{formatArea(listing.area)}Powierzchnia
-
{listing.rooms}Pokoje
- {floorText &&
{floorText}Piętro
} +
{formatArea(listing.area)}Powierzchnia
+
{listing.rooms}Pokoje
+ {floorText &&
{floorText}Piętro
} {listing.yearBuilt &&
{listing.yearBuilt}Rok budowy
}
@@ -28429,6 +28647,27 @@ function Icon({ name }: { name: string }) { ); + // Powierzchnia (metraz) - naroznikowe ramki jak "obszar", czysty styl liniowy. + case 'area': + return ( + + ); + // Liczba pokoi - rzut pomieszczen (plan) ze scianka i przejsciem. + case 'plan': + return ( + + ); + // Pietro - schody. + case 'stairs': + return ( + + ); default: return null; } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 43e0060..c3f89ff 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -3128,6 +3128,13 @@ a.city-card:hover { padding-top: 12px; } +/* Widok listy (domyslny): to kafelki spychaja tresc na dol, wiec stopka idzie tuz pod nie - + bez wlasnego margin-top:auto, ktory rozbilby wolna przestrzen na pol. Kompaktowy i kafelki + zachowuja oryginalne przypiecie stopki do dolu. */ +.result-list.view-lista .result-seller-type { + margin-top: 0; +} + .result-seller-type.company { color: #0f7a48; } @@ -4202,6 +4209,27 @@ a.city-card:hover { margin: 0; } +/* Zielony akcent w naglowku (np. "Twoje ogloszenie" w podgladzie przed publikacja). */ +.add-title-accent { + color: #12a764; +} + +/* Ozdobne zielone kreski ("iskierka") tuz obok napisu "Twoje ogloszenie". */ +.add-title-spark { + color: #12a764; + display: inline-block; + height: 34px; + margin-left: 10px; + vertical-align: middle; + width: 38px; +} + +.add-title-spark path { + stroke: currentColor; + stroke-linecap: round; + stroke-width: 3; +} + .add-title h1 span { color: #10a15e; } @@ -4923,6 +4951,13 @@ a.city-card:hover { width: 100%; } +/* Pole tylko do odczytu (np. e-mail z konta, cena za m2) - wyszarzone, zeby bylo widac, ze zablokowane. */ +.add-field input[readonly] { + background: #f1f5fa; + color: #5f7085; + cursor: not-allowed; +} + .add-field input::placeholder, .editor-box textarea::placeholder { color: #9aa5b4; @@ -5747,13 +5782,22 @@ a.city-card:hover { border-radius: 999px; color: #ffffff; display: grid; - font-size: 20px; - font-weight: 950; height: 28px; place-items: center; width: 28px; } +.photo-order-controls button svg { + height: 15px; + width: 15px; +} + +/* Daszek chevronu rysowany pionowo - obracamy go na strzalke w lewo/prawo, + tak samo jak w sliderze kart i nawigacji zdjec w wynikach. Wczesniejsze glify + tekstowe (< >) mialy asymetryczne odstepy i nie stawaly rowno w kolku. */ +.photo-order-controls button.prev svg { transform: rotate(90deg); } +.photo-order-controls button.next svg { transform: rotate(-90deg); } + .photo-order-controls button:disabled { cursor: not-allowed; opacity: 0.42; @@ -5863,6 +5907,15 @@ a.city-card:hover { border-bottom: 0; } +/* Karta "Dodatkowe informacje" ma padding:0, a jej sekcje wlasny padding 22px 24px z linia na dole. + Naglowek kroku musi dostac ten sam padding, zeby rownal sie z trescia ponizej i mial wlasciwy + odstep (a nie dotykal krawedzi karty). */ +.add-extra-card .add-card-heading { + border-bottom: 1px solid #e6edf3; + margin-bottom: 0; + padding: 22px 24px; +} + .extra-section h2 { margin-bottom: 18px; } @@ -25682,6 +25735,136 @@ a.city-card:hover { font-size: 12px; } +/* Wspolny wiersz kafelkow specyfikacji (ikona -> wartosc -> podpis) uzywany na kartach wynikow + i w podgladzie kreatora. Wyglada tak samo jak .listing-detail-stats na stronie ogloszenia. */ +.spec-tiles { + display: flex; + gap: 8px; +} + +.spec-tile { + align-items: center; + display: flex; + flex: 1 1 0; + flex-direction: column; + gap: 2px; + min-width: 0; + text-align: center; +} + +.spec-tile svg { + color: #129357; + height: 20px; + width: 20px; +} + +.spec-tile strong { + color: #13243d; + font-size: 15px; + line-height: 1.15; +} + +.spec-tile span { + color: #94a3b8; + font-size: 12px; +} + +/* Podglad w kreatorze: kafelki z lekka linia oddzielajaca (krok "Podglad") oraz odstepem (panel boczny). */ +.spec-tiles.final-specs { + border-bottom: 1px solid #e4ebf2; + padding-bottom: 20px; +} + +.spec-tiles.preview-specs { + margin-top: 16px; +} + +/* Drobne chipy z dodatkowymi cechami pod kafelkami w panelu podgladu. */ +.preview-chip-row { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + margin-top: 10px; +} + +.preview-chip-row span { + align-items: center; + color: #536981; + display: inline-flex; + font-size: 11px; + font-weight: 700; + gap: 5px; +} + +.preview-chip-row svg { + color: #536981; + height: 14px; + width: 14px; +} + +/* Wariant kompaktowy - dla gestych kart na liscie wynikow. */ +.spec-tiles.compact { + gap: 4px; +} + +.spec-tiles.compact .spec-tile svg { + height: 16px; + width: 16px; +} + +.spec-tiles.compact .spec-tile strong { + font-size: 13px; +} + +.spec-tiles.compact .spec-tile span { + font-size: 10px; +} + +/* Lista wynikow (zakladka kupno/wynajem): kafelki mniejsze i dosuniete do lewej, zeby nie + rozciagaly sie na cala szerokosc karty i nie wygladaly ciezko. */ +.spec-tiles.result-specs { + gap: 16px; + justify-content: flex-start; + margin: 2px 0; +} + +/* Widok listy: kafelki siadaja tuz nad linia oddzielajaca stopke (nie wisza w polowie karty). */ +.result-list.view-lista .result-specs { + margin-top: auto; + margin-bottom: 12px; +} + +.spec-tiles.result-specs .spec-tile { + flex: 0 0 auto; + gap: 0; + justify-content: flex-end; +} + +.spec-tiles.result-specs .spec-tile svg { + display: block; + height: 16px; + margin: 0 auto -1px; + width: 16px; +} + +/* Wartosc i podpis zwarte - bez luznego odstepu, zeby ikona + liczba + podpis tworzyly jeden + blok "przyklejony" do siebie, a nie trzy elementy wiszace w powietrzu. */ +.spec-tiles.result-specs .spec-tile strong { + line-height: 1.15; +} + +.spec-tiles.result-specs .spec-tile span { + line-height: 1.1; +} + +.spec-tiles.result-specs .spec-tile strong { + font-size: 12px; +} + +.spec-tiles.result-specs .spec-tile span { + font-size: 9px; +} + .listing-detail-primary { align-items: center; background: #129357; @@ -26397,6 +26580,7 @@ a.city-card:hover { .real-result-card { cursor: pointer; + position: relative; transition: box-shadow 0.15s, transform 0.15s; } @@ -32546,20 +32730,30 @@ a.listing-detail-back { .listing-promoted-tag svg { height: 12px; width: 12px; } .ld-badge-promoted { align-items: center; background: #12a764; color: #ffffff; display: inline-flex; gap: 5px; } .ld-badge-promoted svg { height: 13px; width: 13px; } +/* Plakietka "Wyróżnione" - w samym prawym gornym rogu KARTY (nad kolumna ceny, ale wysunieta do + rogu, nie sklejona z cena). Bialy napis, kolor (granatowo-niebieski gradient) taki sam jak przycisk + "Promuj" w zakladce moich ofert. Oglosznia platne za pozycje ida tez na sama gore listy (isPromotedOffer). */ .result-promoted-badge { align-items: center; - background: #12a764; + background: linear-gradient(120deg, #0f2f5b, #1d5fa8); border-radius: 999px; - bottom: 12px; color: #ffffff; display: inline-flex; font-size: 11px; font-weight: 850; gap: 5px; - left: 12px; - padding: 4px 10px; + padding: 5px 12px; position: absolute; - box-shadow: 0 3px 10px rgba(15, 31, 58, 0.22); + top: 12px; + right: 12px; + z-index: 4; + pointer-events: none; + box-shadow: 0 4px 12px rgba(15, 47, 91, 0.32); +} + +.result-promoted-badge, +.result-promoted-badge svg { + color: #ffffff; } .result-promoted-badge svg { height: 12px; width: 12px; }