feat(vip): Add VIP quote page component and route to /quotes/vip in index.html

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-18 00:27:14 -07:00
parent dba94b6dad
commit 87faa3367e
2 changed files with 130 additions and 3 deletions

View file

@ -53,6 +53,85 @@
.vip-personal-note { font-size: 24px; max-width: 100%; }
}
/* vip-text-well: prose has tighter measure than the wide container */
.vip-section-body { max-width: 720px; margin: 0 auto; }
.vip-section-hero .vip-doc-intro { max-width: 640px; }
/* but the option-card grid and the table get the full container width */
.vip-section-body .vip-pricing,
.vip-section-body .vip-table-wrap { max-width: none; }
/* Salutation spacing */
.vip-doc-intro .vip-p:first-child { margin-bottom: 6px; }
/* vip-section-nav */
.vip-section-nav {
position: fixed;
top: 50%;
right: 28px;
transform: translateY(-50%);
z-index: 10;
padding: 14px 0;
pointer-events: auto;
}
.vip-section-nav-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.vip-section-nav-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
color: rgba(212,175,55,0.55);
font-family: 'Menlo', 'Monaco', monospace;
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
transition: color 220ms ease, transform 220ms ease;
}
.vip-section-nav-link::before {
content: '';
display: block;
width: 18px;
height: 1px;
background: currentColor;
opacity: 0.6;
transition: width 280ms ease;
}
.vip-section-nav-item.is-active .vip-section-nav-link {
color: #D4AF37;
}
.vip-section-nav-item.is-active .vip-section-nav-link::before {
width: 40px;
opacity: 1;
}
.vip-section-nav-link:hover { color: #D4AF37; }
.vip-section-nav-index {
flex: 0 0 auto;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
.vip-section-nav-label {
max-width: 140px;
opacity: 0;
transform: translateX(-4px);
transition: opacity 220ms ease, transform 220ms ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vip-section-nav:hover .vip-section-nav-label,
.vip-section-nav-item.is-active .vip-section-nav-label {
opacity: 1;
transform: translateX(0);
}
@media (max-width: 980px) {
.vip-section-nav { display: none; }
}
/* vip-parallax: section bands, ghost numerals, scroll reveal */
.vip-doc {
display: flex;
@ -69,7 +148,7 @@
}
.vip-section-inner {
width: 100%;
max-width: 760px;
max-width: 920px;
margin: 0 auto;
position: relative;
z-index: 1;

View file

@ -371,7 +371,8 @@ export function QuotePage(): ReactElement {
const { intro, sections } = parseDocSections(doc.body);
return (
<div className="vip-doc">
<section className="vip-section vip-section-hero">
<SectionNav items={sections.map((s, i) => ({ id: `section-${i + 1}`, label: s.heading }))} />
<section className="vip-section vip-section-hero" id="section-hero">
<div className="vip-section-inner">
<div className="vip-stamp vip-stamp-hero">
<span>Quote {state.quote.version}</span>
@ -389,6 +390,7 @@ export function QuotePage(): ReactElement {
{sections.map((s, i) => (
<section
key={i}
id={`section-${i + 1}`}
className={`vip-section vip-section-band vip-section-${i % 2 === 0 ? 'b' : 'a'}`}
data-revealable
>
@ -435,6 +437,52 @@ interface MdBlock {
rows?: InlineToken[][][];
}
interface SectionNavItem { id: string; label: string }
function SectionNav({ items }: { items: SectionNavItem[] }): ReactElement {
const [active, setActive] = useState<string | null>(null);
useEffect(() => {
if (typeof IntersectionObserver === 'undefined') return;
const nodes = items
.map((it) => document.getElementById(it.id))
.filter((n): n is HTMLElement => n !== null);
if (nodes.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
if (visible.length > 0) setActive(visible[0]!.target.id);
},
{ rootMargin: '-30% 0px -55% 0px', threshold: [0, 0.25, 0.5, 0.75, 1] },
);
for (const n of nodes) observer.observe(n);
return () => observer.disconnect();
}, [items]);
const handleJump = (e: React.MouseEvent<HTMLAnchorElement>, id: string): void => {
e.preventDefault();
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<nav className="vip-section-nav" aria-label="Sections">
<ol className="vip-section-nav-list">
{items.map((it, i) => (
<li key={it.id} className={`vip-section-nav-item${active === it.id ? ' is-active' : ''}`}>
<a href={`#${it.id}`} onClick={(e) => handleJump(e, it.id)} className="vip-section-nav-link">
<span className="vip-section-nav-index">{String(i + 1).padStart(2, '0')}</span>
<span className="vip-section-nav-label">{it.label}</span>
</a>
</li>
))}
</ol>
</nav>
);
}
interface QuoteDocParts {
title: string;
subtitle: string | null;
@ -780,7 +828,7 @@ const rootStyle: React.CSSProperties = {
justifyContent: 'center',
padding: '64px 24px 96px',
position: 'relative',
fontFamily: "'system-ui', -apple-system, sans-serif",
fontFamily: "'Inter', system-ui, -apple-system, sans-serif",
color: G.cream,
};