"use client"; import { useState, useRef, useEffect } from "react"; import { CircleNotch as LoaderCircle, Download, MagnifyingGlass as Search, Dna, ArrowRight, Flask as Beaker } from '@phosphor-icons/react'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend, Cell } from "recharts"; import { downloadTsv } from "@/lib/export-utils"; import { useAuditTrail } from "@/hooks/useAuditTrail"; import { CriticalButton, FlatInput, FlatTextarea } from "@/components/ui"; import { searchPrimerTargets, analyzePrimer, fetchSequence } from "@/lib/api"; import { extractErrorMessage } from "@/lib/errors"; import type { PrimerSearchHit, PrimerAnalyzeResponse, PrimerStructure } from "@/lib/api"; type PrimerPair = { pair_index: number; left_seq: string; left_tm: number; left_gc: number; left_pos: number; left_len: number; right_seq: string; right_tm: number; right_gc: number; right_pos: number; right_len: number; product_size: number; penalty: number; }; const RISK_TONES: Record = { high: "text-error bg-error/10 border-error/30", medium: "text-accent-amber bg-accent-amber/10 border-accent-amber/30", low: "text-accent-cyan bg-accent-cyan/10 border-accent-cyan/30", none: "text-molecule-protein bg-molecule-protein/10 border-molecule-protein/30", }; function RiskBadge({ structure }: { structure: PrimerStructure }) { const cls = RISK_TONES[structure.risk] ?? RISK_TONES.none; const label = structure.risk.charAt(0).toUpperCase() + structure.risk.slice(1); return {label}; } function RiskCard({ title, structure }: { title: string; structure: PrimerStructure }) { return (
{title}
{structure.stem_length > 0 ? ( <>

ΔG {structure.dg.toFixed(1)} kcal/mol · stem {structure.stem_length} bp

{structure.stem}

{"loop" in structure && structure.loop && (

loop: {structure.loop}

)} {(structure.involves_a3 || structure.involves_b3) && (

3' end involved — extension-competent

)} ) : (

No significant structure predicted.

)}
); } export function PrimerDesigner() { const audit = useAuditTrail(); const [sequence, setSequence] = useState(""); const [productMin, setProductMin] = useState(100); const [productMax, setProductMax] = useState(500); const [optTm, setOptTm] = useState(60); const [pairs, setPairs] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedPair, setSelectedPair] = useState(null); // NCBI gene/sequence search + retrieval const [geneQuery, setGeneQuery] = useState(""); const [searchLoading, setSearchLoading] = useState(false); const [searchResults, setSearchResults] = useState([]); const [searchError, setSearchError] = useState(null); const [usingSeq, setUsingSeq] = useState(null); const [fetchError, setFetchError] = useState(null); // Selected-pair QC const [analysis, setAnalysis] = useState(null); const [analyzing, setAnalyzing] = useState(false); const [analysisError, setAnalysisError] = useState(null); const auditedRef = useRef(false); useEffect(() => { const stored = sessionStorage.getItem('primer_sequence'); if (stored) { sessionStorage.removeItem('primer_sequence'); setSequence(stored); } }, []); // Auto-run QC (hairpin / dimer / in-silico PCR) for the selected pair. useEffect(() => { if (pairs.length === 0 || selectedPair === null) return; const p = pairs[selectedPair]; const tmpl = sequence.replace(/\s/g, "").toUpperCase(); if (!tmpl) return; let cancelled = false; setAnalyzing(true); setAnalysisError(null); analyzePrimer({ left_seq: p.left_seq, right_seq: p.right_seq, template: tmpl, left_pos: p.left_pos, right_pos: p.right_pos, expected_product: p.product_size, }) .then(res => { if (!cancelled) setAnalysis(res); }) .catch(e => { if (!cancelled) setAnalysisError(extractErrorMessage(e)); }) .finally(() => { if (!cancelled) setAnalyzing(false); }); return () => { cancelled = true; }; }, [selectedPair, pairs, sequence]); async function searchGene() { const q = geneQuery.trim(); if (!q) return; setSearchLoading(true); setSearchError(null); setFetchError(null); setSearchResults([]); try { const res = await searchPrimerTargets(q, 12); if (res.error) setSearchError(res.error); else setSearchResults(res.results); } catch (e: any) { setSearchError(extractErrorMessage(e)); } finally { setSearchLoading(false); } } async function applySequence(hit: PrimerSearchHit) { setUsingSeq(hit.accession); setFetchError(null); try { const res = await fetchSequence(hit.accession); if (res.error) { setFetchError(res.error); } else { setSequence(res.sequence); audit.emitSuccess('primer_ncbi_retrieval', 'NCBI', hit.accession, `${res.length}bp`); } } catch (e: any) { setFetchError(extractErrorMessage(e)); } finally { setUsingSeq(null); } } async function design() { setError(null); setPairs([]); setSelectedPair(null); setAnalysis(null); setLoading(true); auditedRef.current = false; try { const res = await fetch("/api/backend/api/primers/design", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sequence, product_size_min: productMin, product_size_max: productMax, opt_tm: optTm }), }); if (!res.ok) { const d = await res.json(); const msg = Array.isArray(d.detail) ? d.detail.map((e: any) => e.msg || String(e)).join("; ") : typeof d.detail === "string" ? d.detail : JSON.stringify(d.detail); throw new Error(msg || res.statusText); } const result = await res.json(); setPairs(result); setSelectedPair(0); if (!auditedRef.current) { auditedRef.current = true; audit.emitSuccess('primer_design', 'Primer3', `${sequence.length}bp`, `${result.length} pairs`); } } catch (e: any) { setError(e.message); audit.emitFailed('primer_design', 'Primer3', `${sequence.length}bp`, e.message); } finally { setLoading(false); } } function copyPair(p: PrimerPair) { navigator.clipboard.writeText(`Forward: ${p.left_seq}\nReverse: ${p.right_seq}`); } const highlightSequence = (seq: string, pair: PrimerPair | null) => { if (!pair) return seq; const lEnd = pair.left_pos + pair.left_len; const rStart = pair.right_pos - pair.right_len + 1; return ( <> {seq.slice(0, pair.left_pos)} {seq.slice(pair.left_pos, lEnd)} {seq.slice(lEnd, rStart)} {seq.slice(rStart, pair.right_pos + 1)} {seq.slice(pair.right_pos + 1)} ); }; const selected = selectedPair !== null && pairs[selectedPair] ? pairs[selectedPair] : null; const tmData = pairs.map(p => ({ name: `P${p.pair_index + 1}`, Left: +p.left_tm.toFixed(1), Right: +p.right_tm.toFixed(1) })); const gcData = pairs.map(p => ({ name: `P${p.pair_index + 1}`, Left: +p.left_gc.toFixed(1), Right: +p.right_gc.toFixed(1) })); const productData = pairs.map(p => ({ name: `P${p.pair_index + 1}`, Size: p.product_size })); const chartTooltip = { contentStyle: { background: "var(--chart-tooltip-bg)", border: "1px solid var(--chart-tooltip-border)", borderRadius: 8, fontSize: 12 }, labelStyle: { color: "var(--chart-tooltip-label)" }, itemStyle: { color: "var(--chart-tooltip-item)" } }; return (

Primer Design (Primer3)

Search NCBI for a gene, retrieve its mRNA/CDS, then design and QC primers locally — instant results, no rate limits.

{/* NCBI gene search */}
1 · Find a gene on NCBI
{ setGeneQuery(e.target.value); setSearchError(null); }} onKeyDown={e => e.key === 'Enter' && searchGene()} placeholder="e.g. TP53 human, BRCA1 human, HBB mRNA" className="flex-1 px-3 py-2 rounded-lg border border-glass-border focus:border-accent-cyan focus:ring-2 focus:ring-accent-cyan/20 outline-none transition text-xs bg-surface-0 text-text-primary" />
{searchError &&

{searchError}

} {fetchError &&

{fetchError}

} {searchResults.length > 0 && (
{searchResults.map((r, i) => (
{r.accession} {r.record_type} {r.suggested_use === 'ideal' && ( ideal template )}

{r.title}

{r.organism} · {r.length.toLocaleString()} bp

))}
)}
{/* Sequence input */}
2 · DNA / CDS sequence {sequence && {sequence.replace(/\s/g, "").length.toLocaleString()} bp}
setSequence(e.target.value)} rows={5} placeholder="Paste a DNA/CDS sequence, or use 'Use sequence' above to fetch one from NCBI..." className="w-full text-xs" />
{[ { label: "Min product (bp)", value: productMin, set: setProductMin, min: 50, max: 999 }, { label: "Max product (bp)", value: productMax, set: setProductMax, min: 100, max: 2000 }, { label: "Optimal Tm (°C)", value: optTm, set: setOptTm, min: 50, max: 75 }, ].map(({ label, value, set, min, max }) => (
set(+e.target.value)} />
))}
{error &&

{error}

} {loading ? <> Designing… : "Design Primers"}
{pairs.length > 0 && (
{pairs.map(p => ( ))}
{/* All-pairs overview charts */}

All candidate pairs — Tm, GC and product size

Melting temperature (°C)

GC content (%)

Product size (bp)

{productData.map((d, i) => )}
{selected && (() => { const p = selected; return (
{[ { label: "Forward (5'→3')", seq: p.left_seq, tm: p.left_tm, gc: p.left_gc, pos: p.left_pos, color: "rgb(var(--accent-cyan))" }, { label: "Reverse (5'→3')", seq: p.right_seq, tm: p.right_tm, gc: p.right_gc, pos: p.right_pos, color: "#A855F7" }, ].map(primer => (
{primer.label}
Tm {primer.tm.toFixed(1)}°C GC {primer.gc.toFixed(1)}% Pos {primer.pos}
{primer.seq}
))}
Product size: {p.product_size} bp Penalty: {p.penalty.toFixed(3)}

Binding positions in sequence

{highlightSequence(sequence.replace(/\s/g, "").toUpperCase(), p)}
Forward Reverse
{/* QC panel */}

Oligo QC + in-silico PCR (reference-verified)

{analyzing && }
{analysisError &&

{analysisError}

} {analysis && (
{analysis.pcr && (
Specificity: {analysis.pcr.specific ? each primer binds once : multiple sites ({analysis.pcr.forward_binding_sites} fwd / {analysis.pcr.reverse_binding_sites} rev)} Fwd sites {analysis.pcr.forward_binding_sites} Rev sites {analysis.pcr.reverse_binding_sites} Primer3 match {analysis.pcr.primer3_consistent ? ✓ : mismatch} Amplicon {analysis.pcr.matches_product_size ? {p.product_size} bp ✓ : mismatch}
{analysis.pcr.amplicons.length > 0 && (

Predicted amplicons: {analysis.pcr.amplicons.slice(0, 3).map(a => `[${a.start}..${a.end}] ${a.length}bp`).join(" ")}

)} {analysis.pcr.note && (

{analysis.pcr.note}

)}
)}
)} {!analysis && !analyzing && !analysisError && (

Select a pair to run QC checks.

)}
); })()}
)}
); }