podhouse-investor-qa
v0.4.1
Published
Headless playback engine for the Podhouse Investor Q&A — answer-window seek, speech-timed callouts, layout-aware captions, HLS via hls.js, fullscreen. A React hook plus optional default-styled, themeable components. No branding baked in.
Downloads
1,339
Readme
podhouse-investor-qa
The headless playback engine behind the Podhouse Investor Q&A — the tuned behavior
(answer-window seek, speech-timed callouts, layout-aware captions, HLS via hls.js, the
"answer card" seek gate, mobile fullscreen, a collapsible text-answer panel, and an
audio-only listen mode) as a React hook plus optional default-styled, themeable
components. No branding or client-specific styling is baked in.
You (the host site) fetch the payload from your own dataUrl route — a server-to-server
call to our per-site Basic-auth API — and pass it straight in. Build against the bundled
./fixtures today; the real API returns the same shape, so your views don't change when
it swaps in.
Install
npm install podhouse-investor-qaPeer deps: react (>=18) and hls.js (^1, optional — needed only for playback). Both
are already present in the target Next 16 / React 19 site.
Quick start (drop-in)
import { InvestorQa, InvestorQaStyles } from "podhouse-investor-qa";
export default function QaClient({ payload }) {
return (
<>
<InvestorQaStyles /> {/* or ship your own CSS against the .iqa-* hooks */}
<InvestorQa payload={payload} options={{ featured: ["S1-Q01", "S3-Q01"] }} />
</>
);
}payload is null while loading — the engine idles until it arrives.
Wiring the data (host side)
The payload comes from your dataUrl route. Fetch our API server-side with the
per-site secret (never in the browser):
// app/api/investor-qa/route.ts (or your existing dataUrl route)
export async function GET() {
const res = await fetch(`${process.env.IQA_API_BASE}/investor-qa/${PROJECT}`, {
headers: {
Authorization: "Basic " + Buffer
.from(`${process.env.IQA_SITE_ID}:${process.env.IQA_SITE_SECRET}`)
.toString("base64"),
},
cache: "no-store", // the HLS URL is short-lived / re-signed
});
return new Response(await res.text(), {
headers: { "content-type": "application/json" },
});
}Then fetch that route from the client and hand the JSON to <InvestorQa payload={...} />.
Composing the pieces
Prefer full control? Drive the hook and place the granular components yourself:
import {
useInvestorQa, Search, Player, AnswerText, Browse,
} from "podhouse-investor-qa";
function Qa({ payload }) {
const engine = useInvestorQa(payload, undefined, { featured: ["S1-Q01"] });
return (
<div className="iqa-root">
<Search engine={engine} placeholder="Ask about occupancy, taxes, risk…" />
<Player engine={engine} /> {/* audio-only "♪ Listen / ▶︎ Watch" toggle is built in */}
<AnswerText engine={engine} /> {/* collapsible full text answer under the player */}
<Browse engine={engine} />
</div>
);
}The drop-in
<InvestorQa>rendersSearch+Player+Browse; add<AnswerText>yourself (as above) when you want the text-answer panel.
useInvestorQa(payload, videoRef?, options?) returns { state, controls, refs }:
- state —
ready,title,chapters,sections,featured,activeChapter,mode(idle | card | clip),layout,cardVisible,cardLabel,isPlaying,isFullscreen,audioOnly,query,suggestions,activeSuggestion. - controls —
play(id),togglePlay(),seek(fraction01),enterFullscreen(),exitFullscreen(),toggleFullscreen(),setAudioOnly(on),toggleAudioOnly(),setQuery(q),moveSuggestion(±1),chooseActiveSuggestion(). - refs — the DOM bindings the default
Player/Callout/Captionsattach; attach a subset if you build a custom stage.
The email gate
Soft-gate the Q&A for lead capture: viewers watch freeAnswers distinct answers,
then the next new answer raises a prompt instead of playing. One email unlocks
everything, and both the watch count and the unlock persist in localStorage
(per browser — this is lead-capture friction on public content, not access
control; the media URL is unchanged). Replays of already-watched answers are
never blocked, and dismissing the prompt just returns the viewer to browsing.
const engine = useInvestorQa(payload, undefined, {
gate: {
freeAnswers: 3,
collectName: true, // the card asks for a full name beside the email
// Your capture endpoint — the engine never sends anything anywhere itself.
// info = { name, answersWatched } — the watch flow as QUESTION TEXTS, in the
// order first picked, persisted across sessions.
onSubmit: async (email, info) => {
const res = await fetch("/lead", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email, form: "qa-gate",
answers: { name: info.name, answersWatched: info.answersWatched },
}),
});
return res.ok; // false / throw keeps the gate up with an error state
},
// After the unlock, each new distinct answer reports the full flow — keep
// the CRM record current however you like.
onProgress: (answersWatched) => { /* re-post, debounced, etc. */ },
},
});
// <Gate engine={engine} /> — the default prompt (the <InvestorQa> drop-in
// renders it automatically when options.gate is set)The watch flow is also live state — state.questionsWatched (question texts,
watch order, persisted) — so a custom gate card can read it directly instead of
tracking plays itself.
<Gate engine={engine} />— a themed modal (kicker / heading / sub / email + CTA / dismiss, all overridable via props). Class hooks:.iqa-gate,.iqa-gate-backdrop,.iqa-gate-card,.iqa-gate-kick,.iqa-gate-h,.iqa-gate-sub,.iqa-gate-row,.iqa-gate-input,.iqa-gate-cta,.iqa-gate-err,.iqa-gate-dismiss.- Custom UI: read
state.gateVisible/state.gateUnlocked/state.answersWatched; callcontrols.submitGate(email)(validates, runsonSubmit, unlocks and resumes the blocked answer) or do your own capture and callcontrols.unlock();controls.closeGate()dismisses. - Options:
storageKey(default"iqa-gate") to scope persistence,persist: falsefor session-only counting.
Text answer & audio-only mode
Two extras beyond the player, both driven by the same engine:
<AnswerText engine={engine} />— a collapsible, readable transcript of the active question's full answer, shown under the player. Renders nothing until an answer is picked. Props:readLabel/hideLabel(toggle text),defaultOpen(defaulttrue). Class hooks:.iqa-answer,.iqa-answer-toggle,.iqa-answer-body,.iqa-answer-q,.iqa-answer-text.- Audio-only mode —
<Player>shows a "♪ Listen / ▶︎ Watch" toggle once an answer is active; in audio mode the video stays audible but the stage is replaced by a compact audio face (captions keep flowing). Turn the button off with<Player audio={false} />, or relabel vialistenLabel/watchLabel. Drive it yourself withcontrols.toggleAudioOnly()/controls.setAudioOnly(on)and readstate.audioOnly. Class hooks:.iqa-modebtn,.iqa-audio(on.iqa-player),.iqa-audioface,.iqa-audioface-icon,.iqa-audioface-q.
High-frequency work (caption text, seek value, callout reveal, play glyph) is written
imperatively to refs and never re-renders React; only real transitions touch state.
B-roll (0.4+)
A visual with kind: "broll" carries a short muted clip (media.url) instead of a
slideSpec. Over its region the engine swaps the speaker's picture for the clip —
full frame, the split slot, or the PiP, whatever the current callout layout puts the
speaker in — while the answer's audio, captions and callouts continue. media.sourceIn lets a visual play a window of a longer file (the engine pre-seeks
there while buffering). Clips are
buffered ahead (the first one when the answer starts, the next during each fade-out),
kept loosely in sync with the master, and skipped if not ready in time; a clip that
errors is dropped for the session. The default Player mounts the <Broll> element;
custom stages place <Broll engine={engine} /> right after the master video (omit it
to ignore B-roll). state.brollActive reports when a clip is on screen; the stage
carries the iqa-broll-on class.
Theming
Everything is scoped under .iqa-root and driven by CSS variables. Override the tokens
on your own wrapper — or restyle the .iqa-* class hooks wholesale:
.iqa-root {
--iqa-bg: #0b1220;
--iqa-panel-bg: #131c2e;
--iqa-ink: #eaf0ff;
--iqa-mut: #9fb0cc;
--iqa-acc: #5b8cff;
--iqa-acc2: #3f6ae0;
--iqa-line: #24304a;
--iqa-field: #ffffff;
--iqa-field-ink: #16233d;
--iqa-serif: "Playfair Display", Georgia, serif;
--iqa-sans: "Inter", system-ui, sans-serif;
--iqa-radius: 18px;
}Per-speaker theming: the question-number badge carries data-speaker="<speaker>", so
.iqa-qnum[data-speaker="founder-a"] { background: … } colors it.
Import the raw CSS string with import { investorQaStyles } from "podhouse-investor-qa"
if you'd rather inject it yourself.
Fixtures
import { fixturePayload } from "podhouse-investor-qa/fixtures";A brand-neutral payload in the exact contract shape (5 answers across 3 sections, three
callouts, sample captions). media.hlsUrl is a placeholder — point it at a real signed
HLS playlist to see playback.
The data contract
type InvestorQaPayload = {
project: { title: string; sectionTitles: Record<number, string> };
chapters: { id; section; qnum; speaker; question; answer; startTime; endTime; order }[];
visuals: { chapterId; layout; region: { startTime; endTime }; slideSpec }[];
captions: { s; e; w; ci }[];
media: { hlsUrl: string };
};All contract types are exported (InvestorQaPayload, Chapter, Visual, Caption,
SlideSpec, Layout, …).
