dodawanie alerow cenowych
This commit is contained in:
+445
-6
@@ -9,7 +9,7 @@ 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 SMS_VERIFICATION_CODE = '123456';
|
||||
|
||||
type View = 'home' | 'buy' | 'rent' | 'sell' | 'valuation' | 'priceHistory' | 'districtRanking' | 'negotiation' | 'comparison' | 'add' | 'services' | 'guides' | 'map' | 'login' | 'account' | 'accountMeetings' | 'accountListings' | 'accountSettings' | 'accountSecurity' | 'accountProfileEdit' | 'accountPublicProfile' | 'notifications' | 'favorites' | 'messages' | 'admin' | 'listingDetail';
|
||||
type View = 'home' | 'buy' | 'rent' | 'sell' | 'valuation' | 'priceHistory' | 'districtRanking' | 'negotiation' | 'comparison' | 'add' | 'services' | 'guides' | 'map' | 'login' | 'account' | 'accountPriceAlerts' | 'accountMeetings' | 'accountListings' | 'accountSettings' | 'accountSecurity' | 'accountProfileEdit' | 'accountPublicProfile' | 'notifications' | 'favorites' | 'messages' | 'admin' | 'listingDetail';
|
||||
|
||||
type MessageThreadListing = {
|
||||
listingId: number;
|
||||
@@ -109,7 +109,7 @@ type LiveListingSummary = ApiListingSummary & {
|
||||
const LIVE_LISTINGS_REFRESH_MS = 15000;
|
||||
|
||||
const PROTECTED_VIEWS = new Set<View>([
|
||||
'add', 'admin', 'account', 'accountMeetings', 'accountListings', 'accountSettings',
|
||||
'add', 'admin', 'account', 'accountPriceAlerts', 'accountMeetings', 'accountListings', 'accountSettings',
|
||||
'accountSecurity', 'accountProfileEdit', 'accountPublicProfile', 'notifications', 'favorites', 'messages', 'comparison',
|
||||
]);
|
||||
|
||||
@@ -362,6 +362,73 @@ type FavoriteListing = {
|
||||
badge: string;
|
||||
};
|
||||
|
||||
type FavoritePriceAlertEvent = {
|
||||
id: string;
|
||||
listingId: number;
|
||||
title: string;
|
||||
location: string;
|
||||
area: string;
|
||||
previousPrice: number;
|
||||
currentPrice: number;
|
||||
changedAt: string;
|
||||
};
|
||||
|
||||
type CityPriceAlertSubscription = {
|
||||
key: string;
|
||||
label: string;
|
||||
city: string;
|
||||
region: string;
|
||||
propertyType: HistoryPropertyType;
|
||||
marketType: HistoryMarketType;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX = 'favorite-price-alert-events';
|
||||
const FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX = 'favorite-price-snapshots';
|
||||
const CITY_PRICE_ALERTS_STORAGE_SUFFIX = 'city-price-alert-subscriptions';
|
||||
const PRICE_ALERT_EVENTS_LIMIT = 40;
|
||||
|
||||
function readUserJsonStorage<T>(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<T>(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);
|
||||
}
|
||||
|
||||
const favoriteListings: FavoriteListing[] = [];
|
||||
|
||||
function favoriteListingKey(listingId: number): string {
|
||||
@@ -2325,6 +2392,11 @@ function App() {
|
||||
const [favoritesPreferredTab, setFavoritesPreferredTab] = useState<FavoriteTab>('buy');
|
||||
const [unreadFavoriteIds, setUnreadFavoriteIds] = useState<Set<string>>(new Set());
|
||||
const hydratedFavoriteIdsRef = useRef<Set<number>>(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 translatedOriginalTextRef = useRef<Map<Text, string>>(new Map());
|
||||
const translatedOriginalAttrRef = useRef<Map<Element, Record<string, string>>>(new Map());
|
||||
const translationObserverRef = useRef<MutationObserver | null>(null);
|
||||
@@ -2735,6 +2807,98 @@ function App() {
|
||||
};
|
||||
}, [favoriteListingsState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || favoriteListingIdList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let snapshots = readUserJsonStorage<Record<string, number>>(user, FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX, {});
|
||||
|
||||
const syncFavoritePrices = async () => {
|
||||
const refreshed = await Promise.all(
|
||||
favoriteListingIdList.map(async (listingId) => {
|
||||
try {
|
||||
return await apiFetch<ApiListingDetail>(`/listings/${listingId}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const incomingEvents: FavoritePriceAlertEvent[] = [];
|
||||
const nextSnapshots = { ...snapshots };
|
||||
const refreshedFavorites = new Map<number, FavoriteListing>();
|
||||
|
||||
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 (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²`,
|
||||
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<FavoritePriceAlertEvent[]>(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, []);
|
||||
const merged = mergePriceAlertEvents(incomingEvents, existingEvents);
|
||||
writeUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, merged);
|
||||
}
|
||||
|
||||
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, favoriteListingIdList, favoriteListingIdsSignature]);
|
||||
|
||||
return (
|
||||
<div className={`site-shell ${view === 'login' ? 'login-shell' : ''}`}>
|
||||
<Header activeView={view} onNavigate={navigate} onLogout={handleLogout} hasUnreadFavorites={unreadFavoriteIds.size > 0} />
|
||||
@@ -2742,6 +2906,7 @@ function App() {
|
||||
{view === 'login' && <LoginPage onLogin={handleAuthenticated} />}
|
||||
{view === 'admin' && <AdminPage onNavigate={navigate} />}
|
||||
{view === 'account' && <AccountDashboardPage onNavigate={navigate} activeView={view} />}
|
||||
{view === 'accountPriceAlerts' && <AccountPriceAlertsPage onNavigate={navigate} activeView={view} user={user} onOpenListing={openListing} />}
|
||||
{view === 'comparison' && <AccountComparisonPage onNavigate={navigate} activeView={view} onOpenListing={openListing} />}
|
||||
{view === 'accountMeetings' && <AccountMeetingsPage onNavigate={navigate} activeView={view} />}
|
||||
{view === 'accountListings' && <AccountListingsPage onNavigate={navigate} activeView={view} onOpenListing={openListing} />}
|
||||
@@ -2789,7 +2954,7 @@ function App() {
|
||||
)}
|
||||
{view === 'sell' && <SellPage onNavigate={navigate} />}
|
||||
{view === 'valuation' && <ValuationPage onNavigate={navigate} />}
|
||||
{view === 'priceHistory' && <PriceHistoryPage onNavigate={navigate} />}
|
||||
{view === 'priceHistory' && <PriceHistoryPage onNavigate={navigate} user={user} />}
|
||||
{view === 'districtRanking' && <DistrictRankingPage onNavigate={navigate} />}
|
||||
{view === 'negotiation' && (
|
||||
<NegotiationPage
|
||||
@@ -4248,6 +4413,7 @@ function AccountSidebar({ onNavigate, activeView }: { onNavigate: (view: View) =
|
||||
const isSettingsView = activeView === 'accountSettings' || activeView === 'accountProfileEdit' || activeView === 'accountPublicProfile';
|
||||
const isSecurityView = activeView === 'accountSecurity';
|
||||
const isDashboardView = activeView === 'account';
|
||||
const isPriceAlertsView = activeView === 'accountPriceAlerts';
|
||||
const isMeetingsView = activeView === 'accountMeetings';
|
||||
const isListingsView = activeView === 'accountListings';
|
||||
const isPriceHistoryView = activeView === 'priceHistory';
|
||||
@@ -4293,7 +4459,7 @@ function AccountSidebar({ onNavigate, activeView }: { onNavigate: (view: View) =
|
||||
<button><Icon name="search" /> Zapisane wyszukiwania <span>5</span></button>
|
||||
<button><Icon name="heart" /> Ulubione ogłoszenia <span>12</span></button>
|
||||
<button><Icon name="clock" /> Ostatnio oglądane</button>
|
||||
<button><Icon name="bell" /> Alerty cenowe <span>3</span></button>
|
||||
<button className={isPriceAlertsView ? 'active' : ''} onClick={() => onNavigate('accountPriceAlerts')}><Icon name="bell" /> Alerty cenowe <span>3</span></button>
|
||||
<button className={isComparisonView ? 'active' : ''} onClick={() => onNavigate('comparison')}><Icon name="shuffle" /> Porównanie mieszkań</button>
|
||||
</div>
|
||||
|
||||
@@ -4776,6 +4942,228 @@ function AccountDashboardPage({ onNavigate, activeView }: { onNavigate: (view: V
|
||||
);
|
||||
}
|
||||
|
||||
function AccountPriceAlertsPage({
|
||||
onNavigate,
|
||||
activeView,
|
||||
user,
|
||||
onOpenListing,
|
||||
}: {
|
||||
onNavigate: (view: View) => void;
|
||||
activeView: View;
|
||||
user: AuthUser | null;
|
||||
onOpenListing: (id: number) => void;
|
||||
}) {
|
||||
const [cityAlerts, setCityAlerts] = useState<CityPriceAlertSubscription[]>([]);
|
||||
const [priceEvents, setPriceEvents] = useState<FavoritePriceAlertEvent[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setCityAlerts([]);
|
||||
setPriceEvents([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const hydrate = () => {
|
||||
setCityAlerts(readUserJsonStorage<CityPriceAlertSubscription[]>(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []));
|
||||
setPriceEvents(readUserJsonStorage<FavoritePriceAlertEvent[]>(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, []));
|
||||
};
|
||||
|
||||
hydrate();
|
||||
const intervalId = window.setInterval(hydrate, 10000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [user]);
|
||||
|
||||
const latestEvent = priceEvents[0] ?? null;
|
||||
const cityInsights = useMemo(() => cityAlerts.map((subscription, index) => {
|
||||
const market = HISTORY_PRICE_MARKETS.find((item) => item.key === subscription.key) ?? findHistoryMarket(subscription.label);
|
||||
const district = historyShortMarketLabel(market ?? resolveHistoryMarket(subscription.label));
|
||||
const rawPrice = market?.apartmentM2 ?? estimateHistoryLocationPrice(subscription.label).price;
|
||||
const price = Math.round(rawPrice * HISTORY_PROPERTY_MULTIPLIERS[subscription.propertyType] * HISTORY_MARKET_MULTIPLIERS[subscription.marketType]);
|
||||
const delta = (market?.trend24 ?? (0.03 + ((index % 4) * 0.008))) * 100;
|
||||
return {
|
||||
key: subscription.key,
|
||||
district,
|
||||
price,
|
||||
delta,
|
||||
barWidth: Math.max(28, 78 - (index * 9)),
|
||||
};
|
||||
}), [cityAlerts]);
|
||||
|
||||
const cityAveragePrice = cityInsights.length > 0
|
||||
? Math.round(cityInsights.reduce((sum, item) => sum + item.price, 0) / cityInsights.length)
|
||||
: 0;
|
||||
const cityAverageDelta = cityInsights.length > 0
|
||||
? cityInsights.reduce((sum, item) => sum + item.delta, 0) / cityInsights.length
|
||||
: 0;
|
||||
|
||||
const activeAlerts = cityAlerts.map((subscription) => ({
|
||||
title: subscription.label,
|
||||
meta: `${subscription.propertyType} • ${subscription.marketType}`,
|
||||
key: subscription.key,
|
||||
}));
|
||||
|
||||
const removeAlert = (key: string) => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
setCityAlerts((current) => {
|
||||
const next = current.filter((item) => item.key !== key);
|
||||
writeUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const hasPriceDrop = latestEvent ? latestEvent.currentPrice < latestEvent.previousPrice : false;
|
||||
const latestDelta = latestEvent ? latestEvent.currentPrice - latestEvent.previousPrice : 0;
|
||||
const latestDeltaPercent = latestEvent && latestEvent.previousPrice > 0
|
||||
? (Math.abs(latestDelta) / latestEvent.previousPrice) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<section className="account-settings-page price-alerts-page" aria-label="Powiadomienia i alerty cenowe">
|
||||
<div className="account-layout">
|
||||
<AccountSidebar onNavigate={onNavigate} activeView={activeView} />
|
||||
|
||||
<div className="account-main account-settings-main">
|
||||
<div className="price-alerts-heading">
|
||||
<small>Strona główna <Icon name="arrow" /> Powiadomienia i alerty</small>
|
||||
<div>
|
||||
<h1>Powiadomienia i alerty</h1>
|
||||
<p>Śledź zmiany cen w wybranych lokalizacjach i otrzymuj powiadomienia, które pomogą Ci podjąć lepszą decyzję.</p>
|
||||
</div>
|
||||
<button type="button" className="price-alerts-settings">
|
||||
<Icon name="gear" /> Ustawienia alertów
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="price-alerts-hero">
|
||||
<div className="price-alerts-hero-main">
|
||||
<span className="price-alerts-badge">Alert cenowy</span>
|
||||
<h2>
|
||||
{latestEvent
|
||||
? (hasPriceDrop ? 'Cena mieszkania spadła!' : 'Cena mieszkania zmieniła się')
|
||||
: 'Brak nowych zmian cen'}
|
||||
</h2>
|
||||
<p>{latestEvent ? latestEvent.title : 'Dodaj oferty do ulubionych, aby śledzić zmiany cen.'}</p>
|
||||
<small><Icon name="pin" /> {latestEvent ? latestEvent.location : 'Wybierz mieszkania, które Cię interesują'}</small>
|
||||
<div className="price-alerts-pricing">
|
||||
<strong>{latestEvent ? formatPln(latestEvent.currentPrice) : '0 zł'}</strong>
|
||||
<em>{latestEvent ? formatPln(latestEvent.previousPrice) : '0 zł'}</em>
|
||||
<span>
|
||||
{latestEvent
|
||||
? `${latestDelta <= 0 ? '-' : '+'} ${formatPln(Math.abs(latestDelta))} (${latestDeltaPercent.toFixed(1).replace('.', ',')}%)`
|
||||
: 'Brak porównań'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="price-alerts-meta">
|
||||
<span>Powierzchnia: <b>{latestEvent ? latestEvent.area : '—'}</b></span>
|
||||
<span>
|
||||
Cena za m²: <b>
|
||||
{latestEvent
|
||||
? formatM2(Math.round(latestEvent.currentPrice / Math.max(1, Number(latestEvent.area.replace(/[^\d,]/g, '').replace(',', '.')) || 1)))
|
||||
: '—'}
|
||||
</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="price-alerts-hero-image" style={{ backgroundImage: `url(${heroImage})` }} />
|
||||
|
||||
<aside className="price-alerts-hero-side">
|
||||
<strong>Alert utworzony dla Ciebie</strong>
|
||||
<ul>
|
||||
<li>Ulubione oferty: <b>{priceEvents.length}</b></li>
|
||||
<li>Aktywne alerty lokalizacji: <b>{cityAlerts.length}</b></li>
|
||||
<li>Ostatnia aktualizacja: <b>{latestEvent ? new Date(latestEvent.changedAt).toLocaleString('pl-PL') : 'Brak'}</b></li>
|
||||
<li>Status: <b>{latestEvent ? 'Aktywny monitoring cen' : 'Czeka na pierwszą zmianę ceny'}</b></li>
|
||||
</ul>
|
||||
<button type="button" onClick={() => { if (latestEvent) { onOpenListing(latestEvent.listingId); } }} disabled={!latestEvent}>
|
||||
Zobacz ofertę <Icon name="arrow" />
|
||||
</button>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="price-alerts-grid">
|
||||
<article className="price-alerts-card">
|
||||
<header>
|
||||
<h3>Średnia zmiana ceny za m² w alertach</h3>
|
||||
<p>{cityInsights.length > 0 ? `Na podstawie ${cityInsights.length} aktywnych lokalizacji` : 'Dodaj lokalizację, aby aktywować wykres'}</p>
|
||||
</header>
|
||||
<div className="price-alerts-kpi">
|
||||
<strong>{cityInsights.length > 0 ? formatM2(cityAveragePrice) : '0 zł/m²'}</strong>
|
||||
<span>{cityInsights.length > 0 ? `${cityAverageDelta >= 0 ? '+' : ''}${cityAverageDelta.toFixed(1).replace('.', ',')}%` : '0,0%'}</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 320 130" aria-hidden="true" className="price-alerts-chart">
|
||||
<defs>
|
||||
<linearGradient id="priceChartFill" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#26b36a" stopOpacity="0.34" />
|
||||
<stop offset="100%" stopColor="#26b36a" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M20 102 L58 92 L89 94 L116 85 L146 80 L175 81 L203 74 L234 67 L266 69 L300 44"
|
||||
className="price-alerts-chart-line" />
|
||||
<path d="M20 102 L58 92 L89 94 L116 85 L146 80 L175 81 L203 74 L234 67 L266 69 L300 44 L300 120 L20 120 Z"
|
||||
fill="url(#priceChartFill)" />
|
||||
</svg>
|
||||
</article>
|
||||
|
||||
<article className="price-alerts-card">
|
||||
<header>
|
||||
<h3>Zmiana ceny za m² w dzielnicach</h3>
|
||||
<p>Ostatnie 12 miesięcy</p>
|
||||
</header>
|
||||
<div className="price-alerts-districts">
|
||||
{cityInsights.length > 0 ? cityInsights.slice(0, 5).map((item) => (
|
||||
<div key={item.key}>
|
||||
<strong>{item.district}</strong>
|
||||
<span className="price-alerts-district-bar"><i style={{ width: `${item.barWidth}%` }} /></span>
|
||||
<em>{formatM2(item.price)}</em>
|
||||
<b>{item.delta >= 0 ? '+' : ''}{item.delta.toFixed(1).replace('.', ',')}%</b>
|
||||
</div>
|
||||
)) : (
|
||||
<div>
|
||||
<strong>Brak danych</strong>
|
||||
<span className="price-alerts-district-bar"><i style={{ width: '28%' }} /></span>
|
||||
<em>0 zł/m²</em>
|
||||
<b>0,0%</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="price-alerts-link" onClick={() => onNavigate('priceHistory')}>Dodaj kolejne lokalizacje</button>
|
||||
</article>
|
||||
|
||||
<article className="price-alerts-card price-alerts-active-list">
|
||||
<header>
|
||||
<h3>Twoje alerty</h3>
|
||||
</header>
|
||||
<div className="price-alerts-active-items">
|
||||
{activeAlerts.length > 0 ? activeAlerts.map((item) => (
|
||||
<button key={item.key} type="button" onClick={() => removeAlert(item.key)}>
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<small>{item.meta}</small>
|
||||
</div>
|
||||
<span>Usuń</span>
|
||||
</button>
|
||||
)) : (
|
||||
<button type="button" onClick={() => onNavigate('priceHistory')}>
|
||||
<div>
|
||||
<strong>Brak aktywnych alertów</strong>
|
||||
<small>Przejdź do historii cen i dodaj pierwszą lokalizację.</small>
|
||||
</div>
|
||||
<span>Dodaj</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="price-alerts-manage" onClick={() => onNavigate('priceHistory')}>Zarządzaj alertami</button>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountComparisonPage({ onNavigate, activeView, onOpenListing }: { onNavigate: (view: View) => void; activeView: View; onOpenListing: (id: number) => void }) {
|
||||
type ComparisonPropertyFilter = 'ALL' | ApiPropertyType | 'LAND' | 'COMMERCIAL' | 'ROOM';
|
||||
|
||||
@@ -13039,12 +13427,14 @@ function historyShortMarketLabel(market: PriceHistoryMarket) {
|
||||
return tail;
|
||||
}
|
||||
|
||||
function PriceHistoryPage({ onNavigate }: { onNavigate: (view: View) => void }) {
|
||||
function PriceHistoryPage({ onNavigate, user }: { onNavigate: (view: View) => void; user: AuthUser | null }) {
|
||||
const [locationInput, setLocationInput] = useState('');
|
||||
const [selectedMarket, setSelectedMarket] = useState<PriceHistoryMarket | null>(null);
|
||||
const [propertyType, setPropertyType] = useState<HistoryPropertyType>('Mieszkanie');
|
||||
const [marketType, setMarketType] = useState<HistoryMarketType>('Wtórny i pierwotny');
|
||||
const [period, setPeriod] = useState<HistoryPeriod>('2026');
|
||||
const [createAlertFeedback, setCreateAlertFeedback] = useState<string | null>(null);
|
||||
const [cityAlertSubscriptions, setCityAlertSubscriptions] = useState<CityPriceAlertSubscription[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<PlaceSuggestion[]>([]);
|
||||
const [suggestOpen, setSuggestOpen] = useState(false);
|
||||
const [suggestLoading, setSuggestLoading] = useState(false);
|
||||
@@ -13119,6 +13509,9 @@ function PriceHistoryPage({ onNavigate }: { onNavigate: (view: View) => void })
|
||||
const tooltipHeight = 54;
|
||||
const tooltipX = hoveredPoint ? Math.min(chartWidth - tooltipWidth - 8, Math.max(8, hoveredPoint.x - tooltipWidth / 2)) : 0;
|
||||
const tooltipY = hoveredPoint ? Math.max(8, hoveredPoint.y - tooltipHeight - 14) : 0;
|
||||
const selectedMarketAlreadyAlerted = selectedMarket
|
||||
? cityAlertSubscriptions.some((item) => item.key === selectedMarket.key)
|
||||
: false;
|
||||
|
||||
const handleHistoryChartMouseMove = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!hasSelectedMarket) {
|
||||
@@ -13131,6 +13524,14 @@ function PriceHistoryPage({ onNavigate }: { onNavigate: (view: View) => void })
|
||||
setHoveredHistoryIndex(nextIndex);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setCityAlertSubscriptions([]);
|
||||
return;
|
||||
}
|
||||
setCityAlertSubscriptions(readUserJsonStorage<CityPriceAlertSubscription[]>(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []));
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipNextFetch.current) {
|
||||
skipNextFetch.current = false;
|
||||
@@ -13203,6 +13604,43 @@ function PriceHistoryPage({ onNavigate }: { onNavigate: (view: View) => void })
|
||||
setPeriod('2026');
|
||||
};
|
||||
|
||||
const createCityAlert = () => {
|
||||
if (!user) {
|
||||
setCreateAlertFeedback('Zaloguj się, aby dodać alert cenowy.');
|
||||
onNavigate('login');
|
||||
return;
|
||||
}
|
||||
if (!selectedMarket) {
|
||||
setCreateAlertFeedback('Najpierw wybierz lokalizację do monitorowania.');
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription: CityPriceAlertSubscription = {
|
||||
key: selectedMarket.key,
|
||||
label: selectedMarket.label,
|
||||
city: selectedMarket.city,
|
||||
region: selectedMarket.region,
|
||||
propertyType,
|
||||
marketType,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setCityAlertSubscriptions((current) => {
|
||||
if (current.some((item) => item.key === subscription.key)) {
|
||||
setCreateAlertFeedback('Ten alert już jest aktywny.');
|
||||
return current;
|
||||
}
|
||||
const next = [subscription, ...current];
|
||||
writeUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, next);
|
||||
setCreateAlertFeedback('Alert został dodany do Twojego panelu.');
|
||||
return next;
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
onNavigate('accountPriceAlerts');
|
||||
}, 280);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="history-page" aria-label="Historia cen">
|
||||
<div className="history-shell">
|
||||
@@ -13351,7 +13789,8 @@ function PriceHistoryPage({ onNavigate }: { onNavigate: (view: View) => void })
|
||||
<section className="history-alert-card">
|
||||
<span><Icon name="bell" /></span>
|
||||
<p><strong>Chcesz być na bieżąco?</strong><small>Ustaw alert cenowy i otrzymuj powiadomienia o zmianach cen w wybranej lokalizacji.</small></p>
|
||||
<button type="button"><Icon name="bell" /> Utwórz alert cenowy</button>
|
||||
<button type="button" onClick={createCityAlert}><Icon name="bell" /> {selectedMarketAlreadyAlerted ? 'Alert już aktywny' : 'Utwórz alert cenowy'}</button>
|
||||
{createAlertFeedback && <small>{createAlertFeedback}</small>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17099,6 +17099,403 @@ svg {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.price-alerts-page {
|
||||
background: linear-gradient(180deg, #f4f7fb 0%, #eef3f9 100%);
|
||||
}
|
||||
|
||||
.price-alerts-heading {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.price-alerts-heading small {
|
||||
align-items: center;
|
||||
color: #7a8ba0;
|
||||
display: inline-flex;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.price-alerts-heading small svg {
|
||||
color: #a2afc1;
|
||||
font-size: 12px;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.price-alerts-heading h1 {
|
||||
color: #162742;
|
||||
font-size: 38px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.price-alerts-heading p {
|
||||
color: #5d7088;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
margin: 8px 0 0;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
.price-alerts-settings {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe5f0;
|
||||
border-radius: 9px;
|
||||
color: #2a3e58;
|
||||
display: inline-flex;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
gap: 7px;
|
||||
height: 38px;
|
||||
justify-self: end;
|
||||
padding: 0 13px;
|
||||
}
|
||||
|
||||
.price-alerts-hero {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dfe8f1;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: minmax(280px, 1fr) minmax(220px, 360px) minmax(200px, 280px);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.price-alerts-hero-main h2 {
|
||||
color: #152841;
|
||||
font-size: 33px;
|
||||
line-height: 1.05;
|
||||
margin: 8px 0 2px;
|
||||
}
|
||||
|
||||
.price-alerts-hero-main p {
|
||||
color: #2b4059;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.price-alerts-hero-main > small {
|
||||
align-items: center;
|
||||
color: #5f738c;
|
||||
display: inline-flex;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
gap: 5px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.price-alerts-badge {
|
||||
background: #e9f8ef;
|
||||
border-radius: 999px;
|
||||
color: #0f9657;
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 3px 9px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.price-alerts-pricing {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 11px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.price-alerts-pricing strong {
|
||||
color: #129256;
|
||||
font-size: 39px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.price-alerts-pricing em {
|
||||
color: #8191a5;
|
||||
font-size: 19px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.price-alerts-pricing span {
|
||||
color: #f04444;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.price-alerts-meta {
|
||||
color: #71839b;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
gap: 8px 18px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.price-alerts-meta b {
|
||||
color: #2a405a;
|
||||
}
|
||||
|
||||
.price-alerts-hero-image {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
border-radius: 12px;
|
||||
min-height: 210px;
|
||||
}
|
||||
|
||||
.price-alerts-hero-side {
|
||||
background: #f4fbf7;
|
||||
border: 1px solid #dfeee6;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.price-alerts-hero-side strong {
|
||||
color: #148c53;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.price-alerts-hero-side ul {
|
||||
color: #3d556d;
|
||||
display: grid;
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
gap: 6px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.price-alerts-hero-side b {
|
||||
color: #1f354f;
|
||||
}
|
||||
|
||||
.price-alerts-hero-side button {
|
||||
align-items: center;
|
||||
background: #17a762;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
display: inline-flex;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
gap: 7px;
|
||||
height: 35px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.price-alerts-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.price-alerts-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dfe8f1;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.price-alerts-card header h3 {
|
||||
color: #182a45;
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.price-alerts-card header p {
|
||||
color: #6b7d93;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
margin: 5px 0 0;
|
||||
}
|
||||
|
||||
.price-alerts-kpi {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.price-alerts-kpi strong {
|
||||
color: #162a44;
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.price-alerts-kpi span {
|
||||
color: #14a35f;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.price-alerts-chart {
|
||||
height: 132px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.price-alerts-chart-line {
|
||||
fill: none;
|
||||
stroke: #26b36a;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.price-alerts-districts {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.price-alerts-districts div {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: 88px minmax(90px, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.price-alerts-districts strong {
|
||||
color: #233852;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.price-alerts-district-bar {
|
||||
background: #edf1f6;
|
||||
border-radius: 999px;
|
||||
display: block;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.price-alerts-district-bar i {
|
||||
background: linear-gradient(90deg, #25b269 0%, #5ac987 100%);
|
||||
border-radius: 999px;
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.price-alerts-districts em {
|
||||
color: #2d435e;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.price-alerts-districts b {
|
||||
color: #15a35f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.price-alerts-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #18a661;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
justify-self: start;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.price-alerts-active-list {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.price-alerts-active-items {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.price-alerts-active-items button {
|
||||
align-items: center;
|
||||
background: #fbfcfe;
|
||||
border: 1px solid #e2eaf3;
|
||||
border-radius: 9px;
|
||||
color: #21364f;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 9px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.price-alerts-active-items strong {
|
||||
color: #1d3049;
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.price-alerts-active-items small {
|
||||
color: #6d7f96;
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.price-alerts-active-items span {
|
||||
background: #e9f8ef;
|
||||
border-radius: 999px;
|
||||
color: #119659;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.price-alerts-manage {
|
||||
background: #f5f8fc;
|
||||
border: 1px solid #dce6f1;
|
||||
border-radius: 8px;
|
||||
color: #334a64;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
.price-alerts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.price-alerts-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.price-alerts-heading h1 {
|
||||
font-size: 31px;
|
||||
}
|
||||
|
||||
.price-alerts-settings {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 740px) {
|
||||
.price-alerts-pricing strong {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.price-alerts-pricing em {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.price-alerts-pricing span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.price-alerts-districts div {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-head h1 {
|
||||
color: #1a2c46;
|
||||
font-size: 43px;
|
||||
|
||||
Reference in New Issue
Block a user