import Modal from '../shared/Modal' import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react' import { useTranslation } from '../../i18n' import CustomSelect from '../shared/CustomSelect' import { useIsPhone } from '../../mobile/useIsPhone' import { TripMemberAvatar as Avatar } from './TripMemberAvatar' import TripShareDialog from './TripShareDialog' import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers } from './useTripShare' function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record) => string }) { 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 return (
{t('share.linkTitle')}

{t('share.linkHint')}

{/* Permission checkboxes */}
{[...SHARE_SECTIONS, ...SHARE_OPTIONS].map(opt => ({ ...opt, label: t(opt.label), always: 'always' in opt && !!opt.always })).map(opt => ( ))}
{shareUrl ? (
) : ( )}
) } /** * Trip invite link (#1143). One rotating token per trip that an existing, * logged-in user opens to join the trip as a member. Mirrors ShareLinkSection * but the link points at /join/:token (login-required, no registration). */ function TripInviteLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record) => string }) { const { loading, url: inviteUrl, busy, copied, create, remove, copy } = useTripInviteLink(tripId) if (loading) return null return (
{t('trip.invite.linkTitle')}

{t('trip.invite.linkHint')}

{inviteUrl ? (
) : ( )}
) } interface TripMembersModalProps { isOpen: boolean onClose: () => void tripId: number tripTitle: string /** Called after the roster changes (guest/member added, renamed or removed) so the * planner can refresh its members for Costs participants, Collab, etc. */ onMembersChanged?: () => void } /** 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 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 handleRemove = (userId: number, isSelf: boolean) => { if (confirm(isSelf ? t('members.confirmLeave') : t('members.confirmRemove'))) void m.remove(userId, isSelf) } const handleDeleteGuest = (userId: number) => { if (confirm(t('members.confirmRemoveGuest'))) void m.removeGuest(userId) } return (
{/* Left column: Members */}
{/* Trip name */}
{t('nav.trip')}
{tripTitle}
{/* Add member dropdown */} {canManageMembers &&
setSelectedUserId(String(value))} placeholder={t('members.selectUser')} options={[ { value: '', label: t('members.selectUser') }, // As a string: the select compares values strictly, and the chosen id is // kept as a string, so a number here left the trigger on its placeholder. ...availableUsers.map(u => ({ value: String(u.id), label: u.username, })), ]} searchable style={{ flex: 1 }} size="sm" />
{availableUsers.length === 0 && allUsers.length > 0 && canManageMembers && (

{t('members.allHaveAccess')}

)}
} {/* Members list */}
{t('members.access')} ({realMembers.length} {t('members.persons', { count: realMembers.length })})
{loading ? (
{[1, 2].map(i => (
))}
) : (
{realMembers.map(member => { const isSelf = member.id === user?.id const canRemove = isSelf || (canManageMembers && member.role !== 'owner') return (
{member.username} {isSelf && ({t('members.you')})} {member.role === 'owner' && ( {t('members.owner')} )}
{isCurrentOwner && member.role !== 'owner' && ( )} {canRemove && ( )}
) })}
)}
{/* Guests (#1362) — accountless participants, managed by the owner */} {(isCurrentOwner || guests.length > 0) && (
{t('members.guests')}{guests.length > 0 ? ` (${guests.length})` : ''}

{t('members.guestsHint')}

{guests.map(g => (
{renamingGuestId === g.id ? ( setRenameValue(e.target.value)} 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" style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '4px 8px', borderRadius: 8, outline: 'none', fontFamily: 'inherit' }} /> ) : (
{g.username} {t('members.guest')}
)} {isCurrentOwner && renamingGuestId !== g.id && ( <> )}
))}
{isCurrentOwner && (
0 ? 8 : 0 }}> setNewGuestName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleAddGuest() }} placeholder={t('members.guestNamePlaceholder')} maxLength={50} className="bg-surface border border-edge text-content" style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '8px 10px', borderRadius: 10, outline: 'none', fontFamily: 'inherit' }} />
)}
)}
{/* Right column: Share Link */} {canManageShare &&
}
) }