diff --git a/client/src/components/Journey/JourneyDetailPageAddTripDialog.tsx b/client/src/components/Journey/JourneyDetailPageAddTripDialog.tsx index 4de598184..786f73657 100644 --- a/client/src/components/Journey/JourneyDetailPageAddTripDialog.tsx +++ b/client/src/components/Journey/JourneyDetailPageAddTripDialog.tsx @@ -1,11 +1,10 @@ -import { useEffect, useId, useState } from 'react' +import { useId, useState } from 'react' import { Link2, Search } from 'lucide-react' import { useTranslation } from '../../i18n' -import { journeyApi } from '../../api/client' -import { useToast } from '../shared/Toast' import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import { INPUT } from '../shared/dialogParts' import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers' +import { useJourneyTripLinking } from './useJourneySettings' export function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: { journeyId: number @@ -15,14 +14,9 @@ export function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: }) { const { t } = useTranslation() const labelId = useId() - const [trips, setTrips] = useState<{ id: number; title: string; destination?: string; start_date?: string; end_date?: string }[]>([]) const [search, setSearch] = useState('') - const [adding, setAdding] = useState(null) - const toast = useToast() - - useEffect(() => { - journeyApi.availableTrips().then(d => setTrips(d.trips || [])).catch(() => {}) - }, []) + const { availableTrips: trips, linkingTripId: adding, linkTrip: handleAdd } = + useJourneyTripLinking({ journeyId, onLinked: onAdded, loadOnMount: true }) const filtered = trips.filter(trip => { if (existingTripIds.includes(trip.id)) return false @@ -31,19 +25,6 @@ export function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: return trip.title.toLowerCase().includes(q) || (trip.destination || '').toLowerCase().includes(q) }) - const handleAdd = async (tripId: number) => { - setAdding(tripId) - try { - await journeyApi.addTrip(journeyId, tripId) - toast.success(t('journey.trips.tripLinked')) - onAdded() - } catch { - toast.error(t('journey.trips.linkFailed')) - } finally { - setAdding(null) - } - } - return ( (null) const [showDiscardConfirm, setShowDiscardConfirm] = useState(false) - const isDirty = title !== journey.title || subtitle !== (journey.subtitle || '') const handleClose = () => { if (isDirty) setShowDiscardConfirm(true); else onClose() } const coverRef = useRef(null) - const toast = useToast() - const navigate = useNavigate() - const { updateJourney, deleteJourney } = useJourneyStore() - - const handleSave = async () => { - setSaving(true) - try { - await updateJourney(journey.id, { title, subtitle: subtitle || null }) - onSaved() - } catch { - toast.error(t('journey.settings.saveFailed')) - } finally { - setSaving(false) - } - } - - const handleCoverUpload = async (e: ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - const formData = new FormData() - formData.append('cover', await normalizeImageFile(file)) - try { - await journeyApi.uploadCover(journey.id, formData) - toast.success(t('journey.settings.coverUpdated')) - onSaved() - } catch { - toast.error(t('journey.settings.coverFailed')) - } - } - - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - const [archiving, setArchiving] = useState(false) - - const handleArchiveToggle = async () => { - setArchiving(true) - try { - const newStatus = journey.status === 'archived' ? 'active' : 'archived' - await updateJourney(journey.id, { status: newStatus }) - toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened')) - onSaved() - } catch { - toast.error(t('journey.settings.saveFailed')) - } finally { - setArchiving(false) - } - } - - // Saved on the spot rather than on Save, like the archive switch above it: - // it is a view setting, and the point of it is seeing the map change (#2194). - const [savingTracks, setSavingTracks] = useState(false) - const handleTracksToggle = async () => { - setSavingTracks(true) - try { - await updateJourney(journey.id, { show_trip_tracks: !journey.show_trip_tracks }) - // onRefresh, not onSaved: onSaved closes the dialog, which would destroy the - // switch the moment it is flipped AND skip handleClose's unsaved-changes - // guard, silently dropping a title the owner had typed but not saved yet. - onRefresh() - } catch { - toast.error(t('journey.settings.saveFailed')) - } finally { - setSavingTracks(false) - } - } - - /** - * Turn one of the optional entry fields off for this journey. - * - * Saved on the spot for the same reason the tracks switch is, and through - * `onRefresh` rather than `onSaved` so flipping a switch does not close the - * dialog out from under a half-typed title. - * - * Nothing is erased: an entry that already carries a mood keeps it in the - * database, the form simply stops asking. Switching back on brings it into - * view again. - */ - const [savingField, setSavingField] = useState(null) - const handleFieldToggle = async (field: 'show_verdict' | 'show_mood' | 'show_weather') => { - setSavingField(field) - try { - await updateJourney(journey.id, { [field]: journey[field] === 0 }) - onRefresh() - } catch { - toast.error(t('journey.settings.saveFailed')) - } finally { - setSavingField(null) - } - } - - // Entries placed from their photos (#1003), saved on the spot like the tracks switch. - const [savingPhotoLocation, setSavingPhotoLocation] = useState(false) - const handlePhotoLocationToggle = async () => { - setSavingPhotoLocation(true) - try { - await updateJourney(journey.id, { photo_location: !journey.photo_location }) - onRefresh() - } catch { - toast.error(t('journey.settings.saveFailed')) - } finally { - setSavingPhotoLocation(false) - } - } - - // The state shown for the journey, set by hand (#762). Saved on the spot like - // the switches; 'auto' stores null and hands it back to the trip dates. - const [savingStatus, setSavingStatus] = useState(false) - const statusChoice = journey.status_override ?? 'auto' - const handleStatusChange = async (next: 'auto' | 'draft' | 'live' | 'completed') => { - if (next === statusChoice || savingStatus) return - setSavingStatus(true) - try { - await updateJourney(journey.id, { status_override: next === 'auto' ? null : next }) - onRefresh() - } catch { - toast.error(t('journey.settings.saveFailed')) - } finally { - setSavingStatus(false) - } - } - - const handleDelete = async () => { - try { - await deleteJourney(journey.id) - navigate('/journey') - } catch { - toast.error(t('journey.settings.failedToDelete')) - } - } - - const handleRemoveContributor = async (c: JourneyContributor) => { - if (!window.confirm(t('journey.contributors.removeConfirm', { username: c.username }))) return - try { - await journeyApi.removeContributor(journey.id, c.user_id) - toast.success(t('journey.contributors.removed')) - onRefresh() - } catch { - toast.error(t('journey.contributors.removeFailed')) - } - } const archived = journey.status === 'archived' const archiveLabel = archived ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney') @@ -428,17 +286,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, setUnlinkTarget(null)} - onConfirm={async () => { - if (!unlinkTarget) return - try { - await journeyApi.removeTrip(journey.id, unlinkTarget.trip_id) - toast.success(t('journey.trips.tripUnlinked')) - setUnlinkTarget(null) - onSaved() - } catch { - toast.error(t('journey.trips.unlinkFailed')) - } - }} + onConfirm={confirmUnlink} title={t('journey.trips.unlinkTrip')} message={t('journey.trips.unlinkMessage', { title: unlinkTarget?.title })} confirmLabel={t('journey.trips.unlink')} diff --git a/client/src/components/Journey/JourneyShareSection.tsx b/client/src/components/Journey/JourneyShareSection.tsx index 675c255e1..24a38bef3 100644 --- a/client/src/components/Journey/JourneyShareSection.tsx +++ b/client/src/components/Journey/JourneyShareSection.tsx @@ -1,64 +1,10 @@ -import { useEffect, useState } from 'react' import { Link, List, Grid, MapPin, Check } from 'lucide-react' -import { journeyApi } from '../../api/client' import { useTranslation } from '../../i18n' -import { useToast } from '../shared/Toast' -import { copyText } from '../../utils/clipboard' +import { useJourneyShareLink } from './useJourneyShareLink' export default function JourneyShareSection({ journeyId }: { journeyId: number }) { const { t } = useTranslation() - const [link, setLink] = useState<{ token: string; share_timeline: boolean; share_gallery: boolean; share_map: boolean } | null>(null) - const [loading, setLoading] = useState(true) - const [copied, setCopied] = useState(false) - /** The share token is the owner's to manage; a contributor is refused. */ - const [manageable, setManageable] = useState(true) - const toast = useToast() - - useEffect(() => { - journeyApi.getShareLink(journeyId) - .then(d => setLink(d.link || null)) - .catch((err: { response?: { status?: number } }) => { - // A 403 is the server saying this is not yours to manage. Showing the - // section anyway would offer an editor a "create link" button that is - // refused the moment they press it. - if (err?.response?.status === 403) setManageable(false) - }) - .finally(() => setLoading(false)) - }, [journeyId]) - - const createLink = async () => { - try { - const res = await journeyApi.createShareLink(journeyId, { share_timeline: true, share_gallery: true, share_map: true }) - setLink({ token: res.token, share_timeline: true, share_gallery: true, share_map: true }) - toast.success(t('journey.share.linkCreated')) - } catch { toast.error(t('journey.share.createFailed')) } - } - - const togglePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => { - if (!link) return - const previous = link - const updated = { ...link, [key]: !link[key] } - setLink(updated) - try { - await journeyApi.createShareLink(journeyId, { share_timeline: updated.share_timeline, share_gallery: updated.share_gallery, share_map: updated.share_map }) - } catch { setLink(previous); toast.error(t('journey.share.updateFailed')) } - } - - const deleteLink = async () => { - try { - await journeyApi.deleteShareLink(journeyId) - setLink(null) - toast.success(t('journey.share.linkDeleted')) - } catch { toast.error(t('journey.share.deleteFailed')) } - } - - const shareUrl = link ? `${window.location.origin}/public/journey/${link.token}` : '' - - const copyLink = async () => { - if (!(await copyText(shareUrl))) return - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } + const { link, loading, manageable, copied, shareUrl, createLink, togglePerm, deleteLink, copyLink } = useJourneyShareLink(journeyId) if (loading || !manageable) return null diff --git a/client/src/components/Journey/useJourneySettings.test.ts b/client/src/components/Journey/useJourneySettings.test.ts new file mode 100644 index 000000000..731591d0f --- /dev/null +++ b/client/src/components/Journey/useJourneySettings.test.ts @@ -0,0 +1,252 @@ +// FE-JRN-SETTINGSHOOK-001 to FE-JRN-SETTINGSHOOK-014: the journey settings logic behind +// both the desktop dialog and the phone sheet, and the trip linking behind the desktop +// add trip dialog and the phone sheet. +import { act, renderHook, waitFor } from '@testing-library/react'; +import type React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { journeyApi } from '../../api/client'; +import { useJourneyStore, type JourneyContributor, type JourneyDetail } from '../../store/journeyStore'; +import { useJourneySettings, useJourneyTripLinking, type JourneySettingsOptions } from './useJourneySettings'; + +const mockNavigate = vi.fn(); +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + return { ...actual, useNavigate: () => mockNavigate }; +}); +vi.mock('../../i18n', () => ({ + useTranslation: () => ({ + t: (k: string, p?: Record) => (p ? `${k}:${JSON.stringify(p)}` : k), + }), +})); +vi.mock('../../utils/convertHeic', () => ({ normalizeImageFile: (f: File) => Promise.resolve(f) })); + +const updateJourney = vi.fn(async (_id: number, _data: Record) => {}); +const deleteJourney = vi.fn(async (_id: number) => {}); +let addToast: Mock>; + +function buildJourney(overrides: Partial = {}): JourneyDetail { + return { + id: 3, + user_id: 1, + title: 'Italy 2026', + subtitle: 'Rome', + status: 'active', + cover_image: null, + cover_gradient: null, + created_at: 0, + updated_at: 0, + entries: [], + gallery: [], + trips: [], + contributors: [], + stats: { entries: 0, photos: 0, places: 0 }, + ...overrides, + }; +} + +function setup(over: Partial = {}) { + const props: JourneySettingsOptions = { + journey: buildJourney(), + onSaved: vi.fn(), + onRefresh: vi.fn(), + onContentChanged: vi.fn(), + ...over, + }; + const hook = renderHook((p: JourneySettingsOptions) => useJourneySettings(p), { initialProps: props }); + return { ...hook, props }; +} + +function fileEvent(file: File | undefined) { + return { target: { files: file ? [file] : [] } } as unknown as React.ChangeEvent; +} + +beforeEach(() => { + updateJourney.mockReset().mockResolvedValue(undefined); + deleteJourney.mockReset().mockResolvedValue(undefined); + mockNavigate.mockClear(); + useJourneyStore.setState({ updateJourney, deleteJourney }); + addToast = vi.fn>(); + window.__addToast = addToast; +}); + +afterEach(() => { + vi.restoreAllMocks(); + delete window.__addToast; +}); + +describe('useJourneySettings', () => { + it('FE-JRN-SETTINGSHOOK-001: seeds the name, tracks dirtiness and saves it', async () => { + const { result, props } = setup(); + expect(result.current.isDirty).toBe(false); + act(() => { + result.current.setTitle('Italy 2027'); + result.current.setSubtitle(''); + }); + expect(result.current.isDirty).toBe(true); + await act(() => result.current.handleSave()); + expect(updateJourney).toHaveBeenCalledWith(3, { title: 'Italy 2027', subtitle: null }); + expect(props.onSaved).toHaveBeenCalledTimes(1); + expect(result.current.saving).toBe(false); + }); + + it('FE-JRN-SETTINGSHOOK-002: a failed save toasts and keeps the shell open', async () => { + updateJourney.mockRejectedValueOnce(new Error('x')); + const { result, props } = setup(); + await act(() => result.current.handleSave()); + expect(addToast).toHaveBeenCalledWith('journey.settings.saveFailed', 'error', undefined); + expect(props.onSaved).not.toHaveBeenCalled(); + }); + + it('FE-JRN-SETTINGSHOOK-003: a new cover goes up and hands over to onContentChanged', async () => { + const upload = vi.spyOn(journeyApi, 'uploadCover').mockResolvedValueOnce({}).mockRejectedValueOnce(new Error('x')); + const { result, props } = setup(); + await act(() => result.current.handleCoverUpload(fileEvent(undefined))); + expect(upload).not.toHaveBeenCalled(); + + const file = new File(['x'], 'c.png'); + await act(() => result.current.handleCoverUpload(fileEvent(file))); + expect((upload.mock.calls[0][1] as FormData).get('cover')).toBe(file); + expect(addToast).toHaveBeenCalledWith('journey.settings.coverUpdated', 'success', undefined); + expect(props.onContentChanged).toHaveBeenCalledTimes(1); + + await act(() => result.current.handleCoverUpload(fileEvent(file))); + expect(addToast).toHaveBeenLastCalledWith('journey.settings.coverFailed', 'error', undefined); + expect(props.onContentChanged).toHaveBeenCalledTimes(1); + }); + + it('FE-JRN-SETTINGSHOOK-004: archiving flips the status both ways', async () => { + const active = setup(); + await act(() => active.result.current.handleArchiveToggle()); + expect(updateJourney).toHaveBeenLastCalledWith(3, { status: 'archived' }); + expect(addToast).toHaveBeenLastCalledWith('journey.settings.archived', 'success', undefined); + expect(active.props.onSaved).toHaveBeenCalledTimes(1); + + const archived = setup({ journey: buildJourney({ status: 'archived' }) }); + await act(() => archived.result.current.handleArchiveToggle()); + expect(updateJourney).toHaveBeenLastCalledWith(3, { status: 'active' }); + expect(addToast).toHaveBeenLastCalledWith('journey.settings.reopened', 'success', undefined); + }); + + it('FE-JRN-SETTINGSHOOK-005: the tracks switch takes the new value or flips the current one, and only refreshes', async () => { + const { result, props } = setup({ journey: buildJourney({ show_trip_tracks: 1 }) }); + await act(() => result.current.handleTracksToggle()); + expect(updateJourney).toHaveBeenLastCalledWith(3, { show_trip_tracks: false }); + await act(() => result.current.handleTracksToggle(true)); + expect(updateJourney).toHaveBeenLastCalledWith(3, { show_trip_tracks: true }); + expect(props.onRefresh).toHaveBeenCalledTimes(2); + expect(props.onSaved).not.toHaveBeenCalled(); + expect(result.current.savingTracks).toBe(false); + }); + + it('FE-JRN-SETTINGSHOOK-006: an entry field switch takes the new value or flips the stored one', async () => { + const { result } = setup({ journey: buildJourney({ show_mood: 0 } as Partial) }); + await act(() => result.current.handleFieldToggle('show_mood')); + expect(updateJourney).toHaveBeenLastCalledWith(3, { show_mood: true }); + await act(() => result.current.handleFieldToggle('show_weather', false)); + expect(updateJourney).toHaveBeenLastCalledWith(3, { show_weather: false }); + + updateJourney.mockRejectedValueOnce(new Error('x')); + await act(() => result.current.handleFieldToggle('show_verdict')); + expect(addToast).toHaveBeenLastCalledWith('journey.settings.saveFailed', 'error', undefined); + expect(result.current.savingField).toBeNull(); + }); + + it('FE-JRN-SETTINGSHOOK-007: photo locations flip on the spot', async () => { + const { result, props } = setup(); + await act(() => result.current.handlePhotoLocationToggle()); + expect(updateJourney).toHaveBeenCalledWith(3, { photo_location: true }); + expect(props.onRefresh).toHaveBeenCalledTimes(1); + }); + + it('FE-JRN-SETTINGSHOOK-008: the shown status is auto by default, stored as null, and the same choice is a no op', async () => { + const { result } = setup(); + expect(result.current.statusChoice).toBe('auto'); + await act(() => result.current.handleStatusChange('auto')); + expect(updateJourney).not.toHaveBeenCalled(); + await act(() => result.current.handleStatusChange('live')); + expect(updateJourney).toHaveBeenCalledWith(3, { status_override: 'live' }); + + const live = setup({ journey: buildJourney({ status_override: 'live' } as Partial) }); + await act(() => live.result.current.handleStatusChange('auto')); + expect(updateJourney).toHaveBeenLastCalledWith(3, { status_override: null }); + }); + + it('FE-JRN-SETTINGSHOOK-009: deleting leaves for the journey list, a failure toasts', async () => { + const { result } = setup(); + await act(() => result.current.handleDelete()); + expect(deleteJourney).toHaveBeenCalledWith(3); + expect(mockNavigate).toHaveBeenCalledWith('/journey'); + + deleteJourney.mockRejectedValueOnce(new Error('x')); + await act(() => result.current.handleDelete()); + expect(addToast).toHaveBeenCalledWith('journey.settings.failedToDelete', 'error', undefined); + expect(mockNavigate).toHaveBeenCalledTimes(1); + }); + + it('FE-JRN-SETTINGSHOOK-010: removing a contributor asks first', async () => { + const remove = vi.spyOn(journeyApi, 'removeContributor').mockResolvedValue({}); + const confirm = vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true); + const { result, props } = setup(); + const c = { journey_id: 3, user_id: 2, role: 'editor', added_at: 0, username: 'julien' } as JourneyContributor; + await act(() => result.current.handleRemoveContributor(c)); + expect(remove).not.toHaveBeenCalled(); + await act(() => result.current.handleRemoveContributor(c)); + expect(confirm).toHaveBeenLastCalledWith('journey.contributors.removeConfirm:{"username":"julien"}'); + expect(remove).toHaveBeenCalledWith(3, 2); + expect(addToast).toHaveBeenCalledWith('journey.contributors.removed', 'success', undefined); + expect(props.onRefresh).toHaveBeenCalledTimes(1); + }); + + it('FE-JRN-SETTINGSHOOK-011: unlinking the picked trip clears the pick and hands over, a failure keeps it', async () => { + const remove = vi.spyOn(journeyApi, 'removeTrip').mockRejectedValueOnce(new Error('x')).mockResolvedValueOnce({}); + const { result, props } = setup(); + await act(() => result.current.confirmUnlink()); + expect(remove).not.toHaveBeenCalled(); + + act(() => result.current.setUnlinkTarget({ trip_id: 5, title: 'Italy Trip' })); + await act(() => result.current.confirmUnlink()); + expect(addToast).toHaveBeenCalledWith('journey.trips.unlinkFailed', 'error', undefined); + expect(result.current.unlinkTarget).not.toBeNull(); + + await act(() => result.current.confirmUnlink()); + expect(remove).toHaveBeenLastCalledWith(3, 5); + expect(result.current.unlinkTarget).toBeNull(); + expect(props.onContentChanged).toHaveBeenCalledTimes(1); + }); +}); + +describe('useJourneyTripLinking', () => { + it('FE-JRN-SETTINGSHOOK-012: the desktop picker loads the trips on mount, the phone sheet when asked', async () => { + const available = vi.spyOn(journeyApi, 'availableTrips').mockResolvedValue({ trips: [{ id: 9, title: 'Oslo' }] }); + const eager = renderHook(() => useJourneyTripLinking({ journeyId: 3, onLinked: vi.fn(), loadOnMount: true })); + await waitFor(() => expect(eager.result.current.availableTrips).toEqual([{ id: 9, title: 'Oslo' }])); + + const lazy = renderHook(() => useJourneyTripLinking({ journeyId: 3, onLinked: vi.fn() })); + expect(available).toHaveBeenCalledTimes(1); + await act(() => lazy.result.current.loadAvailableTrips()); + expect(lazy.result.current.availableTrips).toEqual([{ id: 9, title: 'Oslo' }]); + }); + + it('FE-JRN-SETTINGSHOOK-013: a failed lookup leaves the list empty', async () => { + vi.spyOn(journeyApi, 'availableTrips').mockRejectedValue(new Error('x')); + const { result } = renderHook(() => useJourneyTripLinking({ journeyId: 3, onLinked: vi.fn() })); + await act(() => result.current.loadAvailableTrips()); + expect(result.current.availableTrips).toEqual([]); + }); + + it('FE-JRN-SETTINGSHOOK-014: linking toasts and hands over, a failure only toasts', async () => { + const add = vi.spyOn(journeyApi, 'addTrip').mockResolvedValueOnce({}).mockRejectedValueOnce(new Error('x')); + const onLinked = vi.fn(); + const { result } = renderHook(() => useJourneyTripLinking({ journeyId: 3, onLinked })); + await act(() => result.current.linkTrip(9)); + expect(add).toHaveBeenCalledWith(3, 9); + expect(addToast).toHaveBeenCalledWith('journey.trips.tripLinked', 'success', undefined); + expect(onLinked).toHaveBeenCalledTimes(1); + + await act(() => result.current.linkTrip(9)); + expect(addToast).toHaveBeenLastCalledWith('journey.trips.linkFailed', 'error', undefined); + expect(onLinked).toHaveBeenCalledTimes(1); + expect(result.current.linkingTripId).toBeNull(); + }); +}); diff --git a/client/src/components/Journey/useJourneySettings.ts b/client/src/components/Journey/useJourneySettings.ts new file mode 100644 index 000000000..c6240ed32 --- /dev/null +++ b/client/src/components/Journey/useJourneySettings.ts @@ -0,0 +1,273 @@ +import type React from 'react'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; + +import { journeyApi } from '../../api/client'; +import { useTranslation } from '../../i18n'; +import { useJourneyStore, type JourneyContributor, type JourneyDetail } from '../../store/journeyStore'; +import { normalizeImageFile } from '../../utils/convertHeic'; +import { useToast } from '../shared/Toast'; + +export type JourneyEntryField = 'show_verdict' | 'show_mood' | 'show_weather'; +export type JourneyStatusChoice = 'auto' | 'draft' | 'live' | 'completed'; + +export interface AvailableTrip { + id: number; + title: string; + destination?: string; + start_date?: string; + end_date?: string; +} + +export interface JourneySettingsOptions { + journey: JourneyDetail; + /** Saved: the shell closes and reloads. */ + onSaved: () => void; + /** Reload without closing, so a half typed title survives a switch being flipped. */ + onRefresh: () => void; + /** + * Called once a new cover or an unlinked trip went through: the desktop dialog + * passes onSaved (it closes), the phone sheet onRefresh (it stays open). + */ + onContentChanged: () => void; +} + +/** + * A journey's settings, behind both the desktop dialog and the phone sheet, which render + * their own markup over it: name and subtitle, cover, the switches that are saved on the + * spot (trip tracks, the optional entry fields, photo locations, the status shown), + * archive, delete, unlinking a trip and removing a contributor. + */ +export function useJourneySettings({ journey, onSaved, onRefresh, onContentChanged }: JourneySettingsOptions) { + const { t } = useTranslation(); + const toast = useToast(); + const navigate = useNavigate(); + const { updateJourney, deleteJourney } = useJourneyStore(); + + const [title, setTitle] = useState(journey.title); + const [subtitle, setSubtitle] = useState(journey.subtitle || ''); + const [saving, setSaving] = useState(false); + const [archiving, setArchiving] = useState(false); + const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [savingTracks, setSavingTracks] = useState(false); + const [savingField, setSavingField] = useState(null); + const [savingPhotoLocation, setSavingPhotoLocation] = useState(false); + const [savingStatus, setSavingStatus] = useState(false); + + const isDirty = title !== journey.title || subtitle !== (journey.subtitle || ''); + + const handleSave = async () => { + setSaving(true); + try { + await updateJourney(journey.id, { title, subtitle: subtitle || null }); + onSaved(); + } catch { + toast.error(t('journey.settings.saveFailed')); + } finally { + setSaving(false); + } + }; + + const handleCoverUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const formData = new FormData(); + formData.append('cover', await normalizeImageFile(file)); + try { + await journeyApi.uploadCover(journey.id, formData); + toast.success(t('journey.settings.coverUpdated')); + onContentChanged(); + } catch { + toast.error(t('journey.settings.coverFailed')); + } + }; + + const handleArchiveToggle = async () => { + setArchiving(true); + try { + const newStatus = journey.status === 'archived' ? 'active' : 'archived'; + await updateJourney(journey.id, { status: newStatus }); + toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened')); + onSaved(); + } catch { + toast.error(t('journey.settings.saveFailed')); + } finally { + setArchiving(false); + } + }; + + // The switches below are saved on the spot rather than on Save, like the archive + // button: they are view settings, and the point of them is seeing the journey change + // (#2194). Through onRefresh, not onSaved: onSaved closes the shell the moment a + // switch is flipped and would drop a title the owner has typed but not saved yet. + // Nothing an entry field stored is erased when it is switched off (#2299): the form + // stops asking, the values stay, and switching back on brings them into view. + + /** `next` is the switch's new value; without one the current value flips. */ + const handleTracksToggle = async (next: boolean = !journey.show_trip_tracks) => { + setSavingTracks(true); + try { + await updateJourney(journey.id, { show_trip_tracks: next }); + onRefresh(); + } catch { + toast.error(t('journey.settings.saveFailed')); + } finally { + setSavingTracks(false); + } + }; + + /** `next` is the switch's new value; without one the current value flips. */ + const handleFieldToggle = async (field: JourneyEntryField, next: boolean = journey[field] === 0) => { + setSavingField(field); + try { + await updateJourney(journey.id, { [field]: next }); + onRefresh(); + } catch { + toast.error(t('journey.settings.saveFailed')); + } finally { + setSavingField(null); + } + }; + + // Entries placed from their photos (#1003). + const handlePhotoLocationToggle = async () => { + setSavingPhotoLocation(true); + try { + await updateJourney(journey.id, { photo_location: !journey.photo_location }); + onRefresh(); + } catch { + toast.error(t('journey.settings.saveFailed')); + } finally { + setSavingPhotoLocation(false); + } + }; + + // The state shown for the journey, set by hand (#762); 'auto' stores null and hands + // it back to the trip dates. + const statusChoice: JourneyStatusChoice = journey.status_override ?? 'auto'; + const handleStatusChange = async (next: JourneyStatusChoice) => { + if (next === statusChoice || savingStatus) return; + setSavingStatus(true); + try { + await updateJourney(journey.id, { status_override: next === 'auto' ? null : next }); + onRefresh(); + } catch { + toast.error(t('journey.settings.saveFailed')); + } finally { + setSavingStatus(false); + } + }; + + const handleDelete = async () => { + try { + await deleteJourney(journey.id); + navigate('/journey'); + } catch { + toast.error(t('journey.settings.failedToDelete')); + } + }; + + const handleRemoveContributor = async (c: JourneyContributor) => { + if (!window.confirm(t('journey.contributors.removeConfirm', { username: c.username }))) return; + try { + await journeyApi.removeContributor(journey.id, c.user_id); + toast.success(t('journey.contributors.removed')); + onRefresh(); + } catch { + toast.error(t('journey.contributors.removeFailed')); + } + }; + + const confirmUnlink = async () => { + if (!unlinkTarget) return; + try { + await journeyApi.removeTrip(journey.id, unlinkTarget.trip_id); + toast.success(t('journey.trips.tripUnlinked')); + setUnlinkTarget(null); + onContentChanged(); + } catch { + toast.error(t('journey.trips.unlinkFailed')); + } + }; + + return { + title, + setTitle, + subtitle, + setSubtitle, + saving, + isDirty, + archiving, + unlinkTarget, + setUnlinkTarget, + showDeleteConfirm, + setShowDeleteConfirm, + savingTracks, + savingField, + savingPhotoLocation, + savingStatus, + statusChoice, + handleSave, + handleCoverUpload, + handleArchiveToggle, + handleTracksToggle, + handleFieldToggle, + handlePhotoLocationToggle, + handleStatusChange, + handleDelete, + handleRemoveContributor, + confirmUnlink, + }; +} + +export interface JourneyTripLinkingOptions { + journeyId: number; + /** Called after a trip was linked (and its toast shown). */ + onLinked: () => void; + /** Load the linkable trips as soon as the picker mounts (the desktop dialog). */ + loadOnMount?: boolean; +} + +/** + * Linking another trip to a journey, behind the desktop add trip dialog and the phone + * settings sheet: the trips the user could link, and the link itself. + */ +export function useJourneyTripLinking({ journeyId, onLinked, loadOnMount = false }: JourneyTripLinkingOptions) { + const { t } = useTranslation(); + const toast = useToast(); + const [availableTrips, setAvailableTrips] = useState([]); + const [linkingTripId, setLinkingTripId] = useState(null); + + useEffect(() => { + if (!loadOnMount) return; + journeyApi + .availableTrips() + .then((d) => setAvailableTrips(d.trips || [])) + .catch(() => {}); + }, [loadOnMount]); + + const loadAvailableTrips = async () => { + try { + const data = await journeyApi.availableTrips(); + setAvailableTrips(data.trips || []); + } catch { + /* the list stays empty */ + } + }; + + const linkTrip = async (tripId: number) => { + setLinkingTripId(tripId); + try { + await journeyApi.addTrip(journeyId, tripId); + toast.success(t('journey.trips.tripLinked')); + onLinked(); + } catch { + toast.error(t('journey.trips.linkFailed')); + } finally { + setLinkingTripId(null); + } + }; + + return { availableTrips, linkingTripId, loadAvailableTrips, linkTrip }; +} diff --git a/client/src/components/Journey/useJourneyShareLink.test.ts b/client/src/components/Journey/useJourneyShareLink.test.ts new file mode 100644 index 000000000..6f92c53d1 --- /dev/null +++ b/client/src/components/Journey/useJourneyShareLink.test.ts @@ -0,0 +1,126 @@ +// FE-JRN-SHAREHOOK-001 to FE-JRN-SHAREHOOK-008: the public share link logic behind +// both the desktop share section and the phone settings sheet. +import { act, renderHook, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { server } from '../../../tests/helpers/msw/server'; +import { journeyShareUrl, useJourneyShareLink } from './useJourneyShareLink'; + +vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) })); + +const LINK = { token: 'tok', share_timeline: true, share_gallery: false, share_map: true }; + +let addToast: Mock>; +let posted: unknown[]; + +beforeEach(() => { + addToast = vi.fn>(); + window.__addToast = addToast; + posted = []; + server.use( + http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: LINK })), + http.post('/api/journeys/7/share-link', async ({ request }) => { + posted.push(await request.json()); + return HttpResponse.json({ token: 'fresh' }); + }), + http.delete('/api/journeys/7/share-link', () => HttpResponse.json({ success: true })) + ); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + delete window.__addToast; +}); + +async function loaded() { + const hook = renderHook(() => useJourneyShareLink(7)); + await waitFor(() => expect(hook.result.current.loading).toBe(false)); + return hook; +} + +describe('journeyShareUrl', () => { + it('FE-JRN-SHAREHOOK-001: the public journey address of a token, empty without a link', () => { + expect(journeyShareUrl(LINK, 'https://trek.example')).toBe('https://trek.example/public/journey/tok'); + expect(journeyShareUrl(null, 'https://trek.example')).toBe(''); + }); +}); + +describe('useJourneyShareLink', () => { + it('FE-JRN-SHAREHOOK-002: loads the existing link', async () => { + const { result } = await loaded(); + expect(result.current.link).toEqual(LINK); + expect(result.current.manageable).toBe(true); + expect(result.current.shareUrl).toBe(`${window.location.origin}/public/journey/tok`); + }); + + it('FE-JRN-SHAREHOOK-003: a 403 marks the link as not this user to manage, other errors do not', async () => { + server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ error: 'no' }, { status: 403 }))); + const refused = await loaded(); + expect(refused.result.current.manageable).toBe(false); + + server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ error: 'down' }, { status: 500 }))); + const failed = await loaded(); + expect(failed.result.current.manageable).toBe(true); + expect(failed.result.current.link).toBeNull(); + }); + + it('FE-JRN-SHAREHOOK-004: creating shares every section', async () => { + server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: null }))); + const { result } = await loaded(); + await act(() => result.current.createLink()); + expect(posted).toEqual([{ share_timeline: true, share_gallery: true, share_map: true }]); + expect(result.current.link).toEqual({ token: 'fresh', share_timeline: true, share_gallery: true, share_map: true }); + expect(addToast).toHaveBeenCalledWith('journey.share.linkCreated', 'success', undefined); + }); + + it('FE-JRN-SHAREHOOK-005: a failed create only toasts', async () => { + server.use( + http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: null })), + http.post('/api/journeys/7/share-link', () => HttpResponse.json({ error: 'x' }, { status: 500 })) + ); + const { result } = await loaded(); + await act(() => result.current.createLink()); + expect(result.current.link).toBeNull(); + expect(addToast).toHaveBeenCalledWith('journey.share.createFailed', 'error', undefined); + }); + + it('FE-JRN-SHAREHOOK-006: a section switch saves the whole set and rolls back when refused', async () => { + const { result } = await loaded(); + await act(() => result.current.togglePerm('share_gallery')); + expect(posted).toEqual([{ share_timeline: true, share_gallery: true, share_map: true }]); + expect(result.current.link?.share_gallery).toBe(true); + + server.use(http.post('/api/journeys/7/share-link', () => HttpResponse.json({ error: 'x' }, { status: 500 }))); + await act(() => result.current.togglePerm('share_map')); + expect(result.current.link?.share_map).toBe(true); + expect(addToast).toHaveBeenCalledWith('journey.share.updateFailed', 'error', undefined); + }); + + it('FE-JRN-SHAREHOOK-007: deleting drops the link, a failure keeps it', async () => { + const { result } = await loaded(); + server.use(http.delete('/api/journeys/7/share-link', () => HttpResponse.json({ error: 'x' }, { status: 500 }))); + await act(() => result.current.deleteLink()); + expect(result.current.link).toEqual(LINK); + expect(addToast).toHaveBeenCalledWith('journey.share.deleteFailed', 'error', undefined); + + server.use(http.delete('/api/journeys/7/share-link', () => HttpResponse.json({ success: true }))); + await act(() => result.current.deleteLink()); + expect(result.current.link).toBeNull(); + expect(addToast).toHaveBeenLastCalledWith('journey.share.linkDeleted', 'success', undefined); + }); + + it('FE-JRN-SHAREHOOK-008: copying shows "copied" for two seconds', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true }); + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true, writable: true }); + const { result } = await loaded(); + vi.useFakeTimers(); + await act(() => result.current.copyLink()); + expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/public/journey/tok`); + expect(result.current.copied).toBe(true); + act(() => vi.advanceTimersByTime(2000)); + expect(result.current.copied).toBe(false); + }); +}); diff --git a/client/src/components/Journey/useJourneyShareLink.ts b/client/src/components/Journey/useJourneyShareLink.ts new file mode 100644 index 000000000..d6d4c344e --- /dev/null +++ b/client/src/components/Journey/useJourneyShareLink.ts @@ -0,0 +1,100 @@ +import { useEffect, useState } from 'react'; + +import { journeyApi } from '../../api/client'; +import { useTranslation } from '../../i18n'; +import { copyText } from '../../utils/clipboard'; +import { useToast } from '../shared/Toast'; + +export interface JourneyShareLink { + token: string; + share_timeline: boolean; + share_gallery: boolean; + share_map: boolean; +} + +export type JourneySharePermission = 'share_timeline' | 'share_gallery' | 'share_map'; + +/** The public address of a journey share token. */ +export function journeyShareUrl(link: JourneyShareLink | null, origin: string): string { + return link ? `${origin}/public/journey/${link.token}` : ''; +} + +/** + * A journey's public share link, behind both the desktop share section and the phone + * settings sheet, which render their own markup over it: load it, create it with every + * section shared, switch a section on or off (rolled back when the server refuses), + * delete it and copy its address. + */ +export function useJourneyShareLink(journeyId: number) { + const { t } = useTranslation(); + const toast = useToast(); + const [link, setLink] = useState(null); + const [loading, setLoading] = useState(true); + const [copied, setCopied] = useState(false); + /** The share token is the owner's to manage; a contributor is refused. */ + const [manageable, setManageable] = useState(true); + + useEffect(() => { + journeyApi + .getShareLink(journeyId) + .then((d) => setLink(d.link || null)) + .catch((err: { response?: { status?: number } }) => { + // A 403 is the server saying this is not yours to manage. Showing the + // section anyway would offer an editor a "create link" button that is + // refused the moment they press it. + if (err?.response?.status === 403) setManageable(false); + }) + .finally(() => setLoading(false)); + }, [journeyId]); + + const createLink = async () => { + try { + const res = await journeyApi.createShareLink(journeyId, { + share_timeline: true, + share_gallery: true, + share_map: true, + }); + setLink({ token: res.token, share_timeline: true, share_gallery: true, share_map: true }); + toast.success(t('journey.share.linkCreated')); + } catch { + toast.error(t('journey.share.createFailed')); + } + }; + + const togglePerm = async (key: JourneySharePermission) => { + if (!link) return; + const previous = link; + const updated = { ...previous, [key]: !previous[key] }; + setLink(updated); + try { + await journeyApi.createShareLink(journeyId, { + share_timeline: updated.share_timeline, + share_gallery: updated.share_gallery, + share_map: updated.share_map, + }); + } catch { + setLink(previous); + toast.error(t('journey.share.updateFailed')); + } + }; + + const deleteLink = async () => { + try { + await journeyApi.deleteShareLink(journeyId); + setLink(null); + toast.success(t('journey.share.linkDeleted')); + } catch { + toast.error(t('journey.share.deleteFailed')); + } + }; + + const shareUrl = journeyShareUrl(link, window.location.origin); + + const copyLink = async () => { + if (!(await copyText(shareUrl))) return; + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return { link, loading, manageable, copied, shareUrl, createLink, togglePerm, deleteLink, copyLink }; +} diff --git a/client/src/mobile/screens/journey/MJourneySettingsSheet.tsx b/client/src/mobile/screens/journey/MJourneySettingsSheet.tsx index d6d2b4cf5..be3774e48 100644 --- a/client/src/mobile/screens/journey/MJourneySettingsSheet.tsx +++ b/client/src/mobile/screens/journey/MJourneySettingsSheet.tsx @@ -1,5 +1,4 @@ -import { useEffect, useRef, useState } from 'react' -import { useNavigate } from 'react-router' +import { useRef, useState } from 'react' import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, @@ -9,29 +8,12 @@ import MIconBtn from '../../components/MIconBtn' import MToggle from '../../components/MToggle' import ConfirmDialog from '../../../components/shared/ConfirmDialog' import { useTranslation } from '../../../i18n' -import { useToast } from '../../../components/shared/Toast' -import { journeyApi } from '../../../api/client' -import { useJourneyStore } from '../../../store/journeyStore' import type { JourneyDetail } from '../../../store/journeyStore' -import { normalizeImageFile } from '../../../utils/convertHeic' -import { copyText } from '../../../utils/clipboard' +import { useJourneySettings, useJourneyTripLinking } from '../../../components/Journey/useJourneySettings' +import { useJourneyShareLink } from '../../../components/Journey/useJourneyShareLink' import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers' import { journeyCoverSrc } from './mobileJourneyMeta' -interface ShareLink { - token: string - share_timeline: boolean - share_gallery: boolean - share_map: boolean -} - -interface AvailableTrip { - id: number - title: string - destination?: string - start_date?: string -} - interface MJourneySettingsSheetProps { journey: JourneyDetail onClose: () => void @@ -50,181 +32,27 @@ export default function MJourneySettingsSheet({ journey, onClose, onSaved, onOpenInvite, onRefresh, onRestoreSuggestions, }: MJourneySettingsSheetProps) { const { t } = useTranslation() - const toast = useToast() - const navigate = useNavigate() - const { updateJourney, deleteJourney } = useJourneyStore() - - const [title, setTitle] = useState(journey.title) - const [subtitle, setSubtitle] = useState(journey.subtitle || '') - const [saving, setSaving] = useState(false) - const [archiving, setArchiving] = useState(false) - const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null) - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const { + title, setTitle, subtitle, setSubtitle, saving, archiving, unlinkTarget, setUnlinkTarget, showDeleteConfirm, + setShowDeleteConfirm, savingTracks, savingField, handleSave, handleCoverUpload, handleArchiveToggle, + handleTracksToggle, handleFieldToggle, handleDelete, handleRemoveContributor, confirmUnlink, + } = useJourneySettings({ journey, onSaved, onRefresh, onContentChanged: onRefresh }) const [addTripOpen, setAddTripOpen] = useState(false) - const [availableTrips, setAvailableTrips] = useState([]) - const [linkingTripId, setLinkingTripId] = useState(null) - const [shareLink, setShareLink] = useState(null) - const [savingTracks, setSavingTracks] = useState(false) - const [copied, setCopied] = useState(false) + const { availableTrips, linkingTripId, loadAvailableTrips, linkTrip } = useJourneyTripLinking({ + journeyId: journey.id, + onLinked: () => { setAddTripOpen(false); onRefresh() }, + }) + const { + link: shareLink, copied, shareUrl, createLink: createShareLink, togglePerm: toggleSharePerm, + deleteLink: deleteShareLink, copyLink: copyShareUrl, + } = useJourneyShareLink(journey.id) const coverRef = useRef(null) - useEffect(() => { - journeyApi.getShareLink(journey.id).then(d => setShareLink(d.link || null)).catch(() => {}) - }, [journey.id]) - const coverSrc = journeyCoverSrc(journey.cover_image) - const shareUrl = shareLink ? `${window.location.origin}/public/journey/${shareLink.token}` : '' - - const handleSave = async () => { - setSaving(true) - try { - await updateJourney(journey.id, { title, subtitle: subtitle || null }) - onSaved() - } catch { - toast.error(t('journey.settings.saveFailed')) - } finally { - setSaving(false) - } - } - - const handleCoverUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - const formData = new FormData() - formData.append('cover', await normalizeImageFile(file)) - try { - await journeyApi.uploadCover(journey.id, formData) - toast.success(t('journey.settings.coverUpdated')) - onRefresh() - } catch { - toast.error(t('journey.settings.coverFailed')) - } - } - - // Written on the spot rather than on Save, like the archive button: it is a view - // setting and the point of it is watching the map change (#2194). onRefresh, not - // onSaved, because onSaved closes the sheet the instant the switch is flipped and - // would drop a title the owner has typed but not saved yet. - const handleTracksToggle = async (next: boolean) => { - setSavingTracks(true) - try { - await updateJourney(journey.id, { show_trip_tracks: next }) - onRefresh() - } catch { - toast.error(t('journey.settings.saveFailed')) - } finally { - setSavingTracks(false) - } - } - - /** - * Turn one of the optional entry fields off for this journey (discussion #2299). - * - * Written on the spot and through onRefresh, for the same two reasons the tracks - * switch above is. Nothing stored is erased: the form stops asking, the values - * stay, and switching back on brings them into view. - */ - const [savingField, setSavingField] = useState(null) - const handleFieldToggle = async (field: 'show_verdict' | 'show_mood' | 'show_weather', next: boolean) => { - setSavingField(field) - try { - await updateJourney(journey.id, { [field]: next }) - onRefresh() - } catch { - toast.error(t('journey.settings.saveFailed')) - } finally { - setSavingField(null) - } - } const openAddTrip = async () => { setAddTripOpen(v => !v) - if (availableTrips.length === 0) { - try { - const data = await journeyApi.availableTrips() - setAvailableTrips(data.trips || []) - } catch { /* row stays empty */ } - } - } - - const linkTrip = async (tripId: number) => { - setLinkingTripId(tripId) - try { - await journeyApi.addTrip(journey.id, tripId) - toast.success(t('journey.trips.tripLinked')) - setAddTripOpen(false) - onRefresh() - } catch { - toast.error(t('journey.trips.linkFailed')) - } finally { - setLinkingTripId(null) - } - } - - const createShareLink = async () => { - try { - const res = await journeyApi.createShareLink(journey.id, { share_timeline: true, share_gallery: true, share_map: true }) - setShareLink({ token: res.token, share_timeline: true, share_gallery: true, share_map: true }) - toast.success(t('journey.share.linkCreated')) - } catch { - toast.error(t('journey.share.createFailed')) - } - } - - const copyShareUrl = async () => { - if (!(await copyText(shareUrl))) return - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - const toggleSharePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => { - if (!shareLink) return - const previous = shareLink - const updated = { ...previous, [key]: !previous[key] } - setShareLink(updated) - try { - await journeyApi.createShareLink(journey.id, { - share_timeline: updated.share_timeline, - share_gallery: updated.share_gallery, - share_map: updated.share_map, - }) - } catch { - setShareLink(previous) - toast.error(t('journey.share.updateFailed')) - } - } - - const deleteShareLink = async () => { - try { - await journeyApi.deleteShareLink(journey.id) - setShareLink(null) - toast.success(t('journey.share.linkDeleted')) - } catch { - toast.error(t('journey.share.deleteFailed')) - } - } - - const handleArchiveToggle = async () => { - setArchiving(true) - try { - const newStatus = journey.status === 'archived' ? 'active' : 'archived' - await updateJourney(journey.id, { status: newStatus }) - toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened')) - onSaved() - } catch { - toast.error(t('journey.settings.saveFailed')) - } finally { - setArchiving(false) - } - } - - const handleDelete = async () => { - try { - await deleteJourney(journey.id) - navigate('/journey') - } catch { - toast.error(t('journey.settings.failedToDelete')) - } + if (availableTrips.length === 0) await loadAvailableTrips() } const eyebrow = 'font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint' @@ -395,16 +223,7 @@ export default function MJourneySettingsSheet({ {c.role !== 'owner' && (