Small fixes & fmt
This commit is contained in:
parent
6b12e21d50
commit
f32a552f46
23 changed files with 347 additions and 99 deletions
|
|
@ -301,7 +301,7 @@ def _paginate(page, total_results: int, channel: str) -> list[dict]:
|
|||
if not all_listings or total_results <= len(all_listings):
|
||||
return all_listings
|
||||
|
||||
seen_ids = {l["id"] for l in all_listings}
|
||||
seen_ids = {listing["id"] for listing in all_listings}
|
||||
current_url = page.url
|
||||
page_num = 2
|
||||
|
||||
|
|
|
|||
|
|
@ -329,6 +329,7 @@ export default function App() {
|
|||
searchesLoading={savedSearches.loading}
|
||||
onDeleteSearch={savedSearches.deleteSearch}
|
||||
onUpdateSearchNotes={savedSearches.updateSearchNotes}
|
||||
onUpdateSearchName={savedSearches.updateSearchName}
|
||||
onOpenSearch={(params) => {
|
||||
window.location.href = `/dashboard?${params}`;
|
||||
}}
|
||||
|
|
@ -343,10 +344,7 @@ export default function App() {
|
|||
) : activePage === 'invites' && user ? (
|
||||
<InvitesPage user={user} />
|
||||
) : activePage === 'account' && user ? (
|
||||
<AccountPage
|
||||
user={user}
|
||||
onRefreshAuth={refreshAuth}
|
||||
/>
|
||||
<AccountPage user={user} onRefreshAuth={refreshAuth} />
|
||||
) : activePage === 'invite' && inviteCode ? (
|
||||
<InvitePage
|
||||
code={inviteCode}
|
||||
|
|
|
|||
|
|
@ -140,17 +140,72 @@ function formatPropertyDetails(data: SavedPropertyData): string {
|
|||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function EditableName({ value, onSave }: { value: string; onSave: (name: string) => void }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [text, setText] = useState(value);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setText(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const commit = () => {
|
||||
setEditing(false);
|
||||
const trimmed = text.trim();
|
||||
if (trimmed && trimmed !== value) onSave(trimmed);
|
||||
else setText(value);
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit();
|
||||
if (e.key === 'Escape') {
|
||||
setText(value);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onBlur={commit}
|
||||
className="w-full font-medium text-navy-950 dark:text-warm-100 bg-warm-50 dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded px-1.5 py-0.5 text-sm focus:outline-none focus:ring-1 focus:ring-teal-400"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<h3
|
||||
onClick={() => setEditing(true)}
|
||||
className="font-medium text-navy-950 dark:text-warm-100 truncate cursor-pointer hover:text-teal-600 dark:hover:text-teal-400 border-b border-dotted border-transparent hover:border-warm-400 dark:hover:border-warm-500"
|
||||
title="Click to rename"
|
||||
>
|
||||
{value}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedSearchesTab({
|
||||
searches,
|
||||
loading,
|
||||
onDelete,
|
||||
onUpdateNotes,
|
||||
onUpdateName,
|
||||
onOpen,
|
||||
}: {
|
||||
searches: SavedSearch[];
|
||||
loading: boolean;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onUpdateNotes: (id: string, notes: string) => void;
|
||||
onUpdateName: (id: string, name: string) => void;
|
||||
onOpen: (params: string) => void;
|
||||
}) {
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
|
@ -229,9 +284,12 @@ function SavedSearchesTab({
|
|||
)}
|
||||
|
||||
<div className="p-4 flex flex-col flex-1">
|
||||
<h3 className="font-medium text-navy-950 dark:text-warm-100 truncate mb-1">
|
||||
{search.name}
|
||||
</h3>
|
||||
<div className="mb-1">
|
||||
<EditableName
|
||||
value={search.name}
|
||||
onSave={(name) => onUpdateName(search.id, name)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">
|
||||
{formatRelativeTime(search.created)}
|
||||
</p>
|
||||
|
|
@ -414,6 +472,7 @@ export function SavedPage({
|
|||
searchesLoading,
|
||||
onDeleteSearch,
|
||||
onUpdateSearchNotes,
|
||||
onUpdateSearchName,
|
||||
onOpenSearch,
|
||||
savedProperties,
|
||||
propertiesLoading,
|
||||
|
|
@ -425,6 +484,7 @@ export function SavedPage({
|
|||
searchesLoading: boolean;
|
||||
onDeleteSearch: (id: string) => Promise<void>;
|
||||
onUpdateSearchNotes: (id: string, notes: string) => void;
|
||||
onUpdateSearchName: (id: string, name: string) => void;
|
||||
onOpenSearch: (params: string) => void;
|
||||
savedProperties: SavedProperty[];
|
||||
propertiesLoading: boolean;
|
||||
|
|
@ -470,6 +530,7 @@ export function SavedPage({
|
|||
loading={searchesLoading}
|
||||
onDelete={onDeleteSearch}
|
||||
onUpdateNotes={onUpdateSearchNotes}
|
||||
onUpdateName={onUpdateSearchName}
|
||||
onOpen={onOpenSearch}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -186,8 +186,8 @@ export default function HomePage({
|
|||
tabs, one postcode at a time.
|
||||
</p>
|
||||
<p>
|
||||
We flip that. Tell us what you need (budget, commute, schools, safety) and we show
|
||||
you every area in England that qualifies. No guesswork. No wasted viewings.
|
||||
We flip that. Tell us what you need (budget, commute, schools, safety) and we show you
|
||||
every area in England that qualifies. No guesswork. No wasted viewings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ const FAQ_SECTIONS: FAQSection[] = [
|
|||
{
|
||||
question: 'How can I check if an area is safe before I move there?',
|
||||
answer:
|
||||
"We overlay real police-recorded crime data, broken down by type, onto every neighbourhood in England. Filter by violent crime, burglary, or antisocial behaviour and instantly see which postcodes have the lowest numbers.",
|
||||
'We overlay real police-recorded crime data, broken down by type, onto every neighbourhood in England. Filter by violent crime, burglary, or antisocial behaviour and instantly see which postcodes have the lowest numbers.',
|
||||
},
|
||||
{
|
||||
question:
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ function EditableLabel({
|
|||
if (e.key === 'Escape') setEditing(false);
|
||||
}}
|
||||
onBlur={commit}
|
||||
className="absolute -translate-x-1/2 w-16 text-[10px] text-center rounded border border-warm-300 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-0.5 focus:outline-none focus:ring-1 focus:ring-teal-400"
|
||||
className="absolute w-16 text-[10px] text-center rounded border border-warm-300 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-0.5 focus:outline-none focus:ring-1 focus:ring-teal-400"
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
|
|
@ -81,7 +81,7 @@ function EditableLabel({
|
|||
|
||||
return (
|
||||
<span
|
||||
className={`absolute -translate-x-1/2 cursor-pointer hover:text-teal-600 dark:hover:text-teal-400 border-b border-dotted border-warm-400 dark:border-warm-500 ${className ?? ''}`}
|
||||
className={`absolute cursor-pointer hover:text-teal-600 dark:hover:text-teal-400 border-b border-dotted border-warm-400 dark:border-warm-500 ${className ?? ''}`}
|
||||
style={style}
|
||||
onClick={startEdit}
|
||||
>
|
||||
|
|
@ -119,24 +119,32 @@ function SliderLabels({
|
|||
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw);
|
||||
const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], raw);
|
||||
|
||||
// Smoothly spread labels apart as thumbs get close to prevent overlap.
|
||||
// t=1 (centered) when far apart, t=0 (split) when touching.
|
||||
const SPREAD_THRESHOLD = 20; // percentage gap below which labels start separating
|
||||
const gapPct = rightPct - leftPct;
|
||||
const t = Math.min(1, Math.max(0, gapPct / SPREAD_THRESHOLD));
|
||||
const leftTranslate = `translateX(${-100 + t * 50}%)`;
|
||||
const rightTranslate = `translateX(${-t * 50}%)`;
|
||||
|
||||
if (feature && onValueChange) {
|
||||
return (
|
||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<EditableLabel
|
||||
value={labels[0]}
|
||||
formatted={minLabel}
|
||||
onCommit={(v) => onValueChange([v, labels[1]])}
|
||||
onCommit={(v) => onValueChange([Math.min(v, labels[1]), labels[1]])}
|
||||
prefix={feature.prefix}
|
||||
suffix={feature.suffix}
|
||||
style={{ left: `${leftPct}%` }}
|
||||
style={{ left: `${leftPct}%`, transform: leftTranslate }}
|
||||
/>
|
||||
<EditableLabel
|
||||
value={labels[1]}
|
||||
formatted={maxLabel}
|
||||
onCommit={(v) => onValueChange([labels[0], v])}
|
||||
onCommit={(v) => onValueChange([labels[0], Math.max(v, labels[0])])}
|
||||
prefix={feature.prefix}
|
||||
suffix={feature.suffix}
|
||||
style={{ left: `${rightPct}%` }}
|
||||
style={{ left: `${rightPct}%`, transform: rightTranslate }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -144,10 +152,10 @@ function SliderLabels({
|
|||
|
||||
return (
|
||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<span className="absolute -translate-x-1/2" style={{ left: `${leftPct}%` }}>
|
||||
<span className="absolute" style={{ left: `${leftPct}%`, transform: leftTranslate }}>
|
||||
{minLabel}
|
||||
</span>
|
||||
<span className="absolute -translate-x-1/2" style={{ left: `${rightPct}%` }}>
|
||||
<span className="absolute" style={{ left: `${rightPct}%`, transform: rightTranslate }}>
|
||||
{maxLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -391,7 +399,9 @@ export default memo(function Filters({
|
|||
ref={containerRef}
|
||||
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 ${addFilterCollapsed ? '' : 'md:basis-[40%]'}`}>
|
||||
<div
|
||||
className={`shrink-0 md:shrink md:min-h-0 flex flex-col ${addFilterCollapsed ? '' : '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 bg-teal-50 dark:bg-teal-900/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
|
||||
|
|
@ -452,10 +462,7 @@ export default memo(function Filters({
|
|||
|
||||
<div className="px-2 py-1 space-y-1">
|
||||
{travelTimeEntries.map((entry, index) => (
|
||||
<div
|
||||
key={`tt_${index}`}
|
||||
data-filter-name={`tt_${index}`}
|
||||
>
|
||||
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
|
|
@ -464,9 +471,7 @@ export default memo(function Filters({
|
|||
useBest={entry.useBest}
|
||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
||||
onSetDestination={(slug, label) =>
|
||||
onTravelTimeSetDestination(index, slug, label)
|
||||
}
|
||||
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
|
|
@ -560,9 +565,7 @@ export default memo(function Filters({
|
|||
<Slider
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
step={
|
||||
scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)
|
||||
}
|
||||
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
|
|
@ -570,9 +573,7 @@ export default memo(function Filters({
|
|||
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)),
|
||||
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
|
||||
pMax >= 100
|
||||
? (hist?.max ?? feature.max!)
|
||||
: snap(scale.toValue(pMax)),
|
||||
|
|
@ -606,13 +607,18 @@ export default memo(function Filters({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`shrink-0 md:shrink md:min-h-0 flex flex-col border-t border-warm-200 dark:border-warm-700 ${addFilterCollapsed ? '' : 'md:basis-[60%]'}`}>
|
||||
<div
|
||||
className={`shrink-0 md:shrink md:min-h-0 flex flex-col border-t border-warm-200 dark:border-warm-700 ${addFilterCollapsed ? '' : 'md:basis-[60%]'}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setAddFilterCollapsed((v) => !v)}
|
||||
className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30 cursor-pointer hover:bg-teal-100 dark:hover:bg-teal-900/50"
|
||||
>
|
||||
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">Add Filter</span>
|
||||
<ChevronIcon direction={addFilterCollapsed ? 'down' : 'up'} className="w-4 h-4 text-warm-400 dark:text-warm-500" />
|
||||
<ChevronIcon
|
||||
direction={addFilterCollapsed ? 'down' : 'up'}
|
||||
className="w-4 h-4 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
</button>
|
||||
{!addFilterCollapsed && (
|
||||
<div className="md:min-h-0 md:flex-1 flex flex-col">
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default function HistogramLegend() {
|
|||
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span>{' '}
|
||||
indicates the global average
|
||||
indicates the national average
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -264,8 +264,18 @@ export default function JourneyInstructions({
|
|||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||
>
|
||||
View on Google Maps
|
||||
<svg className="w-3 h-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path
|
||||
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -284,8 +294,18 @@ export default function JourneyInstructions({
|
|||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||
>
|
||||
View on Google Maps
|
||||
<svg className="w-3 h-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path
|
||||
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ export function FeatureActions({
|
|||
return (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{feature.detail && onShowInfo && (
|
||||
<IconButton onClick={() => onShowInfo(feature)} title="Feature info">
|
||||
<InfoIcon />
|
||||
<IconButton onClick={() => onShowInfo(feature)} title="Feature info" size="md">
|
||||
<InfoIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export function FeatureLabel({
|
|||
{featureIcon}
|
||||
{GroupIcon && <GroupIcon className={iconClass} />}
|
||||
<span
|
||||
className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}
|
||||
className={`${textClass} ${size === 'sm' ? 'font-medium text-navy-950 dark:text-warm-100' : 'text-warm-700 dark:text-warm-300 truncate'}`}
|
||||
>
|
||||
{feature.name}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
|
||||
|
|
@ -15,6 +15,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
const pendingDragRef = useRef<string | null>(null);
|
||||
const dragActiveRef = useRef<string | null>(null);
|
||||
const dragValueRef = useRef<[number, number] | null>(null);
|
||||
const undoStackRef = useRef<FeatureFilters[]>([]);
|
||||
|
||||
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
||||
|
||||
|
|
@ -34,17 +35,41 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
const meta = features.find((f) => f.name === name);
|
||||
if (!meta) return;
|
||||
trackEvent('Filter Add', { feature: name });
|
||||
if (meta.type === 'enum' && meta.values) {
|
||||
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
|
||||
} else if (meta.type === 'numeric' && meta.histogram) {
|
||||
setFilters((prev) => ({ ...prev, [name]: [meta.histogram!.min, meta.histogram!.max] }));
|
||||
} else if (meta.min != null && meta.max != null) {
|
||||
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
|
||||
}
|
||||
setFilters((prev) => {
|
||||
undoStackRef.current.push(prev);
|
||||
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
|
||||
if (meta.type === 'enum' && meta.values) {
|
||||
return { ...prev, [name]: [...meta.values!] };
|
||||
} else if (meta.type === 'numeric' && meta.histogram) {
|
||||
return { ...prev, [name]: [meta.histogram!.min, meta.histogram!.max] };
|
||||
} else if (meta.min != null && meta.max != null) {
|
||||
return { ...prev, [name]: [meta.min!, meta.max!] };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
[features]
|
||||
);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
const prev = undoStackRef.current.pop();
|
||||
if (prev) setFilters(prev);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
return;
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [handleUndo]);
|
||||
|
||||
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
|
||||
setFilters((prev) => ({ ...prev, [name]: value }));
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -167,6 +167,15 @@ export function useSavedSearches(userId: string | null) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const updateSearchName = useCallback(async (id: string, name: string) => {
|
||||
try {
|
||||
await pb.collection('saved_searches').update(id, { name });
|
||||
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s)));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update name');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
searches,
|
||||
loading,
|
||||
|
|
@ -176,5 +185,6 @@ export function useSavedSearches(userId: string | null) {
|
|||
saveSearch,
|
||||
deleteSearch,
|
||||
updateSearchNotes,
|
||||
updateSearchName,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,12 @@ export function useTravelDestinations(mode: TransportMode) {
|
|||
return res.json();
|
||||
})
|
||||
.then((data: { destinations: Destination[] }) => {
|
||||
cacheRef.current[mode] = data.destinations;
|
||||
setDestinations(data.destinations);
|
||||
const normalized = data.destinations.map((d) => ({
|
||||
...d,
|
||||
city: d.city === 'City of London' ? 'London' : d.city,
|
||||
}));
|
||||
cacheRef.current[mode] = normalized;
|
||||
setDestinations(normalized);
|
||||
})
|
||||
.catch((err) => logNonAbortError('travel destinations', err))
|
||||
.finally(() => setLoading(false));
|
||||
|
|
|
|||
|
|
@ -61,8 +61,7 @@ export async function fetchWithRetry<T>(
|
|||
|
||||
/** Fire-and-forget request to pre-warm the screenshot cache for OG images. */
|
||||
export function prewarmScreenshot(params: string): void {
|
||||
fetch(apiUrl('screenshot', new URLSearchParams(`og=1&${params}`)), authHeaders())
|
||||
.catch(() => {}); // best-effort, don't care if it fails
|
||||
fetch(apiUrl('screenshot', new URLSearchParams(`og=1&${params}`)), authHeaders()).catch(() => {}); // best-effort, don't care if it fails
|
||||
}
|
||||
|
||||
export async function shortenUrl(params: string): Promise<string> {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
/** Copy text to clipboard with execCommand fallback for older browsers. */
|
||||
export function copyToClipboard(text: string, onSuccess: () => void): void {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess).catch(() => {
|
||||
// Fallback if clipboard permission denied
|
||||
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();
|
||||
});
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(onSuccess)
|
||||
.catch(() => {
|
||||
// Fallback if clipboard permission denied
|
||||
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();
|
||||
});
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
|
|
|
|||
|
|
@ -51,24 +51,23 @@ const RIGHTMOVE_PRICES = [
|
|||
|
||||
// Rightmove allowed monthly rent values (pcm)
|
||||
const RIGHTMOVE_RENTS = [
|
||||
250, 300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000,
|
||||
3500, 4000, 5000, 7500, 10000, 15000, 25000,
|
||||
250, 300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500,
|
||||
4000, 5000, 7500, 10000, 15000, 25000,
|
||||
];
|
||||
|
||||
// OnTheMarket allowed buy prices
|
||||
const OTM_PRICES = [
|
||||
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000,
|
||||
160000, 170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 275000,
|
||||
300000, 325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000, 550000, 600000, 650000,
|
||||
700000, 750000, 800000, 900000, 1000000, 1250000, 1500000, 2000000, 2500000, 3000000, 5000000,
|
||||
7500000, 10000000, 15000000,
|
||||
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000, 160000,
|
||||
170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 275000, 300000,
|
||||
325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000, 550000, 600000, 650000, 700000,
|
||||
750000, 800000, 900000, 1000000, 1250000, 1500000, 2000000, 2500000, 3000000, 5000000, 7500000,
|
||||
10000000, 15000000,
|
||||
];
|
||||
|
||||
// OnTheMarket allowed monthly rent values (pcm)
|
||||
const OTM_RENTS = [
|
||||
100, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, 1000,
|
||||
1100, 1200, 1250, 1300, 1400, 1500, 1750, 2000, 2500, 3000, 3500, 4000, 5000, 7500, 10000,
|
||||
25000,
|
||||
100, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, 1000, 1100,
|
||||
1200, 1250, 1300, 1400, 1500, 1750, 2000, 2500, 3000, 3500, 4000, 5000, 7500, 10000, 25000,
|
||||
];
|
||||
|
||||
// Zoopla allowed buy prices
|
||||
|
|
@ -81,8 +80,8 @@ const ZOOPLA_PRICES = [
|
|||
|
||||
// Zoopla allowed monthly rent values (pcm)
|
||||
const ZOOPLA_RENTS = [
|
||||
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500,
|
||||
4000, 5000, 7500, 10000, 25000,
|
||||
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500, 4000,
|
||||
5000, 7500, 10000, 25000,
|
||||
];
|
||||
|
||||
function snapToAllowed(value: number, allowed: number[], direction: 'floor' | 'ceil'): number {
|
||||
|
|
@ -133,7 +132,9 @@ export function buildPropertySearchUrls({
|
|||
// For rent mode, check asking rent first
|
||||
const priceFilter = isRent
|
||||
? filters['Asking rent (monthly)']
|
||||
: (filters['Asking price'] ?? filters['Estimated current price'] ?? filters['Last known price']);
|
||||
: (filters['Asking price'] ??
|
||||
filters['Estimated current price'] ??
|
||||
filters['Last known price']);
|
||||
const minPrice =
|
||||
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
|
||||
const maxPrice =
|
||||
|
|
|
|||
|
|
@ -264,8 +264,8 @@ def main():
|
|||
print()
|
||||
|
||||
# Summary stats
|
||||
prices = [l["price"] for l in listings if l["price"]]
|
||||
beds = [l["beds"] for l in listings if l["beds"]]
|
||||
prices = [item["price"] for item in listings if item["price"]]
|
||||
beds = [item["beds"] for item in listings if item["beds"]]
|
||||
if prices:
|
||||
print(f"Price range: £{min(prices):,} - £{max(prices):,}")
|
||||
print(f"Median: £{sorted(prices)[len(prices)//2]:,}")
|
||||
|
|
|
|||
|
|
@ -437,10 +437,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
.route("/api/features", get(routes::get_features))
|
||||
.route("/api/hexagons", get(routes::get_hexagons))
|
||||
.route("/api/postcodes", get(routes::get_postcodes))
|
||||
.route(
|
||||
"/api/postcode/{postcode}",
|
||||
get(routes::get_postcode_lookup),
|
||||
)
|
||||
.route("/api/postcode/{postcode}", get(routes::get_postcode_lookup))
|
||||
.route("/api/pois", get(routes::get_pois))
|
||||
.route("/api/poi-categories", get(routes::get_poi_categories))
|
||||
.route("/api/places", get(routes::get_places))
|
||||
|
|
@ -478,10 +475,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
"/api/checkout",
|
||||
post(routes::post_checkout).layer(ConcurrencyLimitLayer::new(10)),
|
||||
)
|
||||
.route(
|
||||
"/api/stripe-webhook",
|
||||
post(routes::post_stripe_webhook),
|
||||
)
|
||||
.route("/api/stripe-webhook", post(routes::post_stripe_webhook))
|
||||
.route(
|
||||
"/api/invites",
|
||||
get(routes::get_invites).post(routes::post_invites),
|
||||
|
|
@ -491,10 +485,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
.route("/s/{code}", get(routes::get_short_url))
|
||||
.route("/api/telemetry", post(routes::post_telemetry))
|
||||
.route("/api/reload", post(routes::post_reload))
|
||||
.route(
|
||||
"/pb/{*rest}",
|
||||
any(routes::proxy_to_pocketbase),
|
||||
)
|
||||
.route("/pb/{*rest}", any(routes::proxy_to_pocketbase))
|
||||
// Tile routes use a different state type — kept as closures
|
||||
.route(
|
||||
"/api/tiles/{z}/{x}/{y}",
|
||||
|
|
|
|||
|
|
@ -154,6 +154,20 @@ impl Field {
|
|||
}
|
||||
}
|
||||
|
||||
fn number(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
r#type: "number".to_string(),
|
||||
required: None,
|
||||
max_select: None,
|
||||
collection_id: None,
|
||||
max_size: None,
|
||||
mime_types: None,
|
||||
on_create: None,
|
||||
on_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn autodate(name: &str, on_create: bool, on_update: bool) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
|
|
@ -717,6 +731,39 @@ pub async fn ensure_collections(
|
|||
ensure_autodate_fields(client, base_url, &token, "short_urls").await?;
|
||||
}
|
||||
|
||||
if !existing.iter().any(|n| n == "ai_query_logs") {
|
||||
let users_id = find_users_collection_id(client, base_url, &token).await?;
|
||||
create_collection(
|
||||
client,
|
||||
base_url,
|
||||
&token,
|
||||
CreateCollection {
|
||||
name: "ai_query_logs".to_string(),
|
||||
r#type: "base".to_string(),
|
||||
fields: vec![
|
||||
Field::relation("user", &users_id),
|
||||
Field::text("query", true),
|
||||
Field::text("listing_type", false),
|
||||
Field::text("response_filters", false),
|
||||
Field::text("response_notes", false),
|
||||
Field::number("tokens_used"),
|
||||
Field::number("rounds"),
|
||||
Field::text("model", false),
|
||||
Field::autodate("created", true, false),
|
||||
Field::autodate("updated", true, true),
|
||||
],
|
||||
list_rule: None,
|
||||
view_rule: None,
|
||||
create_rule: None,
|
||||
update_rule: None,
|
||||
delete_rule: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
ensure_autodate_fields(client, base_url, &token, "ai_query_logs").await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -869,6 +916,56 @@ async fn poll_pocketbase_counts(state: &AppState) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Insert a record into the `ai_query_logs` collection.
|
||||
/// Best-effort — logs warnings on failure but does not propagate errors.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn log_ai_query(
|
||||
state: &AppState,
|
||||
user_id: &str,
|
||||
query: &str,
|
||||
listing_type: &str,
|
||||
response_filters: &str,
|
||||
response_notes: &str,
|
||||
tokens_used: u64,
|
||||
rounds: u64,
|
||||
) {
|
||||
let token = match get_superuser_token(state).await {
|
||||
Ok(tk) => tk,
|
||||
Err(err) => {
|
||||
warn!("Failed to auth superuser for AI query log: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let url = format!("{pb_url}/api/collections/ai_query_logs/records");
|
||||
let res = state
|
||||
.http_client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"user": user_id,
|
||||
"query": query,
|
||||
"listing_type": listing_type,
|
||||
"response_filters": response_filters,
|
||||
"response_notes": response_notes,
|
||||
"tokens_used": tokens_used,
|
||||
"rounds": rounds,
|
||||
"model": &state.gemini_model,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
warn!("Failed to log AI query ({status})");
|
||||
}
|
||||
Err(err) => warn!("Failed to log AI query: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn pb_count(
|
||||
client: &reqwest::Client,
|
||||
pb_url: &str,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use tracing::{info, warn};
|
|||
use crate::auth::OptionalUser;
|
||||
use crate::consts::{AI_FILTERS_MAX_TOKENS, AI_FILTERS_TEMPERATURE, AI_FILTERS_WEEKLY_TOKEN_LIMIT};
|
||||
use crate::data::slugify;
|
||||
use crate::pocketbase::get_superuser_token;
|
||||
use crate::pocketbase::{get_superuser_token, log_ai_query};
|
||||
use crate::routes::{FeatureInfo, FeaturesResponse};
|
||||
use crate::state::{AppState, SharedState};
|
||||
use crate::utils::gemini_chat;
|
||||
|
|
@ -783,6 +783,28 @@ pub async fn post_ai_filters(
|
|||
counter!("ai_tokens_total").increment(total_tokens_accumulated);
|
||||
counter!("ai_requests_total", "status" => "success").increment(1);
|
||||
|
||||
// Log the query to PocketBase (fire-and-forget)
|
||||
let filters_json = serde_json::to_string(&filters).unwrap_or_default();
|
||||
let log_state = state.clone();
|
||||
let log_user_id = user.id.clone();
|
||||
let log_query = req.query.clone();
|
||||
let log_listing_type = listing_type.to_string();
|
||||
let log_notes = notes.clone();
|
||||
let log_rounds = (round + 1) as u64;
|
||||
tokio::spawn(async move {
|
||||
log_ai_query(
|
||||
&log_state,
|
||||
&log_user_id,
|
||||
&log_query,
|
||||
&log_listing_type,
|
||||
&filters_json,
|
||||
&log_notes,
|
||||
total_tokens_accumulated,
|
||||
log_rounds,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
return Ok(Json(AiFiltersResponse {
|
||||
filters,
|
||||
travel_time_filters,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ static PROXY_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
|||
.expect("Failed to build proxy HTTP client")
|
||||
});
|
||||
|
||||
pub async fn proxy_to_pocketbase(State(shared): State<Arc<SharedState>>, req: Request) -> impl IntoResponse {
|
||||
pub async fn proxy_to_pocketbase(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
req: Request,
|
||||
) -> impl IntoResponse {
|
||||
let state = shared.load_state();
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
|
|
|
|||
|
|
@ -128,7 +128,9 @@ pub struct POICategoriesResponse {
|
|||
groups: Vec<POICategoryGroup>,
|
||||
}
|
||||
|
||||
pub async fn get_poi_categories(State(shared): State<Arc<SharedState>>) -> Json<POICategoriesResponse> {
|
||||
pub async fn get_poi_categories(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
) -> Json<POICategoriesResponse> {
|
||||
let state = shared.load_state();
|
||||
let groups: Vec<POICategoryGroup> = state.poi_category_groups.to_vec();
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,10 @@ struct PbRecord {
|
|||
params: String,
|
||||
}
|
||||
|
||||
pub async fn post_shorten(State(shared): State<Arc<SharedState>>, Json(req): Json<ShortenRequest>) -> Response {
|
||||
pub async fn post_shorten(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Json(req): Json<ShortenRequest>,
|
||||
) -> Response {
|
||||
let state = shared.load_state();
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
|
|
@ -86,7 +89,10 @@ pub async fn post_shorten(State(shared): State<Arc<SharedState>>, Json(req): Jso
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn get_short_url(State(shared): State<Arc<SharedState>>, Path(code): Path<String>) -> Response {
|
||||
pub async fn get_short_url(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Path(code): Path<String>,
|
||||
) -> Response {
|
||||
let state = shared.load_state();
|
||||
|
||||
if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue