lgtm
This commit is contained in:
parent
5e73287eaf
commit
e2b85fe819
73 changed files with 1180 additions and 2028 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue