cf91c04e90
The OAuth-only 'sb-token' localStorage key was read by profile, slip, dashboard (recent-scans), settings, and tracker for their authenticated fetches. Email/password users never had that key, so those fetches sent no Authorization header and silently returned nothing. - web/src/lib/authToken.js — currentAccessToken() reads the REAL Supabase session (sb-<ref>-auth-token, v2 top-level or v1 currentSession), legacy fallback. CommonJS so Jest can unit-test it (5 tests). - Swept all 5 pages to the helper (scan already session-first from DS1). - lib/api.ts (0 callers) + ParlayTray (unmounted) left as dead code. 236 suites / 2842 tests green, next build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
207 lines
8.2 KiB
TypeScript
207 lines
8.2 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { currentAccessToken } from '@/lib/authToken';
|
|
|
|
interface FullProfile {
|
|
id: string;
|
|
email: string;
|
|
tier: 'free' | 'analyst' | 'desk';
|
|
scan_count: number;
|
|
scan_reset_date: string;
|
|
subscription_status: string;
|
|
subscription_end: string | null;
|
|
founder_pricing: boolean;
|
|
cancel_at_period_end: boolean;
|
|
}
|
|
|
|
export default function ProfilePage() {
|
|
const router = useRouter();
|
|
const { user, tier: authTier, signOut, loading: authLoading } = useAuth();
|
|
const [profile, setProfile] = useState<FullProfile | null>(null);
|
|
const [working, setWorking] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !user) router.replace('/login?next=/profile');
|
|
}, [authLoading, user, router]);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
const token = currentAccessToken();
|
|
fetch('/api/user/profile', {
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
})
|
|
.then((r) => r.json())
|
|
.then(setProfile)
|
|
.catch(() => setProfile(null));
|
|
}, [user]);
|
|
|
|
const handleCancel = async () => {
|
|
if (!confirm('Cancel your subscription at the end of the current period?')) return;
|
|
setWorking(true);
|
|
setError('');
|
|
const token = currentAccessToken();
|
|
const res = await fetch('/api/user/profile', {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: JSON.stringify({ cancel_at_period_end: true }),
|
|
});
|
|
setWorking(false);
|
|
if (res.ok) {
|
|
const updated = await res.json();
|
|
setProfile((p) => (p ? { ...p, ...updated } : p));
|
|
} else {
|
|
const body = await res.json().catch(() => ({}));
|
|
setError(body.error || 'Could not update — try again in a moment.');
|
|
}
|
|
};
|
|
|
|
if (authLoading || !user || !profile) {
|
|
return (
|
|
<section style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading profile…</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// Session 41 — tier is read from useAuth (the same live-session source the
|
|
// nav uses) so the two can't disagree. The /api/user/profile fetch can lag
|
|
// or return a stale/null tier; the auth context is authoritative. Profile
|
|
// still supplies the other fields (scan_count, subscription_*, founder).
|
|
const tier = authTier || profile.tier || 'free';
|
|
|
|
return (
|
|
<section style={{ maxWidth: 600, margin: '0 auto', padding: '24px 16px 120px' }}>
|
|
<header style={{ marginBottom: 24 }}>
|
|
<h1 style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 4 }}>
|
|
Profile
|
|
</h1>
|
|
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
|
|
{profile.email}
|
|
</p>
|
|
</header>
|
|
|
|
{/* Tier card */}
|
|
<section className="surface diagonal-cut animate-fade-up" style={{ padding: 24, marginBottom: 16 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
|
<div>
|
|
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>YOUR TIER</p>
|
|
{/* Session 27 — always render a tier label. When the profile
|
|
API returns null/undefined tier (free users sometimes do),
|
|
fall back to 'free' so the field is never blank. */}
|
|
<h2 style={{ fontSize: 28, fontWeight: 800, marginTop: 4, textTransform: 'capitalize', color: tierColor(tier) }}>
|
|
{tier}
|
|
{profile.founder_pricing && (
|
|
<span
|
|
className="mono"
|
|
style={{
|
|
marginLeft: 12,
|
|
fontSize: 11,
|
|
padding: '2px 8px',
|
|
borderRadius: 999,
|
|
background: 'var(--accent)',
|
|
color: 'var(--text-primary)',
|
|
verticalAlign: 'middle',
|
|
}}
|
|
>
|
|
FOUNDER
|
|
</span>
|
|
)}
|
|
</h2>
|
|
</div>
|
|
{tier === 'free' && (
|
|
<a href="/api/checkout?tier=analyst" className="btn-primary" style={{ padding: '10px 18px', fontSize: 13 }}>
|
|
Upgrade
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
{tier !== 'free' && (
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
|
<Stat label="Status" value={profile.subscription_status} tone={profile.subscription_status === 'active' ? 'good' : 'warn'} />
|
|
<Stat label="Renews" value={profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : '—'} />
|
|
</div>
|
|
)}
|
|
|
|
{tier === 'free' && (
|
|
<Stat label="Reads this month" value={`${profile.scan_count} of 5`} />
|
|
)}
|
|
</section>
|
|
|
|
{/* Founder pricing promo for free users */}
|
|
{tier === 'free' && (
|
|
<section className="surface diagonal-cut-strong" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-a)' }}>
|
|
<p className="mono" style={{ fontSize: 11, color: 'var(--grade-a)', letterSpacing: '0.08em', marginBottom: 8 }}>
|
|
FOUNDER ACCESS
|
|
</p>
|
|
<h3 style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>
|
|
Lock $14.99/mo for life before it's gone.
|
|
</h3>
|
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
|
First 100 users keep founder pricing even after the regular price jumps to $24.99.
|
|
</p>
|
|
<a href="/api/checkout?tier=analyst" className="btn-primary">
|
|
Lock founder price →
|
|
</a>
|
|
</section>
|
|
)}
|
|
|
|
{/* Subscription actions */}
|
|
{tier !== 'free' && !profile.cancel_at_period_end && (
|
|
<section className="surface" style={{ padding: 20, marginBottom: 16 }}>
|
|
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>Cancel subscription</h3>
|
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
|
Your access continues through the end of the current billing period. No refunds for partial months.
|
|
</p>
|
|
{error && <p style={{ color: 'var(--grade-d)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
|
<button onClick={handleCancel} disabled={working} className="btn-ghost" style={{ color: 'var(--grade-d)' }}>
|
|
{working ? 'Working…' : 'Cancel at period end'}
|
|
</button>
|
|
</section>
|
|
)}
|
|
|
|
{profile.cancel_at_period_end && (
|
|
<section className="surface" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-c)' }}>
|
|
<p style={{ fontSize: 13, color: 'var(--grade-c)' }}>
|
|
Cancellation scheduled. Access ends{' '}
|
|
{profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : 'at period end'}.
|
|
</p>
|
|
</section>
|
|
)}
|
|
|
|
{/* Sign out */}
|
|
<section style={{ marginTop: 32, textAlign: 'center' }}>
|
|
<button onClick={() => signOut().then(() => router.replace('/'))} className="btn-ghost" style={{ padding: '12px 24px' }}>
|
|
Sign out
|
|
</button>
|
|
</section>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Stat({ label, value, tone }: { label: string; value: string; tone?: 'good' | 'warn' }) {
|
|
const color = tone === 'good' ? 'var(--grade-a)' : tone === 'warn' ? 'var(--grade-c)' : 'var(--text-primary)';
|
|
return (
|
|
<div style={{ padding: '12px 14px', background: 'var(--bg-elevated)', borderRadius: 10 }}>
|
|
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
|
{label.toUpperCase()}
|
|
</div>
|
|
<div className="mono" style={{ fontSize: 14, fontWeight: 700, color, marginTop: 4, textTransform: 'capitalize' }}>
|
|
{value}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function tierColor(tier: string): string {
|
|
if (tier === 'desk') return 'var(--grade-a)';
|
|
if (tier === 'analyst') return 'var(--grade-b)';
|
|
return 'var(--text-primary)';
|
|
}
|