import { Fragment, type CSSProperties, type ChangeEvent as ReactChangeEvent, type ClipboardEvent as ReactClipboardEvent, type Dispatch, type DragEvent as ReactDragEvent, type FormEvent as ReactFormEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import './styles.css'; import { Link, NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; import { ROUTES, GUIDE_TOPICS, guideTopicBySlug, guideTopicListPath, articlePath, guideAddPath, listingPath, publicProfilePath, conversationPath, newConversationPath, ADMIN_CONVERSATION_SLUG, publicProfileNickPath, listingEditPath, mapPath, buyCityPath, negotiationPath, priceHistoryPath, VALUATION_STEP_PATHS, companiesPath, advertisingPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; import { guideContents, type GuideContent, type GuideBlock } from './guides-data'; import { ProtectedRoute } from './ProtectedRoute'; import { Avatar, initialsFrom } from './Avatar'; import { apiFetch, useAuth } from './auth'; import type { AccountType, AreaUnit, AuthUser, ContactPreference, Currency, PreferredLanguage, ProfileVisibility, RegisterPending, SearchPreferences, UserSettings, UserSettingsPatch } from './auth'; import { useDisplay } from './display'; import { useNotifications, toDisplayNotifications } from './notifications'; import type { ServerNotification, DisplayNotification } from './notifications'; import { getManualTranslation } from './i18nOverrides'; import { AdminLeadsView, AdminCampaignsView, AdminSmtpConfigView, AdminSmsConfigView } from './admin/marketing'; import { AdminArticleRequestsView } from './admin/articleRequests'; import { RichTextEditor, SafeHtml, stripHtml, isRichTextEmpty } from './RichText'; import { compressImageFile } from './media'; import { BannerSlot } from './Banners'; import { AdminBannersView } from './admin/banners'; import { AdminPromotionView } from './admin/promotion'; import { AdminSettingsView } from './admin/settings'; import { TermsPage, PrivacyPolicyPage, CookieBanner } from './legal'; import { SiteFooter } from './Footer'; import { ActivationPage, PasswordResetPage, PaymentRedirectPage, PhoneOtpModal } from './AuthPages'; const heroImage = new URL('./assets/hero-interior.png', import.meta.url).href; const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href; const loginRoomImage = new URL('./assets/login-room.png', import.meta.url).href; const plLogoLight = new URL('./assets/pl_logo_light.png', import.meta.url).href; const plLogoDark = new URL('./assets/pl_logo_dark.png', import.meta.url).href; // Podswietlenie aktywnej pozycji menu wyznacza NavLink na podstawie adresu. const navLinkClass = ({ isActive }: { isActive: boolean }) => (isActive ? 'active' : ''); type MessageThreadListing = { listingId: number; title: string; price: number; city: string; district: string | null; area: number; rooms: number; floor: string | null; buildingFloors: string | null; thumbnail: string | null; }; type MessageThreadTarget = { id: string; name: string; status: string; // Konto rozmowcy (wlasciciel ogloszenia) - dzieki niemu zgloszenie z rozmowy // wiaze sie z realnym kontem, a nie tylko z nazwa wyswietlana. contactEmail: string | null; contactUserId: number | null; listing: MessageThreadListing; }; // Rozmowy o ogloszeniach z /api/conversations. Pola ogloszenia sa null, gdy zostalo usuniete. type ApiConversationListing = { listingId: number; title: string; available: boolean; price: number | null; city: string | null; district: string | null; area: number | null; rooms: number | null; floor: string | null; buildingFloors: string | null; thumbnail: string | null; }; type ApiConversationBase = { id: number; listing: ApiConversationListing; otherUserId: number; otherUserName: string; otherUserExists: boolean; selling: boolean; }; type ApiConversationSummary = ApiConversationBase & { lastMessage: string; lastMessageMine: boolean; unreadCount: number; lastMessageAt: string; }; type ApiConversationDetail = ApiConversationBase & { blockedByMe: boolean; blocked: boolean; messages: { id: number; content: string; mine: boolean; read: boolean; createdAt: string }[]; }; // Wspolne pola watku w widoku wiadomosci dla rozmowy z serwera. function conversationThreadBase(conversation: ApiConversationBase) { const { listing } = conversation; const status = !conversation.otherUserExists ? 'Konto rozmówcy zostało usunięte' : conversation.selling ? 'Pyta o Twoje ogłoszenie' : 'Sprzedający'; return { id: `conv-${conversation.id}`, conversationId: conversation.id, name: conversation.otherUserName, avatarImage: listing.thumbnail || heroImage, status: listing.available ? status : `${status} • ogłoszenie nieaktywne`, contactUserId: conversation.otherUserExists ? conversation.otherUserId : null, selling: conversation.selling, listing: listing.price == null || listing.area == null || listing.rooms == null ? null : { listingId: listing.listingId, title: listing.title, price: listing.price, city: listing.city ?? '', district: listing.district, area: listing.area, rooms: listing.rooms, floor: listing.floor, buildingFloors: listing.buildingFloors, thumbnail: listing.thumbnail, }, }; } export type ApiOfferType = 'SALE' | 'RENT'; export type ApiPropertyType = 'APARTMENT' | 'HOUSE'; export type ApiListingStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'PAUSED'; // Lekka reprezentacja z list (Kupuje/Wynajmuje, moje ogloszenia) - bez pelnej galerii. export type ApiListingSummary = { id: number; title: string; description: string; offerType: ApiOfferType; propertyType: ApiPropertyType; city: string; district: string | null; address: string | null; price: number; area: number; rooms: number; floor: string | null; buildingFloors: string | null; market: string | null; yearBuilt: number | null; coverPhoto: string | null; ownerEmail: string | null; status: ApiListingStatus; viewsCount: number; promotedUntil: string | null; createdAt: string; }; // Pelne ogloszenie dla widoku szczegolow. export type ApiListingDetail = { id: number; title: string; description: string; offerType: ApiOfferType; propertyType: ApiPropertyType; city: string; district: string | null; street: string | null; building: string | null; address: string | null; price: number; area: number; rooms: number; floor: string | null; buildingFloors: string | null; market: string | null; yearBuilt: number | null; condition: string | null; ownership: string | null; contactName: string | null; contactPhone: string | null; contactEmail: string | null; rentExtra: number | null; availableFrom: string | null; furnishing: string | null; buildingType: string | null; buildingMaterial: string | null; windows: string | null; exposure: string | null; roomHeight: string | null; lat: number | null; lng: number | null; media: string[]; amenities: string[]; photos: string[]; virtualTourUrl: string | null; ownerEmail: string | null; ownerId: number | null; // Zdjecie profilowe wlasciciela - null, gdy go nie ustawil albo ogloszenie nie ma konta w serwisie. ownerAvatar: string | null; // Typ konta wlasciciela - po nim rozrozniamy oferte prywatna od firmowej. null = brak konta w serwisie. ownerAccountType: AccountType | null; // Nazwa wlasciciela - uzywana jako etykieta oferty firmowej. ownerName: string | null; // Data zalozenia konta wlasciciela ("Na Polska Lokalnie od...") - null, gdy ogloszenie nie ma konta. ownerCreatedAt: string | null; // Ostatnia aktywnosc wlasciciela ("Ostatnio aktywny/a...") - null, gdy ogloszenie nie ma konta. ownerLastActiveAt: string | null; status: ApiListingStatus; createdAt: string; viewsCount?: number; promotedUntil?: string | null; // true = wlasciciel publikuje tylko przyblizona lokalizacje (mapa rysuje obszar zamiast punktu). approximateLocation?: boolean; }; // Ostatnio przegladane oferty - trzymane lokalnie (per urzadzenie) do slajdera na stronie glownej. type RecentlyViewedItem = { id: number; title: string; city: string; district: string | null; price: number; offerType: ApiOfferType; area: number; rooms: number; photo: string | null; }; // Historia ogladania jest osobna dla kazdego konta - na wspolnym komputerze jedna osoba // nie moze zobaczyc, czego szukala druga. const RECENTLY_VIEWED_SUFFIX = 'recently-viewed'; // Ile ostatnio otwartych ofert pokazuje panel "Ostatnio przeglądane". const RECENTLY_VIEWED_PANEL_LIMIT = 5; function readRecentlyViewed(user: AuthUser | null): RecentlyViewedItem[] { if (typeof window === 'undefined') { return []; } try { const raw = window.localStorage.getItem(accountStorageKey(RECENTLY_VIEWED_SUFFIX, user)); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function pushRecentlyViewed(item: RecentlyViewedItem, user: AuthUser | null): void { if (typeof window === 'undefined') { return; } try { const list = readRecentlyViewed(user).filter((entry) => entry.id !== item.id); list.unshift(item); window.localStorage.setItem(accountStorageKey(RECENTLY_VIEWED_SUFFIX, user), JSON.stringify(list.slice(0, 12))); } catch { /* brak dostepu do localStorage - pomijamy */ } } type LiveListingSummary = ApiListingSummary & { lat: number | null; lng: number | null; // Pelna galeria z detalu - pobierana i tak dla lat/lng, wiec karta moze pokazac slider zdjec // bez wchodzenia w oferte (jak na Otodom). photos: string[]; // Typ konta wlasciciela i jego nazwa - do etykiety "Oferta prywatna" / oferty firmowej na karcie. ownerAccountType: AccountType | null; ownerName: string | null; // Konto wlasciciela - etykieta oferty firmowej linkuje do jego profilu publicznego (/profil/:id). ownerId: number | null; }; const LIVE_LISTINGS_REFRESH_MS = 15000; type NotificationCategory = 'listing' | 'messages' | 'system'; type FavoriteTab = 'buy' | 'rent'; type FavoriteListing = { id: string; listingId: number; mode: FavoriteTab; image: string; price: string; /** Wartosci zrodlowe (PLN, m²) - po nich sortujemy i z nich budujemy etykiety w wybranej walucie. */ priceValue: number; areaValue: number; isRent: boolean; title: string; location: string; area: string; rooms: string; floor: string; year: string; added: string; photos: number; badge: string; }; type FavoritePriceAlertEvent = { id: string; listingId: number; title: string; location: string; area: string; offerType?: ApiOfferType; previousPrice: number; currentPrice: number; changedAt: string; }; type CityPriceAlertSnapshot = { dayKey: string; perM2: number; updatedAt: string; }; type CityPriceAlertEvent = { id: string; subscriptionKey: string; label: string; city: string; region: string; propertyType: HistoryPropertyType; marketType: HistoryMarketType; previousPerM2: number; currentPerM2: number; changedAt: string; }; type CityPriceAlertSubscription = { key: string; label: string; city: string; region: string; propertyType: HistoryPropertyType; marketType: HistoryMarketType; createdAt: string; // Przelacznik powiadomien dla alertu. Pole opcjonalne - subskrypcje zapisane przed jego // dodaniem nie maja go w localStorage, a brak wartosci traktujemy jako "wlaczone". notificationsEnabled?: boolean; }; type ListingPriceAlertSubscription = { listingId: number; title: string; location: string; offerType: ApiOfferType; createdAt: string; // Jak w alertach lokalizacji: brak wartosci traktujemy jako "powiadomienia wlaczone". notificationsEnabled?: boolean; }; type SavedSearchMode = 'buy' | 'rent'; type SavedSearchRecord = { id: string; mode: SavedSearchMode; city: string; propertyType: string; priceMin: number | null; priceMax: number | null; areaMin: number | null; areaMax: number | null; rooms: number | null; wtorny: boolean; pierwotny: boolean; noFeeOnly: boolean; more: Record; createdAt: string; updatedAt: string; }; const FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX = 'favorite-price-alert-events'; const FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX = 'favorite-price-snapshots'; const FAVORITE_LISTINGS_STORAGE_SUFFIX = 'favorite-listings'; const FAVORITES_PREFERRED_TAB_STORAGE_SUFFIX = 'favorites-preferred-tab'; const CITY_PRICE_ALERTS_STORAGE_SUFFIX = 'city-price-alert-subscriptions'; const LISTING_PRICE_ALERTS_STORAGE_SUFFIX = 'listing-price-alert-subscriptions'; const CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX = 'city-price-alert-snapshots'; const CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX = 'city-price-alert-events'; // Klucz zostaje tylko po to, zeby raz przeniesc na konto wyszukiwania zapisane w przegladarce // przed przeniesieniem tej funkcji na serwer. const SAVED_SEARCHES_STORAGE_SUFFIX = 'saved-searches'; // Zapisane wyszukiwanie w postaci z API - id nadaje serwer. type ApiSavedSearch = { id: number; mode: SavedSearchMode; city: string | null; propertyType: string | null; priceMin: number | null; priceMax: number | null; areaMin: number | null; areaMax: number | null; rooms: number | null; wtorny: boolean; pierwotny: boolean; noFeeOnly: boolean; more: string | null; createdAt: string; updatedAt: string; }; function fromApiSavedSearch(item: ApiSavedSearch): SavedSearchRecord { let more: Record = {}; try { more = item.more ? JSON.parse(item.more) as Record : {}; } catch { more = {}; } return { id: String(item.id), mode: item.mode, city: item.city ?? '', propertyType: item.propertyType ?? '', priceMin: item.priceMin, priceMax: item.priceMax, areaMin: item.areaMin, areaMax: item.areaMax, rooms: item.rooms, wtorny: item.wtorny, pierwotny: item.pierwotny, noFeeOnly: item.noFeeOnly, more, createdAt: item.createdAt, updatedAt: item.updatedAt, }; } function toSavedSearchPayload(search: SavedSearchRecord | SavedSearchDraft) { return { mode: search.mode, city: search.city, propertyType: search.propertyType, priceMin: search.priceMin, priceMax: search.priceMax, areaMin: search.areaMin, areaMax: search.areaMax, rooms: search.rooms, wtorny: search.wtorny, pierwotny: search.pierwotny, noFeeOnly: search.noFeeOnly, more: JSON.stringify(search.more ?? {}), }; } // Lokalizacje, ktorych cene za m2 sprawdzil uzytkownik - najnowsza pierwsza, przechowywane per konto. // Klucz zostaje tylko po to, zeby raz przeniesc na konto lokalizacje zapisane w przegladarce // przed przeniesieniem tej listy na serwer. const PRICE_HISTORY_MARKETS_SUFFIX = 'price-history-markets'; function parsePriceHistoryMarkets(raw: string | null): PriceHistoryMarket[] { if (!raw) { return []; } try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item.key === 'string') : []; } catch { return []; } } /** Lista lokalizacji z Historii cen jest ustawieniem konta - zapisujemy ja przy kazdej zmianie. */ function savePriceHistoryMarkets(markets: PriceHistoryMarket[]): void { apiFetch('/me/settings', { method: 'PUT', body: JSON.stringify({ priceHistoryMarkets: JSON.stringify(markets) }) }) .catch(() => { /* brak sieci - lista wroci do stanu z konta przy nastepnym wejsciu */ }); } const PRICE_HISTORY_MARKETS_LIMIT = 5; const PRICE_ALERT_EVENTS_LIMIT = 40; const SAVED_SEARCHES_LIMIT = 60; function readUserJsonStorage(user: AuthUser | null, suffix: string, fallback: T): T { if (typeof window === 'undefined') { return fallback; } try { const raw = window.localStorage.getItem(accountStorageKey(suffix, user)); if (!raw) { return fallback; } return JSON.parse(raw) as T; } catch { return fallback; } } function writeUserJsonStorage(user: AuthUser | null, suffix: string, value: T): void { if (typeof window === 'undefined') { return; } window.localStorage.setItem(accountStorageKey(suffix, user), JSON.stringify(value)); } function mergePriceAlertEvents( incoming: FavoritePriceAlertEvent[], existing: FavoritePriceAlertEvent[], ): FavoritePriceAlertEvent[] { if (incoming.length === 0) { return existing; } const known = new Set(existing.map((item) => item.id)); const uniqueIncoming = incoming.filter((item) => !known.has(item.id)); if (uniqueIncoming.length === 0) { return existing; } return [...uniqueIncoming, ...existing] .sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime()) .slice(0, PRICE_ALERT_EVENTS_LIMIT); } function currentLocalDayKey(date = new Date()): string { const year = date.getFullYear(); const month = `${date.getMonth() + 1}`.padStart(2, '0'); const day = `${date.getDate()}`.padStart(2, '0'); return `${year}-${month}-${day}`; } function cityAlertStorageKey(subscription: CityPriceAlertSubscription): string { return `${subscription.key}|${subscription.propertyType}|${subscription.marketType}`; } function cityAlertPropertyTypeToApi(propertyType: HistoryPropertyType): ApiPropertyType | null { if (propertyType === 'Mieszkanie') { return 'APARTMENT'; } if (propertyType === 'Dom') { return 'HOUSE'; } return null; } function cityAlertMarketFilter(marketType: HistoryMarketType): 'wtorny' | 'pierwotny' | null { if (marketType === 'Wtórny') { return 'wtorny'; } if (marketType === 'Pierwotny') { return 'pierwotny'; } return null; } // Ogloszenia pasujace do kryteriow alertu (miasto + typ nieruchomosci + rynek). // Wspolne dla sredniej ceny za m² i dla wyboru najlepszej biezacej oferty. function filterCityAlertListings( subscription: CityPriceAlertSubscription, listings: ApiListingSummary[], ): ApiListingSummary[] { const targetPropertyType = cityAlertPropertyTypeToApi(subscription.propertyType); const targetMarket = cityAlertMarketFilter(subscription.marketType); const cityQuery = subscription.city.trim() || subscription.label.split(',')[0]?.trim() || ''; return listings.filter((item) => { if (cityQuery && !cityStartsWith(item.city, cityQuery)) { return false; } if (targetPropertyType && item.propertyType !== targetPropertyType) { return false; } if (targetMarket && item.market !== targetMarket) { return false; } return item.area > 0 && item.price > 0; }); } // Najlepsza oferta dla alertu: najnizsza cena za m² sposrod pasujacych ogloszen. function findBestCityAlertOffer( subscription: CityPriceAlertSubscription, listings: ApiListingSummary[], ): ApiListingSummary | null { const filtered = filterCityAlertListings(subscription, listings); if (filtered.length === 0) { return null; } return filtered.reduce((best, item) => ( (item.price / item.area) < (best.price / best.area) ? item : best )); } // null = brak pasujacych ogloszen, wiec nie ma z czego policzyc sredniej. Wczesniej podstawiana // byla szacunkowa stawka z tabeli, przez co znikniecie ogloszen wygladalo jak zmiana ceny. function calculateCityAlertPerM2(subscription: CityPriceAlertSubscription, listings: ApiListingSummary[]): number | null { const filtered = filterCityAlertListings(subscription, listings); if (filtered.length === 0) { return null; } const averagePerM2 = filtered.reduce((sum, item) => sum + (item.price / item.area), 0) / filtered.length; return Math.max(1, Math.round(averagePerM2)); } function mergeCityPriceAlertEvents( incoming: CityPriceAlertEvent[], existing: CityPriceAlertEvent[], ): CityPriceAlertEvent[] { if (incoming.length === 0) { return existing; } const known = new Set(existing.map((item) => item.id)); const uniqueIncoming = incoming.filter((item) => !known.has(item.id)); if (uniqueIncoming.length === 0) { return existing; } return [...uniqueIncoming, ...existing] .sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime()) .slice(0, PRICE_ALERT_EVENTS_LIMIT); } const favoriteListings: FavoriteListing[] = []; function favoriteListingKey(listingId: number): string { return `listing-${listingId}`; } function favoriteTabFromOfferType(offerType: ApiOfferType): FavoriteTab { return offerType === 'RENT' ? 'rent' : 'buy'; } function toFavoriteListingFromSummary(listing: ApiListingSummary): FavoriteListing { const mode = favoriteTabFromOfferType(listing.offerType); const price = listing.offerType === 'RENT' ? `${formatPln(listing.price)} / mies.` : formatPln(listing.price); const location = [listing.city, listing.district].filter(Boolean).join(', '); const floor = listingFloorText(listing.floor, listing.buildingFloors); return { id: favoriteListingKey(listing.id), listingId: listing.id, mode, image: listing.coverPhoto ?? heroImage, priceValue: listing.price, areaValue: listing.area, isRent: mode === 'rent', price, title: listing.title, location: location || listing.city, area: `${`${listing.area}`.replace('.', ',')} m²`, rooms: listingRoomsText(listing.rooms), floor: floor ? `${floor} piętro` : 'Brak danych o piętrze', year: listing.yearBuilt ? `${listing.yearBuilt} rok` : 'Brak danych o roku budowy', added: listingRelativeDate(listing.createdAt) || 'Dodane niedawno', photos: 1, badge: mode === 'rent' ? 'Na wynajem' : 'Na sprzedaż', }; } function toFavoriteListing(listing: ApiListingDetail): FavoriteListing { const mode = favoriteTabFromOfferType(listing.offerType); const price = listing.offerType === 'RENT' ? `${formatPln(listing.price)} / mies.` : formatPln(listing.price); const location = [listing.city, listing.district].filter(Boolean).join(', '); const floor = listingFloorText(listing.floor, listing.buildingFloors); return { id: favoriteListingKey(listing.id), listingId: listing.id, mode, image: listing.photos[0] ?? heroImage, priceValue: listing.price, areaValue: listing.area, isRent: mode === 'rent', price, title: listing.title, location: location || listing.city, area: `${`${listing.area}`.replace('.', ',')} m²`, rooms: listingRoomsText(listing.rooms), floor: floor ? `${floor} piętro` : 'Brak danych o piętrze', year: listing.yearBuilt ? `${listing.yearBuilt} rok` : 'Brak danych o roku budowy', added: listingRelativeDate(listing.createdAt) || 'Dodane niedawno', photos: Math.max(listing.photos.length, 1), badge: mode === 'rent' ? 'Na wynajem' : 'Na sprzedaż', }; } function buildMessageThreadTargetFromListing(listing: ApiListingDetail, user: AuthUser | null): MessageThreadTarget { const sellerName = listing.contactName?.trim() || 'Administracja Polska Lokalnie'; const isOwner = Boolean(user && listing.ownerEmail && user.email.toLowerCase() === listing.ownerEmail.toLowerCase()); const sellerRole = isOwner ? 'To Twoje ogłoszenie' : 'Oficjalny kontakt zespołem serwisu Polska Lokalnie'; const threadIdBase = listing.ownerEmail?.trim() || sellerName; const threadId = `listing-${listing.id}-${threadIdBase.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; return { id: threadId, name: sellerName, status: sellerRole, contactEmail: listing.ownerEmail ?? null, contactUserId: listing.ownerId ?? null, listing: { listingId: listing.id, title: listing.title, price: listing.price, city: listing.city, district: listing.district, area: listing.area, rooms: listing.rooms, floor: listing.floor, buildingFloors: listing.buildingFloors, thumbnail: listing.photos[0] ?? heroImage, }, }; } const benefits = [ { icon: 'coin', title: '0 zł', text: 'za ogłoszenie', }, { icon: 'chart', title: 'Kupujący bez pośredników', text: 'piszą prosto do Ciebie', }, { icon: 'shuffle', title: 'Dopasowani', text: 'kupujący', }, { icon: 'shield', title: 'Pełna widoczność', text: 'w całej Polsce', }, ]; const buyQuickFilters = [ { icon: 'flame', title: 'Okazje cenowe', text: 'Najlepsze oferty', }, { icon: 'shield', title: 'Bez pośredników', text: 'Oferty prywatne', }, { icon: 'spark', title: 'Nowe dzisiaj', text: 'Dodane w ciągu 24h', }, ]; const insightCards = [ { icon: 'gear', title: 'Wycena mieszkania', text: 'Sprawdź ile warte jest Twoje mieszkanie w 2 minuty', action: 'Wycena za darmo', path: ROUTES.valuation, }, { icon: 'trend', title: 'Historia cen', text: 'Sprawdź jak zmieniały się ceny w okolicy', action: 'Zobacz wykresy', path: ROUTES.priceHistory, }, { icon: 'house', title: 'Ranking dzielnic', text: 'Porównaj dzielnice pod kątem życia i inwestycji', action: 'Zobacz ranking', path: ROUTES.districtRanking, }, { icon: 'percent', title: 'Zdolność kredytowa', text: 'Sprawdź na jaki kredyt Cię stać', action: 'Sprawdź teraz', path: ROUTES.creditCalculator, }, ]; // Ranking obszarow liczony przez backend z opublikowanych ogloszen (/api/listings/areas). type AreaStats = { city: string; district: string | null; listingsCount: number; avgPricePerM2: number; minPricePerM2: number; maxPricePerM2: number; avgArea: number; totalViews: number; totalSaves: number; newLast30Days: number; trendPercent: number | null; lat: number | null; lng: number | null; }; type AreaRanking = { generatedAt: string; totalListings: number; cities: AreaStats[]; districts: AreaStats[]; }; type RankingLevel = 'districts' | 'cities'; type RankingMetric = 'price' | 'popularity' | 'offers' | 'trend'; const RANKING_METRIC_LABELS: Record = { price: 'Najwyższa cena za m²', popularity: 'Największe zainteresowanie', offers: 'Najwięcej ofert', trend: 'Największy wzrost cen', }; const cities = [ { name: 'Warszawa', position: '48% 55%' }, { name: 'Kraków', position: '54% 54%' }, { name: 'Wrocław', position: '42% 58%' }, { name: 'Gdańsk', position: '62% 52%' }, ]; // Normalizacja nazwy miasta jak w backendzie (ListingService.normalizeCity): trim, lower, ł->l, bez znakow diakrytycznych. // Dzieki temu liczenie ogloszen po miescie dopasowuje sie tak samo jak filtr na serwerze. function normalizeCityName(value: string | null | undefined): string { if (!value) { return ''; } return value.trim().toLowerCase().replace(/ł/g, 'l').normalize('NFD').replace(/\p{Diacritic}/gu, ''); } // Polska odmiana rzeczownika "ogloszenie" przez liczbe. function formatListingCountLabel(count: number): string { const formatted = count.toLocaleString('pl-PL'); const mod10 = count % 10; const mod100 = count % 100; if (count === 1) { return `${formatted} ogłoszenie`; } if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) { return `${formatted} ogłoszenia`; } return `${formatted} ogłoszeń`; } const rentQuickFilters = [ { icon: 'sun', title: 'Nowe dzisiaj', text: 'Dodane w ciągu 24h', }, { icon: 'student', title: 'Dla studentów', text: 'Blisko uczelni', }, { icon: 'paw', title: 'Zwierzęta mile widziane', text: 'Przyjazne zwierzętom', }, { icon: 'car', title: 'Z miejscem parkingowym', text: 'Parking dostępny', }, ]; type PropertySearchType = 'Mieszkanie' | 'Dom' | 'Działka' | 'Lokal użytkowy' | 'Pokój'; const POLISH_CITIES = [ 'Warszawa', 'Kraków', 'Łódź', 'Wrocław', 'Poznań', 'Gdańsk', 'Szczecin', 'Bydgoszcz', 'Lublin', 'Białystok', 'Katowice', 'Gdynia', 'Częstochowa', 'Radom', 'Sosnowiec', 'Toruń', 'Kielce', 'Rzeszów', 'Gliwice', 'Zabrze', 'Olsztyn', 'Bielsko-Biała', 'Bytom', 'Zielona Góra', 'Rybnik', 'Ruda Śląska', 'Opole', 'Tychy', 'Gorzów Wielkopolski', 'Dąbrowa Górnicza', 'Elbląg', 'Płock', 'Wałbrzych', 'Włocławek', 'Tarnów', 'Chorzów', 'Koszalin', 'Kalisz', 'Legnica', 'Grudziądz', 'Słupsk', 'Jaworzno', 'Jastrzębie-Zdrój', 'Nowy Sącz', 'Jelenia Góra', 'Siedlce', 'Mysłowice', 'Konin', 'Piła', 'Piotrków Trybunalski', 'Inowrocław', 'Lubin', 'Ostrów Wielkopolski', 'Suwałki', 'Stargard', 'Gniezno', 'Ostrowiec Świętokrzyski', 'Siemianowice Śląskie', 'Głogów', 'Pabianice', 'Leszno', 'Żory', 'Zamość', 'Pruszków', 'Łomża', 'Ełk', 'Tomaszów Mazowiecki', 'Chełm', 'Mielec', 'Kędzierzyn-Koźle', 'Przemyśl', 'Stalowa Wola', 'Tczew', 'Biała Podlaska', 'Bełchatów', 'Świdnica', 'Będzin', 'Zgierz', 'Piekary Śląskie', 'Racibórz', 'Legionowo', 'Ostrołęka', 'Świnoujście', 'Starachowice', 'Zawiercie', 'Wejherowo', 'Puławy', 'Wodzisław Śląski', 'Skierniewice', 'Tarnowskie Góry', 'Starogard Gdański', 'Rumia', 'Kutno', 'Nysa', 'Sieradz', 'Radomsko', 'Otwock', 'Skarżysko-Kamienna', 'Dębica', 'Ciechanów', 'Kalisz', 'Mińsk Mazowiecki', 'Krosno', 'Piaseczno', 'Sopot', 'Świętochłowice', 'Nowy Targ', 'Zakopane', 'Oświęcim', 'Bochnia', 'Brzeg', 'Malbork', 'Kołobrzeg', 'Sanok', 'Jarosław', 'Lubliniec', 'Augustów', 'Władysławowo', 'Gostyń', 'Wągrowiec', 'Żywiec', 'Cieszyn', 'Mława', 'Kraśnik', 'Świdnik', 'Grójec', 'Wieliczka', 'Józefów', 'Marki', 'Ząbki', 'Łuków', 'Hrubieszów', 'Biłgoraj', 'Sandomierz', 'Police', 'Knurów', 'Czeladź', 'Bolesławiec', 'Oleśnica', 'Kłodzko', 'Dzierżoniów', 'Nowa Sól', 'Żary', 'Kościerzyna', 'Chojnice', 'Kwidzyn', 'Lębork', 'Iława', 'Giżycko', 'Działdowo', 'Szczecinek', 'Wałcz', 'Białogard', 'Goleniów', 'Gryfino', 'Międzyrzecz', 'Krotoszyn', 'Jarocin', 'Środa Wielkopolska', 'Turek', 'Koło', 'Września', 'Śrem', 'Rawicz', 'Kościan', ]; const ROOM_WORDS: Record = { 1: 'pokój', 2: 'pokoje', 3: 'pokoje', 4: 'pokoje', 5: 'pokoi' }; const POPULAR_CITIES = ['Warszawa', 'Kraków', 'Wrocław', 'Gdańsk', 'Poznań', 'Łódź']; type SortKey = 'default' | 'new' | 'old' | 'cheap' | 'expensive' | 'small' | 'big'; const SORT_OPTIONS: { value: SortKey; label: string }[] = [ { value: 'default', label: 'Domyślnie' }, { value: 'new', label: 'Data dodania: najnowsze' }, { value: 'old', label: 'Data dodania: najstarsze' }, { value: 'cheap', label: 'Cena: od najniższej' }, { value: 'expensive', label: 'Cena: od najwyższej' }, { value: 'small', label: 'Powierzchnia: od najmniejszej' }, { value: 'big', label: 'Powierzchnia: od największej' }, ]; const PRICE_PRESETS: { label: string; value: number }[] = [ { label: 'do 500k', value: 500000 }, { label: 'do 700k', value: 700000 }, { label: 'do 1 mln', value: 1000000 }, { label: 'do 1,5 mln', value: 1500000 }, { label: 'do 2 mln', value: 2000000 }, ]; const RENT_PRICE_PRESETS: { label: string; value: number }[] = [ { label: 'do 2 tys.', value: 2000 }, { label: 'do 3 tys.', value: 3000 }, { label: 'do 4 tys.', value: 4000 }, { label: 'do 6 tys.', value: 6000 }, { label: 'do 8 tys.', value: 8000 }, ]; const MORE_FILTERS: { key: string; label: string; options: string[] }[] = [ { key: 'pietro', label: 'Piętro', options: ['Dowolne', 'Parter', '1-3 piętro', '4-10 piętro', 'Powyżej 10'] }, { key: 'balkon', label: 'Balkon / Taras', options: ['Dowolne', 'Balkon', 'Taras', 'Loggia', 'Ogródek'] }, { key: 'stan', label: 'Stan mieszkania', options: ['Dowolne', 'Do wykończenia', 'Do odświeżenia', 'Dobry', 'Po remoncie', 'Wysoki standard'] }, { key: 'winda', label: 'Winda', options: ['Dowolne', 'Tak', 'Nie'] }, { key: 'ogrzewanie', label: 'Ogrzewanie', options: ['Dowolne', 'Miejskie', 'Gazowe', 'Elektryczne', 'Pompa ciepła'] }, { key: 'parking', label: 'Miejsce parkingowe', options: ['Dowolne', 'Garaż', 'Hala garażowa', 'Miejsce naziemne', 'Brak'] }, { key: 'czynsz', label: 'Czynsz', options: ['Dowolny', 'do 400 zł', 'do 600 zł', 'do 800 zł', 'do 1000 zł'] }, { key: 'rok', label: 'Rok budowy', options: ['Dowolny', 'Do 1970', '1971-2000', '2001-2015', 'Po 2015'] }, ]; const PRICE_SLIDER_MAX = 2000000; const PRICE_SLIDER_STEP = 25000; const RENT_PRICE_SLIDER_MAX = 15000; const RENT_PRICE_SLIDER_STEP = 100; const AREA_PRESETS: { label: string; min: number; max: number }[] = [ { label: 'do 35 m²', min: 0, max: 35 }, { label: '35-50 m²', min: 35, max: 50 }, { label: '50-70 m²', min: 50, max: 70 }, { label: '70-90 m²', min: 70, max: 90 }, { label: 'pow. 90 m²', min: 90, max: 0 }, ]; const AREA_SLIDER_MAX = 150; const AREA_SLIDER_STEP = 5; const ROOM_OPTIONS = [1, 2, 3, 4, 5]; function roomsLabel(rooms: number): string { return `${rooms} ${ROOM_WORDS[rooms] ?? 'pokoi'}`; } /** * Cena w wartosci bazowej, czyli w zlotowkach. * * Uzywaj tam, gdzie kwota ma NIE isc za ustawieniem waluty uzytkownika: platnosci za promowanie * (AutoPay pobiera zlotowki), panel administratora oraz pola, w ktorych wlasciciel wpisuje cene * oferty. W pozostalych miejscach formatuj przez useDisplay(). */ function formatPln(value: number): string { return `${value.toLocaleString('pl-PL').replace(/\u00a0/g, ' ')} zł`; } /** Cena za metr w wartosci bazowej - odpowiednik formatPln dla stawek za powierzchnie. */ function basePricePerMeter(plnPerSquareMeter: number): string { return `${formatPln(Math.round(plnPerSquareMeter))}/m²`; } /** * Formatery przekazywane funkcjom spoza komponentow - one same nie moga siegnac po useDisplay(), * wiec dostaja gotowe funkcje od komponentu, ktory je wywoluje. */ type PriceFormatters = { formatPrice: (pln: number) => string; formatArea: (squareMeters: number) => string; formatPricePerArea: (plnPerSquareMeter: number) => string; }; /** * Konwertuje ulotny blob/URL zdjecia na skompresowany data URL (JPEG), przeskalowany do maks. * `maxDim` px po dluzszym boku - dzieki temu zdjecia da sie utrwalic w bazie bez rozdmuchiwania jej. */ async function imageUrlToDataUrl(url: string, maxDim = 1280, quality = 0.72): Promise { // Zdjecie juz zapisane w ogloszeniu (skompresowany JPEG) zostaje bez zmian. Ponowne kodowanie przy // kazdej edycji pogarszalo jakosc i zmienialo bajty - serwer uznawal to za podmiane zdjec. if (url.startsWith('data:image/jpeg;base64,')) { return url; } try { const blob = await (await fetch(url)).blob(); const bitmap = await createImageBitmap(blob); const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height)); const width = Math.max(1, Math.round(bitmap.width * scale)); const height = Math.max(1, Math.round(bitmap.height * scale)); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) { return null; } ctx.drawImage(bitmap, 0, 0, width, height); bitmap.close(); return canvas.toDataURL('image/jpeg', quality); } catch { return null; } } /** * Bezpieczny generator ID. crypto.randomUUID istnieje tylko w bezpiecznym kontekscie (HTTPS/localhost); * przy dostepie po IP w sieci lokalnej jest undefined i rzucalby wyjatek, wywalajac apke do bialego ekranu. */ function safeRandomId(): string { const webCrypto = typeof crypto !== 'undefined' ? crypto : undefined; if (webCrypto && typeof webCrypto.randomUUID === 'function') { return webCrypto.randomUUID(); } return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } // slug trafia do adresu listy firm: /firmy?kategoria=remonty const serviceCategories = [ { slug: 'finanse', icon: 'credit-card', title: 'Finanse', text: 'Kredyty hipoteczne, doradcy, ubezpieczenia', }, { slug: 'prawo', icon: 'scale', title: 'Prawo i dokumenty', text: 'Notariusze, prawnicy, rzeczoznawcy', }, { slug: 'remonty', icon: 'paint', title: 'Remont i wykończenie', text: 'Firmy remontowe, fachowcy, projekty wnętrz', }, { slug: 'przeprowadzka', icon: 'truck', title: 'Przeprowadzka', text: 'Firmy przeprowadzkowe, magazyny, transport', }, { slug: 'wyposazenie', icon: 'sofa', title: 'Wyposażenie wnętrz', text: 'Meble, AGD, kuchnie na wymiar, oświetlenie', }, { slug: 'sprzedaz', icon: 'camera', title: 'Sprzedaż nieruchomości', text: 'Fotografia, home staging, wirtualne spacery 3D', }, { slug: 'deweloperzy', icon: 'city', title: 'Deweloperzy i inwestycje', text: 'Nowe inwestycje, deweloperzy, biura sprzedaży', }, { slug: 'inne', icon: 'dots', title: 'Inne usługi', text: 'Usługi dodatkowe związane z nieruchomościami', }, ]; type ServiceCategorySlug = (typeof serviceCategories)[number]['slug']; /** * Polecane firmy na /firmy-i-uslugi. * * Pusto, bo w serwisie nie ma jeszcze ani jednej firmy - zamiast wymyslonych wizytowek z ocenami * i liczba opinii pokazujemy zaproszenie "Szukamy specjalistow". Gdy pojawi sie zrodlo prawdziwych * firm, wystarczy je tu podstawic - karty renderuja sie same, bez zmian w widoku. */ const serviceCompanies: Array<{ icon: string; title: string; rating: string; text: string; city: string; }> = []; /** Tyle wolnych miejsc pokazujemy, gdy nie ma firm - jedno na kazda kolumne siatki. */ const EMPTY_COMPANY_SLOTS = [1, 2, 3, 4]; /** * Baza firm dla listy /firmy. * * Pusta - do serwisu nie dolaczyla jeszcze zadna firma. Zamiast wymyslonych wizytowek z ocenami * i opiniami lista pokazuje wtedy zaproszenie (patrz CompaniesEmptyState). Po podpieciu prawdziwego * zrodla wystarczy je tu podstawic, a filtrowanie po ?kategoria= zadziala bez zmian w widoku. */ const companyDirectory: Array<{ title: string; category: ServiceCategorySlug; rating: string; text: string; city: string; image: string; position: string; }> = []; const serviceSteps = [ { icon: 'group', title: 'Wybierz usługę', text: 'Wybierz kategorię, która Cię interesuje', }, { icon: 'search', title: 'Porównaj firmy', text: 'Sprawdź opinie, ceny i portfolio firm', }, { icon: 'message', title: 'Skontaktuj się', text: 'Napisz lub zadzwoń bezpośrednio do firmy', }, { icon: 'paint', title: 'Zrealizuj usługę', text: 'Ciesz się profesjonalnie wykonaną usługą', }, ]; const sellSteps = [ { icon: 'document', title: 'Dodaj ogłoszenie', text: 'Wypełnij formularz i dodaj zdjęcia', }, { icon: 'calculator', title: 'My wycenimy się resztą', text: 'Twoje ogłoszenie trafia na listę ofert i mapę', }, { icon: 'message', title: 'Otrzymujesz zapytania', text: 'Kupujący kontaktują się bezpośrednio z Tobą', }, { icon: 'scale', title: 'Negocjujesz warunki', text: 'Rozmawiasz i ustalasz szczegóły sprzedaży', }, { icon: 'money', title: 'Sprzedajesz', text: 'Finalizujesz transakcję i odbierasz pieniądze', }, ]; const sellReasons = [ { icon: 'check', title: 'Całkowicie za darmo', text: '0 zł prowizji, 0 zł opłat', }, { icon: 'target', title: 'Kupujący z całej Polski', text: 'Ogłoszenie widoczne dla każdego, kto szuka w Twojej okolicy', }, { icon: 'spark', title: 'Bezpośredni kontakt', text: 'Kupujący pisze i dzwoni prosto do Ciebie, bez pośrednika', }, { icon: 'shield', title: 'Pełna kontrola', text: 'To Ty decydujesz z kim rozmawiasz i na jakich warunkach', }, { icon: 'warning', title: 'Wsparcie na każdym etapie', text: 'Poradniki, wycena i kontakt z obsługą serwisu', }, ]; // Tylko to, co serwis naprawde robi z ogloszeniem - bez integracji, ktorych nie ma. const sellChannels = [ { icon: 'logo', title: 'Lista ofert', text: 'Wyszukiwarka i filtry', }, { icon: 'pin', title: 'Mapa ofert', text: 'Widoczne w okolicy', }, { icon: 'message', title: 'Podgląd linku', text: 'Zdjęcie i cena w komunikatorach', }, { icon: 'star', title: 'Wyróżnienie', text: 'Płatne miejsce na górze listy', }, ]; const sellFeatures = [ { icon: 'star', title: 'Wyróżnione ogłoszenie', text: 'Twoje ogłoszenie na szczycie listy', }, { icon: 'camera', title: 'Galeria zdjęć', text: 'Dodaj do 8 zdjęć nieruchomości', }, { icon: 'tour', title: 'Wirtualny spacer 360°', text: 'Pokaż mieszkanie w 3D', }, { icon: 'chart', title: 'Statystyki oglądalności', text: 'Sprawdzaj zainteresowanie', }, { icon: 'bell', title: 'Powiadomienia o nowych', text: 'Bądź pierwszy w kontaktach', }, ]; const sellTestimonials = [ { name: 'Anna, Kraków', result: 'Sprzedane w 6 dni', text: 'Dodałam ogłoszenie w poniedziałek, w sobotę miałam już kupca! Zero prowizji, świetny kontakt.', }, { name: 'Marek, Warszawa', result: 'Sprzedane w 4 dni', text: 'Najlepszy portal do sprzedaży mieszkań. Dużo zapytań i poważnych kupujących.', }, { name: 'Kasia, Wrocław', result: 'Sprzedane w 7 dni', text: 'Prosty formularz, szybka publikacja i bardzo wszystkie zero kosztów. Polecam!', }, ]; const addProgressSteps = ['Szczegóły', 'Lokalizacja', 'Zdjęcia', 'Dodatkowe informacje', 'Podgląd ogłoszenia']; // Minimalna dlugosc opisu ogloszenia w znakach - warunek przejscia z kroku "Szczegóły" dalej. const MIN_DESCRIPTION_CHARS = 30; const listingTips = [ { icon: 'document', title: 'Napisz szczegółowy opis', text: 'Im więcej informacji, tym większe zainteresowanie.', }, { icon: 'money', title: 'Ustal dokładną cenę', text: 'Sprawdź ceny podobnych mieszkań w okolicy.', }, { icon: 'lock', title: 'Bądź w kontakcie', text: 'Szybkie odpowiedzi zwiększają szanse na sprzedaż.', }, ]; const extraInfoTips = [ { icon: 'document', title: 'Im więcej informacji, tym lepiej', text: 'Szczegółowy opis zwiększa zaufanie i zainteresowanie ofertą.', }, { icon: 'shield', title: 'Sprawdź dokładnie dane', text: 'Upewnij się, że wszystkie informacje są poprawne.', }, { icon: 'star', title: 'Wyróżnij atuty nieruchomości', text: 'Dodatkowe udogodnienia mogą przyciągnąć kupujących.', }, ]; const mediaOptions = [ { icon: 'flame', label: 'Ogrzewanie miejskie', checked: true }, { icon: 'flame', label: 'Ogrzewanie gazowe' }, { icon: 'house', label: 'Ogrzewanie elektryczne' }, { icon: 'building', label: 'Ogrzewanie podłogowe' }, { icon: 'sun', label: 'Woda miejska', checked: true }, { icon: 'link', label: 'Kanalizacja', checked: true }, { icon: 'link', label: 'Gospodarka ściekowa' }, { icon: 'spark', label: 'Prąd', checked: true }, { icon: 'flame', label: 'Gaz' }, { icon: 'wifi', label: 'Internet', checked: true }, { icon: 'wifi', label: 'Światłowód' }, { icon: 'screen', label: 'Telewizja kablowa' }, { icon: 'shield', label: 'Alarm' }, { icon: 'phone', label: 'Domofon' }, { icon: 'screen', label: 'Wideodomofon' }, { icon: 'spark', label: 'Smart Home' }, { icon: 'sun', label: 'Fotowoltaika' }, { icon: 'snow', label: 'Pompa ciepła' }, { icon: 'building', label: 'Rekuperacja' }, { icon: 'snow', label: 'Klimatyzacja' }, ]; const RENT_BILLING_OPTIONS_HEATING = ['Wliczone w cenę', 'Według zużycia', 'Stała opłata']; const RENT_BILLING_OPTIONS_WATER = ['Wliczona w cenę', 'Według zużycia', 'Stała opłata']; const RENT_BILLING_OPTIONS_ENERGY = ['Wliczony w cenę', 'Według zużycia', 'Stała opłata']; const RENT_BILLING_OPTIONS_INTERNET = ['Wliczony w cenę', 'Dodatkowo płatny (kwota)']; const RENT_BILLING_OPTIONS_TV = ['Wliczona w cenę', 'Dodatkowo płatna (kwota)']; const RENT_BILLING_OPTIONS_PARKING = ['W cenie', 'Dodatkowo płatny (kwota/mies.)']; function billingRequiresAmount(mode: string): boolean { return mode === 'Stała opłata' || mode.includes('(kwota'); } function getRentBillingOptions(label: string): string[] | null { if (label.startsWith('Ogrzewanie')) { return RENT_BILLING_OPTIONS_HEATING; } if (label === 'Woda miejska') { return RENT_BILLING_OPTIONS_WATER; } if (label === 'Prąd' || label === 'Gaz') { return RENT_BILLING_OPTIONS_ENERGY; } if (label === 'Internet') { return RENT_BILLING_OPTIONS_INTERNET; } if (label === 'Telewizja kablowa') { return RENT_BILLING_OPTIONS_TV; } return null; } const amenityOptions = [ { icon: 'building', label: 'Balkon', checked: true }, { icon: 'building', label: 'Taras' }, { icon: 'wifi', label: 'Ogródek' }, { icon: 'city', label: 'Winda', checked: true }, { icon: 'car', label: 'Parking', checked: true }, { icon: 'car', label: 'Garaż' }, { icon: 'building', label: 'Strych' }, { icon: 'shield', label: 'Ogrodzenie' }, { icon: 'student', label: 'Dla studentów' }, { icon: 'paw', label: 'Zwierzęta mile widziane' }, { icon: 'building', label: 'Piwnica', checked: true }, { icon: 'building', label: 'Komórka lokatorska' }, { icon: 'document', label: 'Dostęp dla niepełnosprawnych' }, { icon: 'eye', label: 'Monitoring' }, { icon: 'shield', label: 'Ochrona' }, ]; const houseAmenityOptions = [ { icon: 'flame', label: 'Kominek' }, { icon: 'sun', label: 'Basen' }, { icon: 'sun', label: 'Jacuzzi' }, { icon: 'snow', label: 'Sauna' }, { icon: 'sun', label: 'Ogród zimowy' }, { icon: 'house', label: 'Altana' }, { icon: 'car', label: 'Automatyczna brama' }, { icon: 'shield', label: 'Rolety zewnętrzne' }, { icon: 'eye', label: 'Monitoring zewnętrzny' }, { icon: 'shield', label: 'System alarmowy' }, { icon: 'sun', label: 'Panele fotowoltaiczne' }, { icon: 'snow', label: 'Pompa ciepła' }, { icon: 'spark', label: 'Magazynek energii' }, { icon: 'car', label: 'Ładowarka do samochodu elektrycznego' }, { icon: 'flame', label: 'Grill murowany' }, { icon: 'sun', label: 'Oczko wodne' }, { icon: 'student', label: 'Plac zabaw' }, { icon: 'target', label: 'Boisko' }, ]; const extraInfoSelectOptions = { rentalPeriod: ['Wybierz', 'Krótkoterminowy', 'Długoterminowy', 'Bez określonego czasu'], furnishing: ['Wybierz', 'Tak', 'Częściowo', 'Nie'], buildingType: ['Wybierz', 'Blok', 'Kamienica', 'Apartamentowiec', 'Dom wielorodzinny', 'Loft'], houseType: ['Wybierz', 'Wolnostojący', 'Bliźniak', 'Szeregowy', 'Segment skrajny', 'Segment środkowy', 'Dworek', 'Rezydencja'], buildingMaterial: ['Wybierz', 'Cegła', 'Pustak', 'Beton', 'Drewno', 'Keramzyt', 'Silikat', 'Prefabrykat', 'Inny'], windows: ['Wybierz', 'PCV', 'Drewniane', 'Aluminiowe'], exposure: ['Wybierz', 'Północ', 'Południe', 'Wschód', 'Zachód', 'Północ-południe', 'Wschód-zachód', 'Wielostronna'], houseFloors: ['Wybierz', 'Parterowy', '1', '2', '3+'], roofType: ['Wybierz', 'Dwuspadowy', 'Czterospadowy', 'Płaski', 'Wielospadowy'], roofCover: ['Wybierz', 'Dachówka', 'Blachodachówka', 'Blacha', 'Papa', 'Gont', 'Inne'], garageDetails: ['Wybierz', '1 stanowisko', '2 stanowiska', '3+', 'W bryle budynku', 'Wolnostojący'], basement: ['Wybierz', 'Tak', 'Częściowe', 'Nie'], attic: ['Wybierz', 'Użytkowy', 'Nieużytkowy', 'Brak'], fencing: ['Wybierz', 'Tak', 'Częściowe', 'Nie'], accessRoad: ['Wybierz', 'Asfalt', 'Kostka', 'Utwardzony', 'Szutrowy', 'Polny'], sewageType: ['Wybierz', 'Kanalizacja', 'Szambo', 'Przydomowa oczyszczalnia'], // Nowe slowniki dla dynamicznej sekcji "Dodatkowe informacje" (per kategoria). landType: ['Wybierz', 'Budowlana', 'Rolna', 'Rekreacyjna', 'Leśna', 'Inwestycyjna', 'Siedliskowa', 'Rolno-budowlana'], landPurpose: ['Wybierz', 'Mieszkaniowa', 'Usługowa', 'Przemysłowa', 'Rolnicza', 'Rekreacyjna', 'Mieszana'], planningStatus: ['Wybierz', 'MPZP', 'Warunki zabudowy (WZ)', 'Brak / w trakcie'], plotShape: ['Wybierz', 'Prostokąt', 'Kwadrat', 'Trapez', 'Wąska i długa', 'Nieregularny'], localPurpose: ['Wybierz', 'Handlowy', 'Usługowy', 'Biurowy', 'Gastronomiczny', 'Magazynowy', 'Medyczny', 'Produkcyjny', 'Inny'], buildingKind: ['Wybierz', 'Biurowy', 'Handlowy', 'Magazynowy', 'Produkcyjny', 'Usługowy', 'Magazynowo-biurowy', 'Hotelowy'], garageType: ['Wybierz', 'Garaż murowany', 'Hala garażowa', 'Miejsce postojowe', 'Blaszak', 'Podziemny'], garageLevel: ['Wybierz', 'Naziemny', 'Podziemny (-1)', 'Podziemny (-2)', 'Wielopoziomowy'], legalStatus: ['Wybierz', 'Uregulowany', 'Do uregulowania', 'Z obciążeniem hipotecznym'], landRegister: ['Wybierz', 'Tak', 'Nie', 'W trakcie zakładania'], roommates: ['Wybierz', 'Brak', '1', '2', '3', '4+'], roommatePreference: ['Wybierz', 'Bez znaczenia', 'Kobiety', 'Mężczyźni', 'Studenci', 'Osoby pracujące'], }; // --------------------------------------------------------------------------------------------- // Dynamiczna sekcja "Dodatkowe informacje" (krok 4 kreatora). // Dla kazdej kategorii pokazujemy tylko pola, ktore realnie maja sens dla kupujacego/najemcy. // Kafelki (checkboxy) zapisuja sie przez tablice media[]/amenities[] i sa widoczne na ogloszeniu. // Selecty/pola tekstowe zapisuja sie tam, gdzie istnieje kolumna w bazie (furnishing, buildingType, // buildingMaterial, windows, exposure, roomHeight, availableFrom); pozostale zyja w podgladzie kreatora. // --------------------------------------------------------------------------------------------- // Ikony kafelkow: najpierw z istniejacych list opcji, potem uzupelnienie dla nowych etykiet. const EXTRA_TILE_ICONS: Record = { ...Object.fromEntries([...mediaOptions, ...amenityOptions, ...houseAmenityOptions].map((option) => [option.label, option.icon])), Klimatyzacja: 'snow', 'Wentylacja mechaniczna': 'snow', 'Wejście z ulicy': 'pin', Witryna: 'screen', 'Zaplecze socjalne': 'sofa', 'Dostęp 24/7': 'gear', 'Plac manewrowy': 'truck', 'Rampa rozładunkowa': 'truck', 'Brama automatyczna': 'car', Oświetlenie: 'sun', 'Kanał samochodowy': 'car', 'Media przy granicy': 'link', 'Dostęp do drogi publicznej': 'pin', 'Widok na las/wodę': 'tree', 'Dostęp do kuchni': 'flame', 'Dostęp do łazienki': 'sun', 'Osobna łazienka': 'shield', Pralka: 'snow', Ogród: 'tree', }; const tileIcon = (label: string): string => EXTRA_TILE_ICONS[label] ?? 'spark'; type ExtraStore = 'extra' | 'details'; type ExtraFieldDef = { kind: 'select' | 'input' | 'date'; store: ExtraStore; key: string; label: string; options?: string[]; suffix?: string; placeholder?: string; numeric?: 'integer' | 'decimal'; }; const O = extraInfoSelectOptions; const sel = (key: string, label: string, options: string[]): ExtraFieldDef => ({ kind: 'select', store: 'extra', key, label, options }); const numField = (key: string, label: string, suffix?: string, placeholder?: string, numeric: 'integer' | 'decimal' = 'decimal'): ExtraFieldDef => ({ kind: 'input', store: 'extra', key, label, suffix, placeholder, numeric }); const txtField = (key: string, label: string, placeholder?: string): ExtraFieldDef => ({ kind: 'input', store: 'extra', key, label, placeholder }); const dateField = (key: string, label: string): ExtraFieldDef => ({ kind: 'date', store: 'extra', key, label }); // Pola najmu wspolne dla wiekszosci kategorii (prepend przy transakcji "Wynajem"). const RENT_LEAD_FIELDS: ExtraFieldDef[] = [ sel('rentalPeriod', 'Okres najmu', O.rentalPeriod), numField('deposit', 'Kaucja', 'zł', 'Np. 6000'), dateField('availableFrom', 'Dostępne od'), ]; // Pola prawne wazne przy zakupie (append przy transakcji "Sprzedaż"). const SALE_LEGAL_FIELDS: ExtraFieldDef[] = [ sel('legalStatus', 'Stan prawny', O.legalStatus), sel('landRegister', 'Księga wieczysta', O.landRegister), ]; type CategoryExtra = { fields: ExtraFieldDef[]; media: string[]; amenities: string[]; rentAmenities?: string[]; legal?: boolean; // dolacz pola prawne przy sprzedazy rentLead?: boolean; // dolacz pola najmu przy wynajmie (domyslnie true) }; const CATEGORY_EXTRA: Record = { Mieszkanie: { legal: true, fields: [ sel('buildingType', 'Typ zabudowy', O.buildingType), sel('furnishing', 'Umeblowanie', O.furnishing), sel('buildingMaterial', 'Materiał budynku', O.buildingMaterial), sel('windows', 'Okna', O.windows), sel('exposure', 'Ekspozycja okien', O.exposure), numField('roomHeight', 'Wysokość pomieszczeń', 'cm', 'Np. 270', 'integer'), ], media: ['Ogrzewanie miejskie', 'Ogrzewanie gazowe', 'Ogrzewanie elektryczne', 'Ogrzewanie podłogowe', 'Woda miejska', 'Prąd', 'Gaz', 'Internet', 'Światłowód', 'Telewizja kablowa', 'Klimatyzacja'], amenities: ['Balkon', 'Taras', 'Winda', 'Parking', 'Garaż', 'Piwnica', 'Komórka lokatorska', 'Monitoring', 'Ochrona', 'Dostęp dla niepełnosprawnych'], rentAmenities: ['Dla studentów', 'Zwierzęta mile widziane'], }, Dom: { legal: true, fields: [ numField('plotArea', 'Powierzchnia działki', 'm²', 'Np. 650'), sel('buildingMaterial', 'Materiał', O.buildingMaterial), sel('roofType', 'Dach', O.roofType), sel('roofCover', 'Pokrycie dachu', O.roofCover), sel('furnishing', 'Umeblowanie', O.furnishing), sel('windows', 'Okna', O.windows), sel('exposure', 'Ekspozycja', O.exposure), sel('accessRoad', 'Dojazd', O.accessRoad), sel('fencing', 'Ogrodzenie', O.fencing), sel('sewageType', 'Kanalizacja / szambo', O.sewageType), ], media: ['Ogrzewanie gazowe', 'Ogrzewanie elektryczne', 'Ogrzewanie podłogowe', 'Woda miejska', 'Prąd', 'Gaz', 'Internet', 'Światłowód', 'Pompa ciepła', 'Fotowoltaika', 'Rekuperacja', 'Klimatyzacja'], amenities: ['Ogród', 'Kominek', 'Basen', 'Sauna', 'Ogród zimowy', 'Altana', 'Automatyczna brama', 'Rolety zewnętrzne', 'Monitoring zewnętrzny', 'System alarmowy', 'Ładowarka do samochodu elektrycznego', 'Grill murowany', 'Plac zabaw'], rentAmenities: ['Dla studentów', 'Zwierzęta mile widziane'], }, Działka: { legal: true, fields: [ sel('landType', 'Typ działki', O.landType), sel('landPurpose', 'Przeznaczenie', O.landPurpose), sel('planningStatus', 'Plan / warunki zabudowy', O.planningStatus), sel('plotShape', 'Kształt działki', O.plotShape), txtField('plotDimensions', 'Wymiary', 'Np. 20 × 40 m'), sel('accessRoad', 'Dojazd', O.accessRoad), sel('fencing', 'Ogrodzenie', O.fencing), ], media: ['Prąd', 'Woda miejska', 'Gaz', 'Kanalizacja'], amenities: ['Media przy granicy', 'Dostęp do drogi publicznej', 'Widok na las/wodę'], }, 'Lokal użytkowy': { legal: true, fields: [ sel('localPurpose', 'Przeznaczenie lokalu', O.localPurpose), numField('roomHeight', 'Wysokość pomieszczeń', 'cm', 'Np. 320', 'integer'), sel('furnishing', 'Wyposażenie', O.furnishing), ], media: ['Ogrzewanie miejskie', 'Ogrzewanie gazowe', 'Ogrzewanie elektryczne', 'Woda miejska', 'Prąd', 'Gaz', 'Internet', 'Światłowód', 'Klimatyzacja', 'Wentylacja mechaniczna', 'Alarm'], amenities: ['Wejście z ulicy', 'Witryna', 'Zaplecze socjalne', 'Parking', 'Dostęp 24/7', 'Monitoring', 'Ochrona', 'Dostęp dla niepełnosprawnych'], }, 'Budynek użytkowy': { legal: true, fields: [ sel('buildingKind', 'Rodzaj obiektu', O.buildingKind), numField('plotArea', 'Powierzchnia działki', 'm²', 'Np. 1500'), numField('roomHeight', 'Wysokość', 'cm', 'Np. 450', 'integer'), ], media: ['Ogrzewanie gazowe', 'Ogrzewanie elektryczne', 'Woda miejska', 'Prąd', 'Gaz', 'Kanalizacja', 'Internet', 'Światłowód', 'Klimatyzacja', 'Wentylacja mechaniczna'], amenities: ['Plac manewrowy', 'Rampa rozładunkowa', 'Parking', 'Brama automatyczna', 'Monitoring', 'Ochrona', 'Dostęp 24/7'], }, Garaż: { legal: true, fields: [ sel('garageType', 'Typ garażu', O.garageType), sel('garageLevel', 'Poziom', O.garageLevel), ], media: ['Prąd'], amenities: ['Oświetlenie', 'Brama automatyczna', 'Monitoring', 'Ochrona', 'Dostęp 24/7', 'Kanał samochodowy'], }, Pokój: { rentLead: true, fields: [ sel('roommates', 'Liczba współlokatorów', O.roommates), sel('roommatePreference', 'Preferencje współlokatorów', O.roommatePreference), sel('furnishing', 'Umeblowanie', O.furnishing), ], media: ['Internet', 'Światłowód', 'Telewizja kablowa'], amenities: ['Dostęp do kuchni', 'Dostęp do łazienki', 'Osobna łazienka', 'Balkon', 'Pralka', 'Parking', 'Dla studentów', 'Zwierzęta mile widziane'], }, }; // Zbiera pola/kafelki dla wybranej kategorii i transakcji. Nieznana kategoria -> jak mieszkanie. function resolveCategoryExtra(category: string, isRent: boolean): { fields: ExtraFieldDef[]; mediaTiles: string[]; amenityTiles: string[] } { const cfg = CATEGORY_EXTRA[category] ?? CATEGORY_EXTRA.Mieszkanie; const fields: ExtraFieldDef[] = [ ...(isRent && cfg.rentLead !== false ? RENT_LEAD_FIELDS : []), ...cfg.fields, ...(!isRent && cfg.legal ? SALE_LEGAL_FIELDS : []), ]; const amenityTiles = [...cfg.amenities, ...(isRent ? (cfg.rentAmenities ?? []) : [])]; return { fields, mediaTiles: cfg.media, amenityTiles }; } // Klucze extraInfo z wlasna kolumna w bazie - tylko te czyscimy przy zmianie kategorii, bo tylko // one moglyby wyciec do zapisanego ogloszenia (reszta pol zyje wylacznie w podgladzie kreatora). const PERSISTED_EXTRA_SELECTS = ['furnishing', 'buildingType', 'buildingMaterial', 'windows', 'exposure', 'roomHeight'] as const; // Ekran startowy dodawania oferty: dwie karty (sprzedaż/wynajem) z listą kategorii. Wybór ustawia // transakcje i rodzaj nieruchomosci w formularzu. Rodzaje spoza "Mieszkanie"/"Dom" backend zapisuje // jako APARTMENT (enum ma tylko APARTMENT/HOUSE) - etykieta zyje w formularzu, logika sie zgadza. 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 = { 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 = { 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 ( ); } function AddSceneIllustration() { return ( ); } // 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 (

Dodaj ogłoszenie{' '} za darmo

Wystaw swoje ogłoszenie w kilka chwil – bez opłat i ukrytych kosztów.

100% darmoweBez prowizji i opłat
BezpiecznieTwoje dane są chronione
Szybko i łatwoTylko kilka kroków
); } // 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 (
{ADD_INTENT_CARDS.map((card) => (

{card.title}

{card.subtitle}

{card.items.map((item) => ( ))}
))}
); } // 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 ( ); } const listingDetailsSelectOptions = { transaction: ['Sprzedaż', 'Wynajem'], 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+'], houseGarage: ['Wybierz', 'Brak', 'Jednostanowiskowy', 'Dwustanowiskowy', 'Trzystanowiskowy+', 'W bryle budynku', 'Wolnostojący'], houseBasement: ['Wybierz', 'Tak', 'Nie', 'Częściowe'], houseAttic: ['Wybierz', 'Użytkowy', 'Nieużytkowy', 'Brak'], rooms: ['Wybierz', '1', '2', '3', '4', '5', '6+'], floor: ['Wybierz', 'Parter', '1', '2', '3', '4', '5', '6', '7', '8+', 'Poddasze'], buildingFloors: ['Wybierz', '1', '2', '3', '4', '5', '6', '7', '8+'], condition: ['Wybierz', 'Do wprowadzenia', 'Po remoncie', 'Do odświeżenia', 'Do remontu', 'Deweloperski'], ownership: ['Wybierz', 'Pełna własność', 'Spółdzielcze własnościowe', 'Użytkowanie wieczyste', 'Udział'], }; const INITIAL_LISTING_DETAILS = { transaction: 'Sprzedaż', propertyType: 'Mieszkanie', market: 'Wtórny', title: '', description: '', price: '', area: '', rooms: 'Wybierz', houseType: 'Wybierz', houseGarage: 'Wybierz', houseBasement: 'Wybierz', houseAttic: 'Wybierz', floor: 'Wybierz', buildingFloors: 'Wybierz', houseFloors: 'Wybierz', yearBuilt: '', condition: 'Wybierz', ownership: 'Wybierz', contactName: '', // Prefiks kraju trzymamy osobno od numeru krajowego - razem trafiaja do pola contactPhone oferty. contactPhoneCountry: 'PL', contactPhone: '', contactEmail: '', }; /** * Prefiksy telefoniczne dostepne przy ogloszeniu - Polska i kraje najczestsze na naszym rynku. * Liczba cyfr dotyczy numeru komorkowego bez prefiksu (dla Polski 9, np. +48 600 123 456). */ const PHONE_COUNTRIES = [ { code: 'PL', dial: '+48', label: 'Polska', minDigits: 9, maxDigits: 9, example: '600 123 456' }, { code: '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: '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); const digits = value.replace(/\D/g, ''); if (!digits) { return null; } if (digits.length < country.minDigits || digits.length > country.maxDigits) { const expected = country.minDigits === country.maxDigits ? `${country.minDigits} cyfr` : `od ${country.minDigits} do ${country.maxDigits} cyfr`; return `Numer komórkowy dla kraju ${country.label} (${country.dial}) ma ${expected}. Podano ${digits.length}.`; } return null; } // Zapis do oferty: "+48 600 123 456". Dziewieciocyfrowe numery grupujemy po trzy dla czytelnosci. function composeContactPhone(countryCode: string, value: string): string | undefined { const country = phoneCountryOf(countryCode); const digits = value.replace(/\D/g, ''); if (!digits) { return undefined; } const grouped = digits.length === 9 ? digits.replace(/(\d{3})(\d{3})(\d{3})/, '$1 $2 $3') : digits; return `${country.dial} ${grouped}`; } // Odwrotnosc composeContactPhone przy wczytywaniu oferty do edycji (najdluzszy pasujacy prefiks wygrywa). function splitContactPhone(stored: string | null | undefined): { country: string; number: string } { const raw = (stored ?? '').replace(/[\s-]/g, ''); const match = [...PHONE_COUNTRIES] .sort((a, b) => b.dial.length - a.dial.length) .find((country) => raw.startsWith(country.dial)); if (match) { return { country: match.code, number: match.dial === '+48' ? formatPolishPhone(raw.slice(match.dial.length)) : raw.slice(match.dial.length) }; } return { country: 'PL', number: formatPolishPhone(raw.replace(/^\+/, '')) }; } const formatPolishPhone = (digits: string) => (digits.length === 9 ? digits.replace(/(\d{3})(\d{3})(\d{3})/, '$1 $2 $3') : digits); const INITIAL_EXTRA_INFO = { rent: '', deposit: '', rentalPeriod: 'Wybierz', mediaBilling: {} as Record, mediaBillingAmounts: {} as Record, availableFrom: '', plotArea: '', furnishing: 'Wybierz', buildingType: 'Wybierz', buildingMaterial: 'Wybierz', roofType: 'Wybierz', roofCover: 'Wybierz', garageDetails: 'Wybierz', basement: 'Wybierz', attic: 'Wybierz', fencing: 'Wybierz', accessRoad: 'Wybierz', sewageType: 'Wybierz', windows: 'Wybierz', exposure: 'Wybierz', roomHeight: '', // Pola kategorii-specyficzne dodane do dynamicznej sekcji "Dodatkowe informacje". Trzymane na // froncie (podglad kreatora); do bazy trafiaja tylko te, ktore maja swoja kolumne (furnishing, // buildingType, buildingMaterial, windows, exposure, roomHeight, availableFrom) oraz tagi media/amenities. landType: 'Wybierz', landPurpose: 'Wybierz', planningStatus: 'Wybierz', plotShape: 'Wybierz', plotDimensions: '', localPurpose: 'Wybierz', buildingKind: 'Wybierz', garageType: 'Wybierz', garageLevel: 'Wybierz', legalStatus: 'Wybierz', landRegister: 'Wybierz', roommates: 'Wybierz', roommatePreference: 'Wybierz', virtualTourUrl: '', }; type VirtualTourEmbedInfo = { embedUrl: string; providerLabel: string; }; function normalizeVirtualTourInput(rawValue: string): string { const trimmed = rawValue.trim(); if (!trimmed) { throw new Error('Wklej pełny link do wirtualnego spaceru.'); } let parsed: URL; try { parsed = new URL(trimmed); } catch { throw new Error('Podaj poprawny adres URL spaceru (http:// lub https://).'); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error('Link do spaceru musi zaczynać się od http:// lub https://.'); } return parsed.toString(); } function getVirtualTourEmbed(url: string): VirtualTourEmbedInfo | null { let parsed: URL; try { parsed = new URL(url); } catch { return null; } const host = parsed.hostname.toLowerCase(); const pathParts = parsed.pathname.split('/').filter(Boolean); if (host === 'youtu.be' || host.endsWith('.youtu.be')) { const id = pathParts[0]; if (id) { return { embedUrl: `https://www.youtube.com/embed/${encodeURIComponent(id)}`, providerLabel: 'YouTube', }; } } if (host === 'youtube.com' || host.endsWith('.youtube.com')) { const videoId = parsed.searchParams.get('v') || (pathParts[0] === 'embed' ? pathParts[1] : null) || (pathParts[0] === 'shorts' ? pathParts[1] : null); if (videoId) { return { embedUrl: `https://www.youtube.com/embed/${encodeURIComponent(videoId)}`, providerLabel: 'YouTube', }; } } if (host.endsWith('matterport.com')) { const modelId = parsed.searchParams.get('m'); if (modelId) { return { embedUrl: `https://my.matterport.com/show/?m=${encodeURIComponent(modelId)}`, providerLabel: 'Matterport', }; } if (pathParts[0] === 'show') { return { embedUrl: parsed.toString(), providerLabel: 'Matterport', }; } } if (host.endsWith('kuula.co')) { if (pathParts[0] === 'post' && pathParts[1]) { return { embedUrl: `https://kuula.co/share/${encodeURIComponent(pathParts[1])}?fs=1&vr=1&thumbs=1&info=1&logo=1`, providerLabel: 'Kuula', }; } if (pathParts[0] === 'share' && pathParts[1]) { return { embedUrl: parsed.toString(), providerLabel: 'Kuula', }; } } return null; } const addFooterBenefits = [ { icon: 'lock', title: 'Szyfrowane dane', text: 'Zabezpieczamy 100% przesyłanych informacji.', }, { icon: 'group', title: 'Bez pośredników', text: 'Kupujący kontaktuje się z Tobą wprost.', }, { icon: 'clock', title: 'Prosta obsługa', text: 'Dodaj ogłoszenie w mniej niż 5 minut.', }, { icon: 'shield', title: 'Zero kosztów', text: 'Jesteśmy za darmo. Od początku do końca.', }, ]; // Krótkie opisy tematów na kafelkach /poradniki. Ikona, tytuł i slug pochodzą z GUIDE_TOPICS // (jedno źródło prawdy), a licznik artykułów liczymy dynamicznie z API. const GUIDE_TOPIC_TEXTS: Record = { 'kupno-mieszkania': 'Poradniki dla kupujących', 'kredyty-hipoteczne': 'Finansowanie zakupu', 'wynajem': 'Porady dla najemców i wynajmujących', 'inwestowanie': 'Analizy i strategie inwestycyjne', 'remont-i-wykonczenie': 'Praktyczne porady i inspiracje', 'prawo-i-podatki': 'Prawo nieruchomości i podatki', }; const guideTopics = GUIDE_TOPICS.map((topic) => ({ slug: topic.slug, icon: topic.icon, title: topic.title, guide: topic.guide, text: GUIDE_TOPIC_TEXTS[topic.slug] ?? '', })); // Odmiana słowa "artykuł" po liczbie (1 artykuł, 2 artykuły, 5 artykułów). function articleCountLabel(count: number): string { const mod10 = count % 10; const mod100 = count % 100; if (count === 1) { return '1 artykuł'; } if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) { return `${count} artykuły`; } return `${count} artykułów`; } // Skroty do wbudowanych poradnikow - kazdy prowadzi do rozdzialu, ktory opisuje dany temat. const quickGuides = [ { icon: 'scale', title: 'Jak sprawdzić księgę wieczystą?', text: 'Poradnik krok po kroku', to: ROUTES.guideLaw, }, { icon: 'percent', title: 'Podatek od sprzedaży mieszkania', text: 'Kiedy trzeba zapłacić?', to: ROUTES.guideLaw, }, { icon: 'paint', title: 'Ile kosztuje remont mieszkania?', text: 'Aktualne koszty robocizny i materiałów', to: ROUTES.guideRenovation, }, { icon: 'shield', title: 'Jak bezpiecznie wynająć mieszkanie?', text: 'Porady dla wynajmujących', to: ROUTES.guideRent, }, { icon: 'chart', title: 'PCC przy zakupie mieszkania', text: 'Stawki i zwolnienia w 2026 roku', to: ROUTES.guideLaw, }, ]; // Skroty do list ofert - kazdy prowadzi do wyniku, ktory faktycznie odpowiada nazwie kafelka. const guideRelatedOffers = [ { title: 'Mieszkania w Krakowie', to: buyCityPath('Kraków'), image: cityImage, position: '88% 56%', }, { title: 'Mieszkania w Warszawie', to: buyCityPath('Warszawa'), image: cityImage, position: '18% 48%', }, { title: 'Wszystkie oferty sprzedaży', to: ROUTES.buy, image: cityImage, position: '74% 52%', }, { title: 'Oferty wynajmu', to: ROUTES.rent, image: heroImage, position: '47% 58%', }, ]; type LatLng = { lat: number; lng: number }; const POLAND_CENTER: LatLng = { lat: 52.1, lng: 19.3 }; const CITY_COORDS: Record = { Warszawa: { lat: 52.2297, lng: 21.0122 }, Kraków: { lat: 50.0647, lng: 19.945 }, Łódź: { lat: 51.7592, lng: 19.456 }, Wrocław: { lat: 51.1079, lng: 17.0385 }, Poznań: { lat: 52.4064, lng: 16.9252 }, Gdańsk: { lat: 54.352, lng: 18.6466 }, Szczecin: { lat: 53.4285, lng: 14.5528 }, Bydgoszcz: { lat: 53.1235, lng: 18.0084 }, Lublin: { lat: 51.2465, lng: 22.5684 }, Białystok: { lat: 53.1325, lng: 23.1688 }, Katowice: { lat: 50.2649, lng: 19.0238 }, Gdynia: { lat: 54.5189, lng: 18.5305 }, Częstochowa: { lat: 50.7971, lng: 19.12 }, Radom: { lat: 51.4027, lng: 21.1471 }, Sosnowiec: { lat: 50.2862, lng: 19.104 }, Toruń: { lat: 53.0138, lng: 18.5984 }, Kielce: { lat: 50.8661, lng: 20.6286 }, Rzeszów: { lat: 50.0413, lng: 21.999 }, Gliwice: { lat: 50.2945, lng: 18.6714 }, Zabrze: { lat: 50.3249, lng: 18.7857 }, Olsztyn: { lat: 53.7784, lng: 20.4801 }, 'Bielsko-Biała': { lat: 49.8224, lng: 19.0444 }, Bytom: { lat: 50.3483, lng: 18.9157 }, 'Zielona Góra': { lat: 51.9356, lng: 15.5062 }, Rybnik: { lat: 50.0971, lng: 18.5416 }, Opole: { lat: 50.6751, lng: 17.9213 }, Tychy: { lat: 50.131, lng: 18.9866 }, 'Gorzów Wielkopolski': { lat: 52.7368, lng: 15.2288 }, Płock: { lat: 52.5468, lng: 19.7064 }, Tarnów: { lat: 50.0121, lng: 20.9858 }, Koszalin: { lat: 54.1943, lng: 16.1714 }, Kalisz: { lat: 51.7611, lng: 18.0911 }, Legnica: { lat: 51.207, lng: 16.1619 }, Słupsk: { lat: 54.4641, lng: 17.0287 }, Elbląg: { lat: 54.1561, lng: 19.4045 }, Wałbrzych: { lat: 50.7714, lng: 16.2845 }, 'Nowy Sącz': { lat: 49.6175, lng: 20.7153 }, 'Jelenia Góra': { lat: 50.9044, lng: 15.7197 }, Sopot: { lat: 54.4418, lng: 18.5601 }, Zakopane: { lat: 49.2992, lng: 19.9496 }, Przemyśl: { lat: 49.7838, lng: 22.7677 }, }; const POLAND_BOUNDS = { minLat: 48.8, maxLat: 54.9, minLng: 14.0, maxLng: 24.2 }; function projectToWorld(lat: number, lng: number, zoom: number) { const scale = 256 * 2 ** zoom; const x = ((lng + 180) / 360) * scale; const sinLat = Math.sin((lat * Math.PI) / 180); const y = (0.5 - Math.log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI)) * scale; return { x, y }; } function unprojectFromWorld(x: number, y: number, zoom: number): LatLng { const scale = 256 * 2 ** zoom; const lng = (x / scale) * 360 - 180; const n = Math.PI - (2 * Math.PI * y) / scale; const lat = (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); return { lat, lng }; } function clampCenter(center: LatLng): LatLng { return { lat: Math.min(POLAND_BOUNDS.maxLat, Math.max(POLAND_BOUNDS.minLat, center.lat)), lng: Math.min(POLAND_BOUNDS.maxLng, Math.max(POLAND_BOUNDS.minLng, center.lng)), }; } function normalizeSearchText(value: string): string { return value .trim() .toLowerCase() // "ł" to osobna litera, a nie "l" ze znakiem diakrytycznym - NFD jej nie rozklada, wiec bez // tej podmiany "Łódź" zostaje jako "łodz" i wpisane "lodz" nigdy w nie nie trafia. .replace(/ł/g, 'l') .normalize('NFD') .replace(/\p{Diacritic}/gu, ''); } /** * Odleglosc edycyjna: ile pojedynczych poprawek dzieli dwa napisy. Liczymy dodanie, usuniecie, * zamiane litery oraz przestawienie dwoch sasiednich liter ("wrocwal" zamiast "wroclaw") - to * ostatnie jest jedna z najczestszych literowek przy szybkim pisaniu, a zwykly Levenshtein * liczylby je jako dwie osobne poprawki i taka nazwa nie zmiescilaby sie w tolerancji. */ function editDistance(a: string, b: string): number { if (a === b) { return 0; } const rows: number[][] = [Array.from({ length: b.length + 1 }, (_, index) => index)]; for (let i = 1; i <= a.length; i += 1) { const current = [i]; for (let j = 1; j <= b.length; j += 1) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; let best = Math.min(current[j - 1] + 1, rows[i - 1][j] + 1, rows[i - 1][j - 1] + cost); if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { best = Math.min(best, rows[i - 2][j - 2] + 1); } current[j] = best; } rows.push(current); } return rows[a.length][b.length]; } /** * Ile literowek wybaczamy przy zapytaniu danej dlugosci. Krotkich nie ruszamy: "gda" pasuje * do zbyt wielu nazw, zeby jeszcze zgadywac, co uzytkownik mial na mysli. * * Progi dobrane pomiarem na liscie 55 polskich miast. Dopuszczenie dwoch poprawek juz przy * siedmiu znakach zaczyna mylic prawdziwe nazwy: "krakow" trafia w Katowice, a "kielce" * w Siedlce - a to gorszy blad niz nieznalezienie miasta wpisanego z dwiema literowkami. * Przy obecnych progach jedyne kolizje to pary nierozroznialne takze dla czlowieka * (Lublin i Lubin, Wrocław i Włocławek). */ function cityTypoTolerance(length: number): number { if (length < 4) { return 0; } return length < 8 ? 1 : 2; } /** * Czy nazwa miasta pasuje do tego, co wpisano. Najpierw zwykle dopasowanie poczatku nazwy, * a gdy ono zawiedzie - z marginesem na literowki ("warszwa" trafia w "Warszawa"). */ function cityStartsWith(cityName: string, query: string): boolean { const normalizedQuery = normalizeSearchText(query); if (!normalizedQuery) { return true; } const normalizedName = normalizeSearchText(cityName); if (normalizedName.startsWith(normalizedQuery)) { return true; } const tolerance = cityTypoTolerance(normalizedQuery.length); if (tolerance === 0) { return false; } // Porownujemy z poczatkiem nazwy, nie z cala - inaczej "gdansk" przegralby z dluzszymi nazwami. // Dlugosc okna zmieniamy w zakresie tolerancji, zeby zlapac tez brakujaca albo nadmiarowa litere. const from = Math.max(1, normalizedQuery.length - tolerance); const to = Math.min(normalizedName.length, normalizedQuery.length + tolerance); for (let length = from; length <= to; length += 1) { if (editDistance(normalizedQuery, normalizedName.slice(0, length)) <= tolerance) { return true; } } return false; } function findKnownCityKey(name: string): string | null { const normalized = normalizeSearchText(name); if (!normalized) { return null; } return Object.keys(CITY_COORDS).find((cityName) => normalizeSearchText(cityName) === normalized) ?? null; } function useResolvedCityCenter(cityName: string, fallbackCenter: LatLng): LatLng { const [center, setCenter] = useState(fallbackCenter); useEffect(() => { const trimmed = cityName.trim(); if (!trimmed) { setCenter(fallbackCenter); return; } const knownCity = findKnownCityKey(trimmed); if (knownCity) { setCenter(CITY_COORDS[knownCity]); return; } let cancelled = false; const controller = new AbortController(); fetchPlaceSuggestions(trimmed, controller.signal) .then((places) => { if (cancelled || places.length === 0) { return; } setCenter(clampCenter({ lat: places[0].lat, lng: places[0].lng })); }) .catch(() => undefined); return () => { cancelled = true; controller.abort(); }; }, [cityName, fallbackCenter.lat, fallbackCenter.lng]); return center; } function listingLatLng(offer: { city: string }, index: number): LatLng { const base = CITY_COORDS[offer.city] ?? POLAND_CENTER; const radius = 0.035; return { lat: base.lat + Math.sin(index * 1.7) * radius, lng: base.lng + Math.cos(index * 2.3) * radius * 1.4, }; } type PlaceSuggestion = { id: number; label: string; sublabel: string; lat: number; lng: number; }; const PLACE_TYPE_LABELS: Record = { city: 'miasto', town: 'miasto', village: 'wieś', hamlet: 'przysiółek', administrative: 'gmina', municipality: 'gmina', suburb: 'dzielnica', neighbourhood: 'osiedle', quarter: 'dzielnica', county: 'powiat', state: 'województwo', locality: 'miejscowość', }; type NominatimResult = { place_id: number; lat: string; lon: string; display_name: string; type: string; class: string; address?: Record; }; function describePlace(item: NominatimResult): PlaceSuggestion { const address = item.address ?? {}; const label = address.city ?? address.town ?? address.village ?? address.hamlet ?? address.municipality ?? item.display_name.split(',')[0]; const typeLabel = PLACE_TYPE_LABELS[item.type] ?? PLACE_TYPE_LABELS[item.class] ?? 'miejscowość'; const county = address.county ? address.county.replace(/^powiat\s+/i, '') : ''; const state = address.state ? address.state.replace(/^województwo\s+/i, '') : ''; const sublabel = [typeLabel, county, state].filter(Boolean).join(', '); return { id: item.place_id, label, sublabel, lat: Number(item.lat), lng: Number(item.lon), }; } async function fetchPlaceSuggestions(query: string, signal: AbortSignal): Promise { const url = new URL('https://nominatim.openstreetmap.org/search'); url.searchParams.set('q', query); url.searchParams.set('format', 'jsonv2'); url.searchParams.set('countrycodes', 'pl'); url.searchParams.set('addressdetails', '1'); url.searchParams.set('accept-language', 'pl'); url.searchParams.set('limit', '8'); const response = await fetch(url.toString(), { signal, headers: { Accept: 'application/json' }, }); if (!response.ok) { throw new Error('Nie udało się pobrać podpowiedzi'); } const data = (await response.json()) as NominatimResult[]; const seen = new Set(); const places: PlaceSuggestion[] = []; for (const item of data) { const place = describePlace(item); const key = `${place.label}|${place.sublabel}`; if (seen.has(key)) { continue; } seen.add(key); places.push(place); } return places; } type ListingLocation = { city: string; district: string; estate: string; street: string; building: string; unit: string; // Zaznaczone = na mapie pokazujemy przyblizony obszar zamiast dokladnego punktu. approximateLocation: boolean; }; type ListingPhoto = { id: string; name: string; url: string; }; const INITIAL_LISTING_LOCATION: ListingLocation = { city: '', district: '', estate: '', street: '', building: '', unit: '', approximateLocation: false, }; // Promien obszaru pokazywanego zamiast dokladnego punktu (zgodny z rozrzutem wspolrzednych z API). 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 }; function locationSearchQuery(location: ListingLocation) { return [location.street, location.building, location.estate, location.district, location.city, 'Polska'] .filter((part) => part.trim()) .join(', '); } function locationZoom(location: ListingLocation) { if (location.street && location.building) { return 17; } if (location.street || location.district || location.estate) { return 15; } return 12; } // Wynik geokodowania adresu: pozycja pinezki plus informacja, czy trafilismy w konkretny budynek // (Nominatim zwrocil numer domu). Ta precyzja decyduje, czy da sie pominac reczne pola ulicy i numeru. type GeocodeResult = { point: LatLng; precise: boolean }; async function geocodeListingLocation(location: ListingLocation, signal: AbortSignal): Promise { const query = locationSearchQuery(location); if (!location.city.trim() || query.length < 4) { return null; } const url = new URL('https://nominatim.openstreetmap.org/search'); url.searchParams.set('q', query); url.searchParams.set('format', 'jsonv2'); url.searchParams.set('countrycodes', 'pl'); url.searchParams.set('addressdetails', '1'); url.searchParams.set('accept-language', 'pl'); url.searchParams.set('limit', '1'); const response = await fetch(url.toString(), { signal, headers: { Accept: 'application/json' } }); if (!response.ok) { throw new Error('Nie udało się odszukać adresu'); } const [result] = (await response.json()) as NominatimResult[]; if (!result) { return null; } return { point: { lat: Number(result.lat), lng: Number(result.lon) }, precise: Boolean(result.address?.house_number), }; } async function reverseGeocodeListingLocation(point: LatLng, signal: AbortSignal): Promise> { const url = new URL('https://nominatim.openstreetmap.org/reverse'); url.searchParams.set('lat', String(point.lat)); url.searchParams.set('lon', String(point.lng)); url.searchParams.set('format', 'jsonv2'); url.searchParams.set('addressdetails', '1'); url.searchParams.set('accept-language', 'pl'); url.searchParams.set('zoom', '18'); const response = await fetch(url.toString(), { signal, headers: { Accept: 'application/json' } }); if (!response.ok) { throw new Error('Nie udało się pobrać lokalizacji z mapy'); } const data = (await response.json()) as NominatimResult; const address = data.address ?? {}; const road = address.road ?? address.pedestrian ?? address.footway ?? address.path ?? address.cycleway ?? address.street ?? ''; const locality = address.village ?? address.hamlet ?? address.locality ?? ''; const street = road && !/^\d+[a-z]?$/i.test(road) ? road : locality; return { city: address.city ?? address.town ?? address.municipality ?? address.county?.replace(/^powiat\s+/i, '') ?? address.village ?? '', district: address.city_district ?? address.suburb ?? address.quarter ?? address.borough ?? address.district ?? '', estate: address.neighbourhood ?? address.residential ?? address.hamlet ?? '', street, building: address.house_number ?? '', }; } const avatars = ['A', 'M', 'K', 'P', 'J']; function getDisplayName(user: AuthUser | null): string { return user?.fullName?.trim() || user?.email || 'Użytkownik'; } function getFirstName(fullName: string): string { const trimmed = fullName.trim(); return trimmed.split(/\s+/)[0] || trimmed; } function getLastName(fullName: string): string { const parts = fullName.trim().split(/\s+/).filter(Boolean); return parts.slice(1).join(' '); } // Inicjaly liczy komponent awatara - to ta sama regula w kazdym miejscu aplikacji. const getInitials = initialsFrom; function contactPreferenceLabel(preference: ContactPreference | null | undefined): string { if (preference === 'EMAIL') { return 'E-mail'; } if (preference === 'PHONE') { return 'Telefon'; } return 'E-mail, Telefon'; } function preferredLanguageLabel(language: PreferredLanguage | null | undefined): string { if (language === 'EN') { return 'Angielski'; } if (language === 'UK') { return 'Ukraiński'; } if (language === 'DE') { return 'Niemiecki'; } return 'Polski'; } function App() { const { user, logout, loadSearchPreferences } = useAuth(); const { formatPrice, formatPricePerArea, areaSuffix } = useDisplay(); const navigate = useNavigate(); const location = useLocation(); const navigationType = useNavigationType(); const [favoriteListingsState, setFavoriteListingsState] = useState(favoriteListings); const [favoritesPreferredTab, setFavoritesPreferredTab] = useState('buy'); const [listingPriceAlertSubscriptionsState, setListingPriceAlertSubscriptionsState] = useState([]); const [unreadFavoriteIds, setUnreadFavoriteIds] = useState>(new Set()); const hydratedFavoriteIdsRef = useRef>(new Set()); const favoriteListingIdList = useMemo( () => Array.from(new Set(favoriteListingsState.map((item) => item.listingId))).sort((left, right) => left - right), [favoriteListingsState], ); const favoriteListingIdsSignature = useMemo(() => favoriteListingIdList.join(','), [favoriteListingIdList]); const listingPriceAlertIdList = useMemo( () => Array.from(new Set(listingPriceAlertSubscriptionsState.map((item) => item.listingId))).sort((left, right) => left - right), [listingPriceAlertSubscriptionsState], ); const listingPriceAlertIdsSignature = useMemo(() => listingPriceAlertIdList.join(','), [listingPriceAlertIdList]); const monitoredListingIdList = useMemo( () => Array.from(new Set([...favoriteListingIdList, ...listingPriceAlertIdList])).sort((left, right) => left - right), [favoriteListingIdList, listingPriceAlertIdList], ); const monitoredListingIdsSignature = useMemo(() => monitoredListingIdList.join(','), [monitoredListingIdList]); const translatedOriginalTextRef = useRef>(new Map()); const translatedOriginalAttrRef = useRef>>(new Map()); const translationObserverRef = useRef(null); const translationTimerRef = useRef(null); const translationInFlightRef = useRef(false); const filters = useFilterState(); const notificationsCenter = useNotifications(); const [savedSearchesState, setSavedSearchesState] = useState([]); /** * Preferencje wyszukiwania z ustawien konta wypelniaja filtry raz - po zalogowaniu. Pozniej * nic ich nie nadpisuje, wiec zmiana filtrow przez uzytkownika jest ostateczna az do wylogowania. * Gosc nie ma preferencji i widzi puste filtry, tak jak dotad. */ const { applyPreferences } = filters; useEffect(() => { if (!user) { return; } let cancelled = false; loadSearchPreferences() .then((prefs) => { if (!cancelled) { applyPreferences(prefs); } }) .catch(() => { /* brak preferencji nie moze psuc wyszukiwarki - zostaja puste filtry */ }); return () => { cancelled = true; }; }, [user, loadSearchPreferences, applyPreferences]); // Po udanym logowaniu wracamy tam, gdzie uzytkownik zmierzal (ProtectedRoute zapisuje cel // w location.state.from). Gdy wszedl na logowanie wprost - admin do panelu, uzytkownik do konta. const handleAuthenticated = (loggedUser: AuthUser) => { const from = (location.state as { from?: string } | null)?.from; const fallback = loggedUser.role === 'ADMIN' ? ROUTES.admin : ROUTES.account; navigate(from && from !== ROUTES.login ? from : fallback, { replace: true }); }; const handleLogout = () => { logout(); navigate(ROUTES.home); }; // Z kalkulatora zdolności: ustaw maksymalną cenę ofert na obliczony budżet i przejdź do wyszukiwarki. const handleSearchWithBudget = (maxPrice: number) => { if (maxPrice > 0) { filters.setCity(''); filters.setCityInput(''); filters.setPriceMin(''); filters.setPriceMax(String(Math.round(maxPrice))); } navigate(ROUTES.buy); }; // Zapisane wyszukiwania trzyma serwer (/api/saved-searches), wiec sa te same na kazdym // urzadzeniu. Wpisy zapisane wczesniej w przegladarce przenosimy raz przy pierwszym wejsciu. useEffect(() => { if (!user) { setSavedSearchesState([]); return; } let cancelled = false; const local = readUserJsonStorage(user, SAVED_SEARCHES_STORAGE_SUFFIX, []); const load = local.length > 0 ? apiFetch('/saved-searches/import', { method: 'POST', body: JSON.stringify(local.map(toSavedSearchPayload)) }) .then((items) => { writeUserJsonStorage(user, SAVED_SEARCHES_STORAGE_SUFFIX, []); return items; }) : apiFetch('/saved-searches'); load .then((items) => { if (!cancelled) { setSavedSearchesState(items.map(fromApiSavedSearch)); } }) .catch(() => { if (!cancelled) { setSavedSearchesState([]); } }); return () => { cancelled = true; }; }, [user]); useEffect(() => { setFavoritesPreferredTab(readUserJsonStorage(user, FAVORITES_PREFERRED_TAB_STORAGE_SUFFIX, 'buy')); setListingPriceAlertSubscriptionsState(readUserJsonStorage(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, [])); }, [user]); // Ulubione trzyma konto (/api/listings/favorites), wiec sa te same na kazdym urzadzeniu. // Wpisy zapisane wczesniej w przegladarce dopisujemy raz przy pierwszym wejsciu. useEffect(() => { if (!user) { setFavoriteListingsState([]); return; } let cancelled = false; const local = readUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []); const localIds = local.map((item) => item.listingId).filter((id): id is number => typeof id === 'number'); const load = localIds.length > 0 ? apiFetch('/listings/favorites/import', { method: 'POST', body: JSON.stringify({ listingIds: localIds }) }) .then((items) => { writeUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []); return items; }) : apiFetch('/listings/favorites'); load .then((items) => { if (!cancelled) { setFavoriteListingsState(items.map(toFavoriteListingFromSummary)); } }) .catch(() => { if (!cancelled) { setFavoriteListingsState([]); } }); return () => { cancelled = true; }; }, [user]); // Zapis i usuniecie ulubionego ida na serwer pojedynczo - stan przegladarki nigdy nie // dyktuje serwerowi calej listy, wiec wejscie z nowego urzadzenia niczego nie kasuje. const saveFavoriteOnServer = (listingId: number, add: boolean) => { apiFetch(`/listings/${listingId}/favorite`, { method: add ? 'POST' : 'DELETE' }) .catch(() => { /* brak sieci - zmiana wroci przy nastepnym wejsciu na konto */ }); }; useEffect(() => { writeUserJsonStorage(user, FAVORITES_PREFERRED_TAB_STORAGE_SUFFIX, favoritesPreferredTab); }, [favoritesPreferredTab, user]); useEffect(() => { writeUserJsonStorage(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, listingPriceAlertSubscriptionsState); }, [listingPriceAlertSubscriptionsState, user]); useEffect(() => { if (!user) { return; } let cancelled = false; let running = false; // Sprawdzanie stawki za m² dla obserwowanych lokalizacji. Wczesniej bylo zamkniete // bramka "raz na dobe" (lastRunKey === todayKey), przez co po zapisaniu pierwszego // punktu odniesienia zadna zmiana ceny tego samego dnia nie byla juz wykrywana - // alerty lokalizacji praktycznie nie dzialaly. Teraz chodzi cyklicznie, tak jak // monitoring cen pojedynczych ogloszen. Zdarzenie i tak powstaje wylacznie wtedy, // gdy srednia faktycznie sie zmieni, wiec czestsze sprawdzanie nie generuje szumu. const runCityAlertsUpdate = async () => { if (cancelled || running) { return; } const todayKey = currentLocalDayKey(); running = true; try { const subscriptions = readUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []); if (subscriptions.length === 0) { return; } const saleListings = await apiFetch('/listings?offerType=SALE'); const previousSnapshots = readUserJsonStorage>(user, CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX, {}); const previousEvents = readUserJsonStorage(user, CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX, []); const nowIso = new Date().toISOString(); const nextSnapshots: Record = { ...previousSnapshots }; const incomingEvents: CityPriceAlertEvent[] = []; subscriptions.forEach((subscription) => { const storageKey = cityAlertStorageKey(subscription); const currentPerM2 = calculateCityAlertPerM2(subscription, saleListings); if (currentPerM2 === null) { // Bez ogloszen nie ma sredniej - zostawiamy ostatni pomiar do porownania pozniej. return; } const previousPerM2 = previousSnapshots[storageKey]?.perM2; if (Number.isFinite(previousPerM2) && previousPerM2 !== currentPerM2) { incomingEvents.push({ id: `${storageKey}:${todayKey}:${previousPerM2}->${currentPerM2}`, subscriptionKey: subscription.key, label: subscription.label, city: subscription.city, region: subscription.region, propertyType: subscription.propertyType, marketType: subscription.marketType, previousPerM2, currentPerM2, changedAt: nowIso, }); } nextSnapshots[storageKey] = { dayKey: todayKey, perM2: currentPerM2, updatedAt: nowIso, }; }); writeUserJsonStorage(user, CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX, nextSnapshots); if (incomingEvents.length > 0) { const mergedEvents = mergeCityPriceAlertEvents(incomingEvents, previousEvents); writeUserJsonStorage(user, CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX, mergedEvents); // Powiadomienie tylko o spadku sredniej stawki - tak samo jak przy obserwowanym // ogloszeniu. Klucz ma prefiks "city-price--", dzieki czemu // usuniecie alertu potrafi posprzatac powiazane powiadomienia. incomingEvents.forEach((event) => { if (event.currentPerM2 < event.previousPerM2) { const diff = event.previousPerM2 - event.currentPerM2; notificationsCenter.notify({ category: 'listing', icon: 'coin', title: 'Spadek cen w lokalizacji', body: `Średnia cena za ${areaSuffix} w „${event.label}” spadła o ${formatPricePerArea(diff)}.`, link: 'priceAlerts', dedupeKey: `city-price-${event.subscriptionKey}-${event.currentPerM2}`, }); } }); } } catch { // Keep silent; updater retries on next interval. } finally { running = false; } }; void runCityAlertsUpdate(); const intervalId = window.setInterval(() => { void runCityAlertsUpdate(); }, 60000); return () => { cancelled = true; window.clearInterval(intervalId); }; }, [user]); useEffect(() => { if (location.pathname === ROUTES.favorites) { setUnreadFavoriteIds(new Set()); } }, [location.pathname]); useEffect(() => { const preferred = user?.preferredLanguage ?? 'PL'; const htmlLang = preferred === 'EN' ? 'en' : preferred === 'UK' ? 'uk' : preferred === 'DE' ? 'de' : 'pl'; document.documentElement.lang = htmlLang; }, [user?.preferredLanguage]); useEffect(() => { const targetLanguage = user?.preferredLanguage ?? 'PL'; const textNodeSources = translatedOriginalTextRef.current; const attrSources = translatedOriginalAttrRef.current; const restorePolish = () => { textNodeSources.forEach((source, node) => { if (node.isConnected) { node.textContent = source; } }); attrSources.forEach((sourceAttrs, element) => { if (!element.isConnected) { return; } Object.entries(sourceAttrs).forEach(([attr, value]) => { element.setAttribute(attr, value); }); }); }; const shouldTranslateSource = (value: string) => { const trimmed = value.trim(); if (!trimmed) { return false; } // Skip pure numbers and separators. if (!/[a-zA-Z\u00C0-\u024F\u0400-\u04FF\u0104\u0105\u0106\u0107\u0118\u0119\u0141\u0142\u0143\u0144\u00D3\u00F3\u015A\u015B\u0179\u017A\u017B\u017C]/.test(trimmed)) { return false; } return true; }; const collectTargets = () => { const textTargets: Array<{ node: Text; source: string }> = []; const attrTargets: Array<{ element: Element; attr: string; source: string }> = []; const root = document.querySelector('.site-shell') ?? document.body; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let current = walker.nextNode(); while (current) { const textNode = current as Text; const parent = textNode.parentElement; if (parent && !['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA'].includes(parent.tagName)) { const source = textNodeSources.get(textNode) ?? textNode.textContent ?? ''; if (!textNodeSources.has(textNode)) { textNodeSources.set(textNode, source); } if (shouldTranslateSource(source)) { textTargets.push({ node: textNode, source }); } } current = walker.nextNode(); } root.querySelectorAll('[placeholder], [title], [aria-label]').forEach((element) => { const tracked = attrSources.get(element) ?? {}; ['placeholder', 'title', 'aria-label'].forEach((attr) => { const raw = element.getAttribute(attr); if (!raw) { return; } const source = tracked[attr] ?? raw; if (!tracked[attr]) { tracked[attr] = source; } if (shouldTranslateSource(source)) { attrTargets.push({ element, attr, source }); } }); if (Object.keys(tracked).length > 0) { attrSources.set(element, tracked); } }); return { textTargets, attrTargets }; }; const translateVisibleContent = async () => { if (targetLanguage === 'PL') { restorePolish(); return; } if (translationInFlightRef.current) { return; } const { textTargets, attrTargets } = collectTargets(); const sources = Array.from(new Set([ ...textTargets.map((item) => item.source), ...attrTargets.map((item) => item.source), ])); if (sources.length === 0) { return; } translationInFlightRef.current = true; try { const mergedTranslations: Record = {}; const missingForApi: string[] = []; sources.forEach((source) => { const manual = getManualTranslation(targetLanguage, source); if (manual != null) { mergedTranslations[source] = manual; } else if (source.length <= 380) { missingForApi.push(source); } else { mergedTranslations[source] = source; } }); if (missingForApi.length > 0) { const fallbackChunks: string[][] = []; for (let index = 0; index < missingForApi.length; index += 150) { fallbackChunks.push(missingForApi.slice(index, index + 150)); } for (const chunk of fallbackChunks) { const response = await apiFetch<{ language: PreferredLanguage; translations: Record }>('/i18n/translate', { method: 'POST', body: JSON.stringify({ language: targetLanguage, texts: chunk }), }); Object.assign(mergedTranslations, response.translations); } } textTargets.forEach(({ node, source }) => { if (!node.isConnected) { return; } const translated = mergedTranslations[source]; if (translated && node.textContent !== translated) { node.textContent = translated; } }); attrTargets.forEach(({ element, attr, source }) => { if (!element.isConnected) { return; } const translated = mergedTranslations[source]; if (translated) { element.setAttribute(attr, translated); } }); } catch { // If translation fails, leave original text untouched. } finally { translationInFlightRef.current = false; } }; const queueTranslate = () => { if (translationTimerRef.current != null) { window.clearTimeout(translationTimerRef.current); } translationTimerRef.current = window.setTimeout(() => { void translateVisibleContent(); }, 80); }; translationObserverRef.current?.disconnect(); if (targetLanguage === 'PL') { restorePolish(); return; } queueTranslate(); const observer = new MutationObserver(() => { queueTranslate(); }); observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['placeholder', 'title', 'aria-label'], }); translationObserverRef.current = observer; return () => { observer.disconnect(); translationObserverRef.current = null; if (translationTimerRef.current != null) { window.clearTimeout(translationTimerRef.current); translationTimerRef.current = null; } }; }, [user?.preferredLanguage, location.pathname]); // Miasto i typ oferty trafiaja do query stringa - wynik na mapie da sie udostepnic linkiem. const openMap = (name: string, source: 'buy' | 'rent' = 'buy') => { navigate(mapPath(name, source === 'rent' ? 'RENT' : 'SALE')); }; const openListing = (id: number) => { navigate(listingPath(id)); }; // Rozmowa o ogloszeniu ma wlasny adres - identyfikator ogloszenia w URL, nie w stanie nawigacji. const openMessagesWithTarget = (target: MessageThreadTarget) => { navigate(newConversationPath(target.listing.listingId)); }; // Klik w powiadomienie: oznacz jako przeczytane (znika z dzwonka, licznik -1) i przejdź do właściwego miejsca. const handleOpenNotification = (notification: ServerNotification | DisplayNotification) => { notificationsCenter.markRead(notification.id); const link = notification.link; if (!link) { navigate(ROUTES.notifications); return; } if (link.startsWith('listing:')) { const id = Number(link.slice('listing:'.length)); if (Number.isFinite(id) && id > 0) { openListing(id); return; } } if (link.startsWith('messages:conversation:')) { const id = Number(link.slice('messages:conversation:'.length)); navigate(Number.isFinite(id) && id > 0 ? conversationPath(id) : ROUTES.messages); return; } if (link === 'messages:admin') { navigate(conversationPath(ADMIN_CONVERSATION_SLUG)); return; } if (link.startsWith('messages')) { navigate(ROUTES.messages); return; } if (link === 'priceAlerts') { navigate(ROUTES.accountPriceAlerts); return; } if (link === 'meetings') { navigate(ROUTES.accountMeetings); return; } if (link === 'savedSearches') { navigate(ROUTES.accountSearches); return; } if (link === 'favorites') { navigate(ROUTES.favorites); return; } // Powiadomienia sprzed scalenia stron moga wskazywac na accountSecurity - to teraz sekcja ustawien. if (link === 'accountSecurity') { navigate(ROUTES.accountSettings); return; } if (link === 'accountSettings' || link === 'account' || link === 'accountListings') { navigate(ROUTES[link]); return; } navigate(ROUTES.notifications); }; const toggleSavedSearch = (mode: SavedSearchMode) => { if (!user) { navigate(ROUTES.login); return; } const draft = buildSavedSearchDraft(mode, filters); const existing = savedSearchesState.find((item) => sameSavedSearchCriteria(item, draft)); if (existing) { removeSavedSearch(existing.id); return; } apiFetch('/saved-searches', { method: 'POST', body: JSON.stringify(toSavedSearchPayload(draft)) }) .then((saved) => { const record = fromApiSavedSearch(saved); setSavedSearchesState((current) => ( current.some((item) => item.id === record.id) ? current : [record, ...current].slice(0, SAVED_SEARCHES_LIMIT) )); }) .catch(() => { /* brak sieci - lista zostaje bez zmian, uzytkownik moze sprobowac ponownie */ }); }; const openSavedSearch = (savedSearch: SavedSearchRecord) => { applySavedSearchToFilters(filters, savedSearch); navigate(savedSearch.mode === 'rent' ? ROUTES.rent : ROUTES.buy); }; const removeSavedSearch = (savedSearchId: string) => { setSavedSearchesState((current) => current.filter((item) => item.id !== savedSearchId)); apiFetch(`/saved-searches/${encodeURIComponent(savedSearchId)}`, { method: 'DELETE' }) .catch(() => { /* usuniecie na serwerze nie wyszlo - wroci przy nastepnym wejsciu */ }); }; const buySearchSaved = savedSearchesState.some((item) => sameSavedSearchCriteria(item, buildSavedSearchDraft('buy', filters))); const rentSearchSaved = savedSearchesState.some((item) => sameSavedSearchCriteria(item, buildSavedSearchDraft('rent', filters))); const isListingFavorite = (listingId: number) => ( favoriteListingsState.some((item) => item.id === favoriteListingKey(listingId)) ); const toggleListingFavorite = (listing: ApiListingDetail) => { const listingKey = favoriteListingKey(listing.id); const alreadyFavorite = favoriteListingsState.some((item) => item.id === listingKey); saveFavoriteOnServer(listing.id, !alreadyFavorite); if (!alreadyFavorite) { notificationsCenter.notify({ category: 'listing', icon: 'heart', title: 'Ogłoszenie zapisane', body: `„${listing.title}” zostało zapisane do ulubionych.`, link: `listing:${listing.id}`, dedupeKey: `favorite-${listing.id}`, }); } setFavoriteListingsState((current) => { const exists = current.some((item) => item.id === listingKey); if (exists) { setUnreadFavoriteIds((prev) => { if (!prev.has(listingKey)) { return prev; } const next = new Set(prev); next.delete(listingKey); return next; }); return current.filter((item) => item.id !== listingKey); } setFavoritesPreferredTab(favoriteTabFromOfferType(listing.offerType)); setUnreadFavoriteIds((prev) => { const next = new Set(prev); next.add(listingKey); return next; }); return [toFavoriteListing(listing), ...current]; }); }; const isListingPriceAlertEnabled = (listingId: number) => ( listingPriceAlertSubscriptionsState.some((item) => item.listingId === listingId) ); const toggleListingPriceAlert = (listing: ApiListingDetail) => { const listingLocation = [listing.city, listing.district].filter(Boolean).join(', '); setListingPriceAlertSubscriptionsState((current) => { const exists = current.some((item) => item.listingId === listing.id); if (exists) { return current.filter((item) => item.listingId !== listing.id); } const next: ListingPriceAlertSubscription = { listingId: listing.id, title: listing.title, location: listingLocation || listing.city, offerType: listing.offerType, createdAt: new Date().toISOString(), }; return [next, ...current]; }); }; // Usuniecie i przelaczanie powiadomien z poziomu /konto/alerty-cenowe. Stan subskrypcji // ogloszen zyje tutaj (efekt nizej zapisuje go do localStorage), wiec strona alertow // modyfikuje go przez te funkcje zamiast pisac po magazynie na wlasna reke. // Usuniecie alertu kasuje caly slad po nim: subskrypcje, zarejestrowane zmiany ceny, // punkt odniesienia oraz powiadomienia w centrum - tak, jakby alertu nigdy nie bylo. const removeListingPriceAlert = (listingId: number) => { setListingPriceAlertSubscriptionsState((current) => current.filter((item) => item.listingId !== listingId)); if (user) { const events = readUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, []); writeUserJsonStorage( user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, events.filter((event) => event.listingId !== listingId), ); const snapshots = readUserJsonStorage>(user, FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX, {}); delete snapshots[String(listingId)]; writeUserJsonStorage(user, FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX, snapshots); } // Powiadomienia o zmianie ceny tego ogloszenia maja klucz "price--". notificationsCenter.dismissByDedupePrefix(`price-${listingId}-`); }; const toggleListingPriceAlertNotifications = (listingId: number) => { setListingPriceAlertSubscriptionsState((current) => current.map((item) => ( item.listingId === listingId ? { ...item, notificationsEnabled: item.notificationsEnabled === false } : item ))); }; const toggleSummaryFavorite = async (listing: ApiListingSummary) => { const listingKey = favoriteListingKey(listing.id); const exists = favoriteListingsState.some((item) => item.id === listingKey); saveFavoriteOnServer(listing.id, !exists); if (exists) { setUnreadFavoriteIds((prev) => { if (!prev.has(listingKey)) { return prev; } const next = new Set(prev); next.delete(listingKey); return next; }); setFavoriteListingsState((current) => current.filter((item) => item.id !== listingKey)); return; } setFavoritesPreferredTab(favoriteTabFromOfferType(listing.offerType)); try { const fullListing = await apiFetch(`/listings/${listing.id}`); const nextFavorite = toFavoriteListing(fullListing); setFavoriteListingsState((current) => { if (current.some((item) => item.id === listingKey)) { return current; } setUnreadFavoriteIds((prev) => { const next = new Set(prev); next.add(listingKey); return next; }); return [nextFavorite, ...current]; }); } catch { const fallbackFavorite = toFavoriteListingFromSummary(listing); setFavoriteListingsState((current) => { if (current.some((item) => item.id === listingKey)) { return current; } setUnreadFavoriteIds((prev) => { const next = new Set(prev); next.add(listingKey); return next; }); return [fallbackFavorite, ...current]; }); } }; useEffect(() => { const toHydrate = favoriteListingsState .filter((item) => item.photos <= 1 && !hydratedFavoriteIdsRef.current.has(item.listingId)) .map((item) => item.listingId); if (toHydrate.length === 0) { return; } toHydrate.forEach((listingId) => hydratedFavoriteIdsRef.current.add(listingId)); let cancelled = false; Promise.all( toHydrate.map(async (listingId) => { try { return await apiFetch(`/listings/${listingId}`); } catch { return null; } }), ).then((resolved) => { if (cancelled) { return; } const hydratedById = new Map(); resolved.forEach((listing) => { if (listing) { hydratedById.set(listing.id, toFavoriteListing(listing)); } }); if (hydratedById.size === 0) { return; } setFavoriteListingsState((current) => current.map((item) => { const hydrated = hydratedById.get(item.listingId); if (!hydrated) { return item; } return { ...item, ...hydrated, id: item.id, listingId: item.listingId, }; })); }); return () => { cancelled = true; }; }, [favoriteListingsState]); useEffect(() => { if (!user || monitoredListingIdList.length === 0) { return; } let cancelled = false; let snapshots = readUserJsonStorage>(user, FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX, {}); const alertListingIdSet = new Set(listingPriceAlertIdList); const syncFavoritePrices = async () => { const refreshed = await Promise.all( monitoredListingIdList.map(async (listingId) => { try { return await apiFetch(`/listings/${listingId}`); } catch { return null; } }), ); if (cancelled) { return; } const nowIso = new Date().toISOString(); const incomingEvents: FavoritePriceAlertEvent[] = []; const nextSnapshots = { ...snapshots }; const refreshedFavorites = new Map(); refreshed.forEach((listing) => { if (!listing) { return; } const listingIdKey = String(listing.id); const previousPrice = nextSnapshots[listingIdKey]; const currentPrice = Number(listing.price); if (Number.isFinite(currentPrice)) { if (alertListingIdSet.has(listing.id) && Number.isFinite(previousPrice) && previousPrice !== currentPrice) { const location = [listing.city, listing.district].filter(Boolean).join(', '); incomingEvents.push({ id: `${listing.id}:${previousPrice}->${currentPrice}`, listingId: listing.id, title: listing.title, location: location || listing.city, area: `${`${listing.area}`.replace('.', ',')} m²`, offerType: listing.offerType, previousPrice, currentPrice, changedAt: nowIso, }); } nextSnapshots[listingIdKey] = currentPrice; } refreshedFavorites.set(listing.id, toFavoriteListing(listing)); }); snapshots = nextSnapshots; writeUserJsonStorage(user, FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX, nextSnapshots); if (incomingEvents.length > 0) { const existingEvents = readUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, []); const merged = mergePriceAlertEvents(incomingEvents, existingEvents); writeUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, merged); // Powiadomienie tylko o spadku ceny obserwowanego ogloszenia. incomingEvents.forEach((event) => { if (event.currentPrice < event.previousPrice) { const diff = event.previousPrice - event.currentPrice; notificationsCenter.notify({ category: 'listing', icon: 'coin', title: 'Zmiana ceny', body: `Cena „${event.title}” spadła o ${formatPrice(diff)}.`, link: `listing:${event.listingId}`, dedupeKey: `price-${event.listingId}-${event.currentPrice}`, }); } }); } if (refreshedFavorites.size > 0) { setFavoriteListingsState((current) => current.map((item) => { const refreshedItem = refreshedFavorites.get(item.listingId); if (!refreshedItem) { return item; } return { ...item, ...refreshedItem, id: item.id, listingId: item.listingId, }; })); } }; void syncFavoritePrices(); const intervalId = window.setInterval(() => { void syncFavoritePrices(); }, 45000); return () => { cancelled = true; window.clearInterval(intervalId); }; }, [user, monitoredListingIdList, monitoredListingIdsSignature, listingPriceAlertIdList, listingPriceAlertIdsSignature]); // Wejscie na nowa trase zaczynamy od gory strony. Przy cofaniu (POP) zostawiamy // pozycje przegladarce, zeby powrot do listy ofert wracal w to samo miejsce. useEffect(() => { if (navigationType !== 'POP') { window.scrollTo({ top: 0, behavior: 'auto' }); } }, [location.pathname, navigationType]); return (
0} onOpenNotification={handleOpenNotification} />
{/* Trasy publiczne */} } /> {/* Logowanie i rejestracja: kazdy krok pod wlasnym adresem, jeden komponent. */} {Object.values(LOGIN_STEP_PATHS).map((path) => ( } /> ))} } /> } /> } /> } /> toggleSavedSearch('buy')} /> )} /> toggleSavedSearch('rent')} /> )} /> )} /> } /> } /> } /> {/* Narzedzia dostepne publicznie (jak /kupno), ale otwierane takze z lewego menu konta. AccountShell pokazuje menu tylko zalogowanemu, a goscia przepuszcza do samej strony - dzieki temu menu nie znika po kliknieciu. */} }> {VALUATION_STEP_PATHS.map((path) => ( } /> ))} } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> {/* Trasy wymagajace zalogowania */} }> } /> } /> } /> )} /> )} /> } /> } /> } /> } /> {/* Bezpieczenstwo i edycja profilu sa teraz sekcjami /konto/ustawienia - stare adresy zostaja jako przekierowania, zeby zapisane linki i zakladki dalej dzialaly. */} } /> } /> } /> } /> } /> } /> } /> } /> {/* Pozostale pozycje lewego menu konta - ten sam uklad co /konto/wyszukiwania */} }> } /> } /> } /> } /> )} /> {/* Panel administratora - zakladki jako podsciezki /admin/* */} }> } /> } />
); } // Szczegoly oferty: identyfikator pochodzi z adresu (/oferta/:id), nie ze stanu Reacta. // Dzieki temu link do oferty da sie wyslac, odswiezyc i otworzyc w nowej karcie. function ListingDetailRoute(props: { onStartChat: (target: MessageThreadTarget) => void; isFavorite: (listingId: number) => boolean; onToggleFavorite: (listing: ApiListingDetail) => void; isListingAlertEnabled: (listingId: number) => boolean; onToggleListingAlert: (listing: ApiListingDetail) => void; }) { const { id } = useParams<{ id: string }>(); const listingId = Number(id); if (!Number.isFinite(listingId) || listingId <= 0) { return ; } return ; } // Dedykowana strona edycji ogloszenia: /edytuj-ogloszenie/:id. Identyfikator z adresu, // dane pobierane z API i wczytywane do kreatora (ten sam formularz co dodawanie, tryb edycji). function AddListingEditRoute() { const { id } = useParams<{ id: string }>(); const listingId = Number(id); if (!Number.isFinite(listingId) || listingId <= 0) { return ; } return ; } // Mapa czyta miasto i typ oferty z query stringa (/mapa?miasto=Krakow&typ=wynajem). function MapSearchRoute() { const [searchParams] = useSearchParams(); const city = searchParams.get('miasto') ?? ''; const offerType: ApiOfferType = searchParams.get('typ') === 'wynajem' ? 'RENT' : 'SALE'; return ; } // Nieznany adres: czytelny komunikat zamiast pustego ekranu. function NotFoundPage() { return (

404

Nie znaleźliśmy tej strony

Adres jest nieprawidłowy albo strona została przeniesiona.

Wróć na stronę główną Przeglądaj oferty
); } type AdminListing = { id: number; title: string; city: string; offerType: 'SALE' | 'RENT'; propertyType: 'APARTMENT' | 'HOUSE'; price: number; area: number; rooms: number; ownerEmail: string | null; status: 'PENDING' | 'APPROVED' | 'REJECTED'; createdAt: string; }; type AdminListingReportAttachment = { fileName: string; fileType: string | null; dataUrl: string | null; }; /** * Wspolny ksztalt dla okna "Szczegoly zgloszenia" - to samo okno obsluguje zgloszenia ogloszen * i uzytkownikow, rozni je tylko opis zglaszanego obiektu (subject*) i notatka o jego stanie. */ type AdminReportDetails = { id: number; status: 'OPEN' | 'RESOLVED'; reasonId: string; reasonTitle: string; reporterName: string | null; reporterEmail: string; createdAt: string; details: string | null; attachmentNames: string[]; attachments?: AdminListingReportAttachment[]; // Kafelki opisujace zgloszenie - roznia sie miedzy zgloszeniem ogloszenia a uzytkownika. facts: { label: string; title: string; meta: string }[]; listingId: number | null; conversation?: AdminUserReportMessage[]; conversationPartner?: string; }; function toReportDetails(report: AdminListingReport): AdminReportDetails { return { ...report, facts: [ { label: 'Ogłoszenie', title: report.listingTitle, meta: `${report.listingCity} • ID: ${report.listingId}` }, { label: 'Data zgłoszenia', title: formatAdminDate(report.createdAt), meta: report.listingDeleted ? 'Ogłoszenie zostało usunięte.' : 'Ogłoszenie nadal aktywne w systemie.', }, ], // Usunietego ogloszenia nie ma juz czego podgladac - wtedy okno nie pokazuje tego przycisku. listingId: report.listingDeleted ? null : report.listingId, }; } function toUserReportDetails(report: AdminUserReport): AdminReportDetails { const accountState = report.reportedUserDeleted ? 'Konto zostało usunięte.' : (report.reportedUserExists ? 'Konto nadal aktywne w serwisie.' : 'Zgłoszony nie ma konta w serwisie.'); const facts = [ { label: 'Zgłoszony użytkownik', title: report.reportedName, meta: report.reportedEmail ?? 'Brak konta powiązanego z rozmową', }, { label: 'Data zgłoszenia', title: formatAdminDate(report.createdAt), meta: accountState }, ]; if (report.listingTitle || report.listingId !== null) { facts.push({ label: 'Ogłoszenie z rozmowy', title: report.listingTitle ?? 'Ogłoszenie bez tytułu', meta: report.listingId !== null ? `ID: ${report.listingId}` : 'Rozmowa bez powiązanego ogłoszenia', }); } return { ...report, facts, listingId: report.listingId, conversation: report.conversation, conversationPartner: report.reportedName, }; } type AdminSupportMessage = { id: number; fullName: string; email: string; topic: string; content: string; accountEmail: string; handled: boolean; handledAt: string | null; handledByEmail: string | null; createdAt: string; }; /** Zgloszenie z formularza reklamowego (/reklama) w widoku administratora. */ type AdminAdvertisingEnquiry = { id: number; fullName: string; companyName: string; email: string; phone: string; serviceCategory: string; message: string | null; handled: boolean; handledAt: string | null; handledByEmail: string | null; createdAt: string; }; type AdminUserReportMessage = { fromReporter: boolean; content: string | null; sentAt: string | null; hasPhoto: boolean; }; type AdminUserReport = { id: number; reportedName: string; reportedEmail: string | null; conversationId: string | null; listingId: number | null; listingTitle: string | null; conversation: AdminUserReportMessage[]; reportedUserDeleted: boolean; reasonId: string; reasonTitle: string; details: string | null; attachmentNames: string[]; attachments?: AdminListingReportAttachment[]; reporterEmail: string; reporterName: string | null; status: 'OPEN' | 'RESOLVED'; reportedUserExists: boolean; createdAt: string; resolvedAt: string | null; resolvedByEmail: string | null; resolutionNote: string | null; }; type AdminListingReport = { id: number; listingId: number; listingTitle: string; listingCity: string; listingOwnerEmail: string | null; reasonId: string; reasonTitle: string; details: string | null; attachmentNames: string[]; attachments?: AdminListingReportAttachment[]; reporterEmail: string; reporterName: string | null; status: 'OPEN' | 'RESOLVED'; listingDeleted: boolean; createdAt: string; resolvedAt: string | null; resolvedByEmail: string | null; resolutionNote: string | null; }; type ForbiddenWord = { id: number; word: string; }; type AdminResetPasswordResponse = { temporaryPassword: string; }; type AdminStatsResponse = { periodDays: number; generatedAt: string; summary: { activeListings: number; pendingListings: number; totalUsers: number; pendingVerificationUsers: number; openReports: number; messagesToAdminTotal: number; blockedUsers: number; }; period: { messagesToAdmin: number; newListings: number; newReports: number; newPersonalAccounts: number; newCompanyAccounts: number; }; daily: { date: string; messagesToAdmin: number; newListings: number; newReports: number; newPersonalAccounts: number; newCompanyAccounts: number; }[]; }; const ADMIN_STATS_PERIOD_OPTIONS = [ { days: 1, label: 'Dziś' }, { days: 7, label: '7 dni' }, { days: 14, label: '14 dni' }, { days: 30, label: '30 dni' }, ] as const; type AdminStatsPeriodMode = 'preset' | 'custom'; const ADMIN_STATUS_LABELS: Record = { PENDING: 'Oczekuje', APPROVED: 'Zatwierdzone', REJECTED: 'Odrzucone', }; const ADMIN_REPORT_STATUS_LABELS: Record = { OPEN: 'Otwarte', RESOLVED: 'Rozpatrzone', }; const ADMIN_OFFER_LABELS: Record = { SALE: 'Sprzedaż', RENT: 'Wynajem', }; const ADMIN_PROPERTY_LABELS: Record = { APARTMENT: 'Mieszkanie', HOUSE: 'Dom', }; function formatAdminDate(iso: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) { return '-'; } return date.toLocaleDateString('pl-PL', { day: '2-digit', month: '2-digit', year: 'numeric' }); } function toDateInputValue(date: Date): string { const year = date.getFullYear(); const month = `${date.getMonth() + 1}`.padStart(2, '0'); const day = `${date.getDate()}`.padStart(2, '0'); return `${year}-${month}-${day}`; } type AdminStatsQuickRange = '' | 'today' | 'last7' | 'last14' | 'last30'; function accountStorageKey(suffix: string, user: AuthUser | null): string { return `accountProfile:${user?.id ?? 'guest'}:${suffix}`; } // Zakladki panelu maja odpowiedniki w sciezkach /admin/* - jedno zrodlo prawdy w routes.ts. type AdminTab = AdminTabKey; const ADMIN_TAB_META: Record = { dashboard: { title: 'Panel administratora', subtitle: 'Zarządzaj ogłoszeniami i użytkownikami serwisu.' }, listings: { title: 'Ogłoszenia', subtitle: 'Przeglądaj, zatwierdzaj i usuwaj ogłoszenia użytkowników.' }, users: { title: 'Użytkownicy', subtitle: 'Zarządzaj kontami zarejestrowanych użytkowników.' }, messages: { title: 'Wiadomości', subtitle: 'Podgląd wiadomości wymienianych między użytkownikami.' }, support: { title: 'Pomoc i kontakt', subtitle: 'Wiadomości wysłane przez formularz „Napisz do nas” na stronie pomocy.' }, advertisingEnquiries: { title: 'Zapytania o reklamę', subtitle: 'Zgłoszenia firm z formularza na stronie „Jeszcze nie mamy tej usługi” (/reklama).' }, reports: { title: 'Zgłoszenia', subtitle: 'Zgłoszenia dotyczące ogłoszeń i użytkowników wymagające uwagi.' }, articleRequests: { title: 'Poradniki - prośby', subtitle: 'Propozycje artykułów od użytkowników oczekujące na akceptację lub odrzucenie.' }, payments: { title: 'Płatności i promowanie', subtitle: 'Plany i pakiety promowania ofert, konfiguracja AutoPay oraz historia płatności.' }, stats: { title: 'Statystyki', subtitle: 'Statystyki aktywności i ruchu w serwisie.' }, settings: { title: 'Ustawienia serwisu', subtitle: 'Globalna konfiguracja serwisu Polska Lokalnie.' }, moderation: { title: 'Weryfikacja ogłoszeń', subtitle: 'Kolejka ogłoszeń oczekujących na moderację.' }, categories: { title: 'Kategorie', subtitle: 'Zarządzaj kategoriami i typami nieruchomości.' }, locations: { title: 'Lokalizacje', subtitle: 'Zarządzaj obsługiwanymi miastami i dzielnicami.' }, promotions: { title: 'Banery i promocje', subtitle: 'Zarządzaj banerami reklamowymi i promocjami.' }, forbiddenWords: { title: 'Zakazane słowa', subtitle: 'Lista wszystkich niedozwolonych słów używanych przez globalny filtr treści.' }, leads: { title: 'Leady', subtitle: 'Baza kontaktów marketingowych i grupy docelowe (dynamiczne segmenty).' }, campaigns: { title: 'Kampanie marketingowe', subtitle: 'Twórz i wysyłaj kampanie e-mail oraz SMS do grup docelowych.' }, smtpConfig: { title: 'Konfiguracja SMTP', subtitle: 'Ustawienia skrzynki e-mail (SMTP) dla kampanii i powiadomień.' }, smsConfig: { title: 'Bramka SMS', subtitle: 'Ustawienia bramki SMS dla kampanii i powiadomień.' }, }; const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ { id: 'dashboard', icon: 'house', label: 'Panel główny' }, { id: 'listings', icon: 'document', label: 'Ogłoszenia' }, { id: 'users', icon: 'user', label: 'Użytkownicy' }, { id: 'messages', icon: 'message', label: 'Wiadomości' }, { id: 'support', icon: 'mail', label: 'Pomoc i kontakt' }, { id: 'advertisingEnquiries', icon: 'megaphone', label: 'Zapytania o reklamę' }, { id: 'reports', icon: 'warning', label: 'Zgłoszenia' }, { id: 'articleRequests', icon: 'document', label: 'Poradniki - prośby' }, { id: 'leads', icon: 'user', label: 'Leady' }, { id: 'campaigns', icon: 'mail', label: 'Kampanie marketingowe' }, { id: 'payments', icon: 'credit-card', label: 'Płatności' }, { id: 'stats', icon: 'chart', label: 'Statystyki' }, { id: 'settings', icon: 'gear', label: 'Ustawienia serwisu' }, ]; const ADMIN_TOOL_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ { id: 'moderation', icon: 'check', label: 'Weryfikacja ogłoszeń' }, { id: 'smtpConfig', icon: 'mail', label: 'Konfiguracja SMTP' }, { id: 'smsConfig', icon: 'message', label: 'Bramka SMS' }, { id: 'forbiddenWords', icon: 'shield', label: 'Zakazane słowa' }, { id: 'categories', icon: 'list', label: 'Kategorie' }, { id: 'locations', icon: 'pin', label: 'Lokalizacje' }, { id: 'promotions', icon: 'spark', label: 'Banery i promocje' }, ]; function AdminPlaceholderCard({ icon, label }: { icon: string; label: string }) { return (
{label}

Ta sekcja jest w przygotowaniu i pojawi się w kolejnej aktualizacji panelu.

); } type AdminMessage = { id: number; senderId: number; recipientId: number; content: string; createdAt: string; mine: boolean; }; type AdminMessageThread = { user: AuthUser; lastMessage: AdminMessage; totalMessages: number; awaitingAdminReply: number; }; function AdminMessageModal({ user, onClose }: { user: AuthUser; onClose: () => void }) { const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [draft, setDraft] = useState(''); const [sending, setSending] = useState(false); const listRef = useRef(null); const loadMessages = useCallback(async (silent = false) => { if (!silent) { setLoading(true); } setError(null); try { const data = await apiFetch(`/admin/users/${user.id}/messages`); setMessages(data); } catch (err) { if (!silent) { setError(err instanceof Error ? err.message : 'Nie udało się pobrać wiadomości.'); } } finally { if (!silent) { setLoading(false); } } }, [user.id]); useEffect(() => { loadMessages(); }, [loadMessages]); useEffect(() => { const intervalId = window.setInterval(() => { loadMessages(true); }, 4000); return () => window.clearInterval(intervalId); }, [loadMessages]); useEffect(() => { if (listRef.current) { listRef.current.scrollTop = listRef.current.scrollHeight; } }, [messages]); const handleSend = async () => { const content = draft.trim(); if (!content || sending) { return; } setSending(true); setError(null); try { const sent = await apiFetch(`/admin/users/${user.id}/messages`, { method: 'POST', body: JSON.stringify({ content }), }); setMessages((current) => [...current, sent]); setDraft(''); } catch (err) { setError(err instanceof Error ? err.message : 'Nie udało się wysłać wiadomości.'); } finally { setSending(false); } }; return (

Wiadomość do: {user.fullName || user.email}

{user.email}

{error &&

{error}

}
{loading ? (

Wczytywanie wiadomości...

) : messages.length === 0 ? (

Brak wiadomości. Napisz pierwszą wiadomość do tego użytkownika.

) : ( messages.map((message) => (

{message.content}

{new Date(message.createdAt).toLocaleString('pl-PL', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
)) )}