PageSourceSearch

https://www.picklebeastpickleball.com/assets/controllers/tournamen…a78d82e7f51da29b3fd5a82fb2394a05ca04c6ca.js

js picklebeastpickleball.com collected 2026-09-24 14:18:32 UTC 27,617 bytes, 643 lines download raw bytes

1// Tournament Teams V2 — Add Team dialog (visually identical to the league
2// add_player_modal.html.erb; only the staging block at the bottom is
3// tournament-specific). This controller drives:
4//   • tab switching (Current / Club Members / Create New / Import)
5//   • filtering the Current list inline by name + email
6//   • debounced search against /teams_v2/players/search
7//   • per-row + → adds to staging; row paints green + check when added
8//   • Create New form → adds an unpersisted "new player" to staging
9//   • Submit → POSTs to create_team endpoint
10
11import { Controller } from "@hotwired/stimulus";
12
13export default class extends Controller {
14  static targets = [
15    "tabButton", "tabPane",
16    "divisionLabel",
17    "currentList", "currentFilterInput", "benchOption",
18    "searchInput", "searchResults", "searchEmpty",
19    "newFirstName", "newLastName", "newEmail", "newSendWelcome",
20    "stagingSlots", "stagingCounter",
21    "teamName", "seed",
22    "submitButton", "errorMessage",
23    "swapConfirm", "swapConfirmBody"
24  ];
25  static values = { searchUrl: String, createUrl: String, reloadUrl: String };
26
27  connect() {
28    this.divisionId = null;
29    this.maxSlots = 2;
30    this.mlpFormat = false;
31    this.staging = [];
32    this._searchTimer = null;
33    this.renderSlots();
34  }
35
36  // ── Public open/close ─────────────────────────────────────────────────
37
38  open(divisionId, divisionName, opts = {}) {
39    this.divisionId = divisionId;
40    this.divisionMode = opts.divisionMode || "team";   // 'team' | 'individual_rotating'
41    // Mixer divisions take ONE player at a time — no team roster.
42    this.maxSlots = this.divisionMode === "individual_rotating"
43      ? 1
44      : Math.max(1, parseInt(opts.playersPerTeam || 2, 10));
45    this.mlpFormat = !!opts.mlpFormat;
46    if (this.hasDivisionLabelTarget) {
47      this.divisionLabelTarget.textContent = divisionName ? `to ${divisionName}` : "to division";
48    }
49    // Toggle a class on the dialog root so CSS can hide team-only
50    // sections (Team Players header / Team Name / Seed) for mixer.
51    this.element.classList.toggle(
52      "ttv-add-team-dialog--mixer",
53      this.divisionMode === "individual_rotating"
54    );
55    this.staging = [];
56    this._addedCount = 0;
57    this.renderSlots();
58    this._refreshAllAddedState();
59    this.clearError();
60    this.element.hidden = false;
61    document.body.style.overflow = "hidden";
62  }
63
64  close(event) {
65    if (event && event.preventDefault) event.preventDefault();
66    this.element.hidden = true;
67    document.body.style.overflow = "";
68    this.staging = [];
69    this.renderSlots();
70    if (this.hasTeamNameTarget) this.teamNameTarget.value = "";
71    if (this.hasSeedTarget) this.seedTarget.value = "";
72    if (this.hasSearchInputTarget) this.searchInputTarget.value = "";
73    if (this.hasCurrentFilterInputTarget) this.currentFilterInputTarget.value = "";
74    if (this.hasSearchResultsTarget) this.searchResultsTarget.innerHTML = this._searchEmptyHtml();
75    this.clearError();
76  }
77
78  // ── Rapid add: reset the modal for the next team WITHOUT closing it ────
79  _resetForNextTeam() {
80    this.staging = [];
81    this.renderSlots();
82    if (this.hasTeamNameTarget) this.teamNameTarget.value = "";
83    if (this.hasSeedTarget) this.seedTarget.value = "";
84    if (this.hasSearchInputTarget) this.searchInputTarget.value = "";
85    if (this.hasCurrentFilterInputTarget) this.currentFilterInputTarget.value = "";
86    if (this.hasSearchResultsTarget) this.searchResultsTarget.innerHTML = this._searchEmptyHtml();
87    this.clearError();
88  }
89
90  // Resolve the main Teams V2 controller (owns the division panes + bench)
91  // so the modal can trigger a surgical pane refresh instead of a reload.
92  _mainController() {
93    const el = document.querySelector('[data-controller~="tournament-teams-v2"]');
94    if (!el) return null;
95    return this.application.getControllerForElementAndIdentifier(el, "tournament-teams-v2");
96  }
97
98  // Mark just-seated players as "added" in the modal's Current bench list so
99  // they can't be re-staged before the next full refresh.
100  _markSeatedInBench(ids) {
101    (ids || []).forEach((id) => {
102      this.element
103        .querySelectorAll(`.session-player-item[data-user-id="${id}"]`)
104        .forEach((row) => row.classList.add("session-player-item--added"));
105    });
106  }
107
108  _showAddedFeedback(json, count, main) {
109    if (!main || typeof main._toast !== "function") return;
110    const name = json && json.team_name ? `"${json.team_name}"` : "Team";
111    try { main._toast(`${name} added (${count} this session).`, "success"); } catch (_e) { /* toast is best-effort */ }
112  }
113
114  // ── Tab switching ─────────────────────────────────────────────────────
115
116  switchTab(event) {
117    const target = event.currentTarget.dataset.tab;
118    this.tabButtonTargets.forEach((btn) => {
119      btn.classList.toggle("active", btn.dataset.tab === target);
120    });
121    this.tabPaneTargets.forEach((pane) => {
122      pane.style.display = pane.dataset.tabPane === target ? "" : "none";
123    });
124  }
125
126  // ── Current bench list (filter + pick) ────────────────────────────────
127
128  filterCurrent(event) {
129    const q = event.currentTarget.value.toLowerCase().trim();
130    const rows = this.currentListTarget.querySelectorAll(".session-player-item");
131    rows.forEach((row) => {
132      const name  = (row.dataset.nameLower || "");
133      const email = (row.dataset.emailLower || "");
134      const match = !q || name.includes(q) || email.includes(q);
135      row.style.display = match ? "" : "none";
136    });
137    // Hide division headers whose rows are all filtered out.
138    const headers = this.currentListTarget.querySelectorAll(".addplayer-div-header");
139    headers.forEach((h) => {
140      let next = h.nextElementSibling;
141      let visible = false;
142      while (next && next.classList.contains("session-player-item")) {
143        if (next.style.display !== "none") { visible = true; break; }
144        next = next.nextElementSibling;
145      }
146      h.style.display = visible ? "" : "none";
147    });
148  }
149
150  pickPlayer(event) {
151    const row = event.currentTarget.closest(".session-player-item") || event.currentTarget;
152    if (row.classList.contains("session-player-item--added")) return;
153    if (row.classList.contains("session-player-item--disabled")) return;
154    if (row.dataset.locked === "true") return;
155    const id = parseInt(row.dataset.userId, 10);
156    if (!id) return;
157    if (this.staging.some((p) => p.id === id)) return;
158    this.addToStaging({
159      id,
160      name: row.dataset.userName || "Player",
161      gender: row.dataset.gender || "",
162      source: "existing"
163    });
164  }
165
166  // On-team row click — open the premium in-modal swap confirmation
167  // instead of the native window.confirm. The actual remove-from-current
168  // -team server action runs in submit() as part of create_team, so the
169  // swap is atomic with the new team's creation: if create fails, no one
170  // was removed.
171  beginSwapFromTeam(event) {
172    if (event.target && event.target.closest(".ttv-gender-picker")) return;
173    const row = event.currentTarget.closest(".session-player-item") || event.currentTarget;
174    const id = parseInt(row.dataset.userId, 10);
175    if (!id) return;
176    if (this.staging.some((p) => p.id === id)) return;
177
178    const name        = row.dataset.userName || "Player";
179    const teamName    = row.dataset.currentTeamName || "their current team";
180    const divisionNm  = row.dataset.currentDivisionName;
181    const currentTeam = parseInt(row.dataset.currentTeamId, 10);
182    if (!currentTeam) return;
183
184    if (!this.hasSwapConfirmTarget || !this.hasSwapConfirmBodyTarget) return;
185
186    // Stash the pending swap on the controller so confirmSwap can act on it.
187    this._pendingSwap = {
188      id, name, teamName,
189      currentTeam,
190      gender: row.dataset.gender || "",
191      rowEl: row
192    };
193
194    const escape = (s) => this._esc(s);
195    this.swapConfirmBodyTarget.innerHTML =
196      `<strong>${escape(name)}</strong> is currently on Team <strong>${escape(teamName)}</strong>` +
197      `${divisionNm ? ` in ${escape(divisionNm)}` : ""}.<br><br>` +
198      `Remove them from that team and add them to the new team you're building?` +
199      `<em>The swap happens when you tap <strong>Add team</strong> — until then nothing changes on Team ${escape(teamName)}.</em>`;
200    this.swapConfirmTarget.hidden = false;
201  }
202
203  confirmSwap(_event) {
204    const pending = this._pendingSwap;
205    if (!pending) { this._closeSwapConfirm(); return; }
206
207    this.addToStaging({
208      id: pending.id,
209      name: pending.name,
210      gender: pending.gender,
211      source: "swap_from_team",
212      swap_from_team_id: pending.currentTeam,
213      swap_from_team_name: pending.teamName
214    });
215    if (pending.rowEl) pending.rowEl.classList.add("session-player-item--added");
216    this._closeSwapConfirm();
217  }
218
219  cancelSwap(_event)        { this._closeSwapConfirm(); }
220  swapBackdropClick(event)  { if (event.target === this.swapConfirmTarget) this._closeSwapConfirm(); }
221  swapStopPropagation(event) { event.stopPropagation(); }
222
223  _closeSwapConfirm() {
224    this._pendingSwap = null;
225    if (this.hasSwapConfirmTarget) this.swapConfirmTarget.hidden = true;
226  }
227
228  // Click handler for the inline M/F mini-picker on rows whose user has
229  // no User.gender set. The choice is the staging entry's gender (not a
230  // backfill of User.gender — that's a separate flow).
231  pickPlayerWithGender(event) {
232    event.stopPropagation();
233    const btn = event.currentTarget;
234    if (btn.classList.contains("ttv-gender-picker-btn--disabled")) return;
235    const row = btn.closest(".session-player-item");
236    if (!row) return;
237    if (row.classList.contains("session-player-item--added")) return;
238    const id = parseInt(row.dataset.userId, 10);
239    if (!id) return;
240    if (this.staging.some((p) => p.id === id)) return;
241    this.addToStaging({
242      id,
243      name: row.dataset.userName || "Player",
244      gender: btn.dataset.gender || "",
245      source: "existing"
246    });
247  }
248
249  // ── Search Club Members ───────────────────────────────────────────────
250
251  searchDebounced(event) {
252    const q = event.currentTarget.value.trim();
253    clearTimeout(this._searchTimer);
254    this._searchTimer = setTimeout(() => this.runSearch(q), 220);
255  }
256
257  async runSearch(q) {
258    if (q.length < 2) {
259      this.searchResultsTarget.innerHTML = this._searchEmptyHtml();
260      return;
261    }
262    try {
263      const url = `${this.searchUrlValue}?q=${encodeURIComponent(q)}`;
264      const res = await fetch(url, { headers: { Accept: "application/json" } });
265      if (!res.ok) throw new Error(`search failed: ${res.status}`);
266      const json = await res.json();
267      const results = json.results || [];
268      if (results.length === 0) {
269        this.searchResultsTarget.innerHTML = `<div class="session-search-hint">No matches.</div>`;
270        return;
271      }
272      this.searchResultsTarget.innerHTML = results.map((u) => this._renderSearchRow(u)).join("");
273      this._refreshAllAddedState();
274    } catch (err) {
275      console.warn("[ttv] search failed", err);
276      this.searchResultsTarget.innerHTML = `<div class="session-search-hint">Search failed. Try again.</div>`;
277    }
278  }
279
280  _searchEmptyHtml() {
281    return `<div class="session-search-hint">
282              <i class="fas fa-search" style="display: block; font-size: 24px; margin-bottom: 8px; opacity: 0.4;"></i>
283              Type a name or email to search club members
284            </div>`;
285  }
286
287  _renderSearchRow(u) {
288    const safeName = this._esc(u.name || u.email || "Player");
289    const safeEmail = this._esc(u.email || "");
290    const avatar = `<div class="pb-avatar pb-avatar--sm" style="width:32px;height:32px"><img width="32" height="32" src="${this._esc(u.avatar_url || '/blue_claws.png')}" alt=""></div>`;
291    const g = String(u.gender || "").toLowerCase();
292    const genderLetter = g.startsWith("m") ? "m" : (g.startsWith("f") ? "f" : "");
293    const genderBadge = genderLetter
294      ? `<span class="ttv-gender-badge ttv-gender-badge--${genderLetter}">${genderLetter.toUpperCase()}</span>`
295      : "";
296
297    // Registration indicator: user is already on a team → "On {team}" pill;
298    // user is registered but on the bench → green "Registered" pill;
299    // user is brand-new to the tournament → no pill.
300    const onTeam = u.placed_team_name && String(u.placed_team_name).trim().length > 0;
301    const isRegistered = !!u.registered && !onTeam;
302    let statusBadge = "";
303    if (onTeam) {
304      statusBadge = `<span class="ttv-status-chip ttv-status-chip--on-team" title="Already on this team in this tournament">On ${this._esc(u.placed_team_name)}</span>`;
305    } else if (isRegistered) {
306      statusBadge = `<span class="ttv-status-chip ttv-status-chip--registered" title="Already registered for this tournament">Registered</span>`;
307    }
308
309    // On-team users can't be picked — show a muted lock chip in the action
310    // slot. Everyone else gets the normal [+] (known gender) or M/F picker
311    // (unknown gender).
312    let rightSide;
313    if (onTeam) {
314      rightSide = `<span class="ttv-locked-chip" title="Already on a team">
315                     <i class="fas fa-lock"></i>
316                   </span>`;
317    } else if (genderLetter) {
318      rightSide = `<div class="session-player-item__action">
319                     <span class="session-player-item__add"><i class="fas fa-plus"></i></span>
320                   </div>`;
321    } else {
322      rightSide = `<div class="ttv-gender-picker" title="Pick gender for MLP roster">
323                     <button type="button" class="ttv-gender-picker-btn ttv-gender-picker-btn--m" data-gender="m"
324                             data-action="click->tournament-teams-v2-add-team#pickPlayerWithGender">M</button>
325                     <button type="button" class="ttv-gender-picker-btn ttv-gender-picker-btn--f" data-gender="f"
326                             data-action="click->tournament-teams-v2-add-team#pickPlayerWithGender">F</button>
327                   </div>`;
328    }
329
330    // On-team rows are non-interactive. Otherwise wire pickPlayer for
331    // known-gender rows (M/F picker handles unknowns).
332    const rowAction = onTeam ? "" : (genderLetter ? `data-action="click->tournament-teams-v2-add-team#pickPlayer"` : "");
333    const rowClass = onTeam ? "session-player-item session-player-item--locked" : "session-player-item";
334
335    return `
336      <div class="${rowClass}"
337           data-user-id="${u.id}"
338           data-user-name="${safeName}"
339           data-gender="${genderLetter}"
340           ${onTeam ? 'data-locked="true"' : ''}
341           ${rowAction}>
342        <div class="session-player-item__info">
343          ${avatar}
344          <div class="session-player-item__details">
345            <span class="session-player-item__name">${safeName}${genderBadge}${statusBadge}</span>
346            <span class="session-player-item__meta">${safeEmail}</span>
347          </div>
348        </div>
349        <div class="session-player-item__right">
350          ${rightSide}
351        </div>
352      </div>`;
353  }
354
355  // ── Create New ────────────────────────────────────────────────────────
356
357  addNewPlayer(_event) {
358    const first = this.newFirstNameTarget.value.trim();
359    const last  = this.newLastNameTarget.value.trim();
360    const email = this.newEmailTarget.value.trim();
361    if (!email || !email.includes("@")) {
362      this.showError("Valid email required.");
363      return;
364    }
365    if (!first && !last) {
366      this.showError("First or last name required.");
367      return;
368    }
369    this.clearError();
370    const name = [first, last].filter(Boolean).join(" ");
371    const sendWelcome = this.hasNewSendWelcomeTarget ? !!this.newSendWelcomeTarget.checked : true;
372    this.addToStaging({ name, email, first, last, sendWelcome, source: "new" });
373    this.newFirstNameTarget.value = "";
374    this.newLastNameTarget.value = "";
375    this.newEmailTarget.value = "";
376  }
377
378  // ── Staging ──────────────────────────────────────────────────────────
379
380  addToStaging(player) {
381    if (this.staging.length >= this.maxSlots) {
382      this.showError(`This division takes ${this.maxSlots} player${this.maxSlots > 1 ? 's' : ''} per team.`);
383      return;
384    }
385    this.staging.push(player);
386    this.renderSlots();
387    this._refreshAllAddedState();
388  }
389
390  removeFromStaging(event) {
391    event.stopPropagation();
392    const idx = parseInt(event.currentTarget.dataset.idx, 10);
393    if (Number.isNaN(idx)) return;
394    this.staging.splice(idx, 1);
395    this.renderSlots();
396    this._refreshAllAddedState();
397  }
398
399  renderSlots() {
400    const slots = [];
401    // Captain crown only renders for true team-captain formats —
402    // Team Cup style rosters (>2 non-MLP players). Mixer, doubles
403    // and MLP all set captain_id server-side, but the operator
404    // doesn't need to think about it visually.
405    const showCaptainCrown =
406      this.divisionMode !== "individual_rotating" &&
407      this.maxSlots > 2 &&
408      !this.mlpFormat;
409
410    for (let i = 0; i < this.maxSlots; i++) {
411      const p = this.staging[i];
412      const isCaptainSlot = i === 0 && showCaptainCrown;
413      // Trailing space inside the tag so the crown and the label that
414      // follows aren't visually glued together — `Captain · + slot 1` and
415      // the player-name label both render after this.
416      const captainBadge = isCaptainSlot
417        ? `<span class="ttv-captain-tag" title="Slot 1 becomes the team captain"><i class="fas fa-crown"></i></span>&nbsp;`
418        : "";
419
420      if (p) {
421        const label = this._esc(p.name || p.email || "Player");
422        const meta = p.source === "new" ? ` <span style="font-size:10px; opacity:0.7;">(new)</span>` : "";
423        const chipClass = isCaptainSlot ? "ttv-staging-chip ttv-staging-chip--captain" : "ttv-staging-chip";
424        slots.push(`
425          <span class="${chipClass}">
426            ${captainBadge}
427            <span>${label}${meta}</span>
428            <button type="button" class="ttv-staging-chip-remove"
429                    data-idx="${i}"
430                    data-action="click->tournament-teams-v2-add-team#removeFromStaging"
431                    aria-label="Remove">
432              <i class="fas fa-times" style="font-size: 9px;"></i>
433            </button>
434          </span>`);
435      } else {
436        const emptyClass = isCaptainSlot ? "ttv-staging-slot-empty ttv-staging-slot-empty--captain" : "ttv-staging-slot-empty";
437        // For mixer (maxSlots=1) and doubles where the crown is hidden,
438        // just say "+ Player" instead of "+ slot 1" — the slot framing
439        // is awkward when there's only one or two anyway.
440        let emptyText;
441        if (this.divisionMode === "individual_rotating") {
442          emptyText = "+ Player";
443        } else if (isCaptainSlot) {
444          emptyText = "Captain · + slot 1";
445        } else {
446          emptyText = `+ slot ${i + 1}`;
447        }
448        slots.push(`<span class="${emptyClass}">${captainBadge}${emptyText}</span>`);
449      }
450    }
451    this.stagingSlotsTarget.innerHTML = slots.join("");
452    if (this.hasStagingCounterTarget) {
453      // For mixer mode (1 slot) the "of 1" framing is awkward — just
454      // say "1 selected" / "0 selected".
455      this.stagingCounterTarget.textContent = (this.maxSlots <= 1)
456        ? `${this.staging.length} selected`
457        : `${this.staging.length} of ${this.maxSlots} selected`;
458    }
459  }
460
461  _refreshAllAddedState() {
462    // For every visible row in either list, mark added vs not + apply
463    // MLP gender gating in the same pass.
464    const lists = [];
465    if (this.hasCurrentListTarget) lists.push(this.currentListTarget);
466    if (this.hasSearchResultsTarget) lists.push(this.searchResultsTarget);
467
468    const gateByGender = this.mlpFormat;
469    const staged = { m: 0, f: 0 };
470    if (gateByGender) {
471      this.staging.forEach((p) => {
472        const g = String(p.gender || "").toLowerCase();
473        if (g.startsWith("m")) staged.m += 1;
474        else if (g.startsWith("f")) staged.f += 1;
475      });
476    }
477    // Default MLP-mixed requirement: 2 men + 2 women. If a future division
478    // surfaces a different requirement we can pass it through opts; for
479    // now this matches the underlying create_organizer_mlp_team service.
480    const requiredMale = 2;
481    const requiredFemale = 2;
482
483    lists.forEach((scope) => {
484      scope.querySelectorAll(".session-player-item").forEach((row) => {
485        const id = parseInt(row.dataset.userId, 10);
486        const added = this.staging.some((p) => p.id === id);
487        row.classList.toggle("session-player-item--added", added);
488
489        const g = String(row.dataset.gender || "").toLowerCase();
490
491        // Row-level gating (only meaningful for known-gender rows). Unknown
492        // rows are gated at the M/F mini-button level below.
493        if (gateByGender && !added && g) {
494          let disabled = false;
495          if (g === "m") disabled = staged.m >= requiredMale;
496          else if (g === "f") disabled = staged.f >= requiredFemale;
497          row.classList.toggle("session-player-item--disabled", disabled);
498        } else if (!gateByGender) {
499          row.classList.remove("session-player-item--disabled");
500        }
501
502        // Mini-picker gating for unknown-gender rows.
503        row.querySelectorAll(".ttv-gender-picker-btn").forEach((btn) => {
504          const bg = btn.dataset.gender;
505          let bDisabled = added; // also disable if the row is already added
506          if (gateByGender && !bDisabled) {
507            if (bg === "m") bDisabled = staged.m >= requiredMale;
508            else if (bg === "f") bDisabled = staged.f >= requiredFemale;
509          }
510          btn.classList.toggle("ttv-gender-picker-btn--disabled", bDisabled);
511        });
512
513        const addEl = row.querySelector(".session-player-item__add");
514        if (addEl) {
515          addEl.innerHTML = added
516            ? `<i class="fas fa-check"></i>`
517            : `<i class="fas fa-plus"></i>`;
518        }
519      });
520    });
521  }
522
523  // ── Submit ────────────────────────────────────────────────────────────
524
525  async submit(_event) {
526    if (this.staging.length === 0) {
527      this.showError("Add at least one player.");
528      return;
529    }
530    const newPlayers = this.staging.filter((p) => p.source === "new");
531    if (newPlayers.length > 0) {
532      this.showError("Magic-link onboarding for new players ships in Phase 7b. Use Current or Club Members for now.");
533      return;
534    }
535    const playerIds = this.staging.map((p) => p.id).filter(Boolean);
536    if (playerIds.length < 1) {
537      this.showError("Pick at least one player.");
538      return;
539    }
540    // Partial rosters are allowed — under-filled teams are created with
541    // needs_partner: true and surface in the bench for the remaining slots.
542    // No "must match maxSlots" check here.
543
544    // Build a per-player gender dict so the controller can route MLP rosters
545    // with the correct gender AND backfill User.gender on records that
546    // were previously nil.
547    const playerGenders = {};
548    this.staging.forEach((p) => {
549      if (p.id && p.gender) playerGenders[String(p.id)] = p.gender;
550    });
551
552    // Swap-from-team map: { user_id_string => old_team_id }. The backend
553    // removes each user from their old team before the new team is created
554    // (single create_team request, server-side transaction per swap).
555    const swapFromTeam = {};
556    this.staging.forEach((p) => {
557      if (p.source === "swap_from_team" && p.id && p.swap_from_team_id) {
558        swapFromTeam[String(p.id)] = p.swap_from_team_id;
559      }
560    });
561
562    const payload = {
563      division_id: this.divisionId,
564      player_ids: playerIds,
565      player_genders: playerGenders,
566      swap_from_team: swapFromTeam,
567      team_name: this.teamNameTarget?.value?.trim() || null,
568      seed: this.seedTarget?.value?.trim() || null
569    };
570
571    this.submitButtonTarget.disabled = true;
572    this.clearError();
573
574    try {
575      const res = await fetch(this.createUrlValue, {
576        method: "POST",
577        headers: {
578          "Content-Type": "application/json",
579          Accept: "application/json",
580          "X-CSRF-Token": this._csrfToken()
581        },
582        body: JSON.stringify(payload)
583      });
584      const json = await res.json().catch(() => ({}));
585      // Capacity gate (ADMIN_CAPACITY_DECISION_ENABLED_FOR) returns a 200 with
586      // needs_admin_decision — that flow isn't wired into this modal, so fall
587      // back to the original close+reload rather than rapid-add.
588      if (res.ok && json.needs_admin_decision) {
589        this.close();
590        window.location.assign(this.reloadUrlValue);
591        return;
592      }
593      if (!res.ok) {
594        this.showError(json.detail || json.error || `Request failed (${res.status})`);
595        return;
596      }
597      // ── Rapid add ──────────────────────────────────────────────────────
598      // Keep the modal OPEN, surgically refresh the affected division (no
599      // full-page reload), reset the inputs, and let the operator stage the
600      // next team immediately. If the stale bench is re-picked, the server
601      // refuses it (TournamentTeamForcePairService rejects a player already on
602      // an active team in this division — :already_on_team), so a re-pick shows
603      // an error rather than silently moving a seated player.
604      const seatedIds = [...playerIds];
605      const main = this._mainController();
606      if (main) {
607        try { main._refreshDivisionPane && main._refreshDivisionPane(this.divisionId); } catch (_e) { /* pane not visible — server state still correct */ }
608        try { main._refreshBenchSection && main._refreshBenchSection(); } catch (_e) { /* fire-and-forget */ }
609      }
610      this._markSeatedInBench(seatedIds);
611      this._addedCount = (this._addedCount || 0) + 1;
612      this._showAddedFeedback(json, this._addedCount, main);
613      this._resetForNextTeam();
614      if (this.hasSearchInputTarget) this.searchInputTarget.focu
614s();
615    } catch (err) {
616      this.showError(err.message || "Network error.");
617    } finally {
618      this.submitButtonTarget.disabled = false;
619    }
620  }
621
622  // ── Helpers ──────────────────────────────────────────────────────────
623
624  showError(msg) {
625    if (!this.hasErrorMessageTarget) return;
626    this.errorMessageTarget.textContent = msg;
627    this.errorMessageTarget.style.display = "block";
628  }
629  clearError() {
630    if (!this.hasErrorMessageTarget) return;
631    this.errorMessageTarget.style.display = "none";
632    this.errorMessageTarget.textContent = "";
633  }
634  _csrfToken() {
635    const meta = document.querySelector('meta[name="csrf-token"]');
636    return meta ? meta.getAttribute("content") : "";
637  }
638  _esc(s) {
639    return String(s || "").replace(/[&<>"']/g, (c) => (
640      { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
641    ));
642  }
643};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.