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 | 4x 4x 4x 4x 4x 4x 4x 4x 4x 63x 63x 63x 63x 56x 56x 63x 30x 30x 30x 7x 25x 23x 63x 198x 63x 62x 198x 198x 198x 198x 30x 189x | import { useEffect } from "react";
import { Box, Stack } from "@mui/material";
import GiveText from "@shared/Text/GiveText";
import type {
SeatSelection,
SeatingRow,
TakenSeat,
} from "@sections/PayBuilder/types";
import { getSelectableRows, seatKey } from "@sections/PayBuilder/seating.helpers";
type Props = {
variantID: number;
// The ticket quantity — how many seats the buyer is currently buying. The map fills up to it.
quantity: number;
// Whether a seat may be selected BEYOND the current quantity: doing so bumps the quantity in
// the parent (so the buyer never has to press "+" before picking an extra seat). Gated by the
// parent's inventory / max-amount caps. Deselecting is always allowed.
canSelectBeyond: boolean;
rows: SeatingRow[];
takenSeats: TakenSeat[];
value: SeatSelection[];
onChange: (next: SeatSelection[]) => void;
// Called instead of onChange when the buyer's selection is trimmed because seats they had
// chosen just became taken (an involuntary drop, not a user toggle). Lets the parent treat it
// differently from a voluntary deselect — e.g. follow the ticket quantity down to the
// survivors, without eroding "+"-added headroom on a deliberate deselect. Falls back to
// onChange when not provided.
onReleaseTaken?: (survivors: SeatSelection[]) => void;
};
// PAY Builder 031 (Figma "Choose your seat") — fixed seat-state colours for the public
// checkout surface (not merchant-themed). Available is dark, Taken is a light wash, Selected
// is the success green — "the seat changes colour to selected once the user clicks it and we
// display it always green". Values are the resolved design tokens noted alongside.
const SEAT_AVAILABLE = "#666665"; // Colour/Icon/icon-secondary
const SEAT_SELECTED = "#088750"; // Primitive/Success/50
const SEAT_TAKEN = "rgba(0,0,0,0.25)"; // Primitive/Transparent/Darken 25
const BORDER_COLOR = "#e5e5e3"; // Colour/Border/primary
const SURFACE = "rgba(245,245,243,0.7)"; // Colour/Surface/secondary-transparent
const LABEL_COLOR = "#666665"; // Colour/Text/secondary
const SEAT_SIZE = "16px";
const LEGEND = [
{ label: "Available", color: SEAT_AVAILABLE },
{ label: "Selected", color: SEAT_SELECTED },
{ label: "Taken", color: SEAT_TAKEN },
] as const;
// AC008/AC009 — let the customer pick available seats from the rows the ticket sells, rendered
// as a seat map with a legend (Figma). The map fills up to the ticket `quantity`; picking a seat
// once the quantity is already full is still allowed (when `canSelectBeyond`) and bumps the
// quantity in the parent, so the buyer never has to press "+" before choosing an extra seat.
// Taken seats are disabled. Seats stay accessible buttons (aria-label "Row X seat N").
// NOTE: one button per seat (no virtualization) with wrapping rows — fine for typical events;
// windowize before supporting very large (1000+ seat) venues.
const SeatPicker = ({
variantID,
quantity,
canSelectBeyond,
rows,
takenSeats,
value,
onChange,
onReleaseTaken,
}: Props) => {
const taken = new Set(takenSeats.map((s) => seatKey(s.rowLabel, s.seatNumber)));
const selected = new Set(value.map((s) => seatKey(s.rowLabel, s.seatNumber)));
// the persisted rows this ticket sells
const myRows = getSelectableRows(rows, variantID);
// If a previously-selected seat becomes taken (e.g. takenSeats refetched after
// someone else booked it), drop it so the customer can't submit a taken seat. This is an
// involuntary release, so route it through onReleaseTaken (falling back to onChange) rather
// than the user-toggle path.
useEffect(() => {
const stillFree = value.filter((s) => !taken.has(seatKey(s.rowLabel, s.seatNumber)));
if (stillFree.length !== value.length) (onReleaseTaken ?? onChange)(stillFree);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [takenSeats, value]);
const toggle = (r: SeatingRow & { id: number }, n: number) => {
const k = seatKey(r.rowLabel, n);
Iif (taken.has(k)) return;
if (selected.has(k)) {
onChange(value.filter((s) => seatKey(s.rowLabel, s.seatNumber) !== k));
} else if (value.length < quantity || canSelectBeyond) {
// fill an open slot (< quantity), or select beyond it when the parent allows a bump
onChange([
...value,
{ seatingRowID: r.id, rowLabel: r.rowLabel, seatNumber: n },
]);
}
};
const seatColor = (k: string) =>
taken.has(k) ? SEAT_TAKEN : selected.has(k) ? SEAT_SELECTED : SEAT_AVAILABLE;
return (
<Stack gap="8px" width="100%">
<GiveText variant="bodyS">
Choose your seat ({value.length}/{quantity})
</GiveText>
<Stack
alignItems="center"
gap="12px"
sx={{
p: "12px",
borderRadius: "8px",
border: `1px solid ${BORDER_COLOR}`,
backgroundColor: SURFACE,
}}
>
<Stack alignItems="center" gap="4px" sx={{ maxWidth: "100%" }}>
{myRows.map((r) => (
<Stack
key={r.id}
direction="row"
alignItems="center"
gap="12px"
sx={{ px: "12px", py: "4px" }}
>
<GiveText
variant="bodyXS"
sx={{ color: LABEL_COLOR, width: "12px", flexShrink: 0 }}
>
{r.rowLabel}
</GiveText>
<Stack alignItems="center" gap="4px">
<Box
sx={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
gap: "4px",
p: "4px",
}}
>
{Array.from(
{ length: r.lastSeat - r.firstSeat + 1 },
(_, i) => r.firstSeat + i,
).map((n) => {
const k = seatKey(r.rowLabel, n);
const isTaken = taken.has(k);
return (
<Box
key={n}
component="button"
type="button"
aria-label={`Row ${r.rowLabel} seat ${n}`}
disabled={isTaken}
onClick={() => toggle(r, n)}
sx={{
p: 0,
m: 0,
border: "none",
width: SEAT_SIZE,
height: SEAT_SIZE,
borderRadius: "2px",
backgroundColor: seatColor(k),
cursor: isTaken ? "not-allowed" : "pointer",
}}
/>
);
})}
</Box>
{r.label ? (
<GiveText variant="bodyXXS" sx={{ color: LABEL_COLOR }}>
{r.label}
</GiveText>
) : null}
</Stack>
</Stack>
))}
</Stack>
<Stack
direction="row"
gap="16px"
justifyContent="center"
sx={{ pt: "12px", borderTop: `1px solid ${BORDER_COLOR}`, width: "100%" }}
>
{LEGEND.map((item) => (
<Stack key={item.label} alignItems="center" gap="4px">
<Box
sx={{
width: "10px",
height: "10px",
borderRadius: "2px",
backgroundColor: item.color,
}}
/>
<GiveText variant="bodyXS" sx={{ color: LABEL_COLOR }}>
{item.label}
</GiveText>
</Stack>
))}
</Stack>
</Stack>
</Stack>
);
};
export default SeatPicker;
|