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 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('.', ',')} 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 (
|
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 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 }) {
|
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user