wyglad ogloszen
Deploy Polska Lokalnie / deploy (push) Successful in 11s

This commit is contained in:
2026-09-04 14:24:11 +02:00
parent 762b76f141
commit e7db409828
2 changed files with 584 additions and 151 deletions
+383 -144
View File
@@ -1978,8 +1978,196 @@ const extraInfoSelectOptions = {
fencing: ['Wybierz', 'Tak', 'Częściowe', 'Nie'], fencing: ['Wybierz', 'Tak', 'Częściowe', 'Nie'],
accessRoad: ['Wybierz', 'Asfalt', 'Kostka', 'Utwardzony', 'Szutrowy', 'Polny'], accessRoad: ['Wybierz', 'Asfalt', 'Kostka', 'Utwardzony', 'Szutrowy', 'Polny'],
sewageType: ['Wybierz', 'Kanalizacja', 'Szambo', 'Przydomowa oczyszczalnia'], 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<string, string> = {
...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<string, CategoryExtra> = {
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 // 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 // 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. // jako APARTMENT (enum ma tylko APARTMENT/HOUSE) - etykieta zyje w formularzu, logika sie zgadza.
@@ -2420,6 +2608,22 @@ const INITIAL_EXTRA_INFO = {
windows: 'Wybierz', windows: 'Wybierz',
exposure: 'Wybierz', exposure: 'Wybierz',
roomHeight: '', 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: '', virtualTourUrl: '',
}; };
@@ -12557,9 +12761,6 @@ function AccountPublicProfilePage() {
{myListings.map((listing) => { {myListings.map((listing) => {
const isRent = listing.offerType === 'RENT'; const isRent = listing.offerType === 'RENT';
const location = [listing.district, listing.city].filter(Boolean).join(', ') || listing.city; 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 ( return (
<article <article
className="public-listing-card" className="public-listing-card"
@@ -12576,11 +12777,14 @@ function AccountPublicProfilePage() {
<strong>{isRent ? formatMonthlyPrice(listing.price) : formatPrice(listing.price)}</strong> <strong>{isRent ? formatMonthlyPrice(listing.price) : formatPrice(listing.price)}</strong>
<h3>{listing.title}</h3> <h3>{listing.title}</h3>
<small><Icon name="pin" /> {location}</small> <small><Icon name="pin" /> {location}</small>
<div className="public-listing-meta"> <SpecTiles
<span><Icon name="scale" /> {formatArea(listing.area)}</span> className="compact"
<span><Icon name="user" /> {listing.rooms} {listing.rooms === 1 ? 'pokój' : 'pokoje'}</span> items={[
{floorLabel && <span><Icon name="building" /> {floorLabel}</span>} { icon: 'area', value: formatArea(listing.area), caption: 'Powierzchnia' },
</div> { icon: 'plan', value: `${listing.rooms}`, caption: listing.rooms === 1 ? 'Pokój' : 'Pokoje' },
...(listing.floor ? [{ icon: 'stairs', value: listing.buildingFloors ? `${listing.floor}/${listing.buildingFloors}` : `${listing.floor}`, caption: 'Piętro' }] : []),
]}
/>
<div className="public-listing-foot"> <div className="public-listing-foot">
<em>{listingRelativeDate(listing.createdAt) || 'Dodane niedawno'}</em> <em>{listingRelativeDate(listing.createdAt) || 'Dodane niedawno'}</em>
</div> </div>
@@ -14086,7 +14290,6 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
const isPhotosStep = currentStep === 2; const isPhotosStep = currentStep === 2;
const isExtraInfoStep = currentStep === 3; const isExtraInfoStep = currentStep === 3;
const isFinalPreviewStep = currentStep === 4; const isFinalPreviewStep = currentStep === 4;
const showListingPreviewDetails = isPhotosStep || isExtraInfoStep;
const mainPhoto = photos[0]; const mainPhoto = photos[0];
// Komunikat o zlym numerze pokazujemy po opuszczeniu pola, zeby nie krzyczec przy pierwszej cyfrze. // Komunikat o zlym numerze pokazujemy po opuszczeniu pola, zeby nie krzyczec przy pierwszej cyfrze.
const contactPhoneError = validateContactPhone(listingDetails.contactPhoneCountry, listingDetails.contactPhone); const contactPhoneError = validateContactPhone(listingDetails.contactPhoneCountry, listingDetails.contactPhone);
@@ -14223,32 +14426,28 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
}; };
const isHouseProperty = listingDetails.propertyType === 'Dom'; const isHouseProperty = listingDetails.propertyType === 'Dom';
const isSaleHouse = isHouseProperty && !isRentTransaction;
const isLandProperty = listingDetails.propertyType === 'Działka'; const isLandProperty = listingDetails.propertyType === 'Działka';
const showFlatFields = !isHouseProperty && !isLandProperty; const showFlatFields = !isHouseProperty && !isLandProperty;
const showRoomFields = !isLandProperty; const showRoomFields = !isLandProperty;
const showBuildingFields = !isLandProperty; const showBuildingFields = !isLandProperty;
const showBuildingExtraFields = !isLandProperty; // Dynamiczna zawartosc kroku "Dodatkowe informacje" - pola i kafelki zalezne od kategorii i transakcji.
const visibleMediaOptions = mediaOptions.filter((option) => { const extraSection = resolveCategoryExtra(listingDetails.propertyType, isRentTransaction);
if (isSaleHouse && option.label === 'Kanalizacja') { const activeExtraFields = extraSection.fields;
return false; 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<string, unknown>)[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 const mediaBillingEntries = isRentTransaction
? [ ? [
...selectedMedia ...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 = [ const previewLocation = [
location.city, location.city,
location.district, location.district,
location.estate, location.estate,
[location.street, location.building].filter(Boolean).join(' '), [location.street, location.building].filter(Boolean).join(' '),
].filter((part) => part.trim()).join(', ') || 'Lokalizacja'; ].filter((part) => part.trim()).join(', ') || 'Lokalizacja';
const previewMetaItems = [ // Rdzenne parametry jako kafelki (ikona -> wartosc -> podpis), tak jak na stronie ogloszenia.
{ icon: 'building', label: previewArea }, const previewSpecTiles: SpecTile[] = [
...(showRoomFields ? [{ icon: 'house', label: previewRooms }] : []), { icon: 'area', value: listingDetails.area.trim() ? `${listingDetails.area.replace('.', ',')} m²` : '—', caption: 'Powierzchnia' },
...(showFlatFields ? [{ icon: 'city', label: previewFloor }] : []), ...(showRoomFields ? [{ icon: 'plan', value: listingDetails.rooms !== 'Wybierz' ? listingDetails.rooms : '—', caption: 'Pokoje' }] : []),
{ icon: 'check', label: listingDetails.market }, ...(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.furnishing !== 'Wybierz' ? [{ icon: 'sofa', label: extraInfo.furnishing === 'Tak' ? 'Umeblowane' : `Umeblowanie: ${extraInfo.furnishing}` }] : []),
...(isExtraInfoStep && extraInfo.buildingType !== 'Wybierz' ? [{ icon: 'building', label: extraInfo.buildingType }] : []), ...(isExtraInfoStep && extraInfo.buildingType !== 'Wybierz' ? [{ icon: 'building', label: extraInfo.buildingType }] : []),
...(['Balkon', 'Taras', 'Ogródek', 'Winda', 'Parking', 'Garaż', 'Piwnica'] ...(['Balkon', 'Taras', 'Ogródek', 'Winda', 'Parking', 'Garaż', 'Piwnica']
@@ -14288,7 +14488,7 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
...(['Internet', 'Klimatyzacja', 'Ogrzewanie miejskie', 'Gaz'] ...(['Internet', 'Klimatyzacja', 'Ogrzewanie miejskie', 'Gaz']
.filter((label) => isExtraInfoStep && selectedMedia.includes(label)) .filter((label) => isExtraInfoStep && selectedMedia.includes(label))
.map((label) => ({ icon: label === 'Internet' ? 'wifi' : label === 'Klimatyzacja' ? 'snow' : label === 'Gaz' || label === 'Ogrzewanie miejskie' ? 'flame' : 'spark', 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 const finalGalleryImages = photos.length > 0
? photos.map((photo) => photo.url) ? photos.map((photo) => photo.url)
: [heroImage, heroImage, cityImage, heroImage, cityImage]; : [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 })), ...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 })), ...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 = [ const finalDetails = [
{ label: 'Rynek', value: listingDetails.market }, { 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: '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: 'Typ domu', value: listingDetails.houseType !== 'Wybierz' ? listingDetails.houseType : 'Nie podano' }] : []),
...(isHouseProperty ? [{ label: 'Liczba kondygnacji', value: listingDetails.houseFloors !== 'Wybierz' ? listingDetails.houseFloors : '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: 'Garaż', value: listingDetails.houseGarage !== 'Wybierz' ? listingDetails.houseGarage : 'Nie podano' }] : []),
...(isHouseProperty ? [{ label: 'Podpiwniczenie', value: listingDetails.houseBasement !== 'Wybierz' ? listingDetails.houseBasement : 'Nie podano' }] : []), ...(isHouseProperty ? [{ label: 'Podpiwniczenie', value: listingDetails.houseBasement !== 'Wybierz' ? listingDetails.houseBasement : 'Nie podano' }] : []),
...(isHouseProperty ? [{ label: 'Strych', value: listingDetails.houseAttic !== 'Wybierz' ? listingDetails.houseAttic : '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' }] : []), ...(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: 'Kaucja', value: formattedSecurityDeposit }] : []),
...(isRentTransaction ? [{ label: 'Okres najmu', value: extraInfo.rentalPeriod !== 'Wybierz' ? extraInfo.rentalPeriod : 'Nie podano' }] : []),
...(isRentTransaction ? [...mediaBillingEntries, ...amenityBillingEntries].map((item) => { ...(isRentTransaction ? [...mediaBillingEntries, ...amenityBillingEntries].map((item) => {
const mode = extraInfo.mediaBilling[item.key] ?? item.options[0]; const mode = extraInfo.mediaBilling[item.key] ?? item.options[0];
const amount = (extraInfo.mediaBillingAmounts[item.key] ?? '').trim(); const amount = (extraInfo.mediaBillingAmounts[item.key] ?? '').trim();
@@ -14326,9 +14534,6 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
value, 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' }, { label: 'Ogłoszenie dodane', value: 'Dzisiaj' },
]; ];
@@ -14528,17 +14733,21 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
yearBuilt: showBuildingFields ? current.yearBuilt : '', yearBuilt: showBuildingFields ? current.yearBuilt : '',
condition: showBuildingFields ? current.condition : 'Wybierz', condition: showBuildingFields ? current.condition : 'Wybierz',
})); }));
if (!showBuildingExtraFields) { // Wyczysc te zapisywane pola extra, ktorych nowa kategoria nie uzywa - inaczej wartosc ustawiona
setExtraInfo((current) => ({ // przy poprzednim rodzaju nieruchomosci (np. "Typ zabudowy" mieszkania) wycieklaby do zapisu.
...current, const activeKeys = new Set([
furnishing: 'Wybierz', ...resolveCategoryExtra(listingDetails.propertyType, true).fields,
buildingType: 'Wybierz', ...resolveCategoryExtra(listingDetails.propertyType, false).fields,
buildingMaterial: 'Wybierz', ].map((field) => field.key));
windows: 'Wybierz', setExtraInfo((current) => {
exposure: 'Wybierz', const patch: Partial<typeof INITIAL_EXTRA_INFO> = {};
roomHeight: '', 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]); }, [listingDetails.propertyType]);
useEffect(() => { useEffect(() => {
@@ -14735,8 +14944,8 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
<section className="add-form-card"> <section className="add-form-card">
<label className="add-field full"> <label className="add-field full">
<span>Tytuł ogłoszenia</span> <span>Tytuł ogłoszenia</span>
<input value={listingDetails.title} maxLength={100} onChange={(event) => updateListingDetails('title', event.target.value)} placeholder={LISTING_TITLE_HINTS[listingDetails.propertyType] ?? DEFAULT_LISTING_TITLE_HINT} /> <input value={listingDetails.title} maxLength={70} onChange={(event) => updateListingDetails('title', event.target.value)} placeholder={LISTING_TITLE_HINTS[listingDetails.propertyType] ?? DEFAULT_LISTING_TITLE_HINT} />
<small>{listingDetails.title.length}/100</small> <small>{listingDetails.title.length}/70</small>
</label> </label>
<div className="add-field full"> <div className="add-field full">
@@ -14828,7 +15037,9 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
</div> </div>
{contactPhoneTouched && contactPhoneError && <small className="add-field-error">{contactPhoneError}</small>} {contactPhoneTouched && contactPhoneError && <small className="add-field-error">{contactPhoneError}</small>}
</label> </label>
<AddInput label="E-mail" value={listingDetails.contactEmail} type="email" placeholder="Np. jan.kowalski@email.pl" onChange={(value) => 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. */}
<AddInput label="E-mail (adres konta)" value={listingDetails.contactEmail || user?.email || ''} type="email" readOnly />
</div> </div>
</section> </section>
@@ -14975,8 +15186,8 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
{index === 0 && <span className="main-photo-badge">Zdjęcie główne</span>} {index === 0 && <span className="main-photo-badge">Zdjęcie główne</span>}
<button className="remove-photo-button" type="button" aria-label={`Usuń zdjęcie ${photo.name}`} onClick={() => removePhoto(photo.id)}>×</button> <button className="remove-photo-button" type="button" aria-label={`Usuń zdjęcie ${photo.name}`} onClick={() => removePhoto(photo.id)}>×</button>
<div className="photo-order-controls" aria-label="Zmień kolejność zdjęcia"> <div className="photo-order-controls" aria-label="Zmień kolejność zdjęcia">
<button type="button" disabled={index === 0} onClick={() => movePhoto(photo.id, -1)} aria-label={`Przesuń zdjęcie ${photo.name} w lewo`}>‹</button> <button className="prev" type="button" disabled={index === 0} onClick={() => movePhoto(photo.id, -1)} aria-label={`Przesuń zdjęcie ${photo.name} w lewo`}><Icon name="chevron" /></button>
<button type="button" disabled={index === photos.length - 1} onClick={() => movePhoto(photo.id, 1)} aria-label={`Przesuń zdjęcie ${photo.name} w prawo`}>›</button> <button className="next" type="button" disabled={index === photos.length - 1} onClick={() => movePhoto(photo.id, 1)} aria-label={`Przesuń zdjęcie ${photo.name} w prawo`}><Icon name="chevron" /></button>
</div> </div>
</article> </article>
))} ))}
@@ -15044,33 +15255,33 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
{isExtraInfoStep && ( {isExtraInfoStep && (
<> <>
<div className="add-title add-step-title">
<h1>Dodatkowe informacje</h1>
<p>Podaj więcej szczegółów, które pomogą wyróżnić Twoje ogłoszenie.</p>
</div>
<section className="add-form-card add-extra-card"> <section className="add-form-card add-extra-card">
{/* Naglowek kroku wewnatrz bialej karty - taki sam styl jak "Dodaj zdjecia"/"Lokalizacja". */}
<div className="add-card-heading">
<h2>Dodatkowe informacje</h2>
<p>Podaj więcej szczegółów, które pomogą wyróżnić Twoje ogłoszenie.</p>
</div>
<div className="extra-section"> <div className="extra-section">
<h2>Informacje uzupełniające</h2> <h2>Informacje uzupełniające</h2>
<div className="extra-details-grid"> <div className="extra-details-grid">
{isRentTransaction && <AddSelect label="Okres najmu" value={extraInfo.rentalPeriod} options={extraInfoSelectOptions.rentalPeriod} onChange={(value) => updateExtraInfo('rentalPeriod', value)} />} {/* Zestaw pol zalezny od kategorii i transakcji - patrz CATEGORY_EXTRA / resolveCategoryExtra. */}
{isRentTransaction && <AddInput label="Kaucja" value={extraInfo.deposit} suffix="zł" placeholder="Np. 6000" onChange={(value) => updateExtraInfo('deposit', value)} />} {activeExtraFields.map((field) => {
{!isSaleHouse && <AddInput label="Dostępne od" value={extraInfo.availableFrom} type="date" onChange={(value) => updateExtraInfo('availableFrom', value)} />} const value = extraFieldValue(field);
{isSaleHouse && <AddInput label="Powierzchnia działki" value={extraInfo.plotArea} suffix="m²" placeholder="Np. 650" onChange={(value) => updateExtraInfo('plotArea', value)} />} const onChange = (next: string) => setExtraField(field, next);
{showBuildingExtraFields && <AddSelect label="Umeblowanie" value={extraInfo.furnishing} options={extraInfoSelectOptions.furnishing} onChange={(value) => updateExtraInfo('furnishing', value)} />} if (field.kind === 'select') {
{showBuildingExtraFields && !isHouseProperty && <AddSelect label="Typ zabudowy" value={extraInfo.buildingType} options={extraInfoSelectOptions.buildingType} onChange={(value) => updateExtraInfo('buildingType', value)} />} return <AddSelect key={field.key} label={field.label} value={value} options={field.options} onChange={onChange} />;
{isSaleHouse && <AddSelect label="Dach" value={extraInfo.roofType} options={extraInfoSelectOptions.roofType} onChange={(value) => updateExtraInfo('roofType', value)} />} }
{isSaleHouse && <AddSelect label="Pokrycie dachu" value={extraInfo.roofCover} options={extraInfoSelectOptions.roofCover} onChange={(value) => updateExtraInfo('roofCover', value)} />} if (field.kind === 'date') {
{showBuildingExtraFields && <AddSelect label="Materiał budynku" value={extraInfo.buildingMaterial} options={extraInfoSelectOptions.buildingMaterial} onChange={(value) => updateExtraInfo('buildingMaterial', value)} />} return <AddInput key={field.key} label={field.label} value={value} type="date" onChange={onChange} />;
{showBuildingExtraFields && <AddSelect label="Okna" value={extraInfo.windows} options={extraInfoSelectOptions.windows} onChange={(value) => updateExtraInfo('windows', value)} />} }
{showBuildingExtraFields && <AddSelect label="Ekspozycja" value={extraInfo.exposure} options={extraInfoSelectOptions.exposure} onChange={(value) => updateExtraInfo('exposure', value)} />} return <AddInput key={field.key} label={field.label} value={value} suffix={field.suffix} placeholder={field.placeholder} numeric={field.numeric} onChange={onChange} />;
{isSaleHouse && <AddSelect label="Dojazd" value={extraInfo.accessRoad} options={extraInfoSelectOptions.accessRoad} onChange={(value) => updateExtraInfo('accessRoad', value)} />} })}
{showBuildingExtraFields && <AddInput label="Wysokość pomieszczeń" value={extraInfo.roomHeight} suffix="cm" onChange={(value) => updateExtraInfo('roomHeight', value)} />}
</div> </div>
</div> </div>
<div className="extra-section"> <div className="extra-section">
<h2>{isSaleHouse ? 'Media' : 'Media i opłaty'}</h2> <h2>{isRentTransaction ? 'Media i opłaty' : 'Media'}</h2>
<div className="extra-option-grid media-option-grid"> <div className="extra-option-grid media-option-grid">
{visibleMediaOptions.map((option) => ( {visibleMediaOptions.map((option) => (
<label className={selectedMedia.includes(option.label) ? 'selected' : ''} key={option.label}> <label className={selectedMedia.includes(option.label) ? 'selected' : ''} key={option.label}>
@@ -15081,12 +15292,6 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
))} ))}
</div> </div>
{isSaleHouse && selectedMedia.includes('Gospodarka ściekowa') && (
<div className="extra-details-grid" style={{ marginTop: 12 }}>
<AddSelect label="Gospodarka ściekowa" value={extraInfo.sewageType} options={extraInfoSelectOptions.sewageType} onChange={(value) => updateExtraInfo('sewageType', value)} />
</div>
)}
{isRentTransaction && mediaBillingEntries.length > 0 && ( {isRentTransaction && mediaBillingEntries.length > 0 && (
<div className="media-billing-panel"> <div className="media-billing-panel">
<h3>Sposób rozliczenia wybranych opłat</h3> <h3>Sposób rozliczenia wybranych opłat</h3>
@@ -15132,12 +15337,6 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
))} ))}
</div> </div>
{isSaleHouse && (
<div className="extra-details-grid" style={{ marginTop: 12 }}>
{selectedAmenities.includes('Ogrodzenie') && <AddSelect label="Ogrodzenie" value={extraInfo.fencing} options={extraInfoSelectOptions.fencing} onChange={(value) => updateExtraInfo('fencing', value)} />}
</div>
)}
{isRentTransaction && amenityBillingEntries.length > 0 && ( {isRentTransaction && amenityBillingEntries.length > 0 && (
<div className="media-billing-panel"> <div className="media-billing-panel">
<h3>Sposób rozliczenia udogodnień</h3> <h3>Sposób rozliczenia udogodnień</h3>
@@ -15187,8 +15386,13 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
{isFinalPreviewStep && ( {isFinalPreviewStep && (
<> <>
<div className="add-title add-step-title"> <div className="add-title add-step-title">
<h1>Podgląd ogłoszenia</h1> <h1>
<p>Sprawdź, jak będzie wyglądać Twoje ogłoszenie.</p> Tak będzie wyglądać <span className="add-title-accent">Twoje ogłoszenie</span>
<svg className="add-title-spark" viewBox="0 0 44 40" fill="none" aria-hidden="true">
<path d="M6 27 18 12 M17 31 31 15 M28 32 38 21" />
</svg>
</h1>
<p>Sprawdź wszystkie informacje przed publikacją. Możesz jeszcze wrócić i coś zmienić.</p>
</div> </div>
<section className="add-form-card final-preview-card"> <section className="add-form-card final-preview-card">
@@ -15217,11 +15421,7 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
{!isRentTransaction && <small>{formattedPricePerMeter}</small>} {!isRentTransaction && <small>{formattedPricePerMeter}</small>}
</strong> </strong>
{isRentTransaction && <span className="final-rent-deposit">{formattedDeposit}</span>} {isRentTransaction && <span className="final-rent-deposit">{formattedDeposit}</span>}
<div className="final-meta-row"> <SpecTiles items={previewSpecTiles} className="final-specs" />
{previewMetaItems.slice(0, 4).map((item) => (
<span key={item.label}><Icon name={item.icon} /> <small>{item.label}</small></span>
))}
</div>
<div className="final-description-block"> <div className="final-description-block">
<h3>Opis ogłoszenia</h3> <h3>Opis ogłoszenia</h3>
@@ -15371,14 +15571,14 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
<section className="listing-preview-card"> <section className="listing-preview-card">
<h2>Podgląd ogłoszenia</h2> <h2>Podgląd ogłoszenia</h2>
<div <div
className={`preview-image ${showListingPreviewDetails ? 'photos-preview-image' : ''}`} className="preview-image photos-preview-image"
style={{ style={{
backgroundImage: `url(${mainPhoto?.url ?? heroImage})`, backgroundImage: `url(${mainPhoto?.url ?? heroImage})`,
backgroundPosition: mainPhoto ? 'center' : undefined, backgroundPosition: mainPhoto ? 'center' : undefined,
backgroundSize: mainPhoto ? 'cover' : undefined, backgroundSize: mainPhoto ? 'cover' : undefined,
}} }}
> >
<span><Icon name="camera" /> {showListingPreviewDetails ? `${Math.max(photos.length, 1)}/20` : '1/8'}</span> <span><Icon name="camera" /> {Math.max(photos.length, 1)}/20</span>
</div> </div>
<div className="preview-body"> <div className="preview-body">
<p>{previewTitle}</p> <p>{previewTitle}</p>
@@ -15387,26 +15587,16 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
{!isRentTransaction && <small>{formattedPricePerMeter}</small>} {!isRentTransaction && <small>{formattedPricePerMeter}</small>}
</strong> </strong>
{isRentTransaction && <span className="preview-rent-deposit">{formattedDeposit}</span>} {isRentTransaction && <span className="preview-rent-deposit">{formattedDeposit}</span>}
{showListingPreviewDetails ? ( {/* Podglad wyglada tak samo na kazdym kroku kreatora - kafelki (ikona/wartosc/podpis)
<> plus lokalizacja i ewentualne dodatkowe cechy. */}
<span><Icon name="pin" /> {previewLocation}</span> <span className="preview-loc"><Icon name="pin" /> {previewLocation}</span>
<div className="preview-meta preview-meta-icons"> <SpecTiles items={previewSpecTiles} className="preview-specs" />
{previewMetaItems.map((item) => ( {previewExtraChips.length > 0 && (
<span key={item.label}><Icon name={item.icon} /> {item.label}</span> <div className="preview-chip-row">
))} {previewExtraChips.map((item) => (
</div> <span key={item.label}><Icon name={item.icon} /> {item.label}</span>
</> ))}
) : ( </div>
<>
<div className="preview-meta">
<span>{listingDetails.propertyType}</span>
<span>{previewArea}</span>
{showRoomFields && <span>{previewRooms}</span>}
{showFlatFields && <span>{previewFloor}</span>}
</div>
{isLocationStep && <span><Icon name="pin" /> {previewLocation}</span>}
<span><Icon name="clock" /> Dzisiaj</span>
</>
)} )}
</div> </div>
</section> </section>
@@ -22286,7 +22476,15 @@ function RealListingsBand({
const viewMode = viewParam === 'kompaktowa' || viewParam === 'kafelki' ? viewParam : 'lista'; const viewMode = viewParam === 'kompaktowa' || viewParam === 'kafelki' ? viewParam : 'lista';
const sortParam = (searchParams.get('sortuj') ?? 'default') as SortKey; const sortParam = (searchParams.get('sortuj') ?? 'default') as SortKey;
const sort: SortKey = SORT_OPTIONS.some((option) => option.value === sortParam) ? sortParam : 'default'; 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 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 at = new Date(a.createdAt).getTime();
const bt = new Date(b.createdAt).getTime(); const bt = new Date(b.createdAt).getTime();
switch (sort) { switch (sort) {
@@ -22372,12 +22570,12 @@ function RealListingsBand({
{pageItems.map((item) => { {pageItems.map((item) => {
const favorite = isFavorite(item.id); const favorite = isFavorite(item.id);
const floor = listingFloorText(item.floor, item.buildingFloors); const floor = listingFloorText(item.floor, item.buildingFloors);
const meta = [ const resultSpecTiles: SpecTile[] = [
formatArea(item.area), { icon: 'area', value: formatArea(item.area), caption: 'Powierzchnia' },
listingRoomsText(item.rooms), { icon: 'plan', value: `${item.rooms}`, caption: item.rooms === 1 ? 'Pokój' : 'Pokoje' },
floor ? `${floor} piętro` : null, ...(floor ? [{ icon: 'stairs', value: `${floor}`, caption: 'Piętro' }] : []),
item.yearBuilt ? `${item.yearBuilt} r.` : null, ...(item.yearBuilt ? [{ icon: 'calendar', value: `${item.yearBuilt}`, caption: 'Rok budowy' }] : []),
].filter(Boolean).join(' • '); ];
// Etykieta jak na Otodom: konto firmowe pokazuje nazwe firmy, kazde inne (prywatne lub // Etykieta jak na Otodom: konto firmowe pokazuje nazwe firmy, kazde inne (prywatne lub
// ogloszenie bez konta w serwisie) - "Oferta prywatna". // ogloszenie bez konta w serwisie) - "Oferta prywatna".
const isCompanyOffer = item.ownerAccountType === 'COMPANY'; const isCompanyOffer = item.ownerAccountType === 'COMPANY';
@@ -22387,6 +22585,9 @@ function RealListingsBand({
// karte (::after), dzieki czemu mamy prawdziwy <a href> - do skopiowania i // karte (::after), dzieki czemu mamy prawdziwy <a href> - do skopiowania i
// otwarcia w nowej karcie - bez zagniezdzania przyciskow w <a>. // otwarcia w nowej karcie - bez zagniezdzania przyciskow w <a>.
<article key={item.id} className="result-card real-result-card"> <article key={item.id} className="result-card real-result-card">
{!!item.promotedUntil && new Date(item.promotedUntil).getTime() > Date.now() && (
<span className="result-promoted-badge"><Icon name="spark" /> Wyróżnione</span>
)}
<div className="result-photo"> <div className="result-photo">
<ResultCardGallery photos={item.photos} cover={item.coverPhoto} alt={item.title} /> <ResultCardGallery photos={item.photos} cover={item.coverPhoto} alt={item.title} />
{offerType === 'SALE' && ( {offerType === 'SALE' && (
@@ -22394,9 +22595,6 @@ function RealListingsBand({
<span className="green-tag">{listingMarketBadge(item.market)}</span> <span className="green-tag">{listingMarketBadge(item.market)}</span>
</div> </div>
)} )}
{!!item.promotedUntil && new Date(item.promotedUntil).getTime() > Date.now() && (
<span className="result-promoted-badge"><Icon name="spark" /> Wyróżnione</span>
)}
<button <button
type="button" type="button"
className={favorite ? 'real-favorite-button saved' : 'real-favorite-button'} className={favorite ? 'real-favorite-button saved' : 'real-favorite-button'}
@@ -22414,7 +22612,7 @@ function RealListingsBand({
<div className="result-info"> <div className="result-info">
<h3><Link className="card-stretched-link" to={listingPath(item.id)}>{item.title}</Link></h3> <h3><Link className="card-stretched-link" to={listingPath(item.id)}>{item.title}</Link></h3>
<p>{[item.city, item.district].filter(Boolean).join(', ')}</p> <p>{[item.city, item.district].filter(Boolean).join(', ')}</p>
<small>{meta}</small> <SpecTiles items={resultSpecTiles} className="compact result-specs" />
{isCompanyOffer && item.ownerId != null ? ( {isCompanyOffer && item.ownerId != null ? (
// Oferta firmowa z kontem w serwisie - klik w nazwe firmy otwiera jej profil publiczny. // Oferta firmowa z kontem w serwisie - klik w nazwe firmy otwiera jej profil publiczny.
// stopPropagation, bo cala karta jest linkiem do oferty (nakladka ::after). // stopPropagation, bo cala karta jest linkiem do oferty (nakladka ::after).
@@ -24451,11 +24649,14 @@ function PublicProfilePage() {
<strong>{formatPrice(listing.price)}{listing.offerType === 'RENT' ? ' / mies.' : ''}</strong> <strong>{formatPrice(listing.price)}{listing.offerType === 'RENT' ? ' / mies.' : ''}</strong>
<h3>{listing.title}</h3> <h3>{listing.title}</h3>
<small><Icon name="pin" /> {[listing.city, listing.district].filter(Boolean).join(', ')}</small> <small><Icon name="pin" /> {[listing.city, listing.district].filter(Boolean).join(', ')}</small>
<div className="public-listing-meta"> <SpecTiles
{listing.area !== null && <span><Icon name="scale" /> {formatArea(listing.area)}</span>} className="compact"
{listing.rooms !== null && <span><Icon name="sofa" /> {listingRoomsText(listing.rooms)}</span>} items={[
{listing.floor && <span><Icon name="building" /> {listingFloorText(listing.floor, listing.buildingFloors)} piętro</span>} ...(listing.area !== null ? [{ icon: 'area', value: formatArea(listing.area), caption: 'Powierzchnia' }] : []),
</div> ...(listing.rooms !== null ? [{ icon: 'plan', value: `${listing.rooms}`, caption: listing.rooms === 1 ? 'Pokój' : 'Pokoje' }] : []),
...(listing.floor ? [{ icon: 'stairs', value: `${listingFloorText(listing.floor, listing.buildingFloors)}`, caption: 'Piętro' }] : []),
]}
/>
<div className="public-listing-foot"> <div className="public-listing-foot">
<em>{listingRelativeDate(listing.createdAt) || 'Dodane niedawno'}</em> <em>{listingRelativeDate(listing.createdAt) || 'Dodane niedawno'}</em>
</div> </div>
@@ -26435,6 +26636,23 @@ function SearchField({ icon, label, value }: { icon: string; label: string; valu
); );
} }
// Wspolny wiersz "kafelkow" ze specyfikacja: ikona -> duza wartosc -> maly podpis. Uzywany na stronie
// ogloszenia, na kartach wynikow i w podgladzie kreatora, zeby wszedzie wygladalo tak samo.
type SpecTile = { icon: string; value: string; caption: string };
function SpecTiles({ items, className }: { items: SpecTile[]; className?: string }) {
return (
<div className={className ? `spec-tiles ${className}` : 'spec-tiles'}>
{items.map((item) => (
<div className="spec-tile" key={item.caption}>
<Icon name={item.icon} />
<strong>{item.value}</strong>
<span>{item.caption}</span>
</div>
))}
</div>
);
}
function AddSelect({ label, value, options = [value], onChange }: { label: string; value: string; options?: string[]; onChange?: (value: string) => void }) { function AddSelect({ label, value, options = [value], onChange }: { label: string; value: string; options?: string[]; onChange?: (value: string) => void }) {
return ( return (
<label className="add-field"> <label className="add-field">
@@ -27380,9 +27598,9 @@ function ListingDetailPage({
</div> </div>
<div className="listing-detail-stats"> <div className="listing-detail-stats">
<div><Icon name="screen" /><strong>{formatArea(listing.area)}</strong><span>Powierzchnia</span></div> <div><Icon name="area" /><strong>{formatArea(listing.area)}</strong><span>Powierzchnia</span></div>
<div><Icon name="sofa" /><strong>{listing.rooms}</strong><span>Pokoje</span></div> <div><Icon name="plan" /><strong>{listing.rooms}</strong><span>Pokoje</span></div>
{floorText && <div><Icon name="building" /><strong>{floorText}</strong><span>Piętro</span></div>} {floorText && <div><Icon name="stairs" /><strong>{floorText}</strong><span>Piętro</span></div>}
{listing.yearBuilt && <div><Icon name="calendar" /><strong>{listing.yearBuilt}</strong><span>Rok budowy</span></div>} {listing.yearBuilt && <div><Icon name="calendar" /><strong>{listing.yearBuilt}</strong><span>Rok budowy</span></div>}
</div> </div>
@@ -28429,6 +28647,27 @@ function Icon({ name }: { name: string }) {
<path d="M19 14.5A8.5 8.5 0 1 1 9.5 5a7 7 0 0 0 9.5 9.5Z" /> <path d="M19 14.5A8.5 8.5 0 1 1 9.5 5a7 7 0 0 0 9.5 9.5Z" />
</svg> </svg>
); );
// Powierzchnia (metraz) - naroznikowe ramki jak "obszar", czysty styl liniowy.
case 'area':
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 9V5a1 1 0 0 1 1-1h4M15 4h4a1 1 0 0 1 1 1v4M20 15v4a1 1 0 0 1-1 1h-4M9 20H5a1 1 0 0 1-1-1v-4" />
</svg>
);
// Liczba pokoi - rzut pomieszczen (plan) ze scianka i przejsciem.
case 'plan':
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 4h16v16H4Z M12 4v7 M4 14h6 M14 14h6" />
</svg>
);
// Pietro - schody.
case 'stairs':
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 19h4v-4h4v-4h4v-4h4" />
</svg>
);
default: default:
return null; return null;
} }
+201 -7
View File
@@ -3128,6 +3128,13 @@ a.city-card:hover {
padding-top: 12px; 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 { .result-seller-type.company {
color: #0f7a48; color: #0f7a48;
} }
@@ -4202,6 +4209,27 @@ a.city-card:hover {
margin: 0; 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 { .add-title h1 span {
color: #10a15e; color: #10a15e;
} }
@@ -4923,6 +4951,13 @@ a.city-card:hover {
width: 100%; 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, .add-field input::placeholder,
.editor-box textarea::placeholder { .editor-box textarea::placeholder {
color: #9aa5b4; color: #9aa5b4;
@@ -5747,13 +5782,22 @@ a.city-card:hover {
border-radius: 999px; border-radius: 999px;
color: #ffffff; color: #ffffff;
display: grid; display: grid;
font-size: 20px;
font-weight: 950;
height: 28px; height: 28px;
place-items: center; place-items: center;
width: 28px; 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 { .photo-order-controls button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.42; opacity: 0.42;
@@ -5863,6 +5907,15 @@ a.city-card:hover {
border-bottom: 0; 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 { .extra-section h2 {
margin-bottom: 18px; margin-bottom: 18px;
} }
@@ -25682,6 +25735,136 @@ a.city-card:hover {
font-size: 12px; 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 { .listing-detail-primary {
align-items: center; align-items: center;
background: #129357; background: #129357;
@@ -26397,6 +26580,7 @@ a.city-card:hover {
.real-result-card { .real-result-card {
cursor: pointer; cursor: pointer;
position: relative;
transition: box-shadow 0.15s, transform 0.15s; transition: box-shadow 0.15s, transform 0.15s;
} }
@@ -32546,20 +32730,30 @@ a.listing-detail-back {
.listing-promoted-tag svg { height: 12px; width: 12px; } .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 { align-items: center; background: #12a764; color: #ffffff; display: inline-flex; gap: 5px; }
.ld-badge-promoted svg { height: 13px; width: 13px; } .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 { .result-promoted-badge {
align-items: center; align-items: center;
background: #12a764; background: linear-gradient(120deg, #0f2f5b, #1d5fa8);
border-radius: 999px; border-radius: 999px;
bottom: 12px;
color: #ffffff; color: #ffffff;
display: inline-flex; display: inline-flex;
font-size: 11px; font-size: 11px;
font-weight: 850; font-weight: 850;
gap: 5px; gap: 5px;
left: 12px; padding: 5px 12px;
padding: 4px 10px;
position: absolute; 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; } .result-promoted-badge svg { height: 12px; width: 12px; }