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):
|
if not all_listings or total_results <= len(all_listings):
|
||||||
return 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
|
current_url = page.url
|
||||||
page_num = 2
|
page_num = 2
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -329,6 +329,7 @@ export default function App() {
|
||||||
searchesLoading={savedSearches.loading}
|
searchesLoading={savedSearches.loading}
|
||||||
onDeleteSearch={savedSearches.deleteSearch}
|
onDeleteSearch={savedSearches.deleteSearch}
|
||||||
onUpdateSearchNotes={savedSearches.updateSearchNotes}
|
onUpdateSearchNotes={savedSearches.updateSearchNotes}
|
||||||
|
onUpdateSearchName={savedSearches.updateSearchName}
|
||||||
onOpenSearch={(params) => {
|
onOpenSearch={(params) => {
|
||||||
window.location.href = `/dashboard?${params}`;
|
window.location.href = `/dashboard?${params}`;
|
||||||
}}
|
}}
|
||||||
|
|
@ -343,10 +344,7 @@ export default function App() {
|
||||||
) : activePage === 'invites' && user ? (
|
) : activePage === 'invites' && user ? (
|
||||||
<InvitesPage user={user} />
|
<InvitesPage user={user} />
|
||||||
) : activePage === 'account' && user ? (
|
) : activePage === 'account' && user ? (
|
||||||
<AccountPage
|
<AccountPage user={user} onRefreshAuth={refreshAuth} />
|
||||||
user={user}
|
|
||||||
onRefreshAuth={refreshAuth}
|
|
||||||
/>
|
|
||||||
) : activePage === 'invite' && inviteCode ? (
|
) : activePage === 'invite' && inviteCode ? (
|
||||||
<InvitePage
|
<InvitePage
|
||||||
code={inviteCode}
|
code={inviteCode}
|
||||||
|
|
|
||||||
|
|
@ -140,17 +140,72 @@ function formatPropertyDetails(data: SavedPropertyData): string {
|
||||||
return parts.join(' · ');
|
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({
|
function SavedSearchesTab({
|
||||||
searches,
|
searches,
|
||||||
loading,
|
loading,
|
||||||
onDelete,
|
onDelete,
|
||||||
onUpdateNotes,
|
onUpdateNotes,
|
||||||
|
onUpdateName,
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
searches: SavedSearch[];
|
searches: SavedSearch[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onDelete: (id: string) => Promise<void>;
|
onDelete: (id: string) => Promise<void>;
|
||||||
onUpdateNotes: (id: string, notes: string) => void;
|
onUpdateNotes: (id: string, notes: string) => void;
|
||||||
|
onUpdateName: (id: string, name: string) => void;
|
||||||
onOpen: (params: string) => void;
|
onOpen: (params: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
|
@ -229,9 +284,12 @@ function SavedSearchesTab({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-4 flex flex-col flex-1">
|
<div className="p-4 flex flex-col flex-1">
|
||||||
<h3 className="font-medium text-navy-950 dark:text-warm-100 truncate mb-1">
|
<div className="mb-1">
|
||||||
{search.name}
|
<EditableName
|
||||||
</h3>
|
value={search.name}
|
||||||
|
onSave={(name) => onUpdateName(search.id, name)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">
|
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">
|
||||||
{formatRelativeTime(search.created)}
|
{formatRelativeTime(search.created)}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -414,6 +472,7 @@ export function SavedPage({
|
||||||
searchesLoading,
|
searchesLoading,
|
||||||
onDeleteSearch,
|
onDeleteSearch,
|
||||||
onUpdateSearchNotes,
|
onUpdateSearchNotes,
|
||||||
|
onUpdateSearchName,
|
||||||
onOpenSearch,
|
onOpenSearch,
|
||||||
savedProperties,
|
savedProperties,
|
||||||
propertiesLoading,
|
propertiesLoading,
|
||||||
|
|
@ -425,6 +484,7 @@ export function SavedPage({
|
||||||
searchesLoading: boolean;
|
searchesLoading: boolean;
|
||||||
onDeleteSearch: (id: string) => Promise<void>;
|
onDeleteSearch: (id: string) => Promise<void>;
|
||||||
onUpdateSearchNotes: (id: string, notes: string) => void;
|
onUpdateSearchNotes: (id: string, notes: string) => void;
|
||||||
|
onUpdateSearchName: (id: string, name: string) => void;
|
||||||
onOpenSearch: (params: string) => void;
|
onOpenSearch: (params: string) => void;
|
||||||
savedProperties: SavedProperty[];
|
savedProperties: SavedProperty[];
|
||||||
propertiesLoading: boolean;
|
propertiesLoading: boolean;
|
||||||
|
|
@ -470,6 +530,7 @@ export function SavedPage({
|
||||||
loading={searchesLoading}
|
loading={searchesLoading}
|
||||||
onDelete={onDeleteSearch}
|
onDelete={onDeleteSearch}
|
||||||
onUpdateNotes={onUpdateSearchNotes}
|
onUpdateNotes={onUpdateSearchNotes}
|
||||||
|
onUpdateName={onUpdateSearchName}
|
||||||
onOpen={onOpenSearch}
|
onOpen={onOpenSearch}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -186,8 +186,8 @@ export default function HomePage({
|
||||||
tabs, one postcode at a time.
|
tabs, one postcode at a time.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
We flip that. Tell us what you need (budget, commute, schools, safety) and we show
|
We flip that. Tell us what you need (budget, commute, schools, safety) and we show you
|
||||||
you every area in England that qualifies. No guesswork. No wasted viewings.
|
every area in England that qualifies. No guesswork. No wasted viewings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ const FAQ_SECTIONS: FAQSection[] = [
|
||||||
{
|
{
|
||||||
question: 'How can I check if an area is safe before I move there?',
|
question: 'How can I check if an area is safe before I move there?',
|
||||||
answer:
|
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:
|
question:
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ function EditableLabel({
|
||||||
if (e.key === 'Escape') setEditing(false);
|
if (e.key === 'Escape') setEditing(false);
|
||||||
}}
|
}}
|
||||||
onBlur={commit}
|
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}
|
style={style}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -81,7 +81,7 @@ function EditableLabel({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<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}
|
style={style}
|
||||||
onClick={startEdit}
|
onClick={startEdit}
|
||||||
>
|
>
|
||||||
|
|
@ -119,24 +119,32 @@ function SliderLabels({
|
||||||
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw);
|
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw);
|
||||||
const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], 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) {
|
if (feature && onValueChange) {
|
||||||
return (
|
return (
|
||||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||||
<EditableLabel
|
<EditableLabel
|
||||||
value={labels[0]}
|
value={labels[0]}
|
||||||
formatted={minLabel}
|
formatted={minLabel}
|
||||||
onCommit={(v) => onValueChange([v, labels[1]])}
|
onCommit={(v) => onValueChange([Math.min(v, labels[1]), labels[1]])}
|
||||||
prefix={feature.prefix}
|
prefix={feature.prefix}
|
||||||
suffix={feature.suffix}
|
suffix={feature.suffix}
|
||||||
style={{ left: `${leftPct}%` }}
|
style={{ left: `${leftPct}%`, transform: leftTranslate }}
|
||||||
/>
|
/>
|
||||||
<EditableLabel
|
<EditableLabel
|
||||||
value={labels[1]}
|
value={labels[1]}
|
||||||
formatted={maxLabel}
|
formatted={maxLabel}
|
||||||
onCommit={(v) => onValueChange([labels[0], v])}
|
onCommit={(v) => onValueChange([labels[0], Math.max(v, labels[0])])}
|
||||||
prefix={feature.prefix}
|
prefix={feature.prefix}
|
||||||
suffix={feature.suffix}
|
suffix={feature.suffix}
|
||||||
style={{ left: `${rightPct}%` }}
|
style={{ left: `${rightPct}%`, transform: rightTranslate }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -144,10 +152,10 @@ function SliderLabels({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
<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}
|
{minLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="absolute -translate-x-1/2" style={{ left: `${rightPct}%` }}>
|
<span className="absolute" style={{ left: `${rightPct}%`, transform: rightTranslate }}>
|
||||||
{maxLabel}
|
{maxLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -391,7 +399,9 @@ export default memo(function Filters({
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full touch-pan-y"
|
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="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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
|
<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">
|
<div className="px-2 py-1 space-y-1">
|
||||||
{travelTimeEntries.map((entry, index) => (
|
{travelTimeEntries.map((entry, index) => (
|
||||||
<div
|
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
|
||||||
key={`tt_${index}`}
|
|
||||||
data-filter-name={`tt_${index}`}
|
|
||||||
>
|
|
||||||
<TravelTimeCard
|
<TravelTimeCard
|
||||||
mode={entry.mode}
|
mode={entry.mode}
|
||||||
slug={entry.slug}
|
slug={entry.slug}
|
||||||
|
|
@ -464,9 +471,7 @@ export default memo(function Filters({
|
||||||
useBest={entry.useBest}
|
useBest={entry.useBest}
|
||||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||||
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
||||||
onSetDestination={(slug, label) =>
|
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
||||||
onTravelTimeSetDestination(index, slug, label)
|
|
||||||
}
|
|
||||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||||
|
|
@ -560,9 +565,7 @@ export default memo(function Filters({
|
||||||
<Slider
|
<Slider
|
||||||
min={scale ? 0 : feature.min!}
|
min={scale ? 0 : feature.min!}
|
||||||
max={scale ? 100 : feature.max!}
|
max={scale ? 100 : feature.max!}
|
||||||
step={
|
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
|
||||||
scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)
|
|
||||||
}
|
|
||||||
value={sliderValue}
|
value={sliderValue}
|
||||||
onValueChange={
|
onValueChange={
|
||||||
scale
|
scale
|
||||||
|
|
@ -570,9 +573,7 @@ export default memo(function Filters({
|
||||||
const step = feature.step ?? 1;
|
const step = feature.step ?? 1;
|
||||||
const snap = (v: number) => Math.round(v / step) * step;
|
const snap = (v: number) => Math.round(v / step) * step;
|
||||||
onDragChange([
|
onDragChange([
|
||||||
pMin <= 0
|
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
|
||||||
? (hist?.min ?? feature.min!)
|
|
||||||
: snap(scale.toValue(pMin)),
|
|
||||||
pMax >= 100
|
pMax >= 100
|
||||||
? (hist?.max ?? feature.max!)
|
? (hist?.max ?? feature.max!)
|
||||||
: snap(scale.toValue(pMax)),
|
: snap(scale.toValue(pMax)),
|
||||||
|
|
@ -606,13 +607,18 @@ export default memo(function Filters({
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
onClick={() => setAddFilterCollapsed((v) => !v)}
|
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"
|
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>
|
<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>
|
</button>
|
||||||
{!addFilterCollapsed && (
|
{!addFilterCollapsed && (
|
||||||
<div className="md:min-h-0 md:flex-1 flex flex-col">
|
<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" />
|
<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="text-warm-700 dark:text-warm-300">
|
||||||
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span>{' '}
|
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span>{' '}
|
||||||
indicates the global average
|
indicates the national average
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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
|
View on Google Maps
|
||||||
<svg className="w-3 h-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg
|
||||||
<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" />
|
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>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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"
|
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
|
View on Google Maps
|
||||||
<svg className="w-3 h-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg
|
||||||
<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" />
|
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>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ export function FeatureActions({
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
{feature.detail && onShowInfo && (
|
{feature.detail && onShowInfo && (
|
||||||
<IconButton onClick={() => onShowInfo(feature)} title="Feature info">
|
<IconButton onClick={() => onShowInfo(feature)} title="Feature info" size="md">
|
||||||
<InfoIcon />
|
<InfoIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export function FeatureLabel({
|
||||||
{featureIcon}
|
{featureIcon}
|
||||||
{GroupIcon && <GroupIcon className={iconClass} />}
|
{GroupIcon && <GroupIcon className={iconClass} />}
|
||||||
<span
|
<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}
|
{feature.name}
|
||||||
</span>
|
</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 type { FeatureMeta, FeatureFilters } from '../types';
|
||||||
import { trackEvent } from '../lib/analytics';
|
import { trackEvent } from '../lib/analytics';
|
||||||
|
|
||||||
|
|
@ -15,6 +15,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
const pendingDragRef = useRef<string | null>(null);
|
const pendingDragRef = useRef<string | null>(null);
|
||||||
const dragActiveRef = useRef<string | null>(null);
|
const dragActiveRef = useRef<string | null>(null);
|
||||||
const dragValueRef = useRef<[number, number] | null>(null);
|
const dragValueRef = useRef<[number, number] | null>(null);
|
||||||
|
const undoStackRef = useRef<FeatureFilters[]>([]);
|
||||||
|
|
||||||
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
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);
|
const meta = features.find((f) => f.name === name);
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
trackEvent('Filter Add', { feature: name });
|
trackEvent('Filter Add', { feature: name });
|
||||||
if (meta.type === 'enum' && meta.values) {
|
setFilters((prev) => {
|
||||||
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
|
undoStackRef.current.push(prev);
|
||||||
} else if (meta.type === 'numeric' && meta.histogram) {
|
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
|
||||||
setFilters((prev) => ({ ...prev, [name]: [meta.histogram!.min, meta.histogram!.max] }));
|
if (meta.type === 'enum' && meta.values) {
|
||||||
} else if (meta.min != null && meta.max != null) {
|
return { ...prev, [name]: [...meta.values!] };
|
||||||
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
|
} 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]
|
[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[]) => {
|
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
|
||||||
setFilters((prev) => ({ ...prev, [name]: value }));
|
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 {
|
return {
|
||||||
searches,
|
searches,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -176,5 +185,6 @@ export function useSavedSearches(userId: string | null) {
|
||||||
saveSearch,
|
saveSearch,
|
||||||
deleteSearch,
|
deleteSearch,
|
||||||
updateSearchNotes,
|
updateSearchNotes,
|
||||||
|
updateSearchName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,12 @@ export function useTravelDestinations(mode: TransportMode) {
|
||||||
return res.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
.then((data: { destinations: Destination[] }) => {
|
.then((data: { destinations: Destination[] }) => {
|
||||||
cacheRef.current[mode] = data.destinations;
|
const normalized = data.destinations.map((d) => ({
|
||||||
setDestinations(data.destinations);
|
...d,
|
||||||
|
city: d.city === 'City of London' ? 'London' : d.city,
|
||||||
|
}));
|
||||||
|
cacheRef.current[mode] = normalized;
|
||||||
|
setDestinations(normalized);
|
||||||
})
|
})
|
||||||
.catch((err) => logNonAbortError('travel destinations', err))
|
.catch((err) => logNonAbortError('travel destinations', err))
|
||||||
.finally(() => setLoading(false));
|
.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. */
|
/** Fire-and-forget request to pre-warm the screenshot cache for OG images. */
|
||||||
export function prewarmScreenshot(params: string): void {
|
export function prewarmScreenshot(params: string): void {
|
||||||
fetch(apiUrl('screenshot', new URLSearchParams(`og=1&${params}`)), authHeaders())
|
fetch(apiUrl('screenshot', new URLSearchParams(`og=1&${params}`)), authHeaders()).catch(() => {}); // best-effort, don't care if it fails
|
||||||
.catch(() => {}); // best-effort, don't care if it fails
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function shortenUrl(params: string): Promise<string> {
|
export async function shortenUrl(params: string): Promise<string> {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
/** Copy text to clipboard with execCommand fallback for older browsers. */
|
/** Copy text to clipboard with execCommand fallback for older browsers. */
|
||||||
export function copyToClipboard(text: string, onSuccess: () => void): void {
|
export function copyToClipboard(text: string, onSuccess: () => void): void {
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
navigator.clipboard.writeText(text).then(onSuccess).catch(() => {
|
navigator.clipboard
|
||||||
// Fallback if clipboard permission denied
|
.writeText(text)
|
||||||
const ta = document.createElement('textarea');
|
.then(onSuccess)
|
||||||
ta.value = text;
|
.catch(() => {
|
||||||
ta.style.position = 'fixed';
|
// Fallback if clipboard permission denied
|
||||||
ta.style.opacity = '0';
|
const ta = document.createElement('textarea');
|
||||||
document.body.appendChild(ta);
|
ta.value = text;
|
||||||
ta.select();
|
ta.style.position = 'fixed';
|
||||||
document.execCommand('copy');
|
ta.style.opacity = '0';
|
||||||
document.body.removeChild(ta);
|
document.body.appendChild(ta);
|
||||||
onSuccess();
|
ta.select();
|
||||||
});
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
onSuccess();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const ta = document.createElement('textarea');
|
const ta = document.createElement('textarea');
|
||||||
ta.value = text;
|
ta.value = text;
|
||||||
|
|
|
||||||
|
|
@ -51,24 +51,23 @@ const RIGHTMOVE_PRICES = [
|
||||||
|
|
||||||
// Rightmove allowed monthly rent values (pcm)
|
// Rightmove allowed monthly rent values (pcm)
|
||||||
const RIGHTMOVE_RENTS = [
|
const RIGHTMOVE_RENTS = [
|
||||||
250, 300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000,
|
250, 300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500,
|
||||||
3500, 4000, 5000, 7500, 10000, 15000, 25000,
|
4000, 5000, 7500, 10000, 15000, 25000,
|
||||||
];
|
];
|
||||||
|
|
||||||
// OnTheMarket allowed buy prices
|
// OnTheMarket allowed buy prices
|
||||||
const OTM_PRICES = [
|
const OTM_PRICES = [
|
||||||
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000,
|
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000, 160000,
|
||||||
160000, 170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 275000,
|
170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 275000, 300000,
|
||||||
300000, 325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000, 550000, 600000, 650000,
|
325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000, 550000, 600000, 650000, 700000,
|
||||||
700000, 750000, 800000, 900000, 1000000, 1250000, 1500000, 2000000, 2500000, 3000000, 5000000,
|
750000, 800000, 900000, 1000000, 1250000, 1500000, 2000000, 2500000, 3000000, 5000000, 7500000,
|
||||||
7500000, 10000000, 15000000,
|
10000000, 15000000,
|
||||||
];
|
];
|
||||||
|
|
||||||
// OnTheMarket allowed monthly rent values (pcm)
|
// OnTheMarket allowed monthly rent values (pcm)
|
||||||
const OTM_RENTS = [
|
const OTM_RENTS = [
|
||||||
100, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, 1000,
|
100, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, 1000, 1100,
|
||||||
1100, 1200, 1250, 1300, 1400, 1500, 1750, 2000, 2500, 3000, 3500, 4000, 5000, 7500, 10000,
|
1200, 1250, 1300, 1400, 1500, 1750, 2000, 2500, 3000, 3500, 4000, 5000, 7500, 10000, 25000,
|
||||||
25000,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Zoopla allowed buy prices
|
// Zoopla allowed buy prices
|
||||||
|
|
@ -81,8 +80,8 @@ const ZOOPLA_PRICES = [
|
||||||
|
|
||||||
// Zoopla allowed monthly rent values (pcm)
|
// Zoopla allowed monthly rent values (pcm)
|
||||||
const ZOOPLA_RENTS = [
|
const ZOOPLA_RENTS = [
|
||||||
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500,
|
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500, 4000,
|
||||||
4000, 5000, 7500, 10000, 25000,
|
5000, 7500, 10000, 25000,
|
||||||
];
|
];
|
||||||
|
|
||||||
function snapToAllowed(value: number, allowed: number[], direction: 'floor' | 'ceil'): number {
|
function snapToAllowed(value: number, allowed: number[], direction: 'floor' | 'ceil'): number {
|
||||||
|
|
@ -133,7 +132,9 @@ export function buildPropertySearchUrls({
|
||||||
// For rent mode, check asking rent first
|
// For rent mode, check asking rent first
|
||||||
const priceFilter = isRent
|
const priceFilter = isRent
|
||||||
? filters['Asking rent (monthly)']
|
? 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 =
|
const minPrice =
|
||||||
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
|
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
|
||||||
const maxPrice =
|
const maxPrice =
|
||||||
|
|
|
||||||
|
|
@ -264,8 +264,8 @@ def main():
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Summary stats
|
# Summary stats
|
||||||
prices = [l["price"] for l in listings if l["price"]]
|
prices = [item["price"] for item in listings if item["price"]]
|
||||||
beds = [l["beds"] for l in listings if l["beds"]]
|
beds = [item["beds"] for item in listings if item["beds"]]
|
||||||
if prices:
|
if prices:
|
||||||
print(f"Price range: £{min(prices):,} - £{max(prices):,}")
|
print(f"Price range: £{min(prices):,} - £{max(prices):,}")
|
||||||
print(f"Median: £{sorted(prices)[len(prices)//2]:,}")
|
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/features", get(routes::get_features))
|
||||||
.route("/api/hexagons", get(routes::get_hexagons))
|
.route("/api/hexagons", get(routes::get_hexagons))
|
||||||
.route("/api/postcodes", get(routes::get_postcodes))
|
.route("/api/postcodes", get(routes::get_postcodes))
|
||||||
.route(
|
.route("/api/postcode/{postcode}", get(routes::get_postcode_lookup))
|
||||||
"/api/postcode/{postcode}",
|
|
||||||
get(routes::get_postcode_lookup),
|
|
||||||
)
|
|
||||||
.route("/api/pois", get(routes::get_pois))
|
.route("/api/pois", get(routes::get_pois))
|
||||||
.route("/api/poi-categories", get(routes::get_poi_categories))
|
.route("/api/poi-categories", get(routes::get_poi_categories))
|
||||||
.route("/api/places", get(routes::get_places))
|
.route("/api/places", get(routes::get_places))
|
||||||
|
|
@ -478,10 +475,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
"/api/checkout",
|
"/api/checkout",
|
||||||
post(routes::post_checkout).layer(ConcurrencyLimitLayer::new(10)),
|
post(routes::post_checkout).layer(ConcurrencyLimitLayer::new(10)),
|
||||||
)
|
)
|
||||||
.route(
|
.route("/api/stripe-webhook", post(routes::post_stripe_webhook))
|
||||||
"/api/stripe-webhook",
|
|
||||||
post(routes::post_stripe_webhook),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/invites",
|
"/api/invites",
|
||||||
get(routes::get_invites).post(routes::post_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("/s/{code}", get(routes::get_short_url))
|
||||||
.route("/api/telemetry", post(routes::post_telemetry))
|
.route("/api/telemetry", post(routes::post_telemetry))
|
||||||
.route("/api/reload", post(routes::post_reload))
|
.route("/api/reload", post(routes::post_reload))
|
||||||
.route(
|
.route("/pb/{*rest}", any(routes::proxy_to_pocketbase))
|
||||||
"/pb/{*rest}",
|
|
||||||
any(routes::proxy_to_pocketbase),
|
|
||||||
)
|
|
||||||
// Tile routes use a different state type — kept as closures
|
// Tile routes use a different state type — kept as closures
|
||||||
.route(
|
.route(
|
||||||
"/api/tiles/{z}/{x}/{y}",
|
"/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 {
|
fn autodate(name: &str, on_create: bool, on_update: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
|
|
@ -717,6 +731,39 @@ pub async fn ensure_collections(
|
||||||
ensure_autodate_fields(client, base_url, &token, "short_urls").await?;
|
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(())
|
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(
|
async fn pb_count(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
pb_url: &str,
|
pb_url: &str,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use tracing::{info, warn};
|
||||||
use crate::auth::OptionalUser;
|
use crate::auth::OptionalUser;
|
||||||
use crate::consts::{AI_FILTERS_MAX_TOKENS, AI_FILTERS_TEMPERATURE, AI_FILTERS_WEEKLY_TOKEN_LIMIT};
|
use crate::consts::{AI_FILTERS_MAX_TOKENS, AI_FILTERS_TEMPERATURE, AI_FILTERS_WEEKLY_TOKEN_LIMIT};
|
||||||
use crate::data::slugify;
|
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::routes::{FeatureInfo, FeaturesResponse};
|
||||||
use crate::state::{AppState, SharedState};
|
use crate::state::{AppState, SharedState};
|
||||||
use crate::utils::gemini_chat;
|
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_tokens_total").increment(total_tokens_accumulated);
|
||||||
counter!("ai_requests_total", "status" => "success").increment(1);
|
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 {
|
return Ok(Json(AiFiltersResponse {
|
||||||
filters,
|
filters,
|
||||||
travel_time_filters,
|
travel_time_filters,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,10 @@ static PROXY_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||||
.expect("Failed to build proxy HTTP client")
|
.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 state = shared.load_state();
|
||||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,9 @@ pub struct POICategoriesResponse {
|
||||||
groups: Vec<POICategoryGroup>,
|
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 state = shared.load_state();
|
||||||
let groups: Vec<POICategoryGroup> = state.poi_category_groups.to_vec();
|
let groups: Vec<POICategoryGroup> = state.poi_category_groups.to_vec();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,10 @@ struct PbRecord {
|
||||||
params: String,
|
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 state = shared.load_state();
|
||||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
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();
|
let state = shared.load_state();
|
||||||
|
|
||||||
if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
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