feat(trips): Share and Save to list in the new dialog look

Share opens on the shared dialog frame: the trip's name and the number of
people in the head band, members and guests on the left, the public link
and the invite link as two cards on the right. Rows carry their actions as
icon buttons named by their tooltips, and removing a member, leaving,
handing over the trip or removing a guest asks in the app's own dialog
instead of the browser's. The invite link can no longer be created twice by
a double click. The phone, which opens the same dialog from its More sheet,
keeps the sheet it had; both run on one set of hooks.

Save to list has the place's name in the head band, Mark visited as a pill
beside it, the lists as rows with a tick, and View at its foot.

The public link's hint loses its dash in every language, and the help
guides and wiki pages of the Share dialog show the new look.
This commit is contained in:
Maurice
2026-09-28 13:19:04 +02:00
parent af9710c618
commit 3901aecbc0
52 changed files with 955 additions and 481 deletions
@@ -126,9 +126,9 @@ describe('SaveToCollectionModal', () => {
render(<SaveToCollectionModal />);
const favorites = await screen.findByRole('button', { name: /Favorites/ });
expect(favorites).toHaveClass('border-accent');
expect(favorites).toHaveAttribute('aria-pressed', 'true');
expect(favorites.querySelector('.lucide-bookmark-check')).not.toBeNull();
expect(screen.getByRole('button', { name: /Wishlist/ })).not.toHaveClass('border-accent');
expect(screen.getByRole('button', { name: /Wishlist/ })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-COMP-SAVETOCOL-007: a failing membership lookup degrades to "saved nowhere"', async () => {
@@ -248,7 +248,7 @@ describe('SaveToCollectionModal', () => {
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
await waitFor(() => expect(useSaveToCollectionStore.getState().version).toBe(1));
expect(screen.getByRole('button', { name: /Wishlist/ })).not.toHaveClass('border-accent');
expect(screen.getByRole('button', { name: /Wishlist/ })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-COMP-SAVETOCOL-016: a response landing after the picker closed is dropped', async () => {
@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState, useCallback } from 'react'
import React, { useEffect, useId, useMemo, useState, useCallback } from 'react'
import { useNavigate } from 'react-router'
import { Bookmark, BookmarkCheck, Check, CheckCircle2, Loader2, Plus } from 'lucide-react'
import Modal from '../shared/Modal'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { collectionsApi } from '../../api/collections'
@@ -25,6 +25,7 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
const { t } = useTranslation()
const toast = useToast()
const navigate = useNavigate()
const labelId = useId()
const [lists, setLists] = useState<Collection[]>([])
const [membership, setMembership] = useState<CollectionMembership | null>(null)
@@ -147,106 +148,89 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
}
}
const openCollections = () => { close(); navigate('/collections') }
return (
<Modal
isOpen
<DialogShell
onClose={close}
title={t('collections.pickList')}
size="sm"
footer={
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={() => { close(); navigate('/collections') }}
className="text-[13px] font-medium text-accent hover:underline"
>
{t('collections.viewInCollection')}
</button>
<button
type="button"
onClick={close}
className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover"
>
{t('common.close')}
</button>
</div>
}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 min-w-0">
<p className="flex-1 min-w-0 text-[13px] font-semibold text-content truncate">{target.name}</p>
{/* One tap for "I have been here", however many lists hold it (#1469). */}
{unvisited.length > 0 && (
<button
type="button"
onClick={handleVisitedEverywhere}
disabled={busyId != null}
className="shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-edge bg-surface-card text-[11px] font-semibold text-content-secondary hover:bg-surface-hover disabled:opacity-60"
>
{busyId === -1
? <Loader2 size={12} className="animate-spin" />
: <CheckCircle2 size={12} strokeWidth={2.2} />}
labelledBy={labelId}
width="narrow"
header={(
<DialogHeader
tile={<DialogTile><Bookmark size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={close}
eyebrow={t('collections.pickList')}
title={target.name}
// One tap for "I have been here", however many lists hold it (#1469).
pills={unvisited.length > 0 ? (
<button type="button" onClick={handleVisitedEverywhere} disabled={busyId != null}
className={`${PILL} hover:opacity-80 disabled:opacity-60`}>
{busyId === -1 ? <Loader2 size={13} className="animate-spin" /> : <CheckCircle2 size={13} strokeWidth={2.2} />}
{unvisited.length > 1 ? t('collections.markVisitedAll') : t('collections.markVisited')}
</button>
)}
) : undefined}
/>
)}
footer={lists.length > 0 ? (
<DialogFooter>
<DialogButton onClick={openCollections} icon={<Bookmark size={14} strokeWidth={2.2} />}>{t('collections.viewInCollection')}</DialogButton>
<FooterSpacer />
</DialogFooter>
) : undefined}
>
{loading ? (
<div className="flex items-center justify-center py-8 text-content-faint">
<Loader2 size={20} className="animate-spin" />
</div>
{loading ? (
<div className="flex items-center justify-center py-8 text-content-faint">
<Loader2 size={20} className="animate-spin" />
) : lists.length === 0 ? (
<div className="flex flex-col items-center px-4 py-6 text-center">
<div className="mb-3 grid h-11 w-11 place-items-center rounded-2xl bg-surface-secondary text-content-faint">
<Bookmark size={20} />
</div>
) : lists.length === 0 ? (
<div className="flex flex-col items-center text-center py-8 px-4">
<div className="w-11 h-11 rounded-2xl bg-surface-secondary flex items-center justify-center mb-3 text-content-faint">
<Bookmark size={20} />
</div>
<p className="text-[13px] text-content-faint mb-3">{t('collections.noListsYet')}</p>
<button
type="button"
onClick={() => { close(); navigate('/collections') }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold"
>
<Plus size={14} /> {t('collections.newList')}
</button>
</div>
) : (
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
{lists.map(list => {
const entry = savedByCollection.get(list.id)
const saved = !!entry
const busy = busyId === list.id
return (
<button
key={list.id}
type="button"
onClick={() => handleToggle(list)}
disabled={busyId != null}
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl border text-left transition-colors disabled:opacity-60 ${saved ? 'border-accent bg-accent-subtle' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
>
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: list.color || 'var(--accent)' }} />
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{list.name}</span>
{list.is_owner === false && (
<span className="text-[10px] uppercase font-semibold text-content-faint">{t('collections.shared')}</span>
)}
{/* Per-list status, so one place can be an idea in one list and
visited in another. A role=button span, safe to nest here. */}
{entry && (
<StatusBadge
status={entry.status}
showLabel={false}
size={12}
onChange={entry.can_edit ? next => { void handleStatus(entry, next) } : undefined}
t={t}
/>
)}
<span className={`w-5 h-5 rounded-md flex items-center justify-center shrink-0 ${saved ? 'bg-accent text-accent-text' : 'border border-edge text-transparent'}`}>
{busy ? <Loader2 size={13} className="animate-spin text-content-faint" /> : saved ? <BookmarkCheck size={13} /> : <Check size={13} />}
</span>
</button>
)
})}
</div>
)}
</div>
</Modal>
<p className="m-0 mb-4 text-content-faint" style={fs(13, 'body')}>{t('collections.noListsYet')}</p>
<DialogButton variant="primary" onClick={openCollections} icon={<Plus size={14} strokeWidth={2.2} />}>{t('collections.newList')}</DialogButton>
</div>
) : (
<div className="flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5">
{lists.map(list => {
const entry = savedByCollection.get(list.id)
const saved = !!entry
const busy = busyId === list.id
return (
<button
key={list.id}
type="button"
aria-pressed={saved}
onClick={() => handleToggle(list)}
disabled={busyId != null}
className={`flex min-h-[46px] items-center gap-3 rounded-[10px] px-3 py-2 text-left disabled:opacity-60 ${saved ? 'bg-surface-card shadow-sm' : 'hover:bg-surface-card'}`}
>
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: list.color || 'var(--accent)' }} />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>{list.name}</span>
{list.is_owner === false && (
<span className="flex-none rounded-full bg-surface-tertiary px-2 py-[2px] font-semibold text-content-muted" style={fs(10.5)}>{t('collections.shared')}</span>
)}
{/* Per-list status, so one place can be an idea in one list and
visited in another. A role=button span, safe to nest here. */}
{entry && (
<StatusBadge
status={entry.status}
showLabel={false}
size={12}
onChange={entry.can_edit ? next => { void handleStatus(entry, next) } : undefined}
t={t}
/>
)}
<span className={`grid h-6 w-6 flex-none place-items-center rounded-[8px] ${saved ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-transparent'}`}>
{busy ? <Loader2 size={13} className="animate-spin text-content-faint" /> : saved ? <BookmarkCheck size={13} /> : <Check size={13} />}
</span>
</button>
)
})}
</div>
)}
</DialogShell>
)
}
@@ -0,0 +1,19 @@
// A member's picture, or their initial on a colour picked from the letter.
const INITIAL_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#10b981', '#f59e0b', '#ef4444', '#06b6d4'] // theme-lint-disable: a member's own colour, like a category's
export function TripMemberAvatar({ username, avatarUrl, size = 32 }: { username: string; avatarUrl: string | null; size?: number }) {
if (avatarUrl) {
return <img src={avatarUrl} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
}
const letter = (username || '?')[0].toUpperCase()
const color = INITIAL_COLORS[(letter.codePointAt(0) ?? 0) % INITIAL_COLORS.length]
return (
<div style={{
width: size, height: size, borderRadius: '50%', background: color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: 'white', flexShrink: 0, // theme-lint-disable: an initial on the member's own colour
}}>
{letter}
</div>
)
}
@@ -1,4 +1,4 @@
// FE-COMP-MEMBERS-001 to FE-COMP-MEMBERS-057
// FE-COMP-MEMBERS-001 to FE-COMP-MEMBERS-060
import type { Mock } from 'vitest';
import { act, render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -198,8 +198,8 @@ describe('TripMembersModal', () => {
it('FE-COMP-MEMBERS-012: shows "you" label next to current user', async () => {
render(<TripMembersModal {...defaultProps} />);
// Rendered as "(you)" — use regex to find it
expect(await screen.findByText(/\(you\)/i)).toBeInTheDocument();
// A badge beside the name on the desktop; the phone keeps "(you)".
expect(await screen.findByText('you')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-013: shows remove access button for members (not owner)', async () => {
@@ -215,14 +215,12 @@ describe('TripMembersModal', () => {
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('alice');
// Remove access button shown for members
expect(screen.getByTitle('Remove access')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove access' })).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-014: remove member calls DELETE API', async () => {
const user = userEvent.setup();
let deleteCalled = false;
// Mock window.confirm to return true so deletion proceeds
vi.spyOn(window, 'confirm').mockReturnValue(true);
server.use(
http.get('/api/trips/1/members', () =>
HttpResponse.json({
@@ -238,10 +236,11 @@ describe('TripMembersModal', () => {
);
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('alice');
const removeBtn = screen.getByTitle('Remove access');
await user.click(removeBtn);
await user.click(screen.getByRole('button', { name: 'Remove access' }));
// The question comes first, in the app's own dialog.
expect(screen.getByText('Remove access for this user?')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Confirm' }));
await waitFor(() => expect(deleteCalled).toBe(true));
vi.restoreAllMocks();
});
it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => {
@@ -440,7 +439,6 @@ describe('TripMembersModal', () => {
it('FE-COMP-MEMBERS-024: leave trip calls DELETE for current user', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
// No reload stub here on purpose: this case asserts the DELETE, and a stubbed
// location is exactly what stops the request from reaching it. jsdom's own
// reload is a no-op that only logs.
@@ -466,14 +464,13 @@ describe('TripMembersModal', () => {
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('alice');
const leaveBtn = screen.getByTitle('Leave trip');
await user.click(leaveBtn);
await user.click(screen.getByRole('button', { name: 'Leave trip' }));
expect(screen.getByText('Leave trip? You will lose access.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Confirm' }));
await waitFor(() => {
expect(deleteCalledForUserId).toBe(String(memberUser.id));
});
vi.restoreAllMocks();
});
it('FE-COMP-MEMBERS-025: "all have access" message shown when all users are members', async () => {
@@ -528,8 +525,8 @@ describe('TripMembersModal', () => {
await screen.findByText('Grandma');
// The guest carries a "Guest" badge.
expect(screen.getAllByText('Guest').length).toBeGreaterThan(0);
// Access count covers owner + the real member only (2), not the guest.
expect(screen.getByText(/Access \(2/)).toBeInTheDocument();
// The head band counts the owner and the real member (2), not the guest.
expect(screen.getByText('2 persons')).toBeInTheDocument();
});
// ── Avatars and load failures (028-031) ───────────────────────────────────
@@ -556,7 +553,7 @@ describe('TripMembersModal', () => {
render(<TripMembersModal {...defaultProps} />);
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to load members', 'error', undefined));
expect(screen.getByText(/Access \(0/)).toBeInTheDocument();
expect(screen.getByText('0 persons')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-031: a failing share-link load still offers link creation', async () => {
@@ -777,7 +774,6 @@ describe('TripMembersModal', () => {
it('FE-COMP-MEMBERS-042: transferring ownership reloads the app', async () => {
const onClose = vi.fn();
const reload = stubReload();
vi.spyOn(window, 'confirm').mockReturnValue(true);
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
let transferBody: Record<string, unknown> | null = null;
server.use(
@@ -789,8 +785,9 @@ describe('TripMembersModal', () => {
);
render(<TripMembersModal {...defaultProps} onClose={onClose} />);
const crown = await screen.findByTitle('Make owner');
const crown = await screen.findByRole('button', { name: 'Make owner' });
fireEvent.click(crown);
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
expect(crown).toBeDisabled();
await waitFor(() => expect(reload).toHaveBeenCalled());
@@ -800,56 +797,45 @@ describe('TripMembersModal', () => {
it('FE-COMP-MEMBERS-043: declining the transfer confirmation does nothing', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(false);
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
let transferCalled = false;
server.use(http.post('/api/trips/1/transfer', () => { transferCalled = true; return HttpResponse.json({}); }));
render(<TripMembersModal {...defaultProps} />);
const crown = await screen.findByTitle('Make owner');
fireEvent.mouseEnter(crown);
expect(crown.style.color).toBe('rgb(217, 119, 6)');
fireEvent.mouseLeave(crown);
expect(crown.style.color).toBe('rgb(156, 163, 175)');
await user.click(crown);
await user.click(await screen.findByRole('button', { name: 'Make owner' }));
expect(screen.getByText('Transfer ownership to alice? You will become a regular member.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByText('Transfer ownership to alice? You will become a regular member.')).not.toBeInTheDocument();
expect(transferCalled).toBe(false);
expect(window.confirm).toHaveBeenCalledWith('Transfer ownership to alice? You will become a regular member.');
});
it('FE-COMP-MEMBERS-044: a failing transfer re-enables the button', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
server.use(http.post('/api/trips/1/transfer', () => HttpResponse.json({ error: 'Not allowed' }, { status: 403 })));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByTitle('Make owner'));
await user.click(await screen.findByRole('button', { name: 'Make owner' }));
await user.click(screen.getByRole('button', { name: 'Confirm' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Not allowed', 'error', undefined));
expect(screen.getByTitle('Make owner')).toBeEnabled();
expect(screen.getByRole('button', { name: 'Make owner' })).toBeEnabled();
});
it('FE-COMP-MEMBERS-045: member removal is cancellable and reports failures', async () => {
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
let deletes = 0;
server.use(http.delete('/api/trips/1/members/:userId', () => { deletes++; return HttpResponse.json({}, { status: 500 }); }));
render(<TripMembersModal {...defaultProps} />);
const removeBtn = await screen.findByTitle('Remove access');
fireEvent.mouseEnter(removeBtn);
expect(removeBtn.style.color).toBe('rgb(239, 68, 68)');
fireEvent.mouseLeave(removeBtn);
expect(removeBtn.style.color).toBe('rgb(156, 163, 175)');
await user.click(removeBtn);
await user.click(await screen.findByRole('button', { name: 'Remove access' }));
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(deletes).toBe(0);
confirmSpy.mockReturnValue(true);
await user.click(screen.getByTitle('Remove access'));
await user.click(screen.getByRole('button', { name: 'Remove access' }));
await user.click(screen.getByRole('button', { name: 'Confirm' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to remove', 'error', undefined));
expect(screen.getByText('alice')).toBeInTheDocument();
@@ -906,10 +892,7 @@ describe('TripMembersModal', () => {
);
render(<TripMembersModal {...defaultProps} />);
const pencil = await screen.findByTitle('Rename');
fireEvent.mouseEnter(pencil);
fireEvent.mouseLeave(pencil);
await user.click(pencil);
await user.click(await screen.findByRole('button', { name: 'Rename' }));
const input = screen.getByDisplayValue('Grandma');
fireEvent.change(input, { target: { value: 'Granny' } });
@@ -926,7 +909,7 @@ describe('TripMembersModal', () => {
server.use(http.put('/api/trips/1/guests/:userId', () => { renames++; return HttpResponse.json({ success: true }); }));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByTitle('Rename'));
await user.click(await screen.findByRole('button', { name: 'Rename' }));
const input = screen.getByDisplayValue('Grandma');
fireEvent.change(input, { target: { value: 'Granny' } });
fireEvent.keyDown(input, { key: 'Enter' });
@@ -943,11 +926,11 @@ describe('TripMembersModal', () => {
server.use(http.put('/api/trips/1/guests/:userId', () => { renames++; return HttpResponse.json({ success: true }); }));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByTitle('Rename'));
await user.click(await screen.findByRole('button', { name: 'Rename' }));
fireEvent.keyDown(screen.getByDisplayValue('Grandma'), { key: 'Escape' });
expect(screen.queryByDisplayValue('Grandma')).not.toBeInTheDocument();
await user.click(screen.getByTitle('Rename'));
await user.click(screen.getByRole('button', { name: 'Rename' }));
const input = screen.getByDisplayValue('Grandma');
fireEvent.change(input, { target: { value: ' ' } });
fireEvent.blur(input);
@@ -962,7 +945,7 @@ describe('TripMembersModal', () => {
server.use(http.put('/api/trips/1/guests/:userId', () => HttpResponse.json({ error: 'Name taken' }, { status: 409 })));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByTitle('Rename'));
await user.click(await screen.findByRole('button', { name: 'Rename' }));
const input = screen.getByDisplayValue('Grandma');
fireEvent.change(input, { target: { value: 'Granny' } });
fireEvent.keyDown(input, { key: 'Enter' });
@@ -973,7 +956,6 @@ describe('TripMembersModal', () => {
it('FE-COMP-MEMBERS-051: removing a guest is confirmed, refreshes costs and notifies', async () => {
const user = userEvent.setup();
const onMembersChanged = vi.fn();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
mockRoster([guestRow]);
let deletedId: string | null = null;
server.use(
@@ -985,15 +967,15 @@ describe('TripMembersModal', () => {
);
render(<TripMembersModal {...defaultProps} onMembersChanged={onMembersChanged} />);
const trash = await screen.findByTitle('Remove access');
fireEvent.mouseEnter(trash);
fireEvent.mouseLeave(trash);
const trash = await screen.findByRole('button', { name: 'Remove access' });
await user.click(trash);
expect(screen.getByText('Remove this guest? Their assignments and cost shares will be removed too.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(deletedId).toBeNull();
confirmSpy.mockReturnValue(true);
fireEvent.click(screen.getByTitle('Remove access'));
await waitFor(() => expect(screen.getByTitle('Remove access')).toBeDisabled());
fireEvent.click(screen.getByRole('button', { name: 'Remove access' }));
fireEvent.click(await screen.findByRole('button', { name: 'Delete' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Remove access' })).toBeDisabled());
await waitFor(() => expect(deletedId).toBe('3'));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Guest removed', 'success', undefined));
@@ -1002,12 +984,12 @@ describe('TripMembersModal', () => {
it('FE-COMP-MEMBERS-052: a failing guest removal is reported', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
mockRoster([guestRow]);
server.use(http.delete('/api/trips/1/guests/:userId', () => HttpResponse.json({}, { status: 500 })));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByTitle('Remove access'));
await user.click(await screen.findByRole('button', { name: 'Remove access' }));
await user.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to remove', 'error', undefined));
expect(screen.getByText('Grandma')).toBeInTheDocument();
@@ -1027,7 +1009,7 @@ describe('TripMembersModal', () => {
expect(screen.queryByText('Guests')).not.toBeInTheDocument();
expect(screen.queryByText('Public Link')).not.toBeInTheDocument();
// Nothing to remove either — a plain member can only leave themselves.
expect(screen.queryByTitle('Remove access')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Remove access' })).not.toBeInTheDocument();
});
// A self-hosted install served over plain HTTP has no navigator.clipboard, and
@@ -1085,3 +1067,64 @@ describe('TripMembersModal', () => {
view.unmount();
});
});
describe('TripMembersModal on a phone', () => {
let width: number;
beforeEach(() => {
width = window.innerWidth;
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true, writable: true });
});
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true });
});
it('FE-COMP-MEMBERS-058: keeps the sheet it had, with "(you)" beside the name', async () => {
render(<TripMembersModal {...defaultProps} />);
expect(await screen.findByText(/\(you\)/i)).toBeInTheDocument();
expect(screen.getByText(/Access \(1/)).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-059: asks with the browser before a member goes, and only then removes', async () => {
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
let deletes = 0;
server.use(http.delete('/api/trips/1/members/:userId', () => { deletes++; return HttpResponse.json({ success: true }); }));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByTitle('Remove access'));
expect(confirmSpy).toHaveBeenCalledWith('Remove access for this user?');
expect(deletes).toBe(0);
confirmSpy.mockReturnValue(true);
await user.click(screen.getByTitle('Remove access'));
await waitFor(() => expect(deletes).toBe(1));
});
it('FE-COMP-MEMBERS-060: a guest is renamed and removed through the same questions', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
mockRoster([{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true }]);
let renamed: string | null = null;
let deleted = false;
server.use(
http.put('/api/trips/1/guests/:userId', async ({ request }) => {
renamed = ((await request.json()) as { name: string }).name;
return HttpResponse.json({ success: true });
}),
http.delete('/api/trips/1/guests/:userId', () => { deleted = true; return HttpResponse.json({ success: true }); }),
);
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByTitle('Rename'));
const input = screen.getByDisplayValue('Grandma');
fireEvent.keyDown(input, { key: 'Escape' });
await user.click(screen.getByTitle('Rename'));
fireEvent.change(screen.getByDisplayValue('Grandma'), { target: { value: 'Granny' } });
fireEvent.keyDown(screen.getByDisplayValue('Granny'), { key: 'Enter' });
await waitFor(() => expect(renamed).toBe('Granny'));
await user.click(screen.getByTitle('Remove access'));
await waitFor(() => expect(deleted).toBe(true));
});
});
+31 -280
View File
@@ -1,91 +1,15 @@
import { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal'
import { tripsApi, authApi, shareApi, tripInviteApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
import CustomSelect from '../shared/CustomSelect'
import { copyText } from '../../utils/clipboard'
interface AvatarProps {
username: string
avatarUrl: string | null
size?: number
}
function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
if (avatarUrl) {
return <img src={avatarUrl} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
}
const letter = (username || '?')[0].toUpperCase()
const colors = ['#3b82f6', '#8b5cf6', '#ec4899', '#10b981', '#f59e0b', '#ef4444', '#06b6d4']
const color = colors[(letter.codePointAt(0) ?? 0) % colors.length]
return (
<div style={{
width: size, height: size, borderRadius: '50%', background: color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: 'white', flexShrink: 0,
}}>
{letter}
</div>
)
}
import { useIsPhone } from '../../mobile/useIsPhone'
import { TripMemberAvatar as Avatar } from './TripMemberAvatar'
import TripShareDialog from './TripShareDialog'
import { useShareLink, useTripInviteLink, useTripMembers } from './useTripShare'
function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record<string, string | number>) => string }) {
const [shareToken, setShareToken] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false })
const toast = useToast()
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }
}, [])
useEffect(() => {
shareApi.getLink(tripId).then(d => {
setShareToken(d.token)
if (d.token) setPerms({ share_map: d.share_map ?? true, share_bookings: d.share_bookings ?? true, share_packing: d.share_packing ?? false, share_budget: d.share_budget ?? false, share_collab: d.share_collab ?? false })
setLoading(false)
}).catch(() => setLoading(false))
}, [tripId])
const shareUrl = shareToken ? `${window.location.origin}/shared/${shareToken}` : null
const handleCreate = async () => {
try {
const d = await shareApi.createLink(tripId, perms)
setShareToken(d.token)
} catch { toast.error(t('share.createError')) }
}
const handleUpdatePerms = async (key: string, val: boolean) => {
const newPerms = { ...perms, [key]: val }
setPerms(newPerms)
if (shareToken) {
try { await shareApi.createLink(tripId, newPerms) } catch { toast.error(t('share.createError')) }
}
}
const handleDelete = async () => {
try {
await shareApi.deleteLink(tripId)
setShareToken(null)
} catch { toast.error(t('common.error')) }
}
const handleCopy = async () => {
if (!shareUrl) return
if (!(await copyText(shareUrl))) return
setCopied(true)
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
}
const { loading, url: shareUrl, perms, copied, create: handleCreate, setPerm, remove: handleDelete, copy: handleCopy } = useShareLink(tripId)
const handleUpdatePerms = (key: string, val: boolean) => setPerm(key as Parameters<typeof setPerm>[0], val)
if (loading) return null
@@ -169,44 +93,7 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
* but the link points at /join/:token (login-required, no registration).
*/
function TripInviteLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record<string, string | number>) => string }) {
const [token, setToken] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [copied, setCopied] = useState(false)
const toast = useToast()
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }, [])
useEffect(() => {
tripInviteApi.getLink(tripId)
.then((d: { token: string | null }) => setToken(d.token))
.catch(() => {})
.finally(() => setLoading(false))
}, [tripId])
const inviteUrl = token ? `${window.location.origin}/join/${token}` : null
const create = async () => {
setBusy(true)
try { const d = await tripInviteApi.createLink(tripId); setToken(d.token) }
catch { toast.error(t('share.createError')) }
finally { setBusy(false) }
}
const remove = async () => {
setBusy(true)
try { await tripInviteApi.deleteLink(tripId); setToken(null) }
catch { toast.error(t('common.error')) }
finally { setBusy(false) }
}
const copy = async () => {
if (!(await copyText(inviteUrl))) return
setCopied(true)
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
}
const { loading, url: inviteUrl, busy, copied, create, remove, copy } = useTripInviteLink(tripId)
if (loading) return null
@@ -272,169 +159,33 @@ interface TripMembersModalProps {
onMembersChanged?: () => void
}
export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle, onMembersChanged }: TripMembersModalProps) {
const [data, setData] = useState(null)
const [allUsers, setAllUsers] = useState([])
const [loading, setLoading] = useState(false)
const [selectedUserId, setSelectedUserId] = useState('')
const [adding, setAdding] = useState(false)
const [removingId, setRemovingId] = useState(null)
const [transferringId, setTransferringId] = useState(null)
const [newGuestName, setNewGuestName] = useState('')
const [addingGuest, setAddingGuest] = useState(false)
const [renamingGuestId, setRenamingGuestId] = useState(null)
const [renameValue, setRenameValue] = useState('')
const toast = useToast()
const { user } = useAuthStore()
/** Share: the new dialog on the desktop; the phone, which opens this from its More sheet, keeps the sheet below. */
export default function TripMembersModal(props: TripMembersModalProps) {
const isPhone = useIsPhone()
return isPhone ? <TripMembersSheet {...props} /> : <TripShareDialog {...props} />
}
function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged }: TripMembersModalProps) {
const { t } = useTranslation()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const loadBudgetItems = useTripStore((s) => s.loadBudgetItems)
const canManageMembers = can('member_manage', trip)
const canManageShare = can('share_manage', trip)
useEffect(() => {
if (isOpen && tripId) {
loadMembers()
loadAllUsers()
}
}, [isOpen, tripId])
const loadMembers = async (notify = false) => {
setLoading(true)
try {
const d = await tripsApi.getMembers(tripId)
setData(d)
// Notify the planner to re-sync (Costs participants etc.) only after an actual
// roster mutation — not on the initial open load, which would be a redundant fetch.
if (notify) onMembersChanged?.()
} catch {
toast.error(t('members.loadError'))
} finally {
setLoading(false)
}
const m = useTripMembers({ isOpen, tripId, onClose, onMembersChanged })
const {
user, loading, realMembers, guests, allUsers, availableUsers, isCurrentOwner, canManageMembers, canManageShare,
selectedUserId, setSelectedUserId, adding, transferringId, removingId,
newGuestName, setNewGuestName, addingGuest, renamingGuestId, renameValue, setRenameValue,
} = m
const handleAdd = () => void m.add()
const handleAddGuest = () => void m.addGuest()
const handleRenameGuest = (userId: number) => void m.commitRename(userId)
const handleTransfer = (newOwnerId: number, username: string) => {
if (confirm(t('members.confirmTransfer', { name: username }))) void m.transfer(newOwnerId)
}
const loadAllUsers = async () => {
try {
const d = await authApi.listUsers()
setAllUsers(d.users)
} catch {}
const handleRemove = (userId: number, isSelf: boolean) => {
if (confirm(isSelf ? t('members.confirmLeave') : t('members.confirmRemove'))) void m.remove(userId, isSelf)
}
const handleAdd = async () => {
setAdding(true)
try {
const target = allUsers.find(u => String(u.id) === String(selectedUserId))
await tripsApi.addMember(tripId, target.username)
setSelectedUserId('')
await loadMembers(true)
toast.success(`${target.username} ${t('members.added')}`)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('members.addError')))
} finally {
setAdding(false)
}
const handleDeleteGuest = (userId: number) => {
if (confirm(t('members.confirmRemoveGuest'))) void m.removeGuest(userId)
}
const handleTransfer = async (newOwnerId, username) => {
if (!confirm(t('members.confirmTransfer', { name: username }))) return
setTransferringId(newOwnerId)
try {
await tripsApi.transferOwnership(tripId, newOwnerId)
// The current user just dropped from owner to member — reload so the trip
// state and permissions everywhere reflect the new ownership.
onClose()
window.location.reload()
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('members.transferError')))
setTransferringId(null)
}
}
const handleAddGuest = async () => {
const name = newGuestName.trim()
if (!name) return
setAddingGuest(true)
try {
await tripsApi.createGuest(tripId, name)
setNewGuestName('')
await loadMembers(true)
toast.success(t('members.guestAdded'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('members.guestAddError')))
} finally {
setAddingGuest(false)
}
}
// Enter commits, and the blur that follows would send the same rename a second
// time. Only the first commit per editing session goes through; a failed one
// reopens the gate so the user can retry from the still-open input.
const renameCommittedRef = useRef(false)
const handleRenameGuest = async (userId) => {
if (renameCommittedRef.current) return
renameCommittedRef.current = true
const name = renameValue.trim()
if (!name) { setRenamingGuestId(null); return }
try {
await tripsApi.renameGuest(tripId, userId, name)
setRenamingGuestId(null)
await loadMembers(true)
} catch (err: unknown) {
renameCommittedRef.current = false
toast.error(getApiErrorMessage(err, t('members.guestRenameError')))
}
}
const handleDeleteGuest = async (userId) => {
if (!confirm(t('members.confirmRemoveGuest'))) return
setRemovingId(userId)
try {
await tripsApi.deleteGuest(tripId, userId)
await loadMembers(true)
await loadBudgetItems(tripId)
toast.success(t('members.guestRemoved'))
} catch {
toast.error(t('members.removeError'))
} finally {
setRemovingId(null)
}
}
const handleRemove = async (userId, isSelf) => {
const msg = isSelf
? t('members.confirmLeave')
: t('members.confirmRemove')
if (!confirm(msg)) return
setRemovingId(userId)
try {
await tripsApi.removeMember(tripId, userId)
if (isSelf) { onClose(); window.location.reload() }
else { await loadMembers(true); toast.success(t('members.removed')) }
} catch {
toast.error(t('members.removeError'))
} finally {
setRemovingId(null)
}
}
// Users not yet in the trip (guests are accountless and never live in the directory)
const existingIds = new Set([
data?.owner?.id,
...(data?.members?.map(m => m.id) || []),
])
const availableUsers = allUsers.filter(u => !existingIds.has(u.id) && !u.is_guest)
const isCurrentOwner = data?.owner?.id === user?.id
// Real members (owner + accounts) and guests (#1362) are listed separately.
const realMembers = data ? [
{ ...data.owner, role: 'owner' },
...data.members.filter(m => !m.is_guest),
] : []
const guests = data ? data.members.filter(m => m.is_guest) : []
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid">
@@ -580,7 +331,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle, o
autoFocus
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleRenameGuest(g.id); if (e.key === 'Escape') { renameCommittedRef.current = true; setRenamingGuestId(null) } }}
onKeyDown={e => { if (e.key === 'Enter') handleRenameGuest(g.id); if (e.key === 'Escape') m.cancelRename() }}
onBlur={() => handleRenameGuest(g.id)}
maxLength={50}
className="bg-surface border border-edge text-content"
@@ -597,7 +348,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle, o
{isCurrentOwner && renamingGuestId !== g.id && (
<>
<button type="button"
onClick={() => { renameCommittedRef.current = false; setRenamingGuestId(g.id); setRenameValue(g.username) }}
onClick={() => m.startRename(g)}
title={t('common.rename')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
@@ -0,0 +1,378 @@
import { useId, useState, type ReactNode } from 'react'
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, 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, PANEL } from '../shared/dialogParts'
import { TripMemberAvatar } from './TripMemberAvatar'
import { useShareLink, useTripInviteLink, useTripMembers, type SharePerm, type TripMembersState } from './useTripShare'
interface TripShareDialogProps {
isOpen: boolean
onClose: () => void
tripId: number
tripTitle: string
onMembersChanged?: () => void
}
/** A question the dialog asks before it acts, answered in a ConfirmDialog over it. */
interface Ask {
title: string
message: string
confirmLabel: string
run: () => void
}
const COUNT = 'rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bold text-content-muted'
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'
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. */
export default function TripShareDialog(props: TripShareDialogProps) {
return props.isOpen ? <OpenDialog {...props} /> : null
}
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 persons = `${m.realMembers.length} ${m.realMembers.length === 1 ? t('members.person') : t('members.persons')}`
return (
<>
<DialogShell
onClose={onClose}
labelledBy={labelId}
width={m.canManageShare ? 'wide' : 'detail'}
align="top"
blocked={!!ask}
header={(
<DialogHeader
tile={<DialogTile><Users size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={t('members.shareTrip')}
title={tripTitle}
pills={m.loading ? undefined : (
<>
<span className={PILL}><Users size={13} strokeWidth={2.2} />{persons}</span>
{m.guests.length > 0 && <span className={PILL}><UserRound size={13} strokeWidth={2.2} />{t('members.guests')} {m.guests.length}</span>}
</>
)}
/>
)}
>
<div className={m.canManageShare ? 'grid grid-cols-2 items-start gap-6 max-md:grid-cols-1' : ''}>
<div className="flex flex-col gap-5">
{m.canManageMembers && <InviteUser m={m} />}
<MembersList m={m} onAsk={setAsk} />
{(m.isCurrentOwner || m.guests.length > 0) && <GuestsList m={m} onAsk={setAsk} />}
</div>
{m.canManageShare && (
<div className="flex flex-col gap-5">
<PublicLink tripId={tripId} />
<InviteLink tripId={tripId} />
</div>
)}
</div>
</DialogShell>
<ConfirmDialog
isOpen={!!ask}
onClose={() => setAsk(null)}
onConfirm={() => { ask?.run(); setAsk(null) }}
title={ask?.title}
message={ask?.message}
confirmLabel={ask?.confirmLabel}
/>
</>
)
}
function InviteUser({ m }: { m: TripMembersState }) {
const { t } = useTranslation()
return (
<DialogSection label={t('members.inviteUser')}>
<div className="flex items-center gap-2">
<CustomSelect
value={m.selectedUserId}
onChange={value => m.setSelectedUserId(String(value))}
placeholder={t('members.selectUser')}
options={[
{ value: '', label: t('members.selectUser') },
// As a string: the select compares values strictly and the pick is kept as one.
...m.availableUsers.map(u => ({ value: String(u.id), label: u.username })),
]}
searchable
style={{ flex: 1 }}
size="sm"
/>
<DialogButton variant="primary" onClick={() => void m.add()} disabled={m.adding || !m.selectedUserId} icon={<UserPlus size={14} strokeWidth={2.2} />}>
{m.adding ? '...' : t('members.invite')}
</DialogButton>
</div>
{m.availableUsers.length === 0 && m.allUsers.length > 0 && (
<p className="m-0 mt-2 text-content-faint" style={fs(11.5)}>{t('members.allHaveAccess')}</p>
)}
</DialogSection>
)
}
/** A round icon button of a row, named by its tooltip; faint until the pointer is on it. */
function RowAction({ label, onClick, disabled, danger, children }: { label: string; onClick: () => void; disabled?: boolean; danger?: boolean; children: ReactNode }) {
return (
<Tooltip label={label}>
<button type="button" onClick={onClick} disabled={disabled} aria-label={label}
className={`grid h-8 w-8 flex-none place-items-center rounded-[9px] text-content-faint hover:bg-surface-secondary disabled:opacity-40 ${danger ? 'hover:text-danger' : 'hover:text-content'}`}>
{children}
</button>
</Tooltip>
)
}
function SectionLabel({ text, count }: { text: string; count?: number }) {
return (
<span className="inline-flex items-center gap-2">
{text}
{count != null && <span className={COUNT} style={fs(10)}>{count}</span>}
</span>
)
}
function MembersList({ m, onAsk }: { m: TripMembersState; onAsk: (ask: Ask) => void }) {
const { t } = useTranslation()
return (
<DialogSection label={<SectionLabel text={t('members.access')} count={m.loading ? undefined : m.realMembers.length} />}>
{m.loading ? (
<div className={ROWS}>
{[1, 2].map(i => <div key={i} className="h-12 animate-pulse rounded-[10px] bg-surface-tertiary" />)}
</div>
) : (
<div className={ROWS}>
{m.realMembers.map(member => {
const isSelf = member.id === m.user?.id
const isOwner = member.role === 'owner'
const canRemove = isSelf || (m.canManageMembers && !isOwner)
return (
<div key={member.id} className={ROW}>
<TripMemberAvatar username={member.username} avatarUrl={member.avatar_url ?? null} />
<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')}>{member.username}</span>
{isSelf && <span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{t('members.you')}</span>}
{isOwner && (
<span className={`${BADGE} bg-warning-soft text-warning`} style={fs(10.5)}>
<Crown size={10} strokeWidth={2.4} />{t('members.owner')}
</span>
)}
</div>
{m.isCurrentOwner && !isOwner && (
<RowAction label={t('members.makeOwner')} disabled={m.transferringId === member.id}
onClick={() => onAsk({
title: t('members.makeOwner'),
message: t('members.confirmTransfer', { name: member.username }),
confirmLabel: t('common.confirm'),
run: () => void m.transfer(member.id),
})}>
<Crown size={15} />
</RowAction>
)}
{canRemove && (
<RowAction label={isSelf ? t('members.leaveTrip') : t('members.removeAccess')} danger disabled={m.removingId === member.id}
onClick={() => onAsk({
title: isSelf ? t('members.leaveTrip') : t('members.removeAccess'),
message: isSelf ? t('members.confirmLeave') : t('members.confirmRemove'),
confirmLabel: t('common.confirm'),
run: () => void m.remove(member.id, isSelf),
})}>
{isSelf ? <LogOut size={15} /> : <UserMinus size={15} />}
</RowAction>
)}
</div>
)
})}
</div>
)}
</DialogSection>
)
}
/** Accountless participants (#1362): the owner names, renames and removes them. */
function GuestsList({ m, onAsk }: { m: TripMembersState; onAsk: (ask: Ask) => void }) {
const { t } = useTranslation()
return (
<DialogSection label={<SectionLabel text={t('members.guests')} count={m.guests.length || undefined} />}>
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('members.guestsHint')}</p>
{m.guests.length > 0 && (
<div className={`${ROWS} mb-2.5`}>
{m.guests.map(g => (
<div key={g.id} className={ROW}>
<TripMemberAvatar username={g.username} avatarUrl={null} />
{m.renamingGuestId === g.id ? (
<input
autoFocus
value={m.renameValue}
onChange={e => m.setRenameValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') void m.commitRename(g.id)
// Escape ends the rename, not the dialog.
if (e.key === 'Escape') { e.preventDefault(); m.cancelRename() }
}}
onBlur={() => void m.commitRename(g.id)}
maxLength={50}
aria-label={t('common.rename')}
className={`${INPUT} flex-1`}
/>
) : (
<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')}>{g.username}</span>
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>
<UserRound size={10} strokeWidth={2.4} />{t('members.guest')}
</span>
</div>
)}
{m.isCurrentOwner && m.renamingGuestId !== g.id && (
<>
<RowAction label={t('common.rename')} onClick={() => m.startRename(g)}>
<Pencil size={14} />
</RowAction>
<RowAction label={t('members.removeAccess')} danger disabled={m.removingId === g.id}
onClick={() => onAsk({
title: t('members.removeAccess'),
message: t('members.confirmRemoveGuest'),
confirmLabel: t('common.delete'),
run: () => void m.removeGuest(g.id),
})}>
<Trash2 size={14} />
</RowAction>
</>
)}
</div>
))}
</div>
)}
{m.isCurrentOwner && (
<div className="flex items-center gap-2">
<input
value={m.newGuestName}
onChange={e => m.setNewGuestName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void m.addGuest() }}
placeholder={t('members.guestNamePlaceholder')}
aria-label={t('members.guestNamePlaceholder')}
maxLength={50}
className={`${INPUT} flex-1`}
/>
<DialogButton onClick={() => void m.addGuest()} disabled={m.addingGuest || !m.newGuestName.trim()} icon={<Plus size={14} strokeWidth={2.2} />}>
{m.addingGuest ? '...' : t('members.addGuest')}
</DialogButton>
</div>
)}
</DialogSection>
)
}
/** A link as a read-only line with its copy button, the way the calendar feed shows its addresses. */
function LinkLine({ url, copied, onCopy }: { url: string; copied: boolean; onCopy: () => void }) {
const { t } = useTranslation()
return (
<div className="flex items-center gap-2 rounded-[10px] border border-edge bg-surface-input py-1 pl-3 pr-1">
<input type="text" value={url} readOnly aria-label={url} onFocus={e => e.currentTarget.select()}
className="min-w-0 flex-1 truncate border-0 bg-transparent font-mono text-content outline-none" style={fs(11.5)} />
<button type="button" onClick={onCopy}
className={`inline-flex flex-none items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 font-semibold ${copied ? 'bg-success-soft text-success' : 'bg-accent text-accent-text hover:opacity-90'}`}
style={fs(11.5, 'body')}>
{copied ? <Check size={12} strokeWidth={2.5} /> : <Copy size={12} strokeWidth={2.2} />}
{copied ? t('common.copied') : t('common.copy')}
</button>
</div>
)
}
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">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card text-content-muted shadow-sm">{icon}</span>
<div className="min-w-0">
<h3 className="m-0 font-semibold text-content" style={fs(13, 'body')}>{title}</h3>
<p className="m-0 mt-0.5 leading-normal text-content-faint" style={fs(11.5)}>{hint}</p>
</div>
</div>
{children}
</section>
)
}
const SHARE_PERMS: { key: SharePerm; label: string; always?: boolean }[] = [
{ key: 'share_map', label: 'share.permMap', always: true },
{ key: 'share_bookings', label: 'share.permBookings' },
{ key: 'share_packing', label: 'share.permPacking' },
{ key: 'share_budget', label: 'share.permBudget' },
{ key: 'share_collab', label: 'share.permCollab' },
]
/** The read-only public link, and which parts of the trip it shows. */
function PublicLink({ tripId }: { tripId: number }) {
const { t } = useTranslation()
const link = useShareLink(tripId)
if (link.loading) return null
return (
<LinkCard icon={<Link2 size={15} strokeWidth={2.2} />} title={t('share.linkTitle')} hint={t('share.linkHint')}>
<div className="flex flex-wrap gap-1.5">
{SHARE_PERMS.map(opt => {
const on = link.perms[opt.key]
return (
<button key={opt.key} type="button" aria-pressed={on} disabled={opt.always}
onClick={() => void link.setPerm(opt.key, !on)}
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 font-semibold disabled:cursor-default ${on ? 'bg-accent text-accent-text disabled:opacity-70' : 'bg-surface-card text-content-muted ring-1 ring-edge-faint hover:text-content'}`}
style={fs(11.5, 'body')}>
{on && <Check size={11} strokeWidth={2.6} />}{t(opt.label)}
</button>
)
})}
</div>
{link.url ? (
<>
<LinkLine url={link.url} copied={link.copied} onCopy={() => void link.copy()} />
<div>
<button type="button" onClick={() => void link.remove()} className={DANGER_BUTTON} style={fs(12.5, 'body')}>
<Trash2 size={13} strokeWidth={2.2} />{t('share.deleteLink')}
</button>
</div>
</>
) : (
<AddRowButton onClick={() => void link.create()}>{t('share.createLink')}</AddRowButton>
)}
</LinkCard>
)
}
/** The link an existing user opens to join as a member (#1143). */
function InviteLink({ tripId }: { tripId: number }) {
const { t } = useTranslation()
const link = useTripInviteLink(tripId)
if (link.loading) return null
return (
<LinkCard icon={<UserPlus size={15} strokeWidth={2.2} />} title={t('trip.invite.linkTitle')} hint={t('trip.invite.linkHint')}>
{link.url ? (
<>
<LinkLine url={link.url} copied={link.copied} onCopy={() => void link.copy()} />
<div className="flex flex-wrap gap-2">
<DialogButton onClick={() => void link.create()} disabled={link.busy} icon={<Link2 size={14} strokeWidth={2.2} />}>
{t('trip.invite.regenerate')}
</DialogButton>
<button type="button" onClick={() => void link.remove()} disabled={link.busy} className={DANGER_BUTTON} style={fs(12.5, 'body')}>
<Trash2 size={13} strokeWidth={2.2} />{t('trip.invite.disable')}
</button>
</div>
</>
) : (
<AddRowButton onClick={() => void link.create()} disabled={link.busy}>{t('trip.invite.create')}</AddRowButton>
)}
</LinkCard>
)
}
+288
View File
@@ -0,0 +1,288 @@
import { useEffect, useRef, useState } from 'react'
import type { TripMember } from '@trek/shared'
import { tripsApi, authApi, shareApi, tripInviteApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
import { copyText } from '../../utils/clipboard'
// The state behind the share dialog, shared by the desktop dialog and the one the
// phone opens from its More sheet. The questions ("Remove this member?") are the
// caller's: the desktop asks in a dialog of its own, the phone with the browser's.
export type SharePerm = 'share_map' | 'share_bookings' | 'share_packing' | 'share_budget' | 'share_collab'
type SharePerms = Record<SharePerm, boolean>
const DEFAULT_PERMS: SharePerms = { share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false }
/** Copy text and show "Copied" for two seconds, the timer restarting on every copy. */
function useCopied() {
const [copied, setCopied] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => () => { if (timer.current) clearTimeout(timer.current) }, [])
const copy = async (text: string | null) => {
if (!text || !(await copyText(text))) return
setCopied(true)
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => setCopied(false), 2000)
}
return { copied, copy }
}
/** The public read-only link and what it may show. */
export function useShareLink(tripId: number) {
const [token, setToken] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [perms, setPerms] = useState<SharePerms>(DEFAULT_PERMS)
const { copied, copy } = useCopied()
const toast = useToast()
const { t } = useTranslation()
useEffect(() => {
shareApi.getLink(tripId).then(d => {
setToken(d.token)
if (d.token) setPerms({ share_map: d.share_map ?? true, share_bookings: d.share_bookings ?? true, share_packing: d.share_packing ?? false, share_budget: d.share_budget ?? false, share_collab: d.share_collab ?? false })
setLoading(false)
}).catch(() => setLoading(false))
}, [tripId])
const url = token ? `${window.location.origin}/shared/${token}` : null
const create = async () => {
try {
const d = await shareApi.createLink(tripId, perms)
setToken(d.token)
} catch { toast.error(t('share.createError')) }
}
const setPerm = async (key: SharePerm, value: boolean) => {
const next = { ...perms, [key]: value }
setPerms(next)
if (token) {
try { await shareApi.createLink(tripId, next) } catch { toast.error(t('share.createError')) }
}
}
const remove = async () => {
try {
await shareApi.deleteLink(tripId)
setToken(null)
} catch { toast.error(t('common.error')) }
}
return { loading, url, perms, copied, create, setPerm, remove, copy: () => copy(url) }
}
/** The rotating link an existing user opens to join the trip (#1143). */
export function useTripInviteLink(tripId: number) {
const [token, setToken] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const { copied, copy } = useCopied()
const toast = useToast()
const { t } = useTranslation()
useEffect(() => {
tripInviteApi.getLink(tripId)
.then((d: { token: string | null }) => setToken(d.token))
.catch(() => {})
.finally(() => setLoading(false))
}, [tripId])
const url = token ? `${window.location.origin}/join/${token}` : null
const create = async () => {
setBusy(true)
try { const d = await tripInviteApi.createLink(tripId); setToken(d.token) }
catch { toast.error(t('share.createError')) }
finally { setBusy(false) }
}
const remove = async () => {
setBusy(true)
try { await tripInviteApi.deleteLink(tripId); setToken(null) }
catch { toast.error(t('common.error')) }
finally { setBusy(false) }
}
return { loading, url, busy, copied, create, remove, copy: () => copy(url) }
}
interface MembersData { owner: TripMember; members: TripMember[] }
interface DirectoryUser { id: number; username: string; is_guest?: boolean }
export type ListedMember = TripMember & { role?: string }
/** The roster: members with an account, the guests without one, and the users who could still be invited. */
export function useTripMembers({ isOpen, tripId, onClose, onMembersChanged }: {
isOpen: boolean
tripId: number
onClose: () => void
/** After a change to the roster, so the planner can refresh Costs participants, Collab and the rest. */
onMembersChanged?: () => void
}) {
const [data, setData] = useState<MembersData | null>(null)
const [allUsers, setAllUsers] = useState<DirectoryUser[]>([])
const [loading, setLoading] = useState(false)
const [selectedUserId, setSelectedUserId] = useState('')
const [adding, setAdding] = useState(false)
const [removingId, setRemovingId] = useState<number | null>(null)
const [transferringId, setTransferringId] = useState<number | null>(null)
const [newGuestName, setNewGuestName] = useState('')
const [addingGuest, setAddingGuest] = useState(false)
const [renamingGuestId, setRenamingGuestId] = useState<number | null>(null)
const [renameValue, setRenameValue] = useState('')
const toast = useToast()
const { user } = useAuthStore()
const { t } = useTranslation()
const can = useCanDo()
const trip = useTripStore(s => s.trip)
const loadBudgetItems = useTripStore(s => s.loadBudgetItems)
const loadMembers = async (notify = false) => {
setLoading(true)
try {
setData(await tripsApi.getMembers(tripId))
// Only after an actual change: the load on opening would be a redundant re-sync.
if (notify) onMembersChanged?.()
} catch {
toast.error(t('members.loadError'))
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!isOpen || !tripId) return
void loadMembers()
authApi.listUsers().then(d => setAllUsers(d.users)).catch(() => {})
}, [isOpen, tripId])
const add = async () => {
const target = allUsers.find(u => String(u.id) === String(selectedUserId))
if (!target) return
setAdding(true)
try {
await tripsApi.addMember(tripId, target.username)
setSelectedUserId('')
await loadMembers(true)
toast.success(`${target.username} ${t('members.added')}`)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('members.addError')))
} finally {
setAdding(false)
}
}
const transfer = async (newOwnerId: number) => {
setTransferringId(newOwnerId)
try {
await tripsApi.transferOwnership(tripId, newOwnerId)
// The current user just dropped from owner to member: reload so the trip
// and the permissions everywhere reflect the new ownership.
onClose()
window.location.reload()
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('members.transferError')))
setTransferringId(null)
}
}
const remove = async (userId: number, isSelf: boolean) => {
setRemovingId(userId)
try {
await tripsApi.removeMember(tripId, userId)
if (isSelf) { onClose(); window.location.reload() }
else { await loadMembers(true); toast.success(t('members.removed')) }
} catch {
toast.error(t('members.removeError'))
} finally {
setRemovingId(null)
}
}
const addGuest = async () => {
const name = newGuestName.trim()
if (!name) return
setAddingGuest(true)
try {
await tripsApi.createGuest(tripId, name)
setNewGuestName('')
await loadMembers(true)
toast.success(t('members.guestAdded'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('members.guestAddError')))
} finally {
setAddingGuest(false)
}
}
// Enter commits, and the blur that follows would send the same rename a second
// time. Only the first commit per editing session goes through; a failed one
// reopens the gate so the user can retry from the still-open input.
const renameCommitted = useRef(false)
const startRename = (guest: TripMember) => {
renameCommitted.current = false
setRenamingGuestId(guest.id)
setRenameValue(guest.username)
}
const cancelRename = () => {
renameCommitted.current = true
setRenamingGuestId(null)
}
const commitRename = async (userId: number) => {
if (renameCommitted.current) return
renameCommitted.current = true
const name = renameValue.trim()
if (!name) { setRenamingGuestId(null); return }
try {
await tripsApi.renameGuest(tripId, userId, name)
setRenamingGuestId(null)
await loadMembers(true)
} catch (err: unknown) {
renameCommitted.current = false
toast.error(getApiErrorMessage(err, t('members.guestRenameError')))
}
}
const removeGuest = async (userId: number) => {
setRemovingId(userId)
try {
await tripsApi.deleteGuest(tripId, userId)
await loadMembers(true)
await loadBudgetItems(tripId)
toast.success(t('members.guestRemoved'))
} catch {
toast.error(t('members.removeError'))
} finally {
setRemovingId(null)
}
}
// Guests are accountless and never live in the directory.
const existingIds = new Set([data?.owner?.id, ...(data?.members?.map(m => m.id) || [])])
const availableUsers = allUsers.filter(u => !existingIds.has(u.id) && !u.is_guest)
// Real members (owner and accounts) and guests (#1362) are listed apart.
const realMembers: ListedMember[] = data ? [{ ...data.owner, role: 'owner' }, ...data.members.filter(m => !m.is_guest)] : []
const guests = data ? data.members.filter(m => m.is_guest) : []
return {
user, loading, realMembers, guests, allUsers, availableUsers,
isCurrentOwner: !!data && data.owner?.id === user?.id,
canManageMembers: can('member_manage', trip),
canManageShare: can('share_manage', trip),
selectedUserId, setSelectedUserId, adding, add,
transferringId, transfer, removingId, remove,
newGuestName, setNewGuestName, addingGuest, addGuest,
renamingGuestId, renameValue, setRenameValue, startRename, cancelRename, commitRename, removeGuest,
}
}
export type TripMembersState = ReturnType<typeof useTripMembers>
export type ShareLinkState = ReturnType<typeof useShareLink>
export type InviteLinkState = ReturnType<typeof useTripInviteLink>
+3 -3
View File
@@ -209,10 +209,10 @@ export function PillSelect<T extends string>({ value, options, onChange, label,
}
/** The dashed full-width button that adds a row: a stop, a leg. */
export function AddRowButton({ onClick, children }: { onClick: () => void; children: ReactNode }) {
export function AddRowButton({ onClick, disabled, children }: { onClick: () => void; disabled?: boolean; children: ReactNode }) {
return (
<button type="button" onClick={onClick}
className="flex w-full items-center justify-center gap-1.5 rounded-[10px] border border-dashed border-edge px-3 py-2 font-semibold text-content-muted hover:border-content-faint hover:text-content"
<button type="button" onClick={onClick} disabled={disabled}
className="flex w-full items-center justify-center gap-1.5 rounded-[10px] border border-dashed border-edge px-3 py-2 font-semibold text-content-muted hover:border-content-faint hover:text-content disabled:cursor-default disabled:opacity-50"
style={fs(12, 'body')}>
<Plus size={13} strokeWidth={2.2} />{children}
</button>