S10 (a1): public ledger profiles v1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Public profile proxy (A1 Session 10) — forwards GET /api/profiles/:handle.
|
||||
* Express owns the privacy rules: unpublished and unknown handles come back
|
||||
* as the SAME 404 body (no existence leak) — this proxy passes it through
|
||||
* untouched.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ handle: string }> },
|
||||
) {
|
||||
const { handle } = await params;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({ error: 'Profile not found' }));
|
||||
return NextResponse.json(data, {
|
||||
status: upstream.status,
|
||||
headers: upstream.ok ? { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120' } : undefined,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Own-profile proxy (A1 Session 10) — forwards GET/POST /api/profiles/me
|
||||
* with the caller's Authorization header (Express requireAuth resolves the
|
||||
* user; the publish toggle + handle claim live behind it).
|
||||
*/
|
||||
function authHeaders(req: NextRequest): HeadersInit {
|
||||
const auth = req.headers.get('authorization');
|
||||
return { Accept: 'application/json', 'Content-Type': 'application/json', ...(auth ? { Authorization: auth } : {}) };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!req.headers.get('authorization')) return NextResponse.json({ profile: null }, { status: 401 });
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/profiles/me`, { headers: authHeaders(req), cache: 'no-store' });
|
||||
const data = await upstream.json().catch(() => ({ profile: null }));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ profile: null }, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/profiles/me`, { method: 'POST', headers: authHeaders(req), body });
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Profile service unreachable.' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,54 @@ export default function SettingsPage() {
|
||||
} catch { /* best-effort */ } finally { setPrefSaving(false); }
|
||||
};
|
||||
|
||||
// A1 Session 10 — public ledger profile: claim a handle + the ONE explicit
|
||||
// publish toggle. PRIVATE BY DEFAULT — nothing is public until the user
|
||||
// flips the toggle and saves.
|
||||
const [pfHandle, setPfHandle] = useState('');
|
||||
const [pfPublished, setPfPublished] = useState(false);
|
||||
const [pfSaving, setPfSaving] = useState(false);
|
||||
const [pfMsg, setPfMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [pfLive, setPfLive] = useState<{ handle: string; published: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.access_token) return;
|
||||
fetch('/api/profiles/me', { headers: { Authorization: `Bearer ${session.access_token}` } })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => {
|
||||
const p = d?.profile;
|
||||
if (!p) return;
|
||||
setPfHandle(p.handle || '');
|
||||
setPfPublished(Boolean(p.published));
|
||||
setPfLive({ handle: p.handle || '', published: Boolean(p.published) });
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [session]);
|
||||
|
||||
const saveProfile = async () => {
|
||||
setPfSaving(true);
|
||||
setPfMsg(null);
|
||||
try {
|
||||
const res = await fetch('/api/profiles/me', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}) },
|
||||
body: JSON.stringify({ handle: pfHandle, published: pfPublished }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
setPfLive({ handle: pfHandle, published: pfPublished });
|
||||
setPfMsg({ ok: true, text: pfPublished ? 'Published. Your settled record is live.' : 'Saved. Your record stays private.' });
|
||||
} else if (res.status === 409) {
|
||||
setPfMsg({ ok: false, text: 'That handle is taken.' });
|
||||
} else {
|
||||
setPfMsg({ ok: false, text: data?.error || 'Could not save profile.' });
|
||||
}
|
||||
} catch {
|
||||
setPfMsg({ ok: false, text: 'Network error. Try again.' });
|
||||
} finally {
|
||||
setPfSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const plan = tierLabel(tier || 'free');
|
||||
const canDelete = deleteText === 'DELETE';
|
||||
|
||||
@@ -231,6 +279,46 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* PUBLIC PROFILE (A1 Session 10) */}
|
||||
<Section label="PUBLIC PROFILE">
|
||||
<p style={{ margin: '0 0 14px', fontSize: 13, lineHeight: 1.55, color: 'var(--text-1)' }}>
|
||||
Publishing puts your ENTIRE settled record on a public page — wins and misses. Private by default.
|
||||
</p>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-0)', marginBottom: 8 }}>Handle</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<span className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>vyndr.app/u/</span>
|
||||
<input
|
||||
value={pfHandle}
|
||||
onChange={(e) => setPfHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 20))}
|
||||
placeholder="your_handle"
|
||||
aria-label="Public profile handle"
|
||||
className="mono"
|
||||
style={{ width: 200, padding: '9px 12px', borderRadius: 8, background: 'var(--bg-2)', border: '1px solid var(--border-hi)', color: '#fff', fontSize: 13, outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<Row>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Publish my record</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Every settled read, nothing curated</div>
|
||||
</div>
|
||||
<Toggle on={pfPublished} onClick={() => setPfPublished((v) => !v)} />
|
||||
</Row>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 12, flexWrap: 'wrap' }}>
|
||||
<button type="button" onClick={saveProfile} disabled={pfSaving || pfHandle.length < 3} className="mono"
|
||||
style={{ cursor: pfHandle.length >= 3 ? 'pointer' : 'not-allowed', padding: '9px 16px', borderRadius: 8, fontWeight: 700, fontSize: 11, letterSpacing: '0.04em', border: '1px solid var(--g-a)', background: pfHandle.length >= 3 ? 'var(--g-a)' : 'transparent', color: pfHandle.length >= 3 ? '#06060B' : 'var(--text-1)' }}>
|
||||
{pfSaving ? 'SAVING…' : 'SAVE PROFILE'}
|
||||
</button>
|
||||
{pfMsg && (
|
||||
<span className="mono" style={{ fontSize: 12, color: pfMsg.ok ? 'var(--g-a)' : 'var(--miss)' }}>{pfMsg.text}</span>
|
||||
)}
|
||||
{pfLive?.published && pfLive.handle && (
|
||||
<a href={`/u/${pfLive.handle}`} className="mono" style={{ fontSize: 12, color: 'var(--g-a)', textDecoration: 'none' }}>
|
||||
VIEW PUBLIC PAGE →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* NOTIFICATIONS */}
|
||||
<Section label="NOTIFICATIONS">
|
||||
<Row>
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { GradePill } from '@/components/GradeCard';
|
||||
|
||||
/**
|
||||
* PublicProfile (A1 Session 10) — the public ledger record for one handle.
|
||||
*
|
||||
* DATA SEMANTICS: every row here is a REAL settled ledger entry — outcome,
|
||||
* actual value, closing-line value. Nothing curated: publishing shows the
|
||||
* ENTIRE settled record, misses included. The header NEVER renders a
|
||||
* percentage under min_sample (20) settles — it shows RECORD BUILDING.
|
||||
*
|
||||
* Unknown and unpublished handles arrive as the same 404 — the page renders
|
||||
* one indistinguishable not-found state for both.
|
||||
*/
|
||||
|
||||
interface ProfileRow {
|
||||
id: string;
|
||||
player_name: string;
|
||||
sport: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
side: 'over' | 'under';
|
||||
locked_odds?: string | null;
|
||||
book?: string | null;
|
||||
grade: string;
|
||||
model_value?: number | null;
|
||||
game_date: string;
|
||||
closing_line?: number | null;
|
||||
clv?: number | null;
|
||||
clv_result?: 'beat' | 'faded' | 'flat' | null;
|
||||
outcome?: 'hit' | 'miss' | 'push' | null;
|
||||
actual_value?: number | null;
|
||||
revised_from_grade?: string | null;
|
||||
}
|
||||
|
||||
interface ProfileAggregate {
|
||||
settled: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
pushes: number;
|
||||
hit_pct: number | null;
|
||||
clv_sample: number;
|
||||
clv_beat: number;
|
||||
beat_close_pct: number | null;
|
||||
pending: number;
|
||||
}
|
||||
|
||||
interface ProfilePayload {
|
||||
handle: string;
|
||||
aggregate: ProfileAggregate | null;
|
||||
entries: ProfileRow[];
|
||||
min_sample?: number;
|
||||
}
|
||||
|
||||
const SPORT_COLOR: Record<string, string> = {
|
||||
nba: '#E94B3C',
|
||||
mlb: '#1E90FF',
|
||||
wnba: '#FFB347',
|
||||
soccer: '#7BC96F',
|
||||
};
|
||||
|
||||
export default function PublicProfile({ handle }: { handle: string }) {
|
||||
const [data, setData] = useState<ProfilePayload | null>(null);
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'notfound'>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setState('loading');
|
||||
fetch(`/api/profiles/${encodeURIComponent(handle)}`)
|
||||
.then(async (r) => {
|
||||
if (!active) return;
|
||||
if (!r.ok) { setState('notfound'); return; }
|
||||
const d = await r.json();
|
||||
if (!active) return;
|
||||
setData(d);
|
||||
setState('ready');
|
||||
})
|
||||
.catch(() => { if (active) setState('notfound'); });
|
||||
return () => { active = false; };
|
||||
}, [handle]);
|
||||
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<section style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading record…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'notfound' || !data) {
|
||||
// Same state for unknown AND unpublished — the API never tells us which.
|
||||
return (
|
||||
<section style={{ maxWidth: 640, margin: '0 auto', padding: '64px 16px 120px', textAlign: 'center' }}>
|
||||
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--amber, #FFB347)', marginBottom: 10 }}>
|
||||
NO RECORD HERE
|
||||
</p>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>This profile does not exist or is not published.</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
VYNDR ledgers are private by default. A record only appears here when its owner publishes it.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const agg = data.aggregate;
|
||||
const minSample = Number(data.min_sample) > 0 ? Number(data.min_sample) : 20;
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 20 }}>
|
||||
<p className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--g-a, #00D4A0)', marginBottom: 8 }}>
|
||||
PUBLIC LEDGER
|
||||
</p>
|
||||
<h1 className="mono" style={{ fontSize: 32, fontWeight: 800, letterSpacing: '-0.02em', marginBottom: 6 }}>
|
||||
@{data.handle}
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 15 }}>
|
||||
Every settled read. Wins and misses. Nothing curated.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Record header — never a percentage under min_sample settles. */}
|
||||
<RecordHeader agg={agg} minSample={minSample} />
|
||||
|
||||
{data.entries.length === 0 ? (
|
||||
<div className="surface diagonal-cut" style={{ padding: 48, textAlign: 'center' }}>
|
||||
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
|
||||
NO SETTLED READS YET
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Reads land here as they settle against real results.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
|
||||
{data.entries.map((row, i) => (
|
||||
<ProfileCard key={row.id} row={row} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordHeader({ agg, minSample }: { agg: ProfileAggregate | null; minSample: number }) {
|
||||
const ready = Boolean(agg && agg.settled >= minSample && agg.hit_pct != null);
|
||||
return (
|
||||
<div
|
||||
className="surface diagonal-cut"
|
||||
style={{ padding: 20, marginBottom: 20, border: `1px solid ${ready ? 'var(--g-a, #00D4A0)' : 'var(--border)'}` }}
|
||||
>
|
||||
{ready && agg ? (
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'baseline' }}>
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)' }}>
|
||||
RECORD · LAST 30D
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--g-a, #00D4A0)' }}>
|
||||
{agg.hits}-{agg.misses} · {agg.hit_pct}% HIT
|
||||
</span>
|
||||
{agg.beat_close_pct != null && (
|
||||
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>
|
||||
{agg.beat_close_pct}% BEAT CLOSE
|
||||
</span>
|
||||
)}
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||
{agg.pending} pending
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="mono" style={{ fontSize: 13, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>
|
||||
RECORD BUILDING
|
||||
</p>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
|
||||
Percentages render at {minSample} settled reads.
|
||||
{agg && (
|
||||
<span className="mono" style={{ color: 'var(--text-primary)' }}>
|
||||
{' '}{agg.settled} settled · {agg.pending} pending
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutcomeChip({ row }: { row: ProfileRow }) {
|
||||
if (!row.outcome) {
|
||||
return <span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>PENDING</span>;
|
||||
}
|
||||
const color = row.outcome === 'hit' ? 'var(--g-a, #00D4A0)'
|
||||
: row.outcome === 'miss' ? 'var(--miss, #FF6B6B)' : 'var(--text-secondary)';
|
||||
const mark = row.outcome === 'hit' ? '✓ HIT' : row.outcome === 'miss' ? '✕ MISS' : '– PUSH';
|
||||
return (
|
||||
<span className="mono" style={{ fontSize: 12, fontWeight: 700, color }}>
|
||||
{mark}{row.actual_value != null ? ` (${row.actual_value})` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ClvChip({ row }: { row: ProfileRow }) {
|
||||
if (!row.clv_result || row.clv == null) return null;
|
||||
const color = row.clv_result === 'beat' ? 'var(--g-a, #00D4A0)'
|
||||
: row.clv_result === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)';
|
||||
return (
|
||||
<span className="mono" title={`Closing line value: locked ${row.line}, closed ${row.closing_line}`}
|
||||
style={{ fontSize: 10.5, fontWeight: 700, color, letterSpacing: '0.04em' }}>
|
||||
CLV {row.clv > 0 ? '+' : ''}{row.clv} · {row.clv_result.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {
|
||||
const sportColor = SPORT_COLOR[row.sport] || 'var(--text-secondary)';
|
||||
return (
|
||||
<article className={`surface diagonal-cut animate-fade-up stagger-${(index % 6) + 1}`} style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
|
||||
<span className="mono" style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 999, background: `${sportColor}1F`, color: sportColor }}>
|
||||
{row.sport.toUpperCase()}
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{row.revised_from_grade && (
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', textDecoration: 'line-through' }}>
|
||||
{row.revised_from_grade}
|
||||
</span>
|
||||
)}
|
||||
<GradePill grade={row.grade} />
|
||||
</span>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{row.player_name}</h3>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>
|
||||
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||
{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
|
||||
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
|
||||
<OutcomeChip row={row} />
|
||||
<ClvChip row={row} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
// A1 Session 10 — every shared /u/:handle link unfurls as a record card.
|
||||
// Self-hosted standalone build → Node runtime (NOT edge; Session-53 rule —
|
||||
// next/og breaks under edge off Vercel).
|
||||
export const alt = 'VYNDR Public Ledger';
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default async function Image({ params }: { params: Promise<{ handle: string }> }) {
|
||||
const { handle } = await params;
|
||||
const h = decodeURIComponent(handle || '').slice(0, 20).toLowerCase() || 'handle';
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: '#06060B',
|
||||
color: '#E8E8F0',
|
||||
padding: 72,
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', position: 'absolute', top: 0, left: 0, right: 0, height: 6, background: '#00D4A0' }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', fontSize: 26, letterSpacing: '0.22em', color: '#00D4A0', fontWeight: 700 }}>
|
||||
PUBLIC LEDGER
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 84, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 18, color: '#FFFFFF' }}>
|
||||
@{h}
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 28, color: '#7A7A8E', marginTop: 20 }}>
|
||||
CLV-verified record · every settled read · misses included
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', fontSize: 44, fontWeight: 900, letterSpacing: '0.08em' }}>
|
||||
<span style={{ color: '#FFFFFF' }}>VYND</span>
|
||||
<span style={{ color: '#00D4A0' }}>R</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 22, color: '#4A4A5E', letterSpacing: '0.12em' }}>
|
||||
NOTHING CURATED · NOTHING DELETED
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from 'next';
|
||||
import PublicProfile from './PublicProfile';
|
||||
|
||||
/**
|
||||
* /u/[handle] (A1 Session 10) — a user's PUBLIC ledger profile. Thin server
|
||||
* wrapper for metadata (+ the segment's opengraph-image.tsx share card);
|
||||
* the record itself renders in the PublicProfile client component.
|
||||
*
|
||||
* PUBLIC route — never gated. Publishing is the owner's explicit choice;
|
||||
* once published, the whole settled record is the page. Misses included.
|
||||
*/
|
||||
export async function generateMetadata({ params }: { params: Promise<{ handle: string }> }): Promise<Metadata> {
|
||||
const { handle } = await params;
|
||||
const h = decodeURIComponent(handle || '').slice(0, 20).toLowerCase();
|
||||
const title = `CLV-verified record — @${h} · VYNDR`;
|
||||
const description = `@${h}'s settled betting record on VYNDR — every read, every result, closing-line value included. Nothing curated, nothing deleted.`;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description },
|
||||
twitter: { card: 'summary_large_image', title, description },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PublicProfilePage({ params }: { params: Promise<{ handle: string }> }) {
|
||||
const { handle } = await params;
|
||||
return <PublicProfile handle={decodeURIComponent(handle || '')} />;
|
||||
}
|
||||
@@ -53,6 +53,11 @@ const OPEN_ROUTES = [
|
||||
'/welcome',
|
||||
'/offline',
|
||||
'/upgrade',
|
||||
/* A1 Session 10 — public ledger profiles. /u/:handle is the SHARE surface:
|
||||
it must load anonymous (the whole point is a public record). Publishing
|
||||
is the owner's explicit opt-in; the privacy gate lives in the API
|
||||
(unpublished → 404), never in the router. */
|
||||
'/u',
|
||||
];
|
||||
|
||||
/* Hash deep-link aliases (§C.3.4). The prototype was a single HTML file using
|
||||
|
||||
Reference in New Issue
Block a user