Morning improvements
This commit is contained in:
parent
3e9fba5303
commit
53fff3efaa
41 changed files with 2438 additions and 637 deletions
|
|
@ -9,7 +9,6 @@ import Header, { type Page } from './components/ui/Header';
|
|||
import AuthModal from './components/ui/AuthModal';
|
||||
import SaveSearchModal from './components/ui/SaveSearchModal';
|
||||
import LicenseSuccessModal from './components/ui/LicenseSuccessModal';
|
||||
import VerificationBanner from './components/ui/VerificationBanner';
|
||||
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
||||
import { fetchWithRetry, apiUrl } from './lib/api';
|
||||
import { trackEvent } from './lib/analytics';
|
||||
|
|
@ -118,15 +117,12 @@ export default function App() {
|
|||
loginWithOAuth,
|
||||
logout,
|
||||
requestPasswordReset,
|
||||
requestVerification,
|
||||
refreshAuth,
|
||||
clearError,
|
||||
} = useAuth();
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
|
||||
const [showLicenseSuccess, setShowLicenseSuccess] = useState(false);
|
||||
const [verificationDismissed, setVerificationDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('license_success') === '1') {
|
||||
|
|
@ -304,13 +300,6 @@ export default function App() {
|
|||
onLogout={logout}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{user && !user.verified && !verificationDismissed && isAuthRequiredPage && (
|
||||
<VerificationBanner
|
||||
email={user.email}
|
||||
onRequestVerification={requestVerification}
|
||||
onDismiss={() => setVerificationDismissed(true)}
|
||||
/>
|
||||
)}
|
||||
{activePage === 'home' ? (
|
||||
<HomePage
|
||||
onOpenDashboard={() => navigateTo('dashboard')}
|
||||
|
|
@ -357,7 +346,6 @@ export default function App() {
|
|||
<AccountPage
|
||||
user={user}
|
||||
onRefreshAuth={refreshAuth}
|
||||
onRequestVerification={requestVerification}
|
||||
/>
|
||||
) : activePage === 'invite' && inviteCode ? (
|
||||
<InvitePage
|
||||
|
|
|
|||
|
|
@ -746,18 +746,13 @@ export function InvitesPage({ user }: { user: AuthUser }) {
|
|||
export default function AccountPage({
|
||||
user,
|
||||
onRefreshAuth,
|
||||
onRequestVerification,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onRefreshAuth: () => Promise<void>;
|
||||
onRequestVerification: (email: string) => Promise<void>;
|
||||
}) {
|
||||
const [newsletterSaving, setNewsletterSaving] = useState(false);
|
||||
const [newsletterError, setNewsletterError] = useState<string | null>(null);
|
||||
|
||||
const [verificationSending, setVerificationSending] = useState(false);
|
||||
const [verificationSent, setVerificationSent] = useState(false);
|
||||
|
||||
const badgeColor =
|
||||
user.subscription === 'licensed'
|
||||
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
|
||||
|
|
@ -773,38 +768,6 @@ export default function AccountPage({
|
|||
<p className="text-sm text-warm-500 dark:text-warm-400">Email</p>
|
||||
<p className="text-navy-950 dark:text-warm-100 font-medium">{user.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!user.verified && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setVerificationSending(true);
|
||||
try {
|
||||
await onRequestVerification(user.email);
|
||||
setVerificationSent(true);
|
||||
setTimeout(() => setVerificationSent(false), 3000);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
} finally {
|
||||
setVerificationSending(false);
|
||||
}
|
||||
}}
|
||||
disabled={verificationSending || verificationSent}
|
||||
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
{verificationSending && <SpinnerIcon className="w-3 h-3 animate-spin" />}
|
||||
{verificationSent ? 'Sent!' : 'Resend verification'}
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
user.verified
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
{user.verified ? 'Verified' : 'Unverified'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscription */}
|
||||
|
|
|
|||
|
|
@ -170,12 +170,6 @@ export default memo(function AiFilterInput({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{error && errorType === 'verification' && (
|
||||
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
Please verify your email address to use AI-powered search. Check your inbox for a
|
||||
verification link.
|
||||
</p>
|
||||
)}
|
||||
{error && errorType === 'limit' && (
|
||||
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
You've reached the weekly AI usage limit. It will reset automatically next week.
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export default function FeatureBrowser({
|
|||
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||
>
|
||||
<div className="min-w-0 mr-2">
|
||||
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
|
||||
<FeatureLabel feature={f} size="sm" />
|
||||
{f.description && (
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
|
||||
{f.description}
|
||||
|
|
@ -145,6 +145,7 @@ export default function FeatureBrowser({
|
|||
feature={f}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setInfoFeature}
|
||||
onAdd={onAddFilter}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,11 @@ import { memo, useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
|||
import { Slider } from '../ui/Slider';
|
||||
import { LightbulbIcon } from '../ui/icons';
|
||||
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { PillGroup } from '../ui/PillGroup';
|
||||
import type { FeatureMeta, FeatureFilters } from '../../types';
|
||||
import { formatFilterValue, buildPercentileScale } from '../../lib/format';
|
||||
import type { PercentileScale } from '../../lib/format';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
|
|
@ -249,29 +246,24 @@ export default memo(function Filters({
|
|||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [collapsedGroups, toggleGroup, expandGroup] = useCollapsibleGroups();
|
||||
|
||||
const activeEntryCount = travelTimeEntries.length;
|
||||
|
||||
const pendingScrollRef = useRef<string | null>(null);
|
||||
|
||||
const handleAddAndScroll = useCallback(
|
||||
(name: string) => {
|
||||
const feature = features.find((f) => f.name === name);
|
||||
if (feature?.group) expandGroup(feature.group);
|
||||
pendingScrollRef.current = name;
|
||||
onAddFilter(name);
|
||||
},
|
||||
[onAddFilter, features, expandGroup]
|
||||
[onAddFilter]
|
||||
);
|
||||
|
||||
const handleAddTravelTimeAndScroll = useCallback(
|
||||
(mode: TransportMode) => {
|
||||
expandGroup('Transport');
|
||||
pendingScrollRef.current = `tt_${travelTimeEntries.length}`;
|
||||
onTravelTimeAddEntry(mode);
|
||||
},
|
||||
[onTravelTimeAddEntry, travelTimeEntries.length, expandGroup]
|
||||
[onTravelTimeAddEntry, travelTimeEntries.length]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -283,21 +275,6 @@ export default memo(function Filters({
|
|||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}, [enabledFeatureList, travelTimeEntries]);
|
||||
const enabledGroups = useMemo(
|
||||
() => groupFeaturesByCategory(enabledFeatureList),
|
||||
[enabledFeatureList]
|
||||
);
|
||||
|
||||
// Ensure "Transport" group exists in active filters when travel time entries are present
|
||||
const mergedGroups = useMemo(() => {
|
||||
if (travelTimeEntries.length === 0) return enabledGroups;
|
||||
if (enabledGroups.some((g) => g.name === 'Transport')) return enabledGroups;
|
||||
const groups = [...enabledGroups];
|
||||
const propsIdx = groups.findIndex((g) => g.name === 'Properties in the area');
|
||||
groups.splice(propsIdx === -1 ? 0 : propsIdx + 1, 0, { name: 'Transport', features: [] });
|
||||
return groups;
|
||||
}, [enabledGroups, travelTimeEntries.length]);
|
||||
|
||||
const percentileScales = useMemo(() => {
|
||||
const scales = new Map<string, PercentileScale>();
|
||||
for (const f of features) {
|
||||
|
|
@ -313,7 +290,7 @@ export default memo(function Filters({
|
|||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full"
|
||||
className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full touch-pan-y"
|
||||
>
|
||||
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
|
|
@ -374,182 +351,159 @@ export default memo(function Filters({
|
|||
</p>
|
||||
)}
|
||||
|
||||
{mergedGroups.map((group) => {
|
||||
const isExpanded = !collapsedGroups.has(group.name);
|
||||
const isTransport = group.name === 'Transport';
|
||||
const groupCount = group.features.length + (isTransport ? travelTimeEntries.length : 0);
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<CollapsibleGroupHeader
|
||||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||
{groupCount}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{isExpanded && (
|
||||
<div className="px-2 py-1 space-y-1">
|
||||
{isTransport &&
|
||||
travelTimeEntries.map((entry, index) => (
|
||||
<div
|
||||
key={`tt_${index}`}
|
||||
data-filter-name={`tt_${index}`}
|
||||
className="scroll-mt-10"
|
||||
>
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{group.features.map((feature) => {
|
||||
if (feature.type === 'enum') {
|
||||
const selectedValues = (filters[feature.name] as string[]) || [];
|
||||
const allValues = feature.values || [];
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
data-filter-name={feature.name}
|
||||
className={`scroll-mt-10 space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<FeatureLabel feature={feature} size="sm" />
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={pinnedFeature === feature.name}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
<PillGroup>
|
||||
{allValues.map((val) => (
|
||||
<PillToggle
|
||||
key={val}
|
||||
label={val}
|
||||
active={selectedValues.includes(val)}
|
||||
onClick={() => {
|
||||
const next = selectedValues.includes(val)
|
||||
? selectedValues.filter((v) => v !== val)
|
||||
: [...selectedValues, val];
|
||||
onFilterChange(feature.name, next);
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
))}
|
||||
</PillGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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]) || [
|
||||
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
|
||||
? [
|
||||
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
|
||||
key={feature.name}
|
||||
data-filter-name={feature.name}
|
||||
className={`scroll-mt-10 space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" />
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Slider
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
step={
|
||||
scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)
|
||||
}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
? ([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()}
|
||||
/>
|
||||
<SliderLabels
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
value={sliderValue}
|
||||
displayValues={scale ? displayValue : undefined}
|
||||
isAtMin={isAtMin}
|
||||
isAtMax={isAtMax}
|
||||
raw={feature.raw}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-2 py-1 space-y-1">
|
||||
{travelTimeEntries.map((entry, index) => (
|
||||
<div
|
||||
key={`tt_${index}`}
|
||||
data-filter-name={`tt_${index}`}
|
||||
>
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
{enabledFeatureList.map((feature) => {
|
||||
if (feature.type === 'enum') {
|
||||
const selectedValues = (filters[feature.name] as string[]) || [];
|
||||
const allValues = feature.values || [];
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<FeatureLabel feature={feature} size="sm" />
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={pinnedFeature === feature.name}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
<PillGroup>
|
||||
{allValues.map((val) => (
|
||||
<PillToggle
|
||||
key={val}
|
||||
label={val}
|
||||
active={selectedValues.includes(val)}
|
||||
onClick={() => {
|
||||
const next = selectedValues.includes(val)
|
||||
? selectedValues.filter((v) => v !== val)
|
||||
: [...selectedValues, val];
|
||||
onFilterChange(feature.name, next);
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
))}
|
||||
</PillGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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]) || [
|
||||
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
|
||||
? [
|
||||
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
|
||||
key={feature.name}
|
||||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" />
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Slider
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
step={
|
||||
scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)
|
||||
}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
? ([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()}
|
||||
/>
|
||||
<SliderLabels
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
value={sliderValue}
|
||||
displayValues={scale ? displayValue : undefined}
|
||||
isAtMin={isAtMin}
|
||||
isAtMax={isAtMax}
|
||||
raw={feature.raw}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[60%] border-t border-warm-200 dark:border-warm-700">
|
||||
<div className="shrink-0 md:shrink md:min-h-0 hidden md:flex flex-col md:basis-[60%] border-t border-warm-200 dark:border-warm-700">
|
||||
<div className="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -76,13 +76,14 @@ function nextMondayAt730(): number {
|
|||
}
|
||||
|
||||
function googleMapsUrl(postcode: string, destination: string): string {
|
||||
const params = new URLSearchParams({
|
||||
api: '1',
|
||||
origin: postcode,
|
||||
destination,
|
||||
travelmode: 'transit',
|
||||
});
|
||||
return `https://www.google.com/maps/dir/?${params}&departure_time=${nextMondayAt730()}`;
|
||||
const ts = nextMondayAt730();
|
||||
const origin = encodeURIComponent(postcode);
|
||||
const dest = encodeURIComponent(destination);
|
||||
// The official api=1 URL scheme doesn't support departure_time.
|
||||
// Use the undocumented data= path parameter with protobuf-like encoding:
|
||||
// !3e3 = transit, !6e0 = "depart at", !7e2 = local time, !8j = timestamp
|
||||
const data = `!4m6!4m5!2m3!6e0!7e2!8j${ts}!3e3`;
|
||||
return `https://www.google.com/maps/dir/${origin}/${dest}/data=${data}`;
|
||||
}
|
||||
|
||||
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
|||
import MapLegend from './MapLegend';
|
||||
import HoverCard from './HoverCard';
|
||||
import { LogoIcon } from '../ui/icons/LogoIcon';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
import { useDeckLayers } from '../../hooks/useDeckLayers';
|
||||
import { MODE_LABELS, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
|
|
@ -167,6 +168,7 @@ export default memo(function Map({
|
|||
const {
|
||||
layers,
|
||||
popupInfo,
|
||||
clearPopupInfo,
|
||||
hoverPosition,
|
||||
countRange,
|
||||
postcodeCountRange,
|
||||
|
|
@ -309,7 +311,7 @@ export default memo(function Map({
|
|||
))}
|
||||
{popupInfo && (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white pointer-events-none"
|
||||
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
|
||||
style={{
|
||||
left: popupInfo.x,
|
||||
top: popupInfo.y - 50,
|
||||
|
|
@ -317,6 +319,12 @@ export default memo(function Map({
|
|||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
|
||||
onClick={clearPopupInfo}
|
||||
>
|
||||
<CloseIcon className="w-3 h-3" />
|
||||
</button>
|
||||
{popupInfo.isCluster ? (
|
||||
<div className="px-3 py-2 text-center">
|
||||
<div className="text-lg font-bold text-teal-600 dark:text-teal-400">
|
||||
|
|
|
|||
|
|
@ -78,15 +78,11 @@ export function TravelTimeCard({
|
|||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
Travel Time ({MODE_LABELS[mode]})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowInfo(true)}
|
||||
className="p-1 -m-0.5 rounded text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 hover:bg-warm-100 dark:hover:bg-warm-700 shrink-0"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<IconButton onClick={() => setShowInfo(true)} title="Feature info">
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
{slug && (
|
||||
<IconButton
|
||||
onClick={onTogglePin}
|
||||
|
|
|
|||
|
|
@ -144,22 +144,13 @@ export default function Header({
|
|||
Dashboard
|
||||
</a>
|
||||
{user && (
|
||||
<>
|
||||
<a
|
||||
href={PAGE_PATHS.saved}
|
||||
className={tabClass('saved')}
|
||||
onClick={(e) => navLink('saved', e)}
|
||||
>
|
||||
Saved
|
||||
</a>
|
||||
<a
|
||||
href={PAGE_PATHS.invites}
|
||||
className={tabClass('invites')}
|
||||
onClick={(e) => navLink('invites', e)}
|
||||
>
|
||||
Invite Friends
|
||||
</a>
|
||||
</>
|
||||
<a
|
||||
href={PAGE_PATHS.invites}
|
||||
className={tabClass('invites')}
|
||||
onClick={(e) => navLink('invites', e)}
|
||||
>
|
||||
Invite Friends
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={PAGE_PATHS.learn}
|
||||
|
|
@ -177,6 +168,15 @@ export default function Header({
|
|||
Pricing
|
||||
</a>
|
||||
)}
|
||||
{user && (
|
||||
<a
|
||||
href={PAGE_PATHS.saved}
|
||||
className={tabClass('saved')}
|
||||
onClick={(e) => navLink('saved', e)}
|
||||
>
|
||||
Saved
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -88,9 +88,9 @@ export default function MobileMenu({
|
|||
{user?.subscription !== 'licensed' &&
|
||||
!user?.isAdmin &&
|
||||
mobileNavItem('pricing', 'Pricing')}
|
||||
{user && mobileNavItem('saved', 'Saved')}
|
||||
{user && mobileNavItem('invites', 'Invite Friends')}
|
||||
{user && mobileNavItem('account', 'Account')}
|
||||
{user && mobileNavItem('saved', 'Saved')}
|
||||
|
||||
{/* Dashboard actions */}
|
||||
{activePage === 'dashboard' && (
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
|
||||
export default function VerificationBanner({
|
||||
email,
|
||||
onRequestVerification,
|
||||
onDismiss,
|
||||
}: {
|
||||
email: string;
|
||||
onRequestVerification: (email: string) => Promise<void>;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const handleResend = useCallback(async () => {
|
||||
setSending(true);
|
||||
try {
|
||||
await onRequestVerification(email);
|
||||
setSent(true);
|
||||
setTimeout(() => setSent(false), 3000);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [email, onRequestVerification]);
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800 px-4 py-2.5 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
Please verify your email address. Check your inbox.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={sending || sent}
|
||||
className="text-sm font-medium text-amber-700 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-100 disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
{sending && <SpinnerIcon className="w-3.5 h-3.5 animate-spin" />}
|
||||
{sent ? 'Sent!' : 'Resend'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-amber-400 dark:text-amber-600 hover:text-amber-600 dark:hover:text-amber-400 text-lg leading-none"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ export interface AiFiltersResult {
|
|||
summary: string;
|
||||
}
|
||||
|
||||
export type AiFilterErrorType = 'auth' | 'verification' | 'limit' | 'error';
|
||||
export type AiFilterErrorType = 'auth' | 'limit' | 'error';
|
||||
|
||||
/** Context of currently active filters, sent for conversational refinement. */
|
||||
export interface AiFiltersContext {
|
||||
|
|
@ -102,9 +102,6 @@ export function useAiFilters(): UseAiFiltersResult {
|
|||
if (response.status === 401) {
|
||||
setErrorType('auth');
|
||||
setError(text || 'Login required');
|
||||
} else if (response.status === 403) {
|
||||
setErrorType('verification');
|
||||
setError(text || 'Email verification required');
|
||||
} else if (response.status === 429) {
|
||||
setErrorType('limit');
|
||||
setError(text || 'Weekly usage limit reached');
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { trackEvent } from '../lib/analytics';
|
|||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
verified: boolean;
|
||||
isAdmin: boolean;
|
||||
subscription: string;
|
||||
newsletter: boolean;
|
||||
|
|
@ -18,7 +17,6 @@ function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser
|
|||
return {
|
||||
id: record.id,
|
||||
email: record.email,
|
||||
verified: typeof record.verified === 'boolean' ? record.verified : false,
|
||||
isAdmin: typeof record.is_admin === 'boolean' ? record.is_admin : false,
|
||||
subscription: typeof record.subscription === 'string' ? record.subscription : 'free',
|
||||
newsletter: typeof record.newsletter === 'boolean' ? record.newsletter : false,
|
||||
|
|
@ -136,20 +134,6 @@ export function useAuth() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const requestVerification = useCallback(async (email: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('users').requestVerification(email);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Verification request failed';
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
|
@ -163,7 +147,6 @@ export function useAuth() {
|
|||
loginWithOAuth,
|
||||
logout,
|
||||
requestPasswordReset,
|
||||
requestVerification,
|
||||
refreshAuth,
|
||||
clearError,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -771,9 +771,12 @@ export function useDeckLayers({
|
|||
onHexagonHoverRef.current(null);
|
||||
}, []);
|
||||
|
||||
const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
|
||||
|
||||
return {
|
||||
layers,
|
||||
popupInfo,
|
||||
clearPopupInfo,
|
||||
hoverPosition,
|
||||
countRange,
|
||||
postcodeCountRange,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import pb from '../lib/pocketbase';
|
||||
import { apiUrl, authHeaders } from '../lib/api';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
|
|
@ -12,39 +12,94 @@ export interface SavedSearch {
|
|||
created: string;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLL_ATTEMPTS = 15;
|
||||
|
||||
export function useSavedSearches(userId: string | null) {
|
||||
const [searches, setSearches] = useState<SavedSearch[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollAttemptsRef = useRef(0);
|
||||
const userIdRef = useRef(userId);
|
||||
userIdRef.current = userId;
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
pollAttemptsRef.current = 0;
|
||||
}, []);
|
||||
|
||||
// Clean up polling on unmount or userId change
|
||||
useEffect(() => stopPolling, [userId, stopPolling]);
|
||||
|
||||
const fetchRecords = useCallback(async (uid: string): Promise<SavedSearch[]> => {
|
||||
const records = await pb.collection('saved_searches').getFullList({
|
||||
sort: '-created',
|
||||
filter: `user = "${uid}"`,
|
||||
});
|
||||
return records.map((r) => ({
|
||||
id: r.id,
|
||||
name: (r as Record<string, unknown>).name as string,
|
||||
params: (r as Record<string, unknown>).params as string,
|
||||
screenshotUrl: (r as Record<string, unknown>).screenshot
|
||||
? pb.files.getURL(r, (r as Record<string, unknown>).screenshot as string)
|
||||
: '',
|
||||
notes: ((r as Record<string, unknown>).notes as string) || '',
|
||||
created: r.created,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
if (pollTimerRef.current) return;
|
||||
pollAttemptsRef.current = 0;
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
const uid = userIdRef.current;
|
||||
if (!uid) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
pollAttemptsRef.current++;
|
||||
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mapped = await fetchRecords(uid);
|
||||
setSearches(mapped);
|
||||
if (!mapped.some((s) => !s.screenshotUrl)) {
|
||||
stopPolling();
|
||||
}
|
||||
} catch {
|
||||
// Silent — background poll errors don't surface to UI
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
}, [stopPolling, fetchRecords]);
|
||||
|
||||
const fetchSearches = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const records = await pb.collection('saved_searches').getFullList({
|
||||
sort: '-created',
|
||||
filter: `user = "${userId}"`,
|
||||
});
|
||||
setSearches(
|
||||
records.map((r) => ({
|
||||
id: r.id,
|
||||
name: (r as Record<string, unknown>).name as string,
|
||||
params: (r as Record<string, unknown>).params as string,
|
||||
screenshotUrl: (r as Record<string, unknown>).screenshot
|
||||
? pb.files.getURL(r, (r as Record<string, unknown>).screenshot as string)
|
||||
: '',
|
||||
notes: ((r as Record<string, unknown>).notes as string) || '',
|
||||
created: r.created,
|
||||
}))
|
||||
);
|
||||
const mapped = await fetchRecords(userId);
|
||||
setSearches(mapped);
|
||||
|
||||
// Poll for missing screenshots so they appear without a page refresh
|
||||
if (mapped.some((s) => !s.screenshotUrl)) {
|
||||
startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load searches');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
}, [userId, fetchRecords, startPolling, stopPolling]);
|
||||
|
||||
const saveSearch = useCallback(
|
||||
async (name: string) => {
|
||||
|
|
|
|||
|
|
@ -205,8 +205,12 @@ export function buildPropertySearchUrls({
|
|||
(listingStatus as string[]).includes('For rent');
|
||||
let openrent: string | null = null;
|
||||
if (isRent) {
|
||||
const postcodeNoSpaces = postcode.replace(/\s+/g, '');
|
||||
const orSlug = postcodeNoSpaces.toLowerCase();
|
||||
const orParams = new URLSearchParams();
|
||||
orParams.set('term', postcode);
|
||||
orParams.set('term', postcodeNoSpaces.toUpperCase());
|
||||
const radiusKm = Math.round((isPostcode ? 0.25 : radiusMiles) * 1.609);
|
||||
orParams.set('area', String(Math.max(1, radiusKm)));
|
||||
const rentFilter = filters['Asking rent (monthly)'];
|
||||
const minRent =
|
||||
Array.isArray(rentFilter) && typeof rentFilter[0] === 'number' ? rentFilter[0] : undefined;
|
||||
|
|
@ -216,7 +220,7 @@ export function buildPropertySearchUrls({
|
|||
if (maxRent !== undefined) orParams.set('prices_max', String(Math.round(maxRent)));
|
||||
if (minBedrooms !== undefined) orParams.set('bedrooms_min', String(Math.floor(minBedrooms)));
|
||||
if (maxBedrooms !== undefined) orParams.set('bedrooms_max', String(Math.ceil(maxBedrooms)));
|
||||
openrent = `https://www.openrent.com/properties-to-rent?${orParams.toString()}`;
|
||||
openrent = `https://www.openrent.co.uk/properties-to-rent/${orSlug}?${orParams.toString()}`;
|
||||
}
|
||||
|
||||
return { rightmove, onthemarket, zoopla, openrent };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue