Skip to content

Commit 324c82a

Browse files
committed
feat: add QR code audience pick flow for live presentations
Adds a QR button in the top banner that opens a modal with a scannable QR code. Audience members scan it, pick from 3 random visualization options, and the selected prompt is sent to the AI agent on the big screen.
1 parent a707089 commit 324c82a

8 files changed

Lines changed: 629 additions & 0 deletions

File tree

apps/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@copilotkit/runtime": "next",
1717
"@copilotkitnext/shared": "next",
1818
"next": "16.1.6",
19+
"qrcode.react": "^4.2.0",
1920
"react": "^19.2.4",
2021
"react-dom": "^19.2.4",
2122
"react-rnd": "^10.5.2",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { NextResponse } from "next/server";
2+
import { networkInterfaces } from "os";
3+
4+
/** Returns the machine's LAN IPv4 address so phones on the same network can connect. */
5+
export async function GET() {
6+
const nets = networkInterfaces();
7+
for (const name of Object.keys(nets)) {
8+
for (const net of nets[name] ?? []) {
9+
if (!net.internal && net.family === "IPv4") {
10+
return NextResponse.json({ ip: net.address });
11+
}
12+
}
13+
}
14+
return NextResponse.json({ ip: null });
15+
}

apps/app/src/app/api/pick/route.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getSession, setSession } from "./store";
3+
4+
/** GET — poll session status (desktop polls this) */
5+
export async function GET(req: NextRequest) {
6+
const sessionId = req.nextUrl.searchParams.get("sessionId");
7+
if (!sessionId) return NextResponse.json({ error: "missing sessionId" }, { status: 400 });
8+
9+
const session = getSession(sessionId);
10+
if (!session) return NextResponse.json({ status: "waiting" });
11+
12+
return NextResponse.json(session);
13+
}
14+
15+
/** PUT — mark session as scanned (mobile calls this on page load) */
16+
export async function PUT(req: NextRequest) {
17+
const { sessionId } = await req.json();
18+
if (!sessionId) return NextResponse.json({ error: "missing sessionId" }, { status: 400 });
19+
20+
const existing = getSession(sessionId);
21+
if (!existing || existing.status === "waiting") {
22+
setSession(sessionId, { status: "scanned" });
23+
}
24+
25+
return NextResponse.json({ ok: true });
26+
}
27+
28+
/** POST — submit picked prompt (mobile calls this when user picks an option) */
29+
export async function POST(req: NextRequest) {
30+
const { sessionId, prompt } = await req.json();
31+
if (!sessionId || !prompt) {
32+
return NextResponse.json({ error: "missing sessionId or prompt" }, { status: 400 });
33+
}
34+
35+
setSession(sessionId, { status: "picked", prompt });
36+
return NextResponse.json({ ok: true });
37+
}

apps/app/src/app/api/pick/store.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Simple in-memory session store for QR pick flow.
3+
* Shared across API route handlers via module-level state.
4+
*/
5+
6+
export type PickSession = {
7+
status: "waiting" | "scanned" | "picked";
8+
prompt?: string;
9+
};
10+
11+
const sessions = new Map<string, PickSession>();
12+
13+
// Auto-expire sessions after 10 minutes
14+
const EXPIRY_MS = 10 * 60 * 1000;
15+
const timers = new Map<string, ReturnType<typeof setTimeout>>();
16+
17+
export function getSession(sessionId: string): PickSession | undefined {
18+
return sessions.get(sessionId);
19+
}
20+
21+
export function setSession(sessionId: string, data: PickSession) {
22+
sessions.set(sessionId, data);
23+
24+
// Reset expiry timer
25+
const existing = timers.get(sessionId);
26+
if (existing) clearTimeout(existing);
27+
timers.set(
28+
sessionId,
29+
setTimeout(() => {
30+
sessions.delete(sessionId);
31+
timers.delete(sessionId);
32+
}, EXPIRY_MS),
33+
);
34+
}

apps/app/src/app/page.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@ import { ExplainerCardsPortal } from "@/components/explainer-cards";
77
import { DemoGallery, type DemoItem } from "@/components/demo-gallery";
88
import { GridIcon } from "@/components/demo-gallery/grid-icon";
99
import { DesktopTipModal } from "@/components/desktop-tip-modal";
10+
import { QrButton, QrModal } from "@/components/qr-modal";
1011
import { CopilotChat, useAgent, useCopilotKit } from "@copilotkit/react-core/v2";
1112

1213
export default function HomePage() {
1314
useGenerativeUIExamples();
1415
useExampleSuggestions();
1516

1617
const [demoDrawerOpen, setDemoDrawerOpen] = useState(false);
18+
const [qrOpen, setQrOpen] = useState(false);
19+
const [qrSessionId] = useState(() => typeof crypto !== "undefined" ? crypto.randomUUID().slice(0, 12) : "fallback");
20+
const [scanStatus, setScanStatus] = useState<"waiting" | "scanned" | "picked">("waiting");
1721
const { agent } = useAgent();
1822
const { copilotkit } = useCopilotKit();
1923

@@ -23,6 +27,35 @@ export default function HomePage() {
2327
copilotkit.runAgent({ agent });
2428
};
2529

30+
// Reset scan status when QR modal opens
31+
useEffect(() => {
32+
if (qrOpen) setScanStatus("waiting");
33+
}, [qrOpen]);
34+
35+
// Poll for QR pick status
36+
useEffect(() => {
37+
if (!qrOpen) return;
38+
const interval = setInterval(async () => {
39+
try {
40+
const res = await fetch(`/api/pick?sessionId=${qrSessionId}`);
41+
const data = await res.json();
42+
if (data.status === "scanned") {
43+
setScanStatus("scanned");
44+
} else if (data.status === "picked" && data.prompt) {
45+
setScanStatus("picked");
46+
setTimeout(() => {
47+
setQrOpen(false);
48+
agent.addMessage({ id: crypto.randomUUID(), content: data.prompt, role: "user" });
49+
copilotkit.runAgent({ agent });
50+
}, 800);
51+
}
52+
} catch {
53+
// ignore polling errors
54+
}
55+
}, 2000);
56+
return () => clearInterval(interval);
57+
}, [qrOpen, qrSessionId, agent, copilotkit]);
58+
2659
// Widget bridge: handle messages from widget iframes
2760
useEffect(() => {
2861
const handler = (e: MessageEvent) => {
@@ -68,6 +101,7 @@ export default function HomePage() {
68101
</p>
69102
</div>
70103
<div className="flex items-center gap-1.5 sm:gap-2">
104+
<QrButton onClick={() => setQrOpen(true)} />
71105
<button
72106
onClick={() => setDemoDrawerOpen(true)}
73107
className="inline-flex items-center gap-1.5 px-2.5 sm:px-3 py-1.5 sm:py-2 rounded-full text-xs sm:text-sm font-medium no-underline whitespace-nowrap transition-all duration-150 hover:-translate-y-px cursor-pointer"
@@ -118,6 +152,13 @@ export default function HomePage() {
118152
/>
119153

120154
<DesktopTipModal />
155+
156+
<QrModal
157+
isOpen={qrOpen}
158+
onClose={() => setQrOpen(false)}
159+
sessionId={qrSessionId}
160+
scanStatus={scanStatus}
161+
/>
121162
</>
122163
);
123164
}

0 commit comments

Comments
 (0)