lgtm
Some checks failed
CI / Check (push) Failing after 2m47s
Build and publish Docker image / build-and-push (push) Successful in 5m37s

This commit is contained in:
Andras Schmelczer 2026-06-26 16:48:20 +01:00
parent 5e73287eaf
commit e2b85fe819
73 changed files with 1180 additions and 2028 deletions

View file

@ -54,63 +54,72 @@ export function useAuth() {
return unsubscribe;
}, []);
const login = useCallback(async (email: string, password: string) => {
setLoading(true);
setError(null);
try {
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
trackEvent('Login', { method: 'email' });
} catch (err) {
const msg = err instanceof Error ? err.message : t('auth.loginFailed');
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, [t]);
const login = useCallback(
async (email: string, password: string) => {
setLoading(true);
setError(null);
try {
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
trackEvent('Login', { method: 'email' });
} catch (err) {
const msg = err instanceof Error ? err.message : t('auth.loginFailed');
setError(msg);
throw err;
} finally {
setLoading(false);
}
},
[t]
);
const register = useCallback(async (email: string, password: string) => {
setLoading(true);
setError(null);
try {
await pb.collection('users').create({
email,
password,
passwordConfirm: password,
newsletter: true,
});
// Auto-login after registration
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
trackEvent('Register');
} catch (err) {
const msg = err instanceof Error ? err.message : t('auth.registrationFailed');
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, [t]);
const register = useCallback(
async (email: string, password: string) => {
setLoading(true);
setError(null);
try {
await pb.collection('users').create({
email,
password,
passwordConfirm: password,
newsletter: true,
});
// Auto-login after registration
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
trackEvent('Register');
} catch (err) {
const msg = err instanceof Error ? err.message : t('auth.registrationFailed');
setError(msg);
throw err;
} finally {
setLoading(false);
}
},
[t]
);
const loginWithOAuth = useCallback(async (provider: string) => {
setLoading(true);
setError(null);
try {
const result = await pb.collection('users').authWithOAuth2({
provider,
createData: { newsletter: true },
});
setUser(recordToUser(result.record));
trackEvent('Login', { method: provider });
} catch (err) {
const msg = err instanceof Error ? err.message : t('auth.oauthFailed');
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, [t]);
const loginWithOAuth = useCallback(
async (provider: string) => {
setLoading(true);
setError(null);
try {
const result = await pb.collection('users').authWithOAuth2({
provider,
createData: { newsletter: true },
});
setUser(recordToUser(result.record));
trackEvent('Login', { method: provider });
} catch (err) {
const msg = err instanceof Error ? err.message : t('auth.oauthFailed');
setError(msg);
throw err;
} finally {
setLoading(false);
}
},
[t]
);
const logout = useCallback(() => {
trackEvent('Logout');
@ -118,19 +127,22 @@ export function useAuth() {
setUser(null);
}, []);
const requestPasswordReset = useCallback(async (email: string) => {
setLoading(true);
setError(null);
try {
await pb.collection('users').requestPasswordReset(email);
} catch (err) {
const msg = err instanceof Error ? err.message : t('auth.passwordResetFailed');
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, [t]);
const requestPasswordReset = useCallback(
async (email: string) => {
setLoading(true);
setError(null);
try {
await pb.collection('users').requestPasswordReset(email);
} catch (err) {
const msg = err instanceof Error ? err.message : t('auth.passwordResetFailed');
setError(msg);
throw err;
} finally {
setLoading(false);
}
},
[t]
);
const refreshAuth = useCallback(async () => {
setLoading(true);

View file

@ -132,6 +132,102 @@ describe('useFilters', () => {
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
});
it('clamps the initial filter set to the limit for non-paying users', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
// A bookmarked or shared URL may carry more filters than the cap allows; the
// initial set must start clamped so the first map request never exceeds the
// server-side cap (which would 400 and blank the map).
const { result } = renderHook(() =>
useFilters({
initialFilters: { a: [0, 1], b: [0, 1], c: [0, 1], d: [0, 1], e: [0, 1] },
features: sixFeatures,
filterLimit: 3,
})
);
expect(Object.keys(result.current.filters)).toHaveLength(3);
});
it('does not clamp the initial filter set when filterLimit is null (licensed users)', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
const { result } = renderHook(() =>
useFilters({
initialFilters: { a: [0, 1], b: [0, 1], c: [0, 1], d: [0, 1], e: [0, 1] },
features: sixFeatures,
filterLimit: null,
})
);
expect(Object.keys(result.current.filters)).toHaveLength(5);
});
it('counts reserved slots (e.g. travel-time filters) toward the cap', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
const onFilterLimitReached = vi.fn();
// Limit 3 with 1 slot already reserved (e.g. a travel-time filter) leaves room
// for only 2 feature filters.
const { result } = renderHook(() =>
useFilters({
initialFilters: {},
features: sixFeatures,
filterLimit: 3,
reservedFilterSlots: 1,
onFilterLimitReached,
})
);
act(() => {
['a', 'b'].forEach((name) => result.current.handleAddFilter(name));
});
expect(Object.keys(result.current.filters)).toHaveLength(2);
expect(onFilterLimitReached).not.toHaveBeenCalled();
act(() => {
result.current.handleAddFilter('c');
});
expect(Object.keys(result.current.filters)).toHaveLength(2);
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
});
it('clamps the initial filter set accounting for reserved slots', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
// 4 initial feature filters, limit 3, 1 reserved slot → keep only 2 features.
const { result } = renderHook(() =>
useFilters({
initialFilters: { a: [0, 1], b: [0, 1], c: [0, 1], d: [0, 1] },
features: sixFeatures,
filterLimit: 3,
reservedFilterSlots: 1,
})
);
expect(Object.keys(result.current.filters)).toHaveLength(2);
});
it('does not cap filters when filterLimit is null (licensed users)', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name,

View file

@ -74,7 +74,14 @@ interface UseFiltersOptions {
features: FeatureMeta[];
/** Max simultaneous filters for the current user; `null`/`undefined` = unlimited. */
filterLimit?: number | null;
/** Called when an add is blocked because `filterLimit` is already reached. */
/** Filter slots already consumed outside this hook (e.g. travel-time entries),
* which share the same cap. Subtracted from `filterLimit` when gating adds. */
reservedFilterSlots?: number;
/** When true, block adding any *new* filter outright (existing filters can still
* be tweaked/replaced). Used so a non-paying user on a shared link can view and
* adjust the shared set but not build on top of it. Triggers `onFilterLimitReached`. */
lockAdds?: boolean;
/** Called when an add is blocked because `filterLimit` is reached or `lockAdds` is set. */
onFilterLimitReached?: () => void;
}
@ -146,11 +153,22 @@ export function useFilters({
initialFilters,
features,
filterLimit,
reservedFilterSlots = 0,
lockAdds = false,
onFilterLimitReached,
}: UseFiltersOptions) {
const initialFiltersRef = useRef<FeatureFilters | null>(null);
if (!initialFiltersRef.current) {
initialFiltersRef.current = normalizeFilters(initialFilters);
// Clamp an incoming filter set (e.g. from a bookmarked or shared URL) to the
// user's remaining budget — the cap minus slots already taken by travel-time
// filters — so a non-paying user never starts above the cap, which the server
// would reject (400), blanking the map on first load.
const normalized = normalizeFilters(initialFilters);
const budget = filterLimit != null ? Math.max(0, filterLimit - reservedFilterSlots) : null;
initialFiltersRef.current =
budget != null && Object.keys(normalized).length > budget
? Object.fromEntries(Object.entries(normalized).slice(0, budget))
: normalized;
}
const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!);
@ -159,6 +177,10 @@ export function useFilters({
filtersRef.current = filters;
const filterLimitRef = useRef(filterLimit);
filterLimitRef.current = filterLimit;
const reservedSlotsRef = useRef(reservedFilterSlots);
reservedSlotsRef.current = reservedFilterSlots;
const lockAddsRef = useRef(lockAdds);
lockAddsRef.current = lockAdds;
const onFilterLimitReachedRef = useRef(onFilterLimitReached);
onFilterLimitReachedRef.current = onFilterLimitReached;
const [activeFeature, setActiveFeature] = useState<string | null>(null);
@ -238,11 +260,14 @@ export function useFilters({
return;
}
// Demo (unlicensed) users are capped at `filterLimit` simultaneous filters.
// Non-paying users are capped at `filterLimit` simultaneous filters, with
// travel-time entries (reservedFilterSlots) counting toward the same cap.
// Re-applying an existing regular feature replaces it (no new key), so it's
// always allowed; the special/generated filters always add a new key.
// `lockAdds` (a non-paying user on a shared link) blocks every new key
// regardless of count — they can adjust the shared set but not build on it.
const limit = filterLimitRef.current;
if (limit != null) {
if (limit != null || lockAddsRef.current) {
const current = filtersRef.current;
const addsNewKey =
name === SCHOOL_FILTER_NAME ||
@ -254,7 +279,9 @@ export function useFilters({
name === TENURE_FILTER_NAME ||
POI_FILTER_NAMES.includes(name as PoiFilterName) ||
!(name in current);
if (addsNewKey && Object.keys(current).length >= limit) {
const overLimit =
limit != null && Object.keys(current).length + reservedSlotsRef.current >= limit;
if (addsNewKey && (lockAddsRef.current || overLimit)) {
onFilterLimitReachedRef.current?.();
return;
}

View file

@ -8,32 +8,35 @@ export function useLicense() {
const [checkingOut, setCheckingOut] = useState(false);
const [error, setError] = useState<string | null>(null);
const startCheckout = useCallback(async (returnPath?: string) => {
trackEvent('Checkout Start', { has_referral: 'false' });
setCheckingOut(true);
setError(null);
try {
const res = await fetch(apiUrl('checkout'), {
method: 'POST',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(returnPath ? { return_path: returnPath } : {}),
}),
});
assertOk(res, 'Checkout');
const data = await res.json();
if (data.url) {
trackEvent('Checkout Redirect');
window.location.href = data.url;
const startCheckout = useCallback(
async (returnPath?: string) => {
trackEvent('Checkout Start', { has_referral: 'false' });
setCheckingOut(true);
setError(null);
try {
const res = await fetch(apiUrl('checkout'), {
method: 'POST',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(returnPath ? { return_path: returnPath } : {}),
}),
});
assertOk(res, 'Checkout');
const data = await res.json();
if (data.url) {
trackEvent('Checkout Redirect');
window.location.href = data.url;
}
} catch (err) {
const msg = err instanceof Error ? err.message : t('upgrade.checkoutFailed');
setError(msg);
throw err;
} finally {
setCheckingOut(false);
}
} catch (err) {
const msg = err instanceof Error ? err.message : t('upgrade.checkoutFailed');
setError(msg);
throw err;
} finally {
setCheckingOut(false);
}
}, [t]);
},
[t]
);
return { startCheckout, checkingOut, error };
}

View file

@ -50,8 +50,8 @@ interface UseMapDataOptions {
pinnedFeature: string | null;
filterRange?: [number, number] | null;
travelTimeEntries: TravelTimeEntry[];
/** Share-link code from the URL; appended to data fetches so the backend
* grants bbox-scoped access for unlicensed recipients. */
/** Legacy share-link code from the URL; still echoed on data fetches but no
* longer grants any special access (the region lock was removed). */
shareCode?: string;
}
@ -100,9 +100,6 @@ export function useMapData({
longitude: number;
zoom: number;
} | null>(null);
const [licenseRequired, setLicenseRequired] = useState(false);
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
// Drag preview state
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
const [dragPostcodeData, setDragPostcodeData] = useState<PostcodeFeature[] | null>(null);
@ -392,19 +389,8 @@ export function useMapData({
signal: abortControllerRef.current.signal,
})
);
if (res.status === 403) {
const errBody = await res.json();
if (requestKey !== latestDataRequestKeyRef.current) return;
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
setLoading(false);
return;
}
}
assertOk(res, 'postcodes');
if (requestKey !== latestDataRequestKeyRef.current) return;
setLicenseRequired(false);
const json: { features: PostcodeFeature[] } = await res.json();
if (requestKey !== latestDataRequestKeyRef.current) return;
setPostcodeData(json.features);
@ -431,19 +417,8 @@ export function useMapData({
signal: abortControllerRef.current.signal,
})
);
if (res.status === 403) {
const errBody = await res.json();
if (requestKey !== latestDataRequestKeyRef.current) return;
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
setLoading(false);
return;
}
}
assertOk(res, 'hexagons');
if (requestKey !== latestDataRequestKeyRef.current) return;
setLicenseRequired(false);
const json: ApiResponse = await res.json();
if (requestKey !== latestDataRequestKeyRef.current) return;
setRawData(json.features);
@ -733,8 +708,7 @@ export function useMapData({
// Treat the map as loading whenever the rendered hexagons don't match the
// current request — covers the brief window between a slider release and
// the main fetch effect actually firing setLoading(true).
const isLoading =
loading || (bounds != null && !licenseRequired && loadedDataKey !== dataRequestKey);
const isLoading = loading || (bounds != null && loadedDataKey !== dataRequestKey);
return {
data,
@ -753,7 +727,5 @@ export function useMapData({
handleResetPreviewScale,
handleViewChange,
setInitialView,
licenseRequired,
freeZone,
};
}

View file

@ -105,23 +105,26 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
handlePoiHoverRef.current(info);
}, []);
const handleClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({
x: info.x,
y: info.y,
name: `${info.object.count} ${t('common.places')}`,
category: t('map.poi.zoomInToSeeDetails'),
group: '',
emoji: '',
id: '',
isCluster: true,
clusterCount: info.object.count,
});
} else {
setPopupInfo(null);
}
}, [t]);
const handleClusterHover = useCallback(
(info: PickingInfo<ClusterPoint>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({
x: info.x,
y: info.y,
name: `${info.object.count} ${t('common.places')}`,
category: t('map.poi.zoomInToSeeDetails'),
group: '',
emoji: '',
id: '',
isCluster: true,
clusterCount: info.object.count,
});
} else {
setPopupInfo(null);
}
},
[t]
);
const handleClusterHoverRef = useRef(handleClusterHover);
handleClusterHoverRef.current = handleClusterHover;

View file

@ -181,34 +181,43 @@ export function useSavedSearches(userId: string | null) {
[userId, fetchSearches, t]
);
const deleteSearch = useCallback(async (id: string) => {
setError(null);
try {
await pb.collection('saved_searches').delete(id);
trackEvent('Search Delete');
setSearches((prev) => prev.filter((s) => s.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : t('savedPage.deleteFailed'));
}
}, [t]);
const deleteSearch = useCallback(
async (id: string) => {
setError(null);
try {
await pb.collection('saved_searches').delete(id);
trackEvent('Search Delete');
setSearches((prev) => prev.filter((s) => s.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : t('savedPage.deleteFailed'));
}
},
[t]
);
const updateSearchNotes = useCallback(async (id: string, notes: string) => {
try {
await pb.collection('saved_searches').update(id, { notes });
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, notes } : s)));
} catch (err) {
setError(err instanceof Error ? err.message : t('savedPage.updateNotesFailed'));
}
}, [t]);
const updateSearchNotes = useCallback(
async (id: string, notes: string) => {
try {
await pb.collection('saved_searches').update(id, { notes });
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, notes } : s)));
} catch (err) {
setError(err instanceof Error ? err.message : t('savedPage.updateNotesFailed'));
}
},
[t]
);
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 : t('savedPage.updateNameFailed'));
}
}, [t]);
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 : t('savedPage.updateNameFailed'));
}
},
[t]
);
const updateSearchParams = useCallback(
async (id: string, params: string) => {