← 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>
);
}