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
+8 -9
View File
@@ -18,10 +18,9 @@ const guide = (id: string): HelpGuide => {
return g
}
/** The block of the Share dialog whose heading this is: the span sits in a row of its own inside the block. */
const block = (page: Page, heading: string | RegExp) => modal(page).getByText(heading, { exact: typeof heading === 'string' }).first().locator('xpath=../..')
/** The Invite User block: its heading is the label, right inside the block. */
const inviteBlock = (page: Page) => modal(page).locator('label').filter({ hasText: 'Invite User' }).locator('xpath=..')
/** A section of the Share dialog by the words it opens with (its label, or the title of a link card). */
const block = (page: Page, opening: RegExp) => modal(page).locator('section').filter({ hasText: opening }).first()
const inviteBlock = (page: Page) => block(page, /^Invite User/)
/**
* The days column's fold tab: the one button straight inside the column's
* frame, which sits 10px in from the left. Its name is Collapse while the
@@ -68,11 +67,11 @@ const SCRIPTS: Record<string, GuideScript> = {
target: inviteBlock,
act: async p => {
await modal(p).getByRole('button', { name: 'Invite', exact: true }).click()
await expect(block(p, /^Access \(/).getByText('mara', { exact: true })).toBeVisible({ timeout: 15_000 })
await expect(block(p, /^Access\s*\d/).getByText('mara', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
only(p => block(p, /^Access \(/)),
only(p => block(p, /^Access\s*\d/)),
],
cleanup: closeModal,
},
@@ -88,7 +87,7 @@ const SCRIPTS: Record<string, GuideScript> = {
await settle(p)
},
},
only(p => block(p, 'Trip invite link')),
only(p => block(p, /^Trip invite link/)),
only(p => modal(p).getByRole('button', { name: 'Regenerate' }).locator('xpath=..')),
],
cleanup: async p => {
@@ -101,7 +100,7 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('add-guest'),
start: openShare,
steps: [
only(p => block(p, 'Guests')),
only(p => block(p, /^Guests/)),
{
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('Guest name'), 'Aunt Lise')
@@ -137,7 +136,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
target: p => block(p, 'Public Link'),
target: p => block(p, /^Public Link/),
act: async p => {
await modal(p).getByRole('button', { name: 'Delete link' }).click()
await expect(modal(p).getByRole('button', { name: 'Create link', exact: true })).toBeVisible({ timeout: 15_000 })
+10
View File
@@ -67,6 +67,16 @@ test('plan: the place inspector', async ({ page, shot }) => {
await shot.page_('PlaceInspector')
})
test('plan: the save to list dialog', async ({ page, shot }) => {
await placeOption(page, 'Senso-ji Temple').click()
await page.waitForTimeout(1000)
await page.getByRole('button', { name: /^Save to Collection/ }).first().click()
await expect(dialog(page)).toBeVisible()
await expect(dialog(page).getByText('Kyoto shortlist')).toBeVisible()
await page.waitForTimeout(500)
await shot.element('SaveToList', dialog(page))
})
test('plan: the edit place dialog', async ({ page, shot }) => {
await placeOption(page, 'Senso-ji Temple').click()
await page.waitForTimeout(1000)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 29 KiB

@@ -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>
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'رابط عام',
'share.linkHint':
'أنشئ رابطًا يمكن لأي شخص استخدامه لعرض هذه الرحلة بدون تسجيل الدخول. للقراءة فقط — لا يمكن التعديل.',
'أنشئ رابطًا يمكن لأي شخص استخدامه لعرض هذه الرحلة بدون تسجيل الدخول. للقراءة فقط: لا يمكن التعديل.',
'share.createLink': 'إنشاء رابط',
'share.deleteLink': 'حذف الرابط',
'share.createError': 'تعذر إنشاء الرابط',
+1 -1
View File
@@ -2,7 +2,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'İctimai link',
'share.linkHint':
'Hər kəsin daxil olmadan bu səyahətə baxa bilməsi üçün link yaradın. Yalnız oxuma rejimidir — redaktə etmək mümkün deyil.',
'Hər kəsin daxil olmadan bu səyahətə baxa bilməsi üçün link yaradın. Yalnız oxuma rejimidir: redaktə etmək mümkün deyil.',
'share.createLink': 'Link yarat',
'share.deleteLink': 'Linki sil',
'share.createError': 'Link yaratmaq mümkün olmadı',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Link público',
'share.linkHint':
'Crie um link que qualquer pessoa pode usar para ver esta viagem sem fazer login. Somente leitura — sem edição possível.',
'Crie um link que qualquer pessoa pode usar para ver esta viagem sem fazer login. Somente leitura: sem edição possível.',
'share.createLink': 'Criar link',
'share.deleteLink': 'Excluir link',
'share.createError': 'Não foi possível criar o link',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Enllaç públic',
'share.linkHint':
'Crea un enllaç que qualsevol pot utilitzar per veure aquest viatge sense iniciar sessió. Només lectura — no es pot editar.',
'Crea un enllaç que qualsevol pot utilitzar per veure aquest viatge sense iniciar sessió. Només lectura: no es pot editar.',
'share.createLink': "Crea l'enllaç",
'share.deleteLink': "Elimina l'enllaç",
'share.createError': "No s'ha pogut crear l'enllaç",
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Veřejný odkaz',
'share.linkHint':
'Vytvořte odkaz, kterým si může kdokoli prohlédnout tuto cestu bez přihlášení. Pouze pro čtení — úpravy nejsou možné.',
'Vytvořte odkaz, kterým si může kdokoli prohlédnout tuto cestu bez přihlášení. Pouze pro čtení: úpravy nejsou možné.',
'share.createLink': 'Vytvořit odkaz',
'share.deleteLink': 'Smazat odkaz',
'share.createError': 'Nepodařilo se vytvořit odkaz',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Öffentlicher Link',
'share.linkHint':
'Erstelle einen Link den jeder ohne Login nutzen kann, um diese Reise anzuschauen. Nur lesen — keine Bearbeitung möglich.',
'Erstelle einen Link den jeder ohne Login nutzen kann, um diese Reise anzuschauen. Nur lesen: keine Bearbeitung möglich.',
'share.createLink': 'Link erstellen',
'share.deleteLink': 'Link löschen',
'share.createError': 'Link konnte nicht erstellt werden',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Public Link',
'share.linkHint':
'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
'Create a link anyone can use to view this trip without logging in. Read-only: no editing possible.',
'share.createLink': 'Create link',
'share.deleteLink': 'Delete link',
'share.createError': 'Could not create link',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Enlace público',
'share.linkHint':
'Crea un enlace que cualquiera puede usar para ver este viaje sin iniciar sesión. Solo lectura — no se puede editar.',
'Crea un enlace que cualquiera puede usar para ver este viaje sin iniciar sesión. Solo lectura: no se puede editar.',
'share.createLink': 'Crear enlace',
'share.deleteLink': 'Eliminar enlace',
'share.createError': 'No se pudo crear el enlace',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Avalik link',
'share.linkHint':
'Loo link, millega igaüks saab seda reisi sisselogimiseta vaadata. Ainult vaatamiseks — muuta ei saa.',
'Loo link, millega igaüks saab seda reisi sisselogimiseta vaadata. Ainult vaatamiseks: muuta ei saa.',
'share.createLink': 'Loo link',
'share.deleteLink': 'Kustuta link',
'share.createError': 'Lingi loomine ebaõnnestus',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Lien public',
'share.linkHint':
"Créez un lien que n'importe qui peut utiliser pour consulter ce voyage sans se connecter. Lecture seule — aucune modification possible.",
"Créez un lien que n'importe qui peut utiliser pour consulter ce voyage sans se connecter. Lecture seule : aucune modification possible.",
'share.createLink': 'Créer un lien',
'share.deleteLink': 'Supprimer le lien',
'share.createError': 'Impossible de créer le lien',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Δημόσιος Σύνδεσμος',
'share.linkHint':
'Δημιουργήστε έναν σύνδεσμο που μπορεί να χρησιμοποιήσει οποιοσδήποτε για να δει αυτό το ταξίδι χωρίς σύνδεση. Μόνο για ανάγνωση — χωρίς δυνατότητα επεξεργασίας.',
'Δημιουργήστε έναν σύνδεσμο που μπορεί να χρησιμοποιήσει οποιοσδήποτε για να δει αυτό το ταξίδι χωρίς σύνδεση. Μόνο για ανάγνωση: χωρίς δυνατότητα επεξεργασίας.',
'share.createLink': 'Δημιουργία συνδέσμου',
'share.deleteLink': 'Διαγραφή συνδέσμου',
'share.createError': 'Αδυναμία δημιουργίας συνδέσμου',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Nyilvános link',
'share.linkHint':
'Hozz létre egy linket, amellyel bárki megtekintheti ezt az utazást bejelentkezés nélkül. Csak olvasható — szerkesztés nem lehetséges.',
'Hozz létre egy linket, amellyel bárki megtekintheti ezt az utazást bejelentkezés nélkül. Csak olvasható: szerkesztés nem lehetséges.',
'share.createLink': 'Link létrehozása',
'share.deleteLink': 'Link törlése',
'share.createError': 'Nem sikerült létrehozni a linket',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Tautan Publik',
'share.linkHint':
'Buat tautan yang bisa digunakan siapa saja untuk melihat perjalanan ini tanpa masuk. Hanya baca — tidak bisa diedit.',
'Buat tautan yang bisa digunakan siapa saja untuk melihat perjalanan ini tanpa masuk. Hanya baca: tidak bisa diedit.',
'share.createLink': 'Buat tautan',
'share.deleteLink': 'Hapus tautan',
'share.createError': 'Gagal membuat tautan',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Link pubblico',
'share.linkHint':
'Crea un link che chiunque può usare per visualizzare questo viaggio senza accedere. Solo lettura — nessuna modifica possibile.',
'Crea un link che chiunque può usare per visualizzare questo viaggio senza accedere. Solo lettura: nessuna modifica possibile.',
'share.createLink': 'Crea link',
'share.deleteLink': 'Elimina link',
'share.createError': 'Impossibile creare il link',
+1 -1
View File
@@ -2,7 +2,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': '공개 링크',
'share.linkHint': '로그인 없이 이 여행을 볼 수 있는 링크를 만드세요. 읽기 전용 — 편집 불가.',
'share.linkHint': '로그인 없이 이 여행을 볼 수 있는 링크를 만드세요. 읽기 전용: 편집 불가.',
'share.createLink': '링크 만들기',
'share.deleteLink': '링크 삭제',
'share.createError': '링크 생성 실패',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Openbare link',
'share.linkHint':
'Maak een link die iedereen kan gebruiken om deze reis te bekijken zonder in te loggen. Alleen-lezen — bewerken niet mogelijk.',
'Maak een link die iedereen kan gebruiken om deze reis te bekijken zonder in te loggen. Alleen-lezen: bewerken niet mogelijk.',
'share.createLink': 'Link aanmaken',
'share.deleteLink': 'Link verwijderen',
'share.createError': 'Kon link niet aanmaken',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Publiczny link',
'share.linkHint':
'Utwórz link umożliwiający przeglądanie tej podróży bez logowania. Tylko do odczytu — bez możliwości edycji.',
'Utwórz link umożliwiający przeglądanie tej podróży bez logowania. Tylko do odczytu: bez możliwości edycji.',
'share.createLink': 'Utwórz link',
'share.deleteLink': 'Usuń link',
'share.createError': 'Nie udało się utworzyć linku',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Публичная ссылка',
'share.linkHint':
'Создайте ссылку, по которой любой сможет просмотреть эту поездку без входа в систему. Только чтение — редактирование невозможно.',
'Создайте ссылку, по которой любой сможет просмотреть эту поездку без входа в систему. Только чтение: редактирование невозможно.',
'share.createLink': 'Создать ссылку',
'share.deleteLink': 'Удалить ссылку',
'share.createError': 'Не удалось создать ссылку',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Verejný odkaz',
'share.linkHint':
'Vytvorte odkaz, ktorým si môže ktokoľvek prezrieť túto cestu bez prihlásenia. Iba na čítanie — úpravy nie sú možné.',
'Vytvorte odkaz, ktorým si môže ktokoľvek prezrieť túto cestu bez prihlásenia. Iba na čítanie: úpravy nie sú možné.',
'share.createLink': 'Vytvoriť odkaz',
'share.deleteLink': 'Zmazať odkaz',
'share.createError': 'Nepodarilo sa vytvoriť odkaz',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Allmän länk',
'share.linkHint':
'Skapa en länk som vem som helst kan använda för att se den här resan utan att logga in. Endast läsbehörighet – redigering är inte möjlig.',
'Skapa en länk som vem som helst kan använda för att se den här resan utan att logga in. Endast läsbehörighet: redigering är inte möjlig.',
'share.createLink': 'Skapa länk',
'share.deleteLink': 'Radera länk',
'share.createError': 'Kunde inte skapa länk',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': "ลิงก์สาธารณะ",
'share.linkHint':
"สร้างลิงก์ที่ทุกคนสามารถใช้เพื่อดูการเดินทางนี้โดยไม่ต้องเข้าสู่ระบบ อ่านอย่างเดียว — ไม่สามารถแก้ไขได้",
"สร้างลิงก์ที่ทุกคนสามารถใช้เพื่อดูการเดินทางนี้โดยไม่ต้องเข้าสู่ระบบ อ่านอย่างเดียว: ไม่สามารถแก้ไขได้",
'share.createLink': "สร้างลิงก์",
'share.deleteLink': "ลบลิงก์",
'share.createError': "ไม่สามารถสร้างลิงก์ได้",
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Genel Bağlantı',
'share.linkHint':
'Herkesin giriş yapmadan bu geziyi görüntülemek için kullanabileceği bir bağlantı oluşturun. Salt okunur — düzenleme mümkün değildir.',
'Herkesin giriş yapmadan bu geziyi görüntülemek için kullanabileceği bir bağlantı oluşturun. Salt okunur: düzenleme mümkün değildir.',
'share.createLink': 'Bağlantı oluştur',
'share.deleteLink': 'Bağlantıyı sil',
'share.createError': 'Bağlantı oluşturulamadı',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Публічне посилання',
'share.linkHint':
'Створіть посилання, за яким будь-хто зможе переглянути цю поїздку без входу. Тільки читання — редагувати не можна.',
'Створіть посилання, за яким будь-хто зможе переглянути цю поїздку без входу. Тільки читання: редагувати не можна.',
'share.createLink': 'Створити посилання',
'share.deleteLink': 'Видалити посилання',
'share.createError': 'Не вдалося створити посилання',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': 'Liên kết công khai',
'share.linkHint':
'Tạo một liên kết mà bất kỳ ai cũng có thể sử dụng để xem chuyến đi này mà không cần đăng nhập. Chỉ đọc — không thể chỉnh sửa.',
'Tạo một liên kết mà bất kỳ ai cũng có thể sử dụng để xem chuyến đi này mà không cần đăng nhập. Chỉ đọc: không thể chỉnh sửa.',
'share.createLink': 'Tạo liên kết',
'share.deleteLink': 'Xóa liên kết',
'share.createError': 'Không thể tạo liên kết',
+2
View File
@@ -41,6 +41,8 @@ You usually find out you have been somewhere while looking at the trip, not whil
- The **Save to list** dialog on a trip place shows every list that already holds it, each with its own status pill. Tap a pill to cycle that list's status, or use **Visited everywhere** to mark all of them at once.
- The places panel has a **mark visited** action in its selection bar (and in the bulk toolbar on phones). Select the places — *Select all* covers the whole trip — and every saved copy of them is marked visited.
![The Save to list dialog: the place's name in the head band, the lists below with a tick for the ones holding it, and View at its foot](assets/SaveToList.png)
Matching is by the place's provider id, its coordinates, or the link a place saved out of that trip already carries, so a copy you renamed inside a list is still found. Lists shared with you read-only are left alone.
## Categories
+6 -6
View File
@@ -1,20 +1,20 @@
# Trip Members and Sharing
![Trip Members](assets/Share.png)
![The Share dialog: 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 on the right](assets/Share.png)
## Opening the Members Panel
- From the **trip planner**: click the Share button in the top navigation bar.
- On **mobile**: open the trip, then **More** → **Share Trip**.
When you have the `share_manage` permission the modal opens to a two-column layout on wider screens (members on the left, share link on the right). Without that permission only the members column is shown. On narrow screens the columns always stack.
The dialog carries the trip's name in its head band, with the number of people on the trip and, once there are any, of its guests. When you have the `share_manage` permission it opens in two columns (members and guests on the left, the two links on the right). Without that permission only the members column is shown. On a narrow window the columns stack.
## Members List
The left column lists everyone who has access to the trip.
- The **trip owner** is marked with a crown badge.
- Your own entry is labeled **(you)**.
- The **trip owner** is marked with an **Owner** badge and a crown.
- Your own entry carries a **you** badge (on the phone it reads **(you)**).
- A remove button appears on your own row, and on every non-owner row if you have the `member_manage` permission.
### Inviting Members
@@ -23,7 +23,7 @@ If you have the `member_manage` permission (default: trip owner), an invite cont
### Removing a Member
Click the remove icon next to any member's name. A confirmation prompt appears before the member is removed.
Click the remove icon next to any member's name (**Remove access**). TREK asks first, in a dialog over the Share dialog, and removes the member only on **Confirm**.
If you click the remove icon next to **your own** name, the action is labeled **Leave trip** and uses a "log out" icon. Leaving reloads the page and returns you to the dashboard.
@@ -74,7 +74,7 @@ These limits are enforced on the server, not just hidden in the UI.
### Renaming and removing
In the owner's Guests section, each guest row has a **Rename** (pencil) and a **Remove access** (trash) button. Removing a guest is **destructive and cascading**:
In the owner's Guests section, each guest row has a **Rename** (pencil) and a **Remove access** (trash) button. Rename turns the name into a field: Enter saves, Escape leaves it as it was. Removing a guest is **destructive and cascading**, so TREK asks before it deletes:
> Remove this guest? Their assignments and cost shares will be removed too.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 170 KiB