diff --git a/app/referral/components/ReferralCalculator.tsx b/app/referral/components/ReferralCalculator.tsx new file mode 100644 index 000000000..5f6bf71f0 --- /dev/null +++ b/app/referral/components/ReferralCalculator.tsx @@ -0,0 +1,297 @@ +'use client'; + +import { useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faUserTie, faUser } from '@fortawesome/pro-light-svg-icons'; + +export function ReferralCalculator() { + const [fundingAmount, setFundingAmount] = useState(7100); + const referralBonus = fundingAmount * 0.1; + + const handleSliderChange = (event: React.ChangeEvent) => { + setFundingAmount(Number(event.target.value)); + }; + + const handleInputChange = (event: React.ChangeEvent) => { + const value = event.target.value; + if (value === '') { + setFundingAmount(0); + return; + } + const numValue = Number(value.replace(/,/g, '')); + if (!isNaN(numValue)) { + setFundingAmount(numValue); + } + }; + + // Whole-number formatting to match the "contributes" input above (no cents). + const formattedBonus = referralBonus.toLocaleString('en-US', { + maximumFractionDigits: 0, + }); + + // Slider fill: percentage of the 0โ€“50k track that is "active", used to paint + // the blue progress portion of the range input. + const fillPercent = Math.max(0, Math.min(100, (fundingAmount / 50000) * 100)); + + return ( +
+
+
+

+ Estimate your referral bonus. +

+

+ See how much you and your referred funder each earn in Funding Credits. +

+
+ +
+
+
+ +
+ $ + +
+ +
+ +
+
+ + You receive + ${formattedBonus} +
+
+ + Funder receives + ${formattedBonus} +
+
+
+
+ + + +
+ ); +} diff --git a/app/referral/components/ReferralFAQ.tsx b/app/referral/components/ReferralFAQ.tsx new file mode 100644 index 000000000..8ec263628 --- /dev/null +++ b/app/referral/components/ReferralFAQ.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useState } from 'react'; +import { Plus } from 'lucide-react'; + +interface FAQItem { + q: string; + a: string; +} + +const ITEMS: ReadonlyArray = [ + { + q: 'How do referral rewards work?', + a: 'When a funder joins through your link and backs research, you both earn 10% of their contribution as Funding Credits. The more they fund, the more credits you each receive.', + }, + { + q: 'What can I do with my referral credits?', + a: 'Funding Credits are non-extractive: they can only be used to fund research on ResearchHub. Direct them to your own proposal or back any open, preregistered proposal you believe in.', + }, + { + q: 'When do referral rewards expire?', + a: 'The 10% bonus applies to everything your referred funder contributes during their first six months on ResearchHub. We flag referrals that are expiring soon so you never miss the window.', + }, + { + q: 'Who can I refer?', + a: 'Anyone who wants to fund science. Share your link with individual donors, foundations, lab partners, or your wider network. Every funder you bring in helps move open science forward.', + }, + { + q: 'How do I share my link?', + a: 'Copy your personal link, show a QR code, or post straight to X, LinkedIn, or Bluesky from the share card above. Each contains your unique referral code so your rewards are tracked automatically.', + }, + { + q: 'Do my referrals get anything?', + a: 'Yes. Your referred funders receive the same 10% bonus in Funding Credits, so joining through your link stretches their impact further too.', + }, +]; + +export function ReferralFAQ() { + const [open, setOpen] = useState(0); + + return ( +
+
+

+ Referrals, explained. +

+
+ +
+
+ {ITEMS.map((item, i) => { + const isOpen = open === i; + return ( +
+ + {isOpen &&
{item.a}
} +
+ ); + })} +
+
+ + + +
+ ); +} diff --git a/app/referral/components/ReferralHero.tsx b/app/referral/components/ReferralHero.tsx new file mode 100644 index 000000000..56c422db8 --- /dev/null +++ b/app/referral/components/ReferralHero.tsx @@ -0,0 +1,326 @@ +'use client'; + +import Link from 'next/link'; +import { Check, Copy, Users } from 'lucide-react'; +import { Logo } from '@/components/ui/Logo'; +import { ReferralMesh } from './ReferralMesh'; +import { ReferralNetworkGraphic } from './ReferralNetworkGraphic'; +import { useReferralShare } from './useReferralShare'; + +export function ReferralHero() { + const { copyLink, isCopied } = useReferralShare(); + + const handleSeeHowItWorks = () => { + const target = document.getElementById('referral-how'); + if (!target) return; + const prefersReducedMotion = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + target.scrollIntoView({ + behavior: prefersReducedMotion ? 'auto' : 'smooth', + block: 'start', + }); + }; + + return ( +
+ +
+ ); +} diff --git a/app/referral/components/ReferralHowItWorks.tsx b/app/referral/components/ReferralHowItWorks.tsx new file mode 100644 index 000000000..ccb314be2 --- /dev/null +++ b/app/referral/components/ReferralHowItWorks.tsx @@ -0,0 +1,200 @@ +'use client'; + +import Image from 'next/image'; + +interface Step { + num: string; + image: string; + alt: string; + title: string; + body: string; +} + +const STEPS: ReadonlyArray = [ + { + num: '01', + image: '/referral/share_your_link.webp', + alt: 'Person sharing referral link from laptop', + title: 'Share your link', + body: 'Send your unique referral link to potential funders, big or small.', + }, + { + num: '02', + image: '/referral/user_funds_research.webp', + alt: 'Person funding research with flask', + title: 'They fund research', + body: 'Your referred funder backs a preregistered proposal on ResearchHub.', + }, + { + num: '03', + image: '/referral/you_both_rewarded.webp', + alt: 'Two people holding reward coins', + title: 'You both get rewarded', + body: 'You each receive 10% of their funded amount in credits to support more research.', + }, +]; + +export function ReferralHowItWorks() { + return ( +
+
+

+ How referrals work. +

+

+ Three simple steps turn your network into funding for open science. +

+
+ +
+
+ {STEPS.map((step) => ( +
+
+ {step.alt} + {step.num} +
+

{step.title}

+

{step.body}

+
+ ))} +
+
+ + + +
+ ); +} diff --git a/app/referral/components/ReferralImpact.tsx b/app/referral/components/ReferralImpact.tsx new file mode 100644 index 000000000..c19d6c6a4 --- /dev/null +++ b/app/referral/components/ReferralImpact.tsx @@ -0,0 +1,379 @@ +'use client'; + +import { useRef, useEffect } from 'react'; +import { Users, FlaskConical, List, Plus, AlertCircle, Coins } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { gsap } from 'gsap'; +import { Button } from '@/components/ui/Button'; +import { Tooltip } from '@/components/ui/Tooltip'; +import { PixelBackdrop } from '@/app/endowment/components/PixelBackdrop'; +import { useReferralMetrics } from '@/hooks/useReferral'; + +function ImpactSkeleton() { + return ( +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+
+ ))} +
+ ); +} + +export function ReferralImpact() { + const router = useRouter(); + const referredUsersRef = useRef(null); + const amountFundedRef = useRef(null); + const creditsEarnedRef = useRef(null); + + const { metrics, isLoading, error } = useReferralMetrics(); + + const displayData = { + referredUsersCount: metrics?.referralActivity.fundersInvited || 0, + amountFundedByReferred: metrics?.networkFundingPower.breakdown.networkFunding || 0, + creditsEarned: metrics?.yourFundingCredits.available || 0, + }; + + useEffect(() => { + const referredUsersEl = referredUsersRef.current; + const amountFundedEl = amountFundedRef.current; + const creditsEarnedEl = creditsEarnedRef.current; + + if (!referredUsersEl || !amountFundedEl || !creditsEarnedEl || isLoading) return; + + const animateValue = (el: HTMLParagraphElement, endValue: number, isCurrency: boolean) => { + const proxy = { value: 0 }; + gsap.to(proxy, { + value: endValue, + duration: 1.5, + ease: 'power2.out', + onUpdate: () => { + if (isCurrency) { + el.textContent = `${Math.round(proxy.value).toLocaleString()} RSC`; + } else { + el.textContent = Math.round(proxy.value).toString(); + } + }, + }); + }; + + const tl = gsap.timeline(); + tl.call(() => animateValue(creditsEarnedEl, displayData.creditsEarned, true), [], 0.1) + .call(() => animateValue(referredUsersEl, displayData.referredUsersCount, false), [], 0.2) + .call(() => animateValue(amountFundedEl, displayData.amountFundedByReferred, true), [], 0.3); + + return () => { + tl.kill(); + }; + }, [ + isLoading, + displayData.creditsEarned, + displayData.referredUsersCount, + displayData.amountFundedByReferred, + ]); + + return ( +
+ + + +
+
+

+ Your referral impact. +

+

+ Track the credits you have earned and the science your network has funded. +

+
+ + {isLoading ? ( + + ) : error ? ( +
+ +

Couldn't load your metrics

+

{error}

+ +
+ ) : ( +
+
+
+ +
+

+ {displayData.creditsEarned.toLocaleString()} RSC +

+

Referral Credits Available

+ +

+ Credits earned must be used towards funding your own proposal or another + proposal on the platform. +

+
+ + +
+ + } + position="top" + width="w-[320px]" + > + e.preventDefault()} className="referral-impact-help"> + How can I use this? + +
+
+ +
+
+ +
+

+ {displayData.referredUsersCount} +

+

Funders Referred

+
+ +
+
+ +
+

+ {displayData.amountFundedByReferred.toLocaleString()} RSC +

+

Funded by Your Referrals

+
+
+ )} +
+ + + +
+ ); +} diff --git a/app/referral/components/ReferralMesh.tsx b/app/referral/components/ReferralMesh.tsx new file mode 100644 index 000000000..7cc79b0a2 --- /dev/null +++ b/app/referral/components/ReferralMesh.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +interface MeshPoint { + x: number; + y: number; + r: number; + bright: boolean; +} + +const TAU = Math.PI * 2; + +/** + * A structured "connection mesh" backdrop for the referral hero: a jittered + * grid of nodes joined by faint links between near neighbors. Distinct from the + * give hero's random Starfield, it reinforces the page's referral-network theme + * at the background level. Static (redraws on resize only) so it stays cheap + * behind the animated network graphic. + */ +export function ReferralMesh({ spacing = 132 }: { spacing?: number }) { + const ref = useRef(null); + const pointsRef = useRef([]); + + useEffect(() => { + const canvas = ref.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let w = 1; + let h = 1; + + const build = () => { + const cols = Math.ceil(w / spacing) + 1; + const rows = Math.ceil(h / spacing) + 1; + const points: MeshPoint[] = []; + for (let i = 0; i <= cols; i++) { + for (let j = 0; j <= rows; j++) { + // Jitter each node off its grid slot so links read organically. + const jitter = spacing * 0.34; + points.push({ + x: i * spacing + (Math.random() - 0.5) * 2 * jitter, + y: j * spacing + (Math.random() - 0.5) * 2 * jitter, + r: 0.8 + Math.random() * 1.4, + bright: Math.random() < 0.12, + }); + } + } + pointsRef.current = points; + }; + + const paint = () => { + ctx.clearRect(0, 0, w, h); + const points = pointsRef.current; + const maxDist = spacing * 1.32; + + // Links between near neighbors. + ctx.lineWidth = 1; + for (let a = 0; a < points.length; a++) { + for (let b = a + 1; b < points.length; b++) { + const dx = points[a].x - points[b].x; + const dy = points[a].y - points[b].y; + const dist = Math.hypot(dx, dy); + if (dist > maxDist) continue; + const t = 1 - dist / maxDist; + ctx.strokeStyle = `rgba(147,197,253,${0.04 + t * 0.14})`; + ctx.beginPath(); + ctx.moveTo(points[a].x, points[a].y); + ctx.lineTo(points[b].x, points[b].y); + ctx.stroke(); + } + } + + // Nodes. + for (const p of points) { + if (p.bright) { + const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 6); + g.addColorStop(0, 'rgba(199,210,254,0.85)'); + g.addColorStop(1, 'rgba(199,210,254,0)'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r * 6, 0, TAU); + ctx.fill(); + } + ctx.fillStyle = p.bright ? 'rgba(224,236,255,0.95)' : 'rgba(147,197,253,0.42)'; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r, 0, TAU); + ctx.fill(); + } + }; + + const resize = () => { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const rect = canvas.getBoundingClientRect(); + w = Math.max(1, rect.width); + h = Math.max(1, rect.height); + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + build(); + paint(); + }; + + resize(); + let ro: ResizeObserver | null = null; + if (window.ResizeObserver) { + ro = new ResizeObserver(resize); + ro.observe(canvas); + } else { + window.addEventListener('resize', resize); + } + return () => { + if (ro) ro.disconnect(); + else window.removeEventListener('resize', resize); + }; + }, [spacing]); + + return ( + <> +