PageSourceSearch

https://www.imleagues.com/signup

html imleagues.com collected 2026-10-01 12:07:14 UTC 154,568 bytes, 2,858 lines download raw bytes

1
2<!--
3    Production v3 registration page. Reached when FeatureIds.UseNewAuthPages
4    is enabled for the school (or globally). Registration.aspx.cs:Page_Load
5    does a Response.Redirect here BEFORE its own SPA / legacy fallback,
6    preserving query string.
7
8    Pure HTML+JS in an .aspx wrapper — no code-behind, no property accesses.
9    Locked-network handling + per-network registration rules (waiver, custom
10    questions, hide-birth-month-day, etc.) are wired client-side via the
11    SPA's existing Account.RegisterBO.Initialize endpoint — see init() below.
12--><!DOCTYPE html>
13<html lang="en">
14<head>
15    <title>Create account &middot; IMLeagues</title>
16    <meta charset="UTF-8">
17    <meta name="viewport" content="width=device-width, initial-scale=1.0">
18    <link rel="icon" type="image/png" href="/public/_assets/imleagues-design/iml-sm-logo.png">
19
20    <!--
21        Production v3 registration page. Shares the auth design system
22        at /public/_assets/imleagues-design/auth-bundle.css with LoginNew.aspx.
23
24        Two-step flow (three including SSO):
25          Step 1   — email + Continue. Routes via email-domain detection.
26          Step 2A  — SSO networks: 5s countdown, then 302 to /SSORedirect.aspx
27                     (IdP creates the account on first sign-in).
28          Step 2   — Non-SSO + unknown: full registration form. Submit
29                     currently STUBBED — wiring to RegisterBO.CreateAccount
30                     is the next phase (1.5), along with per-net
30work rule
31                     wiring (waiver, custom questions, etc.).
32
33        Locked-network context arrives via ?SchID= in the URL. The server
34        resolves it into IML_LOCKED so the hero paints on first frame.
35    -->
36
37    <link rel="preconnect" href="https://fonts.googleapis.com">
38    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
39    
40    <link rel="preconnect" href="https://cdn.intergient.com">
41    <link rel="preconnect" href="https://securepubads.g.doubleclick.net">
42    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
43    
44    <link rel="stylesheet" href="/public/_assets/imleagues-design/auth-bundle.css?v=291">
45    
45<script src="/public/_assets/imleagues-design/auth-analytics.js?v=291" defer></script>
45
46    
47    
47<script src="/public/_assets/imleagues-design/auth-ads.js?v=291" defer></script>
47
48    
48<script src="/public/_assets/imleagues-design/auth-turnstile.js?v=291" defer></script>
48
49    <link rel="stylesheet" href="/public/_assets/vendor/phosphor/regular/style.css?v=291">
50
51    <style>
52        html, body { height: 100%; }
53        body { margin: 0; background: #fff; font-family: var(--iml-font-sans); color: var(--iml-fg); }
54        .lr-split { min-height: 100vh; }
55        .lr-btn[disabled] { cursor: not-allowed; opacity: 0.78; }
56        .lr-btn-spinner { margin-right: 8px; animation: iml-register-spin 0.9s linear infinite; }
57        .lr-btn-spinner[hidden] { display: none; }
58        .lr-success-actions--register {
59            display: grid;
60            grid-template-columns: repeat(2, minmax(0, 1fr));
61            gap: 10px;
62        }
63        .lr-success-actions--register .lr-btn,
64        .lr-success-actions--register a.lr-btn {
65            width: 100%;
66            min-width: 0;
67            min-height: 44px;
68            height: auto;
69            padding: 10px 14px;
70            line-height: 1.25;
71            white-space: normal;
72            text-align: center;
73            justify-content: center;
74        }
75        @media (max-width: 560px) {
76            .lr-success-actions--register {
77                grid-template-columns: 1fr;
78            }
79        }
80        @media (max-width: 560px) {
81            .lr-form-side { padding: 24px 20px; }
82            .lr-form-wrap { align-items: flex-start; justify-content: flex-start; padding: clamp(28px, 12vh, 96px) 0 24px; }
83            .page-hero { margin: 0 0 24px; }
84        }
85        @media (max-width: 560px) {
86            body[data-step="step-2"] .lr-form-wrap,
87            body[data-step="step-2-sso"] .lr-form-wrap {
88                padding: clamp(16px, 4vh, 56px) 0 24px;
89            }
90        }
91        @keyframes iml-register-spin {
92            from { transform: rotate(0deg); }
93            to { transform: rotate(360deg); }
94        }
95    </style>
96</head>
97
98<body data-auth-ads="off">
99
100    
101    <div class="ad-slot">
102        <div class="ad-stack">
103            <span class="ad-label">Advertisement</span>
104            <div id="iml_p_top_leaderboard"></div>
105        </div>
106    </div>
107
108    <div class="lr-split">
109
110        <!-- LEFT — form -->
111        <div class="lr-form-side">
112            <a href="#main" class="lr-skip-link">Skip to form</a>
113            <a href="/" id="home-link" class="lr-home-link" aria-label="Back to IMLeagues homepage" onclick="onHomeLinkClick(event)">
114                <i class="ph ph-arrow-left" aria-hidden="true"></i>
115                <span class="lr-home-logo-mark" aria-hidden="true">
116                    <img src="/public/_assets/imleagues-design/iml-logo-mark.svg" alt="">
117                </span>
118                <span id="home-link-label">IMLeagues</span>
119            </a>
120            <div class="lr-form-wrap">
121                <div class="lr-form-inner" id="main">
122
123                    <!-- Inline error banner. Same pattern as LoginNew.aspx —
124                         no alert() popups. -->
125                    <div id="form-alert" class="lr-alert lr-alert-error" role="alert" aria-live="assertive" tabindex="-1" hidden>
126                        <i class="ph ph-warning-circle lr-alert-icon" aria-hidden="true"></i>
127                        <span id="form-alert-msg"></span>
128                    </div>
129
130                    <!-- STEP 1 — network picker.
131                         Registration is network-first: most users sign up
132                         specifically to join a school/organization. Picking
133                         network first lets SSO schools skip email entry
134                         entirely (the IdP collects it) and surfaces per-
135                         network form rules (status/grad/custom questions/
136                         non-binary toggle) from the start, instead of being
137                         fetched after a Continue round-trip. Personal
138                         accounts get an explicit escape link below the
139                         picker. Login (LoginNew.aspx) stays email-first
140                         because identity = email there, not network. -->
141                    <div id="step-1" class="step">
142                        <div class="page-hero">
143                            <div class="page-hero-mark page-hero-mark--logo">
144                                <img src="/public/_assets/imleagues-design/iml-logo-mark.svg" alt="IMLeagues">
145                            </div>
146                            <h1 class="page-hero-name">Welcome to IMLeagues</h1>
147                            <div class="page-hero-tag">Find your school or organization to create your account.</div>
148                        </div>
149
150                        <div class="lr-field">
151                            <label class="lr-label" for="network-search">Your school or organization</label>
152                            <div class="lr-input-wrap">
153                                <i class="ph ph-magnifying-glass lr-input-icon"></i>
154                                <input id="network-search" class="lr-input" type="text"
155                                       placeholder="Type to search..."
156                                       oninput="onNetworkSearch()"
157                                       onkeydown="onSearchKeydown(event)"
158                                       autocomplete="organization" autofocus>
159                            </div>
160                        </div>
161
162                        <ul id="network-results" class="lr-network-list" role="listbox" aria-label="Network results" hidden></ul>
163
164                        <p class="lr-alt-link">
165                            Not joining a specific network?
166                            <a href="javascript:void(0)" onclick="continueWithoutNetwork()">Create an independent account</a>
167                        </p>
168
169                        <p class="lr-alt-link" style="margin-top: 12px;">
170                            Already have an account? <a href="/login">Sign in</a>
171                        </p>
172                    </div>
173
174                    <!-- STEP 2 — full registration form (non-SSO + unknown) -->
175                    <div id="step-2" class="step" hidden>
176                        <div class="page-hero">
177                            <div class="page-hero-mark page-hero-mark--logo" id="step2-mark">
178                                <img src="/public/_assets/imleagues-design/iml-logo-mark.svg" alt="IMLeagues">
179                            </div>
180                            <h1 class="page-hero-name" id="step2-name">Sign up for IMLeagues</h1>
181                            <div class="page-hero-tag" id="step2-tag">A few details to finish up.</div>
182                        </div>
183
184                        <div id="custom-registration-info" class="lr-inline-card" hidden>
185                            <p class="lr-inline-card-title">Additional registration info</p>
186                            <div id="custom-registration-info-copy" class="lr-inline-card-copy"></div>
187                        </div>
188
189                        <!-- "Use a different network" back link. Hidden when
190                             the user landed via locked subdomain (?SchID=)
191                             since there's nothing to go back to. -->
192                        <div class="back-link-row" id="step2-back-row" style="text-align: left; margin: 0 0 18px;">
193                            <button type="button" class="back-link" onclick="backToStep1()" style="margin-top: 0;">
194                                <i class="ph ph-arrow-left"></i>
195                                <span id="step2-back-label">Use a different network</span>
196                            </button>
197                        </div>
198
199                        <!-- Email field — collected here (step 2) now that
200                             step 1 is the network picker. Placed first so
201                             it's the natural starting point for the form. -->
202                        <div class="lr-field">
203                            <label class="lr-label" for="email">Email</label>
204                            <div class="lr-input-wrap">
205                                <i class="ph ph-envelope-simple lr-input-icon"></i>
206                                <input id="email" class="lr-input" type="email"
207                                       placeholder="[email protected]"
208                                       autocomplete="email" aria-describedby="email-domain-hint">
209                            </div>
210                            <p id="email-domain-hint" class="lr-muted-note" style="margin: 6px 0 0;" hidden></p>
211                        </div>
212
213                        <!-- Email → network match. On the personal-account path
214                             (no network picked, no ?SchID=) the email domain is
215                             checked against every network's domain list as soon
216                             as it looks complete, and any claims are offered
217                             here — a student who skipped the picker, or could
218                             not find their school in it, still lands in their
219                             school instead of in no network at all. Selecting
220                             one runs the same context load as the picker
221                             (onMatchedNetworkChoice); "Create an independent account"
222                             hides it for that domain. -->
223                        <p id="email-match-status" class="lr-match-status" aria-l
223ive="polite" hidden>
224                            <i class="ph ph-circle-notch lr-match-spin" aria-hidden="true"></i>
225                            <span>Checking for your network&hellip;</span>
226                        </p>
227                        <div id="email-match-panel" class="lr-match" role="region" aria-labelledby="email-match-title" hidden>
228                            <div class="lr-match-head">
229                                <span class="lr-match-badge"><i class="ph ph-sparkle" aria-hidden="true"></i><span id="email-match-title">We found your network</span></span>
230                                <span id="email-match-domain" class="lr-match-domain"></span>
231                            </div>
232                            <p id="email-match-copy" class="lr-match-copy"></p>
233                            <div id="email-match-list" class="lr-match-list"></div>
234                            <p class="lr-match-foot">
235                                Not signing up for a network? <a href="javascript:void(0)" id="email-match-dismiss" onclick="dismissEmailMatch()">Create an independent account</a>
236                            </p>
237                        </div>
238
239                        <!-- Joined / declined strip: the resting state once the
240                             email settled a network (joined for them, picked
241                             from the panel, or declined for an independent account). Same
242                             slot as the panel. -->
243                        <div id="email-match-joined" class="lr-joined" role="status" aria-live="polite" hidden>
244                            <span id="email-match-joined-mark" class="lr-joined-mark" aria-hidden="true"></span>
245                            <span class="lr-joined-body">
246                                <span id="email-match-joined-title" class="lr-joined-title"></span>
247                                <span id="email-match-joined-meta" class="lr-match-meta"></span>
248                                <span class="lr-joined-foot"><span id="email-match-joined-question"></span> <a href="javascript:void(0)" id="email-match-joined-link" class="lr-joined-link"></a></span>
249                            </span>
250                        </div>
251
252                        <div class="row-2">
253                            <div class="lr-field">
254                                <label class="lr-label" for="fname">First name</label>
255                                <input id="fname" class="lr-input" type="text" autocomplete="given-name">
256                            </div>
257                            <div class="lr-field">
258                                <label class="lr-label" for="lname">Last name</label>
259                                <input id="lname" class="lr-input" type="text" autocomplete="family-name">
260                            </div>
261                        </div>
262
263                        <div class="lr-field">
264                            <label class="lr-label" for="pw">Create a password</label>
265                            <div class="lr-input-wrap">
266                                <i class="ph ph-lock lr-input-icon"></i>
267                                <input id="pw" class="lr-input" type="password" autocomplete="new-password"
268                                       oninput="updatePasswordStrength()">
269                                <button type="button" class="lr-input-suffix" onclick="togglePwd()" aria-label="Show password" aria-pressed="false">
270                                    <i id="pw-eye" class="ph ph-eye"></i>
271                                </button>
272                            </div>
273                            <div class="pw-strength" id="pw-strength" data-score="0" aria-hidden="true">
274                                <div class="pw-strength-seg"></div>
275                                <div class="pw-strength-seg"></div>
276                                <div class="pw-strength-seg"></div>
277                            </div>
278                            <div class="pw-strength-label" id="pw-strength-label">
279                                Minimum 8 characters with at least one letter and one number.
280                            </div>
281                        </div>
282
283                        <div class="row-2" id="status-grad-row">
284                            <div class="lr-field">
285                                <label class="lr-label" id="status-label" for="status">Status</label>
286                                <select id="status" class="lr-select" onchange="onStatusChange()">
287                                    <option value="">Select status</option>
288                                </select>
289                            </div>
290                            <div class="lr-field">
291                                <label class="lr-label" for="grad-year">Grad year</label>
292                                <select id="grad-year" class="lr-select">
293                                    <option value="">Year</option>
294                                </select>
295                            </div>
296                        </div>
297
298                        <div class="lr-field" role="group" aria-labelledby="birthday-label">
299                            <span class="lr-label" id="birthday-label">Birthday</span>
300                            <div class="birthday-row" id="birthday-row">
301                                <select id="birth-month" class="lr-select" aria-label="Birth month" autocomplete="bday-month"><option value="">Month</option></select>
302                                <select id="birth-day"   class="lr-select" aria-label="Birth day"   autocomplete="bday-day"><option value="">Day</option></select>
303                                <select id="birth-year"  class="lr-select" aria-label="Birth year"  autocomplete="bday-year"><option value="">Year</option></select>
304                            </div>
305                        </div>
306
307                        
308                        <div id="address-block" hidden>
309                            <div class="lr-field">
310                                <label class="lr-label" for="address-street">Street address</label>
311                                <input id="address-street" class="lr-input" type="text" maxlength="250" autocomplete="address-line1">
312                            </div>
313                            <div class="row-2">
314                                <div class="lr-field">
315                                    <label class="lr-label" for="address-city">City</label>
316                                    <input id="address-city" class="lr-input" type="text" maxlength="100" autocomplete="address-level2">
317                                </div>
318                                <div class="lr-field">
319                                    <label class="lr-label" for="address-country">Country</label>
320                                    <select id="address-country" class="lr-select" autocomplete="country" onchange="onAddressCountryChange()">
321                                        <option value="">Select country</option>
322                                    </select>
323                                </div>
324                            </div>
325                            <div class="row-2">
326                                
327                                <div class="lr-field">
328                                    <label class="lr-label" id="address-state-label" for="address-state">State</label>
329                                    <select id="address-state" class="lr-select" autocomplete="address-level1">
330                                        <option value="">Select state</option>
331                                    </select>
332                                    <input id="address-state-text" class="lr-input" type="text" maxlength="100" autocomplete="address-level1" hidden>
333                                </div>
334                                <div class="lr-field">
335                                    <label class="lr-label" id="address-zip-label" for="address-zip">ZIP code</label>
336                                    <input id="address-zip" class="lr-input" type="text" maxlength="20" autocomplete="postal-code">
337                                </div>
338                            </div>
339                        </div>
340
341                        <div class="lr-field" id="gender-field">
342                            <label class="lr-label" for="gender">I Participate As</label>
343                            <select id="gender" class="lr-select" onchange="onGenderChange()">
344                                <option value="">Please select</option>
345                                <option value="m">Man</option>
346                                <option value="f">Woman</option>
347                                <option value="nb" id="opt-nb">Non-binary</option>
348                            </select>
349                        </div>
350
351                        <div class="real-gender-block" id="real-gender-block" hidden>
352                            <p>For non-binary registrations, choose which league type you would typically play in. This is used only for league placement and can be changed later.</p>
353                            <div class="lr-field" style="margin-bottom: 0;">
354                                <label class="lr-label" for="real-gender">League type</label>
355                                <select id="real-gender" class="lr-select">
356                                    <option value="">Choose a league type</option>
357                                    <option value="male">Man</option>
358                                    <option value="female">Woman</option>
359                                </select>
360                            </div>
361                        </div>
362
363                        <!-- Per-network custom questions — populated by phase 1.5 -->
364                        <div id="custom-questions"></div>
365
366                        <div class="terms-row" id="waiver-row" hidden>
367                            <input id="waiver" type="checkbox">
368                            <label for="waiver">
369                                I have read and agree to the <a href="#" id="waiver-link">[Network]'s recreation participation waiver</a>.
370                            </label>
371                        </div>
372
373                        <div class="terms-row" id="optin-row" hidden>
374                            <input id="optin-news" type="checkbox">
375                            <label for="optin-news">
376                                Send me IMLeagues news and product updates.
377                            </label>
378                        </div>
379
380                        <div class="terms-row">
381                            <input id="terms" type="checkbox">
382                            <label for="terms">
383                                I agree to the IMLeagues <a href="/terms" target="_blank">Terms</a> and
384                                <a href="/privacy" target="_blank">Privacy Policy</a>.
385                            </label>
386                        </div>
387
388                        <!-- Two states, set by showJoinRequestPanel():
389                             domains configured -> "email doesn't match" + chips,
390                             none configured    -> "approval required" (a private
391                             network with no domains rejects EVERY email, so
392                             email-centric copy would be advice nobody can follow). -->
393                        <div id="join-request-panel" class="lr-inline-card" hidden>
394                            <p class="lr-inline-card-title" id="join-request-title">This email does not match the network domain</p>
395                            <p class="lr-inline-card-copy" id="join-request-copy">Use your network email, or send a join request with a quick note.</p>
396                            <div id="join-request-domains" class="lr-chip-list"></div>
397                            <div class="lr-field" style="margin-top: 12px; margin-bottom: 0;">
398                                <label class="lr-label" for="join-request-message">Join request message</label>
399                                <textarea id="join-request-message" class="lr-textarea" placeholder="Tell the network admin why you should be added."></textarea>
400                            </div>
401                            <div class="lr-inline-actions">
402                                <button type="button" class="lr-btn lr-btn-primary" onclick="submitJoinRequest()">Send join request</button>
403                                <button type="button" class="lr-btn lr-btn-outlined" id="join-request-alt-btn" onclick="hideJoinRequestPanel()">Use a different email</button>
404                            </div>
405                        </div>
406
407                        <div id="multi-network-panel" class="lr-inline-card" hidden>
408                            <p class="lr-inline-card-title">Choose your network</p>
409                            <p class="lr-inline-card-copy">This email can join more than one network. Pick where you want to register.</p>
410                            <div id="multi-network-list" class="lr-choice-list"></div>
411                        </div>
412
413                        <!--
414                            Honeypot. Real visitors never see or reach this field: the wrapper is
415                            positioned off-screen and tabindex="-1" removes it from keyboard flow.
416                            Naive bots and scanners fill every input by name, and the server silently
417                            discards those submissions (RegisterBO.CreateAccount). Don't rename
418                            "website" - bots target common field names. Mirrors public/contact.html.
419                        -->
420                        <div aria-hidden="true" style="position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden;">
421                            <label for="reg-website">Website (leave blank)</label>
422                            <input id="reg-website" type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
423                        </div>
424
425                        <button id="register-submit" class="lr-btn lr-btn-primary" type="button" onclick="onSubmitRegistration()" aria-busy="false">
426                            <i id="register-submit-spinner" class="ph ph-circle-notch lr-btn-spinner" aria-hidden="true" hidden></i>
427                            <span id="register-submit-label">Create account</span>
428                        </button>
429
430                        <p class="lr-captcha-notice">
431                            <span class="lr-captcha-help">
432                                Having trouble creating your account?
433                                <a href="mailto:[email protected]?subject=Help%20creating%20my%20account">Email support</a>.
434                            </span>
435                        </p>
436
437                        <p class="lr-alt-link">
438                            Already have an account? <a href="/login">Sign in</a>
439                        </p>
440                    </div>
441
442                    <!-- STEP 2A — SSO redirect (no form, IdP makes the account) -->
443                    <div id="step-2-sso" class="step" hidden>
444                        <div class="page-hero">
445                            <div id="sso-mark" class="page-hero-mark page-hero-mark--circle">M</div>
446                            <h1 id="sso-name" class="page-hero-name">Network Name</h1>
447                            <div id="sso-email" class="page-hero-tag"></div>
448                        </div>
449
450                        <p class="sso-context">
451                            Your account is created when you sign in through your network.
452                        </p>
453
454                        <div id="sso-choice-panel" class="lr-inline-card" hidden>
455                            <p class="lr-inline-card-title">How do you want to continue?</p>
456                            <p class="lr-inline-card-copy">This network supports both SSO members and guest registration.</p>
457                            <div class="lr-inline-actions">
458                                <button id="sso-member-btn" type="button" class="lr-btn lr-btn-primary" onclick="continueWithSsoMember()">Continue with SSO</button>
459                                <button id="sso-guest-btn" type="button" class="lr-btn lr-btn-outlined" onclick="continueAsSsoGuest()">Register as guest</button>
460                            </div>
461                        </div>
462
463                        <div id="sso-status" class="sso-status" hidden>
464                            <p class="sso-status-msg" role="status" aria-live="polite" aria-atomic="true">
465                                Redirecting to single sign-on in
466                                <span id="sso-countdown" class="sso-countdown-num">5</span>s
467                            </p>
468                            <div class="sso-progress" aria-hidden="true">
469                                <div class="sso-progress-fill" id="sso-progress-fill"></div>
470                            </div>
471                        </div>
472
473                        <button id="sso-redirect-now" type="button" class="lr-btn lr-btn-primary" onclick="redirectNow()" hidden>Redirect now</button>
474
475                        <div id="sso-or" class="sso-or">or</div>
476
477                        <div class="sso-alt-links" id="sso-back-row">
478                            <button type="button" class="back-link" onclick="backToStep1()">
479                                <i class="ph ph-arrow-left"></i> Use a different network
480                            </button>
481                        </div>
482                    </div>
483
484                    <div id="step-success" class="step" hidden>
485                        <div class="page-hero">
486                            <div class="page-hero-mark page-hero-mark--logo">
487                                <img src="/public/_assets/imleagues-design/iml-logo-mark.svg" alt="IMLeagues">
488                            </div>
489                            <h1 class="page-hero-name">Check your email</h1>
490                            <div class="page-hero-tag">We sent an activation link to <span id="success-email"></span>.</div>
491                        </div>
492
493                        <div id="success-pending-card" class="lr-inline-card" hidden>
494                            <p class="lr-inline-card-title">Join request sent</p>
495                            <p class="lr-inline-card-copy" id="success-pending-copy"></p>
496                        </div>
497
498                        <div class="lr-success-panel">
499                            <p class="lr-inline-card-copy">Open the message, activate your account, then come back here to sign in.</p>
500                            <div class="lr-success-actions lr-success-actions--register">
501                                <button type="button" class="lr-btn lr-btn-primary" onclick="resendActivationEmail()">Resend activation email</button>
502                                <a href="/login" class="lr-btn lr-btn-outlined">Back to sign in</a>
503                            </div>
504                            <p id="success-resend-msg" class="lr-muted-note" hidden></p>
505                        </div>
506                    </div>
507
508                </div>
509            </div>
510
511            
512            <section class="lr-ac" aria-labelledby="ac-heading">
513                <div class="lr-ac-inner">
514                    <h2 class="lr-ac-h2" id="ac-heading">Getting started takes about a minute</h2>
515                    <p class="lr-ac-lede">
516                        IMLeagues is the campus recreation platform for more than 2,000 college,
517                        university, and organization networks. One free account connects you to
518                        everything your rec department runs.
519                    </p>
520
521                    <ol class="lr-ac-steps" role="list">
522                        <li class="lr-ac-step">
523                            <div>
524                                <h3 class="lr-ac-item-title">Find your network</h3>
525                                <p class="lr-ac-item-copy">Search for your school or organization so your account is connected to the right rec department.</p>
526                            </div>
527                        </li>
528                        <li class="lr-ac-step">
529                            <div>
530                                <h3 class="lr-ac-item-title">Create your account</h3>
531                                <p class="lr-ac-item-copy">Use your school email address. We&rsquo;ll send an activation link to confirm it&rsquo;s you.</p>
532                            </div>
533                        </li>
534                        <li class="lr-ac-step">
535                            <div>
536                                <h3 class="lr-ac-item-title">Start playing</h3>
537                                <p class="lr-ac-item-copy">Join leagues, register for classes, and book facilities right away &mdash; on the web or in the IMLeagues mobile app.</p>
538                            </div>
539                        </li>
540                    </ol>
541
542                    <h3 class="lr-ac-h3">What your account includes</h3>
543                    <div class="lr-ac-grid">
544                        <div class="lr-ac-item">
545                            <div class="lr-ac-item-icon"><i class="ph ph-basketball" aria-hidden="true"></i></div>
546                            <div>
547                                <h3 class="lr-ac-item-title">Intramural &amp; club sports</h3>
548                                <p class="lr-ac-item-copy">Join a team or sign up as a free agent, then follow your schedule, standings, and playoff brackets all season.</p>
549                            </div>
550                        </div>
551                        <div class="lr-ac-item">
552                            <div class="lr-ac-item-icon"><i class="ph ph-barbell" aria-hidden="true"></i></div>
553                            <div>
554                                <h3 class="lr-ac-item-title">Fitness &amp; training</h3>
555                                <p class="lr-ac-item-copy">Reserve group fitness classes, book personal training, and keep track of every session you&rsquo;re registered for.</p>
556                            </div>
557                        </div>
558                        <div class="lr-ac-item">
559                            <div class="lr-ac-item-icon"><i class="ph ph-calendar-check" aria-hidden="true"></i></div>
560                            <div>
561                                <h3 class="lr-ac-item-title">Facilities &amp; equipment</h3>
562                                <p class="lr-ac-item-copy">Reserve courts, fields, and spaces, rent a locker, and check out gear from your rec center.</p>
563                            </div>
564                        </div>
565                        <div class="lr-ac-item">
566                            <div class="lr-ac-item-icon"><i class="ph ph-identification-card" aria-hidden="true"></i></div>
567                            <div>
568                                <h3 class="lr-ac-item-title">Membership &amp; check-in</h3>
569                                <p class="lr-ac-item-copy">Buy or renew passes, manage your membership, and check in at the door with your phone or campus card.</p>
570                            </div>
571                        </div>
572                    </div>
573
574                    <div class="lr-ac-help">
575                        <h3 class="lr-ac-help-title">Already have an account?</h3>
576                        <ul class="lr-ac-links" role="list">
577                            <li><a href="/login">Sign in</a></li>
578                            <li><a href="/forgot-password">Reset your password</a></li>
579                            <li><a href="/faq">Read the FAQ</a></li>
580                            <li><a href="/contact">Contact support</a></li>
581                        </ul>
582                    </div>
583
584                    <nav class="lr-ac-nav" aria-label="About IMLeagues">
585                        <ul class="lr-ac-links" role="list">
586                            <li><a href="/about">About us</a></li>
587                            <li><a href="/solutions">Solutions</a></li>
588                            <li><a href="/networks">Networks</a></li>
589                            <li><a href="/testimonials">Testimonials</a></li>
590                            <li><a href="/mobile">Mobile app</a></li>
591                            <li><a href="/integrations">Integrations</a></li>
592                        </ul>
593                        <p class="lr-ac-legal">
594                            <a href="/privacy">Privacy policy</a> &middot;
595                            <a href="/terms">Terms of use</a> &middot;
596                            <a href="/dataprotection">Data protection</a> &middot;
597                            <a href="/accessibility">Accessibility</a>
598                        </p>
599                    </nav>
600                </div>
601            </section>
602
603        </div>
604
605        <!-- RIGHT — brand panel -->
606        <div class="lr-brand-side">
607            <div class="lr-brand-content">
608                <img src="/public/_assets/imleagues-design/iml-logo-outline.png" alt="" class="brand-logo">
609                <h2 class="lr-brand-h2">Join <span class="accent">IMLeagues</span></h2>
610                <p class="lr-brand-p">One account, every league, fitness class, court reservation, and check-in at your network.</p>
611            </div>
612
613            
614            <div class="ad-brand">
615                <div class="ad-stack">
616                    <span class="ad-label">Advertisement</span>
617                    <div id="iml_p_right"></div>
618                </div>
619            </div>
620        </div>
621
622    </div>
623
624    
624<script>
625        // Analytics is optional. auth-analytics.js (deferred, in the head) defines
626        // window.imlTrack, but a content blocker, a failed asset fetch, or a submit
627        // that lands before the deferred script has executed leaves it undefined —
628        // and a bare imlTrack() call then throws inside the handler that made it,
629        // taking the rest of that handler down with it. Pre-define a no-op so every
630        // call site is safe; auth-analytics.js overwrites it with the real function
631        // when it runs, and calls resolve window.imlTrack at call time.
632        if (typeof window.imlTrack !== 'function') window.imlTrack = function () { };
633
634        // Locked-network state. Wired client-side via the SPA's existing
635        // Account.RegisterBO.Initialize endpoint in phase 1.5 — for now,
636        // page renders the generic IMLeagues hero on first paint even when
637        // ?SchID= is in the URL.
638        // TODO(phase-1.5): fetch via Account.RegisterBO.Initialize to
639        // populate { id, name, initial, color, sso } AND the per-network
640        // form rules (waiver, custom questions, hide-birth-month-day,
641        // hide-status-grad-year, non-binary toggle).
642        const _qs = new URLSearchParams(window.location.search);
643        const IML_SCHOOL_ID = _qs.get('SchID') || _qs.get('SchId') || '';
644        const IML_MAIL_INVITATION_ID = _qs.get('MailInvitationId') || '';
645        const IML_INVITE_USER = _qs.get('user') || '';
646        const IML_EVENT_ID = _qs.get('eventId') || '';
647        const IML_INVITE_TYPE = _qs.get('inviteType') || '';
648        const IML_INVITE_TOKEN = _qs.get('token') || '';
649        let IML_LOCKED = null;
650        // Per-network registration rules fetched by init() from
651        // Account.RegisterBO.Initialize. Null = no locked network, fall
652        // back to DEFAULT_RULES.
653        let IML_LOCKED_RULES = null;
654
655        // Fallback rules used when no network is detected (community account /
656        // unknown email). Phase 1.5 will replace this with rules pulled from
657        // RegisterBO.Initialize when a network is detected.
658        const DEFAULT_RULES = {
659            nonBinary: true,
660            waiver: false,
661            customQuestions: [],
662            showStatus: false,
663            hideBirthMonthDay: false,
664            name: 'IMLeagues',
665            statusTitle: 'Status',
666            statusList: [],
667            gradYearList: [],
668            gradYearForStatus: {},
669            birthYearList: [],
670            birthMonthList: [],
671            birthDayList: [],
672            customRegistrationInfo: '',
673            privateAccess: false,
674            schoolDomainString: '',
675            optInIMLeaguesNewsEnabled: false,
676            isSsoAllowGuests: false,
677            ssoGuestsName: 'Guest',
678            ssoMembersName: 'Member',
679            ssoUrl: '',
680            // IM-MemberAddress: no network detected -> never collect an address.
681            addressRegistrationMode: 0,
682            addressDefaultCountry: 'US',
683            addressCountries: [],
684            addressUsStates: [],
685            addressCanadaProvinces: [],
686        };
687
688        const STATUS_OPTIONS = ['Student', 'Faculty', 'Staff', 'Alumni', 'Other'];
689        const GRAD_YEAR_START = new Date().getFullYear() - 4;
690        const GRAD_YEAR_END = new Date().getFullYear() + 8;
691        const BIRTH_YEAR_START = 1940;
692        const BIRTH_YEAR_END = new Date().getFullYear() - 5;
693        const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
694
695        let lockedSchool = IML_LOCKED;
696        let currentNet = IML_LOCKED;
697        let registerSuccessEmail = '';
698        let multiNetworkMatches = [];
699        let currentSsoMeta = {
700            allowGuests: false,
701            guestsName: 'Guest',
702            membersName: 'Member',
703            ssoUrl: ''
704        };
705
706        // ====================== Anti-automation ======================
707        // Turnstile was removed from this page (2026-08). It blocked real users
708        // whenever the third-party script was slow or filtered, and it was
709        // skippable server-side by omitting one JSON field, so it cost humans a
710        // lot and bots nothing.
711        //
712        // What replaces it is invisible to real people and enforced in
713        // RegisterBO.CreateAccount:
714        //   1. the off-screen "website" honeypot input above,
715        //   2. this render timestamp -> elapsedMs (a human cannot complete the
716        //      form in under RegistrationThrottle.MinFillMs), and
717        //   3. per-email + per-IP rate limits (RegistrationThrottle).
718        //
719        // Do NOT add a client-side hard block here. The whole point is that a
720        // failure of any anti-abuse control must never stop a real person from
721        // creating an account before the request even reaches the server.
722        const REG_RENDERED_AT = Date.now();
723
724        // ====================== Dev-host CSRF bypass ======================
725        // See LoginNew.aspx for the full rationale. Briefly: the legacy
726        // AjaxRequestHandler.ashx CSRF check rejects Referer hosts that
727        // don't end in localhost/zdlocal/imlocal/SiteDomain. On dev hosts
728        // (duckdns etc.) we strip the Referer so the check is skipped.
729        function _imlIsAllowedHost(h) {
730            return h === 'localhost' || h.endsWith('.localhost')
731                || h.endsWith('imleagues.com')
732                || h.endsWith('zdlocal.com')
733                || h.endsWith('imlocal.com');
734        }
735        const _imlStripReferer = !_imlIsAllowedHost(location.hostname);
736        function _imlFetchOpts(opts) {
737            opts = opts || {};
738            if (_imlStripReferer) opts.referrerPolicy = 'no-referrer';
739            return opts;
740        }
741
742        // ====================== Error display ======================
743        // Two error channels:
744        //   1. Banner (#form-alert)        — non-field errors (network
745        //                                    failure, backend rejection,
746        //                                    multi-network match, etc.).
747        //   2. Field-level (per .lr-field) — client-side validation that
748        //                                    points to a specific input.
749        // When 2+ field errors exist, a summary list is added to the
750        // banner with jump-links to each failed field.
751        function showError(msg) {
752            const el = document.getElementById('form-alert');
753            const msgEl = document.getElementById('form-alert-msg');
754            msgEl.textContent = msg;
755            // Clear any prior summary list (only on banner-only errors).
756            const list = document.getElementById('form-alert-summary');
757            if (list) list.remove();
758            el.hidden = false;
759            el.scrollIntoView({ behavior: 'smooth', block: 'center' });
760            requestAnimationFrame(() => el.focus({ preventScroll: true }));
761        }
762        function clearError() {
763            document.getElementById('form-alert').hidden = true;
764            const list = document.getElementById('form-alert-summary');
765            if (list) list.remove();
766        }
767
768        function setRegistrationSubmitState(isSubmitting, labelText) {
769            const btn = document.getElementById('register-submit');
770            const spinner = document.getElementById('register-submit-spinner');
771            const label = document.getElementById('register-submit-label');
772            if (btn) {
773                btn.disabled = isSubmitting;
774                btn.setAttribute('aria-busy', isSubmitting ? 'true' : 'false');
775            }
776            if (spinner) spinner.hidden = !isSubmitting;
777            if (label) label.textContent = labelText || (isSubmitting ? 'Creating account...' : 'Create account');
778        }
779
780        function setNetworkSelectionPending(isPending) {
781            const search = document.getElementById('network-search');
782            if (!search) return;
783            search.disabled = isPending;
784            search.setAttribute('aria-busy', isPending ? 'true' : 'false');
785        }
786
787        function getActiveRules() {
788            return (lockedSchool && IML_LOCKED_RULES) ? IML_LOCKED_RULES : DEFAULT_RULES;
789        }
790
791        function getNetworkInitial(name) {
792            return (name || '?').charAt(0).toUpperCase();
793        }
794
795        function normalizeOptionList(list, fallbackText) {
796            return (list || []).map(item => ({
797                value: item.id || item.value || item.name || item,
798                text: item.name || item.text || item.id || item || fallbackText || ''
799            }));
800        }
801
802        // Server sends the suggested grad year as a [{ id: statusId, name: gradYear }] list
803        // (RegisterBO.Initialize); the page wants it keyed by status value for lookup.
804        function normalizeGradYearForStatus(list) {
805            return (list || []).reduce((map, item) => {
806                if (item && item.id) map[String(item.id)] = String(item.name || '');
807                return map;
808            }, {});
809        }
810
811        function getCustomQuestionFieldId(questionId) {
812            return 'custom-' + questionId;
813        }
814
815        function normalizeCustomQuestionOption(option) {
816            const value = typeof option === 'object'
817                ? String(option.value || option.id || option.name || option.text || '')
818                : String(option || '');
819
820            return {
821                value: value,
822                text: typeof option === 'object'
823                    ? String(option.text || option.name || option.value || option.id || '')
824                    : value
825            };
826        }
827
828        function getCustomQuestionPreviousValues(container) {
829            const values = new Map();
830            container.querySelectorAll('[id^="custom-"]').forEach((el) => {
831                values.set(el.id, el.value || '');
832            });
833            return values;
834        }
835
836        function getCustomQuestionAnswer(questionId) {
837            const el = document.getElementById(getCustomQuestionFieldId(questionId));
838            return el ? String(el.value || '').trim() : '';
839        }
840
841        function collectCustomQuestionAnswers(questions) {
842            return (questions || []).map(q => ({
843                id: q.id,
844                answer: getCustomQuestionAnswer(q.id)
845            }));
846        }
847
848        function normalizeInitRules(data, schoolName) {
849            return {
850                name: schoolName || 'IMLeagues',
851                isSsoAllowGuests: !!data.isSsoAllowGuests,
852                ssoGuestsName: data.ssoGuestsName || 'Guest',
853                ssoMembersName: data.ssoMembersName || 'Member',
854                ssoUrl: data.ssoUrl || '',
855                nonBinary: !data.removeNonBinaryOption,
856                showStatus: !data.hideStatusAndGradyear,
857                hideBirthMonthDay: !!data.hideBirthMonthDay,
858                waiver: false,
859                customQuestions: (data.customQuestionList || []).map(q => ({
860                    id: q.id,
861                    label: q.title,
862                    type: q.type === 1 ? 'select' : 'text',
863                    options: (q.values || []).map(normalizeCustomQuestionOption)
864                })),
865                statusTitle: data.statusTitle || 'Status',
866                statusList: normalizeOptionList(data.statusList || []),
867                gradYearList: normalizeOptionList(data.gradYearList || []),
868                gradYearForStatus: normalizeGradYearForStatus(data.gradYearForStatusList),
869                birthYearList: (data.birthYearList || []).map(v => String(v)),
870                birthMonthList: (data.birthMonthList || []).map(v => String(v)),
871                birthDayList: (data.birthDayList || []).map(v => String(v)),
872                customRegistrationInfo: data.customRegistrationInfo || '',
873                privateAccess: !!data.privateAccess,
874                schoolDomainString: data.schoolDomainString || '',
875                optInIMLeaguesNewsEnabled: !!data.optInIMLeaguesNewsEnabled,
876                // IM-MemberAddress. The ASHX serializes every OutVO through
877                // CamelCasePropertyNamesContractResolver, so CreateAccountOutVO's
878                // AddressRegistrationMode / AddressDefaultCountry / AddressCountries /
879                // AddressUsStates / AddressCanadaProvinces arrive camelCased, and each
880                // AddressOptionOutVO arrives as { code, name } — same convention as
881                // hideBirthMonthDay above. The lists are only filled when the mode is not Off.
882                addressRegistrationMode: parseInt(data.addressRegistrationMode, 10) || 0,
883                addressDefaultCountry: data.addressDefaultCountry || 'US',
884                addressCountries: normalizeAddressOptions(data.addressCountries),
885                addressUsStates: normalizeAddressOptions(data.addressUsStates),
886                addressCanadaProvinces: normalizeAddressOptions(data.addressCanadaProvinces)
887            };
888        }
889
890        // IM-MemberAddress: AddressOptionOutVO { code, name } -> fillSelect's { value, text }.
891        function normalizeAddressOptions(list) {
892            return (list || []).map(o => ({
893                value: String((o && o.code) || ''),
894                text: String((o && o.name) || (o && o.code) || '')
895            })).filter(o => o.value);
896        }
897
898        function fillSelect(select, placeholderText, values, selectedValue) {
899            if (!select) return;
900            const currentValue = selectedValue !== undefined ? String(selectedValue || '') : String(select.value || '');
901            select.innerHTML = '';
902            const blank = document.createElement('option');
903            blank.value = '';
904            blank.textContent = placeholderText;
905            select.appendChild(blank);
906            (values || []).forEach(item => {
907                const option = document.createElement('option');
908                const value = typeof item === 'object' ? String(item.value || '') : String(item);
909                option.value = value;
910                option.textContent = typeof item === 'object' ? (item.text || value) : value;
911                if (value === currentValue) option.selected = true;
912                select.appendChild(option);
913            });
914        }
915
916        function populateDropdownsFromRules(rules) {
917            const activeRules = rules || getActiveRules();
918            fillSelect(document.getElementById('status'), 'Select status', activeRules.statusList.length ? activeRules.statusList : normalizeOptionList(STATUS_OPTIONS.map(v => ({ value: v.toLowerCase(), text: v }))), document.getElementById('status').value);
919            fillSelect(document.getElementById('grad-year'), 'Year', activeRules.gradYearList.length ? activeRules.gradYearList : Array.from({ length: GRAD_YEAR_END - GRAD_YEAR_START + 1 }, (_, idx) => ({ value: String(GRAD_YEAR_START + idx), text: String(GRAD_YEAR_START + idx) })), document.getElementById('grad-year').value);
920            fillSelect(document.getElementById('birth-month'), 'Month', activeRules.birthMonthList.length ? activeRules.birthMonthList.map(v => ({ value: v, text: MONTHS[Math.max(parseInt(v, 10) - 1, 0)] || v })) : MONTHS.map((m, idx) => ({ value: String(idx + 1), text: m })), document.getElementById('birth-month').value);
921            fillSelect(document.getElementById('birth-day'), 'Day', activeRules.birthDayList.length ? activeRules.birthDayList.map(v => ({ value: v, text: v })) : Array.from({ length: 31 }, (_, idx) => ({ value: String(idx + 1), text: String(idx + 1) })), document.getElementById('birth-day').value);
922            fillSelect(document.getElementById('birth-year'), 'Year', activeRules.birthYearList.length ? activeRules.birthYearList.map(v => ({ value: v, text: v })) : Array.from({ length: BIRTH_YEAR_END - BIRTH_YEAR_START + 1 }, (_, idx) => ({ value: String(BIRTH_YEAR_END - idx), text: String(BIRTH_YEAR_END - idx) })), document.getElementById('birth-year').value);
923        }
924
925        // ====================== IM-MemberAddress ======================
926        // Client mirror of imLeagues/Utils/Members/MemberAddressHelper.cs. The
927        // server is authoritative and re-checks everything below; this only keeps
928        // the member from a round trip. Every rule keys on the SELECTED country,
929        // never the network's — the network's country is only the initial
930        // pre-selection (rules.addressDefaultCountry).
931        const ADDRESS_MODE_OFF = 0;
932        const ADDRESS_MODE_REQUIRED = 2;
933        const ADDRESS_US_ZIP = /^\d{5}
933(-\d{4})?$/;
934        const ADDRESS_US_ZIP_NINE = /^\d{5}[\s-]*\d{4}$/;
935        const ADDRESS_CA_POSTAL = /^[A-Za-z]\d[A-Za-z][\s-]?\d[A-Za-z]\d$/;
936
937        function isAddressUs(country) { return String(country || '').toUpperCase() === 'US'; }
938        function isAddressCanada(country) { return String(country || '').toUpperCase() === 'CA'; }
939        function addressHasSubdivisionList(country) { return isAddressUs(country) || isAddressCanada(country); }
940
941        function getAddressSubdivisions(rules, country) {
942            if (isAddressUs(country)) return (rules && rules.addressUsStates) || [];
943            if (isAddressCanada(country)) return (rules && rules.addressCanadaProvinces) || [];
944            return [];
945        }
946
947        // Swaps the State control between a dropdown (US/CA) and free text, relabels
948        // it and the postal box, and keeps the label's `for` pointing at whichever
949        // control is visible. `keepState` preserves the value on first paint; a real
950        // country change clears it (D8 — the old subdivision means nothing elsewhere).
951        function applyAddressCountryRules(rules, country, keepState) {
952            const stateSelect = document.getElementById('address-state');
953            const stateText = document.getElementById('address-state-text');
954            const stateLabel = document.getElementById('address-state-label');
955            const zipLabel = document.getElementById('address-zip-label');
956            if (!stateSelect || !stateText || !stateLabel || !zipLabel) return;
957
958            const useList = addressHasSubdivisionList(country);
959            const keptSelect = keepState ? stateSelect.value : '';
960            const keptText = keepState ? stateText.value : '';
961
962            stateLabel.textContent = isAddressUs(country)
963                ? 'State'
964                : isAddressCanada(country) ? 'Province or territory' : 'State / province / region';
965            zipLabel.textContent = isAddressUs(country) ? 'ZIP code' : 'Postal code';
966
967            if (useList) {
968                fillSelect(stateSelect, isAddressCanada(country) ? 'Select province' : 'Select state',
969                    getAddressSubdivisions(rules, country), keptSelect);
970                if (!keepState) stateSelect.value = '';
971                stateText.value = keepState ? keptText : '';
972            } else {
973                stateText.value = keepState ? keptText : '';
974                stateSelect.value = '';
975            }
976            stateSelect.hidden = !useList;
977            stateText.hidden = useList;
978            stateLabel.setAttribute('for', useList ? 'address-state' : 'address-state-text');
979        }
980
981        function onAddressCountryChange() {
982            const rules = getActiveRules();
983            // Changing the country clears the State field and re-validates the postal
984            // code under the new country's rules; street and city are kept.
985            applyAddressCountryRules(rules, document.getElementById('address-country').value, false);
986            clearFieldError('address-state');
987            clearFieldError('address-state-text');
988            clearFieldError('address-zip');
989        }
990
991        function applyAddressRules(rules) {
992            const block = document.getElementById('address-block');
993            if (!block) return;
994            const mode = (rules && rules.addressRegistrationMode) || ADDRESS_MODE_OFF;
995            if (mode === ADDRESS_MODE_OFF) {
996                block.hidden = true;
997                return;
998            }
999            block.hidden = false;
1000
1001            const countrySelect = document.getElementById('address-country');
1002            const defaultCountry = (rules && rules.addressDefaultCountry) || 'US';
1003            const selected = countrySelect.value || defaultCountry;
1004            fillSelect(countrySelect, 'Select country', (rules && rules.addressCountries) || [], selected);
1005            // fillSelect only marks an option when the value is in the list; if the
1006            // network's default is missing from it, fall back to the blank entry.
1007            countrySelect.value = selected;
1008            applyAddressCountryRules(rules, countrySelect.value, true);
1009        }
1010
1011        function collectAddressState(rules) {
1012            const block = document.getElementById('address-block');
1013            const shown = !!block && !block.hidden;
1014            if (!shown) {
1015                return { shown: false, mode: ADDRESS_MODE_OFF, street: '', city: '', state: '', country: '', zip: '', stateFieldId: 'address-state' };
1016            }
1017            const country = document.getElementById('address-country').value || '';
1018            const useList = addressHasSubdivisionList(country);
1019            return {
1020                shown: true,
1021                mode: (rules && rules.addressRegistrationMode) || ADDRESS_MODE_OFF,
1022                street: (document.getElementById('address-street').value || '').trim(),
1023                city: (document.getElementById('address-city').value || '').trim(),
1024                state: ((useList ? document.getElementById('address-state').value : document.getElementById('address-state-text').value) || '').trim(),
1025                country: country,
1026                zip: (document.getElementById('address-zip').value || '').trim(),
1027                stateFieldId: useList ? 'address-state' : 'address-state-text'
1028            };
1029        }
1030
1031        // Pushes into the caller's `errors` array, same shape as every other field check.
1032        function validateAddressState(address, errors) {
1033            if (!address || !address.shown) return;
1034            const country = address.country;
1035            const stateLabel = isAddressUs(country)
1036                ? 'State'
1037                : isAddressCanada(country) ? 'Province or territory' : 'State / province / region';
1038            const zipLabel = isAddressUs(country) ? 'ZIP code' : 'Postal code';
1039
1040            if (address.mode === ADDRESS_MODE_REQUIRED) {
1041                // "Complete" = street + city + country, plus state and postal code
1042                // only when the country is US or Canada.
1043                if (!address.street) errors.push({ fieldId: 'address-street', label: 'Street address', message: 'Enter your street address.' });
1044                if (!address.city) errors.push({ fieldId: 'address-city', label: 'City', message: 'Enter your city.' });
1045                if (!country) errors.push({ fieldId: 'address-country', label: 'Country', message: 'Select your country.' });
1046                if (addressHasSubdivisionList(country) && !address.state) {
1047                    errors.push({ fieldId: address.stateFieldId, label: stateLabel, message: isAddressCanada(country) ? 'Select your province or territory.' : 'Select your state.' });
1048                }
1049                if (addressHasSubdivisionList(country) && !address.zip) {
1050                    errors.push({ fieldId: 'address-zip', label: zipLabel, message: isAddressCanada(country) ? 'Enter your postal code.' : 'Enter your ZIP code.' });
1051                }
1052            }
1053
1054            // Format is checked whenever a US/CA postal code was typed, Required or not.
1055            if (address.zip && isAddressUs(country) && !ADDRESS_US_ZIP.test(address.zip) && !ADDRESS_US_ZIP_NINE.test(address.zip)) {
1056                errors.push({ fieldId: 'address-zip', label: zipLabel, message: 'Enter a ZIP code as 12345 or 12345-6789.' });
1057            }
1058            if (address.zip && isAddressCanada(country) && !ADDRESS_CA_POSTAL.test(address.zip)) {
1059                errors.push({ fieldId: 'address-zip', label: zipLabel, message: 'Enter a postal code as A1A 1A1.' });
1060            }
1061        }
1062
1063        function hideJoinRequestPanel() {
1064            document.getElementById('join-request-panel').hidden = true;
1065            document.getElementById('join-request-message').value = '';
1066            document.getElementById('join-request-domains').innerHTML = '';
1067        }
1068
1069        // Splits RegisterBO's comma-joined domain payload into clean entries.
1070        // Strips one trailing dot per entry: older servers suffix the joined
1071        // list with a sentence period, which otherwise renders as part of the
1072        // last chip ("mail.bgsu.edu.").
1073        function parseDomainList(domains) {
1074            return String(domains || '').split(',')
1075                .map(v => v.trim().replace(/\.$/, ''))
1076                .filter(Boolean);
1077        }
1078
1079        // Returns whether the network has any approved domains, so the caller
1080        // can match its banner copy to the state the panel is showing.
1081        function showJoinRequestPanel(domains) {
1082            const panel = document.getElementById('join-request-panel');
1083            const list = document.getElementById('join-request-domains');
1084            const title = document.getElementById('join-request-title');
1085            const copy = document.getElementById('join-request-copy');
1086            const altBtn = document.getElementById('join-request-alt-btn');
1087            const domainList = parseDomainList(domains);
1088            const hasDomains = domainList.length > 0;
1089            list.innerHTML = '';
1090            domainList.forEach(domain => {
1091                const chip = document.createElement('span');
1092                chip.className = 'lr-chip';
1093                chip.textContent = domain;
1094                list.appendChild(chip);
1095            });
1096            list.hidden = !hasDomains;
1097            // No approved domains = a private network that rejects every
1098            // email, so "use your network email" (and the "different email"
1099            // escape button) would be advice nobody can follow.
1100            if (altBtn) altBtn.hidden = !hasDomains;
1101            if (title) title.textContent = hasDomains
1102                ? "This email doesn't match the network's approved domains"
1103                : 'This network requires approval to join';
1104            if (copy) copy.textContent = hasDomains
1105                ? 'Use an email ending in one of the domains below, or send a join request with a quick note.'
1106                : 'This network is private, so a network admin approves each new member. Send a join request with a quick note.';
1107            panel.hidden = false;
1108            return hasDomains;
1109        }
1110
1111        function hideMultiNetworkPanel() {
1112            multiNetworkMatches = [];
1113            const panel = document.getElementById('multi-network-panel');
1114            const list = document.getElementById('multi-network-list');
1115            if (list) list.innerHTML = '';
1116            if (panel) panel.hidden = true;
1117        }
1118
1119        function showMultiNetworkPanel(matches) {
1120            multiNetworkMatches = matches || [];
1121            const panel = document.getElementById('multi-network-panel');
1122            const list = document.getElementById('multi-network-list');
1123            list.innerHTML = '';
1124            multiNetworkMatches.forEach(match => {
1125                const button = document.createElement('button');
1126                button.type = 'button';
1127                button.className = 'lr-choice-card';
1128                const color = match.mainNavig || match.schoolMainNavig || '#6366F1';
1129                const mark = document.createElement('span');
1130                mark.className = 'lr-choice-card-mark';
1131                mark.style.backgroundColor = color;
1132                if (match.logo) {
1133                    const img = document.createElement('img');
1134                    img.src = match.logo;
1135                    img.alt = '';
1136                    img.onerror = function () { mark.textContent = getNetworkInitial(match.name); };
1137                    mark.appendChild(img);
1138                } else {
1139                    mark.textContent = getNetworkInitial(match.name);
1140                }
1141                const copy = document.createElement('span');
1142                copy.className = 'lr-choice-card-copy';
1143                copy.innerHTML = '<span class="lr-choice-card-label"></span><span class="lr-choice-card-meta"></span>';
1144                copy.querySelector('.lr-choice-card-label').textContent = match.name || 'Network';
1145                copy.querySelector('.lr-choice-card-meta').textContent = match.ssoEnabled ? 'Single sign-on' : 'Standard registration';
1146                button.appendChild(mark);
1147                button.appendChild(copy);
1148                button.onclick = function () { onMatchedNetworkChoice(match); };
1149                list.appendChild(button);
1150            });
1151            panel.hidden = false;
1152        }
1153
1154        // ====================== Email → network match ======================
1155        // Personal-account path only. The picker already knows the network
1156        // and ?SchID= locks it; everything else — "Create an independent
1157        // account", or an invite link with no network — arrives on step 2
1158        // with an email and nothing else, which is exactly when the domain
1159        // is worth asking about. Server: Account.RegisterBO.FindNetworksByEmail
1160        // (read-only, cached per domain). A failed lookup never blocks signup.
1161        //
1162        // The default is deliberately strong: nearly everyone who types an
1163        // address a network claims wants THAT network, so one confident match
1164        // is joined for them — hero, rules and networkId switch, and a slim
1165        // "You're joining …" strip carries the way out. Only a
1166        // single-sign-on network or several matches still ask: the SSO step
1167        // would pull the form away mid-typing, and nobody can guess between
1168        // two networks. While that question is open, submit is held.
1169        const _imlEmailMatch = {
1170            timer: null,         // debounce handle
1171            seq: 0,              // request counter; a stale reply is dropped
1172            shownDomain: '',     // domain the open panel (or joined strip) belongs to
1173            dismissedDomain: '', // "Create an independent account" was clicked for this domain
1174            auto: null,          // { domain, match } while a network joined from the email is active
1175            declined: null,      // { domain, match } after declining the join, so "Join … instead" can undo it
1176            matches: []
1177        };
1178        const EMAIL_MATCH_DEBOUNCE_MS = 350;
1179        // Only ask once the address could plausibly be complete; the server
1180        // validates for real. Keeps a half-typed "doug@b" from firing.
1181        const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i;
1182
1183        function emailDomainOf(email) {
1184            return email.slice(email.lastIndexOf('@') + 1).toLowerCase();
1185        }
1186
1187        // Runs when no network is set, or when the one that is set came from
1188        // this email — so a changed address can take it back again.
1189        function emailMatchApplies() {
1190            if (IML_SCHOOL_ID || document.getElementById('step-2').hidden) return false;
1191            return (!currentNet && !lockedSchool) || !!_imlEmailMatch.auto;
1192        }
1193
1194        function wireEmailNetworkLookup() {
1195            const emailEl = document.getElementById('email');
1196            if (!emailEl) return;
1197            emailEl.addEventListener('input', () => {
1198                clearTimeout(_imlEmailMatch.timer);
1199                _imlEmailMatch.timer = setTimeout(() => lookupEmailNetworks(false), EMAIL_MATCH_DEBOUNCE_MS);
1200            });
1201            // Leaving the field is the strongest "done typing" signal —
1202            // don't make the user wait out the debounce.
1203            emailEl.addEventListener('blur', () => lookupEmailNetworks(true));
1204        }
1205
1206        function lookupEmailNetworks(immediate) {
1207            if (immediate) clearTimeout(_imlEmailMatch.timer);
1208            if (!emailMatchApplies()) { hideEmailMatchPanel(); return; }
1209
1210            const email = (document.getElementById('email').value || '').trim();
1211            if (!EMAIL_SHAPE.test(email)) {
1212                // Mid-edit. A joined network stays until a different complete address is typed.
1213                if (!_imlEmailMatch.auto) hideEmailMatchPanel();
1214                return;
1215            }
1216            const domain = emailDomainOf(email);
1217
1218            if (_imlEmailMatch.auto) {
1219                if (domain === _imlEmailMatch.auto.domain) return;
1220                resetToPersonal(); // the address moved; the join came from it, so it goes too
1221            }
1222            if (_imlEmailMatch.declined && domain !== _imlEmailMatch.declined.domain) {
1223                _imlEmailMatch.declined = null;
1224                hideJoinedStrip();
1225            }
1226            if (domain === _imlEmailMatch.shownDomain) return;
1227            if (domain === _imlEmailMatch.dismissedDomain) { hideEmailMatchPanel(); return; }
1228
1229            const seq = ++_imlEmailMatch.seq;
1230            setEmailMatchStatus(true);
1231            callBO('ro', 'Account.RegisterBO', 'FindNetworksByEmail', 'EmailInVO', { email: email }, '/spa/account/registration')
1232                .then(r => {
1233                    if (seq !== _imlEmailMatch.seq) return; // superseded by a newer keystroke
1234                    setEmailMatchStatus(false);
1235                    // The field can also move on WITHOUT a newer request — mid-edit, or
1236                    // back to a domain already shown or dismissed — so a reply only
1237                    // counts while its domain is still the one in the field.
1238                    const now = (document.getElementById('email').value || '').trim();
1239                    if (!EMAIL_SHAPE.test(now) || emailDomainOf(now) !== domain) return;
1240                    const networks = (r && r.isDone && r.data && Array.isArray(r.data.networks)) ? r.data.networks : [];
1241                    if (!networks.length || !emailMatchApplies()) { hideEmailMatchPanel(); return; }
1242                    if (networks.length === 1 && !networks[0].ssoEnabled) {
1243                        joinEmailMatch(domain, networks[0], 'auto');
1244                        return;
1245                    }
1246                    renderEmailMatchPanel(domain, networks);
1247                })
1248                .catch(() => {
1249                    if (seq !== _imlEmailMatch.seq) return;
1250                    setEmailMatchStatus(false);
1251                    hideEmailMatchPanel();
1252                });
1253        }
1254
1255        function setEmailMatchStatus(visible) {
1256            const status = document.getElementById('email-match-status');
1257            if (status) status.hidden = !visible;
1258        }
1259
1260        // What picking this network means for THIS email, from the server's
1261        // canAutoJoin — the same test CreateAccount applies on submit.
1262        function emailMatchMeta(match) {
1263            if (match.ssoEnabled) return { icon: 'ph-key', text: 'Single sign-on', mod: 'sso' };
1264            if (match.canAutoJoin) return { icon: 'ph-check-circle', text: 'Instant access', mod: 'auto' };
1265            return { icon: 'ph-shield-check', text: 'Admin approval', mod: 'approval' };
1266        }
1267
1268        function emailMatchCopy(networks) {
1269            if (networks.length > 1) return 'Your email belongs to more than one network. Choose one to continue, or create an independent account.';
1270            const net = networks[0];
1271            const name = net.name || 'this network';
1272            if (net.ssoEnabled) return 'Your email belongs to ' + name + ', which uses single sign-on. Continue there to create your account.';
1273            if (net.canAutoJoin) return 'Your email belongs to ' + name + '. Join it and 
1273your account is connected to the right rec department.';
1274            return 'Your email matches ' + name + '. A network admin approves each new member — select it to send a join request.';
1275        }
1276
1277        // The asking panel: a single-sign-on network, or several networks.
1278        function renderEmailMatchPanel(domain, networks) {
1279            const panel = document.getElementById('email-match-panel');
1280            const list = document.getElementById('email-match-list');
1281            const title = document.getElementById('email-match-title');
1282            const copy = document.getElementById('email-match-copy');
1283            const domainEl = document.getElementById('email-match-domain');
1284            hideJoinedStrip();
1285            _imlEmailMatch.shownDomain = domain;
1286            _imlEmailMatch.matches = networks;
1287
1288            const single = networks.length === 1;
1289            title.textContent = single ? 'We found your network' : 'Which network is yours?';
1290            copy.textContent = emailMatchCopy(networks);
1291            domainEl.textContent = '@' + domain;
1292
1293            // The panel borrows the matched network's brand color for its glow;
1294            // with several, the first one leads.
1295            panel.style.setProperty('--match-color', networks[0].schoolMainNavig || networks[0].mainNavig || '#6366F1');
1296
1297            list.innerHTML = '';
1298            networks.forEach((match, index) => {
1299                const color = match.schoolMainNavig || match.mainNavig || '#6366F1';
1300                const button = document.createElement('button');
1301                button.type = 'button';
1302                button.className = 'lr-match-card';
1303                button.style.setProperty('--card-color', color);
1304                button.style.setProperty('--card-index', index);
1305                button.setAttribute('aria-label', 'Continue with ' + (match.name || 'this network'));
1306
1307                const mark = document.createElement('span');
1308                mark.className = 'lr-match-mark';
1309                mark.style.backgroundColor = color;
1310                renderNetworkMark(mark, match);
1311
1312                const meta = emailMatchMeta(match);
1313                const body = document.createElement('span');
1314                body.className = 'lr-match-body';
1315                body.innerHTML = '<span class="lr-match-name"></span>'
1316                    + '<span class="lr-match-meta lr-match-meta--' + meta.mod + '"><i class="ph ' + meta.icon + '" aria-hidden="true"></i><span></span></span>';
1317                body.querySelector('.lr-match-name').textContent = match.name || 'Network';
1318                body.querySelector('.lr-match-meta span').textContent = meta.text;
1319
1320                const cta = document.createElement('span');
1321                cta.className = 'lr-match-cta';
1322                cta.innerHTML = '<span>' + (single ? 'Continue' : 'Select') + '</span><i class="ph ph-arrow-right" aria-hidden="true"></i>';
1323
1324                button.appendChild(mark);
1325                button.appendChild(body);
1326                button.appendChild(cta);
1327                button.onclick = function () { onEmailMatchChoice(match); };
1328                list.appendChild(button);
1329            });
1330
1331            const wasHidden = panel.hidden;
1332            panel.hidden = false;
1333            panel.classList.remove('lr-match--nudge');
1334            if (wasHidden) {
1335                // Restart the reveal each time the panel (re)appears.
1336                panel.classList.remove('lr-match--in');
1337                void panel.offsetWidth;
1338                panel.classList.add('lr-match--in');
1339                imlTrack('signup_email_match_shown', { count: networks.length });
1340            }
1341        }
1342
1343        // Logo on the brand color when the network has one; its initial otherwise.
1344        function renderNetworkMark(mark, match) {
1345            mark.innerHTML = '';
1346            const isPlaceholderLogo = !match.logo || /NoLogo\.|NoImage\./i.test(match.logo);
1347            if (!isPlaceholderLogo) {
1348                const img = document.createElement('img');
1349                img.src = match.logo;
1350                img.alt = '';
1351                img.onerror = function () { img.remove(); mark.textContent = getNetworkInitial(match.name); };
1352                mark.appendChild(img);
1353            } else {
1354                mark.textContent = getNetworkInitial(match.name);
1355            }
1356        }
1357
1358        function hideEmailMatchPanel() {
1359            const panel = document.getElementById('email-match-panel');
1360            if (panel) { panel.hidden = true; panel.classList.remove('lr-match--in', 'lr-match--nudge'); }
1361            _imlEmailMatch.shownDomain = '';
1362            _imlEmailMatch.matches = [];
1363        }
1364
1365        // The resting strip: 'joined' (network active; the foot line leaves for an
1366        // independent account) or 'declined' (independent; the foot line rejoins).
1367        function renderJoinedStrip(match, mode) {
1368            const strip = document.getElementById('email-match-joined');
1369            const mark = document.getElementById('email-match-joined-mark');
1370            const title = document.getElementById('email-match-joined-title');
1371            const meta = document.getElementById('email-match-joined-meta');
1372            const question = document.getElementById('email-match-joined-question');
1373            const link = document.getElementById('email-match-joined-link');
1374            const color = match.schoolMainNavig || match.mainNavig || '#6366F1';
1375            const name = match.name || 'this network';
1376            const declined = mode === 'declined';
1377
1378            strip.style.setProperty('--match-color', color);
1379            strip.classList.toggle('lr-joined--declined', declined);
1380            mark.style.backgroundColor = declined ? '' : color;
1381            if (declined) { mark.innerHTML = ''; mark.textContent = getNetworkInitial(match.name); }
1382            else renderNetworkMark(mark, match);
1383
1384            title.innerHTML = declined ? 'Independent account — not connected to <strong></strong>' : 'You\u2019re joining <strong></strong>';
1385            title.querySelector('strong').textContent = name;
1386
1387            if (declined) {
1388                meta.hidden = true;
1389                question.textContent = 'Changed your mind?';
1390                link.textContent = 'Join ' + name + ' instead';
1391                link.onclick = rejoinEmailMatch;
1392            } else {
1393                const m = emailMatchMeta(match);
1394                meta.className = 'lr-match-meta lr-match-meta--' + m.mod;
1395                meta.innerHTML = '<i class="ph ' + m.icon + '" aria-hidden="true"></i><span></span>';
1396                meta.querySelector('span').textContent = m.mod === 'approval' ? 'Admin approval — goes in as a join request' : m.text;
1397                meta.hidden = false;
1398                question.textContent = 'Not signing up for ' + name + '?';
1399                link.textContent = 'Create an independent account';
1400                link.onclick = declineEmailMatch;
1401            }
1402
1403            strip.hidden = false;
1404            strip.classList.remove('lr-joined--in');
1405            void strip.offsetWidth;
1406            strip.classList.add('lr-joined--in');
1407        }
1408
1409        function hideJoinedStrip() {
1410            const strip = document.getElementById('email-match-joined');
1411            if (strip) { strip.hidden = true; strip.classList.remove('lr-joined--in'); }
1412        }
1413
1414        // Join a network the email pointed at. how: 'auto' = one confident
1415        // match, 'pick' = chosen from the panel, 'rejoin' = "Join it instead".
1416        async function joinEmailMatch(domain, match, how) {
1417            imlTrack('signup_email_match_joined', { how: how, sso: match.ssoEnabled ? 'true' : 'false', auto_join: match.canAutoJoin ? 'true' : 'false' });
1418            hideJoinedStrip();
1419            _imlEmailMatch.declined = null;
1420            _imlEmailMatch.dismissedDomain = '';
1421            // Same path as the post-submit "choose your network" panel: loads
1422            // the network's rules, swaps the hero, keeps the typed email, and
1423            // hands SSO networks to the SSO step.
1424            await onMatchedNetworkChoice(match);
1425            if (!currentNet || currentNet.sso) return;
1426            _imlEmailMatch.auto = { domain: domain, match: match };
1427            _imlEmailMatch.shownDomain = domain;
1428            refreshStep2BackLabel();
1429            renderJoinedStrip(match, 'joined');
1430        }
1431
1432        // Back to a network-less step 2 without leaving it: typed fields survive.
1433        function resetToPersonal() {
1434            currentSsoMeta = { allowGuests: false, guestsName: 'Guest', membersName: 'Member', ssoUrl: '' };
1435            currentNet = null;
1436            lockedSchool = null;
1437            IML_LOCKED_RULES = null;
1438            _imlEmailMatch.auto = null;
1439            _imlEmailMatch.shownDomain = '';
1440            refreshStep2Hero(null);
1441            applyNetworkRules(null);
1442            // showStep() is what normally clears the page accent; the join set it
1443            // to the network's brand color, and this undoes the join without a step change.
1444            setAccentColor(null);
1445            refreshStep2BackLabel();
1446            hideEmailMatchPanel();
1447            hideJoinedStrip();
1448            // A join-request panel (code 140) belongs to the network that just left.
1449            hideJoinRequestPanel();
1450        }
1451
1452        // Mirrors the label rule in showStep() without re-running the step,
1453        // which would replay the fade and count another signup_started.
1454        function refreshStep2BackLabel() {
1455            const backLabel = document.getElementById('step2-back-label');
1456            if (backLabel) backLabel.textContent = (lockedSchool || currentNet) ? 'Use a different network' : 'Pick a network instead';
1457        }
1458
1459        // Leaving the personal path (picker, back link) forgets everything.
1460        function clearEmailMatchState() {
1461            _imlEmailMatch.auto = null;
1462            _imlEmailMatch.declined = null;
1463            _imlEmailMatch.dismissedDomain = '';
1464            hideEmailMatchPanel();
1465            hideJoinedStrip();
1466        }
1467
1468        function declineEmailMatch() {   // strip: "Create an independent account"
1469            const auto = _imlEmailMatch.auto;
1470            if (!auto) return;
1471            imlTrack('signup_email_match_declined');
1472            resetToPersonal();
1473            _imlEmailMatch.dismissedDomain = auto.domain;
1474            _imlEmailMatch.declined = auto;
1475            renderJoinedStrip(auto.match, 'declined');
1476            const fname = document.getElementById('fname');
1477            if (fname) fname.focus();
1478        }
1479
1480        function rejoinEmailMatch() {    // strip: "Join it instead"
1481            const d = _imlEmailMatch.declined;
1482            if (d) joinEmailMatch(d.domain, d.match, 'rejoin');
1483        }
1484
1485        function dismissEmailMatch() {   // panel: "Create an independent account"
1486            _imlEmailMatch.dismissedDomain = _imlEmailMatch.shownDomain;
1487            imlTrack('signup_email_match_dismissed');
1488            hideEmailMatchPanel();
1489            const fname = document.getElementById('fname');
1490            if (fname) fname.focus();
1491        }
1492
1493        function onEmailMatchChoice(match) { // panel card
1494            joinEmailMatch(_imlEmailMatch.shownDomain, match, 'pick');
1495        }
1496
1497        // Submit is held while the panel's question is unanswered.
1498        function emailMatchUndecided() {
1499            const panel = document.getElementById('email-match-panel');
1500            return !!panel && !panel.hidden && !currentNet;
1501        }
1502
1503        function focusEmailMatchPanel() {
1504            const panel = document.getElementById('email-match-panel');
1505            showError('Choose your network to continue, or create an independent account.');
1506            panel.classList.remove('lr-match--nudge');
1507            void panel.offsetWidth;
1508            panel.classList.add('lr-match--nudge');
1509            panel.scrollIntoView({ behavior: _imlReduceMotion ? 'auto' : 'smooth', block: 'center' });
1510            const first = panel.querySelector('.lr-match-card');
1511            // showError() moves focus to the banner on the next animation frame; queue
1512            // after it so the card — where the answer is — is what ends up focused.
1513            // The banner's role="alert" still announces the message.
1514            if (first) requestAnimationFrame(() => first.focus({ preventScroll: true }));
1515        }
1516
1517        function showRegistrationSuccess(email, pendingApproval) {
1518            registerSuccessEmail = email || '';
1519            document.getElementById('success-email').textContent = registerSuccessEmail || 'your email';
1520            // A join request still creates a working account: the user can
1521            // activate and sign in right away, but the network stays out of
1522            // reach until an admin approves the request. Say so, or the
1523            // generic "activate and sign in" copy reads as full membership.
1524            // pendingApproval is the SERVER's verdict (r.data.pendingApproval),
1525            // not the client's asJoinRequest flag: if the user fixed their
1526            // email but still clicked "Send join request", the membership was
1527            // auto-approved and this card would be claiming a wait that will
1528            // never happen.
1529            const pendingCard = document.getElementById('success-pending-card');
1530            const pendingCopy = document.getElementById('success-pending-copy');
1531            if (pendingCard && pendingCopy) {
1532                if (pendingApproval) {
1533                    const netName = (currentNet && currentNet.name) || (lockedSch
1533ool && lockedSchool.name) || 'the network';
1534                    pendingCopy.textContent = 'We sent your request to join ' + netName + ' to its admins. '
1535                        + "You can activate your account and sign in right away — you just won't be able to see or access "
1536                        + netName + ' until an admin approves your request.';
1537                    pendingCard.hidden = false;
1538                } else {
1539                    pendingCopy.textContent = '';
1540                    pendingCard.hidden = true;
1541                }
1542            }
1543            const resendMsg = document.getElementById('success-resend-msg');
1544            resendMsg.hidden = true;
1545            resendMsg.textContent = '';
1546            showStep('step-success');
1547        }
1548
1549        function buildInitRequest(schoolId) {
1550            return {
1551                entityType: 'account',
1552                entityId: schoolId,
1553                // 'Home' (not 'Member') so AdsApi.GetAdsVO resolves entityId as
1554                // the school — its "for login and registration pages" branch —
1555                // and data.ads reflects the school's DisableAds/EnableReducedAds.
1556                pageType: 'Home',
1557                schoolId: schoolId,
1558                mailInvitationId: parseInt(IML_MAIL_INVITATION_ID || '0', 10) || 0,
1559                user: IML_INVITE_USER || ''
1560            };
1561        }
1562
1563        async function loadRegistrationContext(school) {
1564            const r = await callBO('ro', 'Account.RegisterBO', 'Initialize', 'InitRegistrationViewVO', buildInitRequest(school.id), '/spa/account/registration');
1565            if (!(r && r.isDone && r.data)) return null;
1566
1567            const data = r.data;
1568
1569            // IM-2872 parity: a selected school with ads disabled (or reduced)
1570            // tears down / downgrades the page's ad units.
1571            if (typeof window.__imlAuthAdsApply === 'function') {
1572                window.__imlAuthAdsApply(data.ads);
1573            }
1574            const lockedSchool = data.lockedSchool || {};
1575            const net = {
1576                id: lockedSchool.id || school.id,
1577                name: lockedSchool.name || school.name || 'Network',
1578                initial: getNetworkInitial(lockedSchool.name || school.name),
1579                color: lockedSchool.mainNavigColor || school.color || school.mainNavig || school.schoolMainNavig || '#6366F1',
1580                logo: lockedSchool.logo || school.logo || '',
1581                logoMedium: lockedSchool.logoMedium || '',
1582                logoLarge: lockedSchool.logoLarge || '',
1583                sso: !!data.ssoUrl,
1584                ssoUrl: data.ssoUrl || ''
1585            };
1586
1587            return {
1588                data: data,
1589                net: net,
1590                rules: normalizeInitRules(data, net.name)
1591            };
1592        }
1593
1594
1595        function setPrefilledEmail(value) {
1596            const emailInput = document.getElementById('email');
1597            if (emailInput && value && !emailInput.value) emailInput.value = value;
1598        }
1599
1600        function setActiveNetworkContext(net, rules) {
1601            currentNet = net || null;
1602            lockedSchool = net || null;
1603            IML_LOCKED_RULES = rules || null;
1604            currentSsoMeta = {
1605                allowGuests: !!(rules && rules.isSsoAllowGuests),
1606                guestsName: (rules && rules.ssoGuestsName) || 'Guest',
1607                membersName: (rules && rules.ssoMembersName) || 'Member',
1608                ssoUrl: (rules && rules.ssoUrl) || (net && net.ssoUrl) || ''
1609            };
1610        }
1611
1612        // Track the IDs of fields currently in error so we can render
1613        // an ordered summary and clear them on the next submit attempt.
1614        const _imlFieldErrors = new Map(); // id -> { label, message }
1615
1616        function showFieldError(fieldId, label, message) {
1617            const input = document.getElementById(fieldId);
1618            if (!input) return;
1619            const wrapper = input.closest('.lr-field');
1620            if (!wrapper) return;
1621            // Ensure a message slot exists inside the field wrapper.
1622            let slot = wrapper.querySelector('.lr-field-error-msg');
1623            if (!slot) {
1624                slot = document.createElement('div');
1625                slot.className = 'lr-field-error-msg';
1626                slot.id = fieldId + '-err';
1627                slot.innerHTML = '<i class="ph ph-warning-circle" aria-hidden="true"></i><span></span>';
1628                wrapper.appendChild(slot);
1629            }
1630            slot.querySelector('span').textContent = message;
1631            wrapper.classList.add('lr-field--error');
1632            input.setAttribute('aria-invalid', 'true');
1633            // Chain aria-describedby with any existing value (e.g. a help
1634            // text id) so we don't clobber it.
1635            const existing = (input.getAttribute('aria-describedby') || '')
1636                .split(/\s+/).filter(s => s && s !== slot.id);
1637            existing.push(slot.id);
1638            input.setAttribute('aria-describedby', existing.join(' '));
1639            _imlFieldErrors.set(fieldId, { label, message });
1640        }
1641
1642        function clearFieldError(fieldId) {
1643            const input = document.getElementById(fieldId);
1644            if (!input) return;
1645            const wrapper = input.closest('.lr-field');
1646            if (!wrapper) return;
1647            wrapper.classList.remove('lr-field--error');
1648            input.removeAttribute('aria-invalid');
1649            const slotId = fieldId + '-err';
1650            const existing = (input.getAttribute('aria-describedby') || '')
1651                .split(/\s+/).filter(s => s && s !== slotId);
1652            if (existing.length) input.setAttribute('aria-describedby', existing.join(' '));
1653            else input.removeAttribute('aria-describedby');
1654            _imlFieldErrors.delete(fieldId);
1655            // Auto-hide the summary banner once the user has fixed every
1656            // field error — keeps the form clean. We only hide if the
1657            // banner is showing a summary (has the jump-list); banners
1658            // showing backend/network errors stay until the next submit.
1659            if (_imlFieldErrors.size === 0) {
1660                const list = document.getElementById('form-alert-summary');
1661                if (list) document.getElementById('form-alert').hidden = true;
1662            }
1663        }
1664
1665        // Wire oninput / onchange listeners that clear a field's error
1666        // as soon as the user edits it. One-time setup at page load.
1667        function wireFieldErrorClearers() {
1668            ['email', 'fname', 'lname', 'pw', 'status', 'grad-year', 'birth-year', 'birth-month', 'birth-day', 'gender', 'real-gender',
1669             'address-street', 'address-city', 'address-country', 'address-state', 'address-state-text', 'address-zip'].forEach(id => {
1670                const el = document.getElementById(id);
1671                if (!el) return;
1672                const evt = el.tagName === 'SELECT' ? 'change' : 'input';
1673                el.addEventListener(evt, () => clearFieldError(id));
1674            });
1675        }
1676
1677        function clearAllFieldErrors() {
1678            // Snapshot keys first since clearFieldError mutates the map.
1679            Array.from(_imlFieldErrors.keys()).forEach(clearFieldError);
1680        }
1681
1682        // Render a list of errors inside the banner with jump-links to
1683        // each failed field. Called from onSubmitRegistration after
1684        // collecting all field errors. The link's focus on click is
1685        // handled by the browser via #id anchor.
1686        function showErrorSummary(errors) {
1687            const el = document.getElementById('form-alert');
1688            const msgEl = document.getElementById('form-alert-msg');
1689            // Strip any old summary first.
1690            const old = document.getElementById('form-alert-summary');
1691            if (old) old.remove();
1692            if (errors.length === 1) {
1693                msgEl.textContent = errors[0].message;
1694            } else {
1695                msgEl.innerHTML = '<span class="lr-error-summary-title">Please fix '
1696                    + errors.length + ' fields to continue:</span>';
1697                const list = document.createElement('ul');
1698                list.id = 'form-alert-summary';
1699                list.className = 'lr-error-summary-list';
1700                errors.forEach(e => {
1701                    const li = document.createElement('li');
1702                    const a = document.createElement('a');
1703                    a.href = '#' + e.fieldId;
1704                    a.textContent = e.label;
1705                    a.onclick = (ev) => {
1706                        ev.preventDefault();
1707                        const f = document.getElementById(e.fieldId);
1708                        if (f) { f.focus(); f.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
1709                    };
1710                    li.appendChild(a);
1711                    li.appendChild(document.createTextNode(' — ' + e.message));
1712                    list.appendChild(li);
1713                });
1714                msgEl.appendChild(list);
1715            }
1716            el.hidden = false;
1717            el.scrollIntoView({ behavior: 'smooth', block: 'center' });
1718            requestAnimationFrame(() => el.focus({ preventScroll: true }));
1719        }
1720
1721        // ====================== BO call helper ======================
1722        function callBO(endpoint, className, methodName, paramType, jsonData, urlReferrer) {
1723            const handler = endpoint === 'rw'
1724                ? '/Services/AjaxRequestHandlerWithWritableSession.ashx'
1725                : '/Services/AjaxRequestHandler.ashx';
1726            const url = handler
1727                + '?class=imLeagues.Web.Members.Services.BO.' + encodeURIComponent(className)
1728                + '&method=' + encodeURIComponent(methodName)
1729                + '&paramType=imLeagues.Internal.API.VO.Input.' + encodeURIComponent(paramType)
1730                + '&urlReferrer=' + encodeURIComponent(urlReferrer);
1731            return fetch(url, _imlFetchOpts({
1732                method: 'POST',
1733                credentials: 'same-origin',
1734                headers: { 'Content-Type': 'application/json' },
1735                body: JSON.stringify(jsonData || {})
1736            })).then(r => r.json());
1737        }
1738
1739        // ====================== Initial render ======================
1740        // If ?SchID= is in the URL, call Account.RegisterBO.Initialize
1741        // (read-only) to resolve school identity. Same call the SPA's
1742        // registration.html makes — registration.js line 513.
1743        async function init() {
1744            populateDropdowns();
1745            wireFieldErrorClearers();
1746            wireEmailNetworkLookup();
1747            setPrefilledEmail(IML_INVITE_USER);
1748            if (IML_SCHOOL_ID) {
1749                try {
1750                    const context = await loadRegistrationContext({ id: IML_SCHOOL_ID, name: 'Network' });
1751                    if (context) {
1752                        IML_LOCKED = context.net;
1753                        setActiveNetworkContext(context.net, context.rules);
1754                        setPrefilledEmail(context.data.email || IML_INVITE_USER);
1755                    }
1756                } catch (e) {
1757                    console.warn('Registration init school lookup failed; falling back to generic page.', e);
1758                }
1759            }
1760            // Network-first routing:
1761            //   locked + SSO   → step 2A countdown to IdP (no email needed)
1762            //   locked non-SSO → step 2 form with the locked network's rules
1763            //                    (picker step is skipped — network is pinned)
1764            //   no URL lock    → step 1 (the network picker)
1765            if (lockedSchool && lockedSchool.sso) {
1766                showStep('step-2-sso');
1767                fillSsoStep(lockedSchool, (document.getElementById('email').value || '').trim());
1768            } else if (lockedSchool) {
1769                currentNet = lockedSchool;
1770                refreshStep2Hero(currentNet);
1771                applyNetworkRules(currentNet);
1772                showStep('step-2');
1773            } else {
1774                showStep('step-1');
1775                primeNetworkPicker();
1776            }
1777        }
1778        // init() call moved to bottom of script (after all let/const
1779        // declarations) so the synchronous path doesn't hit the TDZ on
1780        // ssoCountdownTimer in cancelSsoCountdown.
1781
1782        // ====================== Hero swaps ======================
1783        // (Step 1 hero is now static IMLeagues — network selection happens
1784        //  on step 1, so there's never a locked-network state to render
1785        //  there. Step 2's hero still swaps based on whether a network is
1786        //  in play.)
1787
1788        // Hero marks are 96px (2-3x that on retina). The picker list only
1789        // carries the 60px thumbnail (logo); Initialize / InitSchoolLoginView
1790        // add the 120px (logoMedium) and full-size (logoLarge) variants, which
1791        // become a srcset so 1x screens fetch the medium file and retina
1792        // screens the original. Placeholders count as no logo.
1793        function heroLogoSources(net) {
1794            const real = (url) => (url && !/NoLogo\.|NoImage\./i.test(url)) ? url : '';
1795            const medium = real(net && net.logoMedium);
1796            const large = real(net && net.logoLarge);
1797            const thumbnail = real(net && net.logo);
1798            const src = medium || large || thumbnail;
1799            if (!src) return null;
1800            return {
1801                src: src,
1802                srcset: (medium && large) ? medium + ' 1x, ' + large + ' 2x' : '',
1803                // last onerror fallback before the initial, when the hero-size files are missing
1804                thumbnail: thumbnail !== src ? thumbnail : ''
1805            };
1806        }
1807
1808        // Renders a network's identity (logo + brand color, or initial
1809        // fallback) into the page-hero-mark element. Same logic as the
1810        // picker rows so step-1 and step-2 stay visually consistent.
1811        function renderHeroMark(markEl, net) {
1812            markEl.innerHTML = '';
1813            if (!net) return;
1814            markEl.style.backgroundColor = net.color || '';
1815            const sources = heroLogoSources(net);
1816            if (!sources) {
1817                markEl.textContent = net.initial;
1818                return;
1819            }
1820            const img = document.createElement('img');
1821            img.alt = '';
1822            img.className = 'page-hero-mark-logo';
1823            let fallbackStep = 0;
1824            img.onerror = () => {
1825                // Degrade one step at a time: drop the srcset so the 120px src
1826                // loads alone, then try the thumbnail, then show the initial.
1827                fallbackStep += 1;
1828                if (fallbackStep === 1 && sources.srcset) {
1829                    img.removeAttribute('srcset');
1830                    return;
1831                }
1832                if (fallbackStep <= 2 && sources.thumbnail) {
1833                    img.removeAttribute('srcset');
1834                    img.src = sources.thumbnail;
1835                    return;
1836                }
1837                img.remove();
1838                markEl.textContent = net.initial;
1839            };
1840            if (sources.srcset) img.srcset = sources.srcset;
1841            img.src = sources.src;
1842            markEl.appendChild(img);
1843        }
1844
1845        function refreshStep2Hero(net) {
1846            const mark = document.getElementById('step2-mark');
1847            const name = document.getElementById('step2-name');
1848            const tag = document.getElementById('step2-tag');
1849            if (net) {
1850                mark.classList.remove('page-hero-mark--logo');
1851                mark.classList.add('page-hero-mark--circle');
1852                renderHeroMark(mark, net);
1853                name.textContent = net.name;
1854                tag.textContent = 'A few details to finish up.';
1855            } else {
1856                mark.classList.remove('page-hero-mark--circle');
1857                mark.classList.add('page-hero-mark--logo');
1858                mark.style.backgroundColor = '';
1859                mark.innerHTML = '<img src="/public/_assets/imleagues-design/iml-logo-mark.svg" alt="IMLeagues">';
1860                name.textContent = 'Sign up for IMLeagues';
1861                tag.textContent = 'A few details to finish up.';
1862            }
1863        }
1864
1865        // Respect prefers-reduced-motion: skip the per-keystroke accent
1866        // preview during single-result narrowing. (Accent still applies
1867        // post-selection on step-2 / step-2-sso since that's a single
1868        // state change, not animation-from-interaction.)
1869        const _imlReduceMotion = window.matchMedia
1870            && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1871        function setAccentColor(color) {
1872            if (color) document.documentElement.style.setProperty('--accent', color);
1873            else document.documentElement.style.removeProperty('--accent');
1874        }
1875        function accentForStep(id) {
1876            // step-1 is the network picker (pre-selection) — no accent.
1877            // step-2 + step-2-sso accent to the network in play.
1878            if (id === 'step-1') return null;
1879            const net = currentNet || lockedSchool;
1880            return net ? net.color : null;
1881        }
1882
1883        // ====================== Network picker (step 1) ======================
1884        // Network-first flow: step 1 IS the picker. Picking a school routes
1885        // straight to step-2-sso (SSO networks) or step-2 (form with the
1886        // network's rules applied). No email-domain detection here — email
1887        // is collected on step 2 as part of the registration form.
1888        // Lazy-loaded full school list (cached for the session) + simple
1889        // case-insensitive client-side name filter. Calls Account.LoginBO
1890        // .GetNonHiddenSchoolList — same endpoint the SPA registration
1891        // uses for its school dropdown (registration.js:532).
1892        let _imlAllSchools = null;
1893        let _imlSchoolFetchPromise = null;
1894        function ensureSchoolList() {
1895            if (_imlAllSchools) return Promise.resolve(_imlAllSchools);
1896            if (_imlSchoolFetchPromise) return _imlSchoolFetchPromise;
1897            _imlSchoolFetchPromise = callBO('rw', 'Account.LoginBO', 'GetNonHiddenSchoolList',
1898                'BaseInVO', {}, '/spa/account/registration')
1899                .then(r => {
1900                    _imlAllSchools = (r && r.isDone && r.data && r.data.schoolList)
1901                        ? r.data.schoolList : [];
1902                    return _imlAllSchools;
1903                })
1904                .catch(() => { _imlAllSchools = []; return _imlAllSchools; });
1905            return _imlSchoolFetchPromise;
1906        }
1907
1908        // Step 1 IS the network picker — kick off the school-list prefetch
1909        // as soon as init() lands so the first keystroke filters instantly.
1910        function primeNetworkPicker() {
1911            ensureSchoolList();
1912        }
1913
1914        function filterNetworkResults(query) {
1915            const q = (query || '').toLowerCase().trim();
1916            const list = _imlAllSchools || [];
1917            if (!q) return [];
1918            // Match against name + short URL slug + both email domains
1919            // so "BGSU" finds Bowling Green, "bgsu.edu" finds it too.
1920            // "Starts with" is reserved for name and short-slug since
1921            // those are canonical short names; domains often have
1922            // www./mail. prefixes that make starts-with meaningless.
1923            // The school list comes back alphabetically sorted, so we
1924            // must scan the whole thing — early-breaking on a combined
1925            // count would fill the bucket with A-L schools that happen
1926            // to *contain* "m" before reaching schools that *start*
1927            // with "m". 2k+ items is cheap per keystroke (O(n) string).
1928            const starts = [];
1929            const contains = [];
1930            for (const s of list) {
1931                const name = (s.name || '').toLowerCase();
1932                const idValue = (s.idValue || '').toLowerCase();
1933                const domain = (s.domain || '').toLowerCase();
1934                const domain2 = (s.domain2 || '').toLowerCase();
1935                if (name.startsWith(q) || idValue.startsWith(q)) {
1936                    starts.push(s);
1937                } else if (name.includes(q) || idValue.includes(q)
1938                    || domain.includes(q) || domain2.includes(q)) {
1939                    contains.push(s);
1940                }
1941            }
1942            return starts.concat(contains).slice(0, 50);
1943        }
1944
1945        function renderNetworkPrompt() {
1946            // 2,000+ networks — listing them all (or even the first 50) is
1947            // noise. Hide the results list entirely until the user types
1948            // something; the bare search input gives enough context.
1949            const ul = document.getElementById('network-results');
1950            ul.innerHTML = '';
1951            ul.classList.remove('lr-network-list--single');
1952            ul.hidden = true;
1953            // Clear any single-result accent preview.
1954            setAccentColor(null);
1955        }
1956
1957        function renderNetworkResults(schools) {
1958            const ul = document.getElementById('network-results');
1959            ul.hidden = false;
1960            ul.innerHTML = '';
1961            // When the filter narrows to exactly one match, swap to a
1962            // "confirmation card" layout so the user clearly sees we found
1963            // their pick (a normal one-row list can read as "no results").
1964            // Pressing Enter from the search input also selects this one
1965            // — see onSearchKeydown.
1966            const isSingle = !!schools && schools.length === 1;
1967            ul.classList.toggle('lr-network-list--single', isSingle);
1968            // Preview the school's color as the accent so the card glows
1969            // in their brand color. Cleared once we drop back to 0 or >1
1970            // results so the picker doesn't keep a stale tint. Skip the
1971            // preview entirely under prefers-reduced-motion (the live
1972            // color flash on each keystroke is the issue, per WCAG 2.3.3).
1973            if (isSingle && schools[0].mainNavigColor && !_imlReduceMotion) {
1974                setAccentColor(schools[0].mainNavigColor);
1975            } else if (!isSingle) {
1976                setAccentColor(null);
1977            }
1978            if (!schools || schools.length === 0) {
1979                // Empty-results fallback: keep the primary suggestion
1980                // short, then offer "browse all" as a secondary path for
1981                // users who don't know their school's exact name.
1982                const li = document.createElement('li');
1983                li.className = 'lr-network-list-empty';
1984                li.innerHTML =
1985                    'No matching networks. Try a different name, or ' +
1986                    '<a href="/networks" target="_blank" rel="noopener" class="ln-browse-all">browse all networks</a>.';
1987                ul.appendChild(li);
1988                return;
1989            }
1990            schools.forEach(s => {
1991                const li = document.createElement('li');
1992                li.setAttribute('role', 'option');
1993                li.tabIndex = 0;
1994                li.onclick = () => selectNetwork(s);
1995                li.onkeydown = (e) => {
1996                    if (e.key === 'Enter' || e.key === ' ') {
1997                        e.preventDefault();
1998                        selectNetwork(s);
1999                    }
2000                };
2001                const mark = document.createElement('div');
2002                mark.className = 'ln-initial';
2003                if (s.mainNavigColor) mark.style.backgroundColor = s.mainNavigColor;
2004                mark.textContent = (s.name || '?').charAt(0).toUpperCase();
2005                // If the network has a real logo, render it on top of the
2006                // colored chip (keeps the brand color visible behind the
2007                // logo since most college logos contain white parts meant
2008                // to sit on a brand-color background). Initial stays as
2009                // the onerror fallback.
2010                const isPlaceholderLogo = !s.logo
2011                    || /NoLogo\.|NoImage\./i.test(s.logo);
2012                if (!isPlaceholderLogo) {
2013                    const img = document.createElement('img');
2014                    img.src = s.logo;
2015                    img.alt = '';
2016                    img.className = 'ln-logo';
2017                    const initial = mark.textContent;
2018                    img.onerror = () => { img.remove(); mark.textContent = initial; };
2019                    mark.textContent = '';
2020                    mark.appendChild(img);
2021                }
2022                li.appendChild(mark);
2023                const name = document.createElement('div');
2024                name.className = 'ln-name';
2025                name.textContent = s.name;
2026                li.appendChild(name);
2027                // Single-result mode gets an Enter-to-continue hint next
2028                // to the name. Hidden by CSS when not in single mode.
2029                if (schools.length === 1) {
2030                    const hint = document.createElement('div');
2031                    hint.className = 'ln-enter-hint';
2032                    hint.innerHTML = 'Press <kbd>Enter</kbd> or click to continue';
2033                    li.appendChild(hint);
2034                }
2035                ul.appendChild(li);
2036            });
2037        }
2038
2039        // Top-left back link is context-aware:
2040        //   - On step-1 (the picker) → goes to imleagues.com
2041        //   - On step-2 / step-2-sso (any path: picked network, SSO, or
2042        //     "personal account") → hijacks the click and returns to the
2043        //     picker. Personal account also lands on step-2 with no
2044        //     network, so we trigger on step (not on "picked").
2045        //   - URL-locked (?SchID=) → leaves it alone since there's no
2046        //     picker to go back to.
2047        function onHomeLinkClick(e) {
2048            if (IML_SCHOOL_ID) return;
2049            const onStep1 = !document.getElementById('step-1').hidden;
2050            if (!onStep1) {
2051                e.preventDefault();
2052                backToStep1();
2053            }
2054        }
2055        function refreshHomeLink() {
2056            const label = document.getElementById('home-link-label');
2057            const link = document.getElementById('home-link');
2058            if (!label || !link) return;
2059            if (IML_SCHOOL_ID) return;
2059 // URL-locked — leave label as IMLeagues
2060            const onStep1 = !document.getElementById('step-1').hidden;
2061            if (!onStep1) {
2062                label.textContent = 'Back to school selection';
2063                link.setAttribute('aria-label', 'Back to school selection');
2064            } else {
2065                label.textContent = 'IMLeagues';
2066                link.setAttribute('aria-label', 'Back to IMLeagues homepage');
2067            }
2068        }
2069
2070        function onSearchKeydown(e) {
2071            // Enter on a single-result narrows-down picks the lone match
2072            // (saves a tab + enter). Escape dismisses the results list
2073            // so keyboard users aren't forced to pick or tab away.
2074            if (e.key === 'Enter') {
2075                const ul = document.getElementById('network-results');
2076                if (!ul.classList.contains('lr-network-list--single')) return;
2077                const li = ul.querySelector('li[role="option"]');
2078                if (li) { e.preventDefault(); li.click(); }
2079            } else if (e.key === 'Escape') {
2080                const search = document.getElementById('network-search');
2081                if (search.value) {
2082                    search.value = '';
2083                    renderNetworkPrompt();
2084                    e.preventDefault();
2085                }
2086            }
2087        }
2088
2089        function onNetworkSearch() {
2090            const q = document.getElementById('network-search').value;
2091            if (!q.trim()) {
2092                // User cleared the input — hide the list again rather than
2093                // flash "no matching networks" before they continue typing.
2094                renderNetworkPrompt();
2095                return;
2096            }
2097            renderNetworkResults(filterNetworkResults(q));
2098        }
2099
2100        async function selectNetwork(school) {
2101            clearError();
2102            hideJoinRequestPanel();
2103            hideMultiNetworkPanel();
2104            clearEmailMatchState();
2105            setNetworkSelectionPending(true);
2106            try {
2107                const context = await loadRegistrationContext({
2108                    id: school.id,
2109                    name: school.name,
2110                    color: school.mainNavigColor || school.color || '#6366F1',
2111                    logo: school.logo || ''
2112                });
2113                if (context) {
2114                    setActiveNetworkContext(context.net, context.rules);
2115                    setPrefilledEmail(context.data.email || IML_INVITE_USER);
2116                } else {
2117                    setActiveNetworkContext({
2118                        id: school.id,
2119                        name: school.name,
2120                        initial: getNetworkInitial(school.name),
2121                        color: school.mainNavigColor || '#6366F1',
2122                        logo: school.logo || '',
2123                        sso: false,
2124                        ssoUrl: ''
2125                    }, null);
2126                }
2127            } catch (e) {
2128                console.warn('Failed to fetch rules for picked network', e);
2129                setActiveNetworkContext({
2130                    id: school.id,
2131                    name: school.name,
2132                    initial: getNetworkInitial(school.name),
2133                    color: school.mainNavigColor || '#6366F1',
2134                    logo: school.logo || '',
2135                    sso: false,
2136                    ssoUrl: ''
2137                }, null);
2138            } finally {
2139                setNetworkSelectionPending(false);
2140            }
2141
2142            if (currentNet && currentNet.sso) {
2143                showStep('step-2-sso');
2144                fillSsoStep(currentNet, (document.getElementById('email').value || '').trim());
2145                return;
2146            }
2147
2148            refreshStep2Hero(currentNet);
2149            applyNetworkRules(currentNet);
2150            showStep('step-2');
2151        }
2152
2153        function continueWithoutNetwork() {
2154            clearError();
2155            hideJoinRequestPanel();
2156            hideMultiNetworkPanel();
2157            clearEmailMatchState();
2158            currentSsoMeta = { allowGuests: false, guestsName: 'Guest', membersName: 'Member', ssoUrl: '' };
2159            currentNet = null;
2160            lockedSchool = null;
2161            IML_LOCKED_RULES = null;
2162            refreshStep2Hero(null);
2163            applyNetworkRules(null);
2164            setPrefilledEmail(IML_INVITE_USER);
2165            showStep('step-2');
2166            // An invite link can arrive with the email already filled in.
2167            lookupEmailNetworks(true);
2168        }
2169
2170        // ====================== SSO countdown ======================
2171        function fillSsoStep(net, email) {
2172            renderHeroMark(document.getElementById('sso-mark'), net);
2173            document.getElementById('sso-name').textContent = net.name;
2174            const emailEl = document.getElementById('sso-email');
2175            emailEl.textContent = email || '';
2176            emailEl.style.display = email ? '' : 'none';
2177            const choicePanel = document.getElementById('sso-choice-panel');
2178            const statusPanel = document.getElementById('sso-status');
2179            const redirectNowBtn = document.getElementById('sso-redirect-now');
2180            const ssoOr = document.getElementById('sso-or');
2181            const memberBtn = document.getElementById('sso-member-btn');
2182            const guestBtn = document.getElementById('sso-guest-btn');
2183            if (memberBtn) memberBtn.textContent = 'Continue as ' + (currentSsoMeta.membersName || 'Member');
2184            if (guestBtn) guestBtn.textContent = 'Register as ' + (currentSsoMeta.guestsName || 'Guest');
2185
2186            if (ssoOr) ssoOr.hidden = !currentSsoMeta.allowGuests;
2187
2188            if (currentSsoMeta.allowGuests) {
2189                cancelSsoCountdown();
2190                if (choicePanel) choicePanel.hidden = false;
2191                if (statusPanel) statusPanel.hidden = true;
2192                if (redirectNowBtn) redirectNowBtn.hidden = true;
2193                document.getElementById('sso-countdown').textContent = SSO_COUNTDOWN_SECONDS;
2194                const fill = document.getElementById('sso-progress-fill');
2195                if (fill) {
2196                    fill.style.transition = 'none';
2197                    fill.style.width = '0%';
2198                }
2199            } else {
2200                if (choicePanel) choicePanel.hidden = true;
2201                if (statusPanel) statusPanel.hidden = false;
2202                if (redirectNowBtn) redirectNowBtn.hidden = false;
2203                startSsoCountdown();
2204            }
2205        }
2206
2207        function continueWithSsoMember() {
2208            const choicePanel = document.getElementById('sso-choice-panel');
2209            if (choicePanel) choicePanel.hidden = true;
2210            const statusPanel = document.getElementById('sso-status');
2211            const redirectNowBtn = document.getElementById('sso-redirect-now');
2212            if (statusPanel) statusPanel.hidden = false;
2213            if (redirectNowBtn) redirectNowBtn.hidden = false;
2214            startSsoCountdown();
2215        }
2216
2217        function continueAsSsoGuest() {
2218            cancelSsoCountdown();
2219            currentSsoMeta.allowGuests = false;
2220            refreshStep2Hero(currentNet);
2221            applyNetworkRules(currentNet);
2222            showStep('step-2');
2223        }
2224
2225        const SSO_COUNTDOWN_SECONDS = 5;
2226        let ssoCountdownTimer = null;
2227        let ssoCountdownRemaining = 0;
2228        function startSsoCountdown() {
2229            cancelSsoCountdown();
2230            const statusPanel = document.getElementById('sso-status');
2231            const redirectNowBtn = document.getElementById('sso-redirect-now');
2232            if (statusPanel) statusPanel.hidden = false;
2233            if (redirectNowBtn) redirectNowBtn.hidden = false;
2234            ssoCountdownRemaining = SSO_COUNTDOWN_SECONDS;
2235            document.getElementById('sso-countdown').textContent = ssoCountdownRemaining;
2236
2237            const fill = document.getElementById('sso-progress-fill');
2238            if (fill) {
2239                fill.style.transition = 'none';
2240                fill.style.width = '0%';
2241                void fill.offsetWidth;
2242                fill.style.transition = 'width ' + (SSO_COUNTDOWN_SECONDS * 1000) + 'ms linear';
2243                fill.style.width = '100%';
2244            }
2245
2246            ssoCountdownTimer = setInterval(() => {
2247                ssoCountdownRemaining--;
2248                document.getElementById('sso-countdown').textContent = Math.max(0, ssoCountdownRemaining);
2249                if (ssoCountdownRemaining <= 0) {
2250                    cancelSsoCountdown();
2251                    redirectToSso();
2252                }
2253            }, 1000);
2254        }
2255        function cancelSsoCountdown() {
2256            if (ssoCountdownTimer) { clearInterval(ssoCountdownTimer); ssoCountdownTimer = null; }
2257            const fill = document.getElementById('sso-progress-fill');
2258            if (fill) {
2259                const computed = getComputedStyle(fill).width;
2260                fill.style.transition = 'none';
2261                fill.style.width = computed;
2262            }
2263        }
2264        function redirectNow() {
2265            cancelSsoCountdown();
2266            redirectToSso();
2267        }
2268        function redirectToSso() {
2269            const net = currentNet || lockedSchool;
2270            if (!net) return;
2271            const passthroughEmail = (document.getElementById('email').value || '').trim() || IML_INVITE_USER;
2272            let url = '/SSORedirectNew.aspx?SchID=' + encodeURIComponent(net.id);
2273            if (IML_EVENT_ID) url += '&eventId=' + encodeURIComponent(IML_EVENT_ID);
2274            if (IML_INVITE_TYPE) url += '&inviteType=' + encodeURIComponent(IML_INVITE_TYPE);
2275            if (passthroughEmail) url += '&email=' + encodeURIComponent(passthroughEmail);
2276            if (IML_INVITE_TOKEN) url += '&token=' + encodeURIComponent(IML_INVITE_TOKEN);
2277            window.location.href = url;
2278        }
2279
2280        // ====================== Step 2 form rules ======================
2281        // Network rules:
2282        //   - lockedSchool (URL-pinned) → use IML_LOCKED_RULES populated by
2283        //     init() from RegisterBO.Initialize
2284        //   - email-detected network → fall back to DEFAULT_RULES (we'd need
2285        //     a follow-up Initialize call to fetch real rules; deferred)
2286        //   - no network → DEFAULT_RULES
2287        function applyNetworkRules(net) {
2288            const rules = (lockedSchool && IML_LOCKED_RULES) ? IML_LOCKED_RULES : DEFAULT_RULES;
2289            const statusLabel = document.getElementById('status-label');
2290            if (statusLabel) statusLabel.textContent = rules.statusTitle || 'Status';
2291            populateDropdownsFromRules(rules);
2292
2293            const optNb = document.getElementById('opt-nb');
2294            const gender = document.getElementById('gender');
2295            if (optNb) {
2296                optNb.hidden = !rules.nonBinary;
2297                if (!rules.nonBinary && gender.value === 'nb') gender.value = '';
2298            }
2299            onGenderChange();
2300
2301            document.getElementById('status-grad-row').hidden = !rules.showStatus;
2302
2303            const birthRow = document.getElementById('birthday-row');
2304            birthRow.classList.toggle('year-only', !!rules.hideBirthMonthDay);
2305            document.getElementById('birth-month').hidden = !!rules.hideBirthMonthDay;
2306            document.getElementById('birth-day').hidden = !!rules.hideBirthMonthDay;
2307
2308            applyAddressRules(rules);
2309
2310            renderCustomQuestions(rules.customQuestions || []);
2311
2312            const infoText = (rules.customRegistrationInfo || '').trim();
2313            const infoCard = document.getElementById('custom-registration-info');
2314            const infoCopy = document.getElementById('custom-registration-info-copy');
2315            if (infoCard && infoCopy) {
2316                infoCard.hidden = !infoText;
2317                infoCopy.innerHTML = infoText;
2318            }
2319
2320            const optInRow = document.getElementById('optin-row');
2321            if (optInRow) optInRow.hidden = !rules.optInIMLeaguesNewsEnabled;
2322
2323            const waiverRow = document.getElementById('waiver-row');
2324            const waiverLink = document.getElementById('waiver-link');
2325            if (rules.waiver) {
2326                waiverRow.hidden = false;
2327                waiverLink.textContent = (net && net.name ? net.name : rules.name) + "'s recreation participation waiver";
2328            } else {
2329                waiverRow.hidden = true;
2330            }
2331
2332            // Surface the private-network gate BEFORE submit. privateAccess /
2333            // schoolDomainString have been in the Initialize payload all
2334            // along, but were previously only read after a failed submit.
2335            const emailHint = document.getElementById('email-domain-hint');
2336            if (emailHint) {
2337                const hintDomains = parseDomainList(rules.schoolDomainString).join(', ');
2338                if (rules.privateAccess && hintDomains) {
2339                    // Em dash, not a period, after the domain list: a period
2340                    // flush against the last domain reads as part of it.
2341                    emailHint.textContent = 'This network automatically approves emails ending in: '
2342                        + hintDomains + ' — other emails need admin approval to join.';
2343                    emailHint.hidden = false;
2344                } else if (rules.privateAccess) {
2345                    emailHint.textContent = 'This network is private — a network admin approves each new member.';
2346                    emailHint.hidden = false;
2347                } else {
2348                    emailHint.textContent = '';
2349                    emailHint.hidden = true;
2350                }
2351            }
2352        }
2353
2354        function renderCustomQuestions(questions) {
2355            const container = document.getElementById('custom-questions');
2356            const previousValues = getCustomQuestionPreviousValues(container);
2357            container.innerHTML = '';
2358
2359            questions.forEach((q) => {
2360                const field = document.createElement('div');
2361                field.className = 'lr-field';
2362                const fieldId = getCustomQuestionFieldId(q.id);
2363                const previousValue = previousValues.get(fieldId) || '';
2364
2365                const label = document.createElement('label');
2366                label.className = 'lr-label';
2367                label.setAttribute('for', fieldId);
2368                label.textContent = q.label;
2369                field.appendChild(label);
2370
2371                if (q.type === 'select') {
2372                    const select = document.createElement('select');
2373                    select.id = fieldId;
2374                    select.className = 'lr-select';
2375                    (q.options || []).forEach(opt => {
2376                        const o = document.createElement('option');
2377                        o.value = opt.value;
2378                        o.textContent = opt.text;
2379                        if (String(opt.value) === String(previousValue)) o.selected = true;
2380                        select.appendChild(o);
2381                    });
2382                    field.appendChild(select);
2383                } else {
2384                    const input = document.createElement('input');
2385                    input.type = 'text';
2386                    input.id = fieldId;
2387                    input.className = 'lr-input';
2388                    input.value = previousValue;
2389                    field.appendChild(input);
2390                }
2391                container.appendChild(field);
2392
2393                const input = document.getElementById(fieldId);
2394                if (input) {
2395                    const evt = input.tagName === 'SELECT' ? 'change' : 'input';
2396                    input.addEventListener(evt, () => clearFieldError(input.id));
2397                }
2398            });
2399        }
2400
2401        async function onMatchedNetworkChoice(match) {
2402            hideMultiNetworkPanel();
2403            hideEmailMatchPanel();
2404            setRegistrationSubmitState(true, 'Loading network...');
2405            try {
2406                const context = await loadRegistrationContext({
2407                    id: match.id,
2408                    name: match.name,
2409                    color: match.mainNavig || match.schoolMainNavig || '#6366F1',
2410                    logo: match.logo || ''
2411                });
2412
2413                if (context) {
2414                    setActiveNetworkContext(context.net, context.rules);
2415                    setPrefilledEmail(context.data.email || IML_INVITE_USER);
2416                } else {
2417                    setActiveNetworkContext({
2418                        id: match.id,
2419                        name: match.name,
2420                        initial: getNetworkInitial(match.name),
2421                        color: match.mainNavig || match.schoolMainNavig || '#6366F1',
2422                        logo: match.logo || '',
2423                        sso: !!match.ssoEnabled,
2424                        ssoUrl: ''
2425                    }, null);
2426                }
2427            } catch (e) {
2428                console.warn('Failed to load selected multi-network context', e);
2429                setActiveNetworkContext({
2430                    id: match.id,
2431                    name: match.name,
2432                    initial: getNetworkInitial(match.name),
2433                    color: match.mainNavig || match.schoolMainNavig || '#6366F1',
2434                    logo: match.logo || '',
2435                    sso: !!match.ssoEnabled,
2436                    ssoUrl: ''
2437                }, null);
2438            } finally {
2439                setRegistrationSubmitState(false);
2440            }
2441
2442            if (currentNet && currentNet.sso) {
2443                showStep('step-2-sso');
2444                fillSsoStep(currentNet, (document.getElementById('email').value || '').trim());
2445                return;
2446            }
2447
2448            refreshStep2Hero(currentNet);
2449            applyNetworkRules(currentNet);
2450            if (document.getElementById('step-2').hidden) {
2451                showStep('step-2');
2452            } else {
2453                // Already on step 2 - a network joined from the email, or picked from
2454                // the post-submit panel. showStep would replay the step fade over a
2455                // form mid-typing and count a second signup_started; do only the parts
2456                // of it that matter here.
2457                clearError();
2458                setAccentColor(accentForStep('step-2'));
2459                refreshStep2BackLabel();
2460            }
2461        }
2462
2463        function submitJoinRequest() {
2464            onSubmitRegistration({
2465                asJoinRequest: true,
2466                requestMessage: (document.getElementById('join-request-message').value || '').trim()
2467            });
2468        }
2469
2470        function resendActivationEmail() {
2471            if (!registerSuccessEmail) return;
2472            const msg = document.getElementById('success-resend-msg');
2473            msg.hidden = true;
2474            msg.textContent = '';
2475
2476            callBO('ro', 'Account.LoginBO', 'ResendEmail', 'EmailInVO', {
2477                email: registerSuccessEmail
2478            }, '/spa/account/registration').then(r => {
2479                msg.hidden = false;
2480                msg.textContent = (r && r.isDone)
2481                    ? 'Activation email sent.'
2482                    : ((r && r.message) || 'Could not resend the activation email.');
2483            }).catch(() => {
2484                msg.hidden = false;
2485                msg.textContent = 'Could not resend the activation email.';
2486            });
2487        }
2488
2489        function onGenderChange() {
2490            const gender = document.getElementById('gender').value;
2491            document.getElementById('real-gender-block').hidden = gender !== 'nb';
2492        }
2493
2494        // Guess the grad year from the status the way the legacy SPA registration page did
2495        // (registration.js changeUserGradYear): pick Freshman, get four years out; pick
2496        // Faculty/Staff, get None. The years are computed server-side, so a network's own
2497        // per-status grade-year settings and its "after June 1 counts as next year" rollover
2498        // both apply. It stays a guess — the user can still change the year afterwards.
2499        function onStatusChange() {
2500            const statusEl = document.getElementById('status');
2501            const gradEl = document.getElementById('grad-year');
2502            if (!statusEl || !gradEl) return;
2503
2504            const suggested = (getActiveRules().gradYearForStatus || {})[statusEl.value];
2505            if (!suggested) return;
2506            // Only take a suggestion the drop-down actually offers, so we can never leave the
2507            // field holding a value the user cannot see or re-select.
2508            if (!Array.from(gradEl.options).some(o => o.value === suggested)) return;
2509
2510            gradEl.value = suggested;
2511            // Setting .value fires no change event, so clear the error banner entry ourselves.
2512            clearFieldError('grad-year');
2513        }
2514
2515        function updatePasswordStrength() {
2516            const pw = document.getElementById('pw').value;
2517            const bar = document.getElementById('pw-strength');
2518            const label = document.getElementById('pw-strength-label');
2519
2520            if (!pw) {
2521                bar.setAttribute('data-score', '0');
2522                label.removeAttribute('data-score');
2523                label.textContent = 'Minimum 8 characters with at least one letter and one number.';
2524                return;
2525            }
2526            const hasLetter = /[A-Za-z]/.test(pw);
2527            const hasNumber = /\d/.test(pw);
2528            const hasSymbol = /[^A-Za-z0-9]/.test(pw);
2529            let score = 1;
2530            if (pw.length >= 8 && hasLetter && hasNumber) score = 2;
2531            if (pw.length >= 12 && hasLetter && hasNumber && hasSymbol) score = 3;
2532
2533            bar.setAttribute('data-score', String(score));
2534            label.setAttribute('data-score', String(score));
2535            label.textContent = {
2536                1: 'Weak — add letters and numbers, 8+ chars.',
2537                2: 'Medium — looks good. Add length or a symbol for strong.',
2538                3: 'Strong password.'
2539            }[score];
2540        }
2541
2542        function populateDropdowns() {
2543            populateDropdownsFromRules(DEFAULT_RULES);
2544        }
2545
2546        function collectRegistrationState() {
2547            const rules = getActiveRules();
2548            const statusEl = document.getElementById('status');
2549            const gradEl = document.getElementById('grad-year');
2550            const statusGradHidden = document.getElementById('status-grad-row').hidden;
2551            const email = (document.getElementById('email').value || '').trim();
2552            const answers = collectCustomQuestionAnswers(rules.customQuestions);
2553
2554            return {
2555                rules: rules,
2556                email: email,
2557                fname: (document.getElementById('fname').value || '').trim(),
2558                lname: (document.getElementById('lname').value || '').trim(),
2559                pw: document.getElementById('pw').value || '',
2560                statusEl: statusEl,
2561                gradEl: gradEl,
2562                statusGradHidden: statusGradHidden,
2563                status: statusGradHidden ? 'None' : (statusEl.options[statusEl.selectedIndex] ? statusEl.options[statusEl.selectedIndex].text : ''),
2564                grad: statusGradHidden ? 'None' : (gradEl.value || ''),
2565                month: document.getElementById('birth-month').value || '',
2566                day: document.getElementById('birth-day').value || '',
2567                year: document.getElementById('birth-year').value || '',
2568                gender: document.getElementById('gender').value || '',
2569                realGender: document.getElementById('real-gender').value || '',
2570                termsOk: document.getElementById('terms').checked,
2571                waiverOk: document.getElementById('waiver').checked,
2572                optInNews: document.getElementById('optin-news').checked,
2573                answers: answers,
2574                address: collectAddressState(rules),
2575                networkId: (lockedSchool && lockedSchool.id) || (currentNet && currentNet.id) || ''
2576            };
2577        }
2578
2579        function buildRegistrationPayload(state, overrides) {
2580            // The current API still stores league participation as male/female.
2581            // Keep the new UI wording, but translate it back to the legacy payload.
2582            const genderForApi = state.gender === 'nb'
2583                ? (state.realGender || '')
2584                : state.gender === 'm'
2585                    ? 'male'
2586                    : state.gender === 'f'
2587                        ? 'female'
2588                        : '';
2589
2590            const dob = state.year
2591                ? (state.year + '-' + (state.month || '1') + '-' + (state.day || '1'))
2592                : '';
2593
2594            return {
2595                dob: dob,
2596                firstName: state.fname,
2597                lastName: state.lname,
2598                email: state.email,
2599                password: state.pw,
2600                gender: genderForApi,
2601                nonBinary: state.gender === 'nb',
2602                status: state.status,
2603                grad: state.grad,
2604                chkReceiveMessage: false,
2605                phoneNumber: '',
2606                carrier: '',
2607                captchaViewIndex: -1,
2608                solveMediaChallenge: '',
2609                solveMediaAnswer: '',
2610                reCaptchaResponse: '',
2611                // Anti-automation, checked server-side in RegisterBO.CreateAccount.
2612                // `website` is the honeypot and must stay empty for a real person.
2613                website: (document.getElementById('reg-website') || {}).value || '',
2614                elapsedMs: Date.now() - REG_RENDERED_AT,
2615                networkId: state.networkId,
2616                answers: state.answers,
2617                asJoinRequest: !!(overrides && overrides.asJoinRequest),
2618                requestMessage: (overrides && overrides.requestMessage) || '',
2619                inviteType: IML_INVITE_TYPE || '',
2620                eventId: IML_EVENT_ID || '',
2621                token: IML_INVITE_TOKEN || '',
2622                optInIMLeaguesNews: !!state.optInNews,
2623                // IM-MemberAddress: CreateAccountInVO.Address/City/State/Country/ZipCode.
2624                // Blank on an Off network (the block is never shown). The server normalizes
2625                // and re-validates; a country with no other part is never stored.
2626                address: state.address.street,
2627                city: state.address.city,
2628                state: state.address.state,
2629                country: state.address.country,
2630                zipCode: state.address.zip,
2631                // The page rendered the address block, so the server may hold a Required network to it.
2632                clientCollectsAddress: !!state.address.shown
2633            };
2634        }
2635
2636        function handleRegistrationFailure(response, state) {
2637            const code = String(response.code || (response.data && response.data.code) || '');
2638            if (code === '140') {
2639                // response.data is RegisterBO's domain list for this network;
2640                // empty string means the network has none configured (private,
2641                // approval-only), not a transport gap — the 140 path always
2642                // sends it. Only a non-string data falls back to cached rules.
2643                const domainPayload = typeof response.data === 'string'
2644                    ? response.data
2645                    : (state.rules.schoolDomainString || '');
2646                const hasDomains = showJoinRequestPanel(domainPayload);
2647                showError(hasDomains
2648                    ? 'Use an approved network email or send a join request.'
2649                    : 'This network requires admin approval to join — send a join request below.');
2650                return;
2651            }
2652            if (code === '403') {
2653                showMultiNetworkPanel(Array.isArray(response.data) ? response.data : []);
2654                showError('We found more than one network for this email. Choose where you want to register.');
2655                return;
2656            }
2657            if (code === '108' && typeof response.data === 'string') {
2658                window.location.href = response.data;
2659                return;
2660            }
2661            if (code === '102' || /too.?young/i.test(response.message || '')) {
2662                showError(/milk/i.test(response.message || '')
2663                    ? 'Sorry, you must be old enough to create an account.'
2664                    : (response.message || 'Sorry, you must be old enough to create an account.'));
2665                return;
2666            }
2667            if (code === '157' || /captcha/i.test(response.message || '')) {
2668                showError("We couldn't verify you're not a bot. Try again. If you keep seeing this, disable your ad blocker for this page or email [email protected].");
2669                return;
2670            }
2671
2672            showError(response.message || 'Could not create the account. Please check your details and try again.');
2673        }
2674
2675        function onSubmitRegistration(overrides) {
2676            overrides = overrides || {};
2677            const submitBtn = document.getElementById('register-submit');
2678            if (submitBtn && submitBtn.disabled) return;
2679
2680            clearError();
2681            clearAllFieldErrors();
2682            hideMultiNetworkPanel();
2683            if (!overrides.asJoinRequest) hideJoinRequestPanel();
2684            // A network match the user has not answered blocks a personal-account
2685            // submit. The few who really want one answer "Create an independent account".
2686            if (emailMatchUndecided()) { focusEmailMatchPanel(); return; }
2687
2688            const state = collectRegistrationState();
2689            const errors = [];
2690            if (!state.email || state.email.indexOf('@') < 1) {
2691                errors.push({ fieldId: 'email', label: 'Email', message: 'Enter a valid email address (e.g. [email protected]).' });
2692            }
2693            if (!state.fname) {
2694                errors.push({ fieldId: 'fname', label: 'First name', message: 'Enter your first name.' });
2695            }
2696            if (!state.lname) {
2697                errors.push({ fieldId: 'lname', label: 'Last name', message: 'Enter your last name.' });
2698            }
2699            if (!state.pw) {
2700                errors.push({ fieldId: 'pw', label: 'Password', message: 'Enter a password (8+ chars with letters and numbers).' });
2701            } else if (state.pw.length < 8 || !/[A-Za-z]/.test(state.pw) || !/\d/.test(state.pw)) {
2702                errors.push({ fieldId: 'pw', label: 'Password', message: 'Password must be 8+ characters and include at least one letter and one number.' });
2703            }
2704            if (!state.statusGradHidden) {
2705                if (!state.statusEl.value) errors.push({ fieldId: 'status', label: 'Status', message: 'Select your role at the network.' });
2706                if (!state.gradEl.value) errors.push({ fieldId: 'grad-year', label: 'Grad year', message: 'Select your expected graduation year.' });
2707            }
2708            if (!state.year) {
2709                errors.push({ fieldId: 'birth-year', label: 'Birth year', message: 'Select your birth year.' });
2710            }
2711            if (!state.gender) {
2712                errors.push({ fieldId: 'gender', label: 'Participation', message: 'Select how you will participate.' });
2713            }
2714            if (state.gender === 'nb' && !state.realGender) {
2715                errors.push({ fieldId: 'real-gender', label: 'League type', message: 'Choose which league type you would typically play in.' });
2716            }
2717            validateAddressState(state.address, errors);
2718            (state.rules.customQuestions || []).forEach((q, index) => {
2719                if (!state.answers[index] || !state.answers[index].answer) {
2720                    errors.push({ fieldId: 'custom-' + q.id, label: q.label, message: q.type === 'select' ? 'Select an option.' : 'Answer this question.' });
2721                }
2722            });
2723
2724            let termsError = null;
2725            let waiverError = null;
2726            if (!state.termsOk) {
2727                termsError = { fieldId: 'terms', label: 'Terms and Privacy', message: 'Agree to the Terms and Privacy Policy to create your account.' };
2728            }
2729            if (!document.getElementById('waiver-row').hidden && !state.waiverOk) {
2730                waiverError = { fieldId: 'waiver', label: 'Waiver', message: 'Agree to the recreation participation waiver to continue.' };
2731            }
2732
2733            if (errors.length || termsError || waiverError) {
2734                errors.forEach(e => showFieldError(e.fieldId, e.label, e.message));
2735                const all = errors.concat(termsError ? [termsError] : [], waiverError ? [waiverError] : []);
2736                showErrorSummary(all);
2737                const first = document.getElementById(all[0].fieldId);
2738                if (first) first.focus();
2739                return;
2740            }
2741
2742            const accountInfoVO = buildRegistrationPayload(state, overrides);
2743            setRegistrationSubmitState(true, 'Verifying...');
2744            imlTrack('signup_submitted', { has_network: state.networkId ? 'true' : 'false' });
2745
2746            setRegistrationSubmitState(true, 'Creating account...');
2747            callBO('rw', 'Account.RegisterBO', 'CreateAccount', 'CreateAccountInVO', accountInfoVO, '/spa/account/registration').then(r => {
2748                if (!r) {
2749                    setRegistrationSubmitState(false);
2750                    showError('Network error during account creation. Please try again.');
2751                    return;
2752                }
2753                if (r.isDone) {
2754                    imlTrack('signup_succeeded');
2755                    if (r.data && r.data.url) {
2756                        window.location.href = r.data.url;
2757                    } else {
2758                        showRegistrationSuccess(state.email, !!(r.data && r.data.pendingApproval));
2759                    }
2760                    return;
2761                }
2762
2763                setRegistrationSubmitState(false);
2764                imlTrack('signup_failed', { code: (r.code || (r.data && r.data.code) || 'unknown') });
2765                handleRegistrationFailure(r, state);
2766            }).catch(() => {
2767                setRegistrationSubmitState(false);
2768                showError('Network error during account creation. Please try again.');
2769            });
2770        }
2771
2772        function backToStep1() {
2773            // URL-locked network (?SchID=) has no step 1 to go back to —
2774            // the picker would let them escape the pinned network. Same
2775            // for locked-SSO (no email collected anyway).
2776            if (IML_SCHOOL_ID) return;
2777            hideJoinRequestPanel();
2778            hideMultiNetworkPanel();
2779            clearEmailMatchState();
2780            currentSsoMeta = { allowGuests: false, guestsName: 'Guest', membersName: 'Member', ssoUrl: '' };
2781            // Clear picker state so the user starts fresh.
2782            currentNet = null;
2783            lockedSchool = null;
2784            IML_LOCKED_RULES = null;
2785            const search = document.getElementById('network-search');
2786            if (search) { search.value = ''; }
2787            renderNetworkPrompt();
2788            showStep('step-1');
2789            if (search) search.focus();
2790        }
2791
2792        function showStep(id) {
2793            // Analytics: signal step transitions. step-1 is the initial
2794            // network picker and is covered by pageview, so skip it.
2795            if (typeof imlTrack === 'function') {
2796                if (id === 'step-2') imlTrack('signup_started');
2797                else if (id === 'step-2-sso') imlTrack('signup_sso_redirect');
2798            }
2799            // Clear any previous error when the user moves between steps —
2800            // the old message is no longer relevant in the new context.
2801            clearError();
2802            document.body.setAttribute('data-step', id);
2803            if (id !== 'step-2-sso') cancelSsoCountdown();
2804            setAccentColor(accentForStep(id));
2805            ['step-1', 'step-2', 'step-2-sso', 'step-success'].forEach(s => {
2806                const el = document.getElementById(s);
2807                if (!el) return;
2808                el.hidden = (s !== id);
2809                if (s === id) {
2810                    el.style.animation = 'none';
2811                    void el.offsetWidth;
2812                    el.style.animation = '';
2813                }
2814            });
2815            // Hide the "Use a different network" back-link when the user
2816            // landed on a URL-locked subdomain (?SchID=) — there's no
2817            // picker to go back to in that case. Applies on step-2 and
2818            // step-2-sso.
2819            const hideBack = !!IML_SCHOOL_ID;
2820            const backRow = document.getElementById('step2-back-row');
2821            const ssoBackRow = document.getElementById('sso-back-row');
2822            if (backRow) backRow.hidden = hideBack;
2823            if (ssoBackRow) ssoBackRow.hidden = hideBack;
2824            // Personal-account flow (no network picked) → reword the
2825            // step-2 back button since "Use a different network" implies
2826            // one was selected.
2827            const backLabel = document.getElementById('step2-back-label');
2828            if (backLabel) {
2829                backLabel.textContent = (lockedSchool || currentNet)
2830                    ? 'Use a different network'
2831                    : 'Pick a network instead';
2832            }
2833            refreshHomeLink();
2834        }
2835
2836        function togglePwd() {
2837            const pw = document.getElementById('pw');
2838            const eye = document.getElementById('pw-eye');
2839            const btn = eye.parentElement;
2840            if (pw.type === 'password') {
2841                pw.type = 'text';
2842                eye.classList.remove('ph-eye'); eye.classList.add('ph-eye-slash');
2843                btn.setAttribute('aria-label', 'Hide password');
2844                btn.setAttribute('aria-pressed', 'true');
2845            } else {
2846                pw.type = 'password';
2847                eye.classList.remove('ph-eye-slash'); eye.classList.add('ph-eye');
2848                btn.setAttribute('aria-label', 'Show password');
2849                btn.setAttribute('aria-pressed', 'false');
2850            }
2851        }
2852
2853        // Kick off — runs last so every `let` it touches via showStep /
2854        // cancelSsoCountdown is past its TDZ.
2855        init();
2856    </script>
2856
2857</body>
2858</html>

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.