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 | 7x 7x 22x 22x 7x 121x 7x 122x 122x 122x 122x 122x 122x 122x 122x 122x 122x 57x 57x 22x 22x 122x 5x 5x 5x 3x 1x 2x 3x 2x 1x 1x 5x 122x 1x 1x 1x 1x 1x 1x 1x 122x 2x 2x 2x 1x 1x 2x 122x | import { useMemo, useState } from "react";
import { useQueryClient } from "react-query";
import { useGetCurrentMerchantId } from "@hooks/common";
import { showMessage } from "@common/Toast";
import { useTeamPermissions } from "@features/Permissions/AccessControl/hooks";
import {
getEventHosts,
invalidateHostRosters,
useGetEventHosts,
useResendHostInvite,
useRevokeHost,
useUpdateHostAssignment,
} from "@services/api/products/hosts";
import { HOST_ASSIGNMENT_TOAST } from "./useAssignableHosts";
import { EventHostApiRow, EventHostRow } from "./host.types";
const MEMBER_STATUS_JOINED = "joined";
const fullName = (first: string, last: string) =>
[first, last].filter(Boolean).join(" ").trim();
const parseHost = (row: EventHostApiRow): EventHostRow => ({
id: row.userAccID,
// A host who hasn't set up their account yet has no name on file, so the row
// shows the email alone rather than a stray space (frame 7207-32344).
name: fullName(row.userFirstName, row.userLastName),
email: row.userEmail,
imageURL: row.userAvatarImageURL,
// `joined` is the membership being live, not proof the person ever opened
// the app: an address that had already joined some other merchant is seated
// `joined` immediately, with no invite row behind it. Either way they can
// work the door today, which is what this split is about — but it does mean
// an Assigned row may never have had an invitation to re-send.
status: row.memberStatus === MEMBER_STATUS_JOINED ? "assigned" : "invited",
});
/** True for the `{ not_authorized: true }` the axios layer rejects a GET 403 with. */
const isNotAuthorizedError = (error: unknown) =>
typeof error === "object" &&
error !== null &&
"not_authorized" in error &&
Boolean((error as { not_authorized?: boolean }).not_authorized);
/**
* The Host tab's data — one by-product read, split by membership status:
*
* - **Assigned** — members of this event whose membership is live (`joined`).
* - **Invited** — members of this event whose invitation is still outstanding.
*
* Invitations are scoped to the event on purpose: a host invited to one event
* must not surface on every other event's Host tab, so the merchant-wide
* member roster is deliberately not consulted here.
*
* Search runs server-side — the read carries `q`, which the endpoint matches
* against the member's name and email.
*/
export const useEventHosts = (eventId?: string, searchQuery?: string) => {
const { merchantId } = useGetCurrentMerchantId();
const queryClient = useQueryClient();
/** The row with a write in flight, whichever write it is. */
const [pendingId, setPendingId] = useState<number | null>(null);
// The roster endpoint is gated on `member:list`, which the sibling tabs'
// product reads are not — a merchant user can reach this tab and still be
// refused its data.
const { isListAllowed } = useTeamPermissions();
const trimmedQuery = searchQuery?.trim() || undefined;
const {
data: response,
isLoading,
isFetching,
isError,
error,
} = useGetEventHosts(merchantId, eventId, trimmedQuery, {
// Don't spend a request on a list this user is known not to be allowed.
enabled: Boolean(merchantId && eventId && isListAllowed),
});
const { mutateAsync: revoke } = useRevokeHost(merchantId);
const { mutateAsync: resend } = useResendHostInvite(merchantId);
const { mutateAsync: updateAssignment } = useUpdateHostAssignment(merchantId);
const { invitedRows, assignedRows } = useMemo(() => {
// Operator-only is the endpoint's guarantee, not ours: the view's role join
// drops a demoted operator's stale assignments and does not project the
// role, so every returned row is a host.
const rows = (response?.data ?? []).map(parseHost);
return {
assignedRows: rows.filter((row) => row.status === "assigned"),
invitedRows: rows.filter((row) => row.status === "invited"),
};
}, [response]);
/**
* Revoke: delete the membership outright, which takes every event with it.
* The confirmation the mockup requires is the caller's — see
* `useRevokeHostConfirm` — because this hook must stay callable from the
* confirmed path only.
*/
const revokeHost = async (row: EventHostRow) => {
setPendingId(row.id);
try {
await revoke(row.id);
// One DELETE, two meanings: pulling a pending invitation is "Invitation
// revoked", while unseating an active host names who lost access.
if (row.status === "invited") {
showMessage("Success", "", true, "Invitation revoked");
} else {
showMessage(
"Success",
`${row.name || row.email} is no longer a host.`,
true,
"Host revoked",
);
}
await invalidateHostRosters(queryClient);
} catch {
// The destructive one of the two, reached through a confirmation the
// merchant has just accepted — "nothing happened" reads as a bug. Only a
// 400 reaches the interceptor's toast and 403 its modal, so 404/409/5xx
// and a dropped connection are silent without this, and the rejection
// escapes into GiveConfirmationPopUp's un-awaited onSuccess.
if (row.status === "invited") {
showMessage(
"Error",
"The invitation could not be revoked. Please try again.",
true,
"Revoke failed",
);
} else {
showMessage(
"Error",
`${
row.name || row.email
}'s access could not be revoked. Please try again.`,
true,
"Revoke Access failed",
);
}
} finally {
setPendingId(null);
}
};
/**
* Unassign: drop THIS event from the host's assignment set and leave the
* membership alone. The narrower of the two destructive actions on the row,
* and the reason both now sit behind one menu — the host keeps their access to
* the merchant's other events (frame 8119-115264).
*/
const unassignHost = async (row: EventHostRow) => {
Iif (!eventId) return;
setPendingId(row.id);
try {
await updateAssignment({
memberId: row.id,
productId: Number(eventId),
assign: false,
});
showMessage("Success", "", true, HOST_ASSIGNMENT_TOAST);
await invalidateHostRosters(queryClient);
} catch {
// Only a 400 reaches the interceptor's toast, and the likely failure here
// is a 403 — without this the row stays put with nothing said, and the
// rejection goes unhandled.
showMessage(
"Error",
`${
row.name || row.email
} could not be unassigned from this event. Please try again.`,
true,
"Unassign failed",
);
} finally {
setPendingId(null);
}
};
/**
* Re-send a pending host's invitation. Called as a floating promise from the
* row menu, so — as with the two above — an unguarded rejection is both a
* silent no-op on screen and an unhandled rejection.
*/
const resendInvite = async (row: EventHostRow) => {
setPendingId(row.id);
try {
await resend(row.id);
showMessage("Success", "", true, "Invitation Sent");
} catch {
showMessage(
"Error",
`The invitation to ${
row.email || row.name
} could not be re-sent. Please try again.`,
true,
"Resend failed",
);
} finally {
setPendingId(null);
}
};
return {
invitedRows,
assignedRows,
resendInvite,
isLoading,
isFetching,
isError,
/**
* Either the permission says no up front, or the read came back refused —
* a GET 403 is rejected as a bare `{ not_authorized: true }`, which would
* otherwise read as an outage.
*/
isNotAuthorized: !isListAllowed || isNotAuthorizedError(error),
revokeHost,
unassignHost,
pendingId,
/** True only when the event has no hosts at all — a search that matched
* nothing must keep the toolbar so the query can be cleared. Read from
* `total` (the count before the response's own page cap) rather than the
* returned rows. */
isEmpty: !isLoading && (response?.total ?? 0) === 0 && !trimmedQuery,
};
};
/** Exported for the prefetch in the tab's tests and for reuse by the invite flow. */
export { getEventHosts };
|