From e40bc98a33909d9ef2fd815a4d57007114de8bc8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:43:12 +0200 Subject: [PATCH] refactor(atlas): share the country popup actions between desktop and phone useAtlasCountryActions marks a country or a region, unmarks a region, adds the country or region to the bucket list and takes the country off it again for both the desktop dialog and the phone sheet. The desktop dialog moves into its own component in AtlasPage so the page body keeps calling useAtlas only. The phone sheet keeps storing a marked region as visited and its own month input; the desktop keeps its month and year picker in useAtlas. --- .../screens/atlas/MAtlasCountryPopup.tsx | 137 +------ client/src/pages/AtlasPage.tsx | 356 +++++++----------- client/src/pages/atlas/useAtlas.ts | 7 +- .../atlas/useAtlasCountryActions.test.ts | 278 ++++++++++++++ .../src/pages/atlas/useAtlasCountryActions.ts | 219 +++++++++++ 5 files changed, 649 insertions(+), 348 deletions(-) create mode 100644 client/src/pages/atlas/useAtlasCountryActions.test.ts create mode 100644 client/src/pages/atlas/useAtlasCountryActions.ts diff --git a/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx b/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx index e86b4ce47..8834670f0 100644 --- a/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx +++ b/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx @@ -1,13 +1,8 @@ -import { useEffect, useState } from 'react' import { ChevronRight, MapPin, Star, Trash2 } from 'lucide-react' import type { LucideIcon } from 'lucide-react' -import apiClient from '../../../api/client' -import { continentForCountry } from '@trek/shared' -import { findBucketDuplicate, isBucketDuplicateError, withCountryMarkedVisited } from '../../../pages/atlas/atlasModel' -import { getApiErrorMessage } from '../../../types' -import { useToast } from '../../../components/shared/Toast' import MSheet from '../../components/MSheet' import type { AtlasController } from './atlasController' +import { useAtlasCountryActions } from '../../../pages/atlas/useAtlasCountryActions' const markTint = 'bg-[rgba(47,163,122,.16)] text-[color:var(--m-st-confirmed)]' // theme-lint-disable — fixed tint from the design's option rows const bucketTint = 'bg-[rgba(232,161,58,.16)] text-[color:var(--m-st-pending)]' // theme-lint-disable — fixed tint from the design's option rows @@ -58,131 +53,11 @@ interface MAtlasCountryPopupProps { * rendered as the mobile card sheet. */ export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) { - const { - t, - confirmAction, - setConfirmAction, - executeConfirmAction, - setData, - setVisitedRegions, - setBucketList, - visitedRegions, - bucketList, - handleDeleteBucketItem, - } = atlas - const toast = useToast() - const [bucketDate, setBucketDate] = useState('') - - useEffect(() => { - if (!confirmAction) setBucketDate('') - }, [confirmAction]) - - const markCountry = async (): Promise => { - if (!confirmAction) return - const { code } = confirmAction - try { - await apiClient.post(`/addons/atlas/country/${code}/mark`) - setData((prev) => (prev ? withCountryMarkedVisited(prev, code) : prev)) - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } - setConfirmAction(null) - } - - const markRegion = async (): Promise => { - if (!confirmAction) return - const { code: countryCode, name: regionName, regionCode } = confirmAction - if (!regionCode) return - try { - await apiClient.post(`/addons/atlas/region/${regionCode}/mark`, { name: regionName, country_code: countryCode }) - setVisitedRegions((prev) => { - const existing = prev[countryCode] || [] - if (existing.find((r) => r.code === regionCode)) return prev - return { ...prev, [countryCode]: [...existing, { code: regionCode, name: regionName, placeCount: 0, status: 'visited' as const, manuallyMarked: true }] } - }) - setData((prev) => (prev ? withCountryMarkedVisited(prev, countryCode) : prev)) - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } - setConfirmAction(null) - } - - const unmarkRegion = async (): Promise => { - if (!confirmAction) return - const { code: countryCode, regionCode } = confirmAction - if (!regionCode) return - try { - await apiClient.delete(`/addons/atlas/region/${regionCode}/mark`) - setVisitedRegions((prev) => { - const remaining = (prev[countryCode] || []).filter((r) => r.code !== regionCode) - const next = { ...prev, [countryCode]: remaining } - if (remaining.length === 0) delete next[countryCode] - return next - }) - // Drop the country too once no visible region is left — how a region was - // derived does not matter, the server hides it either way. Countries with - // real place/trip data are never hidden server-side (#1490), so removing - // them here would only flash and reappear on the next load. - setData((prev) => { - if (!prev) return prev - const c = prev.countries.find((c) => c.code === countryCode) - if (!c || c.placeCount > 0 || c.tripCount > 0) return prev - const remainingRegions = (visitedRegions[countryCode] || []).filter((r) => r.code !== regionCode) - if (remainingRegions.length > 0) return prev - const cont = continentForCountry(countryCode) - return { - ...prev, - countries: prev.countries.filter((c) => c.code !== countryCode), - stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) }, - continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) }, - } - }) - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } - setConfirmAction(null) - } - - const addBucket = async (): Promise => { - if (!confirmAction) return - const targetDate = bucketDate || null - // #1898: one entry per target date. The sheet stays open on a duplicate so - // another month can be picked right away. - if (findBucketDuplicate(bucketList, { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate, lat: null, lng: null })) { - toast.error(t('atlas.bucketDuplicate')) - return - } - try { - const r = await apiClient.post('/addons/atlas/bucket-list', { - name: confirmAction.name, - country_code: confirmAction.code, - target_date: targetDate, - // A region wish hatches that region on the map, not the whole country (#1901). - region_code: confirmAction.regionCode ?? null, - }) - setBucketList((prev) => [r.data.item, ...prev]) - } catch (err) { - if (isBucketDuplicateError(err)) { - toast.error(t('atlas.bucketDuplicate')) - return - } - toast.error(getApiErrorMessage(err, t('common.error'))) - } - setConfirmAction(null) - } - - // A country can sit on the bucket list more than once (one entry per place and - // target date), so taking it off the wishlist means dropping every entry for - // that code. - const removeBucket = async (): Promise => { - if (!confirmAction) return - const wishlistItems = bucketList.filter((b) => b.country_code === confirmAction.code) - await Promise.all(wishlistItems.map((item) => handleDeleteBucketItem(item.id))) - setConfirmAction(null) - } + const { t, confirmAction, setConfirmAction, executeConfirmAction } = atlas + const { bucketDate, setBucketDate, onWishlist, markCountry, markRegion, unmarkRegion, addBucketForDate, removeBucket } = + useAtlasCountryActions(atlas) const a = confirmAction - const onWishlist = !!a && bucketList.some((b) => b.country_code === a.code) return ( setConfirmAction(null)} variant="card" ariaLabel={a?.name}> @@ -231,7 +106,7 @@ export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) { {a.type === 'choose-region' && ( <> - + markRegion(true)} /> {t('common.back')} - diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 87ba6b69d..938b9999c 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from '../i18n' import Navbar from '../components/Layout/Navbar' -import apiClient from '../api/client' import CustomSelect from '../components/shared/CustomSelect' import EmptyState from '../components/shared/EmptyState' import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search, Check, ArrowLeft, ChevronRight, type LucideIcon } from 'lucide-react' @@ -12,13 +11,11 @@ import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, Foot import { EditorField, GRID_2 } from '../components/shared/dialogParts' import DawarichAtlasSidePanel from '../components/Dawarich/DawarichAtlasSidePanel' import CountryFlag from '../components/shared/CountryFlag' -import { A2_TO_A3, countryCodeToFlag, findBucketDuplicate, isBucketDuplicateError, visitedRegionCount, withCountryMarkedVisited, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel' -import { continentForCountry } from '@trek/shared' +import { A2_TO_A3, countryCodeToFlag, visitedRegionCount, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel' import { useAtlas } from './atlas/useAtlas' +import { bucketMonthTarget, useAtlasCountryActions, type AtlasCountryActionsOptions } from './atlas/useAtlasCountryActions' import AtlasCountrySearch from './atlas/AtlasCountrySearch' import AtlasLayerToggle from './atlas/AtlasLayerToggle' -import { useToast } from '../components/shared/Toast' -import { getApiErrorMessage } from '../types' import HelpAnchor from '../components/Help/HelpAnchor' import AtlasCountryPlaces from '../components/Atlas/AtlasCountryPlaces' import { getIntlLanguage } from '@trek/shared' @@ -66,7 +63,6 @@ function AtlasPageDesktop(): React.ReactElement { bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, reloadAfterDawarich, } = useAtlas() - const toast = useToast() // Solid surfaces when the user disabled transparency (read at render — the // attribute is already set by applyAppearance before navigating here). const noTransparency = typeof document !== 'undefined' && document.documentElement.hasAttribute('data-no-transparency') @@ -236,218 +232,150 @@ function AtlasPageDesktop(): React.ReactElement { {/* Country action popup: the flag and the name at the head, what can be done with the country below. A region names its country under its own name. */} - {confirmAction && ( - setConfirmAction(null)} - labelledBy={COUNTRY_DIALOG_TITLE} - width="narrow" - header={( - - {confirmAction.code.length === 2 ? ( - {confirmAction.code} - ) : ( - {countryCodeToFlag(confirmAction.code)} - )} - - )} - // The two removals take the danger wash ConfirmDialog asks its destructive questions in. - tint={confirmAction.type === 'unmark' || confirmAction.type === 'unmark-region' ? 'var(--danger-soft)' : NEUTRAL_TINT} - labelId={COUNTRY_DIALOG_TITLE} - onClose={() => setConfirmAction(null)} - title={confirmAction.name} - sub={confirmAction.countryName || undefined} - /> - )} - footer={( - - {confirmAction.type === 'bucket' && ( - setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })} icon={}> - {t('common.back')} - - )} - - {confirmAction.type !== 'bucket' && ( - setConfirmAction(null)}>{t('common.cancel')} - )} - {confirmAction.type === 'mark' && ( - {t('atlas.markVisited')} - )} - {confirmAction.type === 'unmark' && ( - - )} - {confirmAction.type === 'unmark-region' && ( - - )} - {confirmAction.type === 'bucket' && ( - } onClick={async () => { - const targetDate = bucketMonth > 0 && bucketYear > 0 ? `${bucketYear}-${String(bucketMonth).padStart(2, '0')}` : null - // #1898: one entry per target date. The dialog stays open on a - // duplicate so another month can be picked right away. - if (findBucketDuplicate(bucketList, { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate, lat: null, lng: null })) { - toast.error(t('atlas.bucketDuplicate')) - return - } - try { - const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate, region_code: confirmAction.regionCode ?? null }) - setBucketList(prev => [r.data.item, ...prev]) - } catch (err) { - if (isBucketDuplicateError(err)) { - toast.error(t('atlas.bucketDuplicate')) - return - } - toast.error(getApiErrorMessage(err, t('common.error'))) - } - setBucketMonth(0); setBucketYear(0) - setConfirmAction(null) - }}> - {t('atlas.addToBucket')} - - )} - - )} - > - {confirmAction.type === 'choose' && ( -
- { - try { - await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`) - setData(prev => (prev ? withCountryMarkedVisited(prev, confirmAction.code) : prev)) - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } - setConfirmAction(null) - }} /> - setConfirmAction({ ...confirmAction, type: 'bucket' })} /> - {(() => { - const wishlistItems = bucketList.filter(b => b.country_code === confirmAction.code) - if (wishlistItems.length === 0) return null - return ( - { - await Promise.all(wishlistItems.map(item => handleDeleteBucketItem(item.id))) - setConfirmAction(null) - }} /> - ) - })()} -
- )} - - {confirmAction.type === 'choose-region' && ( -
- { - const { code: countryCode, name: rName, regionCode: rCode } = confirmAction - if (!rCode) return - try { - await apiClient.post(`/addons/atlas/region/${rCode}/mark`, { name: rName, country_code: countryCode }) - setVisitedRegions(prev => { - const existing = prev[countryCode] || [] - if (existing.find(r => r.code === rCode)) return prev - return { ...prev, [countryCode]: [...existing, { code: rCode, name: rName, placeCount: 0, manuallyMarked: true }] } - }) - setData(prev => (prev ? withCountryMarkedVisited(prev, countryCode) : prev)) - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } - setConfirmAction(null) - }} /> - setConfirmAction({ ...confirmAction, type: 'bucket' })} /> -
- )} - - {confirmAction.type === 'unmark' && ( -

{t('atlas.confirmUnmark')}

- )} - - {confirmAction.type === 'unmark-region' && ( -

{t('atlas.confirmUnmarkRegion')}

- )} - - {confirmAction.type === 'mark' && ( -

{t('atlas.confirmMark')}

- )} - - {confirmAction.type === 'bucket' && ( - <> -

{t('atlas.bucketWhen')}

-
- - setBucketMonth(Number(v))} - placeholder={t('atlas.month')} - options={[ - { value: '0', label: t('common.none') }, - ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(getIntlLanguage(language), { month: 'long' }) })), - ]} - size="sm" - /> - - - setBucketYear(Number(v))} - placeholder={t('atlas.year')} - options={[ - { value: '0', label: t('common.none') }, - ...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) })), - ]} - size="sm" - /> - -
- - )} -
- )} + ) } +type CountryActionDialogProps = AtlasCountryActionsOptions & Pick, + 'language' | 'executeConfirmAction' | 'bucketMonth' | 'setBucketMonth' | 'bucketYear' | 'setBucketYear'> + +/** The country action popup over useAtlasCountryActions, the logic it shares with the phone sheet. */ +function CountryActionDialog(props: CountryActionDialogProps): React.ReactElement | null { + const { t, language, confirmAction, setConfirmAction, executeConfirmAction, bucketMonth, setBucketMonth, bucketYear, setBucketYear } = props + const { onWishlist, markCountry, markRegion, unmarkRegion, addBucket, removeBucket } = useAtlasCountryActions(props) + const addBucketForMonth = () => addBucket(bucketMonthTarget(bucketMonth, bucketYear), () => { setBucketMonth(0); setBucketYear(0) }) + if (!confirmAction) return null + return ( + setConfirmAction(null)} + labelledBy={COUNTRY_DIALOG_TITLE} + width="narrow" + header={( + + {confirmAction.code.length === 2 ? ( + {confirmAction.code} + ) : ( + {countryCodeToFlag(confirmAction.code)} + )} + + )} + // The two removals take the danger wash ConfirmDialog asks its destructive questions in. + tint={confirmAction.type === 'unmark' || confirmAction.type === 'unmark-region' ? 'var(--danger-soft)' : NEUTRAL_TINT} + labelId={COUNTRY_DIALOG_TITLE} + onClose={() => setConfirmAction(null)} + title={confirmAction.name} + sub={confirmAction.countryName || undefined} + /> + )} + footer={( + + {confirmAction.type === 'bucket' && ( + setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })} icon={}> + {t('common.back')} + + )} + + {confirmAction.type !== 'bucket' && ( + setConfirmAction(null)}>{t('common.cancel')} + )} + {confirmAction.type === 'mark' && ( + {t('atlas.markVisited')} + )} + {confirmAction.type === 'unmark' && ( + + )} + {confirmAction.type === 'unmark-region' && ( + + )} + {confirmAction.type === 'bucket' && ( + } onClick={addBucketForMonth}> + {t('atlas.addToBucket')} + + )} + + )} + > + {confirmAction.type === 'choose' && ( +
+ + setConfirmAction({ ...confirmAction, type: 'bucket' })} /> + {onWishlist && ( + + )} +
+ )} + + {confirmAction.type === 'choose-region' && ( +
+ markRegion()} /> + setConfirmAction({ ...confirmAction, type: 'bucket' })} /> +
+ )} + + {confirmAction.type === 'unmark' && ( +

{t('atlas.confirmUnmark')}

+ )} + + {confirmAction.type === 'unmark-region' && ( +

{t('atlas.confirmUnmarkRegion')}

+ )} + + {confirmAction.type === 'mark' && ( +

{t('atlas.confirmMark')}

+ )} + + {confirmAction.type === 'bucket' && ( + <> +

{t('atlas.bucketWhen')}

+
+ + setBucketMonth(Number(v))} + placeholder={t('atlas.month')} + options={[ + { value: '0', label: t('common.none') }, + ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(getIntlLanguage(language), { month: 'long' }) })), + ]} + size="sm" + /> + + + setBucketYear(Number(v))} + placeholder={t('atlas.year')} + options={[ + { value: '0', label: t('common.none') }, + ...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) })), + ]} + size="sm" + /> + +
+ + )} +
+ ) +} + /** One answer of the country dialog, built like an export row: icon tile, what it does, and what that means. */ function CountryChoice({ icon: Icon, tone = 'text-content-secondary', title, hint, onClick }: { icon: LucideIcon diff --git a/client/src/pages/atlas/useAtlas.ts b/client/src/pages/atlas/useAtlas.ts index 0fd4d4729..4d9115224 100644 --- a/client/src/pages/atlas/useAtlas.ts +++ b/client/src/pages/atlas/useAtlas.ts @@ -10,12 +10,13 @@ import apiClient, { mapsApi, pluginsApi, type PluginAtlasLayer } from '../../api import L from 'leaflet' import type { GeoJsonFeatureCollection } from '../../types' import { A2_TO_A3, countryStatus, visitedRegionCount, visitMonth, findBucketDuplicate, isBucketDuplicateError, isCountryVisible, normalizeRegionName, regionCacheEvictions, withCountryMarkedVisited, wishlistA3Codes, wishlistRegionCodes, countryColor, REGION_CACHE_MAX, bucketTooltipWidth, bucketTooltipPlacement, bucketTooltipNeedsScroll, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel' -import { continentForCountry, escapeHtml, type VisitStatus } from '@trek/shared' +import { continentForCountry, escapeHtml } from '@trek/shared' import { useGlassGlare } from '../../components/Atlas/useGlassGlare' import { dawarichApi } from '../../api/dawarich' import { useToast } from '../../components/shared/Toast' import { getApiErrorMessage } from '../../types' import { usePlaceLanguage } from '../../hooks/usePlaceLanguage' +import type { AtlasConfirmAction, VisitedRegionMap } from './useAtlasCountryActions' const PLANNED_KEY = 'trek_atlas_show_planned' @@ -163,7 +164,7 @@ export function useAtlas() { // The list of a country's places, opened from its detail (#2174). const [placesOpen, setPlacesOpen] = useState(false) const [geoData, setGeoData] = useState(null) - const [visitedRegions, setVisitedRegions] = useState>({}) + const [visitedRegions, setVisitedRegions] = useState({}) const [pluginLayers, setPluginLayers] = useState([]) const pluginLayerRef = useRef(null) const regionLayerRef = useRef(null) @@ -194,7 +195,7 @@ export function useAtlas() { const loadCountryDetailRef = useRef<(code: string) => void>(() => {}) const handleMarkCountryRef = useRef<(code: string, name: string) => void>(() => {}) const setConfirmActionRef = useRef(() => {}) - const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket' | 'choose-region' | 'unmark-region'; code: string; name: string; regionCode?: string; countryName?: string } | null>(null) + const [confirmAction, setConfirmAction] = useState(null) const [bucketMonth, setBucketMonth] = useState(0) const [bucketYear, setBucketYear] = useState(0) diff --git a/client/src/pages/atlas/useAtlasCountryActions.test.ts b/client/src/pages/atlas/useAtlasCountryActions.test.ts new file mode 100644 index 000000000..addbb6070 --- /dev/null +++ b/client/src/pages/atlas/useAtlasCountryActions.test.ts @@ -0,0 +1,278 @@ +// FE-PAGE-ATLASACTIONS-001 to FE-PAGE-ATLASACTIONS-014: the country / region popup logic +// behind both the desktop dialog and the phone sheet. +import { act, renderHook } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { useState } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { buildAtlasData, buildBucketItem } from '../../../tests/helpers/atlas'; +import { server } from '../../../tests/helpers/msw/server'; +import type { AtlasData, BucketItem } from './atlasModel'; +import { + bucketMonthTarget, + useAtlasCountryActions, + withCountryDroppedAfterRegionUnmark, + withRegionMarked, + withRegionUnmarked, + type AtlasConfirmAction, + type VisitedRegionMap, +} from './useAtlasCountryActions'; + +const t = (k: string) => k; + +let addToast: Mock>; +let posted: { url: string; body: unknown }[]; +let deleted: string[]; + +interface HarnessInit { + confirmAction: AtlasConfirmAction | null; + data?: AtlasData | null; + visitedRegions?: VisitedRegionMap; + bucketList?: BucketItem[]; + handleDeleteBucketItem?: (id: number) => Promise; +} + +/** The hook over real state, the way useAtlas hands it its slices. */ +function setup(init: HarnessInit) { + const handleDeleteBucketItem = init.handleDeleteBucketItem ?? vi.fn().mockResolvedValue(undefined); + return renderHook(() => { + const [confirmAction, setConfirmAction] = useState(init.confirmAction); + const [data, setData] = useState(init.data ?? buildAtlasData()); + const [visitedRegions, setVisitedRegions] = useState(init.visitedRegions ?? {}); + const [bucketList, setBucketList] = useState(init.bucketList ?? []); + const actions = useAtlasCountryActions({ + t, + confirmAction, + setConfirmAction, + setData, + visitedRegions, + setVisitedRegions, + bucketList, + setBucketList, + handleDeleteBucketItem, + }); + return { actions, confirmAction, setConfirmAction, data, visitedRegions, bucketList }; + }); +} + +beforeEach(() => { + addToast = vi.fn>(); + window.__addToast = addToast; + posted = []; + deleted = []; + server.use( + http.post('/api/addons/atlas/country/:code/mark', ({ request }) => { + posted.push({ url: new URL(request.url).pathname, body: null }); + return HttpResponse.json({ success: true }); + }), + http.post('/api/addons/atlas/region/:code/mark', async ({ request }) => { + posted.push({ url: new URL(request.url).pathname, body: await request.json() }); + return HttpResponse.json({ success: true }); + }), + http.delete('/api/addons/atlas/region/:code/mark', ({ request }) => { + deleted.push(new URL(request.url).pathname); + return HttpResponse.json({ success: true }); + }), + http.post('/api/addons/atlas/bucket-list', async ({ request }) => { + const body = (await request.json()) as Record; + posted.push({ url: '/api/addons/atlas/bucket-list', body }); + return HttpResponse.json({ + item: buildBucketItem({ + id: 42, + name: String(body.name), + country_code: String(body.country_code), + target_date: (body.target_date as string | null) ?? null, + }), + }); + }) + ); +}); + +afterEach(() => { + delete window.__addToast; +}); + +describe('atlas popup helpers', () => { + it('FE-PAGE-ATLASACTIONS-001: a bucket month needs both month and year', () => { + expect(bucketMonthTarget(3, 2027)).toBe('2027-03'); + expect(bucketMonthTarget(0, 2027)).toBeNull(); + expect(bucketMonthTarget(3, 0)).toBeNull(); + }); + + it('FE-PAGE-ATLASACTIONS-002: marking a region adds it once', () => { + const region = { code: 'ES-GA', name: 'Galicia', placeCount: 0, manuallyMarked: true }; + const once = withRegionMarked({}, 'ES', region); + expect(once).toEqual({ ES: [region] }); + expect(withRegionMarked(once, 'ES', region)).toBe(once); + }); + + it('FE-PAGE-ATLASACTIONS-003: unmarking the last region of a country drops the country key', () => { + const prev = { + ES: [ + { code: 'ES-GA', name: 'Galicia', placeCount: 0 }, + { code: 'ES-MD', name: 'Madrid', placeCount: 0 }, + ], + }; + expect(withRegionUnmarked(prev, 'ES', 'ES-GA')).toEqual({ ES: [{ code: 'ES-MD', name: 'Madrid', placeCount: 0 }] }); + expect(withRegionUnmarked({ ES: [prev.ES[0]] }, 'ES', 'ES-GA')).toEqual({}); + }); + + it('FE-PAGE-ATLASACTIONS-004: the country goes with its last region only without places or trips of its own', () => { + const regions = { ES: [{ code: 'ES-GA', name: 'Galicia', placeCount: 0 }] }; + const marked = buildAtlasData({ + countries: [{ code: 'ES', tripCount: 0, placeCount: 0, firstVisit: null, lastVisit: null }], + stats: { ...buildAtlasData().stats, totalCountries: 1 }, + continents: { Europe: 1 }, + }); + const dropped = withCountryDroppedAfterRegionUnmark(marked, 'ES', 'ES-GA', regions); + expect(dropped?.countries).toEqual([]); + expect(dropped?.stats.totalCountries).toBe(0); + expect(dropped?.continents?.Europe).toBe(0); + + const withPlaces = buildAtlasData({ countries: [{ ...marked.countries[0], placeCount: 2 }] }); + expect(withCountryDroppedAfterRegionUnmark(withPlaces, 'ES', 'ES-GA', regions)).toBe(withPlaces); + const twoRegions = { ES: [...regions.ES, { code: 'ES-MD', name: 'Madrid', placeCount: 0 }] }; + expect(withCountryDroppedAfterRegionUnmark(marked, 'ES', 'ES-GA', twoRegions)).toBe(marked); + expect(withCountryDroppedAfterRegionUnmark(null, 'ES', 'ES-GA', regions)).toBeNull(); + }); +}); + +describe('useAtlasCountryActions', () => { + it('FE-PAGE-ATLASACTIONS-005: marking a country posts it, adds it to the atlas and closes', async () => { + const { result } = setup({ confirmAction: { type: 'choose', code: 'DE', name: 'Germany' } }); + await act(() => result.current.actions.markCountry()); + expect(posted).toEqual([{ url: '/api/addons/atlas/country/DE/mark', body: null }]); + expect(result.current.data?.countries.map((c) => c.code)).toEqual(['FR', 'DE']); + expect(result.current.confirmAction).toBeNull(); + }); + + it('FE-PAGE-ATLASACTIONS-006: a failed mark toasts and still closes', async () => { + server.use( + http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ error: 'Locked' }, { status: 400 })) + ); + const { result } = setup({ confirmAction: { type: 'choose', code: 'DE', name: 'Germany' } }); + await act(() => result.current.actions.markCountry()); + expect(addToast).toHaveBeenCalledWith('Locked', 'error', undefined); + expect(result.current.data?.countries.map((c) => c.code)).toEqual(['FR']); + expect(result.current.confirmAction).toBeNull(); + }); + + it('FE-PAGE-ATLASACTIONS-007: marking a region stores it without a status (desktop) or as visited (phone)', async () => { + const action: AtlasConfirmAction = { type: 'choose-region', code: 'ES', name: 'Galicia', regionCode: 'ES-GA' }; + const desktop = setup({ confirmAction: action }); + await act(() => desktop.result.current.actions.markRegion()); + expect(posted[0]).toEqual({ + url: '/api/addons/atlas/region/ES-GA/mark', + body: { name: 'Galicia', country_code: 'ES' }, + }); + expect(desktop.result.current.visitedRegions).toEqual({ + ES: [{ code: 'ES-GA', name: 'Galicia', placeCount: 0, manuallyMarked: true }], + }); + expect(desktop.result.current.data?.countries.map((c) => c.code)).toEqual(['FR', 'ES']); + + const phone = setup({ confirmAction: action }); + await act(() => phone.result.current.actions.markRegion(true)); + expect(phone.result.current.visitedRegions).toEqual({ + ES: [{ code: 'ES-GA', name: 'Galicia', placeCount: 0, status: 'visited', manuallyMarked: true }], + }); + expect(phone.result.current.confirmAction).toBeNull(); + }); + + it('FE-PAGE-ATLASACTIONS-008: a region action without a region code does nothing', async () => { + const { result } = setup({ confirmAction: { type: 'choose-region', code: 'ES', name: 'Galicia' } }); + await act(() => result.current.actions.markRegion()); + await act(() => result.current.actions.unmarkRegion()); + expect(posted).toEqual([]); + expect(deleted).toEqual([]); + expect(result.current.confirmAction).not.toBeNull(); + }); + + it('FE-PAGE-ATLASACTIONS-009: unmarking a region removes it and the country it leaves empty', async () => { + const { result } = setup({ + confirmAction: { type: 'unmark-region', code: 'ES', name: 'Galicia', regionCode: 'ES-GA' }, + data: buildAtlasData({ + countries: [{ code: 'ES', tripCount: 0, placeCount: 0, firstVisit: null, lastVisit: null }], + }), + visitedRegions: { ES: [{ code: 'ES-GA', name: 'Galicia', placeCount: 0 }] }, + }); + await act(() => result.current.actions.unmarkRegion()); + expect(deleted).toEqual(['/api/addons/atlas/region/ES-GA/mark']); + expect(result.current.visitedRegions).toEqual({}); + expect(result.current.data?.countries).toEqual([]); + expect(result.current.confirmAction).toBeNull(); + }); + + it('FE-PAGE-ATLASACTIONS-010: adding to the bucket list posts the entry, prepends it and runs beforeClose', async () => { + const beforeClose = vi.fn(); + const { result } = setup({ + confirmAction: { type: 'bucket', code: 'ES', name: 'Galicia', regionCode: 'ES-GA' }, + bucketList: [buildBucketItem()], + }); + await act(() => result.current.actions.addBucket('2027-05', beforeClose)); + expect(posted).toEqual([ + { + url: '/api/addons/atlas/bucket-list', + body: { name: 'Galicia', country_code: 'ES', target_date: '2027-05', region_code: 'ES-GA' }, + }, + ]); + expect(result.current.bucketList.map((b) => b.id)).toEqual([42, 1]); + expect(beforeClose).toHaveBeenCalledTimes(1); + expect(result.current.confirmAction).toBeNull(); + }); + + it('FE-PAGE-ATLASACTIONS-011: a known duplicate stays open without a request', async () => { + const beforeClose = vi.fn(); + const { result } = setup({ + confirmAction: { type: 'bucket', code: 'DE', name: 'Germany' }, + bucketList: [buildBucketItem({ name: 'Germany', country_code: 'DE', target_date: '2027-05' })], + }); + await act(() => result.current.actions.addBucket('2027-05', beforeClose)); + expect(posted).toEqual([]); + expect(addToast).toHaveBeenCalledWith('atlas.bucketDuplicate', 'error', undefined); + expect(beforeClose).not.toHaveBeenCalled(); + expect(result.current.confirmAction).not.toBeNull(); + }); + + it('FE-PAGE-ATLASACTIONS-012: a server duplicate stays open, any other failure toasts and closes', async () => { + server.use(http.post('/api/addons/atlas/bucket-list', () => HttpResponse.json({ error: 'dup' }, { status: 409 }))); + const { result } = setup({ confirmAction: { type: 'bucket', code: 'DE', name: 'Germany' } }); + await act(() => result.current.actions.addBucket(null)); + expect(addToast).toHaveBeenLastCalledWith('atlas.bucketDuplicate', 'error', undefined); + expect(result.current.confirmAction).not.toBeNull(); + + server.use(http.post('/api/addons/atlas/bucket-list', () => HttpResponse.json({ error: 'Boom' }, { status: 500 }))); + await act(() => result.current.actions.addBucket(null)); + expect(addToast).toHaveBeenLastCalledWith('Boom', 'error', undefined); + expect(result.current.confirmAction).toBeNull(); + }); + + it('FE-PAGE-ATLASACTIONS-013: the phone month input feeds the entry and clears when the popup closes', async () => { + const { result } = setup({ confirmAction: { type: 'bucket', code: 'DE', name: 'Germany' } }); + act(() => result.current.actions.setBucketDate('2028-01')); + await act(() => result.current.actions.addBucketForDate()); + expect(posted[0].body).toMatchObject({ target_date: '2028-01' }); + expect(result.current.actions.bucketDate).toBe(''); + + act(() => result.current.setConfirmAction({ type: 'bucket', code: 'DE', name: 'Germany' })); + await act(() => result.current.actions.addBucketForDate()); + expect(posted[1].body).toMatchObject({ target_date: null }); + }); + + it('FE-PAGE-ATLASACTIONS-014: taking a country off the wishlist deletes every entry for it', async () => { + const handleDeleteBucketItem = vi.fn().mockResolvedValue(undefined); + const { result } = setup({ + confirmAction: { type: 'choose', code: 'FR', name: 'France' }, + bucketList: [ + buildBucketItem({ id: 1, country_code: 'FR' }), + buildBucketItem({ id: 2, country_code: 'FR' }), + buildBucketItem({ id: 3, country_code: 'JP' }), + ], + handleDeleteBucketItem, + }); + expect(result.current.actions.onWishlist).toBe(true); + await act(() => result.current.actions.removeBucket()); + expect(handleDeleteBucketItem.mock.calls).toEqual([[1], [2]]); + expect(result.current.confirmAction).toBeNull(); + expect(result.current.actions.onWishlist).toBe(false); + }); +}); diff --git a/client/src/pages/atlas/useAtlasCountryActions.ts b/client/src/pages/atlas/useAtlasCountryActions.ts new file mode 100644 index 000000000..2cc96e293 --- /dev/null +++ b/client/src/pages/atlas/useAtlasCountryActions.ts @@ -0,0 +1,219 @@ +import { continentForCountry, type VisitStatus } from '@trek/shared'; +import type React from 'react'; +import { useEffect, useState } from 'react'; + +import apiClient from '../../api/client'; +import { useToast } from '../../components/shared/Toast'; +import { getApiErrorMessage, type TranslationFn } from '../../types'; +import { + findBucketDuplicate, + isBucketDuplicateError, + withCountryMarkedVisited, + type AtlasData, + type BucketItem, +} from './atlasModel'; + +/** The country or region the action popup is open on, and which step it shows. */ +export interface AtlasConfirmAction { + type: 'mark' | 'unmark' | 'choose' | 'bucket' | 'choose-region' | 'unmark-region'; + code: string; + name: string; + regionCode?: string; + countryName?: string; +} + +export interface VisitedRegion { + code: string; + name: string; + placeCount: number; + manuallyMarked?: boolean; + status?: VisitStatus; +} + +export type VisitedRegionMap = Record; + +type SetState = React.Dispatch>; + +export interface AtlasCountryActionsOptions { + t: TranslationFn; + confirmAction: AtlasConfirmAction | null; + setConfirmAction: SetState; + setData: SetState; + visitedRegions: VisitedRegionMap; + setVisitedRegions: SetState; + bucketList: BucketItem[]; + setBucketList: SetState; + handleDeleteBucketItem: (id: number) => Promise; +} + +/** The bucket list target of a month and year picker: YYYY-MM, or null unless both are set. */ +export function bucketMonthTarget(month: number, year: number): string | null { + return month > 0 && year > 0 ? `${year}-${String(month).padStart(2, '0')}` : null; +} + +/** The visited regions with a hand marked region added to its country, unless it is there already. */ +export function withRegionMarked(prev: VisitedRegionMap, countryCode: string, region: VisitedRegion): VisitedRegionMap { + const existing = prev[countryCode] || []; + if (existing.find((r) => r.code === region.code)) return prev; + return { ...prev, [countryCode]: [...existing, region] }; +} + +/** The visited regions without one region; a country left with none drops out. */ +export function withRegionUnmarked(prev: VisitedRegionMap, countryCode: string, regionCode: string): VisitedRegionMap { + const remaining = (prev[countryCode] || []).filter((r) => r.code !== regionCode); + const next = { ...prev, [countryCode]: remaining }; + if (remaining.length === 0) delete next[countryCode]; + return next; +} + +/** + * The atlas once a region is unmarked: if no visible region of the country remains + * (however a region was derived: the server hides it either way and cascades to the + * country the same way), the country goes too, but only when it has no place or trip + * of its own. A country with real places is never hidden server side (#1490), so + * removing it here would only flash and reappear on the next load. + */ +export function withCountryDroppedAfterRegionUnmark( + prev: AtlasData | null, + countryCode: string, + regionCode: string, + visitedRegions: VisitedRegionMap +): AtlasData | null { + if (!prev) return prev; + const c = prev.countries.find((c) => c.code === countryCode); + if (!c || c.placeCount > 0 || c.tripCount > 0) return prev; + const remainingRegions = (visitedRegions[countryCode] || []).filter((r) => r.code !== regionCode); + if (remainingRegions.length > 0) return prev; + const cont = continentForCountry(countryCode); + return { + ...prev, + countries: prev.countries.filter((c) => c.code !== countryCode), + stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) }, + continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) }, + }; +} + +/** + * What the country / region action popup does, behind both the desktop dialog and the + * phone sheet (which render their own markup and their own bucket list date picker): + * mark a country or a region visited, unmark a region, put the country or region on + * the bucket list for a target month, or take the country off it again. + */ +export function useAtlasCountryActions({ + t, + confirmAction, + setConfirmAction, + setData, + visitedRegions, + setVisitedRegions, + bucketList, + setBucketList, + handleDeleteBucketItem, +}: AtlasCountryActionsOptions) { + const toast = useToast(); + // The phone picker: one YYYY-MM month input, cleared whenever the popup closes. + const [bucketDate, setBucketDate] = useState(''); + + useEffect(() => { + if (!confirmAction) setBucketDate(''); + }, [confirmAction]); + + const markCountry = async (): Promise => { + if (!confirmAction) return; + const { code } = confirmAction; + try { + await apiClient.post(`/addons/atlas/country/${code}/mark`); + setData((prev) => (prev ? withCountryMarkedVisited(prev, code) : prev)); + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))); + } + setConfirmAction(null); + }; + + /** `withVisitedStatus` stores the new region as visited outright (the phone sheet). */ + const markRegion = async (withVisitedStatus = false): Promise => { + if (!confirmAction) return; + const { code: countryCode, name: regionName, regionCode } = confirmAction; + if (!regionCode) return; + try { + await apiClient.post(`/addons/atlas/region/${regionCode}/mark`, { name: regionName, country_code: countryCode }); + const region: VisitedRegion = withVisitedStatus + ? { code: regionCode, name: regionName, placeCount: 0, status: 'visited', manuallyMarked: true } + : { code: regionCode, name: regionName, placeCount: 0, manuallyMarked: true }; + setVisitedRegions((prev) => withRegionMarked(prev, countryCode, region)); + setData((prev) => (prev ? withCountryMarkedVisited(prev, countryCode) : prev)); + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))); + } + setConfirmAction(null); + }; + + const unmarkRegion = async (): Promise => { + if (!confirmAction) return; + const { code: countryCode, regionCode } = confirmAction; + if (!regionCode) return; + try { + await apiClient.delete(`/addons/atlas/region/${regionCode}/mark`); + setVisitedRegions((prev) => withRegionUnmarked(prev, countryCode, regionCode)); + setData((prev) => withCountryDroppedAfterRegionUnmark(prev, countryCode, regionCode, visitedRegions)); + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))); + } + setConfirmAction(null); + }; + + /** + * Adds the bucket list entry for the target month and closes the popup, running + * `beforeClose` first (the desktop dialog resets its month and year picker there). + * A duplicate keeps the popup open and the picker as it is, so another month can be + * picked right away (#1898). + */ + const addBucket = async (targetDate: string | null, beforeClose?: () => void): Promise => { + if (!confirmAction) return; + const entry = { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate }; + if (findBucketDuplicate(bucketList, { ...entry, lat: null, lng: null })) { + toast.error(t('atlas.bucketDuplicate')); + return; + } + try { + // A region wish hatches that region on the map, not the whole country (#1901). + const r = await apiClient.post('/addons/atlas/bucket-list', { + ...entry, + region_code: confirmAction.regionCode ?? null, + }); + setBucketList((prev) => [r.data.item, ...prev]); + } catch (err) { + if (isBucketDuplicateError(err)) { + toast.error(t('atlas.bucketDuplicate')); + return; + } + toast.error(getApiErrorMessage(err, t('common.error'))); + } + beforeClose?.(); + setConfirmAction(null); + }; + + /** The phone sheet: its month input, cleared by the close itself. */ + const addBucketForDate = () => addBucket(bucketDate || null); + + // A country can sit on the bucket list more than once (one entry per place and + // target date), so taking it off the wishlist drops every entry for that code. + const wishlistItems = confirmAction ? bucketList.filter((b) => b.country_code === confirmAction.code) : []; + const removeBucket = async (): Promise => { + if (!confirmAction) return; + await Promise.all(wishlistItems.map((item) => handleDeleteBucketItem(item.id))); + setConfirmAction(null); + }; + + return { + bucketDate, + setBucketDate, + onWishlist: wishlistItems.length > 0, + markCountry, + markRegion, + unmarkRegion, + addBucket, + addBucketForDate, + removeBucket, + }; +}