Pascal zmiany Alerty cenowe + Pomoc i kontakt #1

Merged
pascal merged 3 commits from pascal-zmiany into main 2026-07-13 01:22:21 +02:00
2 changed files with 842 additions and 6 deletions
Showing only changes of commit 3e00962828 - Show all commits
+445 -6
View File
@@ -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 loginRoomImage = new URL('./assets/login-room.png', import.meta.url).href;
const SMS_VERIFICATION_CODE = '123456'; 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 = { type MessageThreadListing = {
listingId: number; listingId: number;
@@ -109,7 +109,7 @@ type LiveListingSummary = ApiListingSummary & {
const LIVE_LISTINGS_REFRESH_MS = 15000; const LIVE_LISTINGS_REFRESH_MS = 15000;
const PROTECTED_VIEWS = new Set<View>([ 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', 'accountSecurity', 'accountProfileEdit', 'accountPublicProfile', 'notifications', 'favorites', 'messages', 'comparison',
]); ]);
@@ -362,6 +362,73 @@ type FavoriteListing = {
badge: string; 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[] = []; const favoriteListings: FavoriteListing[] = [];
function favoriteListingKey(listingId: number): string { function favoriteListingKey(listingId: number): string {
@@ -2325,6 +2392,11 @@ function App() {
const [favoritesPreferredTab, setFavoritesPreferredTab] = useState<FavoriteTab>('buy'); const [favoritesPreferredTab, setFavoritesPreferredTab] = useState<FavoriteTab>('buy');
const [unreadFavoriteIds, setUnreadFavoriteIds] = useState<Set<string>>(new Set()); const [unreadFavoriteIds, setUnreadFavoriteIds] = useState<Set<string>>(new Set());
const hydratedFavoriteIdsRef = useRef<Set<number>>(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 translatedOriginalTextRef = useRef<Map<Text, string>>(new Map());
const translatedOriginalAttrRef = useRef<Map<Element, Record<string, string>>>(new Map()); const translatedOriginalAttrRef = useRef<Map<Element, Record<string, string>>>(new Map());
const translationObserverRef = useRef<MutationObserver | null>(null); const translationObserverRef = useRef<MutationObserver | null>(null);
@@ -2735,6 +2807,98 @@ function App() {
}; };
}, [favoriteListingsState]); }, [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('.', ',')}`,
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 ( return (
<div className={`site-shell ${view === 'login' ? 'login-shell' : ''}`}> <div className={`site-shell ${view === 'login' ? 'login-shell' : ''}`}>
<Header activeView={view} onNavigate={navigate} onLogout={handleLogout} hasUnreadFavorites={unreadFavoriteIds.size > 0} /> <Header activeView={view} onNavigate={navigate} onLogout={handleLogout} hasUnreadFavorites={unreadFavoriteIds.size > 0} />
@@ -2742,6 +2906,7 @@ function App() {
{view === 'login' && <LoginPage onLogin={handleAuthenticated} />} {view === 'login' && <LoginPage onLogin={handleAuthenticated} />}
{view === 'admin' && <AdminPage onNavigate={navigate} />} {view === 'admin' && <AdminPage onNavigate={navigate} />}
{view === 'account' && <AccountDashboardPage onNavigate={navigate} activeView={view} />} {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 === 'comparison' && <AccountComparisonPage onNavigate={navigate} activeView={view} onOpenListing={openListing} />}
{view === 'accountMeetings' && <AccountMeetingsPage onNavigate={navigate} activeView={view} />} {view === 'accountMeetings' && <AccountMeetingsPage onNavigate={navigate} activeView={view} />}
{view === 'accountListings' && <AccountListingsPage onNavigate={navigate} activeView={view} onOpenListing={openListing} />} {view === 'accountListings' && <AccountListingsPage onNavigate={navigate} activeView={view} onOpenListing={openListing} />}
@@ -2789,7 +2954,7 @@ function App() {
)} )}
{view === 'sell' && <SellPage onNavigate={navigate} />} {view === 'sell' && <SellPage onNavigate={navigate} />}
{view === 'valuation' && <ValuationPage 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 === 'districtRanking' && <DistrictRankingPage onNavigate={navigate} />}
{view === 'negotiation' && ( {view === 'negotiation' && (
<NegotiationPage <NegotiationPage
@@ -4248,6 +4413,7 @@ function AccountSidebar({ onNavigate, activeView }: { onNavigate: (view: View) =
const isSettingsView = activeView === 'accountSettings' || activeView === 'accountProfileEdit' || activeView === 'accountPublicProfile'; const isSettingsView = activeView === 'accountSettings' || activeView === 'accountProfileEdit' || activeView === 'accountPublicProfile';
const isSecurityView = activeView === 'accountSecurity'; const isSecurityView = activeView === 'accountSecurity';
const isDashboardView = activeView === 'account'; const isDashboardView = activeView === 'account';
const isPriceAlertsView = activeView === 'accountPriceAlerts';
const isMeetingsView = activeView === 'accountMeetings'; const isMeetingsView = activeView === 'accountMeetings';
const isListingsView = activeView === 'accountListings'; const isListingsView = activeView === 'accountListings';
const isPriceHistoryView = activeView === 'priceHistory'; 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="search" /> Zapisane wyszukiwania <span>5</span></button>
<button><Icon name="heart" /> Ulubione ogłoszenia <span>12</span></button> <button><Icon name="heart" /> Ulubione ogłoszenia <span>12</span></button>
<button><Icon name="clock" /> Ostatnio oglądane</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> <button className={isComparisonView ? 'active' : ''} onClick={() => onNavigate('comparison')}><Icon name="shuffle" /> Porównanie mieszkań</button>
</div> </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 /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 }) { function AccountComparisonPage({ onNavigate, activeView, onOpenListing }: { onNavigate: (view: View) => void; activeView: View; onOpenListing: (id: number) => void }) {
type ComparisonPropertyFilter = 'ALL' | ApiPropertyType | 'LAND' | 'COMMERCIAL' | 'ROOM'; type ComparisonPropertyFilter = 'ALL' | ApiPropertyType | 'LAND' | 'COMMERCIAL' | 'ROOM';
@@ -13039,12 +13427,14 @@ function historyShortMarketLabel(market: PriceHistoryMarket) {
return tail; 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 [locationInput, setLocationInput] = useState('');
const [selectedMarket, setSelectedMarket] = useState<PriceHistoryMarket | null>(null); const [selectedMarket, setSelectedMarket] = useState<PriceHistoryMarket | null>(null);
const [propertyType, setPropertyType] = useState<HistoryPropertyType>('Mieszkanie'); const [propertyType, setPropertyType] = useState<HistoryPropertyType>('Mieszkanie');
const [marketType, setMarketType] = useState<HistoryMarketType>('Wtórny i pierwotny'); const [marketType, setMarketType] = useState<HistoryMarketType>('Wtórny i pierwotny');
const [period, setPeriod] = useState<HistoryPeriod>('2026'); const [period, setPeriod] = useState<HistoryPeriod>('2026');
const [createAlertFeedback, setCreateAlertFeedback] = useState<string | null>(null);
const [cityAlertSubscriptions, setCityAlertSubscriptions] = useState<CityPriceAlertSubscription[]>([]);
const [suggestions, setSuggestions] = useState<PlaceSuggestion[]>([]); const [suggestions, setSuggestions] = useState<PlaceSuggestion[]>([]);
const [suggestOpen, setSuggestOpen] = useState(false); const [suggestOpen, setSuggestOpen] = useState(false);
const [suggestLoading, setSuggestLoading] = useState(false); const [suggestLoading, setSuggestLoading] = useState(false);
@@ -13119,6 +13509,9 @@ function PriceHistoryPage({ onNavigate }: { onNavigate: (view: View) => void })
const tooltipHeight = 54; const tooltipHeight = 54;
const tooltipX = hoveredPoint ? Math.min(chartWidth - tooltipWidth - 8, Math.max(8, hoveredPoint.x - tooltipWidth / 2)) : 0; 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 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>) => { const handleHistoryChartMouseMove = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!hasSelectedMarket) { if (!hasSelectedMarket) {
@@ -13131,6 +13524,14 @@ function PriceHistoryPage({ onNavigate }: { onNavigate: (view: View) => void })
setHoveredHistoryIndex(nextIndex); setHoveredHistoryIndex(nextIndex);
}; };
useEffect(() => {
if (!user) {
setCityAlertSubscriptions([]);
return;
}
setCityAlertSubscriptions(readUserJsonStorage<CityPriceAlertSubscription[]>(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []));
}, [user]);
useEffect(() => { useEffect(() => {
if (skipNextFetch.current) { if (skipNextFetch.current) {
skipNextFetch.current = false; skipNextFetch.current = false;
@@ -13203,6 +13604,43 @@ function PriceHistoryPage({ onNavigate }: { onNavigate: (view: View) => void })
setPeriod('2026'); 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 ( return (
<section className="history-page" aria-label="Historia cen"> <section className="history-page" aria-label="Historia cen">
<div className="history-shell"> <div className="history-shell">
@@ -13351,7 +13789,8 @@ function PriceHistoryPage({ onNavigate }: { onNavigate: (view: View) => void })
<section className="history-alert-card"> <section className="history-alert-card">
<span><Icon name="bell" /></span> <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> <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> </section>
</div> </div>
+397
View File
@@ -17099,6 +17099,403 @@ svg {
align-content: start; 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 { .settings-head h1 {
color: #1a2c46; color: #1a2c46;
font-size: 43px; font-size: 43px;