diff --git a/client/src/components/Collections/MSaveToCollectionSheet.tsx b/client/src/components/Collections/MSaveToCollectionSheet.tsx index 520ddacb3..487328654 100644 --- a/client/src/components/Collections/MSaveToCollectionSheet.tsx +++ b/client/src/components/Collections/MSaveToCollectionSheet.tsx @@ -1,15 +1,9 @@ -import { useEffect, useMemo, useState, useCallback } from 'react' -import { useNavigate } from 'react-router' import { Bookmark, BookmarkCheck, Check, CheckCircle2, Loader2, Plus, X } from 'lucide-react' import MSheet from '../../mobile/components/MSheet' import MIconBtn from '../../mobile/components/MIconBtn' -import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' -import { collectionsApi } from '../../api/collections' -import { useSaveToCollectionStore } from '../../store/saveToCollectionStore' -import { getApiErrorMessage } from '../../utils/apiError' import { STATUS_META, nextStatus } from '../../pages/collections/collectionsModel' -import type { Collection, CollectionMembership, CollectionStatus } from '@trek/shared' +import { VISITED_EVERYWHERE_BUSY, useSaveToCollection } from './useSaveToCollection' /** * Mobile counterpart of SaveToCollectionModal — the same store-driven list @@ -18,135 +12,8 @@ import type { Collection, CollectionMembership, CollectionStatus } from '@trek/s * detail sheet. Rendered instead of the desktop modal on phones (see App.tsx). */ export default function MSaveToCollectionSheet() { - const target = useSaveToCollectionStore(s => s.target) - const close = useSaveToCollectionStore(s => s.close) - const bumpVersion = useSaveToCollectionStore(s => s.bumpVersion) + const { target, close, lists, loading, busyId, savedByCollection, unvisited, handleStatus, handleVisitedEverywhere, handleToggle, openCollections } = useSaveToCollection() const { t } = useTranslation() - const toast = useToast() - const navigate = useNavigate() - - const [lists, setLists] = useState([]) - const [membership, setMembership] = useState(null) - const [loading, setLoading] = useState(false) - const [busyId, setBusyId] = useState(null) - - const membershipQuery = useMemo(() => { - if (!target) return null - return { - google_place_id: target.google_place_id ?? undefined, - google_ftid: target.google_ftid ?? undefined, - name: target.name, - lat: target.lat ?? undefined, - lng: target.lng ?? undefined, - } - }, [target]) - - const refreshMembership = useCallback(async () => { - if (!membershipQuery) return - try { - setMembership(await collectionsApi.membership(membershipQuery)) - } catch { - setMembership({ saved: false, lists: [] }) - } - }, [membershipQuery]) - - // Load lists + membership whenever the picker opens for a new target. - useEffect(() => { - if (!target) return - let cancelled = false - setLoading(true) - setMembership(null) - void Promise.all([ - collectionsApi.list().catch(() => ({ collections: [], incomingInvites: [] })), - membershipQuery - ? collectionsApi.membership(membershipQuery).catch(() => ({ saved: false, lists: [] as CollectionMembership['lists'] })) - : Promise.resolve({ saved: false, lists: [] as CollectionMembership['lists'] }), - ]) - .then(([listRes, m]) => { - if (cancelled) return - setLists(listRes.collections) - setMembership(m) - }) - .finally(() => { if (!cancelled) setLoading(false) }) - return () => { cancelled = true } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [target]) - - const savedByCollection = new Map() - for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l) - - /** Lists holding this place that the viewer may edit and that are not visited yet. */ - const unvisited = (membership?.lists ?? []).filter(l => l.can_edit && l.status !== 'visited') - - const handleStatus = async (entry: CollectionMembership['lists'][number], next: CollectionStatus) => { - if (busyId != null) return - setBusyId(entry.collection_id) - try { - await collectionsApi.setStatus(entry.place_id, next) - await refreshMembership() - bumpVersion() - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setBusyId(null) - } - } - - const handleVisitedEverywhere = async () => { - if (busyId != null || unvisited.length === 0) return - setBusyId(-1) - try { - const { updated } = await collectionsApi.setStatusMany(unvisited.map(l => l.place_id), 'visited') - toast.success(t('collections.markedVisited', { count: updated })) - await refreshMembership() - bumpVersion() - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setBusyId(null) - } - } - - const handleToggle = async (list: Collection) => { - if (busyId != null || !target) return - const savedPlaceId = savedByCollection.get(list.id)?.place_id - setBusyId(list.id) - try { - if (savedPlaceId != null) { - await collectionsApi.deletePlace(savedPlaceId) - toast.success(t('collections.removedFromList', { name: list.name })) - } else { - await collectionsApi.savePlace({ - collection_id: list.id, - source_trip_id: target.source_trip_id ?? null, - source_place_id: target.source_place_id ?? null, - name: target.name, - description: target.description ?? null, - lat: target.lat ?? null, - lng: target.lng ?? null, - address: target.address ?? null, - category_id: target.category_id ?? null, - price: target.price ?? null, - currency: target.currency ?? null, - notes: target.notes ?? null, - image_url: target.image_url ?? null, - google_place_id: target.google_place_id ?? null, - google_ftid: target.google_ftid ?? null, - osm_id: target.osm_id ?? null, - website: target.website ?? null, - phone: target.phone ?? null, - force: true, - }) - toast.success(t('collections.addedToList', { name: list.name })) - } - await refreshMembership() - bumpVersion() - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setBusyId(null) - } - } return ( @@ -181,7 +48,7 @@ export default function MSaveToCollectionSheet() {

{t('collections.noListsYet')}

) : undefined} diff --git a/client/src/components/Collections/useSaveToCollection.test.ts b/client/src/components/Collections/useSaveToCollection.test.ts new file mode 100644 index 000000000..097353f98 --- /dev/null +++ b/client/src/components/Collections/useSaveToCollection.test.ts @@ -0,0 +1,289 @@ +// FE-COMP-SAVETOCOLHOOK-001 to FE-COMP-SAVETOCOLHOOK-014: the store-driven save-to-list +// picker logic behind both the desktop dialog and the phone sheet. +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { Collection, CollectionListResponse, CollectionMembership } from '@trek/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { collectionsApi } from '../../api/collections'; +import { type SaveToCollectionTarget, useSaveToCollectionStore } from '../../store/saveToCollectionStore'; +import { VISITED_EVERYWHERE_BUSY, useSaveToCollection } from './useSaveToCollection'; + +const mockNavigate = vi.fn(); +vi.mock('react-router', () => ({ useNavigate: () => mockNavigate })); + +vi.mock('../../i18n', () => ({ + useTranslation: () => ({ + t: (k: string, p?: Record) => (p ? `${k}:${JSON.stringify(p)}` : k), + }), +})); + +function list(over: Partial): Collection { + return { id: 1, owner_id: 7, name: 'List', color: null, place_count: 0, ...over } as Collection; +} + +const FAVORITES = list({ id: 1, name: 'Favorites' }); +const WISHLIST = list({ id: 2, name: 'Wishlist' }); + +const listResponse = (collections: Collection[]): CollectionListResponse => ({ collections, incomingInvites: [] }); + +const TARGET: SaveToCollectionTarget = { + name: 'Colosseum', + source_trip_id: 5, + source_place_id: 42, + lat: 41.89, + lng: 12.49, + google_place_id: 'gp-1', + google_ftid: 'ft-1', +}; + +const SAVED_IN_FAVORITES: CollectionMembership = { + saved: true, + lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }], +}; + +let addToast: ReturnType; + +function openFor(target: SaveToCollectionTarget = TARGET) { + act(() => useSaveToCollectionStore.setState({ target, version: 0 })); +} + +async function loaded() { + const hook = renderHook(() => useSaveToCollection()); + openFor(); + await waitFor(() => expect(hook.result.current.lists).toHaveLength(2)); + return hook; +} + +beforeEach(() => { + addToast = vi.fn(); + window.__addToast = addToast as unknown as typeof window.__addToast; + mockNavigate.mockClear(); + useSaveToCollectionStore.setState({ target: null, version: 0 }); + vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST])); + vi.spyOn(collectionsApi, 'membership').mockResolvedValue({ saved: false, lists: [] }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + useSaveToCollectionStore.setState({ target: null, version: 0 }); + delete window.__addToast; +}); + +describe('useSaveToCollection', () => { + it('FE-COMP-SAVETOCOLHOOK-001: requests nothing while no target is set', () => { + const { result } = renderHook(() => useSaveToCollection()); + expect(result.current.target).toBeNull(); + expect(result.current.loading).toBe(false); + expect(collectionsApi.list).not.toHaveBeenCalled(); + expect(collectionsApi.membership).not.toHaveBeenCalled(); + }); + + it('FE-COMP-SAVETOCOLHOOK-002: a new target loads the lists and the membership by maps identity', async () => { + vi.spyOn(collectionsApi, 'membership').mockResolvedValue(SAVED_IN_FAVORITES); + const { result } = renderHook(() => useSaveToCollection()); + openFor(); + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.lists.map((l) => l.id)).toEqual([1, 2]); + expect(collectionsApi.membership).toHaveBeenCalledWith({ + google_place_id: 'gp-1', + google_ftid: 'ft-1', + name: 'Colosseum', + lat: 41.89, + lng: 12.49, + }); + expect(result.current.savedByCollection.get(1)?.place_id).toBe(900); + expect(result.current.savedByCollection.has(2)).toBe(false); + expect(result.current.unvisited.map((l) => l.place_id)).toEqual([900]); + }); + + it('FE-COMP-SAVETOCOLHOOK-003: failing requests fall back to no lists and saved nowhere', async () => { + vi.spyOn(collectionsApi, 'list').mockRejectedValue(new Error('offline')); + vi.spyOn(collectionsApi, 'membership').mockRejectedValue(new Error('offline')); + const { result } = renderHook(() => useSaveToCollection()); + openFor(); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.lists).toEqual([]); + expect(result.current.savedByCollection.size).toBe(0); + expect(result.current.unvisited).toEqual([]); + }); + + it('FE-COMP-SAVETOCOLHOOK-004: unvisited leaves out visited and read-only lists', async () => { + vi.spyOn(collectionsApi, 'membership').mockResolvedValue({ + saved: true, + lists: [ + { collection_id: 1, name: 'Favorites', place_id: 900, status: 'visited', can_edit: true }, + { collection_id: 2, name: 'Wishlist', place_id: 901, status: 'idea', can_edit: false }, + { collection_id: 3, name: 'Team', place_id: 902, status: 'want', can_edit: true }, + ], + }); + const { result } = await loaded(); + await waitFor(() => expect(result.current.savedByCollection.size).toBe(3)); + expect(result.current.unvisited.map((l) => l.place_id)).toEqual([902]); + }); + + it('FE-COMP-SAVETOCOLHOOK-005: toggling an unsaved list saves the whole target with nulls, then refreshes and bumps', async () => { + const save = vi.spyOn(collectionsApi, 'savePlace').mockResolvedValue({}); + const { result } = await loaded(); + const membershipCalls = vi.mocked(collectionsApi.membership).mock.calls.length; + + await act(() => result.current.handleToggle(WISHLIST)); + + expect(save).toHaveBeenCalledWith({ + collection_id: 2, + source_trip_id: 5, + source_place_id: 42, + name: 'Colosseum', + description: null, + lat: 41.89, + lng: 12.49, + address: null, + category_id: null, + price: null, + currency: null, + notes: null, + image_url: null, + google_place_id: 'gp-1', + google_ftid: 'ft-1', + osm_id: null, + website: null, + phone: null, + force: true, + }); + expect(addToast).toHaveBeenCalledWith('collections.addedToList:{"name":"Wishlist"}', 'success', undefined); + expect(vi.mocked(collectionsApi.membership).mock.calls.length).toBe(membershipCalls + 1); + expect(useSaveToCollectionStore.getState().version).toBe(1); + expect(result.current.busyId).toBeNull(); + }); + + it('FE-COMP-SAVETOCOLHOOK-006: toggling a saved list deletes that place instead', async () => { + vi.spyOn(collectionsApi, 'membership').mockResolvedValue(SAVED_IN_FAVORITES); + const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({}); + const save = vi.spyOn(collectionsApi, 'savePlace'); + const { result } = await loaded(); + await waitFor(() => expect(result.current.savedByCollection.has(1)).toBe(true)); + + await act(() => result.current.handleToggle(FAVORITES)); + + expect(del).toHaveBeenCalledWith(900); + expect(save).not.toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith('collections.removedFromList:{"name":"Favorites"}', 'success', undefined); + }); + + it('FE-COMP-SAVETOCOLHOOK-007: a failing toggle toasts the error and leaves the version alone', async () => { + vi.spyOn(collectionsApi, 'savePlace').mockRejectedValue(new Error('boom')); + const { result } = await loaded(); + + await act(() => result.current.handleToggle(WISHLIST)); + + expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined); + expect(useSaveToCollectionStore.getState().version).toBe(0); + expect(result.current.busyId).toBeNull(); + }); + + it('FE-COMP-SAVETOCOLHOOK-008: while one change runs, every other change is dropped', async () => { + let resolve!: (v: unknown) => void; + const save = vi.spyOn(collectionsApi, 'savePlace').mockReturnValue(new Promise((r) => (resolve = r)) as never); + const status = vi.spyOn(collectionsApi, 'setStatus'); + const { result } = await loaded(); + + let first!: Promise; + act(() => { + first = result.current.handleToggle(WISHLIST); + }); + expect(result.current.busyId).toBe(2); + await act(() => result.current.handleToggle(FAVORITES)); + await act(() => result.current.handleStatus(SAVED_IN_FAVORITES.lists[0], 'visited')); + expect(save).toHaveBeenCalledTimes(1); + expect(status).not.toHaveBeenCalled(); + + resolve({}); + await act(() => first); + expect(result.current.busyId).toBeNull(); + }); + + it('FE-COMP-SAVETOCOLHOOK-009: a status change sets it per list, then refreshes and bumps', async () => { + const status = vi.spyOn(collectionsApi, 'setStatus').mockResolvedValue({} as never); + const { result } = await loaded(); + + await act(() => result.current.handleStatus(SAVED_IN_FAVORITES.lists[0], 'visited')); + + expect(status).toHaveBeenCalledWith(900, 'visited'); + expect(useSaveToCollectionStore.getState().version).toBe(1); + }); + + it('FE-COMP-SAVETOCOLHOOK-010: a failing status change toasts the error', async () => { + vi.spyOn(collectionsApi, 'setStatus').mockRejectedValue(new Error('boom')); + const { result } = await loaded(); + + await act(() => result.current.handleStatus(SAVED_IN_FAVORITES.lists[0], 'visited')); + + expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined); + expect(useSaveToCollectionStore.getState().version).toBe(0); + }); + + it('FE-COMP-SAVETOCOLHOOK-011: visited everywhere marks every unvisited list in one call', async () => { + vi.spyOn(collectionsApi, 'membership').mockResolvedValue({ + saved: true, + lists: [ + { collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }, + { collection_id: 2, name: 'Wishlist', place_id: 901, status: 'idea', can_edit: true }, + ], + }); + let resolve!: (v: { updated: number }) => void; + const many = vi.spyOn(collectionsApi, 'setStatusMany').mockReturnValue(new Promise((r) => (resolve = r)) as never); + const { result } = await loaded(); + await waitFor(() => expect(result.current.unvisited).toHaveLength(2)); + + let run!: Promise; + act(() => { + run = result.current.handleVisitedEverywhere(); + }); + expect(result.current.busyId).toBe(VISITED_EVERYWHERE_BUSY); + resolve({ updated: 2 }); + await act(() => run); + + expect(many).toHaveBeenCalledWith([900, 901], 'visited'); + expect(addToast).toHaveBeenCalledWith('collections.markedVisited:{"count":2}', 'success', undefined); + expect(useSaveToCollectionStore.getState().version).toBe(1); + }); + + it('FE-COMP-SAVETOCOLHOOK-012: visited everywhere does nothing without an unvisited list, and toasts a failure', async () => { + const many = vi.spyOn(collectionsApi, 'setStatusMany').mockRejectedValue(new Error('boom')); + const { result } = await loaded(); + + await act(() => result.current.handleVisitedEverywhere()); + expect(many).not.toHaveBeenCalled(); + + vi.spyOn(collectionsApi, 'membership').mockResolvedValue(SAVED_IN_FAVORITES); + openFor({ ...TARGET, name: 'Pantheon' }); + await waitFor(() => expect(result.current.unvisited).toHaveLength(1)); + await act(() => result.current.handleVisitedEverywhere()); + expect(many).toHaveBeenCalledWith([900], 'visited'); + expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined); + }); + + it('FE-COMP-SAVETOCOLHOOK-013: opening the collections page closes the picker and navigates', async () => { + const { result } = await loaded(); + + act(() => result.current.openCollections()); + + expect(useSaveToCollectionStore.getState().target).toBeNull(); + expect(mockNavigate).toHaveBeenCalledWith('/collections'); + }); + + it('FE-COMP-SAVETOCOLHOOK-014: a load landing after the target changed is dropped', async () => { + let resolveFirst!: (v: CollectionListResponse) => void; + vi.spyOn(collectionsApi, 'list') + .mockReturnValueOnce(new Promise((r) => (resolveFirst = r))) + .mockResolvedValue(listResponse([WISHLIST])); + const { result } = renderHook(() => useSaveToCollection()); + openFor(); + openFor({ ...TARGET, name: 'Pantheon' }); + await waitFor(() => expect(result.current.lists.map((l) => l.id)).toEqual([2])); + + resolveFirst(listResponse([FAVORITES, WISHLIST])); + await act(async () => {}); + expect(result.current.lists.map((l) => l.id)).toEqual([2]); + }); +}); diff --git a/client/src/components/Collections/useSaveToCollection.ts b/client/src/components/Collections/useSaveToCollection.ts new file mode 100644 index 000000000..a300141f0 --- /dev/null +++ b/client/src/components/Collections/useSaveToCollection.ts @@ -0,0 +1,180 @@ +import type { Collection, CollectionMembership, CollectionStatus } from '@trek/shared'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router'; + +import { collectionsApi } from '../../api/collections'; +import { useTranslation } from '../../i18n'; +import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'; +import { getApiErrorMessage } from '../../utils/apiError'; +import { useToast } from '../shared/Toast'; + +type MembershipEntry = CollectionMembership['lists'][number]; + +/** busyId while the "visited everywhere" bulk action runs. */ +export const VISITED_EVERYWHERE_BUSY = -1; + +/** + * The store-driven "Save to Collection" picker: the one logic path behind the desktop + * dialog and the phone sheet, which render their own markup over it. Every new target + * loads the user's lists and the place's membership; a list toggles the place in or out, + * a status changes per list, and "visited everywhere" marks every editable list at once. + * Each change refreshes the membership and bumps the store version so the inspector + * bookmark indicator stays in sync. + */ +export function useSaveToCollection() { + const target = useSaveToCollectionStore((s) => s.target); + const close = useSaveToCollectionStore((s) => s.close); + const bumpVersion = useSaveToCollectionStore((s) => s.bumpVersion); + const { t } = useTranslation(); + const toast = useToast(); + const navigate = useNavigate(); + + const [lists, setLists] = useState([]); + const [membership, setMembership] = useState(null); + const [loading, setLoading] = useState(false); + const [busyId, setBusyId] = useState(null); + + const membershipQuery = useMemo(() => { + if (!target) return null; + return { + google_place_id: target.google_place_id ?? undefined, + google_ftid: target.google_ftid ?? undefined, + name: target.name, + lat: target.lat ?? undefined, + lng: target.lng ?? undefined, + }; + }, [target]); + + const refreshMembership = useCallback(async () => { + if (!membershipQuery) return; + try { + setMembership(await collectionsApi.membership(membershipQuery)); + } catch { + setMembership({ saved: false, lists: [] }); + } + }, [membershipQuery]); + + // Load lists + membership whenever the picker opens for a new target. The query is + // derived from the target alone, so both change together. + useEffect(() => { + if (!target) return; + let cancelled = false; + setLoading(true); + setMembership(null); + void Promise.all([ + collectionsApi.list().catch(() => ({ collections: [], incomingInvites: [] })), + membershipQuery + ? collectionsApi.membership(membershipQuery).catch(() => ({ saved: false, lists: [] as MembershipEntry[] })) + : Promise.resolve({ saved: false, lists: [] as MembershipEntry[] }), + ]) + .then(([listRes, m]) => { + if (cancelled) return; + setLists(listRes.collections); + setMembership(m); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [target, membershipQuery]); + + const savedByCollection = new Map(); + for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l); + + /** Lists holding this place that the viewer may edit and that are not visited yet. */ + const unvisited = (membership?.lists ?? []).filter((l) => l.can_edit && l.status !== 'visited'); + + const handleStatus = async (entry: MembershipEntry, next: CollectionStatus) => { + if (busyId != null) return; + setBusyId(entry.collection_id); + try { + await collectionsApi.setStatus(entry.place_id, next); + await refreshMembership(); + bumpVersion(); + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))); + } finally { + setBusyId(null); + } + }; + + const handleVisitedEverywhere = async () => { + if (busyId != null || unvisited.length === 0) return; + setBusyId(VISITED_EVERYWHERE_BUSY); + try { + const { updated } = await collectionsApi.setStatusMany( + unvisited.map((l) => l.place_id), + 'visited' + ); + toast.success(t('collections.markedVisited', { count: updated })); + await refreshMembership(); + bumpVersion(); + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))); + } finally { + setBusyId(null); + } + }; + + const handleToggle = async (list: Collection) => { + if (busyId != null || !target) return; + const savedPlaceId = savedByCollection.get(list.id)?.place_id; + setBusyId(list.id); + try { + if (savedPlaceId != null) { + await collectionsApi.deletePlace(savedPlaceId); + toast.success(t('collections.removedFromList', { name: list.name })); + } else { + await collectionsApi.savePlace({ + collection_id: list.id, + source_trip_id: target.source_trip_id ?? null, + source_place_id: target.source_place_id ?? null, + name: target.name, + description: target.description ?? null, + lat: target.lat ?? null, + lng: target.lng ?? null, + address: target.address ?? null, + category_id: target.category_id ?? null, + price: target.price ?? null, + currency: target.currency ?? null, + notes: target.notes ?? null, + image_url: target.image_url ?? null, + google_place_id: target.google_place_id ?? null, + google_ftid: target.google_ftid ?? null, + osm_id: target.osm_id ?? null, + website: target.website ?? null, + phone: target.phone ?? null, + force: true, + }); + toast.success(t('collections.addedToList', { name: list.name })); + } + await refreshMembership(); + bumpVersion(); + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))); + } finally { + setBusyId(null); + } + }; + + const openCollections = () => { + close(); + navigate('/collections'); + }; + + return { + target, + close, + lists, + loading, + busyId, + savedByCollection, + unvisited, + handleStatus, + handleVisitedEverywhere, + handleToggle, + openCollections, + }; +}