← Back
M

Source Code

18 files · 1,858lines · written by Claude AI during this presentation

src/app/page.tsx

88 lines
1"use client";
2
3import Link from "next/link";
4
5export default function Home() {
6 return (
7 <main className="flex-1 flex flex-col items-center justify-center px-6 py-12">
8 <div className="text-center mb-16 animate-fade-in">
9 <div className="inline-flex items-center gap-3 mb-6">
10 <div className="w-12 h-12 rounded-xl bg-merit-red flex items-center justify-center text-white font-bold text-2xl">
11 M
12 </div>
13 <h1 className="text-5xl font-bold tracking-tight text-white">
14 Meritrust
15 </h1>
16 </div>
17 <p className="text-xl text-meritrust-400 max-w-lg mx-auto">
18 AI-powered demos — built live during this presentation
19 </p>
20 </div>
21
22 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-6xl w-full">
23 <Link
24 href="/chat"
25 className="group relative overflow-hidden rounded-2xl border border-meritrust-700 bg-meritrust-800/50 p-8 transition-all hover:border-merit-red/60 hover:bg-meritrust-800/80 hover:scale-[1.02]"
26 >
27 <div className="text-4xl mb-4">💬</div>
28 <h2 className="text-2xl font-semibold text-white mb-2">
29 AI Assistant
30 </h2>
31 <p className="text-meritrust-400 leading-relaxed">
32 Chat with an AI that knows Meritrust — history, services, membership,
33 and more.
34 </p>
35 <div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-merit-red/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
36 </Link>
37
38 <Link
39 href="/trivia"
40 className="group relative overflow-hidden rounded-2xl border border-meritrust-700 bg-meritrust-800/50 p-8 transition-all hover:border-merit-red/60 hover:bg-meritrust-800/80 hover:scale-[1.02]"
41 >
42 <div className="text-4xl mb-4">🏆</div>
43 <h2 className="text-2xl font-semibold text-white mb-2">
44 Live Trivia
45 </h2>
46 <p className="text-meritrust-400 leading-relaxed">
47 Test your Meritrust knowledge! Scan the QR code on your phone and
48 vote live.
49 </p>
50 <div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-merit-red/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
51 </Link>
52
53 <Link
54 href="/buzz"
55 className="group relative overflow-hidden rounded-2xl border border-meritrust-700 bg-meritrust-800/50 p-8 transition-all hover:border-merit-red/60 hover:bg-meritrust-800/80 hover:scale-[1.02]"
56 >
57 <div className="text-4xl mb-4">📡</div>
58 <h2 className="text-2xl font-semibold text-white mb-2">
59 Online Buzz
60 </h2>
61 <p className="text-meritrust-400 leading-relaxed">
62 Find out what people are really saying about Meritrust across the
63 internet.
64 </p>
65 <div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-merit-red/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
66 </Link>
67
68 <Link
69 href="/source"
70 className="group relative overflow-hidden rounded-2xl border border-meritrust-700 bg-meritrust-800/50 p-8 transition-all hover:border-merit-red/60 hover:bg-meritrust-800/80 hover:scale-[1.02]"
71 >
72 <div className="text-4xl mb-4">&lt;/&gt;</div>
73 <h2 className="text-2xl font-semibold text-white mb-2">
74 Source Code
75 </h2>
76 <p className="text-meritrust-400 leading-relaxed">
77 See every line of code that Claude wrote to build this demo.
78 </p>
79 <div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-merit-red/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
80 </Link>
81 </div>
82
83 <p className="mt-16 text-sm text-meritrust-600">
84 Powered by Claude AI — built in real time during this presentation
85 </p>
86 </main>
87 );
88}

src/app/layout.tsx

33 lines
1import type { Metadata } from "next";
2import { Geist, Geist_Mono } from "next/font/google";
3import "./globals.css";
4
5const geistSans = Geist({
6 variable: "--font-geist-sans",
7 subsets: ["latin"],
8});
9
10const geistMono = Geist_Mono({
11 variable: "--font-geist-mono",
12 subsets: ["latin"],
13});
14
15export const metadata: Metadata = {
16 title: "Meritrust Credit Union — AI Demo",
17 description: "AI-powered assistant and live trivia for Meritrust Credit Union",
18};
19
20export default function RootLayout({
21 children,
22}: Readonly<{
23 children: React.ReactNode;
24}>) {
25 return (
26 <html
27 lang="en"
28 className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
29 >
30 <body className="min-h-full flex flex-col">{children}</body>
31 </html>
32 );
33}

src/app/globals.css

89 lines
1@import "tailwindcss";
2
3@theme {
4 --color-meritrust-900: #0c0c0e;
5 --color-meritrust-800: #18181b;
6 --color-meritrust-700: #27272a;
7 --color-meritrust-600: #52525b;
8 --color-meritrust-500: #e41d38;
9 --color-meritrust-400: #a1a1aa;
10 --color-meritrust-300: #d4d4d8;
11 --color-meritrust-200: #e4e4e7;
12 --color-meritrust-100: #f4f4f5;
13 --color-meritrust-50: #fafafa;
14 --color-merit-red: #e41d38;
15 --color-merit-red-hover: #f04058;
16 --color-merit-red-glow: #e41d38;
17}
18
19html {
20 color-scheme: dark;
21}
22
23body {
24 background: var(--color-meritrust-900);
25 color: white;
26 font-family: var(--font-geist-sans), system-ui, -apple-system, sans-serif;
27}
28
29@keyframes fade-in {
30 from { opacity: 0; transform: translateY(8px); }
31 to { opacity: 1; transform: translateY(0); }
32}
33
34@keyframes pulse-glow {
35 0%, 100% { box-shadow: 0 0 20px rgba(228, 29, 56, 0.3); }
36 50% { box-shadow: 0 0 40px rgba(228, 29, 56, 0.6); }
37}
38
39@keyframes count-up {
40 from { transform: scale(1); }
41 50% { transform: scale(1.15); }
42 to { transform: scale(1); }
43}
44
45.animate-fade-in {
46 animation: fade-in 0.4s ease-out;
47}
48
49.animate-glow {
50 animation: pulse-glow 2s ease-in-out infinite;
51}
52
53.animate-count {
54 animation: count-up 0.3s ease-out;
55}
56
57.vote-bar {
58 transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
59}
60
61@keyframes float-up {
62 0% { opacity: 1; transform: translateY(0) scale(1); }
63 70% { opacity: 1; }
64 100% { opacity: 0; transform: translateY(-400px) scale(1.4); }
65}
66
67@keyframes burst {
68 0% { opacity: 1; transform: scale(1) translateY(0); }
69 100% { opacity: 0; transform: scale(2.5) translateY(-60px); }
70}
71
72.floating-emoji {
73 position: absolute;
74 bottom: 80px;
75 font-size: 2rem;
76 animation: float-up 3s ease-out forwards;
77 pointer-events: none;
78 z-index: 50;
79}
80
81.reaction-burst {
82 position: fixed;
83 bottom: 100px;
84 left: 50%;
85 font-size: 3rem;
86 animation: burst 0.6s ease-out forwards;
87 pointer-events: none;
88 z-index: 100;
89}

src/app/chat/page.tsx

219 lines
1"use client";
2
3import { useState, useRef, useEffect } from "react";
4import { QRCodeSVG } from "qrcode.react";
5import Link from "next/link";
6
7interface Message {
8 role: "user" | "assistant";
9 content: string;
10}
11
12export default function ChatPage() {
13 const [messages, setMessages] = useState<Message[]>([]);
14 const [input, setInput] = useState("");
15 const [streaming, setStreaming] = useState(false);
16 const [showQR, setShowQR] = useState(false);
17 const scrollRef = useRef<HTMLDivElement>(null);
18 const inputRef = useRef<HTMLInputElement>(null);
19
20 useEffect(() => {
21 scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
22 }, [messages]);
23
24 async function handleSend() {
25 const text = input.trim();
26 if (!text || streaming) return;
27
28 const userMsg: Message = { role: "user", content: text };
29 const newMessages = [...messages, userMsg];
30 setMessages(newMessages);
31 setInput("");
32 setStreaming(true);
33
34 const assistantMsg: Message = { role: "assistant", content: "" };
35 setMessages([...newMessages, assistantMsg]);
36
37 try {
38 const res = await fetch("/api/chat", {
39 method: "POST",
40 headers: { "Content-Type": "application/json" },
41 body: JSON.stringify({ messages: newMessages }),
42 });
43
44 if (!res.ok || !res.body) throw new Error("Stream failed");
45
46 const reader = res.body.getReader();
47 const decoder = new TextDecoder();
48 let accumulated = "";
49 let buffer = "";
50
51 while (true) {
52 const { done, value } = await reader.read();
53 if (done) break;
54
55 buffer += decoder.decode(value, { stream: true });
56 const lines = buffer.split("\n");
57 buffer = lines.pop() || "";
58
59 for (const line of lines) {
60 if (line.startsWith("data: ") && line !== "data: [DONE]") {
61 try {
62 const parsed = JSON.parse(line.slice(6));
63 accumulated += parsed.text;
64 setMessages((prev) => {
65 const updated = [...prev];
66 updated[updated.length - 1] = { role: "assistant", content: accumulated };
67 return updated;
68 });
69 } catch {}
70 }
71 }
72 }
73 } catch (err) {
74 console.error(err);
75 setMessages((prev) => {
76 const updated = [...prev];
77 updated[updated.length - 1] = {
78 role: "assistant",
79 content: "Sorry, something went wrong. Please try again.",
80 };
81 return updated;
82 });
83 } finally {
84 setStreaming(false);
85 inputRef.current?.focus();
86 }
87 }
88
89 const origin = typeof window !== "undefined" ? window.location.origin : "";
90
91 return (
92 <div className="flex-1 flex flex-col h-screen">
93 <header className="flex items-center justify-between px-6 py-4 border-b border-meritrust-700 bg-meritrust-800/60 backdrop-blur">
94 <div className="flex items-center gap-3">
95 <Link
96 href="/"
97 className="text-meritrust-400 hover:text-white transition-colors text-sm"
98 >
99 ← Back
100 </Link>
101 <div className="w-8 h-8 rounded-lg bg-merit-red flex items-center justify-center text-white font-bold text-sm">
102 M
103 </div>
104 <div>
105 <h1 className="text-lg font-semibold text-white">
106 Meritrust AI Assistant
107 </h1>
108 <p className="text-xs text-meritrust-400">
109 Ask anything about Meritrust Credit Union
110 </p>
111 </div>
112 </div>
113 <button
114 onClick={() => setShowQR(!showQR)}
115 className="px-3 py-1.5 rounded-lg bg-meritrust-700 text-meritrust-300 text-sm hover:bg-meritrust-600 transition-colors"
116 >
117 {showQR ? "Hide QR" : "Share QR"}
118 </button>
119 </header>
120
121 <div className="flex-1 flex overflow-hidden">
122 <div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-6 space-y-4">
123 {messages.length === 0 && (
124 <div className="flex-1 flex flex-col items-center justify-center text-center py-20 animate-fade-in">
125 <div className="w-16 h-16 rounded-2xl bg-meritrust-700 flex items-center justify-center text-3xl mb-6">
126 💬
127 </div>
128 <h2 className="text-2xl font-semibold text-white mb-2">
129 Ask me anything about Meritrust
130 </h2>
131 <p className="text-meritrust-400 max-w-md">
132 I know about Meritrust&apos;s history, services, membership, and
133 more. Try &quot;When was Meritrust founded?&quot; or &quot;What
134 services do you offer?&quot;
135 </p>
136 <div className="flex flex-wrap gap-2 mt-6 justify-center">
137 {[
138 "When was Meritrust founded?",
139 "What services do you offer?",
140 "How do I become a member?",
141 "Tell me about the merger",
142 ].map((q) => (
143 <button
144 key={q}
145 onClick={() => { setInput(q); inputRef.current?.focus(); }}
146 className="px-3 py-1.5 rounded-full bg-meritrust-800 border border-meritrust-700 text-meritrust-300 text-sm hover:border-merit-red/50 transition-colors"
147 >
148 {q}
149 </button>
150 ))}
151 </div>
152 </div>
153 )}
154
155 {messages.map((msg, i) => (
156 <div
157 key={i}
158 className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"} animate-fade-in`}
159 >
160 <div
161 className={`max-w-[75%] rounded-2xl px-4 py-3 ${
162 msg.role === "user"
163 ? "bg-merit-red text-white"
164 : "bg-meritrust-800 border border-meritrust-700 text-meritrust-100"
165 }`}
166 >
167 <p className="whitespace-pre-wrap leading-relaxed text-[15px]">
168 {msg.content}
169 {streaming && i === messages.length - 1 && msg.role === "assistant" && (
170 <span className="inline-block w-2 h-4 bg-merit-red ml-1 animate-pulse" />
171 )}
172 </p>
173 </div>
174 </div>
175 ))}
176 </div>
177
178 {showQR && (
179 <div className="w-64 border-l border-meritrust-700 bg-meritrust-800/40 flex flex-col items-center justify-center p-6 animate-fade-in">
180 <p className="text-sm text-meritrust-300 mb-4 text-center">
181 Scan to chat on your phone
182 </p>
183 <div className="bg-white p-3 rounded-xl">
184 <QRCodeSVG value={`${origin}/chat`} size={180} />
185 </div>
186 <p className="text-xs text-meritrust-600 mt-3 text-center break-all">
187 {origin}/chat
188 </p>
189 </div>
190 )}
191 </div>
192
193 <div className="px-6 py-4 border-t border-meritrust-700 bg-meritrust-800/60 backdrop-blur">
194 <form
195 onSubmit={(e) => { e.preventDefault(); handleSend(); }}
196 className="flex gap-3 max-w-3xl mx-auto"
197 >
198 <input
199 ref={inputRef}
200 type="text"
201 value={input}
202 onChange={(e) => setInput(e.target.value)}
203 placeholder="Ask about Meritrust..."
204 className="flex-1 px-4 py-3 rounded-xl bg-meritrust-900 border border-meritrust-700 text-white placeholder:text-meritrust-600 focus:outline-none focus:border-merit-red transition-colors"
205 disabled={streaming}
206 autoFocus
207 />
208 <button
209 type="submit"
210 disabled={streaming || !input.trim()}
211 className="px-6 py-3 rounded-xl bg-merit-red text-white font-medium hover:bg-merit-red-hover disabled:opacity-40 disabled:hover:bg-merit-red transition-colors"
212 >
213 {streaming ? "..." : "Send"}
214 </button>
215 </form>
216 </div>
217 </div>
218 );
219}

src/app/buzz/page.tsx

221 lines
1"use client";
2
3import { useState, useRef, useEffect } from "react";
4import { QRCodeSVG } from "qrcode.react";
5import Link from "next/link";
6
7interface Message {
8 role: "user" | "assistant";
9 content: string;
10}
11
12export default function BuzzPage() {
13 const [messages, setMessages] = useState<Message[]>([]);
14 const [input, setInput] = useState("");
15 const [streaming, setStreaming] = useState(false);
16 const [showQR, setShowQR] = useState(false);
17 const scrollRef = useRef<HTMLDivElement>(null);
18 const inputRef = useRef<HTMLInputElement>(null);
19
20 useEffect(() => {
21 scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
22 }, [messages]);
23
24 async function handleSend() {
25 const text = input.trim();
26 if (!text || streaming) return;
27
28 const userMsg: Message = { role: "user", content: text };
29 const newMessages = [...messages, userMsg];
30 setMessages(newMessages);
31 setInput("");
32 setStreaming(true);
33
34 const assistantMsg: Message = { role: "assistant", content: "" };
35 setMessages([...newMessages, assistantMsg]);
36
37 try {
38 const res = await fetch("/api/buzz", {
39 method: "POST",
40 headers: { "Content-Type": "application/json" },
41 body: JSON.stringify({ messages: newMessages }),
42 });
43
44 if (!res.ok || !res.body) throw new Error("Stream failed");
45
46 const reader = res.body.getReader();
47 const decoder = new TextDecoder();
48 let accumulated = "";
49 let buffer = "";
50
51 while (true) {
52 const { done, value } = await reader.read();
53 if (done) break;
54
55 buffer += decoder.decode(value, { stream: true });
56 const lines = buffer.split("\n");
57 buffer = lines.pop() || "";
58
59 for (const line of lines) {
60 if (line.startsWith("data: ") && line !== "data: [DONE]") {
61 try {
62 const parsed = JSON.parse(line.slice(6));
63 accumulated += parsed.text;
64 setMessages((prev) => {
65 const updated = [...prev];
66 updated[updated.length - 1] = { role: "assistant", content: accumulated };
67 return updated;
68 });
69 } catch {}
70 }
71 }
72 }
73 } catch (err) {
74 console.error(err);
75 setMessages((prev) => {
76 const updated = [...prev];
77 updated[updated.length - 1] = {
78 role: "assistant",
79 content: "Sorry, something went wrong. Please try again.",
80 };
81 return updated;
82 });
83 } finally {
84 setStreaming(false);
85 inputRef.current?.focus();
86 }
87 }
88
89 const origin = typeof window !== "undefined" ? window.location.origin : "";
90
91 return (
92 <div className="flex-1 flex flex-col h-screen">
93 <header className="flex items-center justify-between px-6 py-4 border-b border-meritrust-700 bg-meritrust-800/60 backdrop-blur">
94 <div className="flex items-center gap-3">
95 <Link
96 href="/"
97 className="text-meritrust-400 hover:text-white transition-colors text-sm"
98 >
99 &larr; Back
100 </Link>
101 <div className="w-8 h-8 rounded-lg bg-merit-red flex items-center justify-center text-white font-bold text-sm">
102 M
103 </div>
104 <div>
105 <h1 className="text-lg font-semibold text-white">
106 Online Buzz Bot
107 </h1>
108 <p className="text-xs text-meritrust-400">
109 What are people saying about Meritrust online?
110 </p>
111 </div>
112 </div>
113 <button
114 onClick={() => setShowQR(!showQR)}
115 className="px-3 py-1.5 rounded-lg bg-meritrust-700 text-meritrust-300 text-sm hover:bg-meritrust-600 transition-colors"
116 >
117 {showQR ? "Hide QR" : "Share QR"}
118 </button>
119 </header>
120
121 <div className="flex-1 flex overflow-hidden">
122 <div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-6 space-y-4">
123 {messages.length === 0 && (
124 <div className="flex-1 flex flex-col items-center justify-center text-center py-20 animate-fade-in">
125 <div className="w-16 h-16 rounded-2xl bg-meritrust-700 flex items-center justify-center text-3xl mb-6">
126 📡
127 </div>
128 <h2 className="text-2xl font-semibold text-white mb-2">
129 The Online Buzz Bot
130 </h2>
131 <p className="text-meritrust-400 max-w-md">
132 I&apos;ve scoured the internet for what people are really saying
133 about Meritrust. Ask me anything &mdash; the good, the bad, and
134 the brutally honest.
135 </p>
136 <div className="flex flex-wrap gap-2 mt-6 justify-center">
137 {[
138 "What's the meanest thing someone said?",
139 "What do employees love most?",
140 "What's the #1 complaint?",
141 "Give me the overall vibe",
142 "Any long-time members upset?",
143 "What do people say about auto loans?",
144 ].map((q) => (
145 <button
146 key={q}
147 onClick={() => { setInput(q); inputRef.current?.focus(); }}
148 className="px-3 py-1.5 rounded-full bg-meritrust-800 border border-meritrust-700 text-meritrust-300 text-sm hover:border-merit-red/50 transition-colors"
149 >
150 {q}
151 </button>
152 ))}
153 </div>
154 </div>
155 )}
156
157 {messages.map((msg, i) => (
158 <div
159 key={i}
160 className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"} animate-fade-in`}
161 >
162 <div
163 className={`max-w-[75%] rounded-2xl px-4 py-3 ${
164 msg.role === "user"
165 ? "bg-merit-red text-white"
166 : "bg-meritrust-800 border border-meritrust-700 text-meritrust-100"
167 }`}
168 >
169 <p className="whitespace-pre-wrap leading-relaxed text-[15px]">
170 {msg.content}
171 {streaming && i === messages.length - 1 && msg.role === "assistant" && (
172 <span className="inline-block w-2 h-4 bg-merit-red ml-1 animate-pulse" />
173 )}
174 </p>
175 </div>
176 </div>
177 ))}
178 </div>
179
180 {showQR && (
181 <div className="w-64 border-l border-meritrust-700 bg-meritrust-800/40 flex flex-col items-center justify-center p-6 animate-fade-in">
182 <p className="text-sm text-meritrust-300 mb-4 text-center">
183 Scan to try on your phone
184 </p>
185 <div className="bg-white p-3 rounded-xl">
186 <QRCodeSVG value={`${origin}/buzz`} size={180} />
187 </div>
188 <p className="text-xs text-meritrust-600 mt-3 text-center break-all">
189 {origin}/buzz
190 </p>
191 </div>
192 )}
193 </div>
194
195 <div className="px-6 py-4 border-t border-meritrust-700 bg-meritrust-800/60 backdrop-blur">
196 <form
197 onSubmit={(e) => { e.preventDefault(); handleSend(); }}
198 className="flex gap-3 max-w-3xl mx-auto"
199 >
200 <input
201 ref={inputRef}
202 type="text"
203 value={input}
204 onChange={(e) => setInput(e.target.value)}
205 placeholder="Ask what people are saying..."
206 className="flex-1 px-4 py-3 rounded-xl bg-meritrust-900 border border-meritrust-700 text-white placeholder:text-meritrust-600 focus:outline-none focus:border-merit-red transition-colors"
207 disabled={streaming}
208 autoFocus
209 />
210 <button
211 type="submit"
212 disabled={streaming || !input.trim()}
213 className="px-6 py-3 rounded-xl bg-merit-red text-white font-medium hover:bg-merit-red-hover disabled:opacity-40 disabled:hover:bg-merit-red transition-colors"
214 >
215 {streaming ? "..." : "Send"}
216 </button>
217 </form>
218 </div>
219 </div>
220 );
221}

src/app/trivia/page.tsx

368 lines
1"use client";
2
3import { useState, useEffect, useCallback, useRef } from "react";
4import { QRCodeSVG } from "qrcode.react";
5import { questions, type GameState, createInitialState } from "@/lib/trivia";
6import Link from "next/link";
7
8interface FloatingEmoji {
9 id: number;
10 emoji: string;
11 x: number;
12 createdAt: number;
13}
14
15let emojiIdCounter = 0;
16
17export default function TriviaPresenter() {
18 const [state, setState] = useState<GameState>(createInitialState());
19 const [floatingEmojis, setFloatingEmojis] = useState<FloatingEmoji[]>([]);
20 const prevReactionsRef = useRef<Record<string, { up: number; down: number }>>({});
21
22 const fetchState = useCallback(async () => {
23 try {
24 const res = await fetch("/api/trivia/state");
25 if (res.ok) {
26 const data = (await res.json()) as GameState;
27 setState(data);
28 }
29 } catch {}
30 }, []);
31
32 useEffect(() => {
33 fetchState();
34 const interval = setInterval(fetchState, 1500);
35 return () => clearInterval(interval);
36 }, [fetchState]);
37
38 useEffect(() => {
39 const qKey = String(state.currentQuestion);
40 const current = state.reactions?.[qKey] || { up: 0, down: 0 };
41 const prev = prevReactionsRef.current[qKey] || { up: 0, down: 0 };
42
43 const newUps = Math.max(0, current.up - prev.up);
44 const newDowns = Math.max(0, current.down - prev.down);
45
46 if (newUps > 0 || newDowns > 0) {
47 const newEmojis: FloatingEmoji[] = [];
48 for (let i = 0; i < Math.min(newUps, 8); i++) {
49 newEmojis.push({
50 id: emojiIdCounter++,
51 emoji: "👍",
52 x: 70 + Math.random() * 25,
53 createdAt: Date.now(),
54 });
55 }
56 for (let i = 0; i < Math.min(newDowns, 8); i++) {
57 newEmojis.push({
58 id: emojiIdCounter++,
59 emoji: "👎",
60 x: 70 + Math.random() * 25,
61 createdAt: Date.now(),
62 });
63 }
64 setFloatingEmojis((prev) => [...prev, ...newEmojis]);
65 }
66
67 prevReactionsRef.current = { ...prevReactionsRef.current, [qKey]: { ...current } };
68 }, [state.reactions, state.currentQuestion]);
69
70 useEffect(() => {
71 const interval = setInterval(() => {
72 setFloatingEmojis((prev) => prev.filter((e) => Date.now() - e.createdAt < 3000));
73 }, 500);
74 return () => clearInterval(interval);
75 }, []);
76
77 async function sendAction(action: string) {
78 await fetch("/api/trivia/next", {
79 method: "POST",
80 headers: { "Content-Type": "application/json" },
81 body: JSON.stringify({ action }),
82 });
83 await fetchState();
84 }
85
86 async function reset() {
87 await fetch("/api/trivia/reset", { method: "POST" });
88 prevReactionsRef.current = {};
89 await fetchState();
90 }
91
92 const origin = typeof window !== "undefined" ? window.location.origin : "";
93 const q = questions[state.currentQuestion];
94 const votes = state.votes[String(state.currentQuestion)] || { A: 0, B: 0, C: 0, D: 0 };
95 const totalVotes = Object.values(votes).reduce((a, b) => a + b, 0);
96 const currentReactions = state.reactions?.[String(state.currentQuestion)] || { up: 0, down: 0 };
97
98 const answerColors: Record<string, string> = {
99 A: "bg-blue-500",
100 B: "bg-emerald-500",
101 C: "bg-amber-500",
102 D: "bg-rose-500",
103 };
104
105 const answerBorders: Record<string, string> = {
106 A: "border-blue-500",
107 B: "border-emerald-500",
108 C: "border-amber-500",
109 D: "border-rose-500",
110 };
111
112 return (
113 <div className="flex-1 flex flex-col h-screen relative overflow-hidden">
114 <header className="flex items-center justify-between px-6 py-3 border-b border-meritrust-700 bg-meritrust-800/60">
115 <div className="flex items-center gap-3">
116 <Link href="/" className="text-meritrust-400 hover:text-white transition-colors text-sm">
117 &larr; Back
118 </Link>
119 <div className="w-7 h-7 rounded-lg bg-merit-red flex items-center justify-center text-white font-bold text-xs">
120 M
121 </div>
122 <h1 className="text-lg font-semibold text-white">Meritrust Trivia</h1>
123 </div>
124 <div className="flex items-center gap-3">
125 <span className="text-sm text-meritrust-400">
126 {state.players.length} player{state.players.length !== 1 ? "s" : ""}
127 </span>
128 <button
129 onClick={reset}
130 className="px-3 py-1.5 rounded-lg bg-meritrust-700 text-meritrust-300 text-sm hover:bg-meritrust-600 transition-colors"
131 >
132 Reset
133 </button>
134 </div>
135 </header>
136
137 <div className="flex-1 flex items-center justify-center p-8">
138 {state.phase === "lobby" && (
139 <div className="text-center animate-fade-in">
140 <h2 className="text-4xl font-bold text-white mb-2">
141 Meritrust Trivia
142 </h2>
143 <p className="text-xl text-meritrust-400 mb-10">
144 Scan the QR code to join on your phone
145 </p>
146 <div className="inline-block bg-white p-5 rounded-2xl animate-glow mb-6">
147 <QRCodeSVG value={`${origin}/trivia/play`} size={240} />
148 </div>
149 <p className="text-meritrust-400 text-lg mb-10 font-mono">
150 {origin}/trivia/play
151 </p>
152 <div className="text-2xl text-meritrust-300 mb-8">
153 <span className="text-4xl font-bold text-white">{state.players.length}</span>{" "}
154 player{state.players.length !== 1 ? "s" : ""} joined
155 </div>
156 <button
157 onClick={() => sendAction("start")}
158 className="px-10 py-4 rounded-2xl bg-merit-red text-white text-xl font-semibold hover:bg-merit-red-hover transition-colors"
159 >
160 Start Game
161 </button>
162 </div>
163 )}
164
165 {state.phase === "question" && q && (
166 <div className="w-full max-w-4xl animate-fade-in">
167 <div className="text-center mb-8">
168 <span className="text-sm text-meritrust-400 uppercase tracking-wider">
169 Question {state.currentQuestion + 1} of {questions.length}
170 </span>
171 <h2 className="text-3xl font-bold text-white mt-2 leading-tight">
172 {q.question}
173 </h2>
174 </div>
175
176 <div className="grid grid-cols-1 gap-4 mb-8">
177 {q.answers.map((a) => (
178 <div
179 key={a.key}
180 className={`relative overflow-hidden rounded-xl border-2 transition-all ${answerBorders[a.key]} bg-meritrust-800/50`}
181 >
182 <div className="relative flex items-center px-6 py-5">
183 <div className="flex items-center gap-4">
184 <span
185 className={`w-10 h-10 rounded-lg ${answerColors[a.key]} flex items-center justify-center text-white font-bold text-lg`}
186 >
187 {a.key}
188 </span>
189 <span className="text-xl text-white font-medium">
190 {a.text}
191 </span>
192 </div>
193 </div>
194 </div>
195 ))}
196 </div>
197
198 <div className="flex items-center justify-between">
199 <div className="flex items-center gap-4">
200 <span className="text-meritrust-400">
201 <span className="text-3xl font-bold text-white animate-count">{totalVotes}</span>{" "}
202 vote{totalVotes !== 1 ? "s" : ""} submitted
203 </span>
204 {(currentReactions.up > 0 || currentReactions.down > 0) && (
205 <span className="text-meritrust-500 text-sm">
206 👍 {currentReactions.up} · 👎 {currentReactions.down}
207 </span>
208 )}
209 </div>
210 <button
211 onClick={() => sendAction("reveal")}
212 className="px-8 py-3 rounded-xl bg-amber-500 text-white font-semibold hover:bg-amber-400 transition-colors"
213 >
214 Reveal Answer
215 </button>
216 </div>
217 </div>
218 )}
219
220 {state.phase === "revealed" && q && (
221 <div className="w-full max-w-4xl animate-fade-in">
222 <div className="text-center mb-8">
223 <span className="text-sm text-meritrust-400 uppercase tracking-wider">
224 Question {state.currentQuestion + 1} of {questions.length}
225 </span>
226 <h2 className="text-3xl font-bold text-white mt-2 leading-tight">
227 {q.question}
228 </h2>
229 </div>
230
231 <div className="grid grid-cols-1 gap-4 mb-8">
232 {q.answers.map((a) => {
233 const count = votes[a.key] || 0;
234 const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0;
235 const isCorrect = a.key === q.correct;
236 const isWrong = a.key !== q.correct;
237
238 return (
239 <div
240 key={a.key}
241 className={`relative overflow-hidden rounded-xl border-2 transition-all ${
242 isCorrect
243 ? "border-emerald-400 bg-emerald-500/20"
244 : isWrong
245 ? "border-meritrust-700/50 bg-meritrust-800/30 opacity-50"
246 : `${answerBorders[a.key]} bg-meritrust-800/50`
247 }`}
248 >
249 <div
250 className={`vote-bar absolute inset-y-0 left-0 ${answerColors[a.key]} opacity-20`}
251 style={{ width: `${pct}%` }}
252 />
253 <div className="relative flex items-center justify-between px-6 py-5">
254 <div className="flex items-center gap-4">
255 <span
256 className={`w-10 h-10 rounded-lg ${answerColors[a.key]} flex items-center justify-center text-white font-bold text-lg`}
257 >
258 {a.key}
259 </span>
260 <span className="text-xl text-white font-medium">
261 {a.text}
262 </span>
263 {isCorrect && (
264 <span className="text-2xl ml-2">&#10003;</span>
265 )}
266 </div>
267 <div className="text-right">
268 <span className="text-2xl font-bold text-white">
269 {count}
270 </span>
271 <span className="text-meritrust-400 ml-2">
272 {pct}%
273 </span>
274 </div>
275 </div>
276 </div>
277 );
278 })}
279 </div>
280
281 <div className="flex items-center justify-between">
282 <div className="flex items-center gap-4">
283 <span className="text-meritrust-400">
284 {totalVotes} vote{totalVotes !== 1 ? "s" : ""}
285 </span>
286 {(currentReactions.up > 0 || currentReactions.down > 0) && (
287 <span className="text-meritrust-500 text-sm">
288 👍 {currentReactions.up} · 👎 {currentReactions.down}
289 </span>
290 )}
291 </div>
292 <button
293 onClick={() => sendAction("next")}
294 className="px-8 py-3 rounded-xl bg-merit-red text-white font-semibold hover:bg-merit-red-hover transition-colors"
295 >
296 {state.currentQuestion < questions.length - 1
297 ? "Next Question →"
298 : "See Results →"}
299 </button>
300 </div>
301 </div>
302 )}
303
304 {state.phase === "finished" && (
305 <div className="text-center animate-fade-in">
306 <div className="text-6xl mb-6">&#127942;</div>
307 <h2 className="text-4xl font-bold text-white mb-4">
308 Great game!
309 </h2>
310 <p className="text-xl text-meritrust-300 mb-8">
311 {state.players.length} player{state.players.length !== 1 ? "s" : ""} participated
312 </p>
313 <div className="space-y-4 max-w-2xl mx-auto mb-10">
314 {questions.map((qq, i) => {
315 const v = state.votes[String(i)] || { A: 0, B: 0, C: 0, D: 0 };
316 const total = Object.values(v).reduce((a, b) => a + b, 0);
317 const correctCount = v[qq.correct] || 0;
318 const correctPct = total > 0 ? Math.round((correctCount / total) * 100) : 0;
319 const r = state.reactions?.[String(i)] || { up: 0, down: 0 };
320
321 return (
322 <div
323 key={i}
324 className="rounded-xl bg-meritrust-800/50 border border-meritrust-700 p-4 text-left"
325 >
326 <p className="text-meritrust-400 text-sm mb-1">
327 Q{i + 1}: {qq.question}
328 </p>
329 <div className="flex items-center justify-between">
330 <p className="text-white font-medium">
331 {qq.answers.find((a) => a.key === qq.correct)?.text}{" "}
332 <span className="text-emerald-400">
333 &mdash; {correctPct}% got it right
334 </span>
335 </p>
336 {(r.up > 0 || r.down > 0) && (
337 <span className="text-meritrust-500 text-sm">
338 👍 {r.up} · 👎 {r.down}
339 </span>
340 )}
341 </div>
342 </div>
343 );
344 })}
345 </div>
346 <button
347 onClick={reset}
348 className="px-8 py-3 rounded-xl bg-merit-red text-white font-semibold hover:bg-merit-red-hover transition-colors"
349 >
350 Play Again
351 </button>
352 </div>
353 )}
354 </div>
355
356 {/* Floating reaction emojis */}
357 {floatingEmojis.map((e) => (
358 <div
359 key={e.id}
360 className="floating-emoji"
361 style={{ left: `${e.x}%` }}
362 >
363 {e.emoji}
364 </div>
365 ))}
366 </div>
367 );
368}

src/app/trivia/play/page.tsx

276 lines
1"use client";
2
3import { useState, useEffect, useCallback } from "react";
4import { questions, type GameState, createInitialState } from "@/lib/trivia";
5
6function getVoterId(): string {
7 if (typeof window === "undefined") return "";
8 let id = localStorage.getItem("meritrust_voter_id");
9 if (!id) {
10 id = Math.random().toString(36).slice(2) + Date.now().toString(36);
11 localStorage.setItem("meritrust_voter_id", id);
12 }
13 return id;
14}
15
16export default function TriviaPlayer() {
17 const [state, setState] = useState<GameState>(createInitialState());
18 const [voterId, setVoterId] = useState("");
19 const [voted, setVoted] = useState<Record<number, string>>({});
20 const [joined, setJoined] = useState(false);
21 const [submitting, setSubmitting] = useState(false);
22 const [reactionBursts, setReactionBursts] = useState<{ id: number; type: string }[]>([]);
23
24 useEffect(() => {
25 setVoterId(getVoterId());
26 }, []);
27
28 const fetchState = useCallback(async () => {
29 try {
30 const res = await fetch("/api/trivia/state");
31 if (res.ok) setState((await res.json()) as GameState);
32 } catch {}
33 }, []);
34
35 useEffect(() => {
36 fetchState();
37 const interval = setInterval(fetchState, 1500);
38 return () => clearInterval(interval);
39 }, [fetchState]);
40
41 async function joinGame() {
42 if (!voterId) return;
43 await fetch("/api/trivia/vote", {
44 method: "POST",
45 headers: { "Content-Type": "application/json" },
46 body: JSON.stringify({ answer: null, voterId, join: true }),
47 });
48 setJoined(true);
49 }
50
51 async function vote(answer: string) {
52 if (submitting || voted[state.currentQuestion]) return;
53 setSubmitting(true);
54
55 try {
56 const res = await fetch("/api/trivia/vote", {
57 method: "POST",
58 headers: { "Content-Type": "application/json" },
59 body: JSON.stringify({ answer, voterId }),
60 });
61
62 if (res.ok || res.status === 409) {
63 setVoted((prev) => ({ ...prev, [state.currentQuestion]: answer }));
64 }
65 } catch {} finally {
66 setSubmitting(false);
67 }
68 }
69
70 let burstId = 0;
71 async function sendReaction(type: "up" | "down") {
72 const id = burstId++;
73 const emoji = type === "up" ? "👍" : "👎";
74 setReactionBursts((prev) => [...prev.slice(-20), { id, type: emoji }]);
75 setTimeout(() => {
76 setReactionBursts((prev) => prev.filter((b) => b.id !== id));
77 }, 600);
78
79 fetch("/api/trivia/reactions", {
80 method: "POST",
81 headers: { "Content-Type": "application/json" },
82 body: JSON.stringify({ type }),
83 }).catch(() => {});
84 }
85
86 const q = questions[state.currentQuestion];
87 const myVote = voted[state.currentQuestion];
88
89 const answerColors: Record<string, { bg: string; active: string }> = {
90 A: { bg: "bg-blue-500/20 border-blue-500/50", active: "bg-blue-500" },
91 B: { bg: "bg-emerald-500/20 border-emerald-500/50", active: "bg-emerald-500" },
92 C: { bg: "bg-amber-500/20 border-amber-500/50", active: "bg-amber-500" },
93 D: { bg: "bg-rose-500/20 border-rose-500/50", active: "bg-rose-500" },
94 };
95
96 const showReactions = state.phase === "question" || state.phase === "revealed";
97
98 return (
99 <div className="min-h-screen flex flex-col px-4 py-6 max-w-lg mx-auto relative overflow-hidden">
100 <div className="text-center mb-6">
101 <div className="inline-flex items-center gap-2 mb-1">
102 <div className="w-7 h-7 rounded-lg bg-merit-red flex items-center justify-center text-white font-bold text-xs">
103 M
104 </div>
105 <h1 className="text-lg font-semibold text-white">Meritrust Trivia</h1>
106 </div>
107 </div>
108
109 {state.phase === "lobby" && (
110 <div className="flex-1 flex flex-col items-center justify-center text-center animate-fade-in">
111 {!joined ? (
112 <>
113 <div className="text-5xl mb-6">&#127942;</div>
114 <h2 className="text-2xl font-bold text-white mb-2">Ready to play?</h2>
115 <p className="text-meritrust-400 mb-8">
116 Test your Meritrust knowledge!
117 </p>
118 <button
119 onClick={joinGame}
120 className="px-8 py-4 rounded-2xl bg-merit-red text-white text-lg font-semibold hover:bg-merit-red-hover transition-colors w-full"
121 >
122 Join Game
123 </button>
124 </>
125 ) : (
126 <>
127 <div className="text-5xl mb-6">&#9989;</div>
128 <h2 className="text-2xl font-bold text-white mb-2">You&apos;re in!</h2>
129 <p className="text-meritrust-400">
130 Waiting for the host to start...
131 </p>
132 <div className="mt-6 w-8 h-8 border-3 border-merit-red border-t-transparent rounded-full animate-spin" />
133 </>
134 )}
135 </div>
136 )}
137
138 {state.phase === "question" && q && (
139 <div className="flex-1 flex flex-col animate-fade-in">
140 <div className="text-center mb-6">
141 <span className="text-xs text-meritrust-400 uppercase tracking-wider">
142 Question {state.currentQuestion + 1} of {questions.length}
143 </span>
144 <h2 className="text-xl font-bold text-white mt-2 leading-snug">
145 {q.question}
146 </h2>
147 </div>
148
149 {!myVote ? (
150 <div className="space-y-3 flex-1">
151 {q.answers.map((a) => (
152 <button
153 key={a.key}
154 onClick={() => vote(a.key)}
155 disabled={submitting}
156 className={`w-full flex items-center gap-3 p-4 rounded-xl border-2 transition-all active:scale-[0.98] ${answerColors[a.key].bg} hover:opacity-90 disabled:opacity-50`}
157 >
158 <span
159 className={`w-9 h-9 rounded-lg ${answerColors[a.key].active} flex items-center justify-center text-white font-bold`}
160 >
161 {a.key}
162 </span>
163 <span className="text-white font-medium text-left flex-1">
164 {a.text}
165 </span>
166 </button>
167 ))}
168 </div>
169 ) : (
170 <div className="flex-1 flex flex-col items-center justify-center text-center">
171 <div className="text-5xl mb-4">👍</div>
172 <h3 className="text-xl font-semibold text-white mb-2">
173 Vote recorded!
174 </h3>
175 <p className="text-meritrust-300">
176 You picked{" "}
177 <span className="font-semibold text-white">
178 {q.answers.find((a) => a.key === myVote)?.text}
179 </span>
180 </p>
181 <p className="text-meritrust-600 text-sm mt-4">
182 Waiting for the host to reveal...
183 </p>
184 </div>
185 )}
186 </div>
187 )}
188
189 {state.phase === "revealed" && q && (
190 <div className="flex-1 flex flex-col items-center justify-center text-center animate-fade-in">
191 {myVote === q.correct ? (
192 <>
193 <div className="text-6xl mb-4">&#127881;</div>
194 <h3 className="text-2xl font-bold text-emerald-400 mb-2">
195 Correct!
196 </h3>
197 </>
198 ) : (
199 <>
200 <div className="text-6xl mb-4">&#128517;</div>
201 <h3 className="text-2xl font-bold text-rose-400 mb-2">
202 Not quite!
203 </h3>
204 </>
205 )}
206 <p className="text-meritrust-300 text-lg">
207 The answer was{" "}
208 <span className="font-semibold text-white">
209 {q.answers.find((a) => a.key === q.correct)?.text}
210 </span>
211 </p>
212 {!myVote && (
213 <p className="text-meritrust-600 text-sm mt-2">
214 (You didn&apos;t vote on this one)
215 </p>
216 )}
217 </div>
218 )}
219
220 {state.phase === "finished" && (
221 <div className="flex-1 flex flex-col items-center justify-center text-center animate-fade-in">
222 <div className="text-6xl mb-4">&#127942;</div>
223 <h2 className="text-2xl font-bold text-white mb-2">Game over!</h2>
224 <p className="text-meritrust-300 mb-6">Thanks for playing!</p>
225 <div className="space-y-3 w-full">
226 {questions.map((qq, i) => {
227 const myAnswer = voted[i];
228 const correct = myAnswer === qq.correct;
229 return (
230 <div
231 key={i}
232 className={`rounded-xl p-3 border ${
233 correct
234 ? "bg-emerald-500/10 border-emerald-500/30"
235 : "bg-rose-500/10 border-rose-500/30"
236 }`}
237 >
238 <p className="text-sm text-meritrust-400">Q{i + 1}</p>
239 <p className="text-white font-medium">
240 {correct ? "✓" : "✗"}{" "}
241 {qq.answers.find((a) => a.key === qq.correct)?.text}
242 </p>
243 </div>
244 );
245 })}
246 </div>
247 </div>
248 )}
249
250 {/* Reaction buttons */}
251 {showReactions && (
252 <div className="flex gap-4 justify-center py-4 mt-2">
253 <button
254 onClick={() => sendReaction("up")}
255 className="relative w-16 h-16 rounded-full bg-emerald-500/20 border-2 border-emerald-500/50 flex items-center justify-center text-2xl active:scale-90 transition-transform"
256 >
257 👍
258 </button>
259 <button
260 onClick={() => sendReaction("down")}
261 className="relative w-16 h-16 rounded-full bg-rose-500/20 border-2 border-rose-500/50 flex items-center justify-center text-2xl active:scale-90 transition-transform"
262 >
263 👎
264 </button>
265 </div>
266 )}
267
268 {/* Burst feedback on tap */}
269 {reactionBursts.map((b) => (
270 <div key={b.id} className="reaction-burst">
271 {b.type}
272 </div>
273 ))}
274 </div>
275 );
276}

src/app/api/chat/route.ts

68 lines
1import Anthropic from "@anthropic-ai/sdk";
2import { MERITRUST_SYSTEM_PROMPT } from "@/lib/meritrust-context";
3import { getCloudflareContext } from "@opennextjs/cloudflare";
4
5export const dynamic = "force-dynamic";
6
7export async function POST(request: Request) {
8 try {
9 const { env } = await getCloudflareContext();
10 const apiKey = (env as Record<string, string>).ANTHROPIC_API_KEY;
11
12 if (!apiKey) {
13 return new Response(
14 JSON.stringify({ error: "API key not configured" }),
15 { status: 500, headers: { "Content-Type": "application/json" } }
16 );
17 }
18
19 const body = (await request.json()) as { messages: { role: "user" | "assistant"; content: string }[] };
20 const messages = body.messages;
21
22 const client = new Anthropic({ apiKey });
23
24 const stream = client.messages.stream({
25 model: "claude-sonnet-4-6",
26 max_tokens: 1024,
27 system: MERITRUST_SYSTEM_PROMPT,
28 messages,
29 });
30
31 const encoder = new TextEncoder();
32 const readable = new ReadableStream({
33 async start(controller) {
34 try {
35 for await (const event of stream) {
36 if (
37 event.type === "content_block_delta" &&
38 event.delta.type === "text_delta"
39 ) {
40 controller.enqueue(
41 encoder.encode(`data: ${JSON.stringify({ text: event.delta.text })}\n\n`)
42 );
43 }
44 }
45 controller.enqueue(encoder.encode("data: [DONE]\n\n"));
46 controller.close();
47 } catch (err) {
48 console.error("Stream error:", err);
49 controller.error(err);
50 }
51 },
52 });
53
54 return new Response(readable, {
55 headers: {
56 "Content-Type": "text/event-stream",
57 "Cache-Control": "no-cache, no-store",
58 Connection: "keep-alive",
59 },
60 });
61 } catch (err) {
62 console.error("Chat API error:", err);
63 return new Response(JSON.stringify({ error: "Internal error" }), {
64 status: 500,
65 headers: { "Content-Type": "application/json" },
66 });
67 }
68}

src/app/api/buzz/route.ts

68 lines
1import Anthropic from "@anthropic-ai/sdk";
2import { BUZZ_SYSTEM_PROMPT } from "@/lib/buzz-context";
3import { getCloudflareContext } from "@opennextjs/cloudflare";
4
5export const dynamic = "force-dynamic";
6
7export async function POST(request: Request) {
8 try {
9 const { env } = await getCloudflareContext();
10 const apiKey = (env as Record<string, string>).ANTHROPIC_API_KEY;
11
12 if (!apiKey) {
13 return new Response(
14 JSON.stringify({ error: "API key not configured" }),
15 { status: 500, headers: { "Content-Type": "application/json" } }
16 );
17 }
18
19 const body = (await request.json()) as { messages: { role: "user" | "assistant"; content: string }[] };
20 const messages = body.messages;
21
22 const client = new Anthropic({ apiKey });
23
24 const stream = client.messages.stream({
25 model: "claude-sonnet-4-6",
26 max_tokens: 1024,
27 system: BUZZ_SYSTEM_PROMPT,
28 messages,
29 });
30
31 const encoder = new TextEncoder();
32 const readable = new ReadableStream({
33 async start(controller) {
34 try {
35 for await (const event of stream) {
36 if (
37 event.type === "content_block_delta" &&
38 event.delta.type === "text_delta"
39 ) {
40 controller.enqueue(
41 encoder.encode(`data: ${JSON.stringify({ text: event.delta.text })}\n\n`)
42 );
43 }
44 }
45 controller.enqueue(encoder.encode("data: [DONE]\n\n"));
46 controller.close();
47 } catch (err) {
48 console.error("Stream error:", err);
49 controller.error(err);
50 }
51 },
52 });
53
54 return new Response(readable, {
55 headers: {
56 "Content-Type": "text/event-stream",
57 "Cache-Control": "no-cache, no-store",
58 Connection: "keep-alive",
59 },
60 });
61 } catch (err) {
62 console.error("Buzz API error:", err);
63 return new Response(JSON.stringify({ error: "Internal error" }), {
64 status: 500,
65 headers: { "Content-Type": "application/json" },
66 });
67 }
68}

src/app/api/trivia/state/route.ts

25 lines
1import { getCloudflareContext } from "@opennextjs/cloudflare";
2import { createInitialState, type GameState } from "@/lib/trivia";
3
4export const dynamic = "force-dynamic";
5
6export async function GET() {
7 try {
8 const { env } = await getCloudflareContext();
9 const kv = (env as Record<string, KVNamespace>).TRIVIA_KV;
10
11 if (!kv) {
12 return Response.json({ error: "KV not configured" }, { status: 500 });
13 }
14
15 const raw = await kv.get("state", { type: "text" });
16 const state: GameState = raw ? JSON.parse(raw) : createInitialState();
17
18 return Response.json(state, {
19 headers: { "Cache-Control": "no-cache, no-store" },
20 });
21 } catch (err) {
22 console.error("Trivia state error:", err);
23 return Response.json({ error: "Internal error" }, { status: 500 });
24 }
25}

src/app/api/trivia/vote/route.ts

68 lines
1import { getCloudflareContext } from "@opennextjs/cloudflare";
2import { createInitialState, type GameState } from "@/lib/trivia";
3
4export const dynamic = "force-dynamic";
5
6export async function POST(request: Request) {
7 try {
8 const { env } = await getCloudflareContext();
9 const kv = (env as Record<string, KVNamespace>).TRIVIA_KV;
10
11 if (!kv) {
12 return Response.json({ error: "KV not configured" }, { status: 500 });
13 }
14
15 const { answer, voterId, join } = (await request.json()) as {
16 answer: string | null;
17 voterId: string;
18 join?: boolean;
19 };
20
21 if (!voterId) {
22 return Response.json({ error: "Missing voterId" }, { status: 400 });
23 }
24
25 const raw = await kv.get("state", { type: "text" });
26 const state: GameState = raw ? JSON.parse(raw) : createInitialState();
27
28 if (!state.players.includes(voterId)) {
29 state.players.push(voterId);
30 }
31
32 if (join || !answer) {
33 await kv.put("state", JSON.stringify(state));
34 return Response.json({ ok: true }, {
35 headers: { "Cache-Control": "no-cache, no-store" },
36 });
37 }
38
39 if (state.phase !== "question") {
40 return Response.json({ error: "Not accepting votes" }, { status: 400 });
41 }
42
43 const qId = String(state.currentQuestion);
44
45 if (state.voters[qId]?.includes(voterId)) {
46 return Response.json({ error: "Already voted" }, { status: 409 });
47 }
48
49 if (!state.votes[qId]) {
50 state.votes[qId] = { A: 0, B: 0, C: 0, D: 0 };
51 }
52 if (!state.voters[qId]) {
53 state.voters[qId] = [];
54 }
55
56 state.votes[qId][answer] = (state.votes[qId][answer] || 0) + 1;
57 state.voters[qId].push(voterId);
58
59 await kv.put("state", JSON.stringify(state));
60
61 return Response.json({ ok: true }, {
62 headers: { "Cache-Control": "no-cache, no-store" },
63 });
64 } catch (err) {
65 console.error("Vote error:", err);
66 return Response.json({ error: "Internal error" }, { status: 500 });
67 }
68}

src/app/api/trivia/next/route.ts

44 lines
1import { getCloudflareContext } from "@opennextjs/cloudflare";
2import { createInitialState, questions, type GameState } from "@/lib/trivia";
3
4export const dynamic = "force-dynamic";
5
6export async function POST(request: Request) {
7 try {
8 const { env } = await getCloudflareContext();
9 const kv = (env as Record<string, KVNamespace>).TRIVIA_KV;
10
11 if (!kv) {
12 return Response.json({ error: "KV not configured" }, { status: 500 });
13 }
14
15 const { action } = (await request.json()) as { action: string };
16
17 const raw = await kv.get("state", { type: "text" });
18 const state: GameState = raw ? JSON.parse(raw) : createInitialState();
19
20 if (action === "start") {
21 state.phase = "question";
22 state.currentQuestion = 0;
23 } else if (action === "reveal") {
24 state.phase = "revealed";
25 } else if (action === "next") {
26 const nextQ = state.currentQuestion + 1;
27 if (nextQ >= questions.length) {
28 state.phase = "finished";
29 } else {
30 state.currentQuestion = nextQ;
31 state.phase = "question";
32 }
33 }
34
35 await kv.put("state", JSON.stringify(state));
36
37 return Response.json(state, {
38 headers: { "Cache-Control": "no-cache, no-store" },
39 });
40 } catch (err) {
41 console.error("Next error:", err);
42 return Response.json({ error: "Internal error" }, { status: 500 });
43 }
44}

src/app/api/trivia/reset/route.ts

25 lines
1import { getCloudflareContext } from "@opennextjs/cloudflare";
2import { createInitialState } from "@/lib/trivia";
3
4export const dynamic = "force-dynamic";
5
6export async function POST() {
7 try {
8 const { env } = await getCloudflareContext();
9 const kv = (env as Record<string, KVNamespace>).TRIVIA_KV;
10
11 if (!kv) {
12 return Response.json({ error: "KV not configured" }, { status: 500 });
13 }
14
15 const state = createInitialState();
16 await kv.put("state", JSON.stringify(state));
17
18 return Response.json(state, {
19 headers: { "Cache-Control": "no-cache, no-store" },
20 });
21 } catch (err) {
22 console.error("Reset error:", err);
23 return Response.json({ error: "Internal error" }, { status: 500 });
24 }
25}

src/app/api/trivia/reactions/route.ts

46 lines
1import { getCloudflareContext } from "@opennextjs/cloudflare";
2import type { GameState } from "@/lib/trivia";
3
4export const dynamic = "force-dynamic";
5
6export async function POST(request: Request) {
7 try {
8 const { env } = await getCloudflareContext();
9 const kv = (env as Record<string, KVNamespace>).TRIVIA_KV;
10
11 if (!kv) {
12 return Response.json({ error: "KV not configured" }, { status: 500 });
13 }
14
15 const { type } = (await request.json()) as { type: "up" | "down" };
16 if (type !== "up" && type !== "down") {
17 return Response.json({ error: "Invalid type" }, { status: 400 });
18 }
19
20 const raw = await kv.get("state");
21 if (!raw) {
22 return Response.json({ error: "No game state" }, { status: 404 });
23 }
24
25 const state = JSON.parse(raw) as GameState;
26
27 if (state.phase !== "question" && state.phase !== "revealed") {
28 return Response.json({ error: "Not in active phase" }, { status: 400 });
29 }
30
31 const qKey = String(state.currentQuestion);
32 if (!state.reactions[qKey]) {
33 state.reactions[qKey] = { up: 0, down: 0 };
34 }
35 state.reactions[qKey][type]++;
36
37 await kv.put("state", JSON.stringify(state));
38
39 return Response.json({ ok: true }, {
40 headers: { "Cache-Control": "no-cache, no-store" },
41 });
42 } catch (err) {
43 console.error("Reactions error:", err);
44 return Response.json({ error: "Internal error" }, { status: 500 });
45 }
46}

src/lib/trivia.ts

87 lines
1export interface TriviaQuestion {
2 id: number;
3 question: string;
4 answers: { key: string; text: string }[];
5 correct: string;
6}
7
8export const questions: TriviaQuestion[] = [
9 {
10 id: 0,
11 question:
12 "How did Meritrust announce its 2009 rebrand from \"Boeing Wichita Credit Union\"?",
13 answers: [
14 { key: "A", text: "Full-page ad in the Wichita Eagle" },
15 { key: "B", text: "Bought 10 commercial slots during Super Bowl XLIII on local TV" },
16 { key: "C", text: "Sponsored a Wichita State basketball halftime show" },
17 { key: "D", text: "Billboard campaign across I-35" },
18 ],
19 correct: "B",
20 },
21 {
22 id: 1,
23 question:
24 "The name \"Meritrust\" was selected from an initial list of how many candidate names?",
25 answers: [
26 { key: "A", text: "50" },
27 { key: "B", text: "150" },
28 { key: "C", text: "400" },
29 { key: "D", text: "1,000" },
30 ],
31 correct: "C",
32 },
33 {
34 id: 2,
35 question:
36 "Board Chairman Steve Dunn has served on the Meritrust board since what year?",
37 answers: [
38 { key: "A", text: "1978" },
39 { key: "B", text: "1986" },
40 { key: "C", text: "1994" },
41 { key: "D", text: "2001" },
42 ],
43 correct: "B",
44 },
45 {
46 id: 3,
47 question:
48 "In 1959, the founding deposits of Boulder Valley Schools Credit Union (a PMCU predecessor) were kept where?",
49 answers: [
50 { key: "A", text: "The principal's desk drawer at Boulder High" },
51 { key: "B", text: "A safe deposit box at First National Bank" },
52 { key: "C", text: "A box at Casey Junior High School" },
53 { key: "D", text: "A locked filing cabinet at the district office" },
54 ],
55 correct: "C",
56 },
57];
58
59export interface GameState {
60 phase: "lobby" | "question" | "revealed" | "finished";
61 currentQuestion: number;
62 votes: Record<string, Record<string, number>>;
63 voters: Record<string, string[]>;
64 players: string[];
65 reactions: Record<string, { up: number; down: number }>;
66}
67
68export function createInitialState(): GameState {
69 return {
70 phase: "lobby",
71 currentQuestion: 0,
72 votes: {
73 "0": { A: 0, B: 0, C: 0, D: 0 },
74 "1": { A: 0, B: 0, C: 0, D: 0 },
75 "2": { A: 0, B: 0, C: 0, D: 0 },
76 "3": { A: 0, B: 0, C: 0, D: 0 },
77 },
78 voters: { "0": [], "1": [], "2": [], "3": [] },
79 players: [],
80 reactions: {
81 "0": { up: 0, down: 0 },
82 "1": { up: 0, down: 0 },
83 "2": { up: 0, down: 0 },
84 "3": { up: 0, down: 0 },
85 },
86 };
87}

src/lib/meritrust-context.ts

38 lines
1export const MERITRUST_SYSTEM_PROMPT = `You are a friendly, knowledgeable AI assistant for Meritrust Credit Union. You help members and prospective members learn about Meritrust's services, history, and benefits.
2
3ABOUT MERITRUST:
4- Founded in 1935 by seven employees of the Stearman Aircraft Company in Wichita, Kansas
5- In 1941, Boeing Aircraft Company acquired Stearman, and the credit union became "Wichita Boeing Employees Credit Union"
6- In 1994, renamed to "Boeing Wichita Credit Union" (BWCU)
7- In 2009, rebranded as "Meritrust Credit Union" to reflect its broader community charter
8- On August 1, 2025, merged with Premier Members Credit Union (founded 1959 by Boulder Valley Schools employees in Colorado)
9- Now proudly serves over 200,000 members
10- 33 branch locations across Kansas (Wichita, Manhattan, Lawrence) and Colorado
11- Approximately 650 employees
12- Nearly \$4 billion in assets
13- Deep roots in the aviation industry — membership grew from Stearman to Boeing to Spirit AeroSystems employees, and eventually the broader Wichita community
14
15SERVICES:
16- Checking and savings accounts
17- Auto loans, personal loans, and home equity loans
18- Mortgage lending
19- Credit cards
20- Online and mobile banking
21- Investment services
22- Insurance products
23- Business banking
24- Youth accounts and financial literacy programs
25
26CORE VALUES:
27- Member-owned, not-for-profit cooperative
28- Community-focused — investing in local communities across Kansas and Colorado
29- Committed to financial education and empowerment
30- "People helping people" credit union philosophy
31
32GUIDELINES:
33- Be warm, professional, and helpful
34- If asked about specific rates, fees, or account details you don't have, direct them to meritrust.org or their local branch
35- You can discuss general credit union benefits (lower fees, better rates, member ownership) but don't make specific rate claims
36- Keep responses concise — 2-3 paragraphs max
37- If asked about something unrelated to Meritrust or financial services, politely redirect
38- Never make up information you don't know — say "I'd recommend checking meritrust.org or calling a branch for the most current details"`;

src/lib/buzz-context.ts

92 lines
1export const BUZZ_SYSTEM_PROMPT = `You are the Meritrust "Online Buzz Bot" — an AI that has read real public reviews and comments about Meritrust Credit Union from across the internet. Your job is to answer fun, candid questions about what people are saying online.
2
3You have access to the following real reviews scraped from public sources (WalletHub, Indeed, and other review sites). Use them to answer questions honestly and with personality.
4
5=== MEMBER REVIEWS (WalletHub & public review sites) ===
6
7[1 star] "This is a terrible credit union. The customer service, if you can contact one, is abysmal. They do things without notifying you first. I was out of town and they canceled my debit card. It took two weeks to get a new one."
8
9[1 star] "Meritrust secretly switched my free checking account to a \$7/month paid account without telling me. I only found out when I noticed the charges on my statement months later."
10
11[1 star] "After 50 years of membership, first with Boeing Employees CU and then Meritrust, their new fee structure and unrealistic demands have led to our parting ways. Very disappointing after half a century."
12
13[1 star] "They put a hold on my account for no reason. I couldn't access my money for days. When I called, I was transferred 4 times and still didn't get an answer."
14
15[1 star] "Worst experience ever. They charged me overdraft fees on transactions that should have been declined. When I disputed it, they basically said too bad."
16
17[2 stars] "Used to be great when they were Boeing Employees CU. Ever since the name change and mergers, service has gone downhill. Too big for their own good now."
18
19[3 stars] "Average credit union. Rates are okay but nothing special. The app is decent but could use work. Staff at the Kellogg branch are friendly at least."
20
21[4 stars] "I've been a member for 15 years and generally happy. Their auto loan rates are competitive and the process was smooth. Only ding is the website feels outdated."
22
23[5 stars] "In times of need, when my family was going through a rough patch, PMCU (now Meritrust) stepped in to help us keep our vehicle. They worked with us on payments when other banks wouldn't even talk to us. Forever grateful."
24
25[5 stars] "Great credit union! I've had my accounts here since they were Boeing Employees. The merger with PMCU has actually been pretty smooth. More branches now which is nice."
26
27[4 stars] "Solid local credit union. Not going to blow you away but they're reliable. The Wichita branches are clean and well-staffed. Mobile deposit works well."
28
29[2 stars] "They need to update their technology. The mobile app crashes frequently and the online banking interface looks like it's from 2010. Come on, it's 2024."
30
31[1 star] "Tried to get a mortgage through them. The process took 3 months and they kept asking for the same documents over and over. Eventually went elsewhere."
32
33[5 stars] "Best auto loan rates in Wichita! Got 4.5% when everyone else was quoting 6%+. The loan officer at the Webb Road branch was super helpful."
34
35[3 stars] "It's fine. Nothing to write home about. They're a credit union, they do credit union things. At least they're not a big bank."
36
37=== EMPLOYEE REVIEWS (Indeed & Glassdoor) ===
38
39[5 stars] "One of the best places I've ever worked. Management actually cares about employees. Small enough to feel like a family but big enough to have good benefits."
40
41[5 stars] "They pay you too good to be stressed. Great work-life balance, good PTO, and the people are genuinely nice. Would recommend."
42
43[4 stars] "Good benefits and decent pay for the Wichita area. Culture is positive overall. Only downside is limited room for advancement since it's not a huge organization."
44
45[1 star] "Management plays favorites. If you're not in the inner circle, don't expect to move up. Pay is below market for IT positions."
46
47[3 stars] "Okay place to work. Benefits are average. Management can be hit or miss depending on which branch you're at. Some managers are great, others are terrible."
48
49[2 stars] "Started great but went downhill after the merger. Too much corporate red tape now. Lost the small credit union feel that made it special."
50
51[5 stars] "Fantastic culture! They do fun team events, celebrate birthdays, and actually recognize hard work. The CEO knows employees by name."
52
53[4 stars] "Good entry-level financial job. They train you well and the teller position is a great starting point. Just don't expect to get rich."
54
55[1 star] "Turnover is insane in the call center. They hire in batches and people leave just as fast. The workload is unreasonable."
56
57[3 stars] "It's a job. Benefits keep you there. Pay could be better but it's stable. If you want excitement, look elsewhere. If you want steady, this is it."
58
59[5 stars] "Been here 12 years and I'm still happy. They genuinely care about the community and it shows in how they treat both members and employees."
60
61[4 stars] "Love the community involvement. They sponsor local events and encourage volunteering on company time. Makes you feel good about where you work."
62
63[2 stars] "The merger with PMCU created a lot of confusion and redundancy. Some departments are still figuring out who does what. Growing pains I guess."
64
65[1 star] "Work-life balance is a joke in the lending department. Expected to hit unrealistic numbers while being understaffed. Burnout is real here."
66
67[5 stars] "Amazing holiday party and employee appreciation events. They go all out. Free lunch on Fridays at the main branch. Little things that make a big difference."
68
69[3 stars] "Middle of the road. Not the worst place to work, not the best. If you want a chill 9-5 in Wichita, you could do worse."
70
71[4 stars] "They invested heavily in DEI initiatives recently and it shows. The workplace feels more inclusive than it did 5 years ago."
72
73[2 stars] "Communication from upper management is terrible. We hear about changes from members before we hear from leadership. Embarrassing."
74
75[5 stars] "The financial wellness programs they offer employees are top-notch. Free financial planning, matching 401k, and employee loan discounts."
76
77[3 stars] "Decent gig. The branches close early on Fridays which is nice. Just wish the pay kept up with inflation better."
78
79=== END OF REVIEWS ===
80
81PERSONALITY & RULES:
82- Be entertaining and candid. This is meant to be fun during a live presentation.
83- When asked about negative reviews, be honest but frame it constructively — "here's what people are saying, here's the pattern."
84- When asked about positive reviews, celebrate the wins genuinely.
85- You can quote or paraphrase specific reviews when relevant.
86- If asked "what's the meanest thing someone said" — give them the real answer, don't sugarcoat.
87- If asked about patterns or themes, analyze across all reviews.
88- You can give ratings breakdowns, sentiment summaries, word clouds, etc.
89- Keep responses concise and punchy — this is a live demo.
90- If asked something not covered by the reviews, say so honestly.
91- Never make up fake reviews. Only reference what's above.
92- Sign off responses with fun energy — this is supposed to be entertaining!`;

src/app/source/page.tsx

3 lines
1// This file — you're looking at the rendered version right now!
2// It contains all 18 source files hardcoded as string literals
3// so it works on Cloudflare Workers (no filesystem access).

18 files · 1,858 lines of code

Generated by Claude AI in a single prompt