Wave 5A: /u house-mode public profile (D2)
Reserved house handle (default 'vyndr', env HOUSE_HANDLE) resolves to the PUBLIC model record — getModelAggregate() with no userId (user_id=NULL rows) — WITHOUT a public_profiles row. It is the ONLY special case; every other handle keeps the private-by-default, byte-identical-404 no-existence-leak contract. The house profile is always public and never 404s (a fetch failure degrades to an honest building state). - src/routes/profiles.js: house short-circuit + sendHouseProfile (public aggregate + by_tier + public settled entries), reserved before the publish lookup so a user claim is shadowed. - PublicProfile.tsx: house label 'VYNDR MODEL · PUBLIC RECORD' + hero/subtitle off data.house; keeps the CLV-VERIFIED record hero + TierRecord calibration + recent settled reads (misses included). - opengraph-image.tsx (1200x630): house-branded eyebrow/heading. - portrait/route.tsx: new 1080x1350 share crop (real aggregate or tagline fallback, never a fabricated number). - Discoverability: 'VIEW AS PUBLIC PAGE ->' on the ledger MODEL header + 'VIEW PUBLIC RECORD ->' under the landing ModelRecord, both to /u/vyndr. - tests/unit/houseProfile.test.js: house resolves to user_id=NULL aggregate (no public_profiles row) + by_tier; unknown/unpublished user handles stay byte-identical 404; page renders house label + TierRecord + portrait crop. 3019 tests green (3012 -> 3019); next build EXIT=0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { GradePill } from '@/components/GradeCard';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark, TierRecord } from '@/components/vyndr';
|
||||
@@ -284,6 +285,17 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe
|
||||
{/* Wave 3 (Addition 2) — the ONE shared record-by-grade-tier table,
|
||||
identical here, on the dashboard, and on /u. */}
|
||||
<TierRecord byTier={agg.by_tier} minSample={minSample} />
|
||||
{/* Wave 5A (D2) — the shareable house/model profile. Same record, a
|
||||
public page you can hand a partner. */}
|
||||
<div style={{ marginTop: 14, textAlign: 'right' }}>
|
||||
<Link
|
||||
href="/u/vyndr"
|
||||
className="mono"
|
||||
style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--g-a, #00D4A0)', textDecoration: 'none' }}
|
||||
>
|
||||
VIEW AS PUBLIC PAGE →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-1
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Hero from '@/components/Hero';
|
||||
@@ -85,8 +86,16 @@ export default function Home() {
|
||||
{/* Session 58 (work-order 1.4) — the public model record (proof-strip
|
||||
footer). Deferred-render: "RECORD BUILDING" until 20 settles, then
|
||||
the real hit% + beat-close%. Self-hides with no data. */}
|
||||
<div style={{ padding: '4px 16px 12px' }}>
|
||||
<div style={{ padding: '4px 16px 12px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||
<ModelRecord />
|
||||
{/* Wave 5A (D2) — the shareable public model record (the house profile). */}
|
||||
<Link
|
||||
href="/u/vyndr"
|
||||
className="mono"
|
||||
style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-tertiary)', textDecoration: 'none' }}
|
||||
>
|
||||
VIEW PUBLIC RECORD →
|
||||
</Link>
|
||||
</div>
|
||||
{/* Founder-seat scarcity meter (§12) */}
|
||||
<div style={{ padding: '0 16px 8px' }}>
|
||||
|
||||
@@ -61,6 +61,11 @@ interface ProfilePayload {
|
||||
aggregate: ProfileAggregate | null;
|
||||
entries: ProfileRow[];
|
||||
min_sample?: number;
|
||||
// Wave 5A (D2) — the HOUSE/model profile: the public user_id=NULL record,
|
||||
// shareable without a claimed public_profiles row. `label` distinguishes it
|
||||
// from a user profile ("VYNDR MODEL · PUBLIC RECORD").
|
||||
house?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const SPORT_COLOR: Record<string, string> = {
|
||||
@@ -115,18 +120,21 @@ export default function PublicProfile({ handle }: { handle: string }) {
|
||||
|
||||
const agg = data.aggregate;
|
||||
const minSample = Number(data.min_sample) > 0 ? Number(data.min_sample) : 20;
|
||||
const isHouse = Boolean(data.house);
|
||||
|
||||
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
|
||||
{isHouse ? (data.label || 'VYNDR MODEL · PUBLIC RECORD') : 'PUBLIC LEDGER'}
|
||||
</p>
|
||||
<h1 className="mono" style={{ fontSize: 32, fontWeight: 800, letterSpacing: '-0.02em', marginBottom: 6 }}>
|
||||
@{data.handle}
|
||||
{isHouse ? 'VYNDR MODEL' : `@${data.handle}`}
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 15 }}>
|
||||
Every settled read. Wins and misses. Nothing curated.
|
||||
{isHouse
|
||||
? 'The VYNDR model’s public record. Every graded read, wins and misses, closing-line value included.'
|
||||
: 'Every settled read. Wins and misses. Nothing curated.'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export const contentType = 'image/png';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; beat_close_pct: number | null; hits: number; misses: number } | null> {
|
||||
async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; beat_close_pct: number | null; hits: number; misses: number; house: boolean } | null> {
|
||||
try {
|
||||
const r = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
@@ -21,7 +21,7 @@ async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; be
|
||||
const d = await r.json();
|
||||
const agg = d && d.aggregate;
|
||||
if (!agg || agg.hit_pct == null) return null;
|
||||
return { hit_pct: agg.hit_pct, beat_close_pct: agg.beat_close_pct ?? null, hits: agg.hits, misses: agg.misses };
|
||||
return { hit_pct: agg.hit_pct, beat_close_pct: agg.beat_close_pct ?? null, hits: agg.hits, misses: agg.misses, house: Boolean(d.house) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -49,10 +49,10 @@ export default async function Image({ params }: { params: Promise<{ handle: stri
|
||||
<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 }}>
|
||||
{rec ? 'CLV-VERIFIED RECORD · 30D' : 'PUBLIC LEDGER'}
|
||||
{rec ? (rec.house ? 'VYNDR MODEL · PUBLIC RECORD' : 'CLV-VERIFIED RECORD · 30D') : 'PUBLIC LEDGER'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 72, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 14, color: '#FFFFFF' }}>
|
||||
@{h}
|
||||
{rec?.house ? 'VYNDR MODEL' : `@${h}`}
|
||||
</div>
|
||||
{rec ? (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 56, marginTop: 26 }}>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* /u/[handle]/portrait (Wave 5A, D2) — the PORTRAIT share crop (1080×1350,
|
||||
* the 4:5 story/feed format from the design mockup). The square-crop OG
|
||||
* (opengraph-image.tsx, 1200×630) unfurls a link; this is the standalone
|
||||
* image you drop into a story or a partner deck.
|
||||
*
|
||||
* It reads the SAME /api/profiles/:handle payload — so the house handle
|
||||
* (`vyndr`) renders the real public model record, a published user handle
|
||||
* renders theirs, and anything else falls back to the tagline. NEVER a
|
||||
* fabricated number: no record past the n-gate → the tagline, not a zero.
|
||||
*
|
||||
* Self-hosted standalone build → Node runtime (NOT edge; Session-53 rule).
|
||||
*/
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const SIZE = { width: 1080, height: 1350 };
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
async function fetchRecord(handle: string): Promise<{
|
||||
hit_pct: number | null;
|
||||
beat_close_pct: number | null;
|
||||
hits: number;
|
||||
misses: number;
|
||||
house: boolean;
|
||||
} | null> {
|
||||
try {
|
||||
const r = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
const d = await r.json();
|
||||
const agg = d && d.aggregate;
|
||||
if (!agg || agg.hit_pct == null) return null;
|
||||
return {
|
||||
hit_pct: agg.hit_pct,
|
||||
beat_close_pct: agg.beat_close_pct ?? null,
|
||||
hits: agg.hits,
|
||||
misses: agg.misses,
|
||||
house: Boolean(d.house),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ handle: string }> },
|
||||
) {
|
||||
const { handle } = await params;
|
||||
const h = decodeURIComponent(handle || '').slice(0, 20).toLowerCase() || 'handle';
|
||||
const rec = await fetchRecord(h);
|
||||
const heading = rec?.house ? 'VYNDR MODEL' : `@${h}`;
|
||||
const eyebrow = rec ? (rec.house ? 'VYNDR MODEL · PUBLIC RECORD' : 'CLV-VERIFIED RECORD · 30D') : 'PUBLIC LEDGER';
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: '#06060B',
|
||||
color: '#E8E8F0',
|
||||
padding: 90,
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', position: 'absolute', top: 0, left: 0, right: 0, height: 8, background: '#00D4A0' }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', fontSize: 30, letterSpacing: '0.22em', color: '#00D4A0', fontWeight: 700 }}>
|
||||
{eyebrow}
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 84, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 18, color: '#FFFFFF' }}>
|
||||
{heading}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rec ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 44 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', fontSize: 240, fontWeight: 900, lineHeight: 1, color: '#00D4A0' }}>{rec.hit_pct}%</div>
|
||||
<div style={{ display: 'flex', fontSize: 30, letterSpacing: '0.14em', color: '#7A7A8E', marginTop: 12 }}>HIT RATE · {rec.hits}-{rec.misses}</div>
|
||||
</div>
|
||||
{rec.beat_close_pct != null && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', fontSize: 128, fontWeight: 900, lineHeight: 1, color: '#FFFFFF' }}>{rec.beat_close_pct}%</div>
|
||||
<div style={{ display: 'flex', fontSize: 30, letterSpacing: '0.14em', color: '#7A7A8E', marginTop: 12 }}>BEAT CLOSE</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', fontSize: 40, color: '#7A7A8E', lineHeight: 1.4 }}>
|
||||
CLV-verified record · every settled read · misses included
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div style={{ display: 'flex', fontSize: 60, fontWeight: 900, letterSpacing: '0.08em' }}>
|
||||
<span style={{ color: '#FFFFFF' }}>VYND</span>
|
||||
<span style={{ color: '#00D4A0' }}>R</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 26, color: '#4A4A5E', letterSpacing: '0.12em' }}>
|
||||
NOTHING CURATED · NOTHING DELETED
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...SIZE },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user