Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
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, 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 = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||
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 = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<h2 style={{ fontSize: 28, fontWeight: 800, marginTop: 4, textTransform: 'capitalize', color: tierColor(profile.tier) }}>
|
||||
{profile.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>
|
||||
{profile.tier === 'free' && (
|
||||
<a href="/api/checkout?tier=analyst" className="btn-primary" style={{ padding: '10px 18px', fontSize: 13 }}>
|
||||
Upgrade
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{profile.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>
|
||||
)}
|
||||
|
||||
{profile.tier === 'free' && (
|
||||
<Stat label="Reads this month" value={`${profile.scan_count} of 5`} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Founder pricing promo for free users */}
|
||||
{profile.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 */}
|
||||
{profile.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)';
|
||||
}
|
||||
Reference in New Issue
Block a user