diff --git a/app/api/analyze-study/route.ts b/app/api/analyze-study/route.ts deleted file mode 100644 index ba717a0..0000000 --- a/app/api/analyze-study/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { executeQuerySingle } from "@/lib/db"; -import { validateOrigin } from "@/lib/origin-validator"; - -// This endpoint only returns study metadata - NO user genetic data is processed here -export async function POST(request: NextRequest) { - // Validate origin - const originError = validateOrigin(request); - if (originError) return originError; - - try { - const { studyId } = await request.json(); - - if (!studyId) { - return NextResponse.json({ - success: false, - error: 'Missing study ID' - }, { status: 400 }); - } - - // Get study metadata from database (contains no user data) - const query = ` - SELECT - snps, - strongest_snp_risk_allele, - or_or_beta, - ci_text, - study_accession, - disease_trait - FROM gwas_catalog - WHERE id = ? - AND snps IS NOT NULL AND snps != '' - AND strongest_snp_risk_allele IS NOT NULL AND strongest_snp_risk_allele != '' - AND or_or_beta IS NOT NULL AND or_or_beta != '' - `; - - const study = await executeQuerySingle<{ - snps: string | null; - strongest_snp_risk_allele: string | null; - or_or_beta: string | null; - ci_text: string | null; - study_accession: string | null; - disease_trait: string | null; - }>(query, [studyId]); - - if (!study) { - return NextResponse.json({ - success: false, - error: 'Study not found or missing required data' - }, { status: 404 }); - } - - // Determine effect type from ci_text - // Beta coefficients have "increase" or "decrease" in CI text - // e.g., "[NR] unit increase", "[0.0068-0.0139] unit increase", "[112.27-112.33] increase" - // Odds ratios are just numbers: e.g., "[1.08-1.15]" - const ciTextLower = study.ci_text?.toLowerCase() ?? ''; - const hasIncrease = ciTextLower.includes('increase'); - const hasDecrease = ciTextLower.includes('decrease'); - const isBeta = hasIncrease || hasDecrease; - const effectType = isBeta ? 'beta' : 'OR'; - - // CRITICAL FIX: GWAS Catalog stores ALL beta values as positive numbers - // Direction is encoded in ci_text ("increase" vs "decrease") - // We must negate the value for "decrease" studies before sending to client - let effectSize = study.or_or_beta; - if (isBeta && hasDecrease && !hasIncrease) { - const numericValue = parseFloat(study.or_or_beta || '0'); - effectSize = (-Math.abs(numericValue)).toString(); - } - - // Return only study metadata - client will perform the analysis - return NextResponse.json({ - success: true, - study: { - snps: study.snps, - riskAllele: study.strongest_snp_risk_allele, - effectSize: effectSize, - effectType: effectType, - confidenceInterval: study.ci_text, - gwasId: study.study_accession, - } - }); - - } catch (error) { - console.error('Study analysis error:', error); - return NextResponse.json({ - success: false, - error: 'Internal server error during analysis' - }, { status: 500 }); - } -} diff --git a/app/api/stripe/attach-payment-method/route.ts b/app/api/stripe/attach-payment-method/route.ts new file mode 100644 index 0000000..cedfaa1 --- /dev/null +++ b/app/api/stripe/attach-payment-method/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', { + apiVersion: '2025-02-24.acacia', +}); + +export async function POST(request: NextRequest) { + try { + const { subscriptionId, customerId, paymentMethodId } = await request.json(); + + // Validate inputs + if (!subscriptionId || !customerId || !paymentMethodId) { + return NextResponse.json( + { error: 'Subscription ID, customer ID, and payment method ID required' }, + { status: 400 } + ); + } + + console.log(`[Stripe] Attaching payment method ${paymentMethodId} to customer ${customerId} and subscription ${subscriptionId}`); + + // Attach payment method to customer + await stripe.paymentMethods.attach(paymentMethodId, { + customer: customerId, + }); + + console.log(`[Stripe] Payment method attached to customer`); + + // Set as default payment method for the customer + await stripe.customers.update(customerId, { + invoice_settings: { + default_payment_method: paymentMethodId, + }, + }); + + console.log(`[Stripe] Set as default payment method for customer`); + + // Retrieve the subscription to check for existing discounts + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + console.log(`[Stripe] Retrieved subscription, has ${subscription.discounts?.length || 0} discounts`); + + // Update the subscription to use this payment method while preserving discounts + const updateParams: any = { + default_payment_method: paymentMethodId, + }; + + // Preserve existing discounts if any + if (subscription.discounts && subscription.discounts.length > 0) { + updateParams.discounts = subscription.discounts.map((d: any) => ({ + coupon: d.coupon?.id, + promotion_code: d.promotion_code, + })).filter((d: any) => d.coupon || d.promotion_code); + console.log(`[Stripe] Preserving ${updateParams.discounts.length} discounts during update`); + } + + await stripe.subscriptions.update(subscriptionId, updateParams); + + console.log(`[Stripe] Updated subscription ${subscriptionId} with payment method`); + + return NextResponse.json({ + success: true, + message: 'Payment method attached successfully', + }); + } catch (error: any) { + console.error('Stripe attach payment method error:', error); + + return NextResponse.json( + { + error: 'Failed to attach payment method', + details: error.message || 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/app/api/stripe/create-checkout/route.ts b/app/api/stripe/create-checkout/route.ts index 331b9f2..9bcccc5 100644 --- a/app/api/stripe/create-checkout/route.ts +++ b/app/api/stripe/create-checkout/route.ts @@ -9,7 +9,7 @@ const DAYS_PER_MONTH = 30; export async function POST(request: NextRequest) { try { - const { walletAddress } = await request.json(); + const { walletAddress, couponCode } = await request.json(); // Validate inputs if (!walletAddress) { @@ -45,6 +45,49 @@ export async function POST(request: NextRequest) { // Get the base URL for redirect const origin = request.headers.get('origin') || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + // Validate promotion code if provided + let discounts = undefined; + if (couponCode && couponCode.trim()) { + try { + console.log(`[Stripe] Validating promotion code: ${couponCode.trim()}`); + // Search for the promotion code + const promotionCodes = await stripe.promotionCodes.list({ + code: couponCode.trim(), + limit: 1, + }); + + if (promotionCodes.data.length === 0) { + console.log(`[Stripe] Promotion code not found`); + return NextResponse.json( + { error: 'Invalid promotion code' }, + { status: 400 } + ); + } + + const promotionCode = promotionCodes.data[0]; + console.log(`[Stripe] Promotion code retrieved:`, promotionCode.id); + + if (!promotionCode.active) { + console.log(`[Stripe] Promotion code is not active`); + return NextResponse.json( + { error: 'This promotion code is no longer active' }, + { status: 400 } + ); + } + + // Apply the promotion code + discounts = [{ promotion_code: promotionCode.id }]; + console.log(`[Stripe] Promotion code ${promotionCode.id} will be applied to checkout`); + } catch (error: any) { + // Promotion code lookup failed + console.error(`[Stripe] Error validating promotion code:`, error.message); + return NextResponse.json( + { error: `Invalid promotion code: ${error.message || 'Not found'}` }, + { status: 400 } + ); + } + } + // Create Stripe Checkout Session for subscription const session = await stripe.checkout.sessions.create({ payment_method_types: ['card'], @@ -55,6 +98,7 @@ export async function POST(request: NextRequest) { quantity: 1, }, ], + discounts, success_url: `${origin}/payment/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${origin}/payment/cancel`, metadata: { diff --git a/app/api/stripe/create-subscription/route.ts b/app/api/stripe/create-subscription/route.ts index 7b5340f..1b85f46 100644 --- a/app/api/stripe/create-subscription/route.ts +++ b/app/api/stripe/create-subscription/route.ts @@ -7,7 +7,7 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder' export async function POST(request: NextRequest) { try { - const { walletAddress } = await request.json(); + const { walletAddress, couponCode } = await request.json(); // Validate inputs if (!walletAddress) { @@ -50,8 +50,51 @@ export async function POST(request: NextRequest) { }, }); + // Validate promotion code if provided + let promotion_code_id = undefined; + if (couponCode && couponCode.trim()) { + try { + console.log(`[Stripe] Validating promotion code: ${couponCode.trim()}`); + // Search for the promotion code + const promotionCodes = await stripe.promotionCodes.list({ + code: couponCode.trim(), + limit: 1, + }); + + if (promotionCodes.data.length === 0) { + console.log(`[Stripe] Promotion code not found`); + return NextResponse.json( + { error: 'Invalid promotion code' }, + { status: 400 } + ); + } + + const promotionCode = promotionCodes.data[0]; + console.log(`[Stripe] Promotion code retrieved:`, promotionCode.id); + + if (!promotionCode.active) { + console.log(`[Stripe] Promotion code is not active`); + return NextResponse.json( + { error: 'This promotion code is no longer active' }, + { status: 400 } + ); + } + + // Apply the promotion code + promotion_code_id = promotionCode.id; + console.log(`[Stripe] Promotion code ${promotion_code_id} will be applied to subscription`); + } catch (error: any) { + // Promotion code lookup failed + console.error(`[Stripe] Error validating promotion code:`, error.message); + return NextResponse.json( + { error: `Invalid promotion code: ${error.message || 'Not found'}` }, + { status: 400 } + ); + } + } + // Create subscription - const subscription = await stripe.subscriptions.create({ + const subscriptionParams: any = { customer: customer.id, items: [ { @@ -61,22 +104,101 @@ export async function POST(request: NextRequest) { payment_behavior: 'default_incomplete', payment_settings: { save_default_payment_method: 'on_subscription', + payment_method_types: ['card'], }, expand: ['latest_invoice.payment_intent'], metadata: { walletAddress: walletAddress.toLowerCase(), }, - }); + // Always require payment method collection, even if first invoice is $0 + // This ensures we can charge after promotional period ends + collection_method: 'charge_automatically', + }; - const invoice = subscription.latest_invoice as Stripe.Invoice; - const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent; + // Add promotion code if provided + if (promotion_code_id) { + // For subscriptions, use discounts array with promotion_code + subscriptionParams.discounts = [{ promotion_code: promotion_code_id }]; + console.log(`[Stripe] Adding promotion code ${promotion_code_id} to subscription via discounts array`); + } + + const subscription = await stripe.subscriptions.create(subscriptionParams); console.log(`[Stripe] Created subscription: ${subscription.id} for wallet ${walletAddress}`); + const invoice = subscription.latest_invoice as Stripe.Invoice; + + if (!invoice) { + console.error(`[Stripe] No invoice found for subscription ${subscription.id}`); + return NextResponse.json( + { error: 'Failed to create invoice' }, + { status: 500 } + ); + } + + console.log(`[Stripe] Invoice ID: ${invoice.id}, Status: ${invoice.status}, Amount: ${invoice.amount_due}`); + + // Get discount information if available + let discountInfo = null; + if (invoice.discount || (invoice.total_discount_amounts && invoice.total_discount_amounts.length > 0)) { + const discountAmount = invoice.total_discount_amounts?.[0]?.amount || 0; + const originalAmount = invoice.subtotal || 0; + const finalAmount = invoice.amount_due || 0; + + discountInfo = { + originalAmount: (originalAmount / 100).toFixed(2), // Convert cents to dollars + discountAmount: (discountAmount / 100).toFixed(2), + finalAmount: (finalAmount / 100).toFixed(2), + promotionCode: invoice.discount?.promotion_code || null, + }; + + console.log(`[Stripe] Discount applied:`, discountInfo); + } + + const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent | null; + + // If there's no payment intent (e.g., $0 invoice due to 100% discount) + // We need to create a SetupIntent to collect payment method for future charges + if (!paymentIntent) { + console.log(`[Stripe] No payment intent for $0 invoice - creating SetupIntent to collect payment method`); + + const setupIntent = await stripe.setupIntents.create({ + customer: customer.id, + payment_method_types: ['card'], + metadata: { + subscription_id: subscription.id, + wallet_address: walletAddress.toLowerCase(), + }, + }); + + console.log(`[Stripe] SetupIntent created: ${setupIntent.id}`); + + return NextResponse.json({ + success: true, + subscriptionId: subscription.id, + customerId: customer.id, + clientSecret: setupIntent.client_secret, + isSetupIntent: true, // Flag to indicate this is setup, not payment + discount: discountInfo, + }); + } + + if (!paymentIntent.client_secret) { + console.error(`[Stripe] Payment intent exists but has no client_secret:`, paymentIntent.id); + return NextResponse.json( + { error: 'Failed to get payment client secret' }, + { status: 500 } + ); + } + + console.log(`[Stripe] Payment intent: ${paymentIntent.id}, Status: ${paymentIntent.status}`); + return NextResponse.json({ success: true, subscriptionId: subscription.id, clientSecret: paymentIntent.client_secret, + isSetupIntent: false, + discount: discountInfo, }); } catch (error: any) { console.error('Stripe subscription creation error:', error); diff --git a/app/api/studies/route.ts b/app/api/studies/route.ts index 276e9e6..c97b396 100644 --- a/app/api/studies/route.ts +++ b/app/api/studies/route.ts @@ -33,6 +33,7 @@ type RawStudy = { p_value: string | null; pvalue_mlog: string | null; or_or_beta: string | null; + ci_text: string | null; risk_allele_frequency: string | null; strongest_snp_risk_allele: string | null; snps: string | null; @@ -420,6 +421,7 @@ export async function GET(request: NextRequest) { gc.p_value, gc.pvalue_mlog, gc.or_or_beta, + gc.ci_text, gc.risk_allele_frequency, gc.strongest_snp_risk_allele, gc.snps${similarityColumn} @@ -544,6 +546,7 @@ export async function GET(request: NextRequest) { gc.p_value, gc.pvalue_mlog, gc.or_or_beta, + gc.ci_text, gc.risk_allele_frequency, gc.strongest_snp_risk_allele, gc.snps diff --git a/app/components/AuthProvider.tsx b/app/components/AuthProvider.tsx index 2eb51fb..ec756a4 100644 --- a/app/components/AuthProvider.tsx +++ b/app/components/AuthProvider.tsx @@ -138,10 +138,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { daysRemaining: subData.daysRemaining, }); + return subData.isActive; // Return whether subscription is active + } catch (error) { console.error('[AuthProvider] Failed to check subscription:', error); setHasActiveSubscription(false); setSubscriptionData(null); + return false; } finally { setCheckingSubscription(false); } @@ -150,7 +153,27 @@ export function AuthProvider({ children }: { children: ReactNode }) { const refreshSubscription = async () => { const walletAddress = user?.verifiedCredentials?.[0]?.address; if (walletAddress) { - await checkSubscription(walletAddress); + // Clear any cached subscription data first + localStorage.removeItem(`subscription_${walletAddress.toLowerCase()}`); + + // Check immediately + const isActive = await checkSubscription(walletAddress); + + // If still not active, retry up to 3 times with increasing delays + if (!isActive) { + console.log('[AuthProvider] Subscription not active yet, will retry...'); + + const retryDelays = [1000, 2000, 3000]; // 1s, 2s, 3s + for (const delay of retryDelays) { + await new Promise(resolve => setTimeout(resolve, delay)); + console.log(`[AuthProvider] Retry after ${delay}ms...`); + const isNowActive = await checkSubscription(walletAddress); + if (isNowActive) { + console.log('[AuthProvider] ✅ Subscription is now active!'); + break; + } + } + } } }; diff --git a/app/components/PaymentModal.tsx b/app/components/PaymentModal.tsx index ba290ad..4150014 100644 --- a/app/components/PaymentModal.tsx +++ b/app/components/PaymentModal.tsx @@ -281,11 +281,13 @@ export default function PaymentModal({ isOpen, onClose, onSuccess }: PaymentModa const durationDays = Math.round((parseFloat(amount || '0') / 4.99) * 30); trackSubscribedWithCreditCard(durationDays); - // Close modal after 3 seconds and trigger success callback + // Trigger success callback immediately to refresh subscription + onSuccess(); + + // Close modal after 2 seconds setTimeout(() => { onClose(); - onSuccess(); - }, 3000); + }, 2000); }; const handleSendPayment = async () => { @@ -447,23 +449,23 @@ export default function PaymentModal({ isOpen, onClose, onSuccess }: PaymentModa
@@ -720,12 +722,6 @@ export default function PaymentModal({ isOpen, onClose, onSuccess }: PaymentModa

Subscribe with Card

$4.99/month • Recurring subscription via Stripe

-
-

- Wallet: {primaryWallet.address.slice(0, 6)}...{primaryWallet.address.slice(-4)} -

-
- void; onCancel: () => void; } -function SubscriptionForm({ clientSecret, walletAddress, onSuccess, onCancel }: SubscriptionFormProps) { +function SubscriptionForm({ clientSecret, walletAddress, couponCode, discount, isSetupIntent, subscriptionId, customerId, onSuccess, onCancel }: SubscriptionFormProps) { const stripe = useStripe(); const elements = useElements(); const [isProcessing, setIsProcessing] = useState(false); @@ -30,40 +42,95 @@ function SubscriptionForm({ clientSecret, walletAddress, onSuccess, onCancel }: setErrorMessage(null); try { - const { error, paymentIntent } = await stripe.confirmPayment({ - elements, - confirmParams: { - return_url: `${window.location.origin}/payment/success`, - }, - redirect: 'if_required', - }); + if (isSetupIntent) { + // This is a setup intent (for $0 invoices with discount codes) + // We're just collecting the payment method for future charges + const { error, setupIntent } = await stripe.confirmSetup({ + elements, + confirmParams: { + return_url: `${window.location.origin}/payment/success`, + }, + redirect: 'if_required', + }); + + if (error) { + setErrorMessage(error.message || 'Failed to save payment method'); + setIsProcessing(false); + } else if (setupIntent) { + console.log('[StripeForm] Payment method saved, status:', setupIntent.status); + if (setupIntent.status === 'succeeded') { + console.log('[StripeForm] Setup succeeded, payment method ID:', setupIntent.payment_method); + + // Attach payment method to subscription + try { + const response = await fetch('/api/stripe/attach-payment-method', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subscriptionId, + customerId, + paymentMethodId: setupIntent.payment_method, + }), + }); - if (error) { - setErrorMessage(error.message || 'Payment failed'); - setIsProcessing(false); - } else if (paymentIntent) { - // Check payment status - console.log('[StripeForm] Payment intent status:', paymentIntent.status); - if (paymentIntent.status === 'succeeded') { - // Payment succeeded - close modal immediately - // Webhook will record payment in background - console.log('[StripeForm] Payment succeeded, closing modal'); + const data = await response.json(); + if (data.success) { + console.log('[StripeForm] Payment method attached to subscription'); + onSuccess(); + } else { + setErrorMessage(data.error || 'Failed to attach payment method to subscription'); + setIsProcessing(false); + } + } catch (err) { + console.error('[StripeForm] Error attaching payment method:', err); + setErrorMessage('Failed to complete subscription setup'); + setIsProcessing(false); + } + } else { + setErrorMessage(`Setup status: ${setupIntent.status}`); + setIsProcessing(false); + } + } else { + console.log('[StripeForm] No setup intent returned, closing anyway'); onSuccess(); - } else if (paymentIntent.status === 'processing') { - // Payment is processing - setErrorMessage('Payment is processing. This may take a moment...'); - // Wait a bit then close - setTimeout(() => { + } + } else { + // This is a payment intent (normal payment flow) + const { error, paymentIntent } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: `${window.location.origin}/payment/success`, + }, + redirect: 'if_required', + }); + + if (error) { + setErrorMessage(error.message || 'Payment failed'); + setIsProcessing(false); + } else if (paymentIntent) { + // Check payment status + console.log('[StripeForm] Payment intent status:', paymentIntent.status); + if (paymentIntent.status === 'succeeded') { + // Payment succeeded - close modal immediately + // Webhook will record payment in background + console.log('[StripeForm] Payment succeeded, closing modal'); onSuccess(); - }, 3000); + } else if (paymentIntent.status === 'processing') { + // Payment is processing + setErrorMessage('Payment is processing. This may take a moment...'); + // Wait a bit then close + setTimeout(() => { + onSuccess(); + }, 3000); + } else { + setErrorMessage(`Payment status: ${paymentIntent.status}`); + setIsProcessing(false); + } } else { - setErrorMessage(`Payment status: ${paymentIntent.status}`); - setIsProcessing(false); + // No error and no paymentIntent - shouldn't happen + console.log('[StripeForm] No payment intent returned, closing anyway'); + onSuccess(); } - } else { - // No error and no paymentIntent - shouldn't happen - console.log('[StripeForm] No payment intent returned, closing anyway'); - onSuccess(); } } catch (err: any) { setErrorMessage(err.message || 'An unexpected error occurred'); @@ -73,76 +140,337 @@ function SubscriptionForm({ clientSecret, walletAddress, onSuccess, onCancel }: return (
-
+ {/* Pricing Summary */} +
+
+

Premium Subscription

+
+ $4.99 + /month +
+
+ +
+
+ + LLM Chat Assistant +
+
+ + Run All Analysis +
+
+ + Overview Report +
+
+ + {couponCode && discount && ( +
+
+ 🎉 + Promo code "{couponCode}" applied! +
+
+
+ First payment: + + ${discount.originalAmount} + ${discount.finalAmount} + +
+
+ You save: + ${discount.discountAmount} +
+
+
+ Then $4.99/month after promotional period +
+
+ )} +
+ + {/* Payment Element */} +
{errorMessage && ( -
+
+ ⚠️ {errorMessage}
)} -
+
+
+ 🔒 + Secured by Stripe • Cancel anytime +
+ @@ -157,90 +485,485 @@ interface StripeSubscriptionFormProps { export default function StripeSubscriptionForm({ walletAddress, onSuccess, onCancel }: StripeSubscriptionFormProps) { const [clientSecret, setClientSecret] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const [isInitializing, setIsInitializing] = useState(false); const [error, setError] = useState(null); + const [couponCode, setCouponCode] = useState(''); + const [appliedCoupon, setAppliedCoupon] = useState(''); + const [discount, setDiscount] = useState(null); + const [showCouponInput, setShowCouponInput] = useState(false); + const [isSetupIntent, setIsSetupIntent] = useState(false); + const [subscriptionId, setSubscriptionId] = useState(null); + const [customerId, setCustomerId] = useState(null); + + const [hasInitialized, setHasInitialized] = React.useState(false); + + const initializePayment = (promoCode: string) => { + // Prevent double initialization + if (hasInitialized && !promoCode) { + return; + } - React.useEffect(() => { - let cancelled = false; + setIsInitializing(true); + setError(null); - console.log('[StripeForm] Initializing subscription for wallet:', walletAddress); + console.log('[StripeForm] Initializing subscription for wallet:', walletAddress, 'with promo:', promoCode || 'none'); - // Create subscription on mount + // Create subscription fetch('/api/stripe/create-subscription', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ walletAddress }), + body: JSON.stringify({ + walletAddress, + couponCode: promoCode.trim() || undefined + }), }) .then((res) => res.json()) .then((data) => { - if (cancelled) return; - console.log('[StripeForm] Subscription response:', data); - if (data.success && data.clientSecret) { - setClientSecret(data.clientSecret); - console.log('[StripeForm] Client secret received, rendering payment form'); + if (data.success) { + if (data.clientSecret) { + setClientSecret(data.clientSecret); + setIsSetupIntent(data.isSetupIntent || false); + setSubscriptionId(data.subscriptionId || null); + setCustomerId(data.customerId || null); + setHasInitialized(true); + if (promoCode) { + setAppliedCoupon(promoCode); + } + // Set discount info from API response + if (data.discount) { + setDiscount(data.discount); + console.log('[StripeForm] Discount info:', data.discount); + } + console.log('[StripeForm] Client secret received, isSetupIntent:', data.isSetupIntent, 'subscriptionId:', data.subscriptionId); + } else { + setError('Failed to initialize payment'); + console.error('[StripeForm] No client secret received'); + } } else { setError(data.error || 'Failed to initialize payment'); - console.error('[StripeForm] Failed to get client secret:', data); + console.error('[StripeForm] Failed to initialize:', data); } }) .catch((err) => { - if (cancelled) return; setError('Network error. Please try again.'); console.error('[StripeForm] Network error:', err); }) .finally(() => { - if (cancelled) return; - setIsLoading(false); + setIsInitializing(false); }); + }; - return () => { - cancelled = true; - }; - }, [walletAddress]); + const handleApplyCoupon = () => { + if (!couponCode.trim()) return; - if (isLoading) { + // Create subscription with coupon + initializePayment(couponCode); + setShowCouponInput(false); + }; + + if (isInitializing) { return ( -
+
+

Loading payment form...

+
); } if (error) { return ( -
-

{error}

- +
+
⚠️
+

{error}

+
+ + +
+
); } if (!clientSecret) { - return null; + // Show promo code input and continue button before initializing + return ( +
+
+ {!showCouponInput ? ( + <> + + + + ) : ( + <> +
+ setCouponCode(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleApplyCoupon()} + placeholder="Enter promo code" + className="coupon-input-field" + autoFocus + /> + + +
+ + + )} +
+ + +
+ ); } const options = { clientSecret, appearance: { - theme: 'night' as const, + theme: 'stripe' as const, variables: { colorPrimary: '#667eea', + colorBackground: '#ffffff', + colorText: '#1f2937', + colorDanger: '#dc2626', + fontFamily: 'system-ui, sans-serif', + spacingUnit: '4px', + borderRadius: '8px', }, }, }; return ( - - - +
+ + + + + +
); } diff --git a/app/components/StudyResultReveal.tsx b/app/components/StudyResultReveal.tsx index dfd397f..e50e5e2 100644 --- a/app/components/StudyResultReveal.tsx +++ b/app/components/StudyResultReveal.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from "react"; import { useGenotype } from "./UserDataUpload"; import { useResults } from "./ResultsContext"; import { hasMatchingSNPs } from "@/lib/snp-utils"; -import { analyzeStudyClientSide, UserStudyResult } from "@/lib/risk-calculator"; +import { analyzeStudyClientSide, UserStudyResult, determineEffectTypeAndSize } from "@/lib/risk-calculator"; import DisclaimerModal from "./DisclaimerModal"; import LLMCommentaryModal from "./LLMCommentaryModal"; import { SavedResult } from "@/lib/results-manager"; @@ -17,11 +17,13 @@ type StudyResultRevealProps = { traitName: string; studyTitle: string; riskAllele?: string | null; + orOrBeta?: string | null; + ciText?: string | null; isAnalyzable?: boolean; nonAnalyzableReason?: string; }; -export default function StudyResultReveal({ studyId, studyAccession, snps, traitName, studyTitle, riskAllele, isAnalyzable, nonAnalyzableReason }: StudyResultRevealProps) { +export default function StudyResultReveal({ studyId, studyAccession, snps, traitName, studyTitle, riskAllele, orOrBeta, ciText, isAnalyzable, nonAnalyzableReason }: StudyResultRevealProps) { const { genotypeData, isUploaded } = useGenotype(); const { addResult, hasResult, getResult, getResultByGwasId, resultsVersion } = useResults(); const [result, setResult] = useState(null); @@ -97,36 +99,27 @@ export default function StudyResultReveal({ studyId, studyAccession, snps, trait return; } + if (!snps || !riskAllele) { + setError('Missing study data required for analysis'); + return; + } + setIsLoading(true); setError(null); try { - // Fetch study metadata only (no user data sent to server) - const response = await fetch('/api/analyze-study', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - studyId, - }), - }); - - const data = await response.json(); - - if (!response.ok || !data.success) { - throw new Error(data.error || 'Failed to load study data'); - } + // Determine effect type and size from ci_text (client-side, instant) + const { effectType, effectSize } = determineEffectTypeAndSize(orOrBeta || null, ciText || null); // Perform analysis entirely client-side const analysisResult = analyzeStudyClientSide( genotypeData, - data.study.snps, - data.study.riskAllele, - data.study.effectSize, - data.study.gwasId, - data.study.effectType || 'OR', - data.study.confidenceInterval + snps, + riskAllele, + effectSize, + studyAccession || null, + effectType, + ciText || null ); setResult(analysisResult); @@ -136,7 +129,7 @@ export default function StudyResultReveal({ studyId, studyAccession, snps, trait trackStudyResultReveal( analysisResult.hasMatch, analysisResult.hasMatch ? 1 : 0, - data.study.confidenceBand || 'unknown' + 'unknown' // confidenceBand not available here but not critical ); // Save the result (including non-matches) so we don't re-analyze on reload diff --git a/app/globals.css b/app/globals.css index 6fd9aad..147f6af 100644 --- a/app/globals.css +++ b/app/globals.css @@ -5877,7 +5877,7 @@ details[open] .summary-arrow { right: 0; bottom: 0; background: rgba(0, 0, 0, 0.4); - backdrop-filter: blur(1px); + backdrop-filter: blur(0.5px); z-index: 10000; opacity: 0; transition: opacity 0.3s ease; diff --git a/app/page.tsx b/app/page.tsx index e75ebb3..4e8b7d2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -65,6 +65,7 @@ type Study = { p_value: string | null; pvalue_mlog: string | null; or_or_beta: string | null; + ci_text: string | null; risk_allele_frequency: string | null; strongest_snp_risk_allele: string | null; snps: string | null; @@ -1076,6 +1077,8 @@ function MainContent() { traitName={trait} studyTitle={study.study || "Untitled study"} riskAllele={study.strongest_snp_risk_allele} + orOrBeta={study.or_or_beta} + ciText={study.ci_text} isAnalyzable={study.isAnalyzable} nonAnalyzableReason={study.nonAnalyzableReason} /> diff --git a/lib/risk-calculator.ts b/lib/risk-calculator.ts index c6cafe2..e238217 100644 --- a/lib/risk-calculator.ts +++ b/lib/risk-calculator.ts @@ -1,5 +1,40 @@ import { parseVariantIds } from './snp-utils'; +/** + * Determines effect type and adjusts effect size based on ci_text + * GWAS Catalog stores ALL beta values as positive numbers + * Direction is encoded in ci_text ("increase" vs "decrease") + */ +export function determineEffectTypeAndSize( + orOrBeta: string | null, + ciText: string | null +): { effectType: 'OR' | 'beta'; effectSize: string } { + if (!orOrBeta) { + return { effectType: 'OR', effectSize: '1' }; + } + + // Determine effect type from ci_text + // Beta coefficients have "increase" or "decrease" in CI text + // e.g., "[NR] unit increase", "[0.0068-0.0139] unit increase", "[112.27-112.33] increase" + // Odds ratios are just numbers: e.g., "[1.08-1.15]" + const ciTextLower = ciText?.toLowerCase() ?? ''; + const hasIncrease = ciTextLower.includes('increase'); + const hasDecrease = ciTextLower.includes('decrease'); + const isBeta = hasIncrease || hasDecrease; + const effectType = isBeta ? 'beta' : 'OR'; + + // CRITICAL FIX: GWAS Catalog stores ALL beta values as positive numbers + // Direction is encoded in ci_text ("increase" vs "decrease") + // We must negate the value for "decrease" studies + let effectSize = orOrBeta; + if (isBeta && hasDecrease && !hasIncrease) { + const numericValue = parseFloat(orOrBeta); + effectSize = (-Math.abs(numericValue)).toString(); + } + + return { effectType, effectSize }; +} + export type UserStudyResult = { hasMatch: boolean; userGenotype?: string; diff --git a/lib/subscription-manager.ts b/lib/subscription-manager.ts index 551e37e..1591ebd 100644 --- a/lib/subscription-manager.ts +++ b/lib/subscription-manager.ts @@ -165,36 +165,49 @@ export async function checkCombinedSubscription(walletAddress: string): Promise< payments: [], }; - console.log('[Combined Check] Checking both blockchain and Stripe subscriptions for:', walletAddress); - - // Query both sources in parallel with timeout - const [blockchainSub, stripeSub] = await Promise.all([ - withTimeout( - checkBlockchainSubscription(walletAddress).catch(err => { - console.error('[Combined Check] ❌ Blockchain subscription check FAILED with error:', err); - console.error('[Combined Check] Error message:', err.message); - console.error('[Combined Check] Error stack:', err.stack); - return emptySubscription; - }), - 10000, // 10 second timeout for blockchain check (multiple chains can be slow) - emptySubscription - ).then(result => { - if (result === emptySubscription && result.payments.length === 0) { - console.log('[Combined Check] ⚠️ Blockchain check returned empty (likely timeout or error)'); - } else { - console.log('[Combined Check] ✅ Blockchain check completed successfully'); - } - return result; + console.log('[Combined Check] Checking subscription sources for:', walletAddress); + + // Check Stripe first (faster, more common for new users) + console.log('[Combined Check] Checking Stripe subscription first...'); + const stripeSub = await withTimeout( + checkStripeSubscription(walletAddress).catch(err => { + console.error('[Combined Check] Stripe subscription check failed:', err); + return emptySubscription; }), - withTimeout( - checkStripeSubscription(walletAddress).catch(err => { - console.error('[Combined Check] Stripe subscription check failed:', err); - return emptySubscription; - }), - 5000, // 5 second timeout for Stripe API check - emptySubscription - ), - ]); + 5000, // 5 second timeout for Stripe API check + emptySubscription + ); + + console.log('[Combined Check] Stripe check result:', { + stripeActive: stripeSub.isActive, + stripePayments: stripeSub.payments.length, + }); + + // If Stripe subscription is active, no need to check blockchain (optimization) + if (stripeSub.isActive) { + console.log('[Combined Check] ✅ Active Stripe subscription found - skipping blockchain check'); + return stripeSub; + } + + // No active Stripe subscription, check blockchain + console.log('[Combined Check] No active Stripe subscription, checking blockchain...'); + const blockchainSub = await withTimeout( + checkBlockchainSubscription(walletAddress).catch(err => { + console.error('[Combined Check] ❌ Blockchain subscription check FAILED with error:', err); + console.error('[Combined Check] Error message:', err.message); + console.error('[Combined Check] Error stack:', err.stack); + return emptySubscription; + }), + 10000, // 10 second timeout for blockchain check (multiple chains can be slow) + emptySubscription + ).then(result => { + if (result === emptySubscription && result.payments.length === 0) { + console.log('[Combined Check] ⚠️ Blockchain check returned empty (likely timeout or error)'); + } else { + console.log('[Combined Check] ✅ Blockchain check completed successfully'); + } + return result; + }); console.log('[Combined Check] Results:', { blockchainActive: blockchainSub.isActive,