feat(trips): trip settings with a linked journal tab

The Share button becomes the trip's Settings, a round button beside the back
pill. The dialog is rebuilt around three areas (People, Sharing, Journal) with
a side navigation, a fixed height and 960 px width on wide screens.

Journal links one of your journals from the Journey addon to the trip. Every
trip member is ticked as a reader by default, each can be raised to editor or
left out, and the people a save would let in for the first time are named
before it saves. The link can be changed or removed. With a link the trip gets
a Journal tab on desktop and phone that shows the Journey addon's own view,
live through its sync and trip:journal:changed. Nothing shows with the addon
off.

members.shareTrip and nav.share are gone with the button that used them.
This commit is contained in:
Maurice
2026-10-18 17:41:25 +02:00
parent 89f3055a29
commit fe05afec19
109 changed files with 2707 additions and 156 deletions
+31
View File
@@ -0,0 +1,31 @@
import type {
TripJournalCandidatesResponse,
TripJournalLinkMemberRequest,
TripJournalLinkResponse,
} from '@trek/shared';
import apiClient from './client';
/**
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
* and who it is open to. In its own file rather than in the `client.ts` god
* module; repo/tripJournalLinkRepo.ts is the one caller.
*/
export const tripJournalApi = {
get: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
link: (
tripId: number,
journeyId: number,
members: TripJournalLinkMemberRequest[]
): Promise<TripJournalLinkResponse> =>
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
/** The journals this person can open; the dialog offers the ones they own. */
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
apiClient.get('/journeys').then((r) => r.data),
};
+4
View File
@@ -93,6 +93,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// the bag ping: what a run changed is per-binding server state, and the
// files it moved arrive as file:* events.
'docsync:changed',
// Linked journal: components/Trips/useTripJournalLink asks again which journal
// the trip shows and who it is open to. Content-free, because that answer is
// per person (a member left off the link must not learn its title).
'trip:journal:changed',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
+14 -18
View File
@@ -169,23 +169,24 @@ describe('Navbar', () => {
expect(adminLink).toHaveAttribute('href', '/admin');
});
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => {
render(<Navbar onShare={vi.fn()} />);
const shareBtn = screen.getByRole('button', { name: /share/i });
expect(shareBtn).toBeInTheDocument();
it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => {
render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />);
const back = screen.getByRole('button', { name: /back/i });
const settings = screen.getByRole('button', { name: 'Trip settings' });
expect(back.parentElement).toBe(settings.closest('div'));
});
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => {
it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
const user = userEvent.setup();
const onShare = vi.fn();
render(<Navbar onShare={onShare} />);
const shareBtn = screen.getByRole('button', { name: /share/i });
await user.click(shareBtn);
expect(onShare).toHaveBeenCalled();
const onSettings = vi.fn();
render(<Navbar onSettings={onSettings} />);
await user.click(screen.getByRole('button', { name: 'Trip settings' }));
expect(onSettings).toHaveBeenCalled();
});
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
render(<Navbar />);
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
});
@@ -453,8 +454,8 @@ describe('Navbar styling and menu details', () => {
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
});
it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => {
render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />);
it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => {
render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
const back = screen.getByRole('button', { name: /back/i });
fireEvent.mouseEnter(back);
@@ -462,11 +463,6 @@ describe('Navbar styling and menu details', () => {
fireEvent.mouseLeave(back);
expect(back.style.background).toBe('transparent');
const share = screen.getByRole('button', { name: /share/i });
fireEvent.mouseEnter(share);
expect(share.style.background).toBe('var(--bg-hover)');
fireEvent.mouseLeave(share);
expect(share.style.background).toBe('var(--bg-card)');
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
fireEvent.mouseEnter(theme);
+20 -14
View File
@@ -6,7 +6,8 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { usePluginStore } from '../../store/pluginStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import { Tooltip } from '../shared/Tooltip'
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
import HelpButton from '../Help/HelpButton'
@@ -21,7 +22,8 @@ interface NavbarProps {
tripId?: number | string
onBack?: () => void
showBack?: boolean
onShare?: () => void
/** The trip's Settings: who is on it, its links and its journal. */
onSettings?: () => void
}
interface Addon {
@@ -32,7 +34,7 @@ interface Addon {
enabled: boolean
}
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore()
@@ -173,6 +175,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
</span>
))}
{/* The trip's Settings sit with the trip, beside its pill: they are about this
trip, not about the app the right side of the bar belongs to. */}
{onSettings && (
<Tooltip label={t('trip.settings.title')}>
<button
type="button"
onClick={onSettings}
aria-label={t('trip.settings.title')}
className="inline-flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<Settings className="h-4 w-4" aria-hidden="true" />
</button>
</Tooltip>
)}
</div>
{/* Centred liquid-glass tab menu (design handoff).
@@ -245,17 +262,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
itself on top of them. */}
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
{/* Share button */}
{onShare && (
<button type="button" onClick={onShare}
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0 border-edge text-content-secondary bg-surface-card"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
<Users className="w-4 h-4" />
<span className="hidden sm:inline">{t('nav.share')}</span>
</button>
)}
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
{user && <HelpButton />}
@@ -0,0 +1,37 @@
import { BookOpen, Link2 } from 'lucide-react';
/**
* The trip's Journal tab: a book with a link on its corner, because the journal
* is not the trip's own but one from the Journey addon tied to it. Takes the
* props the tab bars hand a lucide icon.
*
* The book is masked out under the link rather than the link given a backing:
* a backing has to match whatever the icon sits on (a card, the accent of a
* picked tab), and the moment it does not, the link vanishes into it.
*/
export function LinkedJournalIcon({
size = 16,
strokeWidth = 2,
className,
}: {
size?: number;
strokeWidth?: number;
className?: string;
}) {
const badge = Math.round(size * 0.6);
// The hole: a circle round the link's centre, a little wider than the link.
const hole = `radial-gradient(circle at calc(100% + 2px) calc(100% + 1px), transparent ${badge * 0.62}px, currentColor ${badge * 0.62 + 0.5}px)`;
return (
// dir="ltr": the hole is cut on the right, so the link has to stay there in Arabic too.
<span
dir="ltr"
className={`relative inline-flex flex-none ${className ?? ''}`}
style={{ width: size, height: size }}
>
<span className="inline-flex" style={{ maskImage: hole, WebkitMaskImage: hole }}>
<BookOpen size={size} strokeWidth={strokeWidth} />
</span>
<Link2 size={badge} strokeWidth={strokeWidth + 0.6} className="absolute -bottom-[3px] -end-[4px]" />
</span>
);
}
@@ -0,0 +1,150 @@
// FE-TJL-SECTION-001 to FE-TJL-SECTION-007
/**
* The Journal part of the trip's Settings, rendered: what someone sees with no
* journal linked, with one linked, while picking, and the warning that names
* the people a save would let in for the first time.
*/
import userEvent from '@testing-library/user-event';
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { seedStore } from '../../../tests/helpers/store';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
import { LinkedJournalIcon } from './LinkedJournalIcon';
import { TripJournalLinkSection } from './TripJournalLinkSection';
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const TRIP = 4;
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
const LINKED: TripJournalLinkResponse = {
link: {
journey_id: 9,
title: 'Japan',
subtitle: null,
cover_image: 'covers/j.jpg',
owner_id: 1,
linked_by: 1,
created_at: 1,
my_role: 'owner',
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
},
can_link: true,
can_unlink: true,
can_edit_members: true,
};
const CANDIDATES: TripJournalCandidate[] = [
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: true, access: 'editor' },
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
];
let answer: TripJournalLinkResponse = EMPTY;
let deletes = 0;
beforeEach(() => {
answer = EMPTY;
deletes = 0;
vi.clearAllMocks();
useTripJournalLinkStore.setState({ byTrip: {} });
seedStore(useAuthStore, { user: buildUser({ id: 1 }) });
useAddonStore.setState({
loaded: true,
addons: [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }],
});
server.use(
http.get('/api/trips/:tripId/journal', () => HttpResponse.json(answer)),
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
http.get('/api/journeys', () => HttpResponse.json({ journeys: [{ id: 9, user_id: 1, title: 'Japan' }] })),
http.delete('/api/trips/:tripId/journal', () => {
deletes += 1;
return HttpResponse.json(EMPTY);
})
);
});
describe('TripJournalLinkSection', () => {
it('FE-TJL-SECTION-001: renders nothing with the Journey addon off', async () => {
useAddonStore.setState({ addons: [] });
const { container } = render(<TripJournalLinkSection tripId={TRIP} />);
await new Promise((r) => setTimeout(r, 30));
expect(container).toBeEmptyDOMElement();
});
it('FE-TJL-SECTION-002: with no journal linked it offers to link one, in its own card', async () => {
render(<TripJournalLinkSection tripId={TRIP} />);
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
expect(screen.getByText('Journal')).toBeInTheDocument();
});
it('FE-TJL-SECTION-003: picking a journal lists the trip and warns about who gets in for the first time', async () => {
const user = userEvent.setup();
render(<TripJournalLinkSection tripId={TRIP} framed={false} />);
await user.click(await screen.findByText('Link a journal'));
await user.click(await screen.findByText('Choose a journal'));
// The select opens its list in a portal, one button per journal.
await user.click(await screen.findByRole('button', { name: 'Japan' }));
expect(await screen.findByText('tom')).toBeInTheDocument();
expect(screen.getByText('Has access')).toBeInTheDocument();
expect(screen.getByRole('note')).toHaveTextContent('tom will get access to this journal for the first time');
await user.click(screen.getAllByRole('radio', { name: 'No access' })[1]);
expect(screen.queryByRole('note')).not.toBeInTheDocument();
await user.click(screen.getByText('Everyone can read'));
expect(screen.getByRole('note')).toBeInTheDocument();
await user.click(screen.getByText('Nobody'));
expect(screen.queryByRole('note')).not.toBeInTheDocument();
await user.click(screen.getByText('Cancel'));
expect(screen.getByText('Link a journal')).toBeInTheDocument();
});
it('FE-TJL-SECTION-004: a linked journal shows its cover, my role and who it is open to', async () => {
answer = LINKED;
render(<TripJournalLinkSection tripId={TRIP} />);
expect(await screen.findByText('Japan')).toBeInTheDocument();
expect(screen.getByText('Your journal')).toBeInTheDocument();
expect(screen.getByText('Linked')).toBeInTheDocument();
expect(screen.getByText('lena')).toBeInTheDocument();
expect(document.querySelector('img[src="/uploads/covers/j.jpg"]')).not.toBeNull();
});
it('FE-TJL-SECTION-005: unlinking asks first, then takes it off', async () => {
const user = userEvent.setup();
answer = LINKED;
render(<TripJournalLinkSection tripId={TRIP} />);
await user.click(await screen.findByRole('button', { name: 'Unlink' }));
expect(screen.getByText('Unlink this journal?')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Unlink' });
await user.click(buttons[buttons.length - 1]);
await waitFor(() => expect(deletes).toBe(1));
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
});
it('FE-TJL-SECTION-006: editing access lists the trip with each one as the link holds them', async () => {
const user = userEvent.setup();
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, members: [] } };
render(<TripJournalLinkSection tripId={TRIP} />);
expect(await screen.findByText('Nobody else from this trip has access yet.')).toBeInTheDocument();
await user.click(screen.getByText('Edit access'));
expect(await screen.findByText('tom')).toBeInTheDocument();
expect(screen.getByText('Owner')).toBeInTheDocument();
expect(
screen.getAllByRole('radio', { name: 'No access' }).every((r) => r.getAttribute('aria-checked') === 'true')
).toBe(true);
});
it('FE-TJL-SECTION-007: the linked-journal icon draws a book with the link on its corner', () => {
const { container } = render(<LinkedJournalIcon size={20} className="x" />);
const root = container.firstElementChild as HTMLElement;
expect(root.getAttribute('dir')).toBe('ltr');
expect(root.style.width).toBe('20px');
expect(container.querySelectorAll('svg')).toHaveLength(2);
});
});
@@ -0,0 +1,324 @@
import type { TripJournalCandidate, TripJournalLinkRole } from '@trek/shared';
import { AlertTriangle, BookOpen, Link2, Unlink, Users } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import ConfirmDialog from '../shared/ConfirmDialog';
import CustomSelect from '../shared/CustomSelect';
import { DialogButton, fs } from '../shared/DialogShell';
import { AddRowButton } from '../shared/dialogParts';
import { LinkedJournalIcon } from './LinkedJournalIcon';
import { TripMemberAvatar } from './TripMemberAvatar';
import { LinkCard } from './TripShareDialog';
import { useTripJournalLink, type JournalMemberChoice } from './useTripJournalLink';
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5';
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5';
const SEGMENT = 'rounded-full px-2.5 py-1 font-semibold';
const ROLES: { value: TripJournalLinkRole | null; labelKey: string }[] = [
{ value: null, labelKey: 'trip.journal.roleNone' },
{ value: 'viewer', labelKey: 'trip.journal.roleViewer' },
{ value: 'editor', labelKey: 'trip.journal.roleEditor' },
];
/**
* The Journal part of the trip's Settings, for the desktop dialog and the phone
* sheet alike: the journal the trip shows, and who of the trip it is open to.
* Hidden with the Journey addon off, and for someone who can neither link one
* nor see one that is linked.
*/
export function TripJournalLinkSection({ tripId, framed = true }: { tripId: number; framed?: boolean }) {
const { t } = useTranslation();
const j = useTripJournalLink(tripId);
const [askUnlink, setAskUnlink] = useState(false);
if (!j.visible) return null;
// The desktop dialog names the area above it already; the phone sheet gets the card with its own title.
const Frame = framed ? JournalCard : Bare;
return (
<>
<Frame>
{j.mode === 'view' && j.link && <LinkedJournal j={j} />}
{j.mode === 'view' && !j.link && j.canLink && (
<AddRowButton onClick={j.startPick}>{t('trip.journal.link')}</AddRowButton>
)}
{j.mode === 'view' && j.link && (
<div className="flex flex-wrap gap-2">
{j.canEditMembers && (
<DialogButton onClick={j.editMembers} icon={<Users size={14} strokeWidth={2.2} />}>
{t('trip.journal.editMembers')}
</DialogButton>
)}
{j.canLink && (
<DialogButton onClick={j.startPick} icon={<BookOpen size={14} strokeWidth={2.2} />}>
{t('trip.journal.change')}
</DialogButton>
)}
{j.canUnlink && (
<button
type="button"
onClick={() => setAskUnlink(true)}
disabled={j.busy}
className="inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:opacity-50"
style={fs(12.5, 'body')}
>
<Unlink size={13} strokeWidth={2.2} />
{t('trip.journal.unlink')}
</button>
)}
</div>
)}
{j.mode === 'pick' && <JournalPicker j={j} />}
{(j.mode === 'members' || (j.mode === 'pick' && j.pickedId != null)) && <MemberChooser j={j} />}
{j.mode !== 'view' && (
<div className="flex justify-end gap-2">
<DialogButton onClick={j.cancel} disabled={j.busy}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
onClick={() => void j.save()}
disabled={!j.canSave}
icon={<Link2 size={14} strokeWidth={2.2} />}
>
{j.mode === 'pick' ? t('trip.journal.linkSubmit') : t('common.save')}
</DialogButton>
</div>
)}
</Frame>
<ConfirmDialog
isOpen={askUnlink}
onClose={() => setAskUnlink(false)}
onConfirm={() => {
setAskUnlink(false);
void j.unlink();
}}
title={t('trip.journal.unlinkTitle')}
message={t('trip.journal.unlinkMessage')}
confirmLabel={t('trip.journal.unlink')}
/>
</>
);
}
function JournalCard({ children }: { children: ReactNode }) {
const { t } = useTranslation();
return (
<LinkCard
icon={<LinkedJournalIcon size={15} strokeWidth={2.2} />}
title={t('trip.journal.title')}
hint={t('trip.journal.hint')}
>
{children}
</LinkCard>
);
}
function Bare({ children }: { children: ReactNode }) {
return <div className="flex flex-col gap-4">{children}</div>;
}
type JournalLink = ReturnType<typeof useTripJournalLink>;
/** The linked journal: cover, title, what the reader may do in it, and who it is open to. */
function LinkedJournal({ j }: { j: JournalLink }) {
const { t } = useTranslation();
const link = j.link;
if (!link) return null;
return (
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-card p-2.5">
<span className="grid h-11 w-11 flex-none place-items-center overflow-hidden rounded-[10px] bg-surface-tertiary text-content-muted">
{link.cover_image ? (
<img src={`/uploads/${link.cover_image}`} alt="" className="h-full w-full object-cover" />
) : (
<BookOpen size={18} strokeWidth={2} />
)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13.5, 'body')}>
{link.title}
</div>
{link.my_role && (
<div className="truncate text-content-faint" style={fs(11.5)}>
{t(`trip.journal.yourRole.${link.my_role}`)}
</div>
)}
</div>
<span
className="inline-flex flex-none items-center gap-1 rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success"
style={fs(10.5)}
>
<Link2 size={10} strokeWidth={2.6} />
{t('trip.journal.linkedBadge')}
</span>
</div>
{link.members && link.members.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{link.members.map((m) => (
<span
key={m.user_id}
className="inline-flex items-center gap-1.5 rounded-full bg-surface-card py-[3px] pe-2.5 ps-[3px] shadow-sm ring-1 ring-edge-faint"
style={fs(11.5)}
>
<TripMemberAvatar username={m.username} avatarUrl={m.avatar_url} size={20} />
<span className="font-medium text-content">{m.username}</span>
<span className="text-content-faint">
{t(m.role === 'editor' ? 'trip.journal.roleEditor' : 'trip.journal.roleViewer')}
</span>
</span>
))}
</div>
)}
{link.members && link.members.length === 0 && (
<p className="m-0 text-content-faint" style={fs(11.5)}>
{t('trip.journal.onlyYou')}
</p>
)}
</div>
);
}
/** The journals of one's own to choose from; one only contributed to is not one's to give away. */
function JournalPicker({ j }: { j: JournalLink }) {
const { t } = useTranslation();
if (j.journals == null) return <div className="h-10 animate-pulse rounded-[10px] bg-surface-tertiary" />;
if (j.journals.length === 0)
return (
<p className="m-0 text-content-faint" style={fs(12)}>
{t('trip.journal.noJournals')}
</p>
);
return (
<CustomSelect
value={j.pickedId == null ? '' : String(j.pickedId)}
onChange={(value) => j.pickJournal(value === '' ? null : Number(value))}
placeholder={t('trip.journal.pickPlaceholder')}
options={[
{ value: '', label: t('trip.journal.pickPlaceholder') },
...j.journals.map((journal) => ({ value: String(journal.id), label: journal.title })),
]}
searchable
size="sm"
/>
);
}
/**
* Everyone of the trip, each with no access, read or edit. Before saving, the
* people the link would let in for the first time are named: from then on they
* see the journal in their own Journey list as well.
*/
function MemberChooser({ j }: { j: JournalLink }) {
const { t } = useTranslation();
if (j.candidates == null) return <div className="h-24 animate-pulse rounded-[14px] bg-surface-tertiary" />;
if (j.candidates.length === 0)
return (
<p className="m-0 text-content-faint" style={fs(12)}>
{t('trip.journal.noMembers')}
</p>
);
return (
<div className="flex flex-col gap-2.5">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-content-secondary" style={fs(12, 'body')}>
{t('trip.journal.members')}
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={() => j.setAll('viewer')}
className="text-content-muted hover:text-content"
style={fs(11.5)}
>
{t('trip.journal.allRead')}
</button>
<span className="text-content-faint" aria-hidden>
/
</span>
<button
type="button"
onClick={() => j.setAll(null)}
className="text-content-muted hover:text-content"
style={fs(11.5)}
>
{t('trip.journal.allNone')}
</button>
</div>
</div>
<div className={ROWS}>
{j.candidates.map((c) => (
<MemberRow key={c.user_id} c={c} choice={j.choice} onRole={j.setRole} showAccess={j.mode === 'pick'} />
))}
</div>
{j.newlyGranted.length > 0 && (
<div role="note" className="flex items-start gap-2 rounded-[12px] bg-warning-soft px-3 py-2.5 text-warning">
<AlertTriangle size={15} strokeWidth={2.2} className="mt-px flex-none" />
<p className="m-0 leading-normal" style={fs(12)}>
{t('trip.journal.warnNew', {
count: j.newlyGranted.length,
names: j.newlyGranted.map((c) => c.username).join(', '),
})}
</p>
</div>
)}
</div>
);
}
function MemberRow({
c,
choice,
onRole,
showAccess,
}: {
c: TripJournalCandidate;
choice: JournalMemberChoice;
onRole: (userId: number, role: TripJournalLinkRole | null) => void;
showAccess: boolean;
}) {
const { t } = useTranslation();
const current = choice[c.user_id] ?? null;
return (
<div className={ROW}>
<TripMemberAvatar username={c.username} avatarUrl={c.avatar_url} />
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>
{c.username}
</span>
{c.is_trip_owner && (
<span
className="rounded-full bg-surface-tertiary px-2 py-[2px] font-semibold text-content-muted"
style={fs(10.5)}
>
{t('members.owner')}
</span>
)}
{showAccess && c.access && (
<span className="rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success" style={fs(10.5)}>
{t('trip.journal.hasAccess')}
</span>
)}
</div>
<div
role="radiogroup"
aria-label={c.username}
className="flex flex-none gap-0.5 rounded-full bg-surface-card p-0.5 ring-1 ring-edge-faint"
>
{ROLES.map((r) => (
<button
key={r.labelKey}
type="button"
role="radio"
aria-checked={current === r.value}
onClick={() => onRole(c.user_id, r.value)}
className={`${SEGMENT} ${current === r.value ? 'bg-accent text-accent-text' : 'text-content-muted hover:text-content'}`}
style={fs(11, 'body')}
>
{t(r.labelKey)}
</button>
))}
</div>
</div>
);
}
@@ -122,10 +122,10 @@ describe('TripMembersModal', () => {
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-002: shows Share Trip title', () => {
it('FE-COMP-MEMBERS-002: shows the Trip settings title', () => {
render(<TripMembersModal {...defaultProps} />);
// members.shareTrip = "Share Trip"
expect(screen.getByText('Share Trip')).toBeInTheDocument();
// trip.settings.title = "Trip settings"
expect(screen.getByText('Trip settings')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-003: shows owner username after load', async () => {
@@ -245,7 +245,7 @@ describe('TripMembersModal', () => {
it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => {
render(<TripMembersModal {...defaultProps} isOpen={true} />);
expect(screen.getByText('Share Trip')).toBeInTheDocument();
expect(screen.getByText('Trip settings')).toBeInTheDocument();
});
// ── Share Link Section (016-021) ───────────────────────────────────────────
@@ -1,3 +1,4 @@
import { TripJournalLinkSection } from './TripJournalLinkSection'
import Modal from '../shared/Modal'
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
@@ -181,7 +182,7 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
<Modal isOpen={isOpen} onClose={onClose} title={t('trip.settings.title')} size="3xl">
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid">
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
@@ -402,6 +403,8 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
<TripInviteLinkSection tripId={tripId} t={t} />
</div>}
<TripJournalLinkSection tripId={tripId} />
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
</div>
</Modal>
+82 -11
View File
@@ -1,12 +1,15 @@
import { useId, useState, type ReactNode } from 'react'
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Settings, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
import { AddRowButton, INPUT, LABEL, PANEL } from '../shared/dialogParts'
import { LinkedJournalIcon } from './LinkedJournalIcon'
import { TripJournalLinkSection } from './TripJournalLinkSection'
import { TripMemberAvatar } from './TripMemberAvatar'
import { useTripJournalSectionVisible } from './useTripJournalLink'
import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers, type TripMembersState } from './useTripShare'
interface TripShareDialogProps {
@@ -29,18 +32,38 @@ const COUNT = 'rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bo
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5 hover:bg-surface-card'
const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold'
/** A fixed height on a wide screen, so the dialog does not jump when another area is picked. */
const BODY = 'flex min-h-0 flex-1 max-md:flex-col md:h-[min(640px,calc(100dvh-240px))]'
const NAV = 'flex flex-none gap-1 border-edge-faint bg-surface-secondary p-3 md:w-[236px] md:flex-col md:border-e max-md:overflow-x-auto max-md:border-b'
const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50'
/** The desktop dialog behind Share: who is on the trip, its guests, and the two links that let others in. */
/**
* The trip's Settings on the desktop: who is on the trip, its guests, the two
* links that let others in, and the journal the trip shows.
*/
export default function TripShareDialog(props: TripShareDialogProps) {
return props.isOpen ? <OpenDialog {...props} /> : null
}
type Area = 'people' | 'sharing' | 'journal'
/** The areas of the dialog, each with its icon, its name and the line under the name. */
const AREAS: { id: Area; icon: (on: boolean) => ReactNode; labelKey: string; hintKey: string }[] = [
{ id: 'people', icon: () => <Users size={16} strokeWidth={2} />, labelKey: 'trip.settings.people', hintKey: 'trip.settings.peopleHint' },
{ id: 'sharing', icon: () => <Link2 size={16} strokeWidth={2} />, labelKey: 'trip.settings.sharing', hintKey: 'trip.settings.sharingHint' },
{ id: 'journal', icon: () => <LinkedJournalIcon size={16} />, labelKey: 'trip.journal.title', hintKey: 'trip.journal.hint' },
]
function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareDialogProps) {
const { t } = useTranslation()
const labelId = useId()
const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged })
const [ask, setAsk] = useState<Ask | null>(null)
const [area, setArea] = useState<Area>('people')
const journalSection = useTripJournalSectionVisible(tripId)
const areas = AREAS.filter(a => a.id === 'people' || (a.id === 'sharing' ? m.canManageShare : journalSection))
const current = areas.find(a => a.id === area) ?? areas[0]
const panelOf = (id: Area) => areas.find(a => a.id === id)
const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}`
return (
@@ -48,15 +71,17 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
<DialogShell
onClose={onClose}
labelledBy={labelId}
width={m.canManageShare ? 'wide' : 'detail'}
width={areas.length > 1 ? 'wide' : 'detail'}
align="top"
blocked={!!ask}
bodyClassName={BODY}
header={(
<DialogHeader
tile={<DialogTile><Users size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tile={<DialogTile><Settings size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={t('members.shareTrip')}
eyebrow={t('trip.settings.title')}
title={tripTitle}
pills={m.loading ? undefined : (
<>
@@ -67,17 +92,24 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
/>
)}
>
<div className={m.canManageShare ? 'grid grid-cols-2 items-start gap-6 max-md:grid-cols-1' : ''}>
<div className="flex flex-col gap-5">
{areas.length > 1 && <AreaNav areas={areas} current={current.id} onPick={setArea} />}
<div className="min-h-0 flex-1 overflow-y-auto px-7 pb-7 pt-6 max-sm:px-5">
{/* Every area stays mounted, so its links and lists are loaded by the time it is picked. */}
<AreaPanel area={panelOf('people')} shown={current.id === 'people'} titled={areas.length > 1}>
{m.canManageMembers && <InviteUser m={m} />}
<MembersList m={m} onAsk={setAsk} />
{(m.isCurrentOwner || m.guests.length > 0) && <GuestsList m={m} onAsk={setAsk} />}
</div>
</AreaPanel>
{m.canManageShare && (
<div className="flex flex-col gap-5">
<AreaPanel area={panelOf('sharing')} shown={current.id === 'sharing'} titled>
<PublicLink tripId={tripId} />
<InviteLink tripId={tripId} />
</div>
</AreaPanel>
)}
{journalSection && (
<AreaPanel area={panelOf('journal')} shown={current.id === 'journal'} titled>
<TripJournalLinkSection tripId={tripId} framed={false} />
</AreaPanel>
)}
</div>
</DialogShell>
@@ -93,6 +125,45 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
)
}
/** The areas down the side on a wide screen, a row of pills above the content on a narrow one. */
function AreaNav({ areas, current, onPick }: { areas: typeof AREAS; current: Area; onPick: (area: Area) => void }) {
const { t } = useTranslation()
return (
<nav aria-label={t('trip.settings.title')} className={NAV}>
{areas.map(a => {
const on = a.id === current
return (
<button key={a.id} type="button" aria-current={on ? 'page' : undefined} onClick={() => onPick(a.id)}
className={`flex flex-none items-center gap-2.5 rounded-[12px] px-2.5 py-2 text-start transition-colors ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-muted hover:bg-surface-hover hover:text-content'}`}>
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[9px] ${on ? 'bg-accent text-accent-text' : 'bg-surface-tertiary'}`}>{a.icon(on)}</span>
<span className="min-w-0">
<span className="block truncate font-semibold" style={fs(13, 'body')}>{t(a.labelKey)}</span>
<span className="block truncate text-content-faint max-md:hidden" style={fs(11)}>{t(a.hintKey)}</span>
</span>
</button>
)
})}
</nav>
)
}
/** One area: its name and what it is for, then its parts. Hidden rather than unmounted while another one shows. */
function AreaPanel({ area, shown, titled, children }: { area: (typeof AREAS)[number] | undefined; shown: boolean; titled: boolean; children: ReactNode }) {
const { t } = useTranslation()
return (
// The class, not the hidden attribute alone: flex would win over the attribute's display:none.
<section hidden={!shown} className={shown ? 'flex flex-col gap-5' : 'hidden'}>
{titled && area && (
<header>
<h3 className="m-0 font-semibold text-content" style={fs(17, 'subtitle')}>{t(area.labelKey)}</h3>
<p className="m-0 mt-1 max-w-[560px] leading-normal text-content-muted" style={fs(12.5, 'body')}>{t(area.hintKey)}</p>
</header>
)}
{children}
</section>
)
}
function InviteUser({ m }: { m: TripMembersState }) {
const { t } = useTranslation()
return (
@@ -291,7 +362,7 @@ function LinkLine({ url, copied, onCopy }: { url: string; copied: boolean; onCop
)
}
function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
export function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
return (
<section className={PANEL}>
<div className="flex items-start gap-2.5">
@@ -0,0 +1,290 @@
// FE-TJL-HOOK-001 to FE-TJL-HOOK-014
/**
* The trip's linked journal as the planner and the Settings dialog read it.
*
* `useTripJournal` decides whether the trip has a Journal tab at all: only with
* the Journey addon on, only once the server answered, and live on every
* `trip:journal:changed`. `useTripJournalLink` is the Settings section: pick one
* of your own journals, everybody of the trip ticked as a reader by default,
* the people who would get in for the first time named before the save.
*/
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { seedStore } from '../../../tests/helpers/store';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
import { useTripJournal, useTripJournalLink, useTripJournalSectionVisible } from './useTripJournalLink';
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const listeners = new Set<(e: Record<string, unknown>) => void>();
vi.mock('../../api/websocket', async (importOriginal) => ({
...(await importOriginal<typeof import('../../api/websocket')>()),
addListener: (fn: (e: Record<string, unknown>) => void) => {
listeners.add(fn);
},
removeListener: (fn: (e: Record<string, unknown>) => void) => {
listeners.delete(fn);
},
}));
const TRIP = 4;
const ME = 1;
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
const LINKED: TripJournalLinkResponse = {
link: {
journey_id: 9,
title: 'Japan',
subtitle: null,
cover_image: null,
owner_id: ME,
linked_by: ME,
created_at: 1,
my_role: 'owner',
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
},
can_link: true,
can_unlink: true,
can_edit_members: true,
};
const CANDIDATES: TripJournalCandidate[] = [
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: false, access: 'editor' },
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
];
let gets = 0;
let answer: TripJournalLinkResponse = EMPTY;
const writes: { method: string; body: unknown }[] = [];
function routes({ writeStatus = 200 } = {}) {
server.use(
http.get('/api/trips/:tripId/journal', () => {
gets += 1;
return HttpResponse.json(answer);
}),
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
http.get('/api/journeys', () =>
HttpResponse.json({
journeys: [
{ id: 9, user_id: ME, title: 'Japan' },
{ id: 10, user_id: 99, title: 'Not mine' },
],
})
),
http.put('/api/trips/:tripId/journal', async ({ request }) => {
writes.push({ method: 'PUT', body: await request.json() });
return writeStatus === 200
? HttpResponse.json(LINKED)
: HttpResponse.json({ error: 'no' }, { status: writeStatus });
}),
http.patch('/api/trips/:tripId/journal/members', async ({ request }) => {
writes.push({ method: 'PATCH', body: await request.json() });
return HttpResponse.json(LINKED);
}),
http.delete('/api/trips/:tripId/journal', () => {
writes.push({ method: 'DELETE', body: null });
return HttpResponse.json(EMPTY);
})
);
}
function journeyAddon(enabled: boolean): void {
useAddonStore.setState({
loaded: true,
addons: enabled ? [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }] : [],
});
}
beforeEach(() => {
gets = 0;
answer = EMPTY;
writes.length = 0;
listeners.clear();
vi.clearAllMocks();
useTripJournalLinkStore.setState({ byTrip: {} });
seedStore(useAuthStore, { user: buildUser({ id: ME }) });
journeyAddon(true);
routes();
});
afterEach(() => _resetNetworkMode());
describe('useTripJournal', () => {
it('FE-TJL-HOOK-001: with the Journey addon off it asks nothing and there is no journal', async () => {
journeyAddon(false);
const { result } = renderHook(() => useTripJournal(TRIP));
expect(result.current.openJournalId).toBeNull();
expect(result.current.settled).toBe(true);
await new Promise((r) => setTimeout(r, 30));
expect(gets).toBe(0);
});
it('FE-TJL-HOOK-002: a linked journal open to this person is the one the tab opens', async () => {
answer = LINKED;
const { result } = renderHook(() => useTripJournal(TRIP));
expect(result.current.settled).toBe(false);
await waitFor(() => expect(result.current.openJournalId).toBe(9));
expect(result.current.settled).toBe(true);
});
it('FE-TJL-HOOK-003: a link that leaves this person out gives no tab', async () => {
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, my_role: null } };
const { result } = renderHook(() => useTripJournal(TRIP));
await waitFor(() => expect(result.current.link).not.toBeNull());
expect(result.current.openJournalId).toBeNull();
});
it('FE-TJL-HOOK-004: trip:journal:changed asks again', async () => {
const { result } = renderHook(() => useTripJournal(TRIP));
await waitFor(() => expect(gets).toBe(1));
answer = LINKED;
act(() => {
for (const fn of [...listeners]) fn({ type: 'trip:journal:changed' });
});
await waitFor(() => expect(result.current.openJournalId).toBe(9));
});
it('FE-TJL-HOOK-005: offline it asks nothing and is settled', () => {
setForcedOffline(true);
const { result } = renderHook(() => useTripJournal(TRIP));
expect(result.current.settled).toBe(true);
expect(gets).toBe(0);
});
it('FE-TJL-HOOK-006: without a trip id it asks nothing', async () => {
renderHook(() => useTripJournal(null));
await new Promise((r) => setTimeout(r, 30));
expect(gets).toBe(0);
});
it('FE-TJL-HOOK-007: the Settings section shows for someone who may link, and not for someone who may not', async () => {
const { result, rerender } = renderHook(() => useTripJournalSectionVisible(TRIP));
await waitFor(() => expect(result.current).toBe(true));
act(() => useTripJournalLinkStore.setState({ byTrip: { [TRIP]: { ...EMPTY, can_link: false } } }));
rerender();
expect(result.current).toBe(false);
});
});
describe('useTripJournalLink', () => {
it('FE-TJL-HOOK-008: picking offers only my journals and ticks everybody as a reader', async () => {
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
act(() => result.current.startPick());
await waitFor(() => expect(result.current.journals).toEqual([{ id: 9, title: 'Japan', subtitle: null }]));
act(() => result.current.pickJournal(9));
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
expect(result.current.choice).toEqual({ 2: 'viewer', 3: 'viewer' });
// lena is in already; tom would be let in for the first time.
expect(result.current.newlyGranted.map((c) => c.username)).toEqual(['tom']);
expect(result.current.canSave).toBe(true);
});
it('FE-TJL-HOOK-009: saving a pick links with the chosen roles and goes back to the view', async () => {
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
act(() => result.current.startPick());
act(() => result.current.pickJournal(9));
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
act(() => {
result.current.setRole(2, 'editor');
result.current.setRole(3, null);
});
expect(result.current.newlyGranted).toEqual([]);
await act(async () => {
await result.current.save();
});
expect(writes).toEqual([{ method: 'PUT', body: { journey_id: 9, members: [{ user_id: 2, role: 'editor' }] } }]);
expect(toast.success).toHaveBeenCalledWith('Journal linked');
expect(result.current.mode).toBe('view');
expect(result.current.link?.title).toBe('Japan');
});
it('FE-TJL-HOOK-010: editing members starts from who the link holds and saves them', async () => {
answer = LINKED;
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.link).not.toBeNull());
act(() => result.current.editMembers());
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
expect(result.current.choice).toEqual({ 2: 'editor', 3: null });
act(() => result.current.setAll('viewer'));
await act(async () => {
await result.current.save();
});
expect(writes).toEqual([
{
method: 'PATCH',
body: {
members: [
{ user_id: 2, role: 'viewer' },
{ user_id: 3, role: 'viewer' },
],
},
},
]);
expect(toast.success).toHaveBeenCalledWith('Access saved');
});
it('FE-TJL-HOOK-011: unlinking takes the journal off the trip', async () => {
answer = LINKED;
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.canUnlink).toBe(true));
await act(async () => {
await result.current.unlink();
});
expect(writes).toEqual([{ method: 'DELETE', body: null }]);
expect(result.current.link).toBeNull();
expect(toast.success).toHaveBeenCalledWith('Journal unlinked');
});
it('FE-TJL-HOOK-012: a refused save says it was not allowed and keeps the form open', async () => {
routes({ writeStatus: 403 });
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
act(() => result.current.startPick());
act(() => result.current.pickJournal(9));
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
await act(async () => {
await result.current.save();
});
expect(toast.error).toHaveBeenCalledWith('You are not allowed to do that.');
expect(result.current.mode).toBe('pick');
expect(result.current.busy).toBe(false);
});
it('FE-TJL-HOOK-013: offline, picking says a connection is needed', async () => {
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
setForcedOffline(true);
act(() => result.current.startPick());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Linking a journal needs a connection.'));
expect(result.current.journals).toEqual([]);
});
it('FE-TJL-HOOK-014: a failing candidate list is reported and cancel goes back to the view', async () => {
server.use(
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ error: 'x' }, { status: 500 }))
);
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
act(() => result.current.startPick());
act(() => result.current.pickJournal(9));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('That did not work. Please try again.'));
expect(result.current.candidates).toEqual([]);
act(() => result.current.cancel());
expect(result.current.mode).toBe('view');
expect(result.current.canSave).toBe(false);
});
});
@@ -0,0 +1,228 @@
import type { TripJournalCandidate, TripJournalLinkRole } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { useTranslation } from '../../i18n';
import { tripJournalLinkRepo, TripJournalOfflineError, type OwnJournal } from '../../repo/tripJournalLinkRepo';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
import { useServerPing } from '../../sync/useServerPing';
import { useToast } from '../shared/Toast';
/** Who a link lets in, as the member list edits it: a role, or left out. */
export type JournalMemberChoice = Record<number, TripJournalLinkRole | null>;
/** The status of a failed request, when the server gave one. */
function statusOf(err: unknown): number | undefined {
return (err as { response?: { status?: number } })?.response?.status;
}
/**
* The trip's linked journal as everybody on the trip reads it: answered once per
* trip from the store, asked again on mount, when the connection returns and on
* every `trip:journal:changed`. With the Journey addon off there is no journal.
*/
export function useTripJournal(tripId: number | null | undefined) {
const addonsLoaded = useAddonStore((s) => s.loaded);
const journeyOn = useAddonStore((s) => s.isEnabled('journey'));
const answer = useTripJournalLinkStore((s) => (tripId != null ? s.byTrip[String(tripId)] : undefined));
const refresh = useTripJournalLinkStore((s) => s.refresh);
const { offline } = useNetworkMode();
const active = journeyOn && tripId != null && !offline;
const reload = useCallback(async () => {
if (tripId != null) await refresh(tripId);
}, [tripId, refresh]);
useEffect(() => {
if (active) void reload();
}, [active, reload]);
useServerPing('trip:journal:changed', active, reload);
const link = journeyOn ? (answer?.link ?? null) : null;
return {
journeyOn,
answer: journeyOn ? answer : undefined,
link,
/** The journal the trip's Journal tab opens: linked, and open to this person. */
openJournalId: link?.my_role ? link.journey_id : null,
/** Whether the answer is in, or none is coming (addon off, offline): the planner may drop the tab then. */
settled: addonsLoaded && (!journeyOn || offline || answer !== undefined),
};
}
/** Whether the trip's Settings carry a Journal section for this person, for laying the dialog out. */
export function useTripJournalSectionVisible(tripId: number): boolean {
const { journeyOn, answer } = useTripJournal(tripId);
return journeyOn && !!answer && (answer.can_link || !!answer.link);
}
/**
* The Journal section of the trip's Settings: link one of your journals, choose
* who it is open to and as what, change it, or take it off. One hook for the
* desktop dialog and the phone sheet, so both offer and warn about the same thing.
*
* Picking a journal asks the server who of the trip already has access to it;
* whoever would be let in for the first time is named before anything is saved,
* because they will see the journal in their own Journey list from then on.
*/
export function useTripJournalLink(tripId: number) {
const { t } = useTranslation();
const toast = useToast();
const userId = useAuthStore((s) => s.user?.id ?? null);
const { journeyOn, answer, link } = useTripJournal(tripId);
const store = useTripJournalLinkStore.getState;
/** 'view' shows the link; 'pick' chooses a journal; 'members' changes who it is open to. */
const [mode, setMode] = useState<'view' | 'pick' | 'members'>('view');
const [journals, setJournals] = useState<OwnJournal[] | null>(null);
const [pickedId, setPickedId] = useState<number | null>(null);
const [candidates, setCandidates] = useState<TripJournalCandidate[] | null>(null);
const [choice, setChoice] = useState<JournalMemberChoice>({});
const [busy, setBusy] = useState(false);
const fail = useCallback(
(err: unknown) => {
if (err instanceof TripJournalOfflineError) toast.error(t('trip.journal.errorOffline'));
else if (statusOf(err) === 403) toast.error(t('trip.journal.errorForbidden'));
else toast.error(t('trip.journal.errorGeneric'));
},
[t, toast]
);
// The people of the trip for the chosen journal, each ticked as they stand:
// on the link with their role, or (for a journal not linked yet) everybody as
// a reader, the default this section promises.
useEffect(() => {
const journeyId = mode === 'members' ? (link?.journey_id ?? null) : mode === 'pick' ? pickedId : null;
if (journeyId == null) return;
let cancelled = false;
tripJournalLinkRepo
.candidates(tripId, journeyId)
.then((list) => {
if (cancelled) return;
setCandidates(list);
const onLink = new Map(
(link?.journey_id === journeyId ? (link.members ?? []) : []).map((m) => [m.user_id, m.role])
);
const keepCurrent = mode === 'members' || onLink.size > 0;
setChoice(
Object.fromEntries(list.map((c) => [c.user_id, keepCurrent ? (onLink.get(c.user_id) ?? null) : 'viewer']))
);
})
.catch((err: unknown) => {
if (!cancelled) {
fail(err);
setCandidates([]);
}
});
return () => {
cancelled = true;
};
}, [mode, pickedId, link, tripId, fail]);
const startPick = useCallback(() => {
setCandidates(null);
setMode('pick');
setPickedId(null);
if (userId == null) return;
tripJournalLinkRepo
.ownJournals(userId)
.then(setJournals)
.catch((err: unknown) => {
fail(err);
setJournals([]);
});
}, [userId, fail]);
// A new pick shows its own people only: the last journal's stay out until they load.
const pickJournal = useCallback((journeyId: number | null) => {
setCandidates(null);
setPickedId(journeyId);
}, []);
const editMembers = useCallback(() => {
setCandidates(null);
setMode('members');
}, []);
const cancel = useCallback(() => {
setMode('view');
setPickedId(null);
setCandidates(null);
}, []);
const setRole = useCallback((memberId: number, role: TripJournalLinkRole | null) => {
setChoice((prev) => ({ ...prev, [memberId]: role }));
}, []);
const setAll = useCallback(
(role: TripJournalLinkRole | null) => {
setChoice(Object.fromEntries((candidates ?? []).map((c) => [c.user_id, role])));
},
[candidates]
);
const chosenMembers = useMemo(
() => Object.entries(choice).flatMap(([id, role]) => (role ? [{ user_id: Number(id), role }] : [])),
[choice]
);
/** The people the save would let into the journal for the first time; the warning names them. */
const newlyGranted = useMemo(
() => (candidates ?? []).filter((c) => c.access === null && choice[c.user_id]),
[candidates, choice]
);
const run = useCallback(
async (write: () => Promise<void>, done: string) => {
setBusy(true);
try {
await write();
toast.success(t(done));
cancel();
} catch (err: unknown) {
fail(err);
} finally {
setBusy(false);
}
},
[cancel, fail, t, toast]
);
const save = useCallback(async () => {
if (mode === 'pick' && pickedId != null) {
await run(() => store().link(tripId, pickedId, chosenMembers), 'trip.journal.linked');
} else if (mode === 'members') {
await run(() => store().setMembers(tripId, chosenMembers), 'trip.journal.membersSaved');
}
}, [mode, pickedId, run, store, tripId, chosenMembers]);
const unlink = useCallback(async () => {
await run(() => store().unlink(tripId), 'trip.journal.unlinked');
}, [run, store, tripId]);
return {
/** The section shows at all: the addon is on and this person may link, or the trip has a journal they see. */
visible: journeyOn && !!answer && (answer.can_link || !!answer.link),
link,
canLink: !!answer?.can_link,
canUnlink: !!answer?.can_unlink,
canEditMembers: !!answer?.can_edit_members,
mode,
startPick,
cancel,
editMembers,
journals,
pickedId,
pickJournal,
candidates,
choice,
setRole,
setAll,
newlyGranted,
canSave: !busy && (mode === 'members' || (mode === 'pick' && pickedId != null)),
busy,
save,
unlink,
};
}
+2
View File
@@ -20,6 +20,7 @@ export const TRIP_TAB_IDS = [
'finanzplan',
'dateien',
'collab',
'journal',
] as const
export type TripTabId = (typeof TRIP_TAB_IDS)[number]
@@ -40,6 +41,7 @@ export const TRIP_TAB_LABEL_KEYS: Record<TripTabId, string> = {
finanzplan: 'trip.tabs.budget',
dateien: 'trip.tabs.files',
collab: 'admin.addons.catalog.collab.name',
journal: 'trip.tabs.journal',
}
export const isTripTabId = (value: unknown): value is TripTabId =>
+8
View File
@@ -824,6 +824,14 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
:root { --nav-h: calc(64px + env(safe-area-inset-top, 0px)); }
}
/* A journal shown inside a trip (its Journal tab): the journal's own fixed bars
measure themselves from --nav-h, so below the trip's navbar they also have to
clear the trip's tab bar. */
.trip-embedded-journal { --nav-h: 44px; }
@media (min-width: 768px) {
.trip-embedded-journal { --nav-h: calc(64px + env(safe-area-inset-top, 0px) + 44px); }
}
.dark {
--bg-primary: #121215;
--bg-secondary: #1a1a1e;
+4
View File
@@ -132,6 +132,10 @@
to { opacity: 0; transform: translateY(6px); }
}
/* A journal inside a trip's Journal tab starts below the trip's own floating
top controls rather than under them. */
.m-embedded-journal { --m-safe-top: calc(env(safe-area-inset-top, 0px) + 62px); }
.m-sheet-in { animation: m-sheet-in 280ms var(--ease-drawer) both; }
.m-sheet-out { animation: m-sheet-out 280ms var(--ease-drawer) both; }
.m-drawer-in { animation: m-drawer-in 280ms var(--ease-drawer) both; }
@@ -7,7 +7,7 @@ import ContributorInviteDialog from '../../../components/Journey/ContributorInvi
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyDetail, type JourneyDetailOptions } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyStore } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore'
import DawarichIcon from '../../../components/shared/DawarichIcon'
@@ -32,8 +32,12 @@ import MJourneySettingsSheet from './MJourneySettingsSheet'
* Journey detail — integrated map with the horizontal 280px card timeline
* (Journey tab) and the 2-column photo gallery (Gallery tab). Cards and map
* markers stay in sync; tapping the centered card opens the entry sheet.
*
* A trip that links the journal shows this same screen in its own tab
* (`embedded`): the trip's back button and controls sit on top, so the screen
* drops its own back button and leaves the trip's URL alone.
*/
export default function MJourneyDetail() {
export default function MJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
const {
id, navigate, toast, t,
current, loading,
@@ -48,7 +52,7 @@ export default function MJourneyDetail() {
dismissSuggestion, restoreSuggestions, openAtEntryId,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
addPickedProviderPhotos, addEntryProviderPhotos,
} = useJourneyDetail()
} = useJourneyDetail({ journeyId, embedded })
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the
// upload button, so it has to know which tab is open — and it stops knowing
@@ -175,7 +179,7 @@ export default function MJourneyDetail() {
const [params, setParams] = useSearchParams()
const [pendingUpload, setPendingUpload] = useState(false)
useEffect(() => {
if (params.get('create') !== 'photo') return
if (embedded || params.get('create') !== 'photo') return
setParams(prev => {
const next = new URLSearchParams(prev)
next.delete('create')
@@ -184,7 +188,7 @@ export default function MJourneyDetail() {
if (!canEditEntries) return
setView('gallery')
setPendingUpload(true)
}, [params, setParams, canEditEntries, setView])
}, [params, setParams, canEditEntries, setView, embedded])
useEffect(() => {
if (!pendingUpload || !providersReady) return
@@ -331,14 +335,18 @@ export default function MJourneyDetail() {
{/* Header: back — segment — upload / overflow menu */}
<div className="absolute inset-x-4 top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2">
<button
type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
>
<ChevronLeft size={18} strokeWidth={2.2} />
</button>
{embedded ? (
<span className="w-[38px] flex-none" aria-hidden />
) : (
<button
type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
>
<ChevronLeft size={18} strokeWidth={2.2} />
</button>
)}
<div className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]">
<button
type="button"
@@ -1,4 +1,4 @@
import { ChevronRight, FileDown, Settings, Share2 } from 'lucide-react'
import { ChevronRight, FileDown, Settings, Settings2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import type { MTripSheetsProps } from '../MTripShell'
@@ -14,6 +14,7 @@ const TILE_COLORS: Record<string, string> = {
finanzplan: '#E8A33D',
dateien: '#68686F',
collab: '#EC4899',
journal: '#C2410C',
}
/**
@@ -48,7 +49,7 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
}
const actions: { id: string; icon: LucideIcon; label: string; go: () => void }[] = [
{ id: 'members', icon: Share2, label: t('members.shareTrip'), go: () => shell.openSheet('members') },
{ id: 'members', icon: Settings2, label: t('trip.settings.title'), go: () => shell.openSheet('members') },
{ id: 'export', icon: FileDown, label: t('mobileTrip.export'), go: () => shell.openSheet('export') },
...(canEditTrip
? [{ id: 'tripedit', icon: Settings, label: t('dashboard.editTrip'), go: () => shell.openSheet('tripedit') }]
@@ -7,6 +7,7 @@ import MFilesTab from './MFilesTab'
import MCollabTab from './MCollabTab'
import MListsTab from './MListsTab'
import MRoadtripTab from '../roadtrip/MRoadtripTab'
import MJourneyDetail from '../../journey/MJourneyDetail'
import PluginFrame from '../../../../components/Plugins/PluginFrame'
/**
@@ -61,6 +62,14 @@ export default function MTripTabPanel({ planner, shell, tab }: MTripTabPanelProp
// switch between them must not lose it.
case 'roadtrip':
return <MRoadtripTab planner={planner} shell={shell} tab={tab} />
// The journal the trip links: the Journey addon's own phone screen, moved
// below the trip's floating top controls (see .m-embedded-journal).
case 'journal':
return planner.journalId == null ? null : (
<div className="m-embedded-journal absolute inset-0 overflow-hidden">
<MJourneyDetail journeyId={planner.journalId} embedded />
</div>
)
default:
return <TabScroller>{null}</TabScroller>
}
+37 -24
View File
@@ -18,7 +18,7 @@ import JourneyDayDawarich from '../components/Journey/JourneyDayDawarich'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useJourneyStore } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { useJourneyDetail } from './journeyDetail/useJourneyDetail'
import { useJourneyDetail, type JourneyDetailOptions } from './journeyDetail/useJourneyDetail'
import { createDraftJourneyEntry, pickGradient, groupByDate, formatDate, photoUrl, matchJourneyEntries } from './journeyDetail/JourneyDetailPage.helpers'
import { EntryCard, SkeletonCard, CheckinCard } from '../components/Journey/JourneyDetailPageEntryCard'
import { GalleryView } from '../components/Journey/JourneyDetailPageGalleryView'
@@ -39,7 +39,12 @@ export default function JourneyDetailPage() {
)
}
function JourneyDetailPageDesktop() {
/**
* The journal screen. The journal page renders it for the route's journal; a trip
* that links a journal renders the same screen inside its own tab (`embedded`),
* so the trip shows the journal with nothing of it built twice.
*/
export function JourneyDetailPageDesktop({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
// Page = wiring container: load + live sync, view state, dialogs, the
// scroll-synced map and the map/trip-date derivations live in the hook.
const {
@@ -62,13 +67,17 @@ function JourneyDetailPageDesktop() {
feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
addPickedProviderPhotos, addEntryProviderPhotos,
} = useJourneyDetail()
} = useJourneyDetail({ journeyId, embedded })
// Inside a trip the trip draws the bar and owns the height; the journal fills
// the box it is given and starts at its top.
const frame = embedded ? 'h-full bg-zinc-50 dark:bg-zinc-950' : 'min-h-screen bg-zinc-50 dark:bg-zinc-950'
const below = embedded ? undefined : { paddingTop: 'var(--nav-h, 0px)' }
if (loading || !current) {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar />
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className="flex justify-center py-20">
<div className={frame}>
{!embedded && <Navbar />}
<div style={below} className="flex justify-center py-20">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
</div>
</div>
@@ -166,8 +175,8 @@ function JourneyDetailPageDesktop() {
) : null
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar />
<div className={frame}>
{!embedded && <Navbar />}
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */}
{showMobileCombined && (
@@ -206,13 +215,15 @@ function JourneyDetailPageDesktop() {
className="fixed inset-x-0 z-30 flex items-start justify-between gap-2 px-4"
style={{ top: 'calc(var(--nav-h, 56px) + 12px)' }}
>
<button type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className={barButton}
>
<ArrowLeft size={16} />
</button>
{!embedded && (
<button type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className={barButton}
>
<ArrowLeft size={16} />
</button>
)}
<div className="flex-1 min-w-0 flex justify-center">
<div className="flex bg-surface-elevated backdrop-blur-lg border border-edge rounded-lg overflow-hidden shadow-lg">
@@ -270,7 +281,7 @@ function JourneyDetailPageDesktop() {
</div>
)}
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className={showMobileCombined ? 'hidden' : ''}>
<div style={below} className={showMobileCombined ? 'hidden' : embedded ? 'h-full' : ''}>
<div
className={
isMobile
@@ -310,13 +321,15 @@ function JourneyDetailPageDesktop() {
<div className="relative z-[3] flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<button type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
>
<ArrowLeft size={14} />
</button>
{!embedded && (
<button type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
>
<ArrowLeft size={14} />
</button>
)}
{/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */}
<div className="hidden md:flex items-center gap-2">
{lifecycle !== 'live' && lifecycle !== 'archived' && (
@@ -821,7 +834,7 @@ function JourneyDetailPageDesktop() {
{/* TREK Studio. It portals itself over the page, so this journey keeps
rendering underneath and shows through the panel's margin. */}
<Outlet />
{!embedded && <Outlet />}
</div>
)
}
+4 -4
View File
@@ -39,7 +39,7 @@ import { latestReservation } from './tripPlanner/transportEditorOpeners'
import {
ReservationsPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel,
RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal,
TourWorkspaceRail, TourPlannerToursRail, TransportModal,
TourWorkspaceRail, TourPlannerToursRail, TransportModal, TripJournalPanel,
} from './tripPlanner/plannerLazy'
import { LazyPanel } from './tripPlanner/LazyPanel'
import { ListsContainer } from './tripPlanner/ListsContainer'
@@ -70,7 +70,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
enabledAddons, collabFeatures, journalId, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewActive, tripOverview, toggleOverview, overviewShown,
dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates,
@@ -242,7 +242,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
return (
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
<HelpAnchor id={helpId} />
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onSettings={() => setShowMembersModal(true)} />
<div className="bg-surface-elevated border-b border-edge-faint" style={{
position: 'fixed', top: 'var(--nav-h)', insetInline: 0, zIndex: 40,
@@ -792,7 +792,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
</LazyPanel>
</div>
)}
{activeTab === 'journal' && <LazyPanel id="journal"><TripJournalPanel journeyId={journalId} /></LazyPanel>}
{activeTab.startsWith('plugin:') && (
<div style={{ position: 'absolute', top: 0, insetInline: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
@@ -29,9 +29,23 @@ const NO_TRACKS: JourneyTrack[] = []
* map/trip-date derivations. JourneyDetailPage stays a wiring container around
* its large two-pane JSX and many presentational sub-components.
* Behaviour is identical to the previous in-component logic.
*
* The journal can also show inside a trip that links it, as the same screen with
* the same dialogs. There the trip names the journal (`journeyId`) instead of the
* route, and `embedded` keeps the screen to its own box: no scroll lock on the
* page, no `?create=` handling on the trip's URL, and no trip out to the journal
* list when the journal goes away; the trip decides what shows then.
*/
export function useJourneyDetail() {
const { id } = useParams()
export interface JourneyDetailOptions {
/** The journal to show; the route's `:id` when left out. */
journeyId?: number
/** Shown inside another screen (a trip) rather than as the journal page. */
embedded?: boolean
}
export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
const params = useParams()
const id = journeyId != null ? String(journeyId) : params.id
const navigate = useNavigate()
const toast = useToast()
const { t, locale } = useTranslation()
@@ -67,9 +81,10 @@ export function useJourneyDetail() {
const [showAddTrip, setShowAddTrip] = useState(false)
const [searchParams, setSearchParams] = useSearchParams()
// The bottom-nav "+" starts a new entry via ?create=entry.
// The bottom-nav "+" starts a new entry via ?create=entry. Not inside a trip:
// the trip's own `create` values live on that same URL.
useEffect(() => {
if (searchParams.get('create') === 'entry' && current && canEditEntries) {
if (!embedded && searchParams.get('create') === 'entry' && current && canEditEntries) {
setEditingEntry(createDraftJourneyEntry(current.id))
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
}
@@ -119,11 +134,11 @@ export function useJourneyDetail() {
}, [current?.hide_skeletons])
useEffect(() => {
if (notFound) {
if (notFound && !embedded) {
toast.error(t('journey.notFound'))
navigate('/journey')
}
}, [notFound])
}, [notFound, embedded])
// WebSocket real-time updates
useEffect(() => {
@@ -374,9 +389,9 @@ export function useJourneyDetail() {
// the body must not scroll underneath it. Goes through the shared counter so a
// modal opening on top of it cannot hand the page back early (#1809).
useEffect(() => {
if (isMobile) return
if (isMobile || embedded) return
return lockBodyScroll()
}, [isMobile])
}, [isMobile, embedded])
// Map only shows real journal entries — skeletons are trip-derived
// suggestions, not something the user actually journaled at that spot.
@@ -549,7 +564,7 @@ export function useJourneyDetail() {
}, [])
return {
id, navigate, toast, t, locale,
id, navigate, toast, t, locale, embedded, notFound,
openStudio, prefetchStudio,
current, loading,
canEditEntries, canEditJourney, myRole,
@@ -0,0 +1,16 @@
import { JourneyDetailPageDesktop } from '../JourneyDetailPage';
/**
* The trip's Journal tab on the desktop: the Journey addon's own journal view,
* not a copy of it, so the journal looks and works the same in both places and
* stays live through the journey's own sync. The wrapper moves the view's fixed
* bars below the trip's tab bar (see .trip-embedded-journal).
*/
export default function TripJournalPanel({ journeyId }: { journeyId: number | null }) {
if (journeyId == null) return null;
return (
<div className="trip-embedded-journal absolute inset-0 overflow-y-auto">
<JourneyDetailPageDesktop journeyId={journeyId} embedded />
</div>
);
}
@@ -17,6 +17,8 @@ export const ExpenseModal = lazyWithRetry(() =>
import('../../components/Budget/CostsPanel').then(m => ({ default: m.ExpenseModal }))
)
export const CollabPanel = lazyWithRetry(() => import('../../components/Collab/CollabPanel'))
// The Journey addon's journal view, which only a trip with a linked journal opens.
export const TripJournalPanel = lazyWithRetry(() => import('./TripJournalPanel'))
export const RoadtripSidebar = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripSidebar'))
export const RoadtripCorridorPanel = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripCorridorPanel'))
export const RoadtripLimitsCard = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripLimitsCard'))
@@ -24,6 +24,8 @@ import { isServiceStopType } from '../../components/Roadtrip/roadtripModel'
import { inspectorStay } from '../../components/Roadtrip/stayReading'
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { useTourPlaceIds } from '../../hooks/useTourPlaceIds'
import { LinkedJournalIcon } from '../../components/Trips/LinkedJournalIcon'
import { useTripJournal } from '../../components/Trips/useTripJournalLink'
import { useAddonStore } from '../../store/addonStore'
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useIsTouch } from '../../hooks/useIsTouch'
@@ -198,6 +200,7 @@ export function useTripPlanner() {
// A trip-page plugin may replace core tabs while it's active (its manifest names
// them; 'plan' is never replaceable) and may pick where its own tab sits.
const journal = useTripJournal(Number.isFinite(tripId) ? tripId : null)
const replacedTabs = new Set(tripPagePlugins.flatMap(p => p.tripPage?.replaces ?? []))
const TRIP_TABS = [
{ id: 'plan', label: t(TRIP_TAB_LABEL_KEYS.plan), icon: Map },
@@ -211,6 +214,8 @@ export function useTripPlanner() {
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t(TRIP_TAB_LABEL_KEYS.finanzplan), icon: Wallet }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t(TRIP_TAB_LABEL_KEYS.dateien), icon: FolderOpen }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t(TRIP_TAB_LABEL_KEYS.collab), icon: Users }] : []),
// A journal from the Journey addon tied to this trip, for whoever the link lets in.
...(journal.openJournalId != null ? [{ id: 'journal', label: t(TRIP_TAB_LABEL_KEYS.journal), icon: LinkedJournalIcon }] : []),
].filter(tab => tab.id === 'plan' || !replacedTabs.has(tab.id))
// Positioned plugin tabs splice in ascending order so two positions stay stable;
// the rest append, exactly as before this capability existed.
@@ -237,12 +242,14 @@ export function useTripPlanner() {
// Same for the addon-owned tabs: until the feed answers, enabledAddons is a
// guess, and evicting on a guess would drop a legitimately requested tab.
if (!addonsLoaded) return
// And for the Journal tab, until the link has been asked for.
if (activeTab === 'journal' && !journal.settled) return
const validTabIds = TRIP_TABS.map(t => t.id)
if (!validTabIds.includes(activeTab)) {
setActiveTab('plan')
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
}
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile])
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile, journal.settled, journal.openJournalId])
const handleTabChange = (rawTabId: string): void => {
// A core tab a plugin replaced is gone from the bar, but a programmatic jump
@@ -653,7 +660,7 @@ export function useTripPlanner() {
trip, days, places, assignments, storedAssignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
enabledAddons, collabFeatures, journalId: journal.openJournalId, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewShown, toggleOverview, overviewActive, tripOverview,
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates, dawarichEnabled: !!enabledAddons.dawarich,
+106
View File
@@ -0,0 +1,106 @@
/**
* FE-TJL-REPO-001 to FE-TJL-REPO-006
*
* `tripJournalLinkRepo` is online-only on purpose (who a journal is open to is
* the server's answer, never a cached guess), so what is pinned is that it
* refuses before a request while offline, that every call reaches its route
* with the body the contract names, and that the picker only offers journals
* the person owns.
*/
import type { TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { afterEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { _resetNetworkMode, setForcedOffline } from '../sync/networkMode';
import { tripJournalLinkRepo, TripJournalOfflineError } from './tripJournalLinkRepo';
const NONE: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
afterEach(() => _resetNetworkMode());
describe('tripJournalLinkRepo', () => {
it('FE-TJL-REPO-001: refuses every call while offline, before a request leaves', async () => {
let calls = 0;
server.use(
http.get('/api/trips/:tripId/journal', () => {
calls += 1;
return HttpResponse.json(NONE);
})
);
setForcedOffline(true);
await expect(tripJournalLinkRepo.get(4)).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.candidates(4, 1)).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.link(4, 1, [])).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.setMembers(4, [])).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.unlink(4)).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.ownJournals(1)).rejects.toBeInstanceOf(TripJournalOfflineError);
expect(calls).toBe(0);
});
it('FE-TJL-REPO-002: reads the trip link', async () => {
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json(NONE)));
expect(await tripJournalLinkRepo.get(4)).toEqual(NONE);
});
it('FE-TJL-REPO-003: asks for the candidates of one journal and hands back the list', async () => {
let asked: string | null = null;
server.use(
http.get('/api/trips/4/journal/candidates', ({ request }) => {
asked = new URL(request.url).searchParams.get('journey_id');
return HttpResponse.json({
candidates: [{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: false, access: null }],
});
})
);
const list = await tripJournalLinkRepo.candidates(4, 9);
expect(asked).toBe('9');
expect(list.map((c) => c.username)).toEqual(['lena']);
});
it('FE-TJL-REPO-004: links, sets members and unlinks with the bodies the contract names', async () => {
const bodies: unknown[] = [];
server.use(
http.put('/api/trips/4/journal', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json(NONE);
}),
http.patch('/api/trips/4/journal/members', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json(NONE);
}),
http.delete('/api/trips/4/journal', () => HttpResponse.json(NONE))
);
await tripJournalLinkRepo.link(4, 9, [{ user_id: 2, role: 'viewer' }]);
await tripJournalLinkRepo.setMembers(4, [{ user_id: 2, role: 'editor' }]);
expect(await tripJournalLinkRepo.unlink(4)).toEqual(NONE);
expect(bodies).toEqual([
{ journey_id: 9, members: [{ user_id: 2, role: 'viewer' }] },
{ members: [{ user_id: 2, role: 'editor' }] },
]);
});
it('FE-TJL-REPO-005: offers only the journals the person owns', async () => {
server.use(
http.get('/api/journeys', () =>
HttpResponse.json({
journeys: [
{ id: 1, user_id: 7, title: 'Mine', subtitle: 'Japan' },
{ id: 2, user_id: 8, title: 'Theirs' },
{ id: 3, user_id: 7, title: 'Also mine' },
],
})
)
);
expect(await tripJournalLinkRepo.ownJournals(7)).toEqual([
{ id: 1, title: 'Mine', subtitle: 'Japan' },
{ id: 3, title: 'Also mine', subtitle: null },
]);
});
it('FE-TJL-REPO-006: the offline error says what it needs', () => {
const err = new TripJournalOfflineError();
expect(err.name).toBe('TripJournalOfflineError');
expect(err.message).toMatch(/connection/);
});
});
+70
View File
@@ -0,0 +1,70 @@
import type { TripJournalCandidate, TripJournalLinkMemberRequest, TripJournalLinkResponse } from '@trek/shared';
import { tripJournalApi } from '../api/tripJournal';
import { isEffectivelyOffline } from '../sync/networkMode';
/** A journal the person owns, as the link picker lists it. */
export interface OwnJournal {
id: number;
title: string;
subtitle: string | null;
}
/**
* The journal a trip links, and who it is open to.
*
* Online-only with no Dexie table, for the reason docSyncRepo gives: every
* answer is the server's (who may see the link, who would be let in), and the
* journal it opens is online-only too, as the Journey addon is. Writing it
* offline would mean granting access nobody could check. The store in front of
* this keeps the last answer for the session.
*/
export class TripJournalOfflineError extends Error {
constructor() {
super('Linking a journal needs a connection');
this.name = 'TripJournalOfflineError';
}
}
function requireOnline(): void {
if (isEffectivelyOffline()) throw new TripJournalOfflineError();
}
export const tripJournalLinkRepo = {
async get(tripId: number): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.get(tripId);
},
async candidates(tripId: number, journeyId: number): Promise<TripJournalCandidate[]> {
requireOnline();
return (await tripJournalApi.candidates(tripId, journeyId)).candidates;
},
async link(
tripId: number,
journeyId: number,
members: TripJournalLinkMemberRequest[]
): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.link(tripId, journeyId, members);
},
async setMembers(tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.setMembers(tripId, members);
},
async unlink(tripId: number): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.unlink(tripId);
},
/** The journals `userId` owns, newest first, for the picker. Those they only contribute to are not theirs to link. */
async ownJournals(userId: number): Promise<OwnJournal[]> {
requireOnline();
const { journeys } = await tripJournalApi.journals();
return journeys
.filter((j) => j.user_id === userId)
.map((j) => ({ id: j.id, title: j.title, subtitle: j.subtitle ?? null }));
},
};
@@ -0,0 +1,81 @@
/**
* FE-TJL-STORE-001 to FE-TJL-STORE-005
*
* The store keeps the server's answer per trip so the Journal tab and the
* Settings dialog read the same one: a write replaces it with what the server
* answered, a 404/403 means there is no journal for this person, and any other
* failure leaves the last answer standing rather than flashing the tab away.
*/
import type { TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { useTripJournalLinkStore } from './tripJournalLinkStore';
const LINKED: TripJournalLinkResponse = {
link: {
journey_id: 9,
title: 'Japan',
subtitle: null,
cover_image: null,
owner_id: 1,
linked_by: 1,
created_at: 1,
my_role: 'owner',
members: [],
},
can_link: true,
can_unlink: true,
can_edit_members: true,
};
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
beforeEach(() => useTripJournalLinkStore.setState({ byTrip: {} }));
describe('tripJournalLinkStore', () => {
it('FE-TJL-STORE-001: refresh keeps the answer under the trip', async () => {
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json(LINKED)));
await useTripJournalLinkStore.getState().refresh(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
});
it('FE-TJL-STORE-002: a 404 (addon off, trip out of reach) is no journal at all', async () => {
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json({ error: 'gone' }, { status: 404 })));
await useTripJournalLinkStore.getState().refresh(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual({
link: null,
can_link: false,
can_unlink: false,
can_edit_members: false,
});
});
it('FE-TJL-STORE-003: any other failure leaves the last answer standing', async () => {
useTripJournalLinkStore.setState({ byTrip: { 4: LINKED } });
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
await useTripJournalLinkStore.getState().refresh(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
});
it('FE-TJL-STORE-004: link, setMembers and unlink each keep what the server answered', async () => {
server.use(
http.put('/api/trips/4/journal', () => HttpResponse.json(LINKED)),
http.patch('/api/trips/4/journal/members', () => HttpResponse.json({ ...LINKED, can_unlink: false })),
http.delete('/api/trips/4/journal', () => HttpResponse.json(EMPTY))
);
const s = useTripJournalLinkStore.getState();
await s.link(4, 9, []);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
await s.setMembers(4, []);
expect(useTripJournalLinkStore.getState().byTrip['4']?.can_unlink).toBe(false);
await s.unlink(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(EMPTY);
});
it('FE-TJL-STORE-005: a failed write rejects for its caller and keeps the last answer', async () => {
useTripJournalLinkStore.setState({ byTrip: { 4: EMPTY } });
server.use(http.put('/api/trips/4/journal', () => HttpResponse.json({ error: 'no' }, { status: 403 })));
await expect(useTripJournalLinkStore.getState().link(4, 9, [])).rejects.toBeTruthy();
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(EMPTY);
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { TripJournalLinkMemberRequest, TripJournalLinkResponse } from '@trek/shared';
import { create } from 'zustand';
import { tripJournalLinkRepo } from '../repo/tripJournalLinkRepo';
/**
* The journal each trip links, as this person may see it, remembered for the
* session so the trip's Journal tab and its Settings dialog render the last
* answer at once. Both read it from here, so the tab appears and goes the moment
* the dialog links or unlinks, without a second request.
*/
interface TripJournalLinkState {
/** Per trip id, the server's answer. Missing until asked. */
byTrip: Record<string, TripJournalLinkResponse>;
refresh: (tripId: number) => Promise<void>;
link: (tripId: number, journeyId: number, members: TripJournalLinkMemberRequest[]) => Promise<void>;
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]) => Promise<void>;
unlink: (tripId: number) => Promise<void>;
}
/** The answer for a trip someone cannot reach, or with the Journey addon off: no journal. */
const NONE: TripJournalLinkResponse = { link: null, can_link: false, can_unlink: false, can_edit_members: false };
export const useTripJournalLinkStore = create<TripJournalLinkState>((set) => {
const keep = (tripId: number, answer: TripJournalLinkResponse) =>
set((s) => ({ byTrip: { ...s.byTrip, [String(tripId)]: answer } }));
return {
byTrip: {},
refresh: async (tripId) => {
try {
keep(tripId, await tripJournalLinkRepo.get(tripId));
} catch (err) {
// 404 is the Journey addon switched off or a trip this person no longer
// reaches: both a real "no journal". Anything else, offline included, is
// no answer at all, and the last one stands.
const status = (err as { response?: { status?: number } })?.response?.status;
if (status === 404 || status === 403) keep(tripId, NONE);
}
},
// The writes answer with the link as it now stands, so the tab and the
// dialog move together; a failure is the caller's to report.
link: async (tripId, journeyId, members) =>
keep(tripId, await tripJournalLinkRepo.link(tripId, journeyId, members)),
setMembers: async (tripId, members) => keep(tripId, await tripJournalLinkRepo.setMembers(tripId, members)),
unlink: async (tripId) => keep(tripId, await tripJournalLinkRepo.unlink(tripId)),
};
});