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, + }; +}