diff --git a/app/components/Icons.tsx b/app/components/Icons.tsx
index d7fe1ea..dd90d10 100644
--- a/app/components/Icons.tsx
+++ b/app/components/Icons.tsx
@@ -255,3 +255,461 @@ export function AIIcon({ className = "", size = 16 }: IconProps) {
);
}
+
+export function MicroscopeIcon({ className = "", size = 20 }: IconProps) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function SparklesIcon({ className = "", size = 20 }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function CacheIcon({ className = "", size = 20 }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function HelpCircleIcon({ className = "", size = 20 }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function FolderIcon({ className = "", size = 20 }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function SunIcon({ className = "", size = 20 }: IconProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function MoonIcon({ className = "", size = 20 }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function CrownIcon({ className = "", size = 20 }: IconProps) {
+ return (
+
+ {/* Ornate crown */}
+
+
+ {/* Crown points */}
+
+
+ {/* Decorative gems on crown points */}
+
+
+
+
+ {/* Inner gem details */}
+
+
+
+
+ {/* Ornate band decoration */}
+
+
+ {/* Side decorations */}
+
+
+
+
+ );
+}
+
+// Premium Feature Icons - Ornate and intricate
+// Run All: Machine that processes genes into reports
+export function RunAllIcon({ className = "", size = 24 }: IconProps) {
+ return (
+
+ {/* Machine body - ornate box */}
+
+
+
+ {/* Input funnel (left) for genes */}
+
+
+
+ {/* DNA helix going into machine */}
+
+
+
+
+
+ {/* Output chute (right) for reports */}
+
+
+
+ {/* Report paper coming out */}
+
+
+
+
+ {/* Machine gears and processing */}
+
+
+
+
+
+
+
+
+ {/* Gear teeth */}
+
+
+
+
+
+
+
+
+
+
+ {/* Control panel on top */}
+
+
+
+
+
+
+ {/* Steam/processing indicator */}
+
+
+
+
+ );
+}
+
+// LLM Chat: Witch using a typewriter
+export function LLMChatIcon({ className = "", size = 24 }: IconProps) {
+ return (
+
+ {/* Witch hat - pointed with ornate brim */}
+
+
+
+
+ {/* Witch head/face */}
+
+
+
+ {/* Witch hair flowing */}
+
+
+
+ {/* Witch body/shoulders */}
+
+
+
+
+ {/* Arms reaching to typewriter */}
+
+
+
+ {/* Typewriter body - ornate vintage design */}
+
+
+
+ {/* Typewriter keys */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Paper coming out of typewriter */}
+
+
+
+
+ {/* Magic sparkles around */}
+
+
+
+
+ );
+}
+
+// Overview Report: Lab tech looking into microscope
+export function OverviewReportIcon({ className = "", size = 24 }: IconProps) {
+ return (
+
+ {/* Lab tech head */}
+
+
+
+ {/* Lab coat collar */}
+
+
+
+
+ {/* Body/shoulders hunched over microscope */}
+
+
+
+
+ {/* Arms/hands on microscope */}
+
+
+
+ {/* Microscope base/platform */}
+
+
+
+ {/* Microscope body - ornate details */}
+
+
+
+ {/* Microscope eyepiece where tech is looking */}
+
+
+
+ {/* Microscope objective lens (bottom) */}
+
+
+
+ {/* Focus knobs */}
+
+
+
+ {/* Slide on stage */}
+
+
+ {/* Data/results visualization on side (screen/readout) */}
+
+
+
+ {/* Graph/data lines on screen */}
+
+
+
+
+
+
+
+ {/* DNA helix on screen */}
+
+
+
+
+ {/* Lab environment details */}
+
+
+
+ {/* Analysis sparkle */}
+
+
+ );
+}
+
+// Robot AI icon for LLM Chat header
+export function RobotIcon({ className = "", size = 24 }: IconProps) {
+ return (
+
+ {/* Antenna with ornate details */}
+
+
+
+
+ {/* Head - ornate box with multiple layers */}
+
+
+
+
+ {/* Eyes - glowing ornate circles */}
+
+
+
+
+
+
+
+ {/* Mouth/display panel */}
+
+
+
+
+ {/* Body - ornate rectangle */}
+
+
+
+ {/* Chest panel with circuit pattern */}
+
+
+
+
+
+
+
+ {/* Arms - ornate mechanical limbs */}
+
+
+
+
+
+ {/* Hands/claws */}
+
+
+
+ {/* Legs - mechanical supports */}
+
+
+
+
+
+ {/* Feet */}
+
+
+
+ {/* Decorative screws/bolts */}
+
+
+
+
+
+ {/* Energy/AI sparkles */}
+
+
+
+ );
+}
+
+export function UserIcon({ className = "", size = 20 }: IconProps) {
+ return (
+
+ {/* Ornate decorative outer circle */}
+
+
+
+ {/* User head with ornate details */}
+
+
+
+ {/* Ornate body/shoulders */}
+
+
+
+ {/* Decorative corner accents */}
+
+
+
+
+
+ );
+}
diff --git a/app/components/LLMChatInline.tsx b/app/components/LLMChatInline.tsx
index d2f453a..1974615 100644
--- a/app/components/LLMChatInline.tsx
+++ b/app/components/LLMChatInline.tsx
@@ -9,6 +9,7 @@ import { useAuth } from "./AuthProvider";
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { callLLM, getLLMDescription } from "@/lib/llm-client";
+import { RobotIcon } from "./Icons";
type Message = {
role: 'user' | 'assistant';
@@ -144,6 +145,25 @@ export default function AIChatInline() {
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) {
setShowConsentModal(true);
@@ -595,9 +615,6 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug
);
}
- // Show subscription required overlay if not subscribed and no promo access
- const isBlocked = !hasActiveSubscription && !hasPromoAccess;
-
return (
<>
{showConsentModal && (
@@ -608,38 +625,10 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug
/>
)}
- {isBlocked && (
-
-
-
🔒 Premium Feature
-
- LLM Chat requires an active premium subscription.
-
-
- Subscribe for $4.99/month to unlock LLM-powered analysis of your genetic results.
-
-
-
- )}
-
🤖 LLM Chat: Your Genetic Results
+
+ LLM Chat: Your Genetic Results
+
{getLLMDescription()} - Your data is processed securely
@@ -813,9 +802,10 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug
- {isLoading ? '⏳' : '➤'} Send
+ {isLoading ? '⏳' : (!hasActiveSubscription && !hasPromoAccess) ? '🔒 Login/Subscribe' : '➤ Send'}
diff --git a/app/components/MenuBar.tsx b/app/components/MenuBar.tsx
index 152a45a..71c6076 100644
--- a/app/components/MenuBar.tsx
+++ b/app/components/MenuBar.tsx
@@ -6,37 +6,24 @@ import { useResults } from "./ResultsContext";
import { useCustomization } from "./CustomizationContext";
import CustomizationModal from "./CustomizationModal";
import LLMConfigModal from "./LLMConfigModal";
-import { FileIcon, SaveIcon, TrashIcon, MessageIcon, ClockIcon } from "./Icons";
-import { AuthButton, useAuth } from "./AuthProvider";
+import { MyDataDropdown, ResultsDropdown, CacheDropdown, HelpDropdown } from "./MenuDropdowns";
+import { DNAIcon, FolderIcon, MicroscopeIcon, SparklesIcon, CacheIcon, HelpCircleIcon, SunIcon, MoonIcon } from "./Icons";
import { getLLMConfig, getProviderDisplayName } from "@/lib/llm-config";
export default function MenuBar() {
const { isUploaded, genotypeData, fileHash } = useGenotype();
const { savedResults, saveToFile, loadFromFile, clearResults } = useResults();
const { status: customizationStatus } = useCustomization();
- const { isAuthenticated, hasActiveSubscription, subscriptionData, user } = useAuth();
const [isLoadingFile, setIsLoadingFile] = useState(false);
const [showCustomizationModal, setShowCustomizationModal] = useState(false);
const [showLLMConfigModal, setShowLLMConfigModal] = useState(false);
+ const [showMyDataDropdown, setShowMyDataDropdown] = useState(false);
+ const [showResultsDropdown, setShowResultsDropdown] = useState(false);
+ const [showCacheDropdown, setShowCacheDropdown] = useState(false);
+ const [showHelpDropdown, setShowHelpDropdown] = useState(false);
const [theme, setTheme] = useState<"light" | "dark">("dark");
const [cacheInfo, setCacheInfo] = useState<{ studies: number; sizeMB: number } | null>(null);
const [llmProvider, setLlmProvider] = useState('');
- const [showSubscriptionMenu, setShowSubscriptionMenu] = useState(false);
-
- useEffect(() => {
- // Close subscription menu when clicking outside
- const handleClickOutside = (event: MouseEvent) => {
- const target = event.target as HTMLElement;
- if (!target.closest('.subscription-indicator')) {
- setShowSubscriptionMenu(false);
- }
- };
-
- if (showSubscriptionMenu) {
- document.addEventListener('click', handleClickOutside);
- return () => document.removeEventListener('click', handleClickOutside);
- }
- }, [showSubscriptionMenu]);
useEffect(() => {
// Detect system preference on mount
@@ -90,17 +77,6 @@ export default function MenuBar() {
}
};
- const getCustomizationIcon = () => {
- switch (customizationStatus) {
- case 'not-set':
- return '⚙️';
- case 'locked':
- return '🔒';
- case 'unlocked':
- return '🔓';
- }
- };
-
const getCustomizationTooltip = () => {
switch (customizationStatus) {
case 'not-set':
@@ -112,10 +88,24 @@ export default function MenuBar() {
}
};
- const handleLLMConfigSave = () => {
- // Reload LLM provider display name
- const config = getLLMConfig();
- setLlmProvider(getProviderDisplayName(config.provider));
+ const handleClearCache = async () => {
+ if (!cacheInfo) return;
+
+ const confirmed = window.confirm(
+ `Clear cached GWAS Catalog data?\n\n` +
+ `${cacheInfo.studies.toLocaleString()} studies (${cacheInfo.sizeMB} MB)\n\n` +
+ `Data will be re-downloaded on next Run All.`
+ );
+ if (confirmed) {
+ try {
+ const { gwasDB } = await import('@/lib/gwas-db');
+ await gwasDB.clearDatabase();
+ setCacheInfo(null);
+ alert('✓ Cache cleared successfully!');
+ } catch {
+ alert('Failed to clear cache. Please try again.');
+ }
+ }
};
return (
@@ -126,8 +116,39 @@ export default function MenuBar() {
/>
setShowLLMConfigModal(false)}
- onSave={handleLLMConfigSave}
+ onClose={() => {
+ setShowLLMConfigModal(false);
+ // Refresh LLM provider after closing modal
+ const config = getLLMConfig();
+ setLlmProvider(getProviderDisplayName(config.provider));
+ }}
+ onSave={() => {}}
+ />
+ setShowMyDataDropdown(false)}
+ isUploaded={isUploaded}
+ genotypeData={genotypeData}
+ UserDataUploadComponent={UserDataUpload}
+ />
+ setShowResultsDropdown(false)}
+ savedResults={savedResults}
+ onLoadFromFile={handleLoadFromFile}
+ onSaveToFile={() => saveToFile(genotypeData?.size, fileHash || undefined)}
+ onClearResults={clearResults}
+ isLoadingFile={isLoadingFile}
+ />
+ setShowCacheDropdown(false)}
+ cacheInfo={cacheInfo}
+ onClearCache={handleClearCache}
+ />
+ setShowHelpDropdown(false)}
/>
@@ -146,214 +167,98 @@ export default function MenuBar() {
-
- {isUploaded && genotypeData && (
-
- {genotypeData.size.toLocaleString()} variants loaded
+ {/* Icon-based Navigation */}
+
+ setShowMyDataDropdown(!showMyDataDropdown)}
+ title="Upload and manage your genetic data"
+ >
+
+
- )}
-
-
+ My Data
+ {isUploaded && genotypeData && (
+ {genotypeData.size.toLocaleString()}
+ )}
+
-
-
- {savedResults.length > 0 && (
-
- {savedResults.length} result{savedResults.length !== 1 ? 's' : ''} cached
+ setShowResultsDropdown(!showResultsDropdown)}
+ title="Load, export, and manage results"
+ >
+
+
- )}
-
-
- {isLoadingFile ? (
- <>
- Loading...
- >
- ) : (
- <>
- Load
- >
- )}
-
+ Results
{savedResults.length > 0 && (
- <>
- saveToFile(genotypeData?.size, fileHash || undefined)}
- title="Export your results to a TSV file"
- >
- Export
-
-
- Clear
-
- >
+ {savedResults.length}
)}
-
-
-
-
+
-
setShowCustomizationModal(true)}
title={getCustomizationTooltip()}
>
- {getCustomizationIcon()} Personalize
+
+
+
+ Personalize
setShowLLMConfigModal(true)}
title="Configure LLM provider and model"
>
- 🤖 LLM: {llmProvider || 'Loading...'}
+
+
+
+ LLM
+ {llmProvider || 'OpenAI'}
-
-
-
-
-
- {cacheInfo && (
- <>
-
- {cacheInfo.studies.toLocaleString()} studies cached ({cacheInfo.sizeMB} MB)
-
-
{
- const confirmed = window.confirm(
- `Clear cached GWAS Catalog data?\n\n` +
- `${cacheInfo.studies.toLocaleString()} studies (${cacheInfo.sizeMB} MB)\n\n` +
- `Data will be re-downloaded on next Run All.`
- );
- if (confirmed) {
- try {
- // Show loading state
- const button = document.activeElement as HTMLButtonElement;
- const originalText = button?.innerHTML;
- if (button) {
- button.disabled = true;
- button.innerHTML = '
Clearing...';
- }
- const { gwasDB } = await import('@/lib/gwas-db');
- await gwasDB.clearDatabase();
- setCacheInfo(null);
-
- // Restore button and show success
- if (button && originalText) {
- button.disabled = false;
- button.innerHTML = originalText;
- }
- alert('✓ Cache cleared successfully!');
- } catch (error) {
- console.error('Failed to clear cache:', error);
- alert('Failed to clear cache. Please try again.');
- }
- }
- }}
- title="Clear locally cached GWAS catalog data"
- >
- Clear Cache
-
- >
- )}
+
setShowCacheDropdown(!showCacheDropdown)}
+ title="View and manage cached GWAS data"
+ >
+
+
+
+ Cache
+ {cacheInfo && (
+ {cacheInfo.studies.toLocaleString()}
+ )}
+
- Feedback
+
+
+
+ Help
- {theme === "dark" ? "☀️" : "🌙"}
+
+ {theme === "dark" ? : }
+
+ Theme
-
-
-
- {isAuthenticated && hasActiveSubscription && subscriptionData && (
- <>
-
-
-
setShowSubscriptionMenu(!showSubscriptionMenu)}
- >
- ✨ Premium ({subscriptionData.daysRemaining}d)
-
- {showSubscriptionMenu && (
-
-
-
Premium Subscription
-
Expires: {subscriptionData.expiresAt ? new Date(subscriptionData.expiresAt).toLocaleDateString() : 'N/A'}
-
Days remaining: {subscriptionData.daysRemaining}
-
-
{
- if (confirm('Are you sure you want to cancel your subscription? You will retain access until the end of your current billing period.')) {
- try {
- const walletAddress = user?.verifiedCredentials?.[0]?.address;
- if (!walletAddress) {
- alert('Could not find wallet address');
- return;
- }
- const response = await fetch('/api/stripe/cancel-subscription', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ walletAddress }),
- });
- const result = await response.json();
- if (result.success) {
- alert('Subscription cancelled successfully. You will retain access until ' + new Date(subscriptionData.expiresAt!).toLocaleDateString());
- window.location.reload();
- } else {
- alert('Failed to cancel subscription: ' + (result.error || 'Unknown error'));
- }
- } catch (error) {
- alert('Failed to cancel subscription. Please try again.');
- }
- }
- }}
- title="Cancel Stripe subscription (only available for card payments)"
- >
- Cancel Subscription
-
-
- )}
-
-
-
- >
- )}
-
-
>
diff --git a/app/components/MenuDropdowns.tsx b/app/components/MenuDropdowns.tsx
new file mode 100644
index 0000000..c3e7420
--- /dev/null
+++ b/app/components/MenuDropdowns.tsx
@@ -0,0 +1,251 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { FileIcon, SaveIcon, TrashIcon, MessageIcon, ClockIcon } from "./Icons";
+
+// My Data Dropdown
+export function MyDataDropdown({
+ isOpen,
+ onClose,
+ isUploaded,
+ genotypeData,
+ UserDataUploadComponent,
+}: {
+ isOpen: boolean;
+ onClose: () => void;
+ isUploaded: boolean;
+ genotypeData: { size: number } | null;
+ UserDataUploadComponent: React.ComponentType;
+}) {
+ const dropdownRef = useRef
(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ onClose();
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }
+ }, [isOpen, onClose]);
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
My Data
+ {isUploaded && genotypeData && (
+
{genotypeData.size.toLocaleString()} variants loaded
+ )}
+
+
+
+ );
+}
+
+// Results Dropdown
+export function ResultsDropdown({
+ isOpen,
+ onClose,
+ savedResults,
+ onLoadFromFile,
+ onSaveToFile,
+ onClearResults,
+ isLoadingFile,
+}: {
+ isOpen: boolean;
+ onClose: () => void;
+ savedResults: unknown[];
+ onLoadFromFile: () => void;
+ onSaveToFile: () => void;
+ onClearResults: () => void;
+ isLoadingFile: boolean;
+}) {
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ onClose();
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }
+ }, [isOpen, onClose]);
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
Results
+ {savedResults.length > 0 && (
+
+ {savedResults.length} result{savedResults.length !== 1 ? "s" : ""} cached
+
+ )}
+
+ {
+ onLoadFromFile();
+ onClose();
+ }}
+ disabled={isLoadingFile}
+ title="Load results from a file"
+ >
+ {isLoadingFile ? (
+ <>
+ Loading...
+ >
+ ) : (
+ <>
+ Load
+ >
+ )}
+
+ {savedResults.length > 0 && (
+ <>
+ {
+ onSaveToFile();
+ onClose();
+ }}
+ title="Export your results to a TSV file"
+ >
+ Export
+
+ {
+ onClearResults();
+ onClose();
+ }}
+ title="Clear all saved results"
+ >
+ Clear
+
+ >
+ )}
+
+
+
+ );
+}
+
+// Cache Dropdown
+export function CacheDropdown({
+ isOpen,
+ onClose,
+ cacheInfo,
+ onClearCache,
+}: {
+ isOpen: boolean;
+ onClose: () => void;
+ cacheInfo: { studies: number; sizeMB: number } | null;
+ onClearCache: () => void;
+}) {
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ onClose();
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }
+ }, [isOpen, onClose]);
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
Cache
+ {cacheInfo ? (
+ <>
+
+ {cacheInfo.studies.toLocaleString()} studies cached ({cacheInfo.sizeMB} MB)
+
+
+ {
+ onClearCache();
+ onClose();
+ }}
+ title="Clear locally cached GWAS catalog data"
+ >
+ Clear Cache
+
+
+ >
+ ) : (
+
No cached data
+ )}
+
+
+ );
+}
+
+// Help Dropdown
+export function HelpDropdown({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ onClose();
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }
+ }, [isOpen, onClose]);
+
+ if (!isOpen) return null;
+
+ return (
+
+ );
+}
diff --git a/app/components/PremiumPaywall.tsx b/app/components/PremiumPaywall.tsx
index 82c8ffd..f8989c9 100644
--- a/app/components/PremiumPaywall.tsx
+++ b/app/components/PremiumPaywall.tsx
@@ -42,6 +42,15 @@ export function PremiumPaywall({ children }: PremiumPaywallProps) {
}
}, []);
+ // Listen for payment modal trigger
+ useEffect(() => {
+ const handleOpenPaymentModal = () => {
+ setShowPaymentModal(true);
+ };
+ window.addEventListener('openPaymentModal', handleOpenPaymentModal);
+ return () => window.removeEventListener('openPaymentModal', handleOpenPaymentModal);
+ }, []);
+
const handleRemovePromoCode = () => {
localStorage.removeItem('promo_access');
setHasPromoAccess(false);
@@ -64,7 +73,7 @@ export function PremiumPaywall({ children }: PremiumPaywallProps) {
setTimeout(() => refreshSubscription(), 5000);
};
- // Always show content, with subscription banner if needed
+ // Always show content
return (
<>
setShowPaymentModal(false)}
onSuccess={handleModalSuccess}
/>
-
- {!hasActiveSubscription && !hasPromoAccess && (
-
-
- Premium subscription required - Subscribe for $4.99/month to access LLM Chat, Run All Analysis, and more.
-
- setShowPaymentModal(true)}
- style={{
- padding: '0.5rem 1rem',
- backgroundColor: '#f59e0b',
- color: 'white',
- border: 'none',
- borderRadius: '6px',
- cursor: 'pointer',
- fontWeight: '600',
- fontSize: '0.875rem',
- whiteSpace: 'nowrap'
- }}
- >
- Subscribe
-
-
- )}
-
- {hasPromoAccess && (
-
- ✓ Premium access active (promo code: {promoCode})
-
- Remove
-
-
- )}
-
{children}
>
);
diff --git a/app/globals.css b/app/globals.css
index b146769..9d81dc4 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -132,6 +132,229 @@ body {
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2);
}
+/* Icon-based Menu Navigation */
+.menu-icons {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.menu-icon-button {
+ background: var(--surface-bg);
+ border: 2px solid var(--border-color);
+ border-radius: 12px;
+ padding: 0.75rem 1rem;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 85px;
+ position: relative;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ /* Tarot card aesthetic - decorative double border */
+ outline: 1px solid transparent;
+ outline-offset: -4px;
+}
+
+.menu-icon-button::before {
+ content: '';
+ position: absolute;
+ inset: 3px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ pointer-events: none;
+ opacity: 0.3;
+}
+
+.menu-icon-button .icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+ color: var(--text-secondary);
+ transition: all 0.3s ease;
+ padding: 0.25rem;
+}
+
+.menu-icon-button .icon svg {
+ display: block;
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
+}
+
+.menu-icon-button .label {
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ line-height: 1;
+ transition: all 0.3s ease;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ font-family: 'Inter', sans-serif;
+}
+
+.menu-icon-button .badge {
+ position: absolute;
+ top: -0.25rem;
+ right: -0.25rem;
+ background: var(--accent-blue);
+ color: white;
+ font-size: 0.65rem;
+ font-weight: 700;
+ padding: 0.15rem 0.4rem;
+ border-radius: 12px;
+ min-width: 20px;
+ text-align: center;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+ border: 2px solid var(--primary-bg);
+}
+
+.menu-icon-button:hover {
+ background: rgba(59, 130, 246, 0.08);
+ border-color: var(--accent-blue);
+ transform: translateY(-3px);
+ box-shadow: 0 4px 16px rgba(59, 130, 246, 0.2);
+}
+
+.menu-icon-button:hover::before {
+ border-color: var(--accent-blue);
+ opacity: 0.5;
+}
+
+.menu-icon-button:hover .label {
+ color: var(--text-primary);
+}
+
+.menu-icon-button:hover .icon {
+ color: var(--accent-blue);
+ transform: scale(1.05);
+}
+
+.menu-icon-button.not-set {
+ border-color: rgba(245, 158, 11, 0.4);
+}
+
+.menu-icon-button.not-set::before {
+ border-color: rgba(245, 158, 11, 0.3);
+}
+
+.menu-icon-button.locked {
+ border-color: rgba(239, 68, 68, 0.4);
+}
+
+.menu-icon-button.locked::before {
+ border-color: rgba(239, 68, 68, 0.3);
+}
+
+.menu-icon-button.unlocked {
+ border-color: rgba(16, 185, 129, 0.4);
+}
+
+.menu-icon-button.unlocked::before {
+ border-color: rgba(16, 185, 129, 0.3);
+}
+
+.menu-icon-button.subscribed {
+ border-color: rgba(16, 185, 129, 0.4);
+}
+
+.menu-icon-button.subscribed::before {
+ border-color: rgba(16, 185, 129, 0.3);
+}
+
+.menu-icon-button.not-subscribed {
+ border-color: rgba(239, 68, 68, 0.4);
+}
+
+.menu-icon-button.not-subscribed::before {
+ border-color: rgba(239, 68, 68, 0.3);
+}
+
+.menu-icon-button.authenticated {
+ border-color: rgba(59, 130, 246, 0.4);
+}
+
+.menu-icon-button.authenticated::before {
+ border-color: rgba(59, 130, 246, 0.3);
+}
+
+.menu-icon-button.not-authenticated {
+ border-color: rgba(156, 163, 175, 0.4);
+}
+
+.menu-icon-button.not-authenticated::before {
+ border-color: rgba(156, 163, 175, 0.3);
+}
+
+/* Auth icon wrapper - allow click-through to DynamicWidget */
+.auth-icon-wrapper {
+ position: relative;
+ pointer-events: none;
+}
+
+.auth-icon-wrapper > * {
+ pointer-events: auto;
+}
+
+/* Menu Dropdowns */
+.menu-dropdown {
+ position: fixed;
+ top: 5rem;
+ right: 2rem;
+ background: var(--secondary-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ padding: 1.5rem;
+ min-width: 300px;
+ max-width: 400px;
+ backdrop-filter: blur(20px);
+ box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.3);
+ z-index: 1000;
+ animation: slideDown 0.2s ease-out;
+}
+
+@keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.dropdown-content h3 {
+ margin: 0 0 1rem 0;
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.dropdown-content .stat-display {
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ margin-bottom: 1rem;
+ padding: 0.5rem;
+ background: rgba(59, 130, 246, 0.05);
+ border: 1px solid rgba(59, 130, 246, 0.15);
+ border-radius: 6px;
+ text-align: center;
+}
+
+.dropdown-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.dropdown-actions .control-button,
+.dropdown-actions a {
+ width: 100%;
+ justify-content: center;
+}
+
.app-title {
margin: 0;
font-size: 1.5rem;
@@ -3661,6 +3884,194 @@ details[open] .summary-arrow {
}
/* Premium Section */
+.premium-compact-header {
+ padding: 0.75rem 3rem 0.5rem 3rem;
+ max-width: 1400px;
+ margin: 0 auto;
+ width: 100%;
+}
+
+.premium-header-content {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ background: var(--surface-bg);
+ border: 2px solid var(--border-color);
+ border-radius: 8px;
+ padding: 0.75rem 1rem;
+}
+
+.premium-wallet-section {
+ flex-shrink: 0;
+}
+
+.auth-prompt-inline,
+.subscription-prompt-inline,
+.subscription-active-inline {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ font-size: 0.9rem;
+}
+
+.auth-prompt-inline {
+ color: var(--text-secondary);
+}
+
+.subscription-prompt-inline {
+ justify-content: space-between;
+}
+
+.subscription-active-inline {
+ color: var(--text-primary);
+}
+
+.subscription-message {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.subscription-message strong {
+ color: var(--text-primary);
+}
+
+.subscription-message span {
+ color: var(--text-secondary);
+ font-size: 0.85rem;
+}
+
+.subscribe-button {
+ padding: 0.5rem 1rem;
+ background: #f59e0b;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 0.875rem;
+ white-space: nowrap;
+ transition: background 0.2s;
+}
+
+.subscribe-button:hover {
+ background: #d97706;
+}
+
+.subscription-menu-container {
+ position: relative;
+}
+
+.subscription-menu-button {
+ padding: 0.25rem;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ color: var(--text-secondary);
+ opacity: 0.6;
+ transition: opacity 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.subscription-menu-button:hover {
+ opacity: 1;
+}
+
+.subscription-menu-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 999;
+}
+
+.subscription-menu-dropdown {
+ position: absolute;
+ top: calc(100% + 0.5rem);
+ right: 0;
+ background: var(--surface-bg);
+ border: 2px solid var(--border-color);
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ min-width: 220px;
+ z-index: 1000;
+ overflow: hidden;
+}
+
+.subscription-menu-header {
+ padding: 0.75rem 1rem;
+}
+
+.subscription-menu-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.subscription-menu-info strong {
+ font-size: 0.875rem;
+ color: var(--text-primary);
+}
+
+.subscription-menu-info span {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+.subscription-menu-info .expires-date {
+ font-size: 0.75rem;
+ opacity: 0.8;
+}
+
+.subscription-menu-divider {
+ height: 1px;
+ background: var(--border-color);
+ margin: 0;
+}
+
+.subscription-menu-item {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ background: transparent;
+ border: none;
+ text-align: left;
+ cursor: pointer;
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ transition: background 0.2s;
+ display: block;
+}
+
+.subscription-menu-item:hover {
+ background: rgba(128, 128, 128, 0.1);
+}
+
+.subscription-menu-item.cancel {
+ color: var(--text-secondary);
+}
+
+.subscription-menu-item.cancel:hover {
+ background: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+}
+
+@media (max-width: 900px) {
+ .premium-compact-header {
+ padding: 1rem 1.5rem;
+ }
+
+ .premium-header-content {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .subscription-prompt-inline {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
+
.premium-section {
flex: 1;
display: flex;
@@ -3681,20 +4092,22 @@ details[open] .summary-arrow {
}
.premium-features-header .collapse-button {
- background: var(--surface-bg);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- padding: 0.5rem 1rem;
- font-size: 0.875rem;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-secondary);
- font-weight: 500;
+ font-weight: 400;
+ opacity: 0.6;
}
.premium-features-header .collapse-button:hover {
- background: var(--border-color);
+ opacity: 1;
color: var(--text-primary);
+ background: rgba(128, 128, 128, 0.1);
}
/* Premium Features Overview - Compact 3-column cards */
@@ -3747,15 +4160,20 @@ details[open] .summary-arrow {
}
.feature-icon {
- width: 2rem;
- height: 2rem;
- color: var(--accent-blue);
- stroke-width: 2;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-primary);
+ opacity: 0.85;
+}
+
+.feature-icon svg {
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
}
.feature-overview-card.disabled .feature-icon {
color: var(--text-secondary);
- opacity: 0.6;
+ opacity: 0.4;
}
.feature-overview-card h3 {
diff --git a/app/page.tsx b/app/page.tsx
index 8f3dc29..13f774f 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState, useCallback, useRef } from "react";
import { GenotypeProvider, useGenotype } from "./components/UserDataUpload";
import { ResultsProvider, useResults } from "./components/ResultsContext";
import { CustomizationProvider } from "./components/CustomizationContext";
+import { AuthButton, useAuth } from "./components/AuthProvider";
+import { RunAllIcon, LLMChatIcon, OverviewReportIcon } from "./components/Icons";
import StudyResultReveal from "./components/StudyResultReveal";
import MenuBar from "./components/MenuBar";
import VariantChips from "./components/VariantChips";
@@ -203,6 +205,7 @@ function MainContent() {
const { genotypeData, isUploaded, setOnDataLoadedCallback } = useGenotype();
const { setOnResultsLoadedCallback, addResult, addResultsBatch, hasResult } = useResults();
const resultsContext = useResults();
+ const { isAuthenticated, hasActiveSubscription, subscriptionData, checkingSubscription, user } = useAuth();
// Track client-side mounting to prevent hydration errors
const [mounted, setMounted] = useState(false);
@@ -253,6 +256,7 @@ function MainContent() {
const [runAllProgress, setRunAllProgress] = useState({ current: 0, total: 0 });
const [showRunAllModal, setShowRunAllModal] = useState(false);
const [showOverviewReportModal, setShowOverviewReportModal] = useState(false);
+ const [showSubscriptionMenu, setShowSubscriptionMenu] = useState(false);
const [showRunAllDisclaimer, setShowRunAllDisclaimer] = useState(false);
const [runAllStatus, setRunAllStatus] = useState<{
phase: 'fetching' | 'downloading' | 'decompressing' | 'parsing' | 'storing' | 'analyzing' | 'embeddings' | 'complete' | 'error';
@@ -1086,7 +1090,111 @@ function MainContent() {
>
) : (
/* Premium Tab - 3 Features with LLM Chat Primary */
-
+ <>
+ {/* Account & Subscription Compact Header */}
+
+
+ {!isAuthenticated ? (
+
+ Sign in to access premium features →
+
+ ) : !hasActiveSubscription ? (
+
+
+ Premium subscription required
+ Subscribe for $4.99/month to access Run All Analysis, LLM Chat, and Overview Report.
+
+
{
+ const event = new CustomEvent('openPaymentModal');
+ window.dispatchEvent(event);
+ }}
+ className="subscribe-button"
+ >
+ Subscribe
+
+
+ ) : subscriptionData ? (
+
+ ✓ Premium Active
+
+ ) : null}
+
+ {subscriptionData && (
+
+
setShowSubscriptionMenu(!showSubscriptionMenu)}
+ className="subscription-menu-button"
+ title="Subscription options"
+ >
+
+
+
+
+
+
+ {showSubscriptionMenu && (
+ <>
+
setShowSubscriptionMenu(false)}
+ />
+
+
+
+ Subscription Details
+ {subscriptionData.daysRemaining > 0 && (
+ {subscriptionData.daysRemaining} days remaining in current cycle
+ )}
+ {subscriptionData.expiresAt && (
+ Renews {new Date(subscriptionData.expiresAt).toLocaleDateString()}
+ )}
+
+
+
+
{
+ setShowSubscriptionMenu(false);
+ if (!confirm('Are you sure you want to cancel your subscription? It will remain active until the end of your billing period.')) {
+ return;
+ }
+ try {
+ const walletAddress = user?.verifiedCredentials?.[0]?.address;
+ if (!walletAddress) {
+ alert('Could not find wallet address');
+ return;
+ }
+ const response = await fetch('/api/stripe/cancel-subscription', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ walletAddress }),
+ });
+ const data = await response.json();
+ if (response.ok) {
+ alert(data.message || 'Subscription cancelled successfully');
+ window.location.reload();
+ } else {
+ alert(data.error || 'Failed to cancel subscription');
+ }
+ } catch (error) {
+ alert('Error cancelling subscription');
+ console.error(error);
+ }
+ }}
+ className="subscription-menu-item cancel"
+ >
+ Cancel Subscription
+
+
+ >
+ )}
+
+ )}
+
+
+
{null}
{/* Feature Overview Cards - Compact 3-column with collapse button */}
@@ -1103,23 +1211,30 @@ function MainContent() {
{/* Run All Card - First */}
-
-
-
+
+
+
Run All
-
- {!mounted ? 'Loading...' :
- !isUploaded ? 'Upload DNA data first' :
- resultsContext.savedResults.length > 0
- ? `${resultsContext.savedResults.length.toLocaleString()} traits analyzed`
- : 'Analyze all GWAS studies'}
-
+
Run your data through all million+ traits
{
+ if (!isAuthenticated) {
+ // Trigger Dynamic widget to open
+ const dynamicButton = document.querySelector('[data-dynamic-widget-button]') as HTMLElement;
+ if (dynamicButton) dynamicButton.click();
+ } else if (!hasActiveSubscription) {
+ const event = new CustomEvent('openPaymentModal');
+ window.dispatchEvent(event);
+ } else {
+ handleRunAll();
+ }
+ }}
disabled={isRunningAll || !mounted || !isUploaded}
>
{isRunningAll ? 'Running...' :
+ !isAuthenticated ? 'Login' :
+ !hasActiveSubscription ? 'Subscribe' :
!isUploaded ? 'Upload DNA File' :
resultsContext.savedResults.length > 0 ? 'Run Again' : 'Start'}
@@ -1127,38 +1242,39 @@ function MainContent() {
{/* LLM Chat Card - Primary */}
-
-
-
-
-
-
+
+
+
LLM Chat
Ask a private LLM questions about your genetic data
{/* Overview Report Card */}
-
-
-
-
-
-
-
+
+
+
Overview Report (Experimental)
-
- {!mounted ? 'Loading...' :
- resultsContext.savedResults.length < 1000
- ? 'Analyze 1,000+ traits first'
- : 'Generate comprehensive LLM report'}
-
+
Have an LLM analyze all your traits
setShowOverviewReportModal(true)}
- disabled={!mounted || resultsContext.savedResults.length < 1000}
+ onClick={() => {
+ if (!isAuthenticated) {
+ // Trigger Dynamic widget to open
+ const dynamicButton = document.querySelector('[data-dynamic-widget-button]') as HTMLElement;
+ if (dynamicButton) dynamicButton.click();
+ } else if (!hasActiveSubscription) {
+ const event = new CustomEvent('openPaymentModal');
+ window.dispatchEvent(event);
+ } else {
+ setShowOverviewReportModal(true);
+ }
+ }}
+ disabled={!mounted || (!isAuthenticated || !hasActiveSubscription ? false : resultsContext.savedResults.length < 1000)}
>
- {resultsContext.savedResults.length < 1000 ? 'Run Analysis First' : 'Generate Report'}
+ {!isAuthenticated ? 'Login' :
+ !hasActiveSubscription ? 'Subscribe' :
+ resultsContext.savedResults.length < 1000 ? 'Run Analysis First' : 'Generate Report'}
@@ -1170,7 +1286,7 @@ function MainContent() {
{/* LLM Chat - Full Interface */}
-
+ >
)}
diff --git a/lib/subscription-indexer.ts b/lib/subscription-indexer.ts
index e3a2e5a..fe4a17f 100644
--- a/lib/subscription-indexer.ts
+++ b/lib/subscription-indexer.ts
@@ -5,13 +5,16 @@
*/
// Fix Next.js + Alchemy SDK compatibility by polyfilling global fetch
-// This intercepts all fetch calls (including nested ones in ethers.js) to remove invalid referrer
+// This intercepts all fetch calls (including nested ones in ethers.js) to handle invalid referrer
+// The Alchemy SDK (via ethers.js) passes referrer: "client" which is valid in browsers
+// but throws "Referrer 'client' is not a valid URL" in Node.js
const originalFetch = global.fetch;
global.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise
=> {
const options = { ...init };
- // Remove referrer property that causes "Referrer 'client' is not a valid URL" error in Node.js
- if (options && 'referrer' in options) {
- delete options.referrer;
+ // Set referrer to empty string to avoid Node.js validation errors
+ // Empty string is valid in Node.js fetch, while "client" is not
+ if (options) {
+ options.referrer = '';
}
return originalFetch(input, options);
};