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) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ Live Direction
+
+
+ {personalization.heroTitle}
+
+
+ {personalization.heroDescription}
+
+
+
+
+
+ Website changes
+
+
+ - Recommended device: {personalization.recommendedProductName}
+ - Primary audience: {personalization.focusAudience}
+ - Priority themes: {personalization.priorities.join(", ")}
+ - CTA direction: {personalization.ctaLabel}
+ - Featured segments: {personalization.featuredSegments.join(", ")}
+
+
+
+
+
+ Try next
+
+
+ {personalization.followUpPrompts.map((prompt) => (
+
+ ))}
+
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/react-prototype/prototype/src/components/ComparisonTable.jsx b/react-prototype/prototype/src/components/ComparisonTable.jsx
index e96a019..b226605 100644
--- a/react-prototype/prototype/src/components/ComparisonTable.jsx
+++ b/react-prototype/prototype/src/components/ComparisonTable.jsx
@@ -1,6 +1,6 @@
import { motion } from "framer-motion";
-export default function ComparisonTable({ rows }) {
+export default function ComparisonTable({ rows, focusedDevice }) {
return (
@@ -25,9 +25,18 @@ export default function ComparisonTable({ rows }) {
{rows.map((row) => (
- | {row.device} |
+
+
+ {row.device}
+ {row.device === focusedDevice ? (
+
+ AI pick
+
+ ) : null}
+
+ |
{row.bestFor} |
{row.focus} |
{row.price} |
@@ -42,11 +51,18 @@ export default function ComparisonTable({ rows }) {
-
{row.device}
+
+
{row.device}
+ {row.device === focusedDevice ? (
+
+ AI pick
+
+ ) : null}
+
{row.bestFor}
{row.price}
diff --git a/react-prototype/prototype/src/components/Navbar.jsx b/react-prototype/prototype/src/components/Navbar.jsx
index 4ca6dd3..3ccfca5 100644
--- a/react-prototype/prototype/src/components/Navbar.jsx
+++ b/react-prototype/prototype/src/components/Navbar.jsx
@@ -29,7 +29,75 @@ function NavLink({ item, pathname, hash, onClick }) {
);
}
-export default function Navbar() {
+function AiModeToggle({
+ enabled,
+ sessionActive = false,
+ messageCount = 0,
+ onToggle,
+ compact = false
+}) {
+ return (
+
+ );
+}
+
+export default function Navbar({
+ aiEnabled = false,
+ sessionActive = false,
+ messageCount = 0,
+ onToggleAiMode = () => {}
+}) {
const { pathname, hash } = useLocation();
const [open, setOpen] = useState(false);
@@ -67,6 +135,12 @@ export default function Navbar() {
+
+
{mobileLinks.map((item) => (
{tier.price}
- {featured ? (
-
- Popular
-
- ) : null}
+
+ {featured ? (
+
+ Popular
+
+ ) : null}
+ {highlightedLabel ? (
+
+ {highlightedLabel}
+
+ ) : null}
+
{tier.note}