mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
feat(collections): import a whole trip's places into a list (#1903)
* feat(collections): preview which of a trip's places a list already holds The bulk import endpoint has been there since the select-mode copy, but nothing calls it and it only reports duplicates after the fact. A dialog needs the verdict up front, so /:id/importable/:tripId returns the trip's places with the duplicate check the import itself applies, plus whether any day holds the place. * feat(collections): import a whole trip's places into a list Two steps: pick a trip, then tick its places. Places already on the list are greyed out and unpickable, and the ones no day holds start selected — those are the plans a trip left behind, which is the reason to import at all. Sorted so a mostly-saved trip reads as the two that are left rather than a wall of grey. * feat(collections): reach the trip import from the filter row and an empty list * i18n(collections): translate the trip import into all locales * fix(collections): stop the label dialog's buttons wrapping mid-word Three actions in a max-w-sm footer wrapped into two lines each. The assign button also painted white text on the accent, which disappears on a light one. * docs(collections): document the trip import
This commit is contained in:
@@ -39,7 +39,7 @@ export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign,
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.assignN', { count })} size="sm">
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.assignN', { count })} size="lg">
|
||||
{labels.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<Tags size={26} className="text-content-faint" />
|
||||
@@ -67,18 +67,20 @@ export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign,
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* nowrap on all three: the labels are long in several locales and used to
|
||||
wrap into two lines inside the buttons before the dialog got wider. */}
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-edge">
|
||||
<button type="button" onClick={onManage} className="mr-auto flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
|
||||
<button type="button" onClick={onManage} className="mr-auto flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] text-content-secondary whitespace-nowrap hover:bg-surface-hover">
|
||||
<Settings2 size={14} /> {t('collections.labels.manage')}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
|
||||
<button type="button" onClick={onClose} className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] whitespace-nowrap hover:bg-surface-hover">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={assign}
|
||||
disabled={picked.length === 0 || busy}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50"
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold whitespace-nowrap hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('collections.labels.assign')}
|
||||
</button>
|
||||
|
||||
@@ -41,6 +41,8 @@ function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
|
||||
onLabelFilter: vi.fn(),
|
||||
canManageLabels: false,
|
||||
onManageLabels: vi.fn(),
|
||||
canImport: false,
|
||||
onImport: vi.fn(),
|
||||
showSelect: true,
|
||||
selectMode: false,
|
||||
onToggleSelect: vi.fn(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Check, Layers, Tag, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react'
|
||||
import { ChevronDown, Check, Layers, Tag, CheckSquare, Star, Plus, ArrowDownUp, DownloadCloud } from 'lucide-react'
|
||||
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
@@ -87,6 +87,10 @@ interface CollectionFilterBarProps {
|
||||
showSelect: boolean
|
||||
selectMode: boolean
|
||||
onToggleSelect: () => void
|
||||
/** Bulk import from a trip — only on a real list, never the "All saved" union,
|
||||
* which has no single destination to import into. */
|
||||
canImport: boolean
|
||||
onImport: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
@@ -100,7 +104,7 @@ export default function CollectionFilterBar({
|
||||
onStatusFilter, onCategoryFilter, onRatingFilter, onSortMode,
|
||||
canAddPlace, onAddPlace,
|
||||
showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels,
|
||||
showSelect, selectMode, onToggleSelect, t,
|
||||
showSelect, selectMode, onToggleSelect, canImport, onImport, t,
|
||||
}: CollectionFilterBarProps): React.ReactElement {
|
||||
const statusOpts: Opt[] = [
|
||||
{ key: 'all', label: t('common.all'), count: counts.all },
|
||||
@@ -160,6 +164,17 @@ export default function CollectionFilterBar({
|
||||
<CheckSquare size={15} />
|
||||
</button>
|
||||
)}
|
||||
{canImport && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImport}
|
||||
className="col-filter-btn col-filter-icon"
|
||||
aria-label={t('collections.importFromTrip')}
|
||||
title={t('collections.importFromTrip')}
|
||||
>
|
||||
<DownloadCloud size={15} />
|
||||
</button>
|
||||
)}
|
||||
{showLabels && (labelOptions.length > 0 || canManageLabels) && (
|
||||
<CollectionLabelFilter
|
||||
labelOptions={labelOptions}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// FE-COMP-COLIMPORT-001 to FE-COMP-COLIMPORT-007
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import ImportFromTripModal from './ImportFromTripModal';
|
||||
|
||||
const BASE = '/api/addons/collections';
|
||||
|
||||
const TRIPS = [
|
||||
{ id: 7, user_id: 1, title: 'Rome 2026', currency: 'EUR', is_archived: 0, reminder_days: 3, start_date: '2026-05-01', end_date: '2026-05-08', place_count: 3, cover_image: null },
|
||||
{ id: 9, user_id: 1, title: 'Lisbon', currency: 'EUR', is_archived: 0, reminder_days: 3, place_count: 0, cover_image: null },
|
||||
];
|
||||
|
||||
const place = (over: Record<string, unknown>) => ({
|
||||
place_id: 1, name: 'Somewhere', address: null, lat: null, lng: null, category_id: null,
|
||||
image_url: null, already_in_list: false, scheduled: false, day_number: null, date: null, ...over,
|
||||
});
|
||||
|
||||
// Colosseum sits on day 2, the market was never scheduled, the Pantheon is already saved.
|
||||
const IMPORTABLE = [
|
||||
place({ place_id: 11, name: 'Colosseum', scheduled: true, day_number: 2, date: '2026-05-02' }),
|
||||
place({ place_id: 12, name: 'Testaccio Market' }),
|
||||
place({ place_id: 13, name: 'Pantheon', already_in_list: true, scheduled: true, day_number: 1 }),
|
||||
];
|
||||
|
||||
function Harness(props: { onImported?: () => void; onClose?: () => void }): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ImportFromTripModal
|
||||
isOpen
|
||||
collectionId={4}
|
||||
collectionName="Italy"
|
||||
categories={[]}
|
||||
onClose={props.onClose ?? (() => {})}
|
||||
onImported={props.onImported ?? (() => {})}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useHandlers(importable = IMPORTABLE, onPost?: (body: unknown) => void) {
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: TRIPS })),
|
||||
http.get(`${BASE}/4/importable/7`, () => HttpResponse.json({ places: importable })),
|
||||
http.post(`${BASE}/places/from-trip-many`, async ({ request }) => {
|
||||
onPost?.(await request.json());
|
||||
return HttpResponse.json({ copied: 2, skipped: [{ id: 13, name: 'Pantheon' }] });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function openTrip(user: ReturnType<typeof userEvent.setup>) {
|
||||
await waitFor(() => expect(screen.getByText('Rome 2026')).toBeInTheDocument());
|
||||
await user.click(screen.getByText('Rome 2026'));
|
||||
await waitFor(() => expect(screen.getByText('Colosseum')).toBeInTheDocument());
|
||||
}
|
||||
|
||||
describe('ImportFromTripModal', () => {
|
||||
beforeEach(() => useHandlers());
|
||||
|
||||
it('FE-COMP-COLIMPORT-001: lists the trips with their place count and a localized date range', async () => {
|
||||
render(<Harness />);
|
||||
await waitFor(() => expect(screen.getByText('Rome 2026')).toBeInTheDocument());
|
||||
// The count is a compact badge; the full wording stays as its title.
|
||||
expect(screen.getByTitle('3 places')).toHaveTextContent('3');
|
||||
// Formatted through the shared formatter, never the raw ISO string.
|
||||
const dateBadge = screen.getByTitle(/May/);
|
||||
expect(dateBadge.textContent).toMatch(/May 1.*–.*May 8/);
|
||||
expect(dateBadge.textContent).not.toMatch(/2026-05-01/);
|
||||
expect(screen.getByText('Lisbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLIMPORT-002: pre-selects the unscheduled place, not the scheduled one', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
await openTrip(user);
|
||||
|
||||
// Only Testaccio Market is both new and dayless.
|
||||
expect(screen.getByRole('button', { name: /Import 1/ })).toBeInTheDocument();
|
||||
expect(screen.getByText('1 selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLIMPORT-003: marks a place already on the list and refuses to select it', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
await openTrip(user);
|
||||
|
||||
const row = screen.getByText('Pantheon').closest('button')!;
|
||||
expect(within(row).getByText('Already saved')).toBeInTheDocument();
|
||||
expect(row).toBeDisabled();
|
||||
|
||||
await user.click(row);
|
||||
expect(screen.getByText('1 selected')).toBeInTheDocument(); // unchanged
|
||||
});
|
||||
|
||||
it('FE-COMP-COLIMPORT-004: shows the day a scheduled place sits on', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
await openTrip(user);
|
||||
|
||||
const row = screen.getByText('Colosseum').closest('button')!;
|
||||
expect(within(row).getByText('Day 2')).toBeInTheDocument();
|
||||
expect(within(screen.getByText('Testaccio Market').closest('button')!).getByText('Unscheduled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLIMPORT-005: only the selected ids are sent, and the result is reported', async () => {
|
||||
const onPost = vi.fn();
|
||||
const onImported = vi.fn();
|
||||
useHandlers(IMPORTABLE, onPost);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness onImported={onImported} />);
|
||||
await openTrip(user);
|
||||
|
||||
// Add the scheduled one to the pre-selected leftover.
|
||||
await user.click(screen.getByText('Colosseum').closest('button')!);
|
||||
await user.click(screen.getByRole('button', { name: /Import 2/ }));
|
||||
|
||||
await waitFor(() => expect(onPost).toHaveBeenCalled());
|
||||
const body = onPost.mock.calls[0][0] as { source_place_ids: number[]; source_trip_id: number };
|
||||
expect(body.source_trip_id).toBe(7);
|
||||
expect([...body.source_place_ids].sort()).toEqual([11, 12]);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('2 places added')).toBeInTheDocument());
|
||||
expect(screen.getByText(/1 were already on the list/)).toBeInTheDocument();
|
||||
expect(onImported).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLIMPORT-006: the only-new filter hides what is already saved', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
await openTrip(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Only new' }));
|
||||
await waitFor(() => expect(screen.queryByText('Pantheon')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('Colosseum')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLIMPORT-007: a trip whose places are all saved says so instead of offering an empty list', async () => {
|
||||
useHandlers([place({ place_id: 13, name: 'Pantheon', already_in_list: true })]);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
await waitFor(() => expect(screen.getByText('Rome 2026')).toBeInTheDocument());
|
||||
await user.click(screen.getByText('Rome 2026'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/already on this list/)).toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: /Import 0/ })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowLeft, Calendar, Check, ChevronRight, Loader2, MapPin, Map as MapIcon, Search, Sparkles } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import type { CollectionImportablePlace, Trip } from '@trek/shared'
|
||||
|
||||
interface ImportFromTripModalProps {
|
||||
isOpen: boolean
|
||||
collectionId: number
|
||||
collectionName: string
|
||||
categories: Category[]
|
||||
onClose: () => void
|
||||
onImported: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
type Step = 'trip' | 'places' | 'done'
|
||||
|
||||
/** Rows sort new-and-unscheduled first, then the rest of the new ones, and the
|
||||
* already-saved ones last. A trip whose places are mostly saved already then reads
|
||||
* as "here are the two left over" instead of a wall of greyed-out rows. */
|
||||
function importOrder(p: CollectionImportablePlace): number {
|
||||
if (p.already_in_list) return 2
|
||||
return p.scheduled ? 1 : 0
|
||||
}
|
||||
|
||||
/** Dates go through the shared formatter, so they follow the reader's locale
|
||||
* (and drop the year when it is the current one) instead of showing raw ISO. */
|
||||
function tripRange(trip: Trip, locale: string): string | null {
|
||||
const from = formatDate(trip.start_date, locale)
|
||||
const to = formatDate(trip.end_date, locale)
|
||||
if (from && to && to !== from) return `${from} – ${to}`
|
||||
return from || to || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk import of a trip's places into the current list.
|
||||
*
|
||||
* Two steps: pick a trip, then pick its places. The duplicate verdict comes from
|
||||
* the server (`/importable/:tripId`) rather than a second comparison here, so a row
|
||||
* shown as new can never come back as skipped. Places no day holds are pre-selected:
|
||||
* those are the ones a trip left behind, which is what this import is for.
|
||||
*/
|
||||
export default function ImportFromTripModal({ isOpen, collectionId, collectionName, categories, onClose, onImported, t }: ImportFromTripModalProps): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const { language } = useTranslation()
|
||||
const [step, setStep] = useState<Step>('trip')
|
||||
const [trips, setTrips] = useState<Trip[]>([])
|
||||
const [tripsLoading, setTripsLoading] = useState(false)
|
||||
const [trip, setTrip] = useState<Trip | null>(null)
|
||||
const [items, setItems] = useState<CollectionImportablePlace[]>([])
|
||||
const [itemsLoading, setItemsLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const [onlyNew, setOnlyNew] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [result, setResult] = useState<{ copied: number; skipped: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setStep('trip'); setTrip(null); setItems([]); setSelected(new Set()); setQuery(''); setOnlyNew(false); setResult(null)
|
||||
setTripsLoading(true)
|
||||
tripsApi.list()
|
||||
.then((data: { trips?: Trip[] }) => setTrips(data.trips ?? []))
|
||||
.catch(err => toast.error(getApiErrorMessage(err, t('common.error'))))
|
||||
.finally(() => setTripsLoading(false))
|
||||
// toast/t are stable enough for a mount effect; re-running on them would refetch on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen])
|
||||
|
||||
const pickTrip = async (chosen: Trip) => {
|
||||
setTrip(chosen); setStep('places'); setItemsLoading(true); setQuery('')
|
||||
try {
|
||||
const data = await collectionsApi.importable(collectionId, chosen.id)
|
||||
setItems(data.places)
|
||||
// Unscheduled places are the point of the import, so they start selected. A trip
|
||||
// where everything was scheduled would otherwise open with an empty selection, so
|
||||
// fall back to every new place there.
|
||||
const fresh = data.places.filter(p => !p.already_in_list)
|
||||
const leftovers = fresh.filter(p => !p.scheduled)
|
||||
setSelected(new Set((leftovers.length ? leftovers : fresh).map(p => p.place_id)))
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
setStep('trip')
|
||||
} finally {
|
||||
setItemsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const newCount = useMemo(() => items.filter(p => !p.already_in_list).length, [items])
|
||||
const savedCount = items.length - newCount
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return items
|
||||
.filter(p => (onlyNew ? !p.already_in_list : true))
|
||||
.filter(p => !q || p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q))
|
||||
.sort((a, b) => importOrder(a) - importOrder(b) || a.name.localeCompare(b.name))
|
||||
}, [items, onlyNew, query])
|
||||
|
||||
const selectableVisible = visible.filter(p => !p.already_in_list)
|
||||
const allVisibleSelected = selectableVisible.length > 0 && selectableVisible.every(p => selected.has(p.place_id))
|
||||
|
||||
const toggle = (id: number) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (!next.delete(id)) next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAllVisible = () => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (allVisibleSelected) selectableVisible.forEach(p => next.delete(p.place_id))
|
||||
else selectableVisible.forEach(p => next.add(p.place_id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const runImport = async () => {
|
||||
if (!trip || selected.size === 0) return
|
||||
setImporting(true)
|
||||
try {
|
||||
const res = await collectionsApi.saveFromTripMany(collectionId, trip.id, [...selected])
|
||||
setResult({ copied: res.copied, skipped: res.skipped.length })
|
||||
setStep('done')
|
||||
onImported()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const categoryById = useMemo(() => new Map(categories.map(c => [c.id, c])), [categories])
|
||||
|
||||
const title = step === 'places' && trip
|
||||
? trip.title
|
||||
: step === 'done'
|
||||
? t('collections.importDoneTitle')
|
||||
: t('collections.importTitle')
|
||||
|
||||
const footer = step === 'done'
|
||||
? (
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={onClose} className="px-4 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold">{t('common.close')}</button>
|
||||
</div>
|
||||
)
|
||||
: step === 'places'
|
||||
? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[12.5px] text-content-faint">
|
||||
{t('collections.importSelectedCount', { count: selected.size })}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runImport}
|
||||
disabled={importing || selected.size === 0}
|
||||
className="px-4 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-1.5"
|
||||
>
|
||||
{importing ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
{t('collections.importAction', { count: selected.size })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="xl" footer={footer}>
|
||||
<div className="col-imp">
|
||||
{/* ── Step 1: which trip ─────────────────────────────────────────── */}
|
||||
{step === 'trip' && (
|
||||
<>
|
||||
<p className="col-imp-lead">{t('collections.importPickTripHint', { name: collectionName })}</p>
|
||||
{tripsLoading ? (
|
||||
<div className="col-imp-grid">
|
||||
{[0, 1, 2, 3].map(i => <div key={i} className="col-imp-trip trek-skeleton" style={{ height: 68 }} />)}
|
||||
</div>
|
||||
) : trips.length === 0 ? (
|
||||
<div className="col-imp-empty">
|
||||
<MapIcon size={22} />
|
||||
<span>{t('collections.importNoTrips')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-imp-grid trek-stagger">
|
||||
{trips.map(tr => {
|
||||
const range = tripRange(tr, language)
|
||||
const places = tr.place_count ?? 0
|
||||
return (
|
||||
<button key={tr.id} type="button" className="col-imp-trip" onClick={() => pickTrip(tr)}>
|
||||
<span className="col-imp-trip-cover">
|
||||
{tr.cover_image
|
||||
? <img src={tr.cover_image} alt="" loading="lazy" />
|
||||
: <MapIcon size={16} />}
|
||||
</span>
|
||||
<span className="col-imp-trip-body">
|
||||
<span className="col-imp-trip-name">{tr.title}</span>
|
||||
{/* Badges rather than one run-on line: the tile is half the dialog
|
||||
wide, and a date range plus a place count ran out of room and
|
||||
got clipped mid-word. Each pill can now wrap as a unit. */}
|
||||
<span className="col-imp-trip-meta">
|
||||
{range && (
|
||||
<span className="col-imp-pill" title={range}>
|
||||
<Calendar size={10} />{range}
|
||||
</span>
|
||||
)}
|
||||
<span className="col-imp-pill num" title={t('collections.importPlacesCount', { count: places })}>
|
||||
<MapPin size={10} />{places}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight size={16} className="col-imp-trip-go" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: which places ───────────────────────────────────────── */}
|
||||
{step === 'places' && (
|
||||
<>
|
||||
<div className="col-imp-bar">
|
||||
<button type="button" className="col-imp-back" onClick={() => setStep('trip')} aria-label={t('common.back')}>
|
||||
<ArrowLeft size={15} />
|
||||
</button>
|
||||
<div className="col-imp-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('collections.importSearchPlaces')}
|
||||
aria-label={t('collections.importSearchPlaces')}
|
||||
/>
|
||||
</div>
|
||||
{savedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={`col-imp-chip${onlyNew ? ' on' : ''}`}
|
||||
aria-pressed={onlyNew}
|
||||
onClick={() => setOnlyNew(v => !v)}
|
||||
>
|
||||
<Sparkles size={12} />{t('collections.importOnlyNew')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="col-imp-chip"
|
||||
onClick={toggleAllVisible}
|
||||
disabled={selectableVisible.length === 0}
|
||||
>
|
||||
{allVisibleSelected ? t('collections.importClearSelection') : t('collections.importSelectAll')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{itemsLoading ? (
|
||||
<div className="col-imp-list">
|
||||
{[0, 1, 2, 3, 4].map(i => <div key={i} className="col-imp-row trek-skeleton" style={{ height: 56 }} />)}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="col-imp-empty">
|
||||
<MapPin size={22} />
|
||||
<span>{t('collections.importEmptyTrip')}</span>
|
||||
</div>
|
||||
) : newCount === 0 ? (
|
||||
<div className="col-imp-empty col-imp-empty-good">
|
||||
<Check size={22} />
|
||||
<span>{t('collections.importNothingNew')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-imp-list trek-stagger">
|
||||
{visible.map(p => {
|
||||
const dup = p.already_in_list
|
||||
const on = selected.has(p.place_id)
|
||||
const cat = p.category_id != null ? categoryById.get(p.category_id) : undefined
|
||||
const Icon = getCategoryIcon(cat?.icon ?? undefined)
|
||||
return (
|
||||
<button
|
||||
key={p.place_id}
|
||||
type="button"
|
||||
className={`col-imp-row${on ? ' on' : ''}${dup ? ' dup' : ''}`}
|
||||
onClick={() => !dup && toggle(p.place_id)}
|
||||
disabled={dup}
|
||||
aria-pressed={dup ? undefined : on}
|
||||
>
|
||||
<span className="col-imp-box">{on && <Check size={12} strokeWidth={3} />}</span>
|
||||
<span className="col-imp-ico" style={cat?.color ? { color: cat.color } : undefined}>
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
<span className="col-imp-txt">
|
||||
<span className="nm">{p.name}</span>
|
||||
{p.address && <span className="ad">{p.address}</span>}
|
||||
</span>
|
||||
<span className={`col-imp-tag${dup ? ' saved' : p.scheduled ? '' : ' fresh'}`}>
|
||||
{dup
|
||||
? t('collections.importAlreadySaved')
|
||||
: p.scheduled
|
||||
? t('collections.importOnDay', { count: p.day_number ?? 0 })
|
||||
: t('collections.importUnscheduled')}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: what happened ──────────────────────────────────────── */}
|
||||
{step === 'done' && result && (
|
||||
<div className="col-imp-done">
|
||||
<span className="col-imp-done-ring"><Check size={26} strokeWidth={2.5} /></span>
|
||||
<span className="col-imp-done-t">{t('collections.importDone', { count: result.copied })}</span>
|
||||
<span className="col-imp-done-s">
|
||||
{result.skipped > 0
|
||||
? t('collections.importDoneSkipped', { count: result.skipped })
|
||||
: t('collections.importDoneClean', { name: collectionName })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user