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 | 1x 1x 1x 41x 1x 41x 41x 41x 41x 41x 41x 41x 41x 41x 41x 32x 41x 41x 41x 41x 30x 30x 41x 41x 41x 41x 41x 41x 5x 36x 41x 41x 41x 1x 32x 1x 1x 2x | import { useEffect, useState } from "react";
import { Stack } from "@mui/material";
import NiceModal from "@ebay/nice-modal-react";
import { CheckSquareIcon, WarningIcon } from "@phosphor-icons/react";
import useNiceModal from "@common/Modal/ModalFactory/hooks/useNiceModal";
import CustomerPanelContent from "@features/MerchantPortal/Customer/Modals/CustomerPanelContent";
import TransactionPanelContent from "@features/TransactionPanel/components/Summary/TransactionPanelContent";
import { TTransaction } from "@features/TransactionPanel/types";
import { useGetCurrentMerchantId } from "@hooks/common";
import GiveButton from "@shared/Button/GiveButton";
import GiveSidePanel from "@shared/SidePanel/GiveSidePanel";
import { SidePanelFooterContainer } from "@shared/SidePanel/components/SidePanelHeader/SidePanelHeader";
import GiveText from "@shared/Text/GiveText";
import { useCustomThemeV2 } from "@theme/hooks/useCustomThemeV2";
import { styled, useAppTheme } from "@theme/v2/Provider";
import { EventTicketRow, TICKET_INVALID_BANNERS } from "./ticket.types";
import PurchasePlaceholder from "./PurchasePlaceholder";
import TicketCheckInSection from "./TicketCheckInSection";
import TicketDetailsSection from "./TicketDetailsSection";
import TicketPanelHeader from "./TicketPanelHeader";
import TicketSummaryCard from "./TicketSummaryCard";
import { useTicketCheckIn } from "./useTicketCheckIn";
import { useTicketTransaction } from "./useTicketTransaction";
/**
* PayBuilder 032 — the ticket detail panel (frames 7397-90431 pending,
* 7397-91001 checked in by scan, 7397-99399 manual, 7397-100885 do-not-admit,
* 7417-202396 / 7397-91045 seated), and as a bottom sheet on a phone
* (7397-90611 / 7397-90701 / 7417-203712 / 7397-90836 / 7397-100181 /
* 7397-102594).
*
* Driven entirely by the row the user clicked: `componentsV2/Table`'s `useTable`
* hands the panel `rowData` plus `setSelectedRow` / `isFirst` / `isLast`, which is
* what the header's up/down arrows walk. No fetch of its own — the list endpoint
* already returns every field the panel shows, so opening a row costs nothing and
* arrowing through rows never waits on a request.
*
* This file is the orchestrator: state (the second-panel swap), the check-in
* wiring, and the desktop/sheet layouts. The pieces live beside it —
* `TicketPanelHeader`, `TicketSummaryCard`, `TicketDetailsSection`,
* `TicketCheckInSection`, `PurchasePlaceholder`.
*/
export type EventTicketDetailPanelProps = {
rowData?: EventTicketRow;
/** From `useTable`: "prev" | "next" moves the table's selection. */
setSelectedRow?: (direction: string | number) => void;
isFirst?: boolean;
isLast?: boolean;
};
/** Same width as the transaction panel it opens beside. */
const TICKET_PANEL_WIDTH = 640;
/** The transaction panel's own width everywhere else in the app. */
const TRANSACTION_PANEL_WIDTH = 640;
/** A ticket can only be admitted while it is valid, unused and unexpired. */
const isCheckInAvailable = (row?: EventTicketRow) =>
Boolean(row) && !row?.doNotAdmit && !row?.checkedInAt && !row?.isExpired;
export const EventTicketDetailPanelBase = ({
rowData,
setSelectedRow,
isFirst,
isLast,
}: EventTicketDetailPanelProps) => {
const { open, onRemove } = useNiceModal();
const { palette } = useAppTheme();
// The same flag GiveSidePanel switches its bottom sheet on (< 768px), so the
// content and the shell it sits in never disagree about the viewport.
const { isMobileView } = useCustomThemeV2();
const row = rowData;
// Read off the row, not a prop: the table's modal arguments are fixed, so the
// row is the only thing the panel is handed.
const showPickup = Boolean(row?.showPickup);
// The header's "Transaction detail" link opens the purchase beside this panel
// (frames 7420-211783 / 7420-215195). `customer` is the transaction panel's own
// sub-panel; GiveSidePanel has one second slot, so the two take turns in it —
// the same swap GiveTransactionPanel does.
const [secondPanel, setSecondPanel] = useState<
"transaction" | "customer" | null
>(null);
const { merchantId } = useGetCurrentMerchantId();
const { transaction, isLoading: isTransactionLoading } = useTicketTransaction(
{
eventId: row?.eventId,
transactionId: row?.transactionId,
// Fetched only once the link is used — opening a ticket must not cost a
// second request, and arrowing through rows would multiply it.
enabled: secondPanel !== null,
},
);
// After a refund the transaction panel reloads on the refund itself, so its
// history gains the Refunded tab. Elsewhere TransactionPanelContent does that
// by re-showing TRANSACTION_INFO_MODAL with the refund as `data` — here that
// modal is not the one open, so it would stack a third panel on top of this
// one. The panel hands the refund over already in its own `data` shape;
// called bare, this puts the purchase back.
const [refund, setRefund] = useState<TTransaction>();
const showRefund = (_itemId?: string, refunded?: TTransaction) =>
setRefund(refunded);
const openTransaction = () => setSecondPanel("transaction");
const closeSecondPanel = () => setSecondPanel(null);
const closeCustomer = () => setSecondPanel("transaction");
// Arrowing to another ticket must not leave the previous purchase on screen.
useEffect(() => {
setSecondPanel(null);
showRefund();
}, [row?.id]);
// The panel checks in through the same confirm-then-mutate hook as the table
// row rather than borrowing the tab's handler: the table's modal arguments are
// fixed, so the row carries its event id and the panel drives the hook itself.
// The hook's lock is shared across instances, so the row's button and this one
// disable together. Invalidating the list is what refreshes both the row
// behind the panel and the panel (it is fed from that same row).
const { requestCheckIn, checkingInId } = useTicketCheckIn(row?.eventId);
const isCheckingIn = checkingInId !== null;
const banner = row?.doNotAdmit
? row.invalidReason && TICKET_INVALID_BANNERS[row.invalidReason]
: null;
// The purchase is fetched on demand, so the second panel can open before it
// has arrived — it holds the slot with the transaction panel's own skeleton
// rather than mounting that panel with no data.
const secondPanelContent = (() => {
Iif (secondPanel === "customer") {
return (
<CustomerPanelContent
onClose={closeCustomer}
data={transaction?.customer}
merchantID={merchantId}
/>
);
}
if (!transaction) {
return (
<PurchasePlaceholder
isLoading={isTransactionLoading}
onClose={closeSecondPanel}
/>
);
}
return (
<TransactionPanelContent
data={refund ?? transaction}
open={Boolean(secondPanel)}
onClose={closeSecondPanel}
chooseTransaction={showRefund}
handleCustomer={(isOpen: boolean) =>
setSecondPanel(isOpen ? "customer" : "transaction")
}
/>
);
})();
const header = (
<TicketPanelHeader
onNavigate={setSelectedRow}
isFirst={isFirst}
isLast={isLast}
showTransactionLink={Boolean(row?.transactionId)}
onOpenTransaction={openTransaction}
onClose={onRemove}
/>
);
const body = (
<Body>
{row?.doNotAdmit && banner && (
<Banner direction="row" gap="12px" data-testid="ticket-panel-banner">
{/* Icon and copy both sit on the `100` token, same as the chip's
label below them (frame 7412-201886). */}
<WarningIcon
size={20}
color={palette.primitive?.error?.[100]}
style={{ flexShrink: 0 }}
/>
<GiveText
variant="bodyS"
sx={{ color: palette.primitive?.error?.[100] }}
>
{banner}
</GiveText>
</Banner>
)}
{/* On a phone check-in moves out of the summary card into the sheet's
footer (frames 7397-90611 / 7417-203712). */}
<TicketSummaryCard
row={row}
showCheckIn={!isMobileView && isCheckInAvailable(row)}
isCheckingIn={isCheckingIn}
onCheckIn={requestCheckIn}
/>
<TicketDetailsSection row={row} showPickup={showPickup} />
<TicketCheckInSection row={row} />
</Body>
);
return (
<GiveSidePanel
width={TICKET_PANEL_WIDTH}
secondPanelWidth={TRANSACTION_PANEL_WIDTH}
isSecondPanelOpen={Boolean(secondPanel)}
secondPanel={secondPanelContent}
open={open}
onClose={onRemove}
data-testid="event-ticket-detail-panel"
>
{/* The bottom sheet keeps its header pinned, scrolls the detail on its own
and hangs check-in off the bottom edge as a full-width action (frames
7397-90611 / 7417-203712) — on a phone there is no room for it beside
the status chip. The desktop panel scrolls as one piece, as before. */}
{isMobileView ? (
<MobileLayout>
{header}
<MobileScrollArea>{body}</MobileScrollArea>
{isCheckInAvailable(row) && (
<SidePanelFooterContainer>
<GiveButton
label="Check In"
variant="filled"
size="large"
fullWidth
startIcon={<CheckSquareIcon size={20} />}
disabled={isCheckingIn}
data-testid="ticket-panel-check-in"
onClick={() => row && requestCheckIn(row)}
/>
</SidePanelFooterContainer>
)}
</MobileLayout>
) : (
<>
{header}
{body}
</>
)}
</GiveSidePanel>
);
};
const EventTicketDetailPanel = NiceModal.create(EventTicketDetailPanelBase);
export default EventTicketDetailPanel;
const Body = styled(Stack)(({ theme }) => ({
padding: "20px",
gap: "24px",
backgroundColor: theme.palette.surface?.secondary,
minHeight: "100%",
}));
// The sheet is a fixed-height column — header, then the only scrolling region,
// then the footer — so the footer stays on the bottom edge rather than at the
// end of the detail. `minHeight: 0` is what lets the middle child scroll instead
// of stretching the column (the pattern GiveFilterModalContent uses).
const MobileLayout = styled(Stack)({
height: "100%",
minHeight: 0,
});
const MobileScrollArea = styled(Stack)({
flexGrow: 1,
minHeight: 0,
overflowY: "auto",
});
// Frames 7397-100885 / 7420-209617: a tinted error surface above the summary
// card, not a toast — it is a property of the ticket, so it stays on screen.
const Banner = styled(Stack)(({ theme }) => ({
padding: "12px 16px",
borderRadius: "12px",
alignItems: "flex-start",
backgroundColor: theme.palette.primitive?.error?.[25],
}));
|