mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
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:
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user