← back to Professional Directory

agents/web-agent/app/account/claim/page.tsx

320 lines

// /account/claim — claim-flow form. Linked from /doctors/[slug] and /orgs/[slug].
//
// Query params:
//   ?npi=NNNNNNNNNN           — claiming an individual professional profile
//   ?org=NNNN                 — claiming an organization (practice/clinic)
//
// Flow:
//   1. Require auth — if not signed in, deflect to Google OAuth, returnTo here.
//   2. Resolve NPI → numeric professional id via /api/professionals/:npi
//      (or use ?org=N directly).
//   3. Show the target's name + auto-approval rules + practice-domain hint.
//   4. POST /api/doctor-claims with { target_professional_id | target_organization_id,
//      npi, license_number? }. Server emails a verification link to the user's
//      account email (must be the practice-domain email for auto-approve).
//   5. Show "check your email" success state.
'use client';
import { useEffect, useState } from 'react';

type WhoAmI = {
  user: null | {
    id: number; email: string; role: string;
    claimed_professional_id?: number; claimed_organization_id?: number;
  };
};

type Target =
  | { kind: 'pro'; id: number; npi: string; name: string; specialty?: string }
  | { kind: 'org'; id: number; name: string; website?: string; city?: string };

function cardBtn(disabled = false): React.CSSProperties {
  return {
    display: 'inline-block',
    padding: '12px 22px',
    background: disabled ? 'var(--ink-soft)' : 'var(--moss)',
    color: '#fff',
    border: 0,
    borderRadius: 999,
    fontWeight: 600,
    letterSpacing: '0.04em',
    textTransform: 'uppercase',
    fontSize: 14,
    cursor: disabled ? 'not-allowed' : 'pointer',
    opacity: disabled ? 0.6 : 1,
  };
}

function emailDomain(e: string): string {
  const at = e.lastIndexOf('@');
  return at >= 0 ? e.slice(at + 1).toLowerCase() : '';
}
function urlDomain(u: string): string {
  try { return new URL(/^https?:\/\//i.test(u) ? u : 'https://' + u).hostname.toLowerCase().replace(/^www\./, ''); }
  catch { return ''; }
}

export default function ClaimPage() {
  const [me, setMe] = useState<WhoAmI | null>(null);
  const [target, setTarget] = useState<Target | null>(null);
  const [loadErr, setLoadErr] = useState<string>('');
  const [npi, setNpi] = useState('');
  const [license, setLicense] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [done, setDone] = useState<{ to: string } | null>(null);
  const [err, setErr] = useState('');

  // 1. whoami + parse query string
  useEffect(() => {
    let cancelled = false;
    fetch('/api/auth/whoami', { credentials: 'include' })
      .then(r => r.ok ? r.json() : { user: null })
      .then(j => { if (!cancelled) setMe(j); });

    const qs = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : new URLSearchParams();
    const npiQ = qs.get('npi');
    const orgQ = qs.get('org');

    if (npiQ && /^\d{10}$/.test(npiQ)) {
      setNpi(npiQ);
      fetch(`/api/professionals/${npiQ}`)
        .then(r => r.ok ? r.json() : null)
        .then(p => {
          if (cancelled) return;
          if (!p) { setLoadErr(`No professional found with NPI ${npiQ}.`); return; }
          const specs = Array.isArray(p.specialties) ? p.specialties : [];
          setTarget({
            kind: 'pro',
            id: p.id,
            npi: p.npi_number,
            name: p.full_name,
            specialty: specs[0]?.specialty,
          });
        })
        .catch(() => { if (!cancelled) setLoadErr('Failed to load professional.'); });
    } else if (orgQ && /^\d+$/.test(orgQ)) {
      fetch(`/api/organizations/${orgQ}`)
        .then(r => r.ok ? r.json() : null)
        .then(o => {
          if (cancelled) return;
          if (!o) { setLoadErr(`No organization found with id ${orgQ}.`); return; }
          setTarget({
            kind: 'org',
            id: o.id,
            name: o.name,
            website: o.website,
            city: o.city,
          });
        })
        .catch(() => { if (!cancelled) setLoadErr('Failed to load organization.'); });
    } else {
      setLoadErr('Missing or invalid claim target. Use a "Claim this profile" link from a doctor or practice page.');
    }
    return () => { cancelled = true; };
  }, []);

  // Loading
  if (me === null) {
    return <section className="section"><h2>Loading…</h2></section>;
  }

  // Need auth
  if (!me.user) {
    const here = typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/account/claim';
    return (
      <section className="section">
        <div className="eyebrow">Claim</div>
        <h2>Sign in to claim a profile</h2>
        <p style={{ maxWidth: 560, color: 'var(--ink-soft)' }}>
          We verify claims by sending a link to your <strong>practice-domain email</strong> (e.g. you@yourclinic.com).
          Personal Gmail works for sign-in, but auto-approval needs a domain that matches the practice website.
        </p>
        <a href={`/api/auth/google?returnTo=${encodeURIComponent(here)}`} style={{ ...cardBtn(), display: 'inline-block', textAlign: 'center', marginTop: 16 }}>
          Continue with Google →
        </a>
      </section>
    );
  }

  // Already claimed
  if (me.user.claimed_professional_id || me.user.claimed_organization_id) {
    return (
      <section className="section">
        <div className="eyebrow">Claim</div>
        <h2>You already have an active claim</h2>
        <p style={{ color: 'var(--ink-soft)' }}>
          Your account is linked to {me.user.claimed_professional_id
            ? `professional #${me.user.claimed_professional_id}`
            : `organization #${me.user.claimed_organization_id}`}.
          To claim an additional profile, contact admin.
        </p>
        <a href="/account" style={cardBtn()}>Back to account →</a>
      </section>
    );
  }

  // Bad target / not loaded yet
  if (loadErr) {
    return (
      <section className="section">
        <div className="eyebrow">Claim</div>
        <h2>Couldn't load that profile</h2>
        <p style={{ color: 'var(--ink-soft)' }}>{loadErr}</p>
        <a href="/search" style={cardBtn()}>Back to search →</a>
      </section>
    );
  }
  if (!target) {
    return <section className="section"><h2>Loading profile…</h2></section>;
  }

  // Success state
  if (done) {
    return (
      <section className="section">
        <div className="eyebrow">Claim submitted</div>
        <h2>Check your email</h2>
        <p style={{ maxWidth: 560, color: 'var(--ink-soft)' }}>
          We sent a verification link to <strong>{done.to}</strong>. Click it to verify the email.
          If your email domain matches the practice website, your claim auto-approves on the spot.
          Otherwise it goes to admin review (we typically respond within 1 business day).
        </p>
        <p style={{ color: 'var(--ink-soft)', fontSize: 13 }}>
          The link expires in 7 days. Didn't get it? Check spam, or reach out to admin via the dashboard.
        </p>
        <a href="/account" style={cardBtn()}>Back to account →</a>
      </section>
    );
  }

  // Domain hints
  const userDomain = emailDomain(me.user.email);
  const orgDomain = target.kind === 'org' && target.website ? urlDomain(target.website) : '';
  const domainsMatch = orgDomain && userDomain && orgDomain === userDomain;

  async function submit(e: React.FormEvent) {
    e.preventDefault();
    if (!target) return;
    setErr('');
    setSubmitting(true);
    try {
      const body: any = {};
      if (target.kind === 'pro') {
        body.target_professional_id = target.id;
        body.npi = (npi || target.npi || '').trim();
      } else {
        body.target_organization_id = target.id;
      }
      if (license.trim()) body.license_number = license.trim();

      const r = await fetch('/api/doctor-claims', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify(body),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) {
        setErr(j.error || `Submission failed (HTTP ${r.status}).`);
        return;
      }
      setDone({ to: j.verify_link_sent_to || me!.user!.email });
    } catch (ex: any) {
      setErr(ex?.message || 'Network error. Try again.');
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <section className="section" style={{ paddingTop: 48 }}>
      <div className="eyebrow">Claim</div>
      <h2>Claim {target.name}</h2>
      <p style={{ maxWidth: 600, color: 'var(--ink-soft)' }}>
        {target.kind === 'pro'
          ? <>Verifying you are <strong>{target.name}</strong>{target.specialty ? ` (${target.specialty})` : ''}. After verification you can respond to reviews, edit your page, and DM patients on the paid tier.</>
          : <>Verifying you manage <strong>{target.name}</strong>{target.city ? ` in ${target.city}` : ''}. After verification you can respond to reviews, edit page content, and run team accounts.</>}
      </p>

      <div className="card-grid" style={{ marginTop: 32 }}>
        <div className="card">
          <span className="badge">Signed in as</span>
          <span className="name">{me.user.email}</span>
          <span className="meta">
            {target.kind === 'org' && orgDomain
              ? (domainsMatch
                  ? `✓ Your email domain matches ${orgDomain} — claim will auto-approve after you click the verify link.`
                  : `Practice website is ${orgDomain}; your email is @${userDomain}. Auto-approve needs an exact match. Otherwise, queued for admin review.`)
              : 'Auto-approve needs your email domain to match the practice website. Other claims go to admin review.'}
          </span>
        </div>
        <div className="card">
          <span className="badge">What we send</span>
          <span className="name">A verify link to {me.user.email}</span>
          <span className="meta">
            Click it within 7 days. NPI {target.kind === 'pro' ? '(required)' : '(optional)'} and CA license (optional)
            help admin review faster if auto-approve doesn't fire.
          </span>
        </div>
      </div>

      <form onSubmit={submit} style={{ marginTop: 32, maxWidth: 540, display: 'flex', flexDirection: 'column', gap: 16 }}>
        {target.kind === 'pro' && (
          <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
            <span style={{ fontSize: 13, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', color: 'var(--ink-soft)' }}>
              NPI (10 digits)
            </span>
            <input
              type="text"
              required
              pattern="\d{10}"
              maxLength={10}
              value={npi}
              onChange={e => setNpi(e.target.value.replace(/\D/g, '').slice(0, 10))}
              placeholder="e.g. 1234567890"
              style={{ padding: '10px 14px', fontSize: 16, border: '1px solid var(--ink-soft)', borderRadius: 6, background: 'transparent', color: 'var(--ink)' }}
            />
            <span style={{ fontSize: 12, color: 'var(--ink-soft)' }}>
              Auto-approval matches NPI against our copy + checks your email domain against your practice website.
            </span>
          </label>
        )}

        <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
          <span style={{ fontSize: 13, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', color: 'var(--ink-soft)' }}>
            CA license # (optional)
          </span>
          <input
            type="text"
            maxLength={64}
            value={license}
            onChange={e => setLicense(e.target.value.slice(0, 64))}
            placeholder="MD / DO / DC / LAc / RN / etc."
            style={{ padding: '10px 14px', fontSize: 16, border: '1px solid var(--ink-soft)', borderRadius: 6, background: 'transparent', color: 'var(--ink)' }}
          />
          <span style={{ fontSize: 12, color: 'var(--ink-soft)' }}>
            Speeds up admin review for non-NPI verticals (acupuncture, chiropractic, RCFE, etc.).
          </span>
        </label>

        {err && (
          <p role="alert" style={{ color: '#9b1c1c', background: '#fff5f5', border: '1px solid #fecaca', padding: '10px 14px', borderRadius: 6, fontSize: 14 }}>
            {err}
          </p>
        )}

        <div style={{ display: 'flex', gap: 12, alignItems: 'center', marginTop: 8 }}>
          <button type="submit" disabled={submitting} style={cardBtn(submitting)}>
            {submitting ? 'Submitting…' : 'Send verify link →'}
          </button>
          <a href="/account" style={{ fontSize: 14, color: 'var(--ink-soft)' }}>Cancel</a>
        </div>

        <p style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-soft)' }}>
          By submitting you agree drvouch is non-clinical and that you have the authority to act on behalf of this profile.
          Fraudulent claims are revoked and the user blocked.
        </p>
      </form>
    </section>
  );
}