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 | 45x 45x 45x 44x 40x 40x 40x 40x 40x 40x 3x 3x 3x 1x 1x 2x 1x 1x 1x 40x 40x 40x 40x 40x 40x 18x 9x 9x 9x 8x 8x 9x 9x 9x 2x 2x 2x 40x 39x 1x 1x 1x | import { Fragment, useEffect, useRef } from "react";
import { Box, CircularProgress, Stack } from "@mui/material";
import { PlusIcon } from "@phosphor-icons/react";
import { useFieldArray } from "react-hook-form";
import { showMessage } from "@common/Toast";
import GiveButton from "@shared/Button/GiveButton";
import GiveText from "@shared/Text/GiveText";
import { usePayBuilderForm } from "@sections/PayBuilder/provider/PayBuilderFormProvider";
import { useFormNavigation } from "@sections/PayBuilder/hooks/useFormNavigation";
import {
useGetSeatingRows,
useSaveSeatingRows,
} from "@hooks/merchant-api/events/useSeatingRows";
import {
getSeatingRowOverlapErrors,
seatingRowsSignature,
validateSeatingRows,
} from "@sections/PayBuilder/seating.helpers";
import type { SeatingRow } from "@sections/PayBuilder/types";
import SeatingRowRow from "./SeatingRowRow";
const HEADER_COLOR = "#292928";
// A fresh, empty seating-row line. Used both to seed the first row when Assign Seating
// is enabled and by the "Add" button, so the two stay identical.
const BLANK_SEATING_ROW: SeatingRow = {
rowLabel: "",
firstSeat: 1,
lastSeat: 1,
label: "",
};
// PAY Builder 031 — configure the event's seating rows (Row / First Seat / Last Seat / Label)
// inside a grey card. Column headers render once; each row is a label-less input
// line. Rows persist with the normal form Save (see useManagePayFormProvider) —
// there is no dedicated save button. Rows hydrate from the server on edit.
const SeatingConfigEditor = () => {
const { methods, isLoading } = usePayBuilderForm();
// PAY Builder 031 (Figma 6722-6428) — "Save Seating" persists ONLY the seating layout (PUT
// /seating-rows), never the whole form. This is what makes freshly-entered rows selectable in
// the ticket modal: the PUT replaces the rows, the seating-rows query is invalidated on success,
// and the refetched rows come back with ids (only id-bearing rows are assignable). It also lets a
// merchant edit an already-published event's seating without a save→reopen round-trip.
//
// Once the product exists this must NOT run the full form save: that also fires
// variants/bulk-create for a brand-new, not-yet-assigned ticket, which the BE rejects
// (ErrTicketNeedsSeatingRow) because assign-seating is on. The full save swallows that rejection
// (Promise.allSettled → toast) yet still succeeds, and the post-save re-hydration rebuilds the
// ticket list from server variants only — so the rejected ticket silently disappears. A
// seating-only save leaves local Items untouched. The one exception is the brand-new, not-yet-
// saved event (no productId): there is no product to attach rows to yet, so Save Seating saves
// the draft first to mint one (GB-21544) — see handleSaveSeating.
const saveSeating = useSaveSeatingRows();
// on a brand-new (unsaved) event there is no product to attach rows to yet, so
// Save Seating first saves the draft (which mints the productId). See handleSaveSeating.
const { handleSaveDraftClick } = useFormNavigation();
const productId = methods.watch("productId") as number | null;
const { fields, append, remove, replace } = useFieldArray({
control: methods.control,
name: "DateLocation.seatingRows",
});
const { data } = useGetSeatingRows(productId ?? "", Boolean(productId));
// Persist just the seating rows. Validate client-side first (mirrors the BE) so an empty label
// or a bad/overlapping range gets a precise message instead of a generic 422.
//
// the rows attach to an existing product, so a productId is required. When the event
// hasn't been saved yet (!productId) we save the draft first: handleSaveDraftClick POSTs the
// product (minting the productId) and, being an event with Assign Seating on, persists the
// seating rows in that same save (the doSaveSeating path in useManagePayFormProvider) so they
// gain ids and become assignable. We deliberately do NOT run the dedicated seating-only PUT in
// this branch — there is no product to attach rows to until the draft save completes. Once a
// productId exists (the common case, from the Tickets tab on), Save Seating persists ONLY the
// seating layout (PUT /seating-rows), never the whole form (see below).
const handleSaveSeating = () => {
const rows =
(methods.getValues("DateLocation.seatingRows") as
| SeatingRow[]
| undefined) ?? [];
const error = validateSeatingRows(rows);
if (error) {
showMessage("Error", error);
return;
}
if (!productId) {
handleSaveDraftClick();
return;
}
saveSeating.mutate({ productId, rows });
};
// AC005 — flag a duplicate/overlapping seat range within the same row inline, under the
// offending row, as the merchant types (live values, indexed to match `fields`).
const watchedRows =
(methods.watch("DateLocation.seatingRows") as SeatingRow[] | undefined) ??
[];
const overlapErrors = getSeatingRowOverlapErrors(watchedRows);
// Figma note (6722-6428): "Once user saved seating we won't display the button." Offer
// "Save Seating" only while the edited layout differs from what's persisted. The content
// signature ignores casing/whitespace and coerces seat numbers, so it flips to "saved"
// (button hidden) exactly when the form matches the server rows — and back to "unsaved"
// (button shown) as soon as a row is added, edited, or removed.
const hasUnsavedSeating =
seatingRowsSignature(data?.rows ?? []) !==
seatingRowsSignature(watchedRows);
// Hydrate from the server whenever the server rows actually change (new signature),
// not just once when the form is empty. The old "hydrate only when empty" guard froze
// the editor on the first (possibly stale) payload: after Save refetched a 2nd row, the
// form already had 1 row so the guard blocked the update and the new row never showed.
// The signature ref re-applies fresh rows after a save (the seating-rows query is
// invalidated on Save) while not clobbering in-progress edits, since the query does not
// refetch mid-edit (so the signature is stable while the merchant is typing).
//
// Enabling Assign Seating must ALSO immediately show the first seating-row form
// (approved design), not an empty "Add"-only state, so we seed one blank row — but only
// where doing so can't lose real data:
// - existing product: seed a blank row only once the server has SETTLED with zero rows
// (the hydration branch below), never on a transient/errored load and never on top
// of saved rows.
// - new product: the seating query is disabled, so seed one blank row on mount.
const appliedSigRef = useRef<string | null>(null);
const seededRef = useRef(false);
useEffect(() => {
if (data) {
// The server settled. `rows` may be null (the BE returns a nil slice → JSON `null`) or []
// for an event with zero rows — treat both the same and seed the first blank row form.
const serverRows = data.rows ?? [];
const sig = seatingRowsSignature(serverRows);
if (appliedSigRef.current !== sig) {
appliedSigRef.current = sig;
// Use the field-array's own replace so its internal keys stay in sync;
// setValue on a useFieldArray path desyncs them on edit. Server settled with no
// rows → seed the first row form.
replace(serverRows.length ? serverRows : [BLANK_SEATING_ROW]);
}
seededRef.current = true;
return;
}
// New/unsaved product (no server query) — seed the first row once.
if (!seededRef.current && !productId) {
const current =
(methods.getValues("DateLocation.seatingRows") as
| SeatingRow[]
| undefined) ?? [];
if (current.length === 0) replace([BLANK_SEATING_ROW]);
seededRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
return (
<Stack
sx={{
background: "#F5F5F3",
borderRadius: "8px",
padding: "12px 12px 24px",
gap: "16px",
}}
>
{fields.length > 0 && (
<Stack gap="8px">
<Stack direction="row" gap="8px" alignItems="center">
<GiveText
variant="bodyS"
sx={{ width: "64px", flexShrink: 0, color: HEADER_COLOR }}
>
Row
</GiveText>
<GiveText variant="bodyS" sx={{ flex: 1, color: HEADER_COLOR }}>
First Seat
</GiveText>
<GiveText variant="bodyS" sx={{ flex: 1, color: HEADER_COLOR }}>
Last Seat
</GiveText>
<GiveText variant="bodyS" sx={{ flex: 1, color: HEADER_COLOR }}>
Label (optional)
</GiveText>
<Box sx={{ width: "32px", flexShrink: 0 }} />
</Stack>
{fields.map((f, i) => (
<Fragment key={f.id}>
<SeatingRowRow index={i} canDelete onDelete={() => remove(i)} />
{overlapErrors[i] && (
<GiveText variant="bodyXS" color="error1">
{overlapErrors[i]}
</GiveText>
)}
</Fragment>
))}
</Stack>
)}
{/* Action row (Figma 6722-6428): "Add Row" link on the left, "Save Seating" on the right. */}
<Stack direction="row" alignItems="center" justifyContent="space-between">
<GiveButton
variant="text"
size="small"
label="Add Row"
onClick={(e) => {
e.preventDefault();
append(BLANK_SEATING_ROW);
}}
startIcon={<PlusIcon size={18} />}
/>
{hasUnsavedSeating && (
<GiveButton
size="small"
label="Save Seating"
onClick={handleSaveSeating}
disabled={
Boolean(isLoading) ||
saveSeating.isLoading ||
overlapErrors.some(Boolean)
}
endIcon={
isLoading || saveSeating.isLoading ? (
<CircularProgress size="14px" sx={{ color: "inherit" }} />
) : undefined
}
/>
)}
</Stack>
</Stack>
);
};
export default SeatingConfigEditor;
|