diff --git a/app/api/sample-results/route.ts b/app/api/sample-results/route.ts new file mode 100644 index 0000000..6cbf207 --- /dev/null +++ b/app/api/sample-results/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; + +const SAMPLE_RESULTS_URL = "https://monadic-dna-explorer.nyc3.cdn.digitaloceanspaces.com/monadic_dna_explorer_results_2026-05-19.tsv"; + +export async function GET() { + try { + const upstream = await fetch(SAMPLE_RESULTS_URL, { + method: "GET", + cache: "no-store", + }); + + if (!upstream.ok) { + return NextResponse.json( + { error: `Sample results upstream failed with status ${upstream.status}` }, + { status: 502 } + ); + } + + const data = await upstream.arrayBuffer(); + const contentType = upstream.headers.get("content-type") || "text/tab-separated-values; charset=utf-8"; + const contentLength = upstream.headers.get("content-length") || String(data.byteLength); + + return new NextResponse(data, { + status: 200, + headers: { + "Content-Type": contentType, + "Content-Length": contentLength, + "Content-Disposition": "inline; filename=\"monadic_dna_explorer_results_2026-05-19.tsv\"", + "Cache-Control": "no-store", + }, + }); + } catch (error) { + console.error("[sample-results] Failed to fetch sample results:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch sample results" }, + { status: 500 } + ); + } +} diff --git a/app/components/ConversionOnboarding.tsx b/app/components/ConversionOnboarding.tsx index f50f2c3..6d976b2 100644 --- a/app/components/ConversionOnboarding.tsx +++ b/app/components/ConversionOnboarding.tsx @@ -1,3 +1,4 @@ +// MOTHBALLED 2026-05-19: The conversion onboarding flow is preserved for reference, but active entry points now use the lightweight new-user choice modal. "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index 38caebd..f2b32ba 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -69,6 +69,25 @@ export default function Footer() { . All rights reserved.

+

+ + Terms and Conditions + + {" · "} + + Privacy Policy + +

diff --git a/app/components/LLMChatInline.tsx b/app/components/LLMChatInline.tsx index 28e5a8c..77846d1 100644 --- a/app/components/LLMChatInline.tsx +++ b/app/components/LLMChatInline.tsx @@ -5,14 +5,10 @@ import { SavedResult } from "@/lib/results-manager"; import NilAIConsentModal from "./NilAIConsentModal"; import { useResults } from "./ResultsContext"; import { useCustomization } from "./CustomizationContext"; -import { useAuth } from "./AuthProvider"; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { callLLM, callLLMStream, getLLMDescription, MessageContentPart } from "@/lib/llm-client"; -import { getLLMConfig } from "@/lib/llm-config"; -import { SparklesIcon } from "./Icons"; import { trackLLMQuestionAsked } from "@/lib/analytics"; -import { hasValidPromoAccess } from "@/lib/promo-access"; type AttachmentType = 'text' | 'pdf' | 'csv' | 'tsv' | 'image'; @@ -60,16 +56,10 @@ const FOLLOWUP_SUGGESTIONS = [ "How should I adjust my diet and lifestyle?" ]; -type AIChatInlineProps = { - onOpenTour?: () => void; -}; - -export default function AIChatInline({ onOpenTour }: AIChatInlineProps = {}) { +export default function AIChatInline() { const resultsContext = useResults(); const { getTopResultsByRelevance } = resultsContext; const { customization, status: customizationStatus } = useCustomization(); - const { hasActiveSubscription } = useAuth(); - const [mounted, setMounted] = useState(false); const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(""); @@ -78,13 +68,10 @@ export default function AIChatInline({ onOpenTour }: AIChatInlineProps = {}) { const [error, setError] = useState(null); const [showConsentModal, setShowConsentModal] = useState(false); const [hasConsent, setHasConsent] = useState(false); - const [hasPromoAccess, setHasPromoAccess] = useState(false); - const [showPersonalizationPrompt, setShowPersonalizationPrompt] = useState(false); const [expandedMessageIndex, setExpandedMessageIndex] = useState(null); const [attachedFiles, setAttachedFiles] = useState([]); const [attachmentError, setAttachmentError] = useState(null); const [expandedAttachmentIndex, setExpandedAttachmentIndex] = useState(null); - const [showProviderTip, setShowProviderTip] = useState(true); const inputRef = useRef(null); const fileInputRef = useRef(null); @@ -92,11 +79,6 @@ export default function AIChatInline({ onOpenTour }: AIChatInlineProps = {}) { useEffect(() => { setMounted(true); - // Check for promo code access - if (hasValidPromoAccess()) { - setHasPromoAccess(true); - } - // Check consent const consent = localStorage.getItem(CONSENT_STORAGE_KEY); if (consent === 'true') { @@ -114,14 +96,6 @@ export default function AIChatInline({ onOpenTour }: AIChatInlineProps = {}) { } }, []); - useEffect(() => { - // Check if personalization is not set or locked on mount only - if (customizationStatus === 'not-set' || customizationStatus === 'locked') { - setShowPersonalizationPrompt(true); - } else if (customizationStatus === 'unlocked') { - setShowPersonalizationPrompt(false); - } - }, [customizationStatus]); // Removed auto-scroll so user doesn't have to scroll up to read responses // Also removed auto-focus to prevent scrolling to bottom on tab load @@ -131,6 +105,7 @@ export default function AIChatInline({ onOpenTour }: AIChatInlineProps = {}) { localStorage.setItem(CONSENT_STORAGE_KEY, "true"); setHasConsent(true); setShowConsentModal(false); + void handleSendMessage(true); } }; @@ -138,9 +113,6 @@ export default function AIChatInline({ onOpenTour }: AIChatInlineProps = {}) { setShowConsentModal(false); }; - const handlePersonalizationPromptContinue = () => { - setShowPersonalizationPrompt(false); - }; const handleExampleClick = (question: string) => { setInputValue(question); @@ -165,32 +137,6 @@ export default function AIChatInline({ onOpenTour }: AIChatInlineProps = {}) { return `${score.toFixed(2)}x`; }; - const getProviderTip = () => { - if (!mounted) return null; - - const config = getLLMConfig(); - - if (config.provider === 'nilai' || config.provider === 'ollama') { - return { - icon: 'Private', - type: 'privacy', - message: config.provider === 'nilai' - ? 'nilAI is active for privacy-preserving TEE processing.' - : 'Ollama is active for local processing on your device.', - tip: 'Switch models from the LLM button in the menu bar.', - }; - } else if (config.provider === 'huggingface') { - return { - icon: 'Fast', - type: 'performance', - message: 'HuggingFace is active for broader model access.', - tip: 'Switch back to nilAI from the LLM button for stronger privacy.', - }; - } - - return null; - }; - const handleAttachmentClick = () => { setAttachmentError(null); fileInputRef.current?.click(); @@ -351,31 +297,12 @@ export default function AIChatInline({ onOpenTour }: AIChatInlineProps = {}) { return parts; }; - const handleSendMessage = async () => { + const handleSendMessage = async (skipConsentCheck = false) => { const query = inputValue.trim(); if (!query) return; - // Check authentication first - if (!hasActiveSubscription && !hasPromoAccess) { - // Check if user is authenticated - const dynamicButton = document.querySelector('[data-dynamic-widget-button]') as HTMLElement; - if (dynamicButton) { - // Try to determine if user is logged in by checking for Dynamic's user indicator - const isLoggedIn = document.querySelector('[data-dynamic-user-profile]'); - if (!isLoggedIn) { - // Not logged in, trigger login - dynamicButton.click(); - return; - } - } - // User is logged in but not subscribed, show payment modal - const event = new CustomEvent('openPaymentModal'); - window.dispatchEvent(event); - return; - } - // Check consent before sending first message - if (!hasConsent) { + if (!skipConsentCheck && !hasConsent) { setShowConsentModal(true); return; } @@ -574,6 +501,7 @@ RESPONSE STRUCTURE (Complete Each Section Fully): - Group findings into 2-4 major themes (e.g., cardiovascular, metabolic, inflammatory) - For each theme, explain the overall trend and what it means for them specifically - Connect how different themes relate to each other +- Mention the most relevant gene/SNP/allele combinations, along with the associated OR or beta. **Section 3: What This Means for You Specifically** (2-3 paragraphs) - Synthesize how these findings interact with their ethnicity, age, and medical history @@ -918,34 +846,8 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug /> )}
-
-
-
- -
-
-

DNA Chat

-

- {getLLMDescription()} - secure processing for your saved genetic results -

- {onOpenTour && ( - - )} -
-
-
- -
-
- {mounted ? resultsContext.savedResults.length.toLocaleString() : '...'} saved genetic results available -
- {messages.length > 0 && ( + {messages.length > 0 && ( +
- )} -
- - {showPersonalizationPrompt && ( -
-
- Personalization improves answers. - - {customizationStatus === 'locked' - ? ' Unlock your saved profile from the Personalize menu when you are ready.' - : ' Add ancestry, history, and demographics from the Personalize menu when you are ready.'} - -
-
)} - {/* Provider tip banner */} - {showProviderTip && (() => { - const tip = getProviderTip(); - if (!tip) return null; - - return ( -
-
- {tip.icon} -
-
{tip.message}
-
{tip.tip}
-
-
- -
- ); - })()} -
{messages.length === 0 && (
-
-

Ask about your DNA results

- Suggested prompts -
- - {mounted && resultsContext.savedResults.length < 1000 && ( -
-

Limited results ({resultsContext.savedResults.length} studies)

-

- For better answers, analyze more studies or load a prior results file. -

-
- )} + {}
    {EXAMPLE_QUESTIONS.map((question) => ( @@ -1022,14 +871,12 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug ))}
-
- Disclaimer: LLMs can report incorrect or fabricated information and are not medical experts. For educational purposes only. Consult a healthcare professional for medical advice. -
)} {messages - .filter(message => message.role !== 'system') // Hide system messages from UI + .filter(message => message.role !== 'system') + .filter(message => !(message.role === 'assistant' && !message.content && isLoading)) .map((message, idx, filteredMessages) => { // Check if this is the last assistant message in the filtered array const isLastAssistantMessage = message.role === 'assistant' && @@ -1051,31 +898,13 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug )}
{message.role === 'assistant' && ( - <> - - {isLastAssistantMessage && !isLoading && ( -
-
Try asking:
-
- {FOLLOWUP_SUGGESTIONS.map((suggestion, sidx) => ( - - ))} -
-
- )} - + )} {message.role === 'assistant' && message.studiesUsed && message.studiesUsed.length > 0 && (
@@ -1111,6 +940,22 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug )}
)} + {message.role === 'assistant' && isLastAssistantMessage && !isLoading && ( +
+
Try asking:
+
+ {FOLLOWUP_SUGGESTIONS.map((suggestion, sidx) => ( + + ))} +
+
+ )} {message.role === 'user' && message.attachments && message.attachments.length > 0 && (
+
)}
@@ -1246,18 +1111,17 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug
- AI-generated content may contain errors. This is not medical advice. + LLMs can report incorrect or fabricated information and are not medical experts. For educational purposes only. Consult a healthcare professional for medical advice.
diff --git a/app/components/MenuBar.tsx b/app/components/MenuBar.tsx index 21a2510..7e20df0 100644 --- a/app/components/MenuBar.tsx +++ b/app/components/MenuBar.tsx @@ -172,6 +172,8 @@ export default function MenuBar() { }; const handleRunAll = () => { + window.dispatchEvent(new Event("showMobileCompatibilityNotice")); + if (isRunningAll) { setShowRunAllModal(true); return; @@ -358,9 +360,9 @@ export default function MenuBar() { isOpen={showHelpDropdown} onClose={() => setShowHelpDropdown(false)} onRestartOnboarding={() => { - trackGetStartedClicked("restart_onboarding"); + trackGetStartedClicked("welcome_options"); if (pathname === "/") { - window.dispatchEvent(new CustomEvent("openConversionOnboarding", { detail: { mode: "guided" } })); + window.dispatchEvent(new CustomEvent("openNewUserChoiceModal")); return; } @@ -410,13 +412,10 @@ export default function MenuBar() { - - DNA Chat - Premium - + DNA Chat setShowHelpDropdown(!showHelpDropdown)} - title="Get help and reopen onboarding" + title="Get help and start options" data-tour="help-button" > diff --git a/app/components/MenuDropdowns.tsx b/app/components/MenuDropdowns.tsx index 7adc8d0..3e227c8 100644 --- a/app/components/MenuDropdowns.tsx +++ b/app/components/MenuDropdowns.tsx @@ -321,18 +321,6 @@ export function HelpDropdown({

Help & Feedback

- {onRestartOnboarding && ( - - )} (null); useEffect(() => { - const dismissed = localStorage.getItem(STORAGE_KEY) === "true"; - const mediaQuery = window.matchMedia(MOBILE_QUERY); - - const updateVisibility = () => { - setShouldShow(mediaQuery.matches && !dismissed); + const handleTrigger = () => { + const dismissed = localStorage.getItem(STORAGE_KEY) === "true"; + const isMobile = window.matchMedia(MOBILE_QUERY).matches; + if (isMobile && !dismissed) { + setShouldShow(true); + } }; - updateVisibility(); - - if (mediaQuery.addEventListener) { - mediaQuery.addEventListener("change", updateVisibility); - return () => mediaQuery.removeEventListener("change", updateVisibility); - } - - mediaQuery.addListener(updateVisibility); - return () => mediaQuery.removeListener(updateVisibility); + window.addEventListener("showMobileCompatibilityNotice", handleTrigger); + return () => window.removeEventListener("showMobileCompatibilityNotice", handleTrigger); }, []); useEffect(() => { diff --git a/app/components/PremiumFeatureHeader.tsx b/app/components/PremiumFeatureHeader.tsx index 689291f..aac1dbb 100644 --- a/app/components/PremiumFeatureHeader.tsx +++ b/app/components/PremiumFeatureHeader.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { AuthButton, useAuth } from "./AuthProvider"; import { clearPromoAccess, hasValidPromoAccess } from "@/lib/promo-access"; -import { trackPremiumTabViewed } from "@/lib/analytics"; +import { trackOverviewReportTabViewed } from "@/lib/analytics"; type PremiumFeatureHeaderProps = { featureName: string; @@ -51,7 +51,7 @@ export default function PremiumFeatureHeader({ useEffect(() => { if (tabViewTrackedRef.current) return; tabViewTrackedRef.current = true; - trackPremiumTabViewed(featureName.toLowerCase().replace(/\s+/g, "_"), hasPremiumAccess); + trackOverviewReportTabViewed(hasPremiumAccess); }, [featureName, hasPremiumAccess]); return ( diff --git a/app/components/tours/tourContent.ts b/app/components/tours/tourContent.ts index a34ee91..2766cc3 100644 --- a/app/components/tours/tourContent.ts +++ b/app/components/tours/tourContent.ts @@ -17,7 +17,7 @@ export type TourContent = { const myDataStep: TourStep = { name: "my_data", title: "Upload your DNA first", - body: "If you haven't already, click 'My Data' in the top bar to upload your raw genetic data file from 23andMe, AncestryDNA, or similar. Your data is processed entirely in your browser and never sent to any server.", + body: "Click on 'My Data' in the top bar to upload your raw genetic data file from 23andMe, AncestryDNA, or similar. Your data is processed entirely in your browser and never sent to any server.", selector: '[data-tour="my-data-button"]', placement: "bottom", }; @@ -70,7 +70,7 @@ export const dnaChatTour: TourContent = { { name: "intro", title: "Chat with your DNA", - body: "DNA Chat is a private LLM that uses your saved genetic results as context. Ask anything about your traits, sleep, diet, or risks.", + body: "DNA Chat is an anonymous and confidential LLM chat that uses your genetic results as context. Ask anything about your traits, sleep, diet, or risks.", }, myDataStep, { @@ -90,14 +90,14 @@ export const dnaChatTour: TourContent = { { name: "send", title: "Send your question", - body: "Click here to send. The first answer pulls in your most relevant studies as context — follow-ups continue the conversation.", + body: "Click here to send. The first answer pulls in your most relevant results as context and follow-ups continue the conversation.", selector: ".chat-send-button", placement: "top", }, { name: "attach", title: "Upload lab reports and documents", - body: "You can attach PDFs, CSVs, or text files — like genetic lab reports or bloodwork — and ask questions about them directly. Come back every time you have a new document to analyse.", + body: "You can attach PDFs, CSVs, or text files, like lab reports or medical notes, and ask questions about them directly. Come back every time you have a new document to analyse.", selector: '[data-tour="attach-button"]', placement: "top", }, diff --git a/app/dna-chat/page.tsx b/app/dna-chat/page.tsx index eefb3d1..d773f2c 100644 --- a/app/dna-chat/page.tsx +++ b/app/dna-chat/page.tsx @@ -1,40 +1,216 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import MenuBar from "../components/MenuBar"; import Footer from "../components/Footer"; -import PremiumFeatureHeader from "../components/PremiumFeatureHeader"; -import { PremiumPaywall } from "../components/PremiumPaywall"; import LLMChatInline from "../components/LLMChatInline"; -import GuidedTour, { hasCompletedTour } from "../components/GuidedTour"; +import GuidedTour from "../components/GuidedTour"; import { dnaChatTour } from "../components/tours/tourContent"; +import { useResults } from "../components/ResultsContext"; +import { ResultsManager } from "@/lib/results-manager"; import { trackDNAChatViewed } from "@/lib/analytics"; +const SAMPLE_RESULTS_FILE_NAME = "monadic_dna_explorer_results_2026-05-19.tsv"; + +type SampleLoadState = { + status: "idle" | "downloading" | "loading" | "loaded" | "skipped" | "error"; + downloadedBytes: number; + totalBytes: number; + resultCount: number; + error: string | null; +}; + +const initialSampleLoadState: SampleLoadState = { + status: "idle", + downloadedBytes: 0, + totalBytes: 0, + resultCount: 0, + error: null, +}; + +function formatBytes(bytes: number): string { + if (!bytes) return "0 KB"; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export default function DNAChatPage() { + const { addResultsBatch, clearResults, savedResults } = useResults(); const [tourOpen, setTourOpen] = useState(false); + const [sampleLoad, setSampleLoad] = useState(initialSampleLoadState); + const sampleLoadStartedRef = useRef(false); useEffect(() => { trackDNAChatViewed(); }, []); + useEffect(() => { - if (!hasCompletedTour(dnaChatTour.id)) { - setTourOpen(true); - } - }, []); + if (typeof window === "undefined" || sampleLoadStartedRef.current) return; + + const url = new URL(window.location.href); + const shouldLoadSample = url.searchParams.get("sample") === "1"; + + if (!shouldLoadSample) return; + + sampleLoadStartedRef.current = true; + url.searchParams.delete("sample"); + window.history.replaceState({}, "", url.toString()); + + const loadSampleResults = async () => { + if (savedResults.length > 0) { + setSampleLoad({ + status: "skipped", + downloadedBytes: 0, + totalBytes: 0, + resultCount: savedResults.length, + error: null, + }); + return; + } + + try { + setSampleLoad({ ...initialSampleLoadState, status: "downloading" }); + + const response = await fetch("/api/sample-results", { method: "GET" }); + if (!response.ok) { + throw new Error(`Sample results download failed with status ${response.status}.`); + } + + const totalBytes = Number(response.headers.get("content-length") || "0"); + const decoder = new TextDecoder(); + let content = ""; + let downloadedBytes = 0; + + if (response.body) { + const reader = response.body.getReader(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + + downloadedBytes += value.byteLength; + content += decoder.decode(value, { stream: true }); + setSampleLoad({ + status: "downloading", + downloadedBytes, + totalBytes, + resultCount: 0, + error: null, + }); + } + + content += decoder.decode(); + } else { + content = await response.text(); + downloadedBytes = new Blob([content]).size; + } + + setSampleLoad({ + status: "loading", + downloadedBytes, + totalBytes: totalBytes || downloadedBytes, + resultCount: 0, + error: null, + }); + + const session = ResultsManager.parseResultsFile(content, SAMPLE_RESULTS_FILE_NAME); + if (!session.results.length) { + throw new Error("The sample results file did not contain any usable results."); + } + + await clearResults(); + await addResultsBatch(session.results); + localStorage.setItem("dna_chat_sample_results_loaded", "true"); + + setSampleLoad({ + status: "loaded", + downloadedBytes, + totalBytes: totalBytes || downloadedBytes, + resultCount: session.results.length, + error: null, + }); + } catch (error) { + console.error("[DNA Chat] Sample results load failed:", error); + setSampleLoad({ + status: "error", + downloadedBytes: 0, + totalBytes: 0, + resultCount: 0, + error: error instanceof Error ? error.message : "Sample results could not be loaded.", + }); + } + }; + + void loadSampleResults(); + }, [addResultsBatch, clearResults, savedResults.length]); + + const sampleProgress = sampleLoad.totalBytes > 0 + ? Math.min(100, Math.round((sampleLoad.downloadedBytes / sampleLoad.totalBytes) * 100)) + : 0; return (
- - {null} -
- setTourOpen(true)} /> + {(sampleLoad.status === "downloading" || sampleLoad.status === "loading") && ( +
+
+ {sampleLoad.status === "downloading" ? "Downloading sample results" : "Loading sample results"} + + {sampleLoad.status === "downloading" + ? sampleLoad.totalBytes > 0 + ? `${sampleProgress}% downloaded, ${formatBytes(sampleLoad.downloadedBytes)} of ${formatBytes(sampleLoad.totalBytes)}` + : `${formatBytes(sampleLoad.downloadedBytes)} downloaded` + : "Preparing the sample results for DNA Chat."} + +
+
+ +
+
+ )} + + {sampleLoad.status === "loaded" && ( +
+ )} + + {sampleLoad.status === "skipped" && ( +
+
+ Your results are loaded. + DNA Chat will use the results in this browser session. +
+
+ )} + + {sampleLoad.status === "error" && ( +
+
+ Sample results could not be loaded. + {sampleLoad.error} +
+
+ )} + +
+ + +