dodawanie ogloszen poprawki
Deploy Polska Lokalnie / deploy (push) Successful in 10s

This commit is contained in:
2026-09-03 23:28:43 +02:00
parent d973b44e47
commit 282b5e424f
4 changed files with 1277 additions and 157 deletions
+617 -122
View File
@@ -1980,9 +1980,257 @@ const extraInfoSelectOptions = {
sewageType: ['Wybierz', 'Kanalizacja', 'Szambo', 'Przydomowa oczyszczalnia'],
};
// 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.
type AddCategoryItem = { label: string; icon: string };
type AddIntentCard = { key: 'sell' | 'rent'; transaction: string; title: string; subtitle: string; icon: string; items: AddCategoryItem[] };
const ADD_CATEGORY_ICONS: Record<string, string> = {
Mieszkanie: 'apartment',
Dom: 'house',
Działka: 'tree',
'Lokal użytkowy': 'building-plus',
'Budynek użytkowy': 'city',
Pokój: 'sofa',
Garaż: 'car',
};
// Przykladowy tytul dopasowany do wybranej kategorii - dla kazdego rodzaju nieruchomosci inny,
// zeby podpowiedz mialai sens (np. dla dzialki nie sugerujemy "3-pokojowego mieszkania").
const LISTING_TITLE_HINTS: Record<string, string> = {
Mieszkanie: 'Np. 3-pokojowe mieszkanie po remoncie, gotowe do wprowadzenia',
Dom: 'Np. Dom wolnostojący z ogrodem w spokojnej okolicy',
Działka: 'Np. Działka budowlana 800 m² z dostępem do mediów',
'Lokal użytkowy': 'Np. Lokal handlowy w centrum, witryna od ulicy',
'Budynek użytkowy': 'Np. Budynek usługowo-magazynowy z parkingiem',
Pokój: 'Np. Umeblowany pokój w mieszkaniu, blisko centrum',
Garaż: 'Np. Garaż murowany z prądem, miejsce dla 1 auta',
};
const DEFAULT_LISTING_TITLE_HINT = 'Np. Krótki, konkretny tytuł Twojej oferty';
const ADD_INTENT_CARDS: AddIntentCard[] = [
{
key: 'sell',
transaction: 'Sprzedaż',
title: 'Chcę sprzedać',
subtitle: 'Wybierz kategorię nieruchomości, którą chcesz sprzedać.',
icon: 'tag',
items: ['Mieszkanie', 'Dom', 'Działka', 'Lokal użytkowy', 'Budynek użytkowy', 'Garaż'].map((label) => ({ label, icon: ADD_CATEGORY_ICONS[label] })),
},
{
key: 'rent',
transaction: 'Wynajem',
title: 'Chcę wynająć',
subtitle: 'Wybierz kategorię nieruchomości, którą chcesz wynająć.',
icon: 'key',
items: ['Mieszkanie', 'Pokój', 'Dom', 'Działka', 'Lokal użytkowy', 'Budynek użytkowy', 'Garaż'].map((label) => ({ label, icon: ADD_CATEGORY_ICONS[label] })),
},
];
// Ozdobna scenka (budynek + dom + drzewo + chmurki) w naglowku karty wyboru. Rysowana
// currentColor, wiec dziedziczy kolor karty (zielony dla sprzedazy, fioletowy dla wynajmu).
// Grafika pustego stanu galerii zdjec: dwie przezroczyste ramki zdjec z tlem, a na wierzchu domek.
function PhotosEmptyIllustration() {
return (
<svg className="empty-photos-graphic" viewBox="0 0 152 104" fill="none" aria-hidden="true">
<g opacity="0.6">
<g transform="rotate(-8 50 50)">
<rect x="26" y="28" width="48" height="44" rx="7" fill="#eef2f7" stroke="#d9e2ec" strokeWidth="1.6" />
<circle cx="39" cy="41" r="3.6" fill="#ccd8e5" />
<path d="M31 64l11-13 7 8 6-7 12 12" stroke="#ccd8e5" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
</g>
<g transform="rotate(8 102 50)">
<rect x="78" y="28" width="48" height="44" rx="7" fill="#eef2f7" stroke="#d9e2ec" strokeWidth="1.6" />
<circle cx="91" cy="41" r="3.6" fill="#ccd8e5" />
<path d="M83 64l11-13 7 8 6-7 12 12" stroke="#ccd8e5" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
</g>
</g>
<path d="M60 80V56l16-12 16 12v24Z" fill="#e6f8ef" stroke="#12a764" strokeWidth="2.6" strokeLinejoin="round" />
<path d="M55 58 76 42 97 58" stroke="#12a764" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
<rect x="70" y="66" width="12" height="14" rx="1" stroke="#12a764" strokeWidth="2.2" />
</svg>
);
}
function AddSceneIllustration() {
return (
<svg className="add-chooser-scene" viewBox="0 0 170 96" fill="none" aria-hidden="true">
<g opacity="0.65" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M110 22c1.5-6 11-6 13 0 6 0 6 8 0 8h-15c-5 0-4.5-8 2-8Z" />
<path d="M136 34c1-4 7.5-4 8.5 0 4 0 4 6 0 6h-10.5c-4 0-3-6 2-6Z" />
</g>
<g stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
<circle cx="21" cy="57" r="11" fill="currentColor" fillOpacity="0.12" />
<line x1="21" y1="66" x2="21" y2="85" />
</g>
<g stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
<rect x="45" y="31" width="40" height="54" rx="2" fill="currentColor" fillOpacity="0.1" />
<g strokeWidth="1.6" opacity="0.75">
<rect x="53" y="39" width="8" height="8" />
<rect x="69" y="39" width="8" height="8" />
<rect x="53" y="53" width="8" height="8" />
<rect x="69" y="53" width="8" height="8" />
<rect x="53" y="67" width="8" height="8" />
<rect x="69" y="67" width="8" height="8" />
</g>
</g>
<g stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
<path d="M97 85V55l22-14 22 14v30Z" fill="currentColor" fillOpacity="0.1" />
<path d="M93 57l26-17 26 17" />
<rect x="111" y="67" width="15" height="18" />
</g>
<line x1="7" y1="85" x2="163" y2="85" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
// Naglowek ekranu wyboru: wyśrodkowany tytul z podkresleniem i "iskierka", podtytul, trzy plakietki
// zaufania oraz ozdobne przerywane strzalki po bokach (zielona i fioletowa).
function AddChooserHero() {
return (
<div className="add-hero">
<svg className="add-hero-arrow left" viewBox="0 0 120 96" fill="none" aria-hidden="true">
<path d="M104 12C52 8 16 30 30 82" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeDasharray="7 9" />
<path d="M18 66l12 18 16-8" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<svg className="add-hero-arrow right" viewBox="0 0 120 96" fill="none" aria-hidden="true">
<path d="M16 12c52-4 88 18 74 70" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeDasharray="7 9" />
<path d="M102 66l-12 18-16-8" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<h1 className="add-hero-title">
Dodaj ogłoszenie{' '}
<span className="add-hero-hl">
za darmo
<svg className="add-hero-underline" viewBox="0 0 240 22" fill="none" aria-hidden="true">
<path d="M6 14C60 4 176 4 234 12" stroke="currentColor" strokeWidth="5" strokeLinecap="round" />
</svg>
<svg className="add-hero-spark" viewBox="0 0 44 40" fill="none" aria-hidden="true">
<path d="M22 4v13M13 10l5 7M31 10l-5 7" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
</span>
</h1>
<p className="add-hero-sub">Wystaw swoje ogłoszenie w kilka chwil – bez opłat i ukrytych kosztów.</p>
<div className="add-hero-badges">
<div className="add-hero-badge">
<span className="add-hero-badge-ic">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="11" fill="#12a764" />
<path d="M7 12.4l3.3 3.3L17.2 9" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<div><strong>100% darmowe</strong><small>Bez prowizji i opłat</small></div>
</div>
<div className="add-hero-badge">
<span className="add-hero-badge-ic">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 2.5l8 3v6c0 5-3.4 8.3-8 10-4.6-1.7-8-5-8-10v-6l8-3Z" fill="#12a764" />
<path d="M8.4 12.2l2.6 2.6 4.6-5" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<div><strong>Bezpiecznie</strong><small>Twoje dane są chronione</small></div>
</div>
<div className="add-hero-badge">
<span className="add-hero-badge-ic">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8Z" fill="#12a764" />
</svg>
</span>
<div><strong>Szybko i łatwo</strong><small>Tylko kilka kroków</small></div>
</div>
</div>
</div>
);
}
// Karty wyboru na wejsciu do dodawania oferty. Klik w kategorie ustawia transakcje + rodzaj
// i wchodzi w kreator (onPick).
function AddCategoryChooser({ onPick }: { onPick: (transaction: string, propertyType: string) => void }) {
return (
<div className="add-chooser" aria-label="Wybierz, co chcesz zrobić">
{ADD_INTENT_CARDS.map((card) => (
<section className={`add-chooser-card ${card.key}`} key={card.key} aria-label={card.title}>
<header className="add-chooser-head">
<span className="add-chooser-badge"><Icon name={card.icon} /></span>
<div>
<h2>{card.title}</h2>
<p>{card.subtitle}</p>
</div>
<AddSceneIllustration />
</header>
<div className="add-chooser-list">
{card.items.map((item) => (
<button
type="button"
key={item.label}
className="add-chooser-item"
onClick={() => onPick(card.transaction, item.label)}
>
<span className="add-chooser-item-icon"><Icon name={item.icon} /></span>
<span className="add-chooser-item-label">{item.label}</span>
<Icon name="chevron" />
</button>
))}
</div>
</section>
))}
</div>
);
}
// Kroki kreatora dodawania w lewym pionowym stepperze. Wezel 0 = "Kategoria" (ekran wyboru, juz
// ukonczony), wezly 1..5 odpowiadaja krokom formularza (currentStep 0..4).
const ADD_STEP_META = [
{ title: 'Kategoria', desc: 'Wybierz, co chcesz sprzedać lub wynająć' },
{ title: 'Szczegóły oferty', desc: 'Podaj informacje o nieruchomości' },
{ title: 'Lokalizacja', desc: 'Określ adres i lokalizację' },
{ title: 'Zdjęcia', desc: 'Dodaj zdjęcia nieruchomości' },
{ title: 'Dodatkowe informacje', desc: 'Uzupełnij pozostałe szczegóły' },
{ title: 'Podgląd', desc: 'Sprawdź i opublikuj ogłoszenie' },
];
function AddWizardSteps({ currentStep, onGoStep, onBackToCategory }: {
currentStep: number;
onGoStep: (step: number) => void;
onBackToCategory: () => void;
}) {
const activeNode = currentStep + 1; // przesuniecie o "Kategorie" na poczatku
return (
<aside className="add-steps" aria-label="Kroki dodawania ogłoszenia">
<ol className="add-steps-list">
{ADD_STEP_META.map((step, index) => {
const done = index < activeNode;
const active = index === activeNode;
const clickable = index <= activeNode;
const handle = () => (index === 0 ? onBackToCategory() : onGoStep(index - 1));
return (
<li key={step.title} className={`add-steps-item ${done ? 'done' : ''} ${active ? 'active' : ''}`}>
<button
type="button"
className="add-steps-btn"
onClick={clickable ? handle : undefined}
disabled={!clickable}
aria-current={active ? 'step' : undefined}
>
<span className="add-steps-marker">{done ? <Icon name="check" /> : index + 1}</span>
<span className="add-steps-text">
<strong>{index + 1}. {step.title}</strong>
<small>{step.desc}</small>
</span>
</button>
</li>
);
})}
</ol>
</aside>
);
}
const listingDetailsSelectOptions = {
transaction: ['Sprzedaż', 'Wynajem'],
propertyType: ['Mieszkanie', 'Dom', 'Działka', 'Lokal użytkowy', 'Pokój'],
propertyType: ['Mieszkanie', 'Dom', 'Działka', 'Lokal użytkowy', 'Pokój', 'Budynek użytkowy', 'Garaż'],
market: ['Wtórny', 'Pierwotny'],
houseType: ['Wybierz', 'Wolnostojący', 'Bliźniak', 'Szeregowy', 'Segment skrajny', 'Segment środkowy', 'Rezydencja', 'Dworek'],
houseFloors: ['Wybierz', 'Parterowy', '1', '2', '3+'],
@@ -2028,21 +2276,87 @@ const INITIAL_LISTING_DETAILS = {
*/
const PHONE_COUNTRIES = [
{ code: 'PL', dial: '+48', label: 'Polska', minDigits: 9, maxDigits: 9, example: '600 123 456' },
{ code: 'UA', dial: '+380', label: 'Ukraina', minDigits: 9, maxDigits: 9, example: '501 234 567' },
{ code: 'DE', dial: '+49', label: 'Niemcy', minDigits: 10, maxDigits: 11, example: '1512 3456789' },
{ code: 'GB', dial: '+44', label: 'Wielka Brytania', minDigits: 10, maxDigits: 10, example: '7700 900123' },
{ code: 'BY', dial: '+375', label: 'Białoruś', minDigits: 9, maxDigits: 9, example: '291 234 567' },
{ code: 'AL', dial: '+355', label: 'Albania', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'AT', dial: '+43', label: 'Austria', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'BE', dial: '+32', label: 'Belgia', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'BG', dial: '+359', label: 'Bułgaria', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'HR', dial: '+385', label: 'Chorwacja', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'CY', dial: '+357', label: 'Cypr', minDigits: 8, maxDigits: 8, example: '123 456 78' },
{ code: 'CZ', dial: '+420', label: 'Czechy', minDigits: 9, maxDigits: 9, example: '601 123 456' },
{ code: 'SK', dial: '+421', label: 'Słowacja', minDigits: 9, maxDigits: 9, example: '901 123 456' },
{ code: 'LT', dial: '+370', label: 'Litwa', minDigits: 8, maxDigits: 8, example: '612 34567' },
{ code: 'RO', dial: '+40', label: 'Rumunia', minDigits: 9, maxDigits: 9, example: '712 345 678' },
{ code: 'NL', dial: '+31', label: 'Holandia', minDigits: 9, maxDigits: 9, example: '612 345 678' },
{ code: 'DK', dial: '+45', label: 'Dania', minDigits: 8, maxDigits: 8, example: '123 456 78' },
{ code: 'EE', dial: '+372', label: 'Estonia', minDigits: 8, maxDigits: 8, example: '123 456 78' },
{ code: 'FI', dial: '+358', label: 'Finlandia', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'FR', dial: '+33', label: 'Francja', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'DE', dial: '+49', label: 'Niemcy', minDigits: 10, maxDigits: 11, example: '1512 3456789' },
{ code: 'GR', dial: '+30', label: 'Grecja', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'HU', dial: '+36', label: 'Węgry', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'IS', dial: '+354', label: 'Islandia', minDigits: 7, maxDigits: 7, example: '123 456 7' },
{ code: 'IE', dial: '+353', label: 'Irlandia', minDigits: 9, maxDigits: 9, example: '851 234 567' },
{ code: 'IT', dial: '+39', label: 'Włochy', minDigits: 9, maxDigits: 10, example: '312 345 6789' },
{ code: 'LV', dial: '+371', label: 'Łotwa', minDigits: 8, maxDigits: 8, example: '123 456 78' },
{ code: 'LT', dial: '+370', label: 'Litwa', minDigits: 8, maxDigits: 8, example: '612 34567' },
{ code: 'LU', dial: '+352', label: 'Luksemburg', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'MT', dial: '+356', label: 'Malta', minDigits: 8, maxDigits: 8, example: '123 456 78' },
{ code: 'NL', dial: '+31', label: 'Holandia', minDigits: 9, maxDigits: 9, example: '612 345 678' },
{ code: 'NO', dial: '+47', label: 'Norwegia', minDigits: 8, maxDigits: 8, example: '123 456 78' },
{ code: 'PT', dial: '+351', label: 'Portugalia', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'RO', dial: '+40', label: 'Rumunia', minDigits: 9, maxDigits: 9, example: '712 345 678' },
{ code: 'RS', dial: '+381', label: 'Serbia', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'SK', dial: '+421', label: 'Słowacja', minDigits: 9, maxDigits: 9, example: '901 123 456' },
{ code: 'SI', dial: '+386', label: 'Słowenia', minDigits: 8, maxDigits: 8, example: '123 456 78' },
{ code: 'ES', dial: '+34', label: 'Hiszpania', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'SE', dial: '+46', label: 'Szwecja', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'CH', dial: '+41', label: 'Szwajcaria', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'UA', dial: '+380', label: 'Ukraina', minDigits: 9, maxDigits: 9, example: '501 234 567' },
{ code: 'GB', dial: '+44', label: 'Wielka Brytania', minDigits: 10, maxDigits: 10, example: '7700 900123' },
{ code: 'TR', dial: '+90', label: 'Turcja', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'US', dial: '+1', label: 'USA/Kanada', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'MX', dial: '+52', label: 'Meksyk', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'BR', dial: '+55', label: 'Brazylia', minDigits: 11, maxDigits: 11, example: '123 456 789 01' },
{ code: 'AR', dial: '+54', label: 'Argentyna', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'CL', dial: '+56', label: 'Chile', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'CO', dial: '+57', label: 'Kolumbia', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'AU', dial: '+61', label: 'Australia', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'NZ', dial: '+64', label: 'Nowa Zelandia', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'JP', dial: '+81', label: 'Japonia', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'KR', dial: '+82', label: 'Korea Południowa', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'CN', dial: '+86', label: 'Chiny', minDigits: 11, maxDigits: 11, example: '123 456 789 01' },
{ code: 'IN', dial: '+91', label: 'Indie', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'PK', dial: '+92', label: 'Pakistan', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'ID', dial: '+62', label: 'Indonezja', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'TH', dial: '+66', label: 'Tajlandia', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'VN', dial: '+84', label: 'Wietnam', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'SG', dial: '+65', label: 'Singapur', minDigits: 8, maxDigits: 8, example: '123 456 78' },
{ code: 'MY', dial: '+60', label: 'Malezja', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'ZA', dial: '+27', label: 'RPA', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'EG', dial: '+20', label: 'Egipt', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'MA', dial: '+212', label: 'Maroko', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'NG', dial: '+234', label: 'Nigeria', minDigits: 10, maxDigits: 10, example: '123 456 789 0' },
{ code: 'KE', dial: '+254', label: 'Kenia', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'AE', dial: '+971', label: 'ZEA', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'SA', dial: '+966', label: 'Arabia Saudyjska', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'IL', dial: '+972', label: 'Izrael', minDigits: 9, maxDigits: 9, example: '123 456 789' },
{ code: 'QA', dial: '+974', label: 'Katar', minDigits: 8, maxDigits: 8, example: '123 456 78' },
] as const;
const phoneCountryOf = (code: string) => PHONE_COUNTRIES.find((country) => country.code === code) ?? PHONE_COUNTRIES[0];
// Numer telefonu z konta ("+48 500100200") rozbity na kraj (kod PHONE_COUNTRIES) i same cyfry -
// do wstepnego wypelnienia danych kontaktowych w formularzu dodawania oferty.
function contactPhoneFromUser(stored: string | null | undefined): { country: string; number: string } {
const compact = (stored ?? '').replace(/\s/g, '');
if (!compact) {
return { country: 'PL', number: '' };
}
const match = [...PHONE_COUNTRIES]
.sort((a, b) => b.dial.length - a.dial.length)
.find((country) => compact.startsWith(country.dial));
if (!match) {
return { country: 'PL', number: compact.replace(/\D/g, '') };
}
return { country: match.code, number: compact.slice(match.dial.length).replace(/\D/g, '') };
}
// Komunikat bledu albo null, gdy numer jest poprawny. Puste pole jest dozwolone - telefon jest opcjonalny.
function validateContactPhone(countryCode: string, value: string): string | null {
const country = phoneCountryOf(countryCode);
@@ -2703,7 +3017,10 @@ const INITIAL_LISTING_LOCATION: ListingLocation = {
};
// Promien obszaru pokazywanego zamiast dokladnego punktu (zgodny z rozrzutem wspolrzednych z API).
const APPROXIMATE_LOCATION_RADIUS_M = 300;
const APPROXIMATE_LOCATION_RADIUS_M = 1000;
// Zoom mapy w kroku Lokalizacja: dokladny (pinezka) vs przyblizony (mapa sie oddala, widac obszar).
const EXACT_LOCATION_ZOOM = 17;
const APPROXIMATE_LOCATION_ZOOM = 14;
const INITIAL_LISTING_COORDS: LatLng = { lat: 50.0491, lng: 19.9617 };
@@ -13706,7 +14023,50 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
const [editLoadError, setEditLoadError] = useState('');
const [currentStep, setCurrentStep] = useState(0);
const [listingDetails, setListingDetails] = useState(INITIAL_LISTING_DETAILS);
// Ekran startowy z wyborem "Chcę sprzedać / wynająć" + kategorii. Przy edycji pomijamy go i od razu
// pokazujemy formularz. Wybor kategorii ustawia transakcje i rodzaj, a potem wchodzimy w kreator.
const [categoryChosen, setCategoryChosen] = useState(isEdit);
const showCategoryChooser = !isEdit && !categoryChosen;
const chooseCategory = (transaction: string, propertyType: string) => {
setListingDetails((current) => ({ ...current, transaction, propertyType }));
setCurrentStep(0);
setCategoryChosen(true);
};
// Dane kontaktowe wypelniamy z konta zalogowanego uzytkownika (imie i nazwisko, e-mail, telefon) -
// raz i tylko dla nowej oferty. Pola pozostaja w pelni edytowalne. Przy edycji ogloszenia dane
// pochodza z samego ogloszenia, wiec nie nadpisujemy ich kontem.
const contactPrefilledRef = useRef(false);
useEffect(() => {
if (isEdit || contactPrefilledRef.current || !user) {
return;
}
contactPrefilledRef.current = true;
const phone = contactPhoneFromUser(user.phone);
setListingDetails((current) => ({
...current,
contactName: current.contactName || user.fullName || '',
contactEmail: current.contactEmail || user.email || '',
contactPhoneCountry: current.contactPhone ? current.contactPhoneCountry : phone.country,
contactPhone: current.contactPhone || phone.number,
}));
}, [isEdit, user]);
const [contactPhoneTouched, setContactPhoneTouched] = useState(false);
// Wybor kraju telefonu jako picker z flaga (jak w ustawieniach konta), a nie zwykly <select>.
const [isContactCountryOpen, setIsContactCountryOpen] = useState(false);
const contactCountryRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isContactCountryOpen) {
return;
}
const handler = (event: MouseEvent) => {
if (contactCountryRef.current && !contactCountryRef.current.contains(event.target as Node)) {
setIsContactCountryOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [isContactCountryOpen]);
const [location, setLocation] = useState<ListingLocation>(INITIAL_LISTING_LOCATION);
const [photos, setPhotos] = useState<ListingPhoto[]>([]);
const [extraInfo, setExtraInfo] = useState(INITIAL_EXTRA_INFO);
@@ -14102,18 +14462,6 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
};
// Ustawia wskazane zdjecie jako glowne (przenosi na poczatek listy = wyswietla sie pierwsze w ogloszeniu).
const setMainPhoto = (photoId: string) => {
setPhotos((current) => {
const index = current.findIndex((photo) => photo.id === photoId);
if (index <= 0) {
return current;
}
const nextPhotos = [...current];
const [photo] = nextPhotos.splice(index, 1);
nextPhotos.unshift(photo);
return nextPhotos;
});
};
const movePhoto = (photoId: string, direction: -1 | 1) => {
setPhotos((current) => {
@@ -14250,8 +14598,10 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
const nextCenter = clampCenter(point);
setLocationCenter(nextCenter);
setLocationMapCenter(nextCenter);
setLocationMapZoom(locationZoom(location));
setLocationStatus('Pinezka została ustawiona na podstawie wpisanego adresu.');
setLocationMapZoom(location.approximateLocation ? APPROXIMATE_LOCATION_ZOOM : locationZoom(location));
setLocationStatus(location.approximateLocation
? 'Ustawiliśmy przybliżony obszar na podstawie wpisanego adresu.'
: 'Pinezka została ustawiona na podstawie wpisanego adresu.');
})
.catch((error) => {
if (error instanceof DOMException && error.name === 'AbortError') {
@@ -14266,11 +14616,43 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
};
}, [isLocationStep, location]);
// Plynne oddalenie/przyblizenie mapy przy przelaczaniu "Nie pokazuj dokladnego adresu".
// Zoom zmieniamy krok po kroku (poziomy kafelkow sa calkowite), co daje efekt animacji.
const approxZoomAnimRef = useRef<number | null>(null);
useEffect(() => {
if (!isLocationStep) {
return;
}
const target = location.approximateLocation ? APPROXIMATE_LOCATION_ZOOM : EXACT_LOCATION_ZOOM;
if (approxZoomAnimRef.current !== null) {
window.clearInterval(approxZoomAnimRef.current);
}
approxZoomAnimRef.current = window.setInterval(() => {
setLocationMapZoom((z) => {
if (z === target) {
if (approxZoomAnimRef.current !== null) {
window.clearInterval(approxZoomAnimRef.current);
approxZoomAnimRef.current = null;
}
return z;
}
return z + (z < target ? 1 : -1);
});
}, 130);
return () => {
if (approxZoomAnimRef.current !== null) {
window.clearInterval(approxZoomAnimRef.current);
approxZoomAnimRef.current = null;
}
};
}, [location.approximateLocation, isLocationStep]);
const pickLocationPoint = (point: LatLng) => {
const nextCenter = clampCenter(point);
setLocationCenter(nextCenter);
setLocationMapCenter(nextCenter);
setLocationMapZoom((current) => Math.max(current, 16));
// Przy przyblizonej lokalizacji nie doblizamy mapy - obszar ma pozostac widoczny.
setLocationMapZoom((current) => (location.approximateLocation ? current : Math.max(current, 16)));
setLocationStatus('Pobieram adres z wybranego punktu...');
const controller = new AbortController();
reverseGeocodeListingLocation(nextCenter, controller.signal)
@@ -14299,7 +14681,7 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
<strong>Dodaj ogłoszenie</strong>
</div>
{!isPhotosStep && !isExtraInfoStep && !isFinalPreviewStep && (
{isEdit && !isPhotosStep && !isExtraInfoStep && !isFinalPreviewStep && (
<div className="add-title">
{isEdit ? (
<>
@@ -14314,45 +14696,46 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
<p>Wypełnij formularz i dotrzyj do tysięcy zainteresowanych.</p>
</>
)}
{!isEdit && categoryChosen && currentStep === 0 && (
<button type="button" className="add-change-category" onClick={() => setCategoryChosen(false)}>
<Icon name="chevron" /> Zmień kategorię
</button>
)}
{editLoadError && <p className="publish-feedback error" role="alert">{editLoadError}</p>}
</div>
)}
<div className="add-progress" aria-label="Postęp formularza">
{addProgressSteps.map((step, index) => {
const clickable = isEdit || index <= currentStep;
const state = index === currentStep ? 'active' : index < currentStep ? 'completed' : '';
return (
<button
type="button"
key={step}
className={`add-step ${state}${clickable ? ' clickable' : ''}`}
onClick={clickable ? () => goToStep(index) : undefined}
disabled={!clickable}
aria-current={index === currentStep ? 'step' : undefined}
>
<span>{index < currentStep ? <Icon name="check" /> : index + 1}</span>
<strong>{step}</strong>
</button>
);
})}
</div>
<div className={`add-layout ${isFinalPreviewStep ? 'final-preview-layout' : ''}`}>
{showCategoryChooser ? (
<div className="add-chooser-view">
<AddChooserHero />
<AddCategoryChooser onPick={chooseCategory} />
</div>
) : (
<>
<div className={`add-layout add-layout-wizard ${isFinalPreviewStep ? 'final-preview-layout' : ''}`}>
<AddWizardSteps currentStep={currentStep} onGoStep={goToStep} onBackToCategory={isEdit ? () => {} : () => setCategoryChosen(false)} />
<div className="add-form-column">
{/* Naglowek "Wybrana opcja" u gory na kazdym kroku (poza finalnym podgladem),
nad animowana trescia kroku. */}
{!isFinalPreviewStep && (
<section className={`add-selected-option ${listingDetails.transaction === 'Wynajem' ? 'rent' : 'sell'}`}>
<span className="add-selected-badge"><Icon name={ADD_CATEGORY_ICONS[listingDetails.propertyType] ?? 'building'} /></span>
<div className="add-selected-text">
<small>Wybrana opcja</small>
<h2>{listingDetails.transaction === 'Wynajem' ? 'Wynajmij' : 'Sprzedaj'} – {listingDetails.propertyType}</h2>
<p>Dodajesz ogłoszenie za darmo. Bez prowizji i ukrytych kosztów.</p>
</div>
</section>
)}
{/* key={currentStep} remontuje tresc przy zmianie kroku - dzieki temu odpala sie
animacja pojawienia (Dalej/Wstecz). */}
<div className="add-step-anim" key={currentStep}>
{!isLocationStep && !isPhotosStep && !isExtraInfoStep && !isFinalPreviewStep && (
<>
<section className="add-form-card">
<h2>Podstawowe informacje</h2>
<div className="add-three-grid">
<AddSelect label="Transakcja" value={listingDetails.transaction} options={listingDetailsSelectOptions.transaction} onChange={(value) => updateListingDetails('transaction', value)} />
<AddSelect label="Rodzaj nieruchomości" value={listingDetails.propertyType} options={listingDetailsSelectOptions.propertyType} onChange={(value) => updateListingDetails('propertyType', value)} />
<AddSelect label="Rynek" value={listingDetails.market} options={listingDetailsSelectOptions.market} onChange={(value) => updateListingDetails('market', value)} />
</div>
<label className="add-field full">
<span>Tytuł ogłoszenia</span>
<input value={listingDetails.title} maxLength={100} onChange={(event) => updateListingDetails('title', event.target.value)} placeholder="Np. 3-pokojowe mieszkanie po remoncie, gotowe do wprowadzenia" />
<input value={listingDetails.title} maxLength={100} onChange={(event) => updateListingDetails('title', event.target.value)} placeholder={LISTING_TITLE_HINTS[listingDetails.propertyType] ?? DEFAULT_LISTING_TITLE_HINT} />
<small>{listingDetails.title.length}/100</small>
</label>
@@ -14363,7 +14746,7 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
onChange={(html) => updateListingDetails('description', html)}
maxLength={3000}
ariaLabel="Opis ogłoszenia"
placeholder="Opisz nieruchomość, jej stan, okolicę, komunikację, dodatkowe atuty..."
placeholder="Opisz szczegóły oferty: stan, lokalizację, atuty i najważniejsze informacje dla zainteresowanych..."
/>
</div>
</section>
@@ -14371,13 +14754,13 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
<section className="add-form-card">
<h2>Szczegóły nieruchomości</h2>
<div className="add-three-grid">
<AddInput label={isRentTransaction ? 'Czynsz miesięczny' : 'Cena'} value={listingDetails.price} suffix="zł" placeholder={isRentTransaction ? 'Np. 3200' : 'Np. 720000'} onChange={(value) => updateListingDetails('price', value)} />
<AddInput label={isRentTransaction ? 'Czynsz miesięczny' : 'Cena'} value={listingDetails.price} suffix="zł" placeholder={isRentTransaction ? 'Np. 3200' : 'Np. 720000'} numeric="decimal" onChange={(value) => updateListingDetails('price', value)} />
{isRentTransaction ? (
<AddInput label="Czynsz administracyjny" value={extraInfo.rent} suffix="zł" placeholder="Np. 3000" onChange={(value) => updateExtraInfo('rent', value)} />
<AddInput label="Czynsz administracyjny" value={extraInfo.rent} suffix="zł" placeholder="Np. 3000" numeric="decimal" onChange={(value) => updateExtraInfo('rent', value)} />
) : (
<AddInput label="Cena za m²" value={pricePerMeter} suffix="zł/m²" readOnly />
)}
<AddInput label="Powierzchnia" value={listingDetails.area} suffix="m²" placeholder="Np. 55" onChange={(value) => updateListingDetails('area', value)} />
<AddInput label="Powierzchnia" value={listingDetails.area} suffix="m²" placeholder="Np. 55" numeric="decimal" onChange={(value) => updateListingDetails('area', value)} />
{showRoomFields && <AddSelect label="Liczba pokoi" value={listingDetails.rooms} options={listingDetailsSelectOptions.rooms} onChange={(value) => updateListingDetails('rooms', value)} />}
{showFlatFields && <AddSelect label="Piętro" value={listingDetails.floor} options={listingDetailsSelectOptions.floor} onChange={(value) => updateListingDetails('floor', value)} />}
{showFlatFields && <AddSelect label="Liczba pięter w budynku" value={listingDetails.buildingFloors} options={listingDetailsSelectOptions.buildingFloors} onChange={(value) => updateListingDetails('buildingFloors', value)} />}
@@ -14386,9 +14769,10 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
{isHouseProperty && <AddSelect label="Garaż" value={listingDetails.houseGarage} options={listingDetailsSelectOptions.houseGarage} onChange={(value) => updateListingDetails('houseGarage', value)} />}
{isHouseProperty && <AddSelect label="Podpiwniczenie" value={listingDetails.houseBasement} options={listingDetailsSelectOptions.houseBasement} onChange={(value) => updateListingDetails('houseBasement', value)} />}
{isHouseProperty && <AddSelect label="Strych" value={listingDetails.houseAttic} options={listingDetailsSelectOptions.houseAttic} onChange={(value) => updateListingDetails('houseAttic', value)} />}
{showBuildingFields && <AddInput label="Rok budowy" value={listingDetails.yearBuilt} placeholder="Np. 2015" onChange={(value) => updateListingDetails('yearBuilt', value)} />}
{showBuildingFields && <AddInput label="Rok budowy" value={listingDetails.yearBuilt} placeholder="Np. 2015" numeric="integer" onChange={(value) => updateListingDetails('yearBuilt', value)} />}
{showBuildingFields && <AddSelect label="Stan nieruchomości" value={listingDetails.condition} options={listingDetailsSelectOptions.condition} onChange={(value) => updateListingDetails('condition', value)} />}
<AddSelect label="Forma własności" value={listingDetails.ownership} options={listingDetailsSelectOptions.ownership} onChange={(value) => updateListingDetails('ownership', value)} />
<AddSelect label="Rynek" value={listingDetails.market} options={listingDetailsSelectOptions.market} onChange={(value) => updateListingDetails('market', value)} />
</div>
</section>
@@ -14399,15 +14783,39 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
<label className="add-field add-phone-field">
<span>Telefon komórkowy</span>
<div className="add-phone-row">
<select
value={listingDetails.contactPhoneCountry}
onChange={(event) => updateListingDetails('contactPhoneCountry', event.target.value)}
aria-label="Prefiks kraju"
>
{PHONE_COUNTRIES.map((country) => (
<option key={country.code} value={country.code}>{country.dial} {country.label}</option>
))}
</select>
<div ref={contactCountryRef} className="phone-country-picker">
<button
type="button"
className="phone-country-trigger"
onClick={() => setIsContactCountryOpen((open) => !open)}
aria-haspopup="listbox"
aria-expanded={isContactCountryOpen}
aria-label="Prefiks kraju"
>
<img src={`https://flagcdn.com/w20/${phoneCountryOf(listingDetails.contactPhoneCountry).code.toLowerCase()}.png`} alt={`Flaga ${phoneCountryOf(listingDetails.contactPhoneCountry).label}`} loading="lazy" />
<span>{phoneCountryOf(listingDetails.contactPhoneCountry).dial}</span>
<Icon name="chevron" />
</button>
{isContactCountryOpen && (
<div className="phone-country-menu" role="listbox" aria-label="Wybór kierunkowego">
{PHONE_COUNTRIES.map((country) => (
<button
key={country.code}
type="button"
className={country.code === listingDetails.contactPhoneCountry ? 'active' : ''}
onClick={() => {
updateListingDetails('contactPhoneCountry', country.code);
setIsContactCountryOpen(false);
}}
>
<img src={`https://flagcdn.com/w20/${country.code.toLowerCase()}.png`} alt={`Flaga ${country.label}`} loading="lazy" />
<span>{country.dial}</span>
<small>{country.label}</small>
</button>
))}
</div>
)}
</div>
<input
type="tel"
inputMode="numeric"
@@ -14422,25 +14830,30 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
</label>
<AddInput label="E-mail" value={listingDetails.contactEmail} type="email" placeholder="Np. jan.kowalski@email.pl" onChange={(value) => updateListingDetails('contactEmail', value)} />
</div>
<div className="contact-actions">
<button className="next-step-button" type="button" onClick={() => goToStep(1)}>
Dalej: Lokalizacja
<Icon name="arrow" />
</button>
</div>
</section>
<p className="secure-note">
<Icon name="shield" />
Twoje dane są bezpieczne. Nie udostępniamy ich osobom trzecim.
</p>
<div className="add-wizard-nav">
<button className="add-nav-back" type="button" onClick={() => (isEdit ? navigate(-1) : setCategoryChosen(false))}>
<Icon name="chevron" /> Wstecz
</button>
<button className="add-nav-next" type="button" onClick={() => goToStep(1)}>
Dalej: Lokalizacja <Icon name="arrow" />
</button>
</div>
</>
)}
{isLocationStep && (
<section className="add-form-card add-location-card">
<h2>Lokalizacja nieruchomości</h2>
<p className="add-location-subtitle">Podaj dokładną lokalizację nieruchomości.</p>
<div className="add-card-heading">
<h2>Lokalizacja nieruchomości</h2>
<p>Podaj dokładną lokalizację nieruchomości.</p>
</div>
<div className="add-location-grid">
<label className="add-field">
@@ -14494,6 +14907,8 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
marker={locationCenter}
zoom={locationMapZoom}
label={[location.street, location.building].filter(Boolean).join(' ') || location.city}
approximate={location.approximateLocation}
radiusMeters={APPROXIMATE_LOCATION_RADIUS_M}
onChange={(center, zoom) => { setLocationMapCenter(center); setLocationMapZoom(zoom); }}
onPick={pickLocationPoint}
/>
@@ -14517,19 +14932,18 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
{isPhotosStep && (
<>
<div className="add-title add-step-title">
<h1>Dodaj zdjęcia</h1>
<p>Dodaj zdjęcia, które najlepiej prezentują Twoją nieruchomość.</p>
</div>
<section className="add-form-card add-photos-card">
<h2>Zdjęcia nieruchomości</h2>
<div className="add-card-heading">
<h2>Dodaj zdjęcia</h2>
<p>Dodaj zdjęcia, które najlepiej prezentują Twoją nieruchomość.</p>
</div>
<label className="photo-upload-box" onDragOver={(event) => event.preventDefault()} onDrop={handlePhotoDrop}>
<input ref={photoInputRef} type="file" accept="image/png,image/jpeg" multiple onChange={handlePhotoInput} />
<Icon name="upload" />
<strong>Kliknij lub przeciągnij zdjęcia tutaj</strong>
<span>Dodaj do 20 zdjęć w formacie JPG, PNG (max. 10 MB) - szersze niż 1024 px zmniejszymy automatycznie</span>
<strong>Przeciągnij zdjęcia tutaj</strong>
<span className="photo-upload-or">lub kliknij, aby wybrać pliki z urządzenia</span>
<span>Dodaj do 20 zdjęć w formacie JPG, PNG (max. 10 MB).<br />Sugerowana rozdzielczość: min. 1600 x 1200 px.</span>
</label>
<p className="photo-tip-note">
@@ -14564,18 +14978,14 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
<button type="button" disabled={index === 0} onClick={() => movePhoto(photo.id, -1)} aria-label={`Przesuń zdjęcie ${photo.name} w lewo`}>‹</button>
<button type="button" disabled={index === photos.length - 1} onClick={() => movePhoto(photo.id, 1)} aria-label={`Przesuń zdjęcie ${photo.name} w prawo`}>›</button>
</div>
{index !== 0 && (
<button type="button" className="set-main-photo-button" onClick={() => setMainPhoto(photo.id)}>
<Icon name="star" /> Ustaw jako główne
</button>
)}
</article>
))}
</div>
) : (
<div className="empty-photos-box">
<Icon name="camera" />
<p>Nie dodano jeszcze zdjęć. Pierwsze dodane zdjęcie będzie zdjęciem głównym.</p>
<PhotosEmptyIllustration />
<strong>Nie dodałeś jeszcze żadnych zdjęć</strong>
<small>Pierwsze zdjęcie będzie zdjęciem głównym Twojego ogłoszenia.</small>
</div>
)}
@@ -14913,6 +15323,7 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
</section>
</>
)}
</div>
</div>
<aside className="add-sidebar" aria-label="Podgląd i pomoc">
@@ -15062,6 +15473,8 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
))}
</div>
</section>}
</>
)}
</div>
</section>
);
@@ -22840,6 +23253,8 @@ function LocationPickerMap({
label,
onChange,
onPick,
approximate = false,
radiusMeters = 300,
}: {
center: LatLng;
marker: LatLng;
@@ -22847,9 +23262,17 @@ function LocationPickerMap({
label: string;
onChange: (center: LatLng, zoom: number) => void;
onPick: (point: LatLng) => void;
// approximate = pokazuj obszar (plame) zamiast dokladnej pinezki; radiusMeters = promien tego obszaru.
approximate?: boolean;
radiusMeters?: number;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 720, h: 278 });
// Podglad: mapa uliczna (OpenStreetMap) albo satelita (ArcGIS World Imagery).
const [mapType, setMapType] = useState<'map' | 'satellite'>('map');
const tileUrl = (tileX: number, tileY: number) => (mapType === 'satellite'
? `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${zoom}/${tileY}/${tileX}`
: `https://tile.openstreetmap.org/${zoom}/${tileX}/${tileY}.png`);
const [draftMarker, setDraftMarker] = useState<LatLng | null>(null);
const mapDragRef = useRef<{ startX: number; startY: number; originX: number; originY: number; moved: boolean } | null>(null);
const markerDragRef = useRef<{ moved: boolean } | null>(null);
@@ -22866,6 +23289,28 @@ function LocationPickerMap({
return () => observer.disconnect();
}, []);
// Scroll nad mapa zoomuje mapę i NIE przewija strony. React rejestruje onWheel jako pasywny,
// wiec preventDefault nie zadziala - dlatego wpinamy natywny listener z passive: false. Poza
// mapa (brak listenera) scroll dziala normalnie. Aktualne wartosci czytamy przez ref.
const wheelStateRef = useRef({ zoom, center, onChange });
wheelStateRef.current = { zoom, center, onChange };
useEffect(() => {
const node = containerRef.current;
if (!node) {
return;
}
const onWheelNative = (event: WheelEvent) => {
event.preventDefault();
const state = wheelStateRef.current;
const nextZoom = Math.min(19, Math.max(5, state.zoom + (event.deltaY < 0 ? 1 : -1)));
if (nextZoom !== state.zoom) {
state.onChange(state.center, nextZoom);
}
};
node.addEventListener('wheel', onWheelNative, { passive: false });
return () => node.removeEventListener('wheel', onWheelNative);
}, []);
const worldCenter = projectToWorld(center.lat, center.lng, zoom);
const originX = worldCenter.x - size.w / 2;
const originY = worldCenter.y - size.h / 2;
@@ -22897,6 +23342,9 @@ function LocationPickerMap({
const markerWorld = projectToWorld(shownMarker.lat, shownMarker.lng, zoom);
const markerLeft = markerWorld.x - originX;
const markerTop = markerWorld.y - originY;
// Srednica plamy obszaru w pikselach: promien w metrach przeliczony na piksele w tej szerokosci i zoomie.
const metersPerPixel = (156543.03392 * Math.cos((shownMarker.lat * Math.PI) / 180)) / (2 ** zoom);
const areaDiameter = (2 * radiusMeters) / metersPerPixel;
const changeZoom = (delta: number) => {
const nextZoom = Math.min(19, Math.max(5, zoom + delta));
onChange(center, nextZoom);
@@ -22985,11 +23433,6 @@ function LocationPickerMap({
}
};
const onWheel = (event: ReactWheelEvent<HTMLDivElement>) => {
event.preventDefault();
changeZoom(event.deltaY < 0 ? 1 : -1);
};
return (
<div className="add-location-map real-location-map" ref={containerRef}>
<div
@@ -22998,7 +23441,6 @@ function LocationPickerMap({
onPointerMove={onMapPointerMove}
onPointerUp={onMapPointerUp}
onPointerCancel={onMapPointerUp}
onWheel={onWheel}
>
<div className="real-location-tiles" aria-hidden="true">
{tiles.map((tile) => (
@@ -23006,47 +23448,70 @@ function LocationPickerMap({
alt=""
draggable={false}
height={tileSize}
key={tile.key}
key={`${mapType}-${tile.key}`}
loading="lazy"
src={`https://tile.openstreetmap.org/${zoom}/${tile.x}/${tile.y}.png`}
src={tileUrl(tile.x, tile.y)}
style={{ left: tile.left, top: tile.top }}
width={tileSize}
/>
))}
</div>
{approximate ? (
// Przyblizona lokalizacja: zamiast pinezki rysujemy "plame" - obszar o zadanym promieniu.
<div
className="add-location-area"
style={{ left: markerLeft, top: markerTop, width: areaDiameter, height: areaDiameter }}
aria-hidden="true"
/>
) : (
<>
<div
className="add-location-pin real-location-pin"
style={{ left: markerLeft, top: markerTop }}
onPointerDown={onMarkerPointerDown}
onPointerMove={onMarkerPointerMove}
onPointerUp={onMarkerPointerUp}
onPointerCancel={onMarkerPointerUp}
>
<Icon name="pin" />
</div>
<span className="add-location-map-label real-location-label" style={{ left: markerLeft, top: markerTop - 36 }}>
{label}
</span>
</>
)}
</div>
<div className="real-location-maptype" role="group" aria-label="Rodzaj mapy">
<button type="button" className={mapType === 'map' ? 'active' : ''} onClick={() => setMapType('map')}>Mapa</button>
<button type="button" className={mapType === 'satellite' ? 'active' : ''} onClick={() => setMapType('satellite')}>Satelita</button>
</div>
{!approximate && (
<div
className="add-location-pin real-location-pin"
style={{ left: markerLeft, top: markerTop }}
onPointerDown={onMarkerPointerDown}
className="real-location-pin-tool"
onPointerDown={onToolPointerDown}
onPointerMove={onMarkerPointerMove}
onPointerUp={onMarkerPointerUp}
onPointerCancel={onMarkerPointerUp}
title="Przeciągnij pinezkę na adres"
>
<Icon name="pin" />
</div>
<span className="add-location-map-label real-location-label" style={{ left: markerLeft, top: markerTop - 36 }}>
{label}
</span>
</div>
<div
className="real-location-pin-tool"
onPointerDown={onToolPointerDown}
onPointerMove={onMarkerPointerMove}
onPointerUp={onMarkerPointerUp}
onPointerCancel={onMarkerPointerUp}
title="Przeciągnij pinezkę na adres"
>
<Icon name="pin" />
</div>
)}
<div className="real-location-zoom">
<button type="button" aria-label="Przybliż" onClick={() => changeZoom(1)}>+</button>
<button type="button" aria-label="Oddal" onClick={() => changeZoom(-1)}>−</button>
</div>
<a className="real-location-attribution" href="https://www.openstreetmap.org/copyright" rel="noreferrer" target="_blank">
© OpenStreetMap
<a
className="real-location-attribution"
href={mapType === 'satellite' ? 'https://www.esri.com/' : 'https://www.openstreetmap.org/copyright'}
rel="noreferrer"
target="_blank"
>
{mapType === 'satellite' ? '© Esri, Maxar' : '© OpenStreetMap'}
</a>
</div>
);
@@ -25983,12 +26448,29 @@ function AddSelect({ label, value, options = [value], onChange }: { label: strin
);
}
function AddInput({ label, value, suffix, type = 'text', placeholder, readOnly, onChange }: { label: string; value: string; suffix?: string; type?: string; placeholder?: string; readOnly?: boolean; onChange?: (value: string) => void }) {
function AddInput({ label, value, suffix, type = 'text', placeholder, readOnly, onChange, numeric }: { label: string; value: string; suffix?: string; type?: string; placeholder?: string; readOnly?: boolean; onChange?: (value: string) => void; numeric?: 'integer' | 'decimal' }) {
// Pola liczbowe (cena, powierzchnia, rok budowy) przyjmuja tylko cyfry - litery i inne znaki sa
// odrzucane od razu. Tryb "decimal" dopuszcza jeden separator dziesietny (kropka lub przecinek).
const sanitize = (raw: string): string => {
if (numeric === 'integer') {
return raw.replace(/\D/g, '');
}
if (numeric === 'decimal') {
const cleaned = raw.replace(/[^\d.,]/g, '');
const sep = cleaned.search(/[.,]/);
if (sep === -1) {
return cleaned;
}
return cleaned.slice(0, sep + 1) + cleaned.slice(sep + 1).replace(/[.,]/g, '');
}
return raw;
};
const inputMode = numeric === 'integer' ? 'numeric' : numeric === 'decimal' ? 'decimal' : undefined;
return (
<label className="add-field">
<span>{label}</span>
<div className="input-with-suffix">
<input type={type} placeholder={placeholder} readOnly={readOnly} {...(onChange || readOnly ? { value, onChange: (event) => onChange?.(event.target.value) } : { defaultValue: value })} />
<input type={type} inputMode={inputMode} placeholder={placeholder} readOnly={readOnly} {...(onChange || readOnly ? { value, onChange: (event) => onChange?.(sanitize(event.target.value)) } : { defaultValue: value })} />
{suffix && <em>{suffix}</em>}
</div>
</label>
@@ -27549,6 +28031,19 @@ function Icon({ name }: { name: string }) {
<path d="M12 12.6a2.4 2.4 0 1 0 0-4.8 2.4 2.4 0 0 0 0 4.8Z" />
</svg>
);
case 'tree':
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2.5c1.9 0 3.5 1.2 4.1 2.9 1.9.3 3.4 2 3.4 4 0 2.3-1.9 4.1-4.1 4.1H8.6C6.4 13.5 4.5 11.7 4.5 9.4c0-2 1.5-3.6 3.4-3.9C8.5 3.7 10.1 2.5 12 2.5Z" />
<path d="M12 13.5V21M9.3 21h5.4" />
</svg>
);
case 'apartment':
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 21V4.5A1.5 1.5 0 0 1 7.5 3h9A1.5 1.5 0 0 1 18 4.5V21M3.5 21h17M9.5 7h.1M14.5 7h.1M9.5 11h.1M14.5 11h.1M9.5 15h.1M14.5 15h.1M10 21v-3.5h4V21" />
</svg>
);
case 'building':
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
+660 -35
View File
@@ -4306,6 +4306,556 @@ a.city-card:hover {
min-width: 0;
}
/* Animacja pojawienia tresci kroku przy nawigacji Dalej/Wstecz (remount przez key={currentStep}). */
.add-step-anim {
animation: addStepIn 0.32s ease;
display: grid;
gap: 18px;
}
@keyframes addStepIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.add-step-anim { animation: none; }
}
/* === Kreator dodawania oferty: lewy stepper | formularz | podglad === */
.add-layout.add-layout-wizard {
grid-template-columns: 244px minmax(0, 1fr) 340px;
}
.add-steps {
align-self: start;
position: sticky;
top: 92px;
}
.add-steps-list {
display: flex;
flex-direction: column;
list-style: none;
margin: 0;
padding: 0;
}
.add-steps-item {
position: relative;
}
/* linia laczaca markery */
.add-steps-item:not(:last-child)::after {
background: #e3e9f1;
content: "";
left: 27px;
position: absolute;
top: 50px;
width: 2px;
height: calc(100% - 26px);
}
.add-steps-item.done:not(:last-child)::after {
background: #12a764;
}
.add-steps-btn {
align-items: flex-start;
background: none;
border: 0;
border-radius: 10px;
cursor: pointer;
display: flex;
gap: 12px;
padding: 20px 12px;
text-align: left;
width: 100%;
}
.add-steps-btn:disabled {
cursor: default;
}
.add-steps-item.active .add-steps-btn {
background: #eaf7f0;
}
.add-steps-marker {
align-items: center;
background: #ffffff;
border: 2px solid #dbe3ec;
border-radius: 50%;
color: #9aa7b6;
display: flex;
flex: 0 0 auto;
font-size: 13px;
font-weight: 900;
height: 30px;
justify-content: center;
position: relative;
width: 30px;
z-index: 1;
}
.add-steps-item.active .add-steps-marker,
.add-steps-item.done .add-steps-marker {
background: #12a764;
border-color: #12a764;
color: #ffffff;
}
.add-steps-marker svg { height: 15px; width: 15px; }
.add-steps-text strong {
color: #22314a;
display: block;
font-size: 14px;
font-weight: 850;
}
.add-steps-item.active .add-steps-text strong { color: #0f7a48; }
.add-steps-text small {
color: #8794a6;
display: block;
font-size: 12px;
font-weight: 600;
line-height: 1.35;
margin-top: 3px;
}
/* Naglowek "Wybrana opcja" */
.add-selected-option {
align-items: center;
background: #ffffff;
border: 1px solid #edf1f6;
border-radius: 12px;
box-shadow: 0 8px 22px rgba(24, 35, 57, 0.05);
display: flex;
gap: 16px;
padding: 18px 22px;
}
.add-selected-badge {
align-items: center;
background: #e6f8ef;
border-radius: 12px;
color: #0f9257;
display: flex;
flex: 0 0 auto;
height: 52px;
justify-content: center;
width: 52px;
}
.add-selected-badge svg { height: 26px; width: 26px; }
.add-selected-text small {
color: #8794a6;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.add-selected-text h2 {
color: #12a764;
font-size: 22px;
font-weight: 900;
margin: 2px 0 3px;
}
/* Wynajem = fioletowy akcent naglowka, sprzedaz = zielony (domyslny wyzej). */
.add-selected-option.rent .add-selected-badge {
background: #eee9fd;
color: #6f5be0;
}
.add-selected-option.rent .add-selected-text h2 {
color: #6f5be0;
}
.add-selected-text p {
color: #6f7f92;
font-size: 13px;
font-weight: 600;
margin: 0;
}
/* Dolny pasek nawigacji kreatora: Wstecz | Dalej */
.add-wizard-nav {
align-items: center;
display: flex;
gap: 12px;
justify-content: space-between;
margin-top: 4px;
}
.add-nav-back {
align-items: center;
background: #ffffff;
border: 1px solid #d7dfea;
border-radius: 10px;
color: #45566d;
cursor: pointer;
display: inline-flex;
font-size: 14px;
font-weight: 800;
gap: 6px;
padding: 12px 20px;
}
.add-nav-back svg {
height: 15px;
transform: rotate(90deg);
width: 15px;
}
.add-nav-back:hover {
background: #f3f6fa;
border-color: #c2cddb;
}
.add-nav-next {
align-items: center;
background: #12a764;
border: 0;
border-radius: 10px;
color: #ffffff;
cursor: pointer;
display: inline-flex;
font-size: 14px;
font-weight: 850;
gap: 8px;
padding: 13px 24px;
}
.add-nav-next svg { height: 16px; width: 16px; }
.add-nav-next:hover { background: #0f8f55; }
@media (max-width: 1240px) {
.add-layout.add-layout-wizard {
grid-template-columns: 210px minmax(0, 1fr) 300px;
}
}
/* Podglad trzyma sie prawej kolumny dluzej (waskie kolumny) - dzieki temu jest widoczny takze
w Operze, ktora zabiera czesc szerokosci na wbudowany panel boczny. */
@media (max-width: 1040px) {
.add-layout.add-layout-wizard {
grid-template-columns: 180px minmax(0, 1fr) 265px;
}
}
/* Dopiero ponizej 860px podglad schodzi pod formularz (na pelna szerokosc). */
@media (max-width: 860px) {
.add-layout.add-layout-wizard {
grid-template-columns: 170px minmax(0, 1fr);
}
.add-layout-wizard .add-sidebar {
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
position: static;
}
}
@media (max-width: 720px) {
.add-layout.add-layout-wizard {
grid-template-columns: 1fr;
}
.add-steps {
position: static;
}
}
/* Wyśrodkowany kontener ekranu wyboru (hero + dwie karty) - jak na makiecie. */
.add-chooser-view {
margin: 0 auto;
max-width: 1060px;
}
/* Hero ekranu wyboru: tytul z podkresleniem/iskra, podtytul, plakietki i strzalki. */
.add-hero {
padding: 6px 0 4px;
position: relative;
text-align: center;
}
.add-hero-title {
color: #14223a;
font-size: 40px;
font-weight: 950;
letter-spacing: -0.02em;
line-height: 1.1;
margin: 6px 0 0;
}
.add-hero-hl {
color: #12a764;
position: relative;
white-space: nowrap;
}
.add-hero-underline {
color: #16b866;
height: 16px;
left: -1%;
position: absolute;
top: 96%;
width: 102%;
}
.add-hero-spark {
color: #16b866;
height: 26px;
position: absolute;
right: -30px;
top: -12px;
width: 28px;
}
.add-hero-sub {
color: #64748b;
font-size: 15px;
font-weight: 650;
margin: 24px auto 0;
max-width: 560px;
}
.add-hero-badges {
display: flex;
flex-wrap: wrap;
gap: 14px;
justify-content: center;
margin: 24px 0 4px;
}
.add-hero-badge {
align-items: center;
background: #ffffff;
border: 1px solid #edf1f6;
border-radius: 12px;
box-shadow: 0 6px 18px rgba(24, 35, 57, 0.05);
display: flex;
gap: 11px;
padding: 12px 18px;
}
.add-hero-badge-ic {
flex: 0 0 auto;
height: 26px;
width: 26px;
}
.add-hero-badge-ic svg {
height: 26px;
width: 26px;
}
.add-hero-badge strong {
color: #14223a;
display: block;
font-size: 14px;
font-weight: 850;
line-height: 1.2;
}
.add-hero-badge small {
color: #8794a6;
display: block;
font-size: 12px;
font-weight: 650;
margin-top: 2px;
}
.add-hero-arrow {
height: 92px;
position: absolute;
top: 74px;
width: 116px;
}
.add-hero-arrow.left { color: #16b866; left: 4px; }
.add-hero-arrow.right { color: #7c6cf0; right: 4px; }
/* Ekran startowy dodawania oferty: dwie karty "Chcę sprzedać / wynająć" z listą kategorii. */
.add-chooser {
display: grid;
gap: 24px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 26px;
}
.add-chooser-card {
background: #ffffff;
border: 1px solid #edf1f6;
border-radius: 18px;
box-shadow: 0 14px 36px rgba(24, 35, 57, 0.06);
padding: 24px;
}
.add-chooser-head {
align-items: flex-start;
display: flex;
gap: 14px;
margin-bottom: 18px;
}
.add-chooser-badge {
align-items: center;
border-radius: 12px;
display: flex;
flex: 0 0 auto;
height: 46px;
justify-content: center;
width: 46px;
}
.add-chooser-badge svg {
height: 22px;
width: 22px;
}
.add-chooser-card.sell .add-chooser-badge {
background: #e6f8ef;
color: #0f9257;
}
.add-chooser-card.rent .add-chooser-badge {
background: #eee9fd;
color: #6f5be0;
}
.add-chooser-scene {
align-self: flex-start;
flex: 0 0 auto;
height: auto;
margin-left: auto;
width: 150px;
}
.add-chooser-card.sell .add-chooser-scene { color: #7cc6a1; }
.add-chooser-card.rent .add-chooser-scene { color: #a99cea; }
.add-chooser-head h2 {
color: #14223a;
font-size: 20px;
margin: 2px 0 4px;
}
.add-chooser-head p {
color: #6f7f92;
font-size: 13px;
font-weight: 650;
line-height: 1.4;
margin: 0;
}
.add-chooser-list {
display: grid;
gap: 10px;
}
.add-chooser-item {
align-items: center;
background: #ffffff;
border: 1px solid #e3e9f1;
border-radius: 10px;
color: #22314a;
cursor: pointer;
display: flex;
gap: 12px;
padding: 14px 16px;
text-align: left;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
width: 100%;
}
.add-chooser-item:hover {
border-color: #b9c6d6;
box-shadow: 0 6px 16px rgba(24, 35, 57, 0.08);
transform: translateY(-1px);
}
.add-chooser-card.sell .add-chooser-item:hover { border-color: #7fd3aa; }
.add-chooser-card.rent .add-chooser-item:hover { border-color: #b3a6f2; }
.add-chooser-item-icon {
align-items: center;
color: #8793a3;
display: flex;
flex: 0 0 auto;
}
.add-chooser-card.sell .add-chooser-item-icon { color: #0f9257; }
.add-chooser-card.rent .add-chooser-item-icon { color: #6f5be0; }
.add-chooser-item-icon svg { height: 18px; width: 18px; }
.add-chooser-item-label {
flex: 1;
font-size: 14px;
font-weight: 800;
min-width: 0;
}
/* Strzalka wiersza wskazuje w prawo (chevron domyslnie w dol). */
.add-chooser-item > svg:last-child {
color: #aab4c2;
flex: 0 0 auto;
height: 16px;
transform: rotate(-90deg);
width: 16px;
}
.add-change-category {
align-items: center;
background: none;
border: 0;
color: #0f9257;
cursor: pointer;
display: inline-flex;
font-size: 13px;
font-weight: 800;
gap: 4px;
margin-top: 10px;
padding: 0;
}
.add-change-category svg {
height: 14px;
transform: rotate(90deg);
width: 14px;
}
.add-change-category:hover { text-decoration: underline; }
@media (max-width: 1040px) {
.add-hero-arrow {
display: none;
}
}
@media (max-width: 760px) {
.add-chooser {
grid-template-columns: 1fr;
}
.add-hero-title {
font-size: 30px;
}
}
@media (max-width: 480px) {
.add-chooser-scene {
display: none;
}
}
.add-form-card,
.listing-preview-card,
.add-side-card,
@@ -4664,13 +5214,6 @@ a.city-card:hover {
padding-bottom: 18px;
}
.add-location-subtitle {
color: #5f6f83;
font-size: 12px;
font-weight: 750;
margin: -7px 0 18px;
}
.add-location-grid {
display: grid;
gap: 14px;
@@ -4737,6 +5280,52 @@ a.city-card:hover {
width: 256px;
}
/* Przelacznik mapa/satelita w rogu podgladu lokalizacji. */
.real-location-maptype {
background: rgba(255, 255, 255, 0.92);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(24, 35, 57, 0.18);
display: inline-flex;
left: 12px;
overflow: hidden;
position: absolute;
top: 12px;
z-index: 4;
}
.real-location-maptype button {
background: transparent;
border: 0;
color: #45566d;
cursor: pointer;
font-size: 12px;
font-weight: 800;
padding: 7px 14px;
}
.real-location-maptype button.active {
background: #12a764;
color: #ffffff;
}
/* "Plama" przyblizonej lokalizacji - obszar zamiast pinezki. Rozmiar animuje sie przy zmianie zoomu. */
.add-location-area {
animation: addLocationAreaIn 0.35s ease;
background: rgba(18, 167, 100, 0.2);
border: 2px solid rgba(18, 167, 100, 0.8);
border-radius: 50%;
pointer-events: none;
position: absolute;
transform: translate(-50%, -50%);
transition: width 0.3s ease, height 0.3s ease;
z-index: 2;
}
@keyframes addLocationAreaIn {
from { opacity: 0; }
to { opacity: 1; }
}
.real-location-pin {
cursor: grab;
pointer-events: auto;
@@ -4894,6 +5483,25 @@ a.city-card:hover {
padding: 24px;
}
/* Naglowek "Dodaj zdjecia" wewnatrz karty (w ramce razem z zawartoscia). */
.add-card-heading {
margin-bottom: 18px;
}
.add-card-heading h2 {
color: #14223a;
font-size: 22px;
font-weight: 900;
margin: 0;
}
.add-card-heading p {
color: #6f7f92;
font-size: 13px;
font-weight: 650;
margin: 5px 0 0;
}
.photo-upload-box {
align-items: center;
border: 1px dashed #cbd6e2;
@@ -4930,6 +5538,13 @@ a.city-card:hover {
margin-top: 6px;
}
.photo-upload-box .photo-upload-or {
color: #6f7f92;
font-size: 13px;
font-weight: 700;
margin-top: 4px;
}
.photo-tip-note {
align-items: center;
background: #f3fbf7;
@@ -5021,7 +5636,13 @@ a.city-card:hover {
}
.photo-tour-input small {
bottom: auto;
color: #5f748a;
font-size: 11px;
font-weight: 700;
margin-top: 6px;
position: static;
right: auto;
}
.photo-tour-actions {
@@ -5169,25 +5790,40 @@ a.city-card:hover {
.empty-photos-box {
align-items: center;
background: #f8fbfd;
border: 1px solid #e0e8f0;
border: 1px dashed #d4deea;
border-radius: 8px;
color: #526171;
display: flex;
gap: 10px;
min-height: 64px;
padding: 14px;
flex-direction: column;
gap: 6px;
justify-content: center;
min-height: 150px;
padding: 24px;
text-align: center;
}
.empty-photos-box svg {
color: #10a15e;
font-size: 22px;
color: #8fc4a8;
font-size: 30px;
margin-bottom: 4px;
}
.empty-photos-box p {
.empty-photos-graphic {
height: auto;
margin-bottom: 6px;
width: 118px;
}
.empty-photos-box strong {
color: #14223a;
font-size: 14px;
font-weight: 900;
}
.empty-photos-box small {
color: #6f7f92;
font-size: 12px;
font-weight: 800;
line-height: 1.4;
margin: 0;
font-weight: 700;
}
.sort-photos-button {
@@ -11814,19 +12450,6 @@ a.city-card:hover {
display: none;
}
.add-layout {
grid-template-columns: 1fr;
}
.add-sidebar {
grid-template-columns: repeat(2, minmax(0, 1fr));
position: static;
}
.listing-preview-card {
grid-row: span 2;
}
.add-footer-benefits > div {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -33649,11 +34272,13 @@ a.listing-detail-back {
gap: 8px;
}
.add-phone-row select {
flex: 0 0 auto;
max-width: 152px;
padding-left: 10px;
padding-right: 6px;
/* Picker kraju (flaga + kod) - kompaktowy, rownej wysokosci z polem numeru. */
.add-phone-row .phone-country-picker {
flex: 0 0 118px;
}
.add-phone-row .phone-country-trigger {
height: 44px;
}
.add-phone-row input {
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB