All changes
This commit is contained in:
parent
593f380581
commit
49f7ec2f5a
60 changed files with 1783 additions and 679 deletions
28
frontend/src/hooks/useDropdownPosition.ts
Normal file
28
frontend/src/hooks/useDropdownPosition.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useCallback, useLayoutEffect, useState } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
export function useDropdownPosition(
|
||||
anchorRef: React.RefObject<HTMLElement | null>,
|
||||
open: boolean,
|
||||
) {
|
||||
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (!anchorRef.current) return;
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
|
||||
}, [anchorRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
update();
|
||||
window.addEventListener('scroll', update, true);
|
||||
window.addEventListener('resize', update);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', update, true);
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, [open, update]);
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
|
@ -340,7 +340,6 @@ export function useMapData({
|
|||
|
||||
return {
|
||||
data,
|
||||
rawData,
|
||||
postcodeData: effectivePostcodeData,
|
||||
resolution,
|
||||
bounds,
|
||||
|
|
|
|||
145
frontend/src/hooks/useSavedProperties.ts
Normal file
145
frontend/src/hooks/useSavedProperties.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import pb from '../lib/pocketbase';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
import type { Property } from '../types';
|
||||
import { getNum } from '../lib/property-fields';
|
||||
|
||||
export interface SavedPropertyData {
|
||||
propertyType?: string;
|
||||
propertySubType?: string;
|
||||
builtForm?: string;
|
||||
duration?: string;
|
||||
energyRating?: string;
|
||||
price?: number;
|
||||
estimatedPrice?: number;
|
||||
askingPrice?: number;
|
||||
askingRent?: number;
|
||||
bedrooms?: number;
|
||||
floorArea?: number;
|
||||
listingUrl?: string;
|
||||
}
|
||||
|
||||
export interface SavedProperty {
|
||||
id: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
data: SavedPropertyData;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export function useSavedProperties(userId: string | null) {
|
||||
const [properties, setProperties] = useState<SavedProperty[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchProperties = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const records = await pb.collection('saved_properties').getFullList({
|
||||
sort: '-created',
|
||||
filter: `user = "${userId}"`,
|
||||
});
|
||||
setProperties(
|
||||
records.map((r) => {
|
||||
const raw = r as Record<string, unknown>;
|
||||
let data: SavedPropertyData = {};
|
||||
try {
|
||||
data = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data as SavedPropertyData) || {};
|
||||
} catch {
|
||||
// Invalid JSON — use empty data
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
address: raw.address as string,
|
||||
postcode: raw.postcode as string,
|
||||
data,
|
||||
created: r.created,
|
||||
};
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load saved properties');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const saveProperty = useCallback(
|
||||
async (property: Property) => {
|
||||
if (!userId) return;
|
||||
setError(null);
|
||||
try {
|
||||
const data: SavedPropertyData = {
|
||||
propertyType: property.property_type,
|
||||
propertySubType: property.property_sub_type,
|
||||
builtForm: property.built_form,
|
||||
duration: property.duration,
|
||||
energyRating: property.current_energy_rating,
|
||||
price: getNum(property, 'Last known price'),
|
||||
estimatedPrice: getNum(property, 'Estimated current price'),
|
||||
askingPrice: getNum(property, 'Asking price'),
|
||||
askingRent: getNum(property, 'Asking rent (monthly)'),
|
||||
bedrooms: getNum(property, 'Bedrooms'),
|
||||
floorArea: getNum(property, 'Total floor area (sqm)'),
|
||||
listingUrl: property.listing_url || undefined,
|
||||
};
|
||||
|
||||
await pb.collection('saved_properties').create({
|
||||
user: userId,
|
||||
address: property.address || 'Unknown',
|
||||
postcode: property.postcode || '',
|
||||
data: JSON.stringify(data),
|
||||
});
|
||||
trackEvent('Property Save');
|
||||
await fetchProperties();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to save property';
|
||||
setError(msg);
|
||||
}
|
||||
},
|
||||
[userId, fetchProperties]
|
||||
);
|
||||
|
||||
const deleteProperty = useCallback(async (id: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('saved_properties').delete(id);
|
||||
trackEvent('Property Delete');
|
||||
setProperties((prev) => prev.filter((p) => p.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete property');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const savedPropertyKeys = useMemo(
|
||||
() => new Set(properties.map((p) => `${p.address}|${p.postcode}`)),
|
||||
[properties]
|
||||
);
|
||||
|
||||
const isPropertySaved = useCallback(
|
||||
(address?: string, postcode?: string) =>
|
||||
savedPropertyKeys.has(`${address || ''}|${postcode || ''}`),
|
||||
[savedPropertyKeys]
|
||||
);
|
||||
|
||||
const getSavedPropertyId = useCallback(
|
||||
(address?: string, postcode?: string) => {
|
||||
const key = `${address || ''}|${postcode || ''}`;
|
||||
return properties.find((p) => `${p.address}|${p.postcode}` === key)?.id;
|
||||
},
|
||||
[properties]
|
||||
);
|
||||
|
||||
return {
|
||||
properties,
|
||||
loading,
|
||||
error,
|
||||
fetchProperties,
|
||||
saveProperty,
|
||||
deleteProperty,
|
||||
isPropertySaved,
|
||||
getSavedPropertyId,
|
||||
};
|
||||
}
|
||||
|
|
@ -52,23 +52,32 @@ export function useSavedSearches(userId: string | null) {
|
|||
try {
|
||||
const params = window.location.search.replace(/^\?/, '');
|
||||
|
||||
// Capture a screenshot via the screenshot endpoint
|
||||
const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params));
|
||||
const screenshotRes = await fetch(screenshotUrl, authHeaders());
|
||||
if (!screenshotRes.ok) {
|
||||
throw new Error(`Screenshot failed: ${screenshotRes.status} ${screenshotRes.statusText}`);
|
||||
}
|
||||
const screenshotBlob = await screenshotRes.blob();
|
||||
|
||||
// Create record immediately without screenshot
|
||||
const formData = new FormData();
|
||||
formData.append('user', userId);
|
||||
formData.append('name', name);
|
||||
formData.append('params', params);
|
||||
formData.append('screenshot', screenshotBlob, 'screenshot.png');
|
||||
|
||||
await pb.collection('saved_searches').create(formData);
|
||||
const record = await pb.collection('saved_searches').create(formData);
|
||||
trackEvent('Search Save');
|
||||
await fetchSearches();
|
||||
|
||||
// Capture screenshot in background and attach it to the record
|
||||
const screenshotParams = new URLSearchParams(params);
|
||||
const screenshotUrl = apiUrl('screenshot', screenshotParams);
|
||||
fetch(screenshotUrl, authHeaders())
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`Screenshot ${res.status}`);
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const patch = new FormData();
|
||||
patch.append('screenshot', blob, 'screenshot.png');
|
||||
return pb.collection('saved_searches').update(record.id, patch);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Background screenshot failed:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to save search';
|
||||
setError(msg);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ export const MODE_LABELS: Record<TransportMode, string> = {
|
|||
transit: 'Transit',
|
||||
};
|
||||
|
||||
export const MODE_DESCRIPTIONS: Record<TransportMode, string> = {
|
||||
car: 'Drive time via the fastest road route',
|
||||
bicycle: 'Cycling time using bike-friendly routes',
|
||||
walking: 'Walking time along pedestrian paths and pavements',
|
||||
transit: 'Journey time by train, tube, and bus',
|
||||
};
|
||||
|
||||
export interface TravelTimeEntry {
|
||||
mode: TransportMode;
|
||||
slug: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue