[object Object]

← back to Lawyer Directory Builder

Iter 30: /signup + /login submit-disable + loading state

370aff1116ea35d086318322ff7d01c8bd41117c · 2026-05-04 03:20:45 -0700 · SteveStudio2

Applies the iter-27 loading-state pattern to both auth forms.

/signup (was: 'Claim your listing →' static button → vanilla form post):
- 'Claiming your listing…' loading text + champagne-disc spinner
- Button disabled on submit; data-loading-text drives the swap
- Prevents double-submit during bcrypt password hash + DB INSERT

/login (was: 'Sign in →' static button):
- 'Signing in…' loading text + spinner
- Same submit-disable JS pattern

Shared CSS in portal layout:
- button.big-btn:disabled — oxidized gradient + .7 opacity (matches the
  /upgrade .up-cta:disabled / /find-a-lawyer .cta-metal:disabled style)
- button.big-btn .cta-spinner — 13px champagne-bordered ring
- @keyframes portal-spin (720ms linear infinite)
- prefers-reduced-motion: animation disabled, spinner stays as static
  champagne dot so the loading state still reads as a state change

Both auth surfaces now match /find-a-lawyer (consumer) and /upgrade
(payment) for submit-feedback consistency. Slow-network attorneys no
longer see a 'hung browser' on signup or login.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 370aff1116ea35d086318322ff7d01c8bd41117c
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 4 03:20:45 2026 -0700

    Iter 30: /signup + /login submit-disable + loading state
    
    Applies the iter-27 loading-state pattern to both auth forms.
    
    /signup (was: 'Claim your listing →' static button → vanilla form post):
    - 'Claiming your listing…' loading text + champagne-disc spinner
    - Button disabled on submit; data-loading-text drives the swap
    - Prevents double-submit during bcrypt password hash + DB INSERT
    
    /login (was: 'Sign in →' static button):
    - 'Signing in…' loading text + spinner
    - Same submit-disable JS pattern
    
    Shared CSS in portal layout:
    - button.big-btn:disabled — oxidized gradient + .7 opacity (matches the
      /upgrade .up-cta:disabled / /find-a-lawyer .cta-metal:disabled style)
    - button.big-btn .cta-spinner — 13px champagne-bordered ring
    - @keyframes portal-spin (720ms linear infinite)
    - prefers-reduced-motion: animation disabled, spinner stays as static
      champagne dot so the loading state still reads as a state change
    
    Both auth surfaces now match /find-a-lawyer (consumer) and /upgrade
    (payment) for submit-feedback consistency. Slow-network attorneys no
    longer see a 'hung browser' on signup or login.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 src/server/portal.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 51 insertions(+), 3 deletions(-)

diff --git a/src/server/portal.ts b/src/server/portal.ts
index 796abba..ea6e926 100644
--- a/src/server/portal.ts
+++ b/src/server/portal.ts
@@ -138,6 +138,17 @@ function layout(title: string, body: string, opts: { user?: any } = {}) {
   .cta-metal{padding:20px 36px;font-size:12px;letter-spacing:.18em;text-transform:uppercase;background:linear-gradient(180deg,#d4b683 0%,#b89968 38%,#9c7e4f 100%);color:var(--noir);border:1px solid var(--metal);font-weight:500;box-shadow:inset 0 1px 0 rgba(255,235,200,.35),0 1px 0 rgba(0,0,0,.4);transition:transform 220ms ease,box-shadow 220ms ease,background 220ms ease}
   .cta-metal:hover{background:linear-gradient(180deg,#e3c89a 0%,#c8a674 50%,#a98559 100%);transform:translateY(-1px);box-shadow:inset 0 1px 0 rgba(255,240,210,.5),0 4px 18px rgba(184,153,104,.18);color:var(--noir)}
 
+  /* Submit-button loading state (shared by /signup and /login forms) */
+  button.big-btn:disabled{cursor:not-allowed;opacity:.7;transform:none;background:linear-gradient(180deg,#9c8758 0%,#80684a 50%,#5e4a30 100%);box-shadow:inset 0 1px 0 rgba(255,235,200,.15)}
+  button.big-btn:disabled:hover{background:linear-gradient(180deg,#9c8758 0%,#80684a 50%,#5e4a30 100%);transform:none}
+  button.big-btn .cta-spinner{display:none;width:13px;height:13px;border:1.5px solid rgba(10,10,12,.25);border-top-color:var(--noir);border-radius:50%;animation:portal-spin 720ms linear infinite;margin-left:10px;vertical-align:middle}
+  button.big-btn.is-loading .cta-arrow{display:none}
+  button.big-btn.is-loading .cta-spinner{display:inline-block}
+  @keyframes portal-spin{to{transform:rotate(360deg)}}
+  @media (prefers-reduced-motion: reduce){
+    button.big-btn .cta-spinner{animation:none}
+  }
+
 *:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(184,153,104,.45);border-radius:1px}
 *:focus:not(:focus-visible){outline:none}
 
@@ -281,12 +292,31 @@ router.get('/signup', (req, res) => {
           <input class="big-in" name="password" type="password" placeholder="Min 8 characters" required minlength="8" autocomplete="new-password">
         </label>
 
-        <button class="big-btn" type="submit" style="margin-top:28px">Claim your listing →</button>
+        <button class="big-btn" type="submit" style="margin-top:28px" data-loading-text="Claiming your listing…">
+          <span class="cta-text">Claim your listing</span>
+          <span class="cta-arrow" style="font-family:var(--serif);font-style:italic;margin-left:8px">→</span>
+          <span class="cta-spinner" aria-hidden="true"></span>
+        </button>
       </div>
     </form>
 
     <p class="muted" style="margin-top:24px">Already claimed? <a href="/login">Sign in</a></p>
 
+    <script>
+      (function(){
+        var f = document.getElementById('claim-form');
+        if (!f) return;
+        f.addEventListener('submit', function(){
+          var btn = f.querySelector('button[type="submit"]');
+          if (!btn) return;
+          btn.disabled = true;
+          btn.classList.add('is-loading');
+          var t = btn.querySelector('.cta-text');
+          if (t) t.textContent = btn.dataset.loadingText || 'Submitting…';
+        });
+      })();
+    </script>
+
     <style>
       form.stack.big{gap:22px;max-width:720px}
       .signup-vol{display:flex;align-items:center;gap:18px;margin:0 0 26px;color:var(--metal)}
@@ -527,13 +557,31 @@ router.get('/login', (req, res) => {
     </div>
     <h2 class="signup-display" style="font-size:clamp(36px,5vw,56px);margin:0 0 36px;max-width:14ch">Welcome <em>back.</em></h2>
     ${flash(req)}
-    <form class="stack" method="post" action="/login" style="max-width:480px">
+    <form class="stack" method="post" action="/login" style="max-width:480px" id="login-form">
       <input type="hidden" name="next" value="${esc(next)}">
       <input name="email" type="email" placeholder="Email" required autocomplete="email">
       <input name="password" type="password" placeholder="Password" required autocomplete="current-password">
-      <button class="big-btn" type="submit" style="margin-top:8px;width:auto;align-self:flex-start">Sign in →</button>
+      <button class="big-btn" type="submit" style="margin-top:8px;width:auto;align-self:flex-start" data-loading-text="Signing in…">
+        <span class="cta-text">Sign in</span>
+        <span class="cta-arrow" style="font-family:var(--serif);font-style:italic;margin-left:8px">→</span>
+        <span class="cta-spinner" aria-hidden="true"></span>
+      </button>
     </form>
     <p class="muted" style="margin-top:24px">No account yet? <a href="/signup">Claim your listing</a></p>
+    <script>
+      (function(){
+        var f = document.getElementById('login-form');
+        if (!f) return;
+        f.addEventListener('submit', function(){
+          var btn = f.querySelector('button[type="submit"]');
+          if (!btn) return;
+          btn.disabled = true;
+          btn.classList.add('is-loading');
+          var t = btn.querySelector('.cta-text');
+          if (t) t.textContent = btn.dataset.loadingText || 'Signing in…';
+        });
+      })();
+    </script>
   `, { user: req.user }));
 });
 

← c9f2fae Iter 29: /methodology page — closes the Rule 7.1 substantiat  ·  back to Lawyer Directory Builder  ·  Iter 31: /404 — path-aware editorial recovery surface 41bda2f →