Merge branch 'main' of https://github.com/rubyhrzhang/property-map
This commit is contained in:
commit
afa6934a2d
91 changed files with 2122 additions and 1360 deletions
|
|
@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
|
|||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import type { SavedSearch } from '../../hooks/useSavedSearches';
|
||||
import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api';
|
||||
import { copyToClipboard } from '../../lib/clipboard';
|
||||
import { formatRelativeTime } from '../../lib/format';
|
||||
import { summarizeParams } from '../../lib/url-state';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
|
|
@ -42,37 +43,24 @@ function SavedSearchesContent({
|
|||
setDeleteConfirmId(null);
|
||||
}, [deleteConfirmId, onDelete]);
|
||||
|
||||
const copyToClipboard = useCallback((text: string, id: string) => {
|
||||
const onSuccess = () => {
|
||||
const doCopy = useCallback((text: string, id: string) => {
|
||||
copyToClipboard(text, () => {
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
onSuccess();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleShare = useCallback(async (params: string, id: string) => {
|
||||
setSharingId(id);
|
||||
try {
|
||||
const shortUrl = await shortenUrl(params);
|
||||
copyToClipboard(shortUrl, id);
|
||||
doCopy(shortUrl, id);
|
||||
} catch {
|
||||
copyToClipboard(`${window.location.origin}/?${params}`, id);
|
||||
doCopy(`${window.location.origin}/?${params}`, id);
|
||||
} finally {
|
||||
setSharingId(null);
|
||||
}
|
||||
}, [copyToClipboard]);
|
||||
}, [doCopy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -270,7 +258,7 @@ function SettingsContent({
|
|||
|
||||
const handleCopyInvite = () => {
|
||||
if (!inviteUrl) return;
|
||||
navigator.clipboard.writeText(inviteUrl).then(() => {
|
||||
copyToClipboard(inviteUrl, () => {
|
||||
setInviteCopied(true);
|
||||
setTimeout(() => setInviteCopied(false), 2000);
|
||||
});
|
||||
|
|
@ -284,7 +272,7 @@ function SettingsContent({
|
|||
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className="max-w-lg mx-auto space-y-6">
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 divide-y divide-warm-200 dark:divide-warm-700">
|
||||
{/* Email */}
|
||||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
|
|
@ -380,7 +368,7 @@ function SettingsContent({
|
|||
{isLicensed && (
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
|
||||
{user.isAdmin ? 'Generate invite link (free access)' : 'Invite friends (30% off)'}
|
||||
{user.isAdmin ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}
|
||||
</p>
|
||||
{inviteUrl ? (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -409,7 +397,7 @@ function SettingsContent({
|
|||
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2"
|
||||
>
|
||||
{creatingInvite && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{user.isAdmin ? 'Generate invite link' : 'Generate referral link'}
|
||||
{user.isAdmin ? 'Generate free invite link' : 'Generate referral link'}
|
||||
</button>
|
||||
)}
|
||||
{inviteError && (
|
||||
|
|
@ -455,6 +443,20 @@ function SettingsContent({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Support */}
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
||||
<p className="text-warm-600 dark:text-warm-300 mb-2">Need help? Email us at</p>
|
||||
<a
|
||||
href="mailto:support@propertymap.co.uk"
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
|
||||
>
|
||||
support@propertymap.co.uk
|
||||
</a>
|
||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
|
||||
We typically respond within 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useFadeInRef } from '../../hooks/useFadeIn';
|
||||
import HexCanvas from './HexCanvas';
|
||||
import ScrollStory from './ScrollStory';
|
||||
|
|
@ -6,6 +6,7 @@ import BottomIllustration from './BottomIllustration';
|
|||
import { TickerValue } from '../ui/TickerValue';
|
||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||
import { LogoIcon } from '../ui/icons/LogoIcon';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import type { FeatureMeta } from '../../types';
|
||||
|
||||
export default function HomePage({
|
||||
|
|
@ -30,6 +31,35 @@ export default function HomePage({
|
|||
const whyRef = useFadeInRef();
|
||||
const ctaRef = useFadeInRef();
|
||||
|
||||
// Scroll depth tracking
|
||||
const scrolledSections = useRef(new Set<string>());
|
||||
useEffect(() => {
|
||||
const ids = ['how-it-works', 'demo'];
|
||||
const observers: IntersectionObserver[] = [];
|
||||
ids.forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && !scrolledSections.current.has(id)) {
|
||||
scrolledSections.current.add(id);
|
||||
trackEvent('Scroll Depth', { section: id });
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
observer.observe(el);
|
||||
observers.push(observer);
|
||||
});
|
||||
return () => observers.forEach((o) => o.disconnect());
|
||||
}, []);
|
||||
|
||||
// 30s time-on-page event
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => trackEvent('Time on Page', { seconds: '30' }), 30000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
|
||||
<div className="relative" style={{ zIndex: 1 }}>
|
||||
|
|
@ -54,13 +84,17 @@ export default function HomePage({
|
|||
</p>
|
||||
<div className="flex items-center gap-4 mb-10">
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
onClick={() => {
|
||||
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
|
||||
onOpenDashboard();
|
||||
}}
|
||||
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
Explore the map
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
|
||||
const target = document.getElementById('comparison');
|
||||
if (!target) return;
|
||||
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
|
||||
|
|
@ -106,6 +140,7 @@ export default function HomePage({
|
|||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
trackEvent('CTA Click', { location: 'hero', label: 'see_it_in_action' });
|
||||
const target = document.getElementById('demo');
|
||||
if (!target) return;
|
||||
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
|
||||
|
|
@ -256,7 +291,10 @@ export default function HomePage({
|
|||
This deserves proper tools behind it — don't leave it to luck.
|
||||
</p>
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
onClick={() => {
|
||||
trackEvent('CTA Click', { location: 'bottom', label: 'explore_map' });
|
||||
onOpenDashboard();
|
||||
}}
|
||||
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
Explore the map
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ function FAQItemCard({ item }: { item: FAQItem }) {
|
|||
}
|
||||
|
||||
export default function LearnPage() {
|
||||
const [tab, setTab] = useState<LearnTab>('data-sources');
|
||||
const [tab, setTab] = useState<LearnTab>('faq');
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -299,12 +299,12 @@ export default function LearnPage() {
|
|||
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-navy-950 flex flex-col">
|
||||
<div className="max-w-5xl mx-auto w-full px-6 pt-6">
|
||||
<div className="flex gap-2 border-b border-warm-200 dark:border-warm-700">
|
||||
<button className={tabClass('data-sources')} onClick={() => setTab('data-sources')}>
|
||||
Data Sources
|
||||
</button>
|
||||
<button className={tabClass('faq')} onClick={() => setTab('faq')}>
|
||||
FAQ
|
||||
</button>
|
||||
<button className={tabClass('data-sources')} onClick={() => setTab('data-sources')}>
|
||||
Data Sources
|
||||
</button>
|
||||
<button className={tabClass('support')} onClick={() => setTab('support')}>
|
||||
Support
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -23,24 +23,24 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
|
|||
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<form onSubmit={handleSubmit} className="flex gap-1.5">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Describe your ideal property..."
|
||||
className="flex-1 min-w-0 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400"
|
||||
placeholder="Describe your ideal property and area..."
|
||||
className="w-full px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !query.trim()}
|
||||
className="shrink-0 px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center gap-1.5"
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{loading ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'AI'
|
||||
'Set filters with AI'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@ export default function AreaPane({
|
|||
{propertyCount.toLocaleString()} properties
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
|
||||
Stats for {isPostcode ? 'current and historical' : 'all'} properties
|
||||
in this {isPostcode ? 'postcode' : 'area'}
|
||||
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
|
||||
</p>
|
||||
{!isPostcode && stats && (
|
||||
<button
|
||||
onClick={onViewProperties}
|
||||
|
|
@ -128,6 +133,7 @@ export default function AreaPane({
|
|||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
<div>
|
||||
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
||||
<HistogramLegend />
|
||||
{featureGroups.map((group) => {
|
||||
const hasData = group.features.some(
|
||||
|
|
@ -370,7 +376,6 @@ export default function AreaPane({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,35 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
import {
|
||||
buildPropertySearchUrls,
|
||||
H3_RADIUS_MILES,
|
||||
type HexagonLocation,
|
||||
} from '../../lib/external-search';
|
||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||
|
||||
function useRightmoveLocationId(postcode: string | undefined): string | undefined {
|
||||
const [locationId, setLocationId] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!postcode) {
|
||||
setLocationId(undefined);
|
||||
return;
|
||||
}
|
||||
setLocationId(undefined);
|
||||
const controller = new AbortController();
|
||||
fetch(apiUrl('rightmove-location', new URLSearchParams({ postcode })), {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.location_identifier) setLocationId(data.location_identifier);
|
||||
})
|
||||
.catch((err) => logNonAbortError('rightmove-location', err));
|
||||
return () => controller.abort();
|
||||
}, [postcode]);
|
||||
|
||||
return locationId;
|
||||
}
|
||||
|
||||
export default function ExternalSearchLinks({
|
||||
location,
|
||||
|
|
@ -13,29 +38,46 @@ export default function ExternalSearchLinks({
|
|||
location: HexagonLocation;
|
||||
filters: FeatureFilters;
|
||||
}) {
|
||||
const urls = useMemo(() => buildPropertySearchUrls(location, filters), [location, filters]);
|
||||
const radiusMiles = H3_RADIUS_MILES[location.resolution] ?? 1;
|
||||
const rightmoveLocationId = useRightmoveLocationId(location.postcode);
|
||||
const urls = useMemo(
|
||||
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
|
||||
[location, filters, rightmoveLocationId]
|
||||
);
|
||||
const radiusMiles = location.isPostcode ? 0.25 : (H3_RADIUS_MILES[location.resolution] ?? 1);
|
||||
const label = `${radiusMiles}mi radius`;
|
||||
|
||||
if (!urls) return null;
|
||||
|
||||
const linkClass =
|
||||
'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium';
|
||||
const disabledClass =
|
||||
'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-400 dark:text-warm-500 font-medium cursor-default';
|
||||
|
||||
return (
|
||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
|
||||
Search {label} on
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={urls.rightmove}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
||||
>
|
||||
Rightmove
|
||||
</a>
|
||||
{urls.rightmove ? (
|
||||
<a
|
||||
href={urls.rightmove}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
>
|
||||
Rightmove
|
||||
</a>
|
||||
) : (
|
||||
<span className={disabledClass} title="Loading...">
|
||||
Rightmove
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={urls.onthemarket}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
||||
className={linkClass}
|
||||
>
|
||||
OnTheMarket
|
||||
</a>
|
||||
|
|
@ -43,7 +85,7 @@ export default function ExternalSearchLinks({
|
|||
href={urls.zoopla}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
||||
className={linkClass}
|
||||
>
|
||||
Zoopla
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -9,9 +9,17 @@ import { groupFeaturesByCategory } from '../../lib/features';
|
|||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import { RouteIcon, PlusIcon, EyeIcon } from '../ui/icons';
|
||||
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
|
||||
import type { ComponentType } from 'react';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
|
||||
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
|
||||
car: CarIcon,
|
||||
bicycle: BicycleIcon,
|
||||
walking: WalkingIcon,
|
||||
transit: TransitIcon,
|
||||
};
|
||||
|
||||
interface FeatureBrowserProps {
|
||||
availableFeatures: FeatureMeta[];
|
||||
|
|
@ -24,6 +32,8 @@ interface FeatureBrowserProps {
|
|||
onClearOpenInfoFeature?: () => void;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
onAddTravelTimeEntry: (mode: TransportMode) => void;
|
||||
isLicensed: boolean;
|
||||
onUpgradeClick?: () => void;
|
||||
}
|
||||
|
||||
export default function FeatureBrowser({
|
||||
|
|
@ -37,6 +47,8 @@ export default function FeatureBrowser({
|
|||
onClearOpenInfoFeature,
|
||||
travelTimeEntries,
|
||||
onAddTravelTimeEntry,
|
||||
isLicensed,
|
||||
onUpgradeClick,
|
||||
}: FeatureBrowserProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
|
|
@ -77,23 +89,21 @@ export default function FeatureBrowser({
|
|||
name="Travel Time"
|
||||
expanded={isSearching || expandedGroups.has('Travel Time')}
|
||||
onToggle={() => toggleGroup('Travel Time')}
|
||||
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{TRANSPORT_MODES.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => {
|
||||
const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug);
|
||||
const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null;
|
||||
const isPinned = fieldKey !== null && pinnedFeature === fieldKey;
|
||||
const ModeIcon = MODE_ICONS[mode];
|
||||
return (
|
||||
<div
|
||||
key={mode}
|
||||
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
|
||||
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
{MODE_LABELS[mode]}
|
||||
|
|
@ -104,16 +114,6 @@ export default function FeatureBrowser({
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{fieldKey && (
|
||||
<IconButton
|
||||
onClick={() => onTogglePin(fieldKey)}
|
||||
active={isPinned}
|
||||
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
|
||||
size="md"
|
||||
>
|
||||
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md">
|
||||
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
|
|
@ -131,7 +131,7 @@ export default function FeatureBrowser({
|
|||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
|
|
@ -174,10 +174,31 @@ export default function FeatureBrowser({
|
|||
}
|
||||
className="px-3 py-4"
|
||||
/>
|
||||
) : (
|
||||
) : isLicensed ? (
|
||||
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
|
||||
Everyone cares about different things. Pick the filters that matter most to you.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-auto flex flex-col items-center px-5 pt-6 pb-0">
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
|
||||
The biggest financial decision of your life deserves proper tools behind it.
|
||||
</p>
|
||||
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
|
||||
Don't leave it to chance.
|
||||
</p>
|
||||
<button
|
||||
onClick={onUpgradeClick}
|
||||
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
|
||||
>
|
||||
Upgrade to full map
|
||||
</button>
|
||||
<svg viewBox="0 120 1600 230" className="w-full mt-4 block shrink-0" preserveAspectRatio="xMidYMax meet">
|
||||
<path d="M0,350 C400,150 1200,150 1600,350 Z" className="fill-green-500 dark:fill-green-600" />
|
||||
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
|
||||
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
|
||||
<image href="/house.png" x="735" y="110" width="130" height="120" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{infoFeature && (
|
||||
|
|
|
|||
|
|
@ -25,27 +25,20 @@ import {
|
|||
|
||||
type ListingType = 'historical' | 'buy' | 'rent';
|
||||
|
||||
const MODE_RESTRICTED_FEATURES: Record<string, Set<ListingType>> = {
|
||||
'Bathrooms': new Set(['buy', 'rent']),
|
||||
};
|
||||
|
||||
function isFeatureAllowedInMode(featureName: string, mode: ListingType): boolean {
|
||||
const allowed = MODE_RESTRICTED_FEATURES[featureName];
|
||||
return !allowed || allowed.has(mode);
|
||||
}
|
||||
|
||||
function SliderLabels({
|
||||
min,
|
||||
max,
|
||||
value,
|
||||
displayValues,
|
||||
absoluteMax,
|
||||
isAtMin,
|
||||
isAtMax,
|
||||
}: {
|
||||
min: number;
|
||||
max: number;
|
||||
value: [number, number];
|
||||
displayValues?: [number, number];
|
||||
absoluteMax?: boolean;
|
||||
isAtMin?: boolean;
|
||||
isAtMax?: boolean;
|
||||
}) {
|
||||
const range = max - min || 1;
|
||||
const leftPct = ((value[0] - min) / range) * 100;
|
||||
|
|
@ -57,13 +50,13 @@ function SliderLabels({
|
|||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${leftPct}%` }}
|
||||
>
|
||||
{formatFilterValue(labels[0])}
|
||||
{isAtMin ? 'min' : formatFilterValue(labels[0])}
|
||||
</span>
|
||||
<span
|
||||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${rightPct}%` }}
|
||||
>
|
||||
{formatFilterValue(labels[1])}{absoluteMax && value[1] >= max ? '+' : ''}
|
||||
{isAtMax ? 'max' : formatFilterValue(labels[1])}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -87,15 +80,18 @@ interface FiltersProps {
|
|||
openInfoFeature?: string | null;
|
||||
onClearOpenInfoFeature?: () => void;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
travelTimeDataRanges: Map<number, [number, number]>;
|
||||
onTravelTimeAddEntry: (mode: TransportMode) => void;
|
||||
onTravelTimeRemoveEntry: (index: number) => void;
|
||||
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
|
||||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||
onTravelTimeToggleBest: (index: number) => void;
|
||||
aiFilterLoading: boolean;
|
||||
aiFilterError: string | null;
|
||||
aiFilterNotes: string | null;
|
||||
onAiFilterSubmit: (query: string) => void;
|
||||
isLicensed: boolean;
|
||||
onUpgradeClick?: () => void;
|
||||
onResetTutorial?: () => void;
|
||||
}
|
||||
|
||||
export default memo(function Filters({
|
||||
|
|
@ -116,16 +112,49 @@ export default memo(function Filters({
|
|||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
travelTimeEntries,
|
||||
travelTimeDataRanges,
|
||||
onTravelTimeAddEntry,
|
||||
onTravelTimeRemoveEntry,
|
||||
onTravelTimeSetDestination,
|
||||
onTravelTimeRangeChange,
|
||||
onTravelTimeToggleBest,
|
||||
aiFilterLoading,
|
||||
aiFilterError,
|
||||
aiFilterNotes,
|
||||
onAiFilterSubmit,
|
||||
isLicensed,
|
||||
onUpgradeClick,
|
||||
onResetTutorial,
|
||||
}: FiltersProps) {
|
||||
const modeRestrictions = useMemo(() => {
|
||||
const map: Record<string, Set<ListingType>> = {};
|
||||
for (const f of features) {
|
||||
if (f.modes && f.modes.length > 0) {
|
||||
map[f.name] = new Set(f.modes as ListingType[]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [features]);
|
||||
|
||||
const linkedFeatures = useMemo(() => {
|
||||
const pairs: [string, string][] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const f of features) {
|
||||
if (f.linked && !seen.has(f.name)) {
|
||||
pairs.push([f.name, f.linked]);
|
||||
seen.add(f.linked);
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}, [features]);
|
||||
|
||||
const isAllowed = useCallback(
|
||||
(name: string, mode: ListingType) => {
|
||||
const allowed = modeRestrictions[name];
|
||||
return !allowed || allowed.has(mode);
|
||||
},
|
||||
[modeRestrictions]
|
||||
);
|
||||
|
||||
const activeListingType = useMemo((): ListingType => {
|
||||
const val = filters['Listing status'] as string[] | undefined;
|
||||
if (!val || val.length === 0) return 'historical';
|
||||
|
|
@ -135,8 +164,8 @@ export default memo(function Filters({
|
|||
}, [filters]);
|
||||
|
||||
const availableFeatures = useMemo(
|
||||
() => features.filter((f) => !enabledFeatures.has(f.name) && isFeatureAllowedInMode(f.name, activeListingType)),
|
||||
[features, enabledFeatures, activeListingType]
|
||||
() => features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
|
||||
[features, enabledFeatures, activeListingType, isAllowed]
|
||||
);
|
||||
const enabledFeatureList = useMemo(
|
||||
() => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'),
|
||||
|
|
@ -145,16 +174,26 @@ export default memo(function Filters({
|
|||
|
||||
const handleListingSelect = useCallback(
|
||||
(type: ListingType) => {
|
||||
if (type === activeListingType && !filters['Listing status']) return;
|
||||
for (const name of Object.keys(filters)) {
|
||||
if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) {
|
||||
if (name === 'Listing status') continue;
|
||||
if (isAllowed(name, type)) continue;
|
||||
|
||||
// Check if this feature has a linked counterpart in the new mode
|
||||
let swapped = false;
|
||||
for (const [a, b] of linkedFeatures) {
|
||||
const counterpart = name === a ? b : name === b ? a : null;
|
||||
if (counterpart && isAllowed(counterpart, type)) {
|
||||
onFilterChange(counterpart, filters[name] as [number, number]);
|
||||
onRemoveFilter(name);
|
||||
swapped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!swapped) {
|
||||
onRemoveFilter(name);
|
||||
}
|
||||
}
|
||||
if (type === 'historical' && !filters['Listing status']) {
|
||||
onFilterChange('Listing status', ['Historical sale']);
|
||||
return;
|
||||
}
|
||||
const valueMap: Record<string, string> = {
|
||||
historical: 'Historical sale',
|
||||
buy: 'For sale',
|
||||
|
|
@ -162,7 +201,7 @@ export default memo(function Filters({
|
|||
};
|
||||
onFilterChange('Listing status', [valueMap[type]]);
|
||||
},
|
||||
[activeListingType, filters, onFilterChange, onRemoveFilter]
|
||||
[filters, onFilterChange, onRemoveFilter, isAllowed, linkedFeatures]
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -205,7 +244,7 @@ export default memo(function Filters({
|
|||
<div className="flex items-center gap-2 px-3 pb-2">
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<LightbulbIcon />
|
||||
Finding the Perfect Postcode
|
||||
|
|
@ -254,7 +293,7 @@ export default memo(function Filters({
|
|||
name="Travel Time"
|
||||
expanded={!collapsedGroups.has('Travel Time')}
|
||||
onToggle={() => toggleGroup('Travel Time')}
|
||||
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{travelTimeEntries.length}
|
||||
|
|
@ -269,11 +308,12 @@ export default memo(function Filters({
|
|||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
dataRange={travelTimeDataRanges.get(index) ?? null}
|
||||
useBest={entry.useBest}
|
||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
||||
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -296,7 +336,7 @@ export default memo(function Filters({
|
|||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
|
|
@ -344,14 +384,25 @@ export default memo(function Filters({
|
|||
|
||||
const isActive = activeFeature === feature.name;
|
||||
const isPinned = pinnedFeature === feature.name;
|
||||
const hist = feature.histogram;
|
||||
const displayValue =
|
||||
isActive && dragValue
|
||||
? dragValue
|
||||
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
|
||||
: (filters[feature.name] as [number, number]) || [hist?.min ?? feature.min!, hist?.max ?? feature.max!];
|
||||
const scale = percentileScales.get(feature.name);
|
||||
const dataMin = hist?.min ?? feature.min!;
|
||||
const dataMax = hist?.max ?? feature.max!;
|
||||
const isAtMin = displayValue[0] <= dataMin;
|
||||
const isAtMax = displayValue[1] >= dataMax;
|
||||
const sliderValue: [number, number] = scale
|
||||
? [Math.round(scale.toPercentile(displayValue[0])), Math.round(scale.toPercentile(displayValue[1]))]
|
||||
: displayValue;
|
||||
? [
|
||||
isAtMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
|
||||
isAtMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
|
||||
]
|
||||
: [
|
||||
isAtMin ? feature.min! : displayValue[0],
|
||||
isAtMax ? feature.max! : displayValue[1],
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -375,8 +426,18 @@ export default memo(function Filters({
|
|||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
? ([pMin, pMax]) => onDragChange([scale.toValue(pMin), scale.toValue(pMax)])
|
||||
: ([min, max]) => onDragChange([min, max])
|
||||
? ([pMin, pMax]) => {
|
||||
const step = feature.step ?? 1;
|
||||
const snap = (v: number) => Math.round(v / step) * step;
|
||||
onDragChange([
|
||||
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
|
||||
pMax >= 100 ? (hist?.max ?? feature.max!) : snap(scale.toValue(pMax)),
|
||||
]);
|
||||
}
|
||||
: ([min, max]) => onDragChange([
|
||||
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
|
||||
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(feature.name)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
|
|
@ -386,7 +447,8 @@ export default memo(function Filters({
|
|||
max={scale ? 100 : feature.max!}
|
||||
value={sliderValue}
|
||||
displayValues={scale ? displayValue : undefined}
|
||||
absoluteMax={feature.absolute}
|
||||
isAtMin={isAtMin}
|
||||
isAtMax={isAtMax}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -416,6 +478,8 @@ export default memo(function Filters({
|
|||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
onAddTravelTimeEntry={onTravelTimeAddEntry}
|
||||
isLicensed={isLicensed}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -423,59 +487,95 @@ export default memo(function Filters({
|
|||
{showPhilosophy && (
|
||||
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Start with your must-haves, then layer on nice-to-haves.
|
||||
The map narrows down as you add filters — the areas that survive are your best matches.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Be intentional, not reactive
|
||||
1. Budget & property basics
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Your future home isn't a box of cereal you grab because it's on sale.
|
||||
Don't let a seemingly good deal turn into lifelong regret. Instead of waiting
|
||||
for listings to appear, define what you actually want and go find it.
|
||||
Set your price range, minimum floor area, and property type.
|
||||
If you need a lease over freehold (or vice versa), filter for that too.
|
||||
This eliminates most of the map immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
See the full picture
|
||||
2. Commute & transport
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Current listings show only a fraction of the market. There are too few to give you a
|
||||
complete picture, yet too many to evaluate one by one. We aggregate millions of
|
||||
historical sales so you can understand what's truly available in any area.
|
||||
Add a travel time filter to your workplace — choose public transport or cycling
|
||||
and set your maximum tolerable commute. You can also filter by
|
||||
how many stations are within walking distance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Your priorities, your filters
|
||||
3. Safety & environment
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
We all care about different things. Some want peace and quiet; others want to be
|
||||
near the action. Use our filters to define exactly what matters to you and discover
|
||||
postcodes that match.
|
||||
Use the crime filters to cap serious or minor crime rates.
|
||||
Check road noise levels if you're a light sleeper, and
|
||||
environmental risk filters for ground stability concerns.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Find the right place, not just the right listing
|
||||
4. Schools & education
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
The best areas to live don't always have properties listed right now. We help
|
||||
you identify where you should be looking, so when something does come up,
|
||||
you're ready.
|
||||
Filter by the number of Ofsted-rated Good or Outstanding primary and
|
||||
secondary schools nearby. The education deprivation score captures
|
||||
broader area-level attainment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Know what's possible
|
||||
5. Lifestyle & amenities
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
We'd rather tell you upfront if your expectations are unrealistic than have you
|
||||
spend months searching for something that doesn't exist.
|
||||
Want restaurants, parks, or grocery shops within walking distance?
|
||||
Filter by nearby amenity counts. Broadband speed filters help if
|
||||
you work from home.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
6. Energy & running costs
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
EPC ratings from A to G indicate energy efficiency.
|
||||
Filter for better ratings to find homes with lower bills and
|
||||
fewer upgrade headaches.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-1 border-t border-warm-200 dark:border-warm-700">
|
||||
<p className="text-warm-500 dark:text-warm-400 italic">
|
||||
Tip: if nothing survives your filters, relax one constraint at a time
|
||||
to see which compromise unlocks the most options.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{onResetTutorial && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPhilosophy(false);
|
||||
onResetTutorial();
|
||||
}}
|
||||
className="w-full px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm"
|
||||
>
|
||||
Replay interactive tutorial
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { memo } from 'react';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
import { memo, useMemo } from 'react';
|
||||
import type { FeatureFilters, FeatureMeta } from '../../types';
|
||||
import { formatValue } from '../../lib/format';
|
||||
|
||||
interface HoverCardData {
|
||||
|
|
@ -14,11 +14,17 @@ interface HoverCardProps {
|
|||
isPostcode: boolean;
|
||||
data: HoverCardData | null;
|
||||
filters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
}
|
||||
|
||||
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: HoverCardProps) {
|
||||
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, features }: HoverCardProps) {
|
||||
const activeFilterNames = Object.keys(filters);
|
||||
|
||||
const featureMap = useMemo(
|
||||
() => new Map(features.map((f) => [f.name, f])),
|
||||
[features]
|
||||
);
|
||||
|
||||
// Get key stats to show from local data (min_<feature> values)
|
||||
const getDisplayStats = () => {
|
||||
if (!data) return [];
|
||||
|
|
@ -28,8 +34,13 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
|
|||
// Show stats for active filters (up to 4)
|
||||
for (const name of activeFilterNames.slice(0, 4)) {
|
||||
const val = data[`avg_${name}`] ?? data[`min_${name}`];
|
||||
if (val != null && typeof val === 'number') {
|
||||
results.push({ name, value: formatValue(val) });
|
||||
if (val == null || typeof val !== 'number') continue;
|
||||
const meta = featureMap.get(name);
|
||||
if (meta?.type === 'enum' && meta.values) {
|
||||
const label = meta.values[Math.round(val)];
|
||||
if (label) results.push({ name, value: label });
|
||||
} else {
|
||||
results.push({ name, value: formatValue(val, meta) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ export default memo(function Map({
|
|||
data,
|
||||
postcodeData,
|
||||
usePostcodeView,
|
||||
zoom: viewState.zoom,
|
||||
pois,
|
||||
viewFeature,
|
||||
colorRange,
|
||||
|
|
@ -296,6 +297,7 @@ export default memo(function Map({
|
|||
: data.find((d) => d.h3 === hoveredHexagonId) || null
|
||||
}
|
||||
filters={filters}
|
||||
features={features}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
type TravelTimeInitial,
|
||||
} from '../../hooks/useTravelTime';
|
||||
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import UpgradeModal from '../ui/UpgradeModal';
|
||||
|
|
@ -188,24 +189,6 @@ export default function MapPage({
|
|||
|
||||
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||
|
||||
const travelTimeDataRanges = useMemo((): globalThis.Map<number, [number, number]> => {
|
||||
const ranges = new globalThis.Map<number, [number, number]>();
|
||||
for (let i = 0; i < travelTime.entries.length; i++) {
|
||||
const entry = travelTime.entries[i];
|
||||
if (!entry.slug) continue;
|
||||
const fieldName = `avg_${travelFieldKey(entry)}`;
|
||||
const vals: number[] = [];
|
||||
for (const item of mapData.data) {
|
||||
const val = item[fieldName];
|
||||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||
}
|
||||
if (vals.length === 0) continue;
|
||||
vals.sort((a, b) => a - b);
|
||||
ranges.set(i, [vals[0], vals[vals.length - 1]]);
|
||||
}
|
||||
return ranges;
|
||||
}, [travelTime.entries, mapData.data]);
|
||||
|
||||
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -229,16 +212,21 @@ export default function MapPage({
|
|||
const isPostcode = selection.selectedHexagon?.type === 'postcode';
|
||||
|
||||
if (isPostcode) {
|
||||
// For postcodes, get centroid from postcodeData
|
||||
// For postcodes, get centroid from postcodeData; postcode string is the selection id
|
||||
const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId);
|
||||
if (!postcodeFeature?.properties.centroid) return null;
|
||||
const [lon, lat] = postcodeFeature.properties.centroid;
|
||||
return { lat, lon, resolution: mapData.resolution };
|
||||
return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true };
|
||||
} else {
|
||||
// For hexagons, get lat/lon from hexagon data
|
||||
// For hexagons, get lat/lon from hexagon data; central postcode comes from stats
|
||||
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
|
||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
|
||||
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
|
||||
return {
|
||||
lat: hex.lat as number,
|
||||
lon: hex.lon as number,
|
||||
resolution: mapData.resolution,
|
||||
postcode: selection.areaStats?.central_postcode,
|
||||
};
|
||||
}
|
||||
}, [
|
||||
selection.selectedHexagon?.id,
|
||||
|
|
@ -246,6 +234,7 @@ export default function MapPage({
|
|||
mapData.data,
|
||||
mapData.postcodeData,
|
||||
mapData.resolution,
|
||||
selection.areaStats?.central_postcode,
|
||||
]);
|
||||
|
||||
const tutorial = useTutorial(initialLoading, isMobile);
|
||||
|
|
@ -273,6 +262,7 @@ export default function MapPage({
|
|||
link.download = 'perfect-postcode-export.xlsx';
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
trackEvent('Export');
|
||||
})
|
||||
.catch((err) => logNonAbortError('Export failed', err))
|
||||
.finally(() => setExporting(false));
|
||||
|
|
@ -282,6 +272,10 @@ export default function MapPage({
|
|||
onExportStateChange?.({ onExport: handleExport, exporting });
|
||||
}, [handleExport, exporting, onExportStateChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
|
||||
}, [mapData.licenseRequired]);
|
||||
|
||||
const mobileLegendMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
[viewFeature, features]
|
||||
|
|
@ -395,15 +389,18 @@ export default function MapPage({
|
|||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
||||
travelTimeEntries={travelTime.entries}
|
||||
travelTimeDataRanges={travelTimeDataRanges}
|
||||
onTravelTimeAddEntry={travelTime.handleAddEntry}
|
||||
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
||||
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
||||
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
|
||||
onTravelTimeToggleBest={travelTime.handleToggleBest}
|
||||
aiFilterLoading={aiFilters.loading}
|
||||
aiFilterError={aiFilters.error}
|
||||
aiFilterNotes={aiFilters.notes}
|
||||
onAiFilterSubmit={handleAiFilterSubmit}
|
||||
isLicensed={user?.subscription === 'licensed'}
|
||||
onUpgradeClick={() => onNavigateTo('pricing')}
|
||||
onResetTutorial={tutorial.resetTutorial}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -560,6 +557,7 @@ export default function MapPage({
|
|||
callback={tutorial.handleCallback}
|
||||
styles={getTutorialStyles(theme)}
|
||||
disableScrolling
|
||||
locale={{ last: 'Finish' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
@ -614,9 +612,10 @@ export default function MapPage({
|
|||
<button
|
||||
data-tutorial="poi-button"
|
||||
onClick={() => setPoiPaneOpen((p) => !p)}
|
||||
className={`absolute bottom-4 right-4 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||
className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Points of interest</span>
|
||||
</button>
|
||||
{/* Floating POI panel */}
|
||||
{poiPaneOpen && (
|
||||
|
|
@ -626,38 +625,40 @@ export default function MapPage({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-tutorial="right-pane"
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
|
||||
style={{ width: rightPaneWidth }}
|
||||
>
|
||||
{selection.selectedHexagon && (
|
||||
<div
|
||||
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
{...rightPaneHandlers}
|
||||
data-tutorial="right-pane"
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
|
||||
style={{ width: rightPaneWidth }}
|
||||
>
|
||||
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<TabButton
|
||||
label="Area"
|
||||
isActive={selection.rightPaneTab === 'area'}
|
||||
onClick={() => selection.setRightPaneTab('area')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Properties"
|
||||
isActive={selection.rightPaneTab === 'properties'}
|
||||
onClick={selection.handlePropertiesTabClick}
|
||||
/>
|
||||
<div
|
||||
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
{...rightPaneHandlers}
|
||||
>
|
||||
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<TabButton
|
||||
label="Area"
|
||||
isActive={selection.rightPaneTab === 'area'}
|
||||
onClick={() => selection.setRightPaneTab('area')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Properties"
|
||||
isActive={selection.rightPaneTab === 'properties'}
|
||||
onClick={selection.handlePropertiesTabClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selection.rightPaneTab === 'properties'
|
||||
? renderPropertiesPane()
|
||||
: renderAreaPane()}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selection.rightPaneTab === 'properties'
|
||||
? renderPropertiesPane()
|
||||
: renderAreaPane()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapData.licenseRequired && (
|
||||
<UpgradeModal
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import type { POICategoryGroup } from '../../types';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { SearchInput } from '../ui/SearchInput';
|
||||
|
|
@ -31,19 +32,23 @@ export default function POIPane({
|
|||
|
||||
const toggleCategory = (category: string) => {
|
||||
const newSet = new Set(selectedCategories);
|
||||
if (newSet.has(category)) {
|
||||
const wasSelected = newSet.has(category);
|
||||
if (wasSelected) {
|
||||
newSet.delete(category);
|
||||
} else {
|
||||
newSet.add(category);
|
||||
}
|
||||
trackEvent('POI Toggle', { category, selected: String(!wasSelected) });
|
||||
onCategoriesChange(newSet);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
trackEvent('POI Select All');
|
||||
onCategoriesChange(new Set(allCategories));
|
||||
};
|
||||
|
||||
const selectNone = () => {
|
||||
trackEvent('POI Select None');
|
||||
onCategoriesChange(new Set());
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,21 +5,33 @@ import { PlaceSearchInput } from '../ui/PlaceSearchInput';
|
|||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||
import { RouteIcon } from '../ui/icons/RouteIcon';
|
||||
import { CarIcon } from '../ui/icons/CarIcon';
|
||||
import { BicycleIcon } from '../ui/icons/BicycleIcon';
|
||||
import { WalkingIcon } from '../ui/icons/WalkingIcon';
|
||||
import { TransitIcon } from '../ui/icons/TransitIcon';
|
||||
import { formatFilterValue } from '../../lib/format';
|
||||
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
|
||||
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
|
||||
car: CarIcon,
|
||||
bicycle: BicycleIcon,
|
||||
walking: WalkingIcon,
|
||||
transit: TransitIcon,
|
||||
};
|
||||
|
||||
interface TravelTimeCardProps {
|
||||
mode: TransportMode;
|
||||
slug: string;
|
||||
label: string;
|
||||
timeRange: [number, number] | null;
|
||||
dataRange: [number, number] | null;
|
||||
useBest: boolean;
|
||||
isPinned: boolean;
|
||||
onTogglePin: () => void;
|
||||
onSetDestination: (slug: string, label: string) => void;
|
||||
onTimeRangeChange: (range: [number, number]) => void;
|
||||
onToggleBest: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -28,11 +40,12 @@ export function TravelTimeCard({
|
|||
slug,
|
||||
label,
|
||||
timeRange,
|
||||
dataRange,
|
||||
useBest,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
onSetDestination,
|
||||
onTimeRangeChange,
|
||||
onToggleBest,
|
||||
onRemove,
|
||||
}: TravelTimeCardProps) {
|
||||
const search = useLocationSearch(mode);
|
||||
|
|
@ -59,16 +72,18 @@ export function TravelTimeCard({
|
|||
[onSetDestination, search.clear],
|
||||
);
|
||||
|
||||
const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0;
|
||||
const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120;
|
||||
const sliderMin = 0;
|
||||
const sliderMax = 120;
|
||||
const displayRange = timeRange ?? [sliderMin, sliderMax];
|
||||
|
||||
const ModeIcon = MODE_ICONS[mode];
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
||||
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
Travel Time ({MODE_LABELS[mode]})
|
||||
</span>
|
||||
|
|
@ -106,8 +121,26 @@ export function TravelTimeCard({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Best-case toggle — transit only, shown when destination is set */}
|
||||
{slug && mode === 'transit' && (
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useBest}
|
||||
onChange={onToggleBest}
|
||||
className="accent-teal-600 rounded"
|
||||
/>
|
||||
<span className="text-xs text-warm-600 dark:text-warm-300">
|
||||
Best case
|
||||
</span>
|
||||
<span className="text-[10px] text-warm-400 dark:text-warm-500">
|
||||
(optimal departure)
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Time range slider — only show when we have data */}
|
||||
{slug && dataRange && (
|
||||
{slug && (
|
||||
<div>
|
||||
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||
Max time
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { CheckIcon } from '../ui/icons/CheckIcon';
|
|||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { apiUrl } from '../../lib/api';
|
||||
|
||||
const FEATURES = [
|
||||
|
|
@ -59,14 +60,8 @@ export default function PricingPage({
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pricing || !scrollRef.current || !activeCardRef.current) return;
|
||||
if (currentTierIndex === 0) return;
|
||||
const container = scrollRef.current;
|
||||
const card = activeCardRef.current;
|
||||
const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
|
||||
container.scrollLeft = Math.max(0, scrollLeft);
|
||||
setScrolledLeft(container.scrollLeft > 0);
|
||||
}, [pricing, currentTierIndex]);
|
||||
trackEvent('Pricing View');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(apiUrl('pricing'))
|
||||
|
|
@ -98,6 +93,16 @@ export default function PricingPage({
|
|||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!pricing || !scrollRef.current || !activeCardRef.current) return;
|
||||
if (currentTierIndex === 0) return;
|
||||
const container = scrollRef.current;
|
||||
const card = activeCardRef.current;
|
||||
const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
|
||||
container.scrollLeft = Math.max(0, scrollLeft);
|
||||
setScrolledLeft(container.scrollLeft > 0);
|
||||
}, [pricing, currentTierIndex]);
|
||||
|
||||
const ctaButton = isLicensed ? (
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
|
|
@ -183,13 +188,23 @@ export default function PricingPage({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-12">
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-6">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">
|
||||
Early access pricing
|
||||
</h1>
|
||||
<p className="text-lg text-warm-300 max-w-lg mx-auto">
|
||||
No subscriptions, no recurring fees. Pay once and get lifetime
|
||||
access to every feature. The earlier you join, the less you pay.
|
||||
Pay once, access forever. The earlier you join, the less you pay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-2xl mx-auto px-6 mb-12 text-center">
|
||||
<p className="text-warm-400 text-sm leading-relaxed mb-2">
|
||||
Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees,
|
||||
£500 for a survey. Get the wrong area and you're stuck with a long
|
||||
commute, bad schools, or a road you didn't know about.
|
||||
</p>
|
||||
<p className="text-warm-200 font-semibold">
|
||||
Less than your survey costs. Vastly more useful.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -203,7 +218,7 @@ export default function PricingPage({
|
|||
<div className="relative mb-12" style={{ marginLeft: 'calc(-50vw + 50%)', marginRight: 'calc(-50vw + 50%)', width: '100vw' }}>
|
||||
{scrolledLeft && <div className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to right, black, transparent)' }} />}
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to left, black, transparent)' }} />
|
||||
<div ref={scrollRef} onScroll={onScroll} className="flex gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
|
||||
<div ref={scrollRef} onScroll={onScroll} className="flex justify-center gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
|
||||
{pricing.tiers.map((tier, i) => {
|
||||
const isCurrent = i === currentTierIndex;
|
||||
const isFilled =
|
||||
|
|
@ -348,17 +363,6 @@ export default function PricingPage({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-2xl mx-auto px-6 pb-16 text-center">
|
||||
<p className="text-warm-400 leading-relaxed mb-3">
|
||||
Stamp duty on a £400k house: £10,000. Solicitor fees: £1,500.
|
||||
Survey: £500. Moving costs: £1,000. And that's just the money. Get the
|
||||
wrong area and you're stuck — with a long commute, bad schools, or a street
|
||||
that looked fine on the listing photos but turns out to be on a motorway.
|
||||
</p>
|
||||
<p className="text-warm-200 font-semibold">
|
||||
One payment. Lifetime access. Less than your survey costs and vastly more useful.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { GoogleIcon } from './icons/GoogleIcon';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
|
||||
type View = 'login' | 'register' | 'forgot';
|
||||
|
||||
|
|
@ -30,6 +31,10 @@ export default function AuthModal({
|
|||
const [password, setPassword] = useState('');
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent('Auth Modal Open', { tab: initialTab });
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const switchView = useCallback(
|
||||
(newView: View) => {
|
||||
setView(newView);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { FeatureMeta } from '../../types';
|
||||
import { InfoIcon } from './icons';
|
||||
import { getGroupIcon } from '../../lib/group-icons';
|
||||
|
||||
interface FeatureLabelProps {
|
||||
feature: FeatureMeta;
|
||||
|
|
@ -15,11 +16,15 @@ export function FeatureLabel({
|
|||
size = 'xs',
|
||||
}: FeatureLabelProps) {
|
||||
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
||||
const GroupIcon = feature.group ? getGroupIcon(feature.group) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
|
||||
>
|
||||
{GroupIcon && (
|
||||
<GroupIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { shortenUrl } from '../../lib/api';
|
||||
import { copyToClipboard } from '../../lib/clipboard';
|
||||
import { DownloadIcon } from './icons/DownloadIcon';
|
||||
import { BookmarkIcon } from './icons/BookmarkIcon';
|
||||
import { LogoIcon } from './icons/LogoIcon';
|
||||
|
|
@ -63,42 +64,29 @@ export default function Header({
|
|||
if (!isMobile) setMenuOpen(false);
|
||||
}, [isMobile]);
|
||||
|
||||
const copyToClipboard = useCallback((text: string) => {
|
||||
const onSuccess = () => {
|
||||
const doCopy = useCallback((text: string) => {
|
||||
copyToClipboard(text, () => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
onSuccess();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleShare = useCallback(async () => {
|
||||
const params = window.location.search.replace(/^\?/, '');
|
||||
if (!params) {
|
||||
copyToClipboard(window.location.href);
|
||||
doCopy(window.location.href);
|
||||
return;
|
||||
}
|
||||
setSharing(true);
|
||||
try {
|
||||
const shortUrl = await shortenUrl(params);
|
||||
copyToClipboard(shortUrl);
|
||||
doCopy(shortUrl);
|
||||
} catch {
|
||||
copyToClipboard(window.location.href);
|
||||
doCopy(window.location.href);
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
}, [copyToClipboard]);
|
||||
}, [doCopy]);
|
||||
|
||||
const tabClass = (page: Page) =>
|
||||
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { CheckIcon } from './icons/CheckIcon';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
|
||||
export default function SaveSearchModal({
|
||||
onClose,
|
||||
onSave,
|
||||
onViewSearches,
|
||||
saving,
|
||||
error,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSave: (name: string) => Promise<void>;
|
||||
onViewSearches: () => void;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
|
|
@ -21,12 +25,12 @@ export default function SaveSearchModal({
|
|||
if (!name.trim() || saving) return;
|
||||
try {
|
||||
await onSave(name.trim());
|
||||
onClose();
|
||||
setSaved(true);
|
||||
} catch {
|
||||
// Error displayed in modal
|
||||
}
|
||||
},
|
||||
[name, saving, onSave, onClose]
|
||||
[name, saving, onSave]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -45,7 +49,9 @@ export default function SaveSearchModal({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">Save Search</h2>
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
|
||||
{saved ? 'Search saved' : 'Save Search'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
||||
|
|
@ -54,41 +60,68 @@ export default function SaveSearchModal({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 pt-2 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder="My search"
|
||||
autoFocus
|
||||
/>
|
||||
{saved ? (
|
||||
<div className="p-5 pt-2 space-y-4">
|
||||
<div className="flex items-center gap-2 text-teal-600 dark:text-teal-400">
|
||||
<CheckIcon className="w-5 h-5" />
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300">
|
||||
Your search has been saved successfully.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewSearches}
|
||||
className="px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700"
|
||||
>
|
||||
View saved searches
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="p-5 pt-2 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder="My search"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || saving}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || saving}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -38,11 +38,29 @@ export default function UserMenu({
|
|||
{open && (
|
||||
<div className="absolute right-0 top-10 w-56 bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-lg z-50">
|
||||
<div className="px-4 py-3 border-b border-warm-200 dark:border-warm-700">
|
||||
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
<span
|
||||
className={`shrink-0 text-xs font-medium px-1.5 py-0.5 rounded ${
|
||||
user.subscription === 'licensed' || user.isAdmin
|
||||
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
|
||||
: 'bg-warm-100 text-warm-500 dark:bg-warm-700 dark:text-warm-400'
|
||||
}`}
|
||||
>
|
||||
{user.subscription === 'licensed' || user.isAdmin ? 'Pro' : 'Free'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<a
|
||||
href="/account"
|
||||
onClick={() => setOpen(false)}
|
||||
className="block w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
|
||||
>
|
||||
Account
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
|
|
|
|||
23
frontend/src/components/ui/icons/BicycleIcon.tsx
Normal file
23
frontend/src/components/ui/icons/BicycleIcon.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BicycleIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="6" cy="17" r="3" />
|
||||
<circle cx="18" cy="17" r="3" />
|
||||
<path d="M6 17l3-7h4l3 7" />
|
||||
<path d="M9 10l3 4h3" />
|
||||
<circle cx="12" cy="7" r="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/icons/CarIcon.tsx
Normal file
22
frontend/src/components/ui/icons/CarIcon.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CarIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 17h14v-5l-2-6H7L5 12v5z" />
|
||||
<circle cx="7.5" cy="17" r="2" />
|
||||
<circle cx="16.5" cy="17" r="2" />
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/ChartBarIcon.tsx
Normal file
21
frontend/src/components/ui/icons/ChartBarIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChartBarIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="20" x2="18" y2="10" />
|
||||
<line x1="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/GraduationCapIcon.tsx
Normal file
20
frontend/src/components/ui/icons/GraduationCapIcon.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GraduationCapIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||
<path d="M6 12v5c0 2 3 3 6 3s6-1 6-3v-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/HouseIcon.tsx
Normal file
20
frontend/src/components/ui/icons/HouseIcon.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HouseIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/ui/icons/ShieldIcon.tsx
Normal file
19
frontend/src/components/ui/icons/ShieldIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ShieldIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/ShoppingBagIcon.tsx
Normal file
21
frontend/src/components/ui/icons/ShoppingBagIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ShoppingBagIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<path d="M16 10a4 4 0 01-8 0" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/TagIcon.tsx
Normal file
20
frontend/src/components/ui/icons/TagIcon.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TagIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z" />
|
||||
<line x1="7" y1="7" x2="7.01" y2="7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/ui/icons/TransitIcon.tsx
Normal file
25
frontend/src/components/ui/icons/TransitIcon.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TransitIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="6" y="3" width="12" height="14" rx="3" />
|
||||
<path d="M6 12h12" />
|
||||
<circle cx="9" cy="15" r="1" />
|
||||
<circle cx="15" cy="15" r="1" />
|
||||
<path d="M9 20l-2 2" />
|
||||
<path d="M15 20l2 2" />
|
||||
<path d="M9 3V1h6v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/TreeIcon.tsx
Normal file
20
frontend/src/components/ui/icons/TreeIcon.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TreeIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 22v-7" />
|
||||
<path d="M17 15H7l2-4H5l7-9 7 9h-4l2 4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/icons/UsersIcon.tsx
Normal file
22
frontend/src/components/ui/icons/UsersIcon.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UsersIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 010 7.75" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ui/icons/WalkingIcon.tsx
Normal file
23
frontend/src/components/ui/icons/WalkingIcon.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WalkingIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="4.5" r="2" />
|
||||
<path d="M13.5 9L15 15l-3 4" />
|
||||
<path d="M10.5 9L9 15l3 4" />
|
||||
<path d="M10 9h4l2 4" />
|
||||
<path d="M8 13l2-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,3 +7,15 @@ export { FilterIcon } from './FilterIcon';
|
|||
export { LightbulbIcon } from './LightbulbIcon';
|
||||
export { MenuIcon } from './MenuIcon';
|
||||
export { RouteIcon } from './RouteIcon';
|
||||
export { CarIcon } from './CarIcon';
|
||||
export { BicycleIcon } from './BicycleIcon';
|
||||
export { WalkingIcon } from './WalkingIcon';
|
||||
export { TransitIcon } from './TransitIcon';
|
||||
export { HouseIcon } from './HouseIcon';
|
||||
export { GraduationCapIcon } from './GraduationCapIcon';
|
||||
export { ChartBarIcon } from './ChartBarIcon';
|
||||
export { ShieldIcon } from './ShieldIcon';
|
||||
export { UsersIcon } from './UsersIcon';
|
||||
export { ShoppingBagIcon } from './ShoppingBagIcon';
|
||||
export { TreeIcon } from './TreeIcon';
|
||||
export { TagIcon } from './TagIcon';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue