All files / src/features/Events/EventDetail/tickets useEventTickets.ts

41.93% Statements 13/31
64% Branches 16/25
38.46% Functions 5/13
39.28% Lines 11/28

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                                          11x     11x         11x           11x 27x   11x   11x 10x                   11x       9x                                                       11x                                                                                                                                            
import { useEffect, useMemo } from "react";
import { QKEY_EVENT_TICKETS } from "@constants/queryKeys";
import { useAppSelector } from "@redux/hooks";
import { sortingKey } from "@redux/slices/fundraisers";
import { selectQueryString } from "@redux/slices/search";
import {
  EventTicketApi,
  useGetEventTickets,
} from "@services/api/products/tickets";
import { useFormattedFilters } from "@shared/GiveFilter/hooks/useFormattedFilters";
import { usePagination } from "@hooks/common/usePagination";
import { useRowsPerPage } from "componentsV2/Table/hooks/useRowsPerPage";
import { STORED_EVENT_TICKETS_ROWS_PER_PAGE } from "../constants";
import {
  EventTicketRow,
  TicketCheckInMethod,
  TicketInvalidReason,
} from "./ticket.types";
import { useTicketCheckIn } from "./useTicketCheckIn";
 
/** The mockup's default: newest purchase first. */
export const DEFAULT_SORTING = "-createdAt";
 
/** API sort keys, passed to the columns so they stay contract-free. */
export const TICKET_SORT_KEYS = {
  purchaseDate: "createdAt",
  ticketHolder: "holderName",
};
 
const INVALID_REASONS: TicketInvalidReason[] = [
  "voided",
  "refunded",
  "chargeback",
];
 
const invalidReasonOf = (paymentStatus: string) =>
  INVALID_REASONS.find((reason) => reason === paymentStatus) ?? null;
 
const CHECKIN_METHODS: TicketCheckInMethod[] = ["scan", "manual"];
 
const checkInMethodOf = (consumedVia: string) =>
  CHECKIN_METHODS.find((method) => method === consumedVia) ?? null;
 
/**
 * Maps `product.EventTicketHolderView` into the row the table renders.
 *
 * `doNotAdmit` follows the ticket STATUS, not `paymentStatus` — the server sets
 * `cancelled` only where the reversal actually invalidated the ticket, and it
 * can report a cancelled ticket with no reason (a reversal that was itself
 * undone), which still must not be admitted.
 */
export const parseEventTicket = (
  ticket: EventTicketApi,
  /** What the row needs from its surroundings — see EventTicketRow. */
  context: { eventId?: string; showPickup?: boolean } = {},
): EventTicketRow => ({
  id: ticket.id,
  eventId: context.eventId,
  showPickup: context.showPickup,
  displayId: ticket.code,
  purchasedAt: ticket.createdAt,
  holderName: ticket.holderName,
  holderEmail: ticket.holderEmail || undefined,
  ticketName: ticket.ticketType,
  seatLabel: ticket.seatLabel || null,
  checkedInAt: ticket.consumedAt,
  doNotAdmit: ticket.status === "cancelled",
  invalidReason: invalidReasonOf(ticket.paymentStatus),
  isExpired: ticket.status === "expired",
  ticketPrice: ticket.ticketPrice,
  checkInMethod: checkInMethodOf(ticket.consumptionMethod),
  hostName: ticket.consumedBy || undefined,
  hostEmail: ticket.consumedByEmail || undefined,
  // "" (nothing to pick up) parses to unset, like the other absent fields.
  pickupStatus: ticket.pickupState || undefined,
  // Empty means "no purchase on record"; the panel's link is hidden for those.
  transactionId: ticket.transactionID || undefined,
});
 
/**
 * PayBuilder 032 (Tickets tab) — server state: page, rows-per-page, sort, search
 * and the check-in action.
 */
export const useEventTickets = (
  eventId?: string,
  /** Passed through to every row so the detail panel can gate its pickup line. */
  showPickup?: boolean,
) => {
  const { rowsPerPage } = useRowsPerPage(STORED_EVENT_TICKETS_ROWS_PER_PAGE);
 
  const searchQuery = useAppSelector((state) =>
    selectQueryString(state, QKEY_EVENT_TICKETS),
  );
  const sorting = useAppSelector((state) =>
    sortingKey(state, QKEY_EVENT_TICKETS),
  );
 
  // The filter panel (FilterPagesEnum.EVENT_TICKETS) writes its fql string into
  // the dynamic filter slice under this same query key.
  const { formattedFilterString } = useFormattedFilters({
    queryKey: QKEY_EVENT_TICKETS,
  });
 
  const { page, setPage } = usePagination(0, searchQuery);
  const listParams = {
    page,
    sorting: sorting || DEFAULT_SORTING,
    searchQuery,
    maxRowsPerPage: rowsPerPage,
    filter: formattedFilterString,
  };
 
  const { data, isLoading, isFetching, error } = useGetEventTickets(
    eventId as string,
    QKEY_EVENT_TICKETS,
  )(listParams, {
    enabled: Boolean(eventId),
    refetchOnWindowFocus: false,
    keepPreviousData: true,
  });
 
  useEffect(() => {
    setPage(1);
  }, [searchQuery, sorting, rowsPerPage, formattedFilterString]);
 
  const allRows = useMemo(
    () =>
      (data?.data ?? []).map((row) =>
        parseEventTicket(row, { eventId, showPickup }),
      ),
    [data?.data, eventId, showPickup],
  );
 
  // Check-in goes through the confirmation modal (frame 7412-195380); the shared
  // hook owns the mutation and the success toast.
  const { requestCheckIn, checkingInId } = useTicketCheckIn(eventId);
 
  return {
    allRows,
    totalRows: data?.total ?? 0,
    rowsPerPage,
    page,
    setPage: () => setPage((current) => current + 1),
    setPageDispatcher: setPage,
    isLoading,
    isFetching,
    isError: Boolean(error),
    searchQuery,
    sorting: sorting || DEFAULT_SORTING,
    checkIn: requestCheckIn,
    checkingInId,
  };
};