Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 | 7x 7x 7x 7x 122x 122x 122x 122x 122x 122x 122x 122x 122x 122x 13x 13x 122x 122x 122x 122x 1x 4x 122x 2x 2x 122x 121x 121x 121x 121x | import { ReactNode, useState } from "react";
import { Stack } from "@mui/material";
import NiceModal from "@ebay/nice-modal-react";
import {
PaperPlaneTiltIcon,
ProhibitIcon,
UserMinusIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { QKEY_EVENT_HOSTS } from "@constants/queryKeys";
import { ACTION_DENY_MESSAGE } from "@constants/permissions";
import { useCustomThemeV2 } from "@theme/hooks/useCustomThemeV2";
import ContextualMenu from "@shared/ContextualMenu/ContextualMenu";
import { ContextualMenuOptionProps } from "@shared/ContextualMenu/ContextualMenu.types";
import GiveText from "@shared/Text/GiveText";
import Table from "componentsV2/Table/Table";
import TechnicalErrorState from "componentsV2/Table/components/TechnicalErrorState";
import { useTeamPermissions } from "@features/Permissions/AccessControl/hooks";
import { getNestedTableSectionHeight } from "@features/Settings/Team/helpers/pahSectionLayout";
import {
EVENT_ASSIGN_HOST_MODAL,
EVENT_INVITE_HOST_MODAL,
} from "modals/modal_names";
import { EventHostRow } from "./host.types";
import { useEventHostColumns } from "./useEventHostColumns";
import { useEventHosts } from "./useEventHosts";
import { useRevokeHostConfirm } from "./useRevokeHostConfirm";
type Props = {
eventId?: string;
/** The shared banner head — see EventTicketsTab for why this is a render fn. */
renderHead: (options?: { hideToolbar?: boolean }) => ReactNode;
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
searchQuery?: string;
handleSearchChange?: (value: string) => void;
};
const noop = () => undefined;
/**
* Single-row height for the Invited section's wrapper, which must carry a
* definite px height or the nested table's rows collapse — see
* `getNestedTableSectionHeight`, shared with Settings → Team's PAH section
* (same nested-table arrangement, same 72px `GiveMemberNameCell` rows).
*
* The numbers differ from Team's because this section's heading paddings are
* 16/8 rather than 52/16, and because this tab's columns hide the column-header
* strip to match the mockups (see HIDE_HEADER_CELL in useEventHostColumns) —
* which the narrow layout drops anyway, hence the shorter compact base.
*/
const INVITED_ONE_ROW_PX = 120;
const INVITED_ONE_ROW_COMPACT_PX = 116;
/**
* PayBuilder — the Host tab (frames 8089-55598, 8119-109601, 8119-110578,
* 8119-115602): who can work this event's door.
*
* Two sections on one scroller, built the way Settings → Team builds its
* PAH/Members split: the outer table owns the "Assigned Host" list and the
* scroll, and everything above it — the banner head and the whole "Invited
* Host" section — rides in its `Head`. That keeps one scroll container for the
* page (the sticky breadcrumb overlay is driven by its `onScroll`) and reuses
* the V2 table's skeleton and empty state instead of hand-rolling either.
*
* The Invited section is absent — not empty — when nothing is pending.
*
* Both sections are scoped to THIS event: "Assigned" is the door staff who
* have activated, "Invited" is this event's still-outstanding invitations. A
* host invited to another event does not appear here — but one *assigned* to
* another event does appear on the Assign Host modal's bench, which is what
* makes that modal the way to grow this list without sending an email.
*
* Every row action here writes an account member, so each carries the gate its
* own call is subject to: unassigning writes the member's assignment set, while
* revoking deletes the membership. The roster read itself needs `member:list`,
* which the sibling tabs' product reads do not.
*/
const EventHostTab = ({
eventId,
renderHead,
onScroll,
// Defaulted, not merely optional: the table reads `searchQuery !== ""` as
// "a search is active", so leaving it undefined makes an empty tab render the
// "Clear Search" state instead of "No host assigned yet".
searchQuery = "",
handleSearchChange,
}: Props) => {
const {
invitedRows,
assignedRows,
isLoading,
isFetching,
isError,
isNotAuthorized,
revokeHost,
unassignHost,
resendInvite,
pendingId,
isEmpty,
} = useEventHosts(eventId, searchQuery);
const { isAddAllowed, isDeleteAllowed, isUpdateAccessPolicyAllowed } =
useTeamPermissions();
const { confirmRevoke } = useRevokeHostConfirm({ onConfirm: revokeHost });
// `member:list` denied up front, or the read itself refused — see
// useEventHosts.
const canReadRoster = !isNotAuthorized;
// Narrow views drop the nested table's column-header strip, so the Invited
// wrapper needs the shorter base height — the same split Team's PAH uses.
const { isMobileView, isLargeTabletView } = useCustomThemeV2();
const isCompactView = isMobileView || isLargeTabletView;
// One menu shared by every row, anchored to whichever kebab opened it — the
// same arrangement Settings → Team uses. Which options it carries is decided
// from the row, since the two sections offer different ones.
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null);
const [menuRow, setMenuRow] = useState<EventHostRow | null>(null);
const closeMenu = () => setMenuAnchor(null);
const { columns } = useEventHostColumns({
onOpenMenu: (event, row) => {
setMenuAnchor(event.currentTarget);
setMenuRow(row);
},
pendingId,
});
const openAssignModal = () =>
NiceModal.show(EVENT_ASSIGN_HOST_MODAL, { eventId });
const openInviteModal = () =>
NiceModal.show(EVENT_INVITE_HOST_MODAL, { eventId });
// A write already in flight for this row disables its destructive options:
// the menu closes on click but can be reopened, and either call landing twice
// is a 404 at best.
const isRowPending = Boolean(menuRow && pendingId === menuRow.id);
/**
* An assigned host's two exits (frame 8119-115264). Unassign is the narrow
* one — this event only; Revoke is the wide one and takes the confirmation
* step, because the word does not say that it clears every other event too.
*/
const assignedMenuOptions: ContextualMenuOptionProps[] = [
{
text: "Unassign Host",
IconRight: UserMinusIcon,
disabled: !isUpdateAccessPolicyAllowed || isRowPending,
tooltipTitle: ACTION_DENY_MESSAGE,
disableTooltip: isUpdateAccessPolicyAllowed,
onClick: () => menuRow && unassignHost(menuRow),
},
{
text: "Revoke Access",
type: "destructive",
IconRight: ProhibitIcon,
disabled: !isDeleteAllowed || isRowPending,
tooltipTitle: ACTION_DENY_MESSAGE,
disableTooltip: isDeleteAllowed,
onClick: () => menuRow && confirmRevoke(menuRow),
},
];
/**
* A pending host has no access to unassign yet, so their row's menu is about
* the invitation instead (frame 7117-41722). Withdrawing one is immediate:
* unlike Revoke Access it takes away nothing the host has, so there is
* nothing for a confirmation to warn about.
*/
const invitedMenuOptions: ContextualMenuOptionProps[] = [
{
text: "Resend Invitation",
IconRight: PaperPlaneTiltIcon,
disabled: !isAddAllowed || isRowPending,
tooltipTitle: ACTION_DENY_MESSAGE,
disableTooltip: isAddAllowed,
onClick: () => menuRow && resendInvite(menuRow),
},
{
text: "Revoke Invitation",
type: "destructive",
IconRight: XCircleIcon,
disabled: !isDeleteAllowed || isRowPending,
tooltipTitle: ACTION_DENY_MESSAGE,
disableTooltip: isDeleteAllowed,
onClick: () => menuRow && revokeHost(menuRow),
},
];
// A 403 on the roster is not an outage — it renders through the table's own
// not-authorized state below, the way every other permission-gated V2 table
// reports it.
if (isError && !isLoading && !isNotAuthorized) return <TechnicalErrorState />;
// Frame 8089-55598: an event with no hosts at all shows only the banner, tabs
// and the centred empty state — no search, no buttons in the toolbar. The
// empty state carries both actions instead. A roster the user may not read
// has nothing to search or invite into either.
const hideToolbar = isEmpty || !canReadRoster;
// Frame 8119-109601 ("Filled - Has Invited - No Assigned Host"): once invites
// are listed above, the Assigned section's empty state keeps only Assign Host
// — an invitation is already out, so repeating Invite beside the toolbar's own
// button would put the same CTA on the page twice — and the lead-in shortens
// from the default 64px band to 20px.
//
// Decided from the rows alone, search or not: a query that matched only an
// invited host has found someone, so the Assigned section under it is empty,
// not a miss — its Clear Search would wipe a search that hit. A true miss
// (both lists empty) keeps the query and with it "No results" / Clear Search.
//
// Gated on `canReadRoster` for the same reason the section below is: with the
// roster refused there is no invited list on screen to shorten the lead-in to.
const hasInvitesOnly =
canReadRoster && invitedRows.length > 0 && assignedRows.length === 0;
// Frame 8119-109601 keeps the "Assigned Host (0)" heading above its empty
// state, so the heading is unconditional once anything is on the tab.
const Head = (
<Stack>
{renderHead({ hideToolbar })}
{canReadRoster && invitedRows.length > 0 && (
<Stack
direction="column"
height={getNestedTableSectionHeight(
invitedRows.length,
isCompactView ? INVITED_ONE_ROW_COMPACT_PX : INVITED_ONE_ROW_PX,
)}
>
<GiveText variant="bodyL" paddingTop="16px" paddingBottom="8px">
{`Invited Host (${invitedRows.length})`}
</GiveText>
<Table
type="event-hosts"
allRows={invitedRows}
columns={columns}
totalRows={invitedRows.length}
isLoading={false}
page={1}
setPage={noop}
setPageDispatcher={noop}
openModal=""
hideCaret
// The outer table's scroller already applies the page's horizontal
// padding; this nested one must not pad again.
disablePadding
useIdForRows
minHeight="-webkit-fill-available"
/>
</Stack>
)}
{!hideToolbar && (
<GiveText variant="bodyL" paddingTop="16px" paddingBottom="8px">
{`Assigned Host (${assignedRows.length})`}
</GiveText>
)}
</Stack>
);
return (
<>
<Table
type={hasInvitesOnly ? "event-hosts-invited-only" : "event-hosts"}
sectionName="Host"
allRows={canReadRoster ? assignedRows : []}
columns={columns}
totalRows={canReadRoster ? assignedRows.length : 0}
isLoading={isLoading}
isFetching={isFetching}
page={1}
setPage={noop}
setPageDispatcher={noop}
openModal=""
queryKey={QKEY_EVENT_HOSTS}
// The table's empty-search branch outranks the section type, so the
// invited-only state must not see the query (see hasInvitesOnly).
searchQuery={hasInvitesOnly ? "" : searchQuery}
handleSearchChange={handleSearchChange}
isListPermissionAllowed={canReadRoster}
// With the toolbar hidden on an empty tab, these are the only way in.
// Assign leads because it is the one that needs neither an email address
// nor the host to act (frame 8089-55598).
// Each carries its own tooltip, not one shared by the pair: the two
// buttons are gated on different permissions, so a single message would
// be wrong for whichever it does not describe. This state is only
// reached when the tab is wholly empty, which is exactly when the
// toolbar is hidden — without these, a merchant lacking both
// permissions gets two dead buttons and no explanation anywhere.
emptyStateAction={{
handleAction: openAssignModal,
disabled: !eventId || !isUpdateAccessPolicyAllowed,
tooltipProps: {
title: ACTION_DENY_MESSAGE,
disableHoverListener: isUpdateAccessPolicyAllowed,
},
}}
// Declared only by the "event-hosts" section, so this is inert on the
// invited-only one.
emptyStateSecondaryAction={{
handleAction: openInviteModal,
disabled: !eventId || !isAddAllowed,
tooltipProps: {
title: ACTION_DENY_MESSAGE,
disableHoverListener: isAddAllowed,
},
}}
emptyStateCellSx={hasInvitesOnly ? { paddingTop: "20px" } : undefined}
hideCaret
useIdForRows
Head={Head}
onScroll={onScroll}
// The content sits on the page's animated backdrop, so the column
// header scrolls off with it, transparent (see Table).
scrollingHeader
/>
<ContextualMenu
anchorEl={menuAnchor}
// Below v2_sm this renders as a bottom sheet, where the blurred
// tertiary surface reads as a grey wash — the mockups' sheet is plain
// white, so mobile goes solid primary (as EventDetailHeader's menu
// does) while the desktop popover keeps the blurred look.
color={isMobileView ? "primary" : "tertiary"}
texture={isMobileView ? "solid" : "blurred"}
options={
menuRow?.status === "invited"
? invitedMenuOptions
: assignedMenuOptions
}
handleClose={closeMenu}
/>
</>
);
};
export default EventHostTab;
|