mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 (
|
||||
<MSheet open={!!a} onClose={() => setConfirmAction(null)} variant="card" ariaLabel={a?.name}>
|
||||
@@ -231,7 +106,7 @@ export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) {
|
||||
|
||||
{a.type === 'choose-region' && (
|
||||
<>
|
||||
<OptionRow icon={MapPin} tint={markTint} title={t('atlas.markVisited')} hint={t('atlas.markRegionVisitedHint')} onClick={markRegion} />
|
||||
<OptionRow icon={MapPin} tint={markTint} title={t('atlas.markVisited')} hint={t('atlas.markRegionVisitedHint')} onClick={() => markRegion(true)} />
|
||||
<OptionRow
|
||||
icon={Star}
|
||||
tint={bucketTint}
|
||||
@@ -298,7 +173,7 @@ export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) {
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
<button type="button" onClick={addBucket} className={actBtn}>
|
||||
<button type="button" onClick={addBucketForDate} className={actBtn}>
|
||||
{t('atlas.addToBucket')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+142
-214
@@ -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 && (
|
||||
<DialogShell
|
||||
onClose={() => setConfirmAction(null)}
|
||||
labelledBy={COUNTRY_DIALOG_TITLE}
|
||||
width="narrow"
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={(
|
||||
<DialogTile>
|
||||
{confirmAction.code.length === 2 ? (
|
||||
<img src={`https://flagcdn.com/w80/${confirmAction.code.toLowerCase()}.png`} alt={confirmAction.code} className="h-6 w-[34px] rounded-[5px] object-cover ring-1 ring-edge-faint" />
|
||||
) : (
|
||||
<span className="leading-none" style={fs(26, 'subtitle')}>{countryCodeToFlag(confirmAction.code)}</span>
|
||||
)}
|
||||
</DialogTile>
|
||||
)}
|
||||
// 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={(
|
||||
<DialogFooter>
|
||||
{confirmAction.type === 'bucket' && (
|
||||
<DialogButton onClick={() => setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })} icon={<ArrowLeft size={14} strokeWidth={2.2} />}>
|
||||
{t('common.back')}
|
||||
</DialogButton>
|
||||
)}
|
||||
<FooterSpacer />
|
||||
{confirmAction.type !== 'bucket' && (
|
||||
<DialogButton onClick={() => setConfirmAction(null)}>{t('common.cancel')}</DialogButton>
|
||||
)}
|
||||
{confirmAction.type === 'mark' && (
|
||||
<DialogButton variant="primary" onClick={executeConfirmAction}>{t('atlas.markVisited')}</DialogButton>
|
||||
)}
|
||||
{confirmAction.type === 'unmark' && (
|
||||
<button type="button" onClick={executeConfirmAction} className={DANGER_BUTTON} style={fs(13, 'body')}>
|
||||
{t('atlas.unmark')}
|
||||
</button>
|
||||
)}
|
||||
{confirmAction.type === 'unmark-region' && (
|
||||
<button type="button" onClick={async () => {
|
||||
const { code: countryCode, regionCode: rCode } = confirmAction
|
||||
if (!rCode) return
|
||||
try {
|
||||
await apiClient.delete(`/addons/atlas/region/${rCode}/mark`)
|
||||
setVisitedRegions(prev => {
|
||||
const remaining = (prev[countryCode] || []).filter(r => r.code !== rCode)
|
||||
const next = { ...prev, [countryCode]: remaining }
|
||||
if (remaining.length === 0) delete next[countryCode]
|
||||
return next
|
||||
})
|
||||
// If no visible regions remain at all (not just manually-marked ones:
|
||||
// the server now hides a region regardless of how it was derived, and
|
||||
// cascades to the country the same way), remove the country too, but
|
||||
// only when it has no real place/trip data of its own: a country with
|
||||
// real places is never actually hidden server-side (#1490), so
|
||||
// optimistically removing it here would just flash and reappear on
|
||||
// the next reload.
|
||||
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 !== rCode)
|
||||
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)
|
||||
}} className={DANGER_BUTTON} style={fs(13, 'body')}>
|
||||
{t('atlas.unmark')}
|
||||
</button>
|
||||
)}
|
||||
{confirmAction.type === 'bucket' && (
|
||||
<DialogButton variant="primary" icon={<Star size={14} strokeWidth={2.2} />} 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')}
|
||||
</DialogButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
)}
|
||||
>
|
||||
{confirmAction.type === 'choose' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<CountryChoice icon={MapPin} title={t('atlas.markVisited')} hint={t('atlas.markVisitedHint')} onClick={async () => {
|
||||
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)
|
||||
}} />
|
||||
<CountryChoice icon={Star} tone="text-warning" title={t('atlas.addToBucket')} hint={t('atlas.addToBucketHint')}
|
||||
onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })} />
|
||||
{(() => {
|
||||
const wishlistItems = bucketList.filter(b => b.country_code === confirmAction.code)
|
||||
if (wishlistItems.length === 0) return null
|
||||
return (
|
||||
<CountryChoice icon={Trash2} tone="text-danger" title={t('atlas.removeFromBucket')} hint={t('atlas.removeFromBucketHint')} onClick={async () => {
|
||||
await Promise.all(wishlistItems.map(item => handleDeleteBucketItem(item.id)))
|
||||
setConfirmAction(null)
|
||||
}} />
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'choose-region' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<CountryChoice icon={MapPin} title={t('atlas.markVisited')} hint={t('atlas.markRegionVisitedHint')} onClick={async () => {
|
||||
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)
|
||||
}} />
|
||||
<CountryChoice icon={Star} tone="text-warning" title={t('atlas.addToBucket')} hint={t('atlas.addToBucketHint')}
|
||||
onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'unmark' && (
|
||||
<p className="m-0 text-content-secondary" style={fs(13.5, 'body')}>{t('atlas.confirmUnmark')}</p>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'unmark-region' && (
|
||||
<p className="m-0 text-content-secondary" style={fs(13.5, 'body')}>{t('atlas.confirmUnmarkRegion')}</p>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'mark' && (
|
||||
<p className="m-0 text-content-secondary" style={fs(13.5, 'body')}>{t('atlas.confirmMark')}</p>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'bucket' && (
|
||||
<>
|
||||
<p className="m-0 text-content-secondary" style={fs(13.5, 'body')}>{t('atlas.bucketWhen')}</p>
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('atlas.month')} htmlFor={BUCKET_MONTH_ID}>
|
||||
<CustomSelect
|
||||
id={BUCKET_MONTH_ID}
|
||||
value={String(bucketMonth)}
|
||||
onChange={v => 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"
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label={t('atlas.year')} htmlFor={BUCKET_YEAR_ID}>
|
||||
<CustomSelect
|
||||
id={BUCKET_YEAR_ID}
|
||||
value={String(bucketYear)}
|
||||
onChange={v => 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"
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogShell>
|
||||
)}
|
||||
<CountryActionDialog
|
||||
t={t} language={language} confirmAction={confirmAction} setConfirmAction={setConfirmAction}
|
||||
executeConfirmAction={executeConfirmAction} setData={setData} visitedRegions={visitedRegions}
|
||||
setVisitedRegions={setVisitedRegions} bucketList={bucketList} setBucketList={setBucketList}
|
||||
handleDeleteBucketItem={handleDeleteBucketItem}
|
||||
bucketMonth={bucketMonth} setBucketMonth={setBucketMonth} bucketYear={bucketYear} setBucketYear={setBucketYear}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CountryActionDialogProps = AtlasCountryActionsOptions & Pick<ReturnType<typeof useAtlas>,
|
||||
'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 (
|
||||
<DialogShell
|
||||
onClose={() => setConfirmAction(null)}
|
||||
labelledBy={COUNTRY_DIALOG_TITLE}
|
||||
width="narrow"
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={(
|
||||
<DialogTile>
|
||||
{confirmAction.code.length === 2 ? (
|
||||
<img src={`https://flagcdn.com/w80/${confirmAction.code.toLowerCase()}.png`} alt={confirmAction.code} className="h-6 w-[34px] rounded-[5px] object-cover ring-1 ring-edge-faint" />
|
||||
) : (
|
||||
<span className="leading-none" style={fs(26, 'subtitle')}>{countryCodeToFlag(confirmAction.code)}</span>
|
||||
)}
|
||||
</DialogTile>
|
||||
)}
|
||||
// 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={(
|
||||
<DialogFooter>
|
||||
{confirmAction.type === 'bucket' && (
|
||||
<DialogButton onClick={() => setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })} icon={<ArrowLeft size={14} strokeWidth={2.2} />}>
|
||||
{t('common.back')}
|
||||
</DialogButton>
|
||||
)}
|
||||
<FooterSpacer />
|
||||
{confirmAction.type !== 'bucket' && (
|
||||
<DialogButton onClick={() => setConfirmAction(null)}>{t('common.cancel')}</DialogButton>
|
||||
)}
|
||||
{confirmAction.type === 'mark' && (
|
||||
<DialogButton variant="primary" onClick={executeConfirmAction}>{t('atlas.markVisited')}</DialogButton>
|
||||
)}
|
||||
{confirmAction.type === 'unmark' && (
|
||||
<button type="button" onClick={executeConfirmAction} className={DANGER_BUTTON} style={fs(13, 'body')}>
|
||||
{t('atlas.unmark')}
|
||||
</button>
|
||||
)}
|
||||
{confirmAction.type === 'unmark-region' && (
|
||||
<button type="button" onClick={unmarkRegion} className={DANGER_BUTTON} style={fs(13, 'body')}>
|
||||
{t('atlas.unmark')}
|
||||
</button>
|
||||
)}
|
||||
{confirmAction.type === 'bucket' && (
|
||||
<DialogButton variant="primary" icon={<Star size={14} strokeWidth={2.2} />} onClick={addBucketForMonth}>
|
||||
{t('atlas.addToBucket')}
|
||||
</DialogButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
)}
|
||||
>
|
||||
{confirmAction.type === 'choose' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<CountryChoice icon={MapPin} title={t('atlas.markVisited')} hint={t('atlas.markVisitedHint')} onClick={markCountry} />
|
||||
<CountryChoice icon={Star} tone="text-warning" title={t('atlas.addToBucket')} hint={t('atlas.addToBucketHint')}
|
||||
onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })} />
|
||||
{onWishlist && (
|
||||
<CountryChoice icon={Trash2} tone="text-danger" title={t('atlas.removeFromBucket')} hint={t('atlas.removeFromBucketHint')} onClick={removeBucket} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'choose-region' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<CountryChoice icon={MapPin} title={t('atlas.markVisited')} hint={t('atlas.markRegionVisitedHint')} onClick={() => markRegion()} />
|
||||
<CountryChoice icon={Star} tone="text-warning" title={t('atlas.addToBucket')} hint={t('atlas.addToBucketHint')}
|
||||
onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'unmark' && (
|
||||
<p className="m-0 text-content-secondary" style={fs(13.5, 'body')}>{t('atlas.confirmUnmark')}</p>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'unmark-region' && (
|
||||
<p className="m-0 text-content-secondary" style={fs(13.5, 'body')}>{t('atlas.confirmUnmarkRegion')}</p>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'mark' && (
|
||||
<p className="m-0 text-content-secondary" style={fs(13.5, 'body')}>{t('atlas.confirmMark')}</p>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'bucket' && (
|
||||
<>
|
||||
<p className="m-0 text-content-secondary" style={fs(13.5, 'body')}>{t('atlas.bucketWhen')}</p>
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('atlas.month')} htmlFor={BUCKET_MONTH_ID}>
|
||||
<CustomSelect
|
||||
id={BUCKET_MONTH_ID}
|
||||
value={String(bucketMonth)}
|
||||
onChange={v => 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"
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label={t('atlas.year')} htmlFor={BUCKET_YEAR_ID}>
|
||||
<CustomSelect
|
||||
id={BUCKET_YEAR_ID}
|
||||
value={String(bucketYear)}
|
||||
onChange={v => 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"
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -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<GeoJsonFeatureCollection | null>(null)
|
||||
const [visitedRegions, setVisitedRegions] = useState<Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean; status?: VisitStatus }[]>>({})
|
||||
const [visitedRegions, setVisitedRegions] = useState<VisitedRegionMap>({})
|
||||
const [pluginLayers, setPluginLayers] = useState<PluginAtlasLayer[]>([])
|
||||
const pluginLayerRef = useRef<L.GeoJSON | null>(null)
|
||||
const regionLayerRef = useRef<L.GeoJSON | null>(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<typeof setConfirmAction>(() => {})
|
||||
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<AtlasConfirmAction | null>(null)
|
||||
const [bucketMonth, setBucketMonth] = useState(0)
|
||||
const [bucketYear, setBucketYear] = useState(0)
|
||||
|
||||
|
||||
@@ -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<NonNullable<Window['__addToast']>>;
|
||||
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<void>;
|
||||
}
|
||||
|
||||
/** 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<AtlasConfirmAction | null>(init.confirmAction);
|
||||
const [data, setData] = useState<AtlasData | null>(init.data ?? buildAtlasData());
|
||||
const [visitedRegions, setVisitedRegions] = useState<VisitedRegionMap>(init.visitedRegions ?? {});
|
||||
const [bucketList, setBucketList] = useState<BucketItem[]>(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<NonNullable<Window['__addToast']>>();
|
||||
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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, VisitedRegion[]>;
|
||||
|
||||
type SetState<T> = React.Dispatch<React.SetStateAction<T>>;
|
||||
|
||||
export interface AtlasCountryActionsOptions {
|
||||
t: TranslationFn;
|
||||
confirmAction: AtlasConfirmAction | null;
|
||||
setConfirmAction: SetState<AtlasConfirmAction | null>;
|
||||
setData: SetState<AtlasData | null>;
|
||||
visitedRegions: VisitedRegionMap;
|
||||
setVisitedRegions: SetState<VisitedRegionMap>;
|
||||
bucketList: BucketItem[];
|
||||
setBucketList: SetState<BucketItem[]>;
|
||||
handleDeleteBucketItem: (id: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/** 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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user