From fecb130d1ee398d9910912806506d76f22c5e3d8 Mon Sep 17 00:00:00 2001 From: Mona Lisa Date: Mon, 27 Apr 2026 19:27:12 -0700 Subject: [PATCH] Build Gemini personalization experience for the prototype --- react-prototype/prototype/.gitignore | 3 + react-prototype/prototype/README.md | 11 + react-prototype/prototype/api/gemini-chat.js | 5 + react-prototype/prototype/package-lock.json | 9 + .../prototype/server/geminiHandler.js | 190 ++++++++++++++++ react-prototype/prototype/src/App.jsx | 138 +++++++++++- .../src/components/AssistantDock.jsx | 59 +++++ .../src/components/AssistantOverlay.jsx | 212 ++++++++++++++++++ .../src/components/ComparisonTable.jsx | 26 ++- .../prototype/src/components/Navbar.jsx | 83 ++++++- .../prototype/src/components/PricingCard.jsx | 27 ++- .../prototype/src/components/ProductCard.jsx | 14 +- .../prototype/src/components/SegmentCard.jsx | 9 +- .../prototype/src/pages/HomePage.jsx | 85 +++++-- .../prototype/src/utils/assistant.js | 137 +++++++++++ react-prototype/prototype/vite.config.js | 27 ++- 16 files changed, 993 insertions(+), 42 deletions(-) create mode 100644 react-prototype/prototype/api/gemini-chat.js create mode 100644 react-prototype/prototype/server/geminiHandler.js create mode 100644 react-prototype/prototype/src/components/AssistantDock.jsx create mode 100644 react-prototype/prototype/src/components/AssistantOverlay.jsx create mode 100644 react-prototype/prototype/src/utils/assistant.js diff --git a/react-prototype/prototype/.gitignore b/react-prototype/prototype/.gitignore index b947077..c744b43 100644 --- a/react-prototype/prototype/.gitignore +++ b/react-prototype/prototype/.gitignore @@ -1,2 +1,5 @@ node_modules/ dist/ +.env +.env.* +!.env.example diff --git a/react-prototype/prototype/README.md b/react-prototype/prototype/README.md index d0f9514..9eeb309 100644 --- a/react-prototype/prototype/README.md +++ b/react-prototype/prototype/README.md @@ -17,6 +17,17 @@ npm install npm run dev ``` +## Gemini setup + +Create a local `.env` in `react-prototype/prototype` with: + +```bash +GEMINI_API_KEY=your_gemini_api_key_here +GEMINI_MODEL=gemini-2.5-flash +``` + +The React app sends assistant requests to `/api/gemini-chat`. In local Vite dev, that route is handled by the Vite server middleware. In deployment, the same path is served by `api/gemini-chat.js`. + ## Build ```bash diff --git a/react-prototype/prototype/api/gemini-chat.js b/react-prototype/prototype/api/gemini-chat.js new file mode 100644 index 0000000..88fd5f4 --- /dev/null +++ b/react-prototype/prototype/api/gemini-chat.js @@ -0,0 +1,5 @@ +import { handleGeminiChatRequest } from "../server/geminiHandler.js"; + +export default async function handler(req, res) { + return handleGeminiChatRequest(req, res); +} diff --git a/react-prototype/prototype/package-lock.json b/react-prototype/prototype/package-lock.json index e8c2e5c..a9c574d 100644 --- a/react-prototype/prototype/package-lock.json +++ b/react-prototype/prototype/package-lock.json @@ -65,6 +65,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1400,6 +1401,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1850,6 +1852,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2098,6 +2101,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2267,6 +2271,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2276,6 +2281,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2626,6 +2632,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2703,6 +2710,7 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -2796,6 +2804,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/react-prototype/prototype/server/geminiHandler.js b/react-prototype/prototype/server/geminiHandler.js new file mode 100644 index 0000000..7ea953a --- /dev/null +++ b/react-prototype/prototype/server/geminiHandler.js @@ -0,0 +1,190 @@ +const geminiResponseSchema = { + type: "object", + additionalProperties: false, + required: ["reply", "personalization"], + properties: { + reply: { type: "string" }, + personalization: { + type: "object", + additionalProperties: false, + required: [ + "recommendedSlug", + "recommendedProductName", + "focusAudience", + "priorities", + "featuredSegments", + "comparisonFocus", + "highlightedPricingTier", + "followUpPrompts", + "heroTitle", + "heroDescription", + "ctaLabel", + "summary" + ], + properties: { + recommendedSlug: { + type: "string", + enum: ["pulseband", "pulsering", "pulsewatch"] + }, + recommendedProductName: { + type: "string", + enum: ["PulseBand", "PulseRing", "PulseWatch"] + }, + focusAudience: { type: "string" }, + priorities: { + type: "array", + items: { type: "string" }, + minItems: 1, + maxItems: 4 + }, + featuredSegments: { + type: "array", + items: { type: "string" }, + minItems: 1, + maxItems: 3 + }, + comparisonFocus: { + type: "string", + enum: ["PulseBand", "PulseRing", "PulseWatch"] + }, + highlightedPricingTier: { type: "string" }, + followUpPrompts: { + type: "array", + items: { type: "string" }, + minItems: 1, + maxItems: 4 + }, + heroTitle: { type: "string" }, + heroDescription: { type: "string" }, + ctaLabel: { type: "string" }, + summary: { type: "string" } + } + } + } +}; + +function sendJson(res, status, body) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(body)); +} + +function getApiKey() { + return process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || ""; +} + +function getModel() { + return process.env.GEMINI_MODEL || "gemini-2.5-flash"; +} + +async function readJsonBody(req) { + if (req.body && typeof req.body === "object") return req.body; + + const chunks = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + if (!chunks.length) return {}; + + const raw = Buffer.concat(chunks).toString("utf8"); + return raw ? JSON.parse(raw) : {}; +} + +function extractTextFromResponse(payload) { + return payload?.candidates?.[0]?.content?.parts?.[0]?.text || ""; +} + +function buildPrompt(message, messages) { + const recentMessages = Array.isArray(messages) ? messages.slice(-6) : []; + + return [ + "You are PulseWear AI, a personalization assistant for a smartwatch and wearable website.", + "Your job is to understand what the shopper is looking for and return both a natural reply and structured website personalization.", + "The product options are only:", + "- PulseBand: athletes, runners, gym users, recovery, training performance", + "- PulseRing: professionals, wellness users, older adults, sleep, stress, health insights", + "- PulseWatch: students, tech users, multitaskers, productivity, apps, all-in-one daily use", + "Choose the single best-fit device based on the user's goals.", + "Keep the reply concise, warm, and specific to the user's request.", + "Priorities should be short phrases like fitness, recovery, sleep, focus, productivity, stress, health, budget, lifestyle.", + "featuredSegments should be the audience cards on the homepage that deserve emphasis.", + "comparisonFocus should be the device that should stand out in the comparison table.", + "highlightedPricingTier should be the plan tier that best fits the shopper for the recommended device.", + "followUpPrompts should be short clickable suggestions for the assistant.", + "Hero title and CTA should sound polished and website-ready.", + `Recent conversation: ${JSON.stringify(recentMessages)}`, + `Latest user message: ${message}` + ].join("\n"); +} + +export async function handleGeminiChatRequest(req, res) { + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + return sendJson(res, 405, { error: "Method not allowed." }); + } + + const apiKey = getApiKey(); + if (!apiKey) { + return sendJson(res, 503, { + error: "GEMINI_API_KEY is not configured on the server." + }); + } + + try { + const body = await readJsonBody(req); + const message = typeof body.message === "string" ? body.message.trim() : ""; + const messages = Array.isArray(body.messages) ? body.messages : []; + + if (!message) { + return sendJson(res, 400, { error: "A message is required." }); + } + + const geminiResponse = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${getModel()}:generateContent`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-goog-api-key": apiKey + }, + body: JSON.stringify({ + contents: [ + { + parts: [ + { + text: buildPrompt(message, messages) + } + ] + } + ], + generationConfig: { + responseMimeType: "application/json", + responseJsonSchema: geminiResponseSchema + } + }) + } + ); + + const payload = await geminiResponse.json(); + if (!geminiResponse.ok) { + const apiError = + payload?.error?.message || "Gemini request failed."; + return sendJson(res, geminiResponse.status, { error: apiError }); + } + + const text = extractTextFromResponse(payload); + if (!text) { + return sendJson(res, 502, { + error: "Gemini returned an empty response." + }); + } + + const parsed = JSON.parse(text); + return sendJson(res, 200, parsed); + } catch (error) { + return sendJson(res, 500, { + error: error instanceof Error ? error.message : "Unexpected server error." + }); + } +} diff --git a/react-prototype/prototype/src/App.jsx b/react-prototype/prototype/src/App.jsx index 4b66734..28ae7b9 100644 --- a/react-prototype/prototype/src/App.jsx +++ b/react-prototype/prototype/src/App.jsx @@ -1,12 +1,24 @@ import { AnimatePresence, motion } from "framer-motion"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Route, Routes, useLocation } from "react-router-dom"; +import AssistantDock from "./components/AssistantDock"; +import AssistantOverlay from "./components/AssistantOverlay"; import Footer from "./components/Footer"; import Navbar from "./components/Navbar"; import ProductPage from "./components/ProductPage"; import HomePage from "./pages/HomePage"; +import { buildAssistantResponse, getDefaultPersonalization } from "./utils/assistant"; import { productCatalog } from "./data/siteData"; +const SESSION_STORAGE_KEY = "pulsewear-ai-session-v1"; +const defaultMessages = [ + { + role: "assistant", + content: + "Tell me what kind of life you are shopping for and I will reshape the PulseWear site around the best-fit device." + } +]; + function ScrollManager() { const location = useLocation(); @@ -43,18 +55,122 @@ function PageFrame({ children }) { export default function App() { const location = useLocation(); + const [aiModeOpen, setAiModeOpen] = useState(false); + const [messages, setMessages] = useState(() => { + if (typeof window === "undefined") return defaultMessages; + + try { + const saved = window.localStorage.getItem(SESSION_STORAGE_KEY); + if (!saved) return defaultMessages; + const parsed = JSON.parse(saved); + return Array.isArray(parsed.messages) && parsed.messages.length ? parsed.messages : defaultMessages; + } catch { + return defaultMessages; + } + }); + const [isAssistantLoading, setIsAssistantLoading] = useState(false); + const [personalization, setPersonalization] = useState(() => { + if (typeof window === "undefined") return getDefaultPersonalization(); + + try { + const saved = window.localStorage.getItem(SESSION_STORAGE_KEY); + if (!saved) return getDefaultPersonalization(); + const parsed = JSON.parse(saved); + return parsed.personalization || getDefaultPersonalization(); + } catch { + return getDefaultPersonalization(); + } + }); + const sessionActive = messages.length > 1; + + useEffect(() => { + if (typeof window === "undefined") return; + + window.localStorage.setItem( + SESSION_STORAGE_KEY, + JSON.stringify({ + messages, + personalization + }) + ); + }, [messages, personalization]); + + function resetAssistantSession() { + setMessages(defaultMessages); + setPersonalization(getDefaultPersonalization()); + setAiModeOpen(false); + + if (typeof window !== "undefined") { + window.localStorage.removeItem(SESSION_STORAGE_KEY); + } + } + + async function handleAssistantMessage(input) { + const nextMessages = [...messages, { role: "user", content: input }]; + setMessages(nextMessages); + setIsAssistantLoading(true); + + try { + const response = await fetch("/api/gemini-chat", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + message: input, + messages: nextMessages + }) + }); + + if (!response.ok) { + let errorMessage = "Gemini request failed."; + + try { + const payload = await response.json(); + if (payload?.error) errorMessage = payload.error; + } catch { + // Ignore parse failures and keep the default error message. + } + + throw new Error(errorMessage); + } + + const payload = await response.json(); + setPersonalization(payload.personalization); + setMessages((current) => [ + ...current, + { role: "assistant", content: payload.reply } + ]); + } catch (error) { + const fallback = buildAssistantResponse(input); + setPersonalization(fallback.personalization); + setMessages((current) => [ + ...current, + { + role: "assistant", + content: `${fallback.reply} Gemini fallback: ${error instanceof Error ? error.message : "unknown error"}.` + } + ]); + console.error("Gemini chat failed, using local fallback:", error); + } finally { + setIsAssistantLoading(false); + } + } return (
- + setAiModeOpen((value) => !value)} + /> - + } /> @@ -72,6 +188,22 @@ export default function App() {
+ setAiModeOpen(true)} + onReset={resetAssistantSession} + /> + setAiModeOpen(false)} + onResetSession={resetAssistantSession} + onSendMessage={handleAssistantMessage} + />
); } diff --git a/react-prototype/prototype/src/components/AssistantDock.jsx b/react-prototype/prototype/src/components/AssistantDock.jsx new file mode 100644 index 0000000..f5c0f3f --- /dev/null +++ b/react-prototype/prototype/src/components/AssistantDock.jsx @@ -0,0 +1,59 @@ +import { AnimatePresence, motion } from "framer-motion"; + +export default function AssistantDock({ + visible, + personalization, + messageCount, + onOpen, + onReset +}) { + return ( + + {visible ? ( + +
+
+
+
+ AI session saved +
+

+ {personalization.recommendedProductName} is still leading. +

+

+ {messageCount} messages saved. Reopen the assistant to refine your recommendation or start over. +

+
+ +
+ +
+ +
+ {personalization.focusAudience} +
+
+
+
+ ) : null} +
+ ); +} diff --git a/react-prototype/prototype/src/components/AssistantOverlay.jsx b/react-prototype/prototype/src/components/AssistantOverlay.jsx new file mode 100644 index 0000000..64c3e58 --- /dev/null +++ b/react-prototype/prototype/src/components/AssistantOverlay.jsx @@ -0,0 +1,212 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useState } from "react"; + +const starterPrompts = [ + "I am a student and want one device for focus, sleep, and workouts.", + "I want the best wearable for recovery and training performance.", + "Show me the most affordable option for wellness and daily health insight." +]; + +export default function AssistantOverlay({ + open, + messages, + isLoading, + personalization, + onClose, + onResetSession, + onSendMessage +}) { + const [draft, setDraft] = useState(""); + + useEffect(() => { + if (!open) return undefined; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [open]); + + function handleSubmit(event) { + event.preventDefault(); + const message = draft.trim(); + if (!message || isLoading) return; + setDraft(""); + onSendMessage(message); + } + + function handlePromptClick(prompt) { + if (isLoading) return; + setDraft(""); + onSendMessage(prompt); + } + + return ( + + {open ? ( + +
+
+
+

+ AI Assistant +

+

+ Describe your rhythm and I will personalize the site. +

+

+ Your conversation is saved, so you can close this and come back anytime. +

+
+ +
+ + +
+
+ +
+
+
+
+
Conversation
+
+ Ask for the wearable that best matches your needs. +
+
+ {isLoading ? ( +
+ Thinking +
+ ) : null} +
+ +
+ {messages.map((message, index) => ( + +
+ {message.role === "user" ? "You" : "PulseWear AI"} +
+

+ {message.content} +

+
+ ))} +
+ +
+
+ {starterPrompts.map((prompt) => ( + + ))} +
+ +
+