All files / src/sections/PayBuilder/Checkout/components ParticipantDetails.tsx

89.33% Statements 67/75
85.71% Branches 48/56
81.81% Functions 18/22
90.47% Lines 57/63

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 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311                                                  13x                                   13x             158x 158x 158x 158x 158x   158x 158x 158x 158x 158x   158x 55x                   158x 146x   158x 62x 10x     158x   158x     158x 158x     158x         158x       158x 158x             158x                       158x 34x 17x 17x           158x 41x 8x 4x 8x       4x                             158x 45x     21x 11x       11x           11x                                 158x 34x                         158x   47x 63x   47x 2x   47x                   26x                       1x                                                         2x                     2x                   35x                                                                  
import { useEffect, useMemo, useState } from "react";
import { Box, Stack } from "@mui/material";
import { InfoIcon, TicketIcon } from "@phosphor-icons/react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import GiveText from "@shared/Text/GiveText";
import GiveSwitch from "@shared/Switch/GiveSwitch";
import { useAppTheme } from "@theme/v2/Provider";
import { usePayBuilderForm } from "@sections/PayBuilder/provider/PayBuilderFormProvider";
import { useCart } from "@sections/PayBuilder/provider/CartContext";
import SectionHeader from "./SectionHeader";
import ParticipantRow from "./ParticipantRow";
import { useEventParticipantDetails } from "../hooks/useEventParticipantDetails";
import {
  PARTICIPANT_SECTION_TITLE,
  PARTICIPANT_SINGLE_BANNER,
  SAME_AS_CONTACT_LABEL,
  SINGLE_PARTICIPANT_KEY,
  buildParticipantSlots,
  buildPreviewParticipantSlots,
  isContactComplete,
  isSameAsContactActive,
  reconcileParticipants,
} from "../participants.helpers";
 
/** Ties the switch input to its <label> so the copy is a click target. */
const SAME_AS_CONTACT_SWITCH_ID = "participants-same-as-contact";
 
/**
 * Participant Details on the public event checkout.
 *
 * Two modes, both drawn by the mockups:
 *
 *   toggle ON  (AC013/AC014) — one collapsible row per purchased UNIT, so a cart
 *                              of 2xVIP + 2xGeneral renders four rows, not two
 *   toggle OFF (AC053-AC055) — the section still renders, with one row and a
 *                              "Same as contact details" switch on by default.
 *                              That default sends nothing and lets the BE apply
 *                              the buyer's Contact Name to every ticket, so an
 *                              untouched event checkout behaves as it does today.
 *                              The row stays expandable and live-synced from
 *                              Contact (mockup 7471:57034); editing a field
 *                              breaks the link and the edit sticks.
 */
const ParticipantDetails = ({
  isDisabledFields,
  isPreviewMode,
}: {
  isDisabledFields?: boolean;
  isPreviewMode?: boolean;
}) => {
  const { palette } = useAppTheme();
  const methods = useFormContext();
  const { methods: leftSidepanelMethods } = usePayBuilderForm();
  const { cartItems } = useCart();
  const isRendered = useEventParticipantDetails();
 
  const { Checkout, Items } = leftSidepanelMethods.watch();
  const { participantDetails, participantPhone } = Checkout;
  const collectPerTicket = !!participantDetails?.render;
  const showPhone = !!participantPhone?.render;
  const isPhoneRequired = !!participantPhone?.required;
 
  const cartSlots = useMemo(
    () => buildParticipantSlots(cartItems),
    // cartItems is a new array identity on every cart mutation, which is exactly
    // when the slots must be recomputed.
    [cartItems],
  );
 
  // The builder preview renders this checkout with an EMPTY cart, so the real
  // slots are [] and the merchant would never see the section they are
  // configuring. Fall back to representative rows built from the event's
  // tickets — preview only, and never when a real cart exists.
  const previewSlotsSignature = Items?.map(
    (item) => `${item?.variantID ?? item?.id}:${item?.title}:${item?.display}`,
  ).join("|");
  const slots = useMemo(() => {
    if (cartSlots.length > 0 || !isPreviewMode) return cartSlots;
    return buildPreviewParticipantSlots(Items);
  }, [cartSlots, isPreviewMode, previewSlotsSignature]); // eslint-disable-line
 
  const slotSignature = slots.map((slot) => slot.key).join("|");
 
  const sameAsContact = methods.watch("participantsSameAsContact");
  // The Contact "Send all tickets to this email" checkbox — per-ticket mode
  // only, where it mirrors the contact email into every row's email field.
  const sendAllToContactEmail = methods.watch("sendAllTicketsToEmail");
  const contactEmail = methods.watch("email");
  // Only the single-row branch mirrors the contact name, so per-ticket mode does
  // not subscribe and its rows do not re-render on every Contact keystroke.
  const contactName = useWatch({
    control: methods.control,
    name: "name",
    disabled: collectPerTicket,
  });
  const participantErrors = methods.formState.errors?.participants;
 
  // The switch renders OFF and disabled until Contact has something to copy
  // (mockup 7417:204472), while the AC054 ON default survives underneath.
  const hasContactToCopy = isContactComplete(contactName, contactEmail);
  const sameAsContactActive = isSameAsContactActive({
    sameAsContact,
    contactName,
    contactEmail,
  });
 
  // The first ticket starts open (mockup 1); the rest are collapsed.
  const [expandedKeys, setExpandedKeys] = useState<Record<string, boolean>>({});
 
  /**
   * AC017 — reconcile against the current cart. Keys are variant-based, so this
   * survives both a quantity change and the cart being recreated after a decline;
   * what the buyer already typed for a surviving unit is never touched.
   */
  // Keyed on the slot SIGNATURE, not on `slots` or `methods`: both are new
  // identities every render, and re-running this on each one would overwrite
  // the buyer's in-progress typing with the reconciled snapshot.
  // Skipped in preview: those slots are illustrative, and writing their keys into
  // `participants` would seed the checkout form with values no order can honour.
  useEffect(() => {
    if (!isRendered || isPreviewMode) return;
    const current = methods.getValues("participants") ?? {};
    methods.setValue("participants", reconcileParticipants(current, slots));
  }, [slotSignature, isRendered, isPreviewMode]); // eslint-disable-line
 
  // "Send all tickets to this email" (note on mockup 7471:54270) — the rows keep
  // visible, editable emails, so the checkbox mirrors rather than hides. Runs
  // after the reconcile above so a unit added while checked is filled too.
  useEffect(() => {
    if (!isRendered || isPreviewMode || !collectPerTicket) return;
    if (!sendAllToContactEmail) return;
    slots.forEach(({ key }) => {
      methods.setValue(`participants.${key}.email`, contactEmail ?? "");
    });
    // An error earned while the fields were empty is stale once the contact
    // email fills them; submit revalidates from scratch.
    Iif (participantErrors) {
      methods.clearErrors(slots.map(({ key }) => `participants.${key}.email`));
    }
  }, [
    sendAllToContactEmail,
    contactEmail,
    slotSignature,
    collectPerTicket,
    isRendered,
    isPreviewMode,
  ]); // eslint-disable-line
 
  // "Same as contact details" (mockup 7471:57034) — the single row stays visible
  // and carries the Contact values, so breaking the link leaves the buyer with
  // the fields already filled instead of an empty form.
  useEffect(() => {
    if (!isRendered || isPreviewMode || collectPerTicket) return;
    // The effective state, not the raw value: an OFF-because-incomplete switch
    // must not keep writing into the row.
    if (!sameAsContactActive) return;
    methods.setValue(
      `participants.${SINGLE_PARTICIPANT_KEY}.name`,
      contactName ?? "",
    );
    methods.setValue(
      `participants.${SINGLE_PARTICIPANT_KEY}.email`,
      contactEmail ?? "",
    );
    // Whole branch, phone included: while linked the schema requires none of
    // these, so an error earned unlinked would outlive the rule behind it.
    Iif (participantErrors) {
      methods.clearErrors(`participants.${SINGLE_PARTICIPANT_KEY}`);
    }
  }, [
    sameAsContactActive,
    contactName,
    contactEmail,
    collectPerTicket,
    isRendered,
    isPreviewMode,
  ]); // eslint-disable-line
 
  /**
   * AC016 — a validation error inside a collapsed row is an unactionable dead
   * end: the buyer is blocked with nothing to fix on screen. Open every row that
   * has one.
   */
  useEffect(() => {
    Eif (!participantErrors) return;
    setExpandedKeys((prev) => ({
      ...prev,
      ...Object.keys(participantErrors).reduce<Record<string, boolean>>(
        (acc, key) => {
          acc[key] = true;
          return acc;
        },
        {},
      ),
    }));
  }, [participantErrors]);
 
  if (!isRendered || slots.length === 0) return null;
 
  const isExpanded = (key: string, fallback: boolean) =>
    expandedKeys[key] ?? fallback;
 
  const toggle = (key: string, fallback: boolean) =>
    setExpandedKeys((prev) => ({ ...prev, [key]: !isExpanded(key, fallback) }));
 
  return (
    <Stack spacing="20px" data-testid="participant-details">
      <SectionHeader
        title={PARTICIPANT_SECTION_TITLE}
        icon={<TicketIcon size={24} />}
      />
 
      {collectPerTicket ? (
        <Stack spacing={2.5}>
          {slots.map((slot, index) => (
            <ParticipantRow
              key={slot.key}
              fieldKey={`participants.${slot.key}`}
              title={`Ticket ${slot.ticketNumber}`}
              subtitle={slot.variantName}
              isExpanded={isExpanded(slot.key, index === 0)}
              onToggle={() => toggle(slot.key, index === 0)}
              showPhone={showPhone}
              isPhoneRequired={isPhoneRequired}
              isDisabledFields={isDisabledFields}
              onEmailEdit={
                sendAllToContactEmail
                  ? () => methods.setValue("sendAllTicketsToEmail", false)
                  : undefined
              }
            />
          ))}
        </Stack>
      ) : (
        <Stack spacing={2.5}>
          {/* Mockup 7471:57044 — an info Notice box (darken-5 fill, 12px pad,
              Info icon, Body S on primary), not a plain caption line. */}
          <Stack
            direction="row"
            alignItems="center"
            gap="12px"
            padding="12px"
            borderRadius="8px"
            sx={{
              backgroundColor: palette.primitive?.transparent?.["darken-5"],
            }}
          >
            <InfoIcon size={24} />
            <GiveText variant="bodyS">{PARTICIPANT_SINGLE_BANNER}</GiveText>
          </Stack>
          {/* Expanded for the buyer (mockup 7471:57034), collapsed only in the
              builder preview (mockup 7417:204472). */}
          <ParticipantRow
            fieldKey={`participants.${SINGLE_PARTICIPANT_KEY}`}
            title="Ticket"
            isExpanded={isExpanded(SINGLE_PARTICIPANT_KEY, !isPreviewMode)}
            onToggle={() => toggle(SINGLE_PARTICIPANT_KEY, !isPreviewMode)}
            // While the link holds, the payload carries no participant at all, so
            // a phone here would read as required yet never be validated or sent.
            showPhone={showPhone && !sameAsContactActive}
            isPhoneRequired={isPhoneRequired}
            isDisabledFields={isDisabledFields}
            // The RAW value, not the effective one: an edit made while Contact is
            // still empty must kill the ON default too, or completing Contact
            // later would relight the switch and mirror over what was typed.
            onFieldEdit={
              sameAsContact
                ? () => methods.setValue("participantsSameAsContact", false)
                : undefined
            }
            headerAction={
              <Controller
                control={methods.control}
                name="participantsSameAsContact"
                render={({ field: { onChange } }) => (
                  // Switch first, Body S on primary (mockup 7417:204476). The
                  // label is a real <label> so clicking the copy toggles it.
                  <GiveSwitch
                    id={SAME_AS_CONTACT_SWITCH_ID}
                    label={
                      <Box
                        component="label"
                        htmlFor={SAME_AS_CONTACT_SWITCH_ID}
                        sx={{
                          cursor: hasContactToCopy ? "pointer" : "default",
                        }}
                      >
                        <GiveText component="span" variant="bodyS">
                          {SAME_AS_CONTACT_LABEL}
                        </GiveText>
                      </Box>
                    }
                    checked={sameAsContactActive}
                    onChange={onChange}
                    disabled={isDisabledFields || !hasContactToCopy}
                    inputProps={{
                      ...{ "data-testid": "participant-same-as-contact" },
                    }}
                  />
                )}
              />
            }
          />
        </Stack>
      )}
    </Stack>
  );
};
 
export default ParticipantDetails;