diff --git a/client/e2e/help/trip.guide.ts b/client/e2e/help/trip.guide.ts index 5e833af59..ccfbaf174 100644 --- a/client/e2e/help/trip.guide.ts +++ b/client/e2e/help/trip.guide.ts @@ -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 = { 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 = { 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 = { 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 = { }, }, { - 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 }) diff --git a/client/e2e/screenshots/plan.shot.ts b/client/e2e/screenshots/plan.shot.ts index b15301167..2d11bcff4 100644 --- a/client/e2e/screenshots/plan.shot.ts +++ b/client/e2e/screenshots/plan.shot.ts @@ -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) diff --git a/client/public/help-media/add-guest/result.webp b/client/public/help-media/add-guest/result.webp index 6758912d4..a5b404c57 100644 Binary files a/client/public/help-media/add-guest/result.webp and b/client/public/help-media/add-guest/result.webp differ diff --git a/client/public/help-media/add-guest/step-1.webp b/client/public/help-media/add-guest/step-1.webp index 5d2b1e10c..651b69b00 100644 Binary files a/client/public/help-media/add-guest/step-1.webp and b/client/public/help-media/add-guest/step-1.webp differ diff --git a/client/public/help-media/add-guest/step-2.webp b/client/public/help-media/add-guest/step-2.webp index 1ed6a2b83..20522ebf2 100644 Binary files a/client/public/help-media/add-guest/step-2.webp and b/client/public/help-media/add-guest/step-2.webp differ diff --git a/client/public/help-media/add-member/result.webp b/client/public/help-media/add-member/result.webp index a7b94656a..a58da0895 100644 Binary files a/client/public/help-media/add-member/result.webp and b/client/public/help-media/add-member/result.webp differ diff --git a/client/public/help-media/add-member/step-2.webp b/client/public/help-media/add-member/step-2.webp index 8ff47b6bc..e3ea95301 100644 Binary files a/client/public/help-media/add-member/step-2.webp and b/client/public/help-media/add-member/step-2.webp differ diff --git a/client/public/help-media/add-member/step-3.webp b/client/public/help-media/add-member/step-3.webp index faaa746ba..8bae293d0 100644 Binary files a/client/public/help-media/add-member/step-3.webp and b/client/public/help-media/add-member/step-3.webp differ diff --git a/client/public/help-media/public-link/step-1.webp b/client/public/help-media/public-link/step-1.webp index 0d9f37bd9..ca0e03ae8 100644 Binary files a/client/public/help-media/public-link/step-1.webp and b/client/public/help-media/public-link/step-1.webp differ diff --git a/client/public/help-media/public-link/step-2.webp b/client/public/help-media/public-link/step-2.webp index 165e3bace..fcee36293 100644 Binary files a/client/public/help-media/public-link/step-2.webp and b/client/public/help-media/public-link/step-2.webp differ diff --git a/client/public/help-media/public-link/step-3.webp b/client/public/help-media/public-link/step-3.webp index 87c28f10f..15419ab06 100644 Binary files a/client/public/help-media/public-link/step-3.webp and b/client/public/help-media/public-link/step-3.webp differ diff --git a/client/public/help-media/transfer-ownership/step-1.webp b/client/public/help-media/transfer-ownership/step-1.webp index 9d424f0c8..5e416dd49 100644 Binary files a/client/public/help-media/transfer-ownership/step-1.webp and b/client/public/help-media/transfer-ownership/step-1.webp differ diff --git a/client/public/help-media/transfer-ownership/step-2.webp b/client/public/help-media/transfer-ownership/step-2.webp index 5b179e58d..0f0a907c1 100644 Binary files a/client/public/help-media/transfer-ownership/step-2.webp and b/client/public/help-media/transfer-ownership/step-2.webp differ diff --git a/client/public/help-media/trip-invite-link/step-1.webp b/client/public/help-media/trip-invite-link/step-1.webp index 99a4777a8..6479c815a 100644 Binary files a/client/public/help-media/trip-invite-link/step-1.webp and b/client/public/help-media/trip-invite-link/step-1.webp differ diff --git a/client/public/help-media/trip-invite-link/step-2.webp b/client/public/help-media/trip-invite-link/step-2.webp index b2d3f1781..f85cceb09 100644 Binary files a/client/public/help-media/trip-invite-link/step-2.webp and b/client/public/help-media/trip-invite-link/step-2.webp differ diff --git a/client/public/help-media/trip-invite-link/step-3.webp b/client/public/help-media/trip-invite-link/step-3.webp index ec31a97f5..4ef407776 100644 Binary files a/client/public/help-media/trip-invite-link/step-3.webp and b/client/public/help-media/trip-invite-link/step-3.webp differ diff --git a/client/src/components/Collections/SaveToCollectionModal.test.tsx b/client/src/components/Collections/SaveToCollectionModal.test.tsx index 87aa28edf..7afa588b4 100644 --- a/client/src/components/Collections/SaveToCollectionModal.test.tsx +++ b/client/src/components/Collections/SaveToCollectionModal.test.tsx @@ -126,9 +126,9 @@ describe('SaveToCollectionModal', () => { render(); 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 () => { diff --git a/client/src/components/Collections/SaveToCollectionModal.tsx b/client/src/components/Collections/SaveToCollectionModal.tsx index 899b5a145..6dbcfcce4 100644 --- a/client/src/components/Collections/SaveToCollectionModal.tsx +++ b/client/src/components/Collections/SaveToCollectionModal.tsx @@ -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([]) const [membership, setMembership] = useState(null) @@ -147,106 +148,89 @@ export default function SaveToCollectionModal(): React.ReactElement | null { } } + const openCollections = () => { close(); navigate('/collections') } + return ( - - - - - } - > -
-
-

{target.name}

- {/* One tap for "I have been here", however many lists hold it (#1469). */} - {unvisited.length > 0 && ( - - )} + ) : undefined} + /> + )} + footer={lists.length > 0 ? ( + + }>{t('collections.viewInCollection')} + + + ) : undefined} + > + {loading ? ( +
+
- {loading ? ( -
- + ) : lists.length === 0 ? ( +
+
+
- ) : lists.length === 0 ? ( -
-
- -
-

{t('collections.noListsYet')}

- -
- ) : ( -
- {lists.map(list => { - const entry = savedByCollection.get(list.id) - const saved = !!entry - const busy = busyId === list.id - return ( - - ) - })} -
- )} -
- +

{t('collections.noListsYet')}

+ }>{t('collections.newList')} +
+ ) : ( +
+ {lists.map(list => { + const entry = savedByCollection.get(list.id) + const saved = !!entry + const busy = busyId === list.id + return ( + + ) + })} +
+ )} + ) } diff --git a/client/src/components/Trips/TripMemberAvatar.tsx b/client/src/components/Trips/TripMemberAvatar.tsx new file mode 100644 index 000000000..6d36d68f8 --- /dev/null +++ b/client/src/components/Trips/TripMemberAvatar.tsx @@ -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 + } + const letter = (username || '?')[0].toUpperCase() + const color = INITIAL_COLORS[(letter.codePointAt(0) ?? 0) % INITIAL_COLORS.length] + return ( +
+ {letter} +
+ ) +} diff --git a/client/src/components/Trips/TripMembersModal.test.tsx b/client/src/components/Trips/TripMembersModal.test.tsx index 0e35564c9..2ea415b85 100644 --- a/client/src/components/Trips/TripMembersModal.test.tsx +++ b/client/src/components/Trips/TripMembersModal.test.tsx @@ -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(); - // 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(); 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(); 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(); 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(); 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 | null = null; server.use( @@ -789,8 +785,9 @@ describe('TripMembersModal', () => { ); render(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); + 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(); + + 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(); + + 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)); + }); +}); diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx index f11929331..9d3f0cd0f 100644 --- a/client/src/components/Trips/TripMembersModal.tsx +++ b/client/src/components/Trips/TripMembersModal.tsx @@ -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 - } - const letter = (username || '?')[0].toUpperCase() - const colors = ['#3b82f6', '#8b5cf6', '#ec4899', '#10b981', '#f59e0b', '#ef4444', '#06b6d4'] - const color = colors[(letter.codePointAt(0) ?? 0) % colors.length] - return ( -
- {letter} -
- ) -} +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 }) { - const [shareToken, setShareToken] = useState(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 | 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[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 }) { - const [token, setToken] = useState(null) - const [loading, setLoading] = useState(true) - const [busy, setBusy] = useState(false) - const [copied, setCopied] = useState(false) - const toast = useToast() - const copyTimerRef = useRef | 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 ? : +} + +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 (
@@ -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 && ( <> + + ) +} + +function SectionLabel({ text, count }: { text: string; count?: number }) { + return ( + + {text} + {count != null && {count}} + + ) +} + +function MembersList({ m, onAsk }: { m: TripMembersState; onAsk: (ask: Ask) => void }) { + const { t } = useTranslation() + return ( + }> + {m.loading ? ( +
+ {[1, 2].map(i =>
)} +
+ ) : ( +
+ {m.realMembers.map(member => { + const isSelf = member.id === m.user?.id + const isOwner = member.role === 'owner' + const canRemove = isSelf || (m.canManageMembers && !isOwner) + return ( +
+ +
+ {member.username} + {isSelf && {t('members.you')}} + {isOwner && ( + + {t('members.owner')} + + )} +
+ {m.isCurrentOwner && !isOwner && ( + onAsk({ + title: t('members.makeOwner'), + message: t('members.confirmTransfer', { name: member.username }), + confirmLabel: t('common.confirm'), + run: () => void m.transfer(member.id), + })}> + + + )} + {canRemove && ( + 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 ? : } + + )} +
+ ) + })} +
+ )} + + ) +} + +/** Accountless participants (#1362): the owner names, renames and removes them. */ +function GuestsList({ m, onAsk }: { m: TripMembersState; onAsk: (ask: Ask) => void }) { + const { t } = useTranslation() + return ( + }> +

{t('members.guestsHint')}

+ {m.guests.length > 0 && ( +
+ {m.guests.map(g => ( +
+ + {m.renamingGuestId === g.id ? ( + 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`} + /> + ) : ( +
+ {g.username} + + {t('members.guest')} + +
+ )} + {m.isCurrentOwner && m.renamingGuestId !== g.id && ( + <> + m.startRename(g)}> + + + onAsk({ + title: t('members.removeAccess'), + message: t('members.confirmRemoveGuest'), + confirmLabel: t('common.delete'), + run: () => void m.removeGuest(g.id), + })}> + + + + )} +
+ ))} +
+ )} + {m.isCurrentOwner && ( +
+ 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`} + /> + void m.addGuest()} disabled={m.addingGuest || !m.newGuestName.trim()} icon={}> + {m.addingGuest ? '...' : t('members.addGuest')} + +
+ )} +
+ ) +} + +/** 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 ( +
+ e.currentTarget.select()} + className="min-w-0 flex-1 truncate border-0 bg-transparent font-mono text-content outline-none" style={fs(11.5)} /> + +
+ ) +} + +function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) { + return ( +
+
+ {icon} +
+

{title}

+

{hint}

+
+
+ {children} +
+ ) +} + +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 ( + } title={t('share.linkTitle')} hint={t('share.linkHint')}> +
+ {SHARE_PERMS.map(opt => { + const on = link.perms[opt.key] + return ( + + ) + })} +
+ {link.url ? ( + <> + void link.copy()} /> +
+ +
+ + ) : ( + void link.create()}>{t('share.createLink')} + )} +
+ ) +} + +/** 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 ( + } title={t('trip.invite.linkTitle')} hint={t('trip.invite.linkHint')}> + {link.url ? ( + <> + void link.copy()} /> +
+ void link.create()} disabled={link.busy} icon={}> + {t('trip.invite.regenerate')} + + +
+ + ) : ( + void link.create()} disabled={link.busy}>{t('trip.invite.create')} + )} +
+ ) +} diff --git a/client/src/components/Trips/useTripShare.ts b/client/src/components/Trips/useTripShare.ts new file mode 100644 index 000000000..6d1211933 --- /dev/null +++ b/client/src/components/Trips/useTripShare.ts @@ -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 + +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 | 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(null) + const [loading, setLoading] = useState(true) + const [perms, setPerms] = useState(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(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(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() + 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 +export type ShareLinkState = ReturnType +export type InviteLinkState = ReturnType diff --git a/client/src/components/shared/dialogParts.tsx b/client/src/components/shared/dialogParts.tsx index 283ff3cf0..702a960c8 100644 --- a/client/src/components/shared/dialogParts.tsx +++ b/client/src/components/shared/dialogParts.tsx @@ -209,10 +209,10 @@ export function PillSelect({ 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 ( - diff --git a/shared/src/i18n/ar/share.ts b/shared/src/i18n/ar/share.ts index 4d4c77f23..0e24dd09f 100644 --- a/shared/src/i18n/ar/share.ts +++ b/shared/src/i18n/ar/share.ts @@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types'; const share: TranslationStrings = { 'share.linkTitle': 'رابط عام', 'share.linkHint': - 'أنشئ رابطًا يمكن لأي شخص استخدامه لعرض هذه الرحلة بدون تسجيل الدخول. للقراءة فقط — لا يمكن التعديل.', + 'أنشئ رابطًا يمكن لأي شخص استخدامه لعرض هذه الرحلة بدون تسجيل الدخول. للقراءة فقط: لا يمكن التعديل.', 'share.createLink': 'إنشاء رابط', 'share.deleteLink': 'حذف الرابط', 'share.createError': 'تعذر إنشاء الرابط', diff --git a/shared/src/i18n/az/share.ts b/shared/src/i18n/az/share.ts index da0759a68..8dcc957d5 100644 --- a/shared/src/i18n/az/share.ts +++ b/shared/src/i18n/az/share.ts @@ -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ı', diff --git a/shared/src/i18n/br/share.ts b/shared/src/i18n/br/share.ts index 2d0410fe5..e32f9d9a2 100644 --- a/shared/src/i18n/br/share.ts +++ b/shared/src/i18n/br/share.ts @@ -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', diff --git a/shared/src/i18n/ca/share.ts b/shared/src/i18n/ca/share.ts index 02e5933f1..3238fa10f 100644 --- a/shared/src/i18n/ca/share.ts +++ b/shared/src/i18n/ca/share.ts @@ -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ç", diff --git a/shared/src/i18n/cs/share.ts b/shared/src/i18n/cs/share.ts index 4daea1a2b..6ab49941c 100644 --- a/shared/src/i18n/cs/share.ts +++ b/shared/src/i18n/cs/share.ts @@ -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', diff --git a/shared/src/i18n/de/share.ts b/shared/src/i18n/de/share.ts index adb9d7114..584a6366d 100644 --- a/shared/src/i18n/de/share.ts +++ b/shared/src/i18n/de/share.ts @@ -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', diff --git a/shared/src/i18n/en/share.ts b/shared/src/i18n/en/share.ts index 0a3a2a463..1230961a3 100644 --- a/shared/src/i18n/en/share.ts +++ b/shared/src/i18n/en/share.ts @@ -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', diff --git a/shared/src/i18n/es/share.ts b/shared/src/i18n/es/share.ts index d63370b4e..1e8b82620 100644 --- a/shared/src/i18n/es/share.ts +++ b/shared/src/i18n/es/share.ts @@ -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', diff --git a/shared/src/i18n/et/share.ts b/shared/src/i18n/et/share.ts index 55c58304f..34747ff09 100644 --- a/shared/src/i18n/et/share.ts +++ b/shared/src/i18n/et/share.ts @@ -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', diff --git a/shared/src/i18n/fr/share.ts b/shared/src/i18n/fr/share.ts index f90041aff..5239caf38 100644 --- a/shared/src/i18n/fr/share.ts +++ b/shared/src/i18n/fr/share.ts @@ -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', diff --git a/shared/src/i18n/gr/share.ts b/shared/src/i18n/gr/share.ts index 100c538f8..f3956303b 100644 --- a/shared/src/i18n/gr/share.ts +++ b/shared/src/i18n/gr/share.ts @@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types'; const share: TranslationStrings = { 'share.linkTitle': 'Δημόσιος Σύνδεσμος', 'share.linkHint': - 'Δημιουργήστε έναν σύνδεσμο που μπορεί να χρησιμοποιήσει οποιοσδήποτε για να δει αυτό το ταξίδι χωρίς σύνδεση. Μόνο για ανάγνωση — χωρίς δυνατότητα επεξεργασίας.', + 'Δημιουργήστε έναν σύνδεσμο που μπορεί να χρησιμοποιήσει οποιοσδήποτε για να δει αυτό το ταξίδι χωρίς σύνδεση. Μόνο για ανάγνωση: χωρίς δυνατότητα επεξεργασίας.', 'share.createLink': 'Δημιουργία συνδέσμου', 'share.deleteLink': 'Διαγραφή συνδέσμου', 'share.createError': 'Αδυναμία δημιουργίας συνδέσμου', diff --git a/shared/src/i18n/hu/share.ts b/shared/src/i18n/hu/share.ts index 0fdea4325..e847170a6 100644 --- a/shared/src/i18n/hu/share.ts +++ b/shared/src/i18n/hu/share.ts @@ -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', diff --git a/shared/src/i18n/id/share.ts b/shared/src/i18n/id/share.ts index 1dca2c0de..8a2280b29 100644 --- a/shared/src/i18n/id/share.ts +++ b/shared/src/i18n/id/share.ts @@ -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', diff --git a/shared/src/i18n/it/share.ts b/shared/src/i18n/it/share.ts index d0bbb17cf..0fd53b2d3 100644 --- a/shared/src/i18n/it/share.ts +++ b/shared/src/i18n/it/share.ts @@ -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', diff --git a/shared/src/i18n/ko/share.ts b/shared/src/i18n/ko/share.ts index d6df716bc..b1abfff4b 100644 --- a/shared/src/i18n/ko/share.ts +++ b/shared/src/i18n/ko/share.ts @@ -2,7 +2,7 @@ import type { TranslationStrings } from '../types'; const share: TranslationStrings = { 'share.linkTitle': '공개 링크', - 'share.linkHint': '로그인 없이 이 여행을 볼 수 있는 링크를 만드세요. 읽기 전용 — 편집 불가.', + 'share.linkHint': '로그인 없이 이 여행을 볼 수 있는 링크를 만드세요. 읽기 전용: 편집 불가.', 'share.createLink': '링크 만들기', 'share.deleteLink': '링크 삭제', 'share.createError': '링크 생성 실패', diff --git a/shared/src/i18n/nl/share.ts b/shared/src/i18n/nl/share.ts index 85792503d..3c624282d 100644 --- a/shared/src/i18n/nl/share.ts +++ b/shared/src/i18n/nl/share.ts @@ -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', diff --git a/shared/src/i18n/pl/share.ts b/shared/src/i18n/pl/share.ts index e57124bc0..e8c7f3cb6 100644 --- a/shared/src/i18n/pl/share.ts +++ b/shared/src/i18n/pl/share.ts @@ -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', diff --git a/shared/src/i18n/ru/share.ts b/shared/src/i18n/ru/share.ts index 816c83ab6..c1f0aecae 100644 --- a/shared/src/i18n/ru/share.ts +++ b/shared/src/i18n/ru/share.ts @@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types'; const share: TranslationStrings = { 'share.linkTitle': 'Публичная ссылка', 'share.linkHint': - 'Создайте ссылку, по которой любой сможет просмотреть эту поездку без входа в систему. Только чтение — редактирование невозможно.', + 'Создайте ссылку, по которой любой сможет просмотреть эту поездку без входа в систему. Только чтение: редактирование невозможно.', 'share.createLink': 'Создать ссылку', 'share.deleteLink': 'Удалить ссылку', 'share.createError': 'Не удалось создать ссылку', diff --git a/shared/src/i18n/sk/share.ts b/shared/src/i18n/sk/share.ts index e2b44f05e..d7b685846 100644 --- a/shared/src/i18n/sk/share.ts +++ b/shared/src/i18n/sk/share.ts @@ -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', diff --git a/shared/src/i18n/sv/share.ts b/shared/src/i18n/sv/share.ts index 718be9d35..371a002c5 100644 --- a/shared/src/i18n/sv/share.ts +++ b/shared/src/i18n/sv/share.ts @@ -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', diff --git a/shared/src/i18n/th/share.ts b/shared/src/i18n/th/share.ts index 6ac6cfabe..b62b6437e 100644 --- a/shared/src/i18n/th/share.ts +++ b/shared/src/i18n/th/share.ts @@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types'; const share: TranslationStrings = { 'share.linkTitle': "ลิงก์สาธารณะ", 'share.linkHint': - "สร้างลิงก์ที่ทุกคนสามารถใช้เพื่อดูการเดินทางนี้โดยไม่ต้องเข้าสู่ระบบ อ่านอย่างเดียว — ไม่สามารถแก้ไขได้", + "สร้างลิงก์ที่ทุกคนสามารถใช้เพื่อดูการเดินทางนี้โดยไม่ต้องเข้าสู่ระบบ อ่านอย่างเดียว: ไม่สามารถแก้ไขได้", 'share.createLink': "สร้างลิงก์", 'share.deleteLink': "ลบลิงก์", 'share.createError': "ไม่สามารถสร้างลิงก์ได้", diff --git a/shared/src/i18n/tr/share.ts b/shared/src/i18n/tr/share.ts index fbe0b182a..c9b4f2d1f 100644 --- a/shared/src/i18n/tr/share.ts +++ b/shared/src/i18n/tr/share.ts @@ -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ı', diff --git a/shared/src/i18n/uk/share.ts b/shared/src/i18n/uk/share.ts index 8b10cd313..1416296ef 100644 --- a/shared/src/i18n/uk/share.ts +++ b/shared/src/i18n/uk/share.ts @@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types'; const share: TranslationStrings = { 'share.linkTitle': 'Публічне посилання', 'share.linkHint': - 'Створіть посилання, за яким будь-хто зможе переглянути цю поїздку без входу. Тільки читання — редагувати не можна.', + 'Створіть посилання, за яким будь-хто зможе переглянути цю поїздку без входу. Тільки читання: редагувати не можна.', 'share.createLink': 'Створити посилання', 'share.deleteLink': 'Видалити посилання', 'share.createError': 'Не вдалося створити посилання', diff --git a/shared/src/i18n/vi/share.ts b/shared/src/i18n/vi/share.ts index 6396f766d..1df6d5f22 100644 --- a/shared/src/i18n/vi/share.ts +++ b/shared/src/i18n/vi/share.ts @@ -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', diff --git a/wiki/Collections.md b/wiki/Collections.md index 7150f9f9c..d300eed7b 100644 --- a/wiki/Collections.md +++ b/wiki/Collections.md @@ -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 diff --git a/wiki/Trip-Members-and-Sharing.md b/wiki/Trip-Members-and-Sharing.md index 45781c28c..1e6766e8b 100644 --- a/wiki/Trip-Members-and-Sharing.md +++ b/wiki/Trip-Members-and-Sharing.md @@ -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. diff --git a/wiki/assets/SaveToList.png b/wiki/assets/SaveToList.png new file mode 100644 index 000000000..d76884e98 Binary files /dev/null and b/wiki/assets/SaveToList.png differ diff --git a/wiki/assets/Share.png b/wiki/assets/Share.png index 265435c08..48a38fc5d 100644 Binary files a/wiki/assets/Share.png and b/wiki/assets/Share.png differ