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 · 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…</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’ll send an activation link to confirm it’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 — 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 & 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 & training</h3> 555 <p class="lr-ac-item-copy">Reserve group fitness classes, book personal training, and keep track of every session you’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 & 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 & 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> · 595 <a href="/terms">Terms of use</a> · 596 <a href="/dataprotection">Data protection</a> · 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 + '¶mType=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.