From 1018fafdd8ae628fa7fdc0f122c44aa6d7347c15 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 17 Jun 2026 01:26:22 +0000 Subject: [PATCH 1/4] Revamp /referral with Give & Endowment design language Convert /referral into a standalone immersive landing page that mirrors /give and /endowment: cosmos hero with AnimatedGlobe, scroll-triggered top bar with back button, styled-jsx sections (Cal Sans headings, gradient accents, hover-lift cards), light-blue impact band, cosmos calculator, and a FAQ accordion, ending in the shared LandingPageFooter. All existing functionality is preserved: referral link copy/QR/social share with analytics, live impact metrics with GSAP count-up and credits tooltip CTAs, paginated referred-users list with expiration UX, and the 10% bonus calculator. Share/copy/social logic is centralized in a useReferralShare hook. Removes the now-unused components/Referral dashboard files. --- .../components/ReferralCalculator.tsx | 297 ++++++++++++ app/referral/components/ReferralFAQ.tsx | 182 +++++++ app/referral/components/ReferralHero.tsx | 350 ++++++++++++++ .../components/ReferralHowItWorks.tsx | 200 ++++++++ app/referral/components/ReferralImpact.tsx | 379 +++++++++++++++ .../components/ReferralNetworkList.tsx | 452 ++++++++++++++++++ app/referral/components/ReferralReadyGate.tsx | 40 ++ app/referral/components/ReferralShareCard.tsx | 349 ++++++++++++++ app/referral/components/ReferralTopBar.tsx | 189 ++++++++ app/referral/components/useReferralShare.ts | 124 +++++ app/referral/layout.tsx | 7 + app/referral/page.tsx | 27 +- components/Referral/HowItWorksSection.tsx | 57 --- components/Referral/ReferralCalculator.tsx | 96 ---- components/Referral/ReferralDashboard.tsx | 28 -- components/Referral/ReferralHeader.tsx | 21 - components/Referral/ReferralImpactSection.tsx | 145 ------ .../Referral/ReferralImpactSkeleton.tsx | 48 -- components/Referral/ReferralLinkCard.tsx | 172 ------- components/Referral/ReferralLinkSkeleton.tsx | 28 -- components/Referral/ReferredUsersList.tsx | 216 --------- components/Referral/ReferredUsersSkeleton.tsx | 48 -- components/Referral/index.ts | 10 - 23 files changed, 2590 insertions(+), 875 deletions(-) create mode 100644 app/referral/components/ReferralCalculator.tsx create mode 100644 app/referral/components/ReferralFAQ.tsx create mode 100644 app/referral/components/ReferralHero.tsx create mode 100644 app/referral/components/ReferralHowItWorks.tsx create mode 100644 app/referral/components/ReferralImpact.tsx create mode 100644 app/referral/components/ReferralNetworkList.tsx create mode 100644 app/referral/components/ReferralReadyGate.tsx create mode 100644 app/referral/components/ReferralShareCard.tsx create mode 100644 app/referral/components/ReferralTopBar.tsx create mode 100644 app/referral/components/useReferralShare.ts create mode 100644 app/referral/layout.tsx delete mode 100644 components/Referral/HowItWorksSection.tsx delete mode 100644 components/Referral/ReferralCalculator.tsx delete mode 100644 components/Referral/ReferralDashboard.tsx delete mode 100644 components/Referral/ReferralHeader.tsx delete mode 100644 components/Referral/ReferralImpactSection.tsx delete mode 100644 components/Referral/ReferralImpactSkeleton.tsx delete mode 100644 components/Referral/ReferralLinkCard.tsx delete mode 100644 components/Referral/ReferralLinkSkeleton.tsx delete mode 100644 components/Referral/ReferredUsersList.tsx delete mode 100644 components/Referral/ReferredUsersSkeleton.tsx diff --git a/app/referral/components/ReferralCalculator.tsx b/app/referral/components/ReferralCalculator.tsx new file mode 100644 index 000000000..010e6fe95 --- /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); + } + }; + + const formattedBonus = referralBonus.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + + // 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..bca4fdc48 --- /dev/null +++ b/app/referral/components/ReferralHero.tsx @@ -0,0 +1,350 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import Link from 'next/link'; +import { Check, Copy, Users } from 'lucide-react'; +import { Logo } from '@/components/ui/Logo'; +import AnimatedGlobe from '@/components/Globe/AnimatedGlobe'; +import { Starfield } from '@/app/give/components/Starfield'; +import { useReferralShare } from './useReferralShare'; + +export function ReferralHero() { + const { copyLink, isCopied } = useReferralShare(); + + // The AnimatedGlobe canvas is sized in px, so we measure its column and clamp + // to keep it crisp on desktop while never overflowing on small screens. + const globeWrapRef = useRef(null); + const [globeSize, setGlobeSize] = useState(460); + + useEffect(() => { + const el = globeWrapRef.current; + if (!el) return; + const measure = () => { + const w = el.getBoundingClientRect().width; + setGlobeSize(Math.round(Math.max(260, Math.min(480, w)))); + }; + measure(); + let ro: ResizeObserver | null = null; + if (window.ResizeObserver) { + ro = new ResizeObserver(measure); + ro.observe(el); + } else { + window.addEventListener('resize', measure); + } + return () => { + if (ro) ro.disconnect(); + else window.removeEventListener('resize', measure); + }; + }, []); + + 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/ReferralNetworkList.tsx b/app/referral/components/ReferralNetworkList.tsx new file mode 100644 index 000000000..0141327da --- /dev/null +++ b/app/referral/components/ReferralNetworkList.tsx @@ -0,0 +1,452 @@ +'use client'; + +import { Users, ChevronLeft, ChevronRight, Share2, Clock, AlertCircle } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Avatar } from '@/components/ui/Avatar'; +import { Tooltip } from '@/components/ui/Tooltip'; +import { AuthorTooltip } from '@/components/ui/AuthorTooltip'; +import { useReferralNetworkDetails } from '@/hooks/useReferral'; + +const USERS_PER_PAGE = 5; + +// Days remaining before a referred user's bonus window closes; drives the +// "expiring soon" warning and the expired styling. +const getDaysUntilExpiration = (expirationDate: string): number => { + const now = new Date(); + const diffTime = new Date(expirationDate).getTime() - now.getTime(); + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); +}; + +const scrollToShare = () => { + const target = document.getElementById('referral-share'); + if (!target) return; + const prefersReducedMotion = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + target.scrollIntoView({ + behavior: prefersReducedMotion ? 'auto' : 'smooth', + block: 'start', + }); +}; + +function NetworkSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +export function ReferralNetworkList() { + const { + networkDetails, + isLoading, + error, + currentPage, + totalPages, + hasNextPage, + hasPrevPage, + goToNextPage, + goToPrevPage, + } = useReferralNetworkDetails(USERS_PER_PAGE); + + const displayUsers = networkDetails.map((user) => { + const daysUntilExpiration = getDaysUntilExpiration(user.referralBonusExpirationDate); + + return { + name: user.fullName, + avatarUrl: user.profileImage, + totalFunded: user.totalFunded, + creditsEarned: user.referralBonusEarned, + dateJoined: new Date(user.signupDate).toLocaleDateString(), + authorId: user.authorId, + isExpired: user.isReferralBonusExpired, + daysUntilExpiration, + expirationDate: new Date(user.referralBonusExpirationDate).toLocaleDateString(), + }; + }); + + return ( +
+
+
+

+ Your referred network. +

+

+ The funders who joined through your link and the science they have backed. +

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

Couldn't load your network

+

{error}

+ +
+ ) : displayUsers.length === 0 ? ( +
+ +

No referred funders yet

+

Share your referral link to start building your network and earning credits.

+ +
+ ) : ( + <> +
+ {displayUsers.map((user, index) => ( +
+ + + + +
+
+ {user.name} + {user.isExpired ? ( + +

Referral Expired

+

Benefits expired on {user.expirationDate}

+

No new credits will be earned

+
+ } + position="top" + width="w-48" + > + + + ) : user.daysUntilExpiration <= 30 ? ( + +

Expiring Soon

+

+ {user.daysUntilExpiration} days until expiration +

+

Expires: {user.expirationDate}

+

Credits will stop after expiration

+
+ } + position="top" + width="w-48" + > + + + ) : null} +
+ Joined {user.dateJoined} + {!user.isExpired && user.daysUntilExpiration <= 30 && ( + + Expires in {user.daysUntilExpiration} days + + )} +
+ +
+
+ Total Funded + + {user.totalFunded.toLocaleString()} RSC + +
+
+ Credits Earned + + {user.creditsEarned.toLocaleString()} RSC + + {user.isExpired && (Final amount)} +
+
+
+ ))} +
+ + {totalPages > 1 && ( +
+ + + Page {currentPage} of {totalPages} + + +
+ )} + + )} +
+ + + + + ); +} diff --git a/app/referral/components/ReferralReadyGate.tsx b/app/referral/components/ReferralReadyGate.tsx new file mode 100644 index 000000000..15aef0071 --- /dev/null +++ b/app/referral/components/ReferralReadyGate.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useEffect, useState, type ReactNode } from 'react'; + +interface ReferralReadyGateProps { + children: ReactNode; +} + +/** + * Hides the referral landing page until styled-jsx CSS has been applied to the + * DOM (after the first paint frame following mount), preventing a brief FOUC in + * dev where unstyled content can flash before component styles land. The content + * tree mounts immediately (so data fetching/effects start right away); only the + * opacity transitions from 0 to 1 once the page is styled. + */ +export function ReferralReadyGate({ children }: Readonly) { + const [ready, setReady] = useState(false); + + useEffect(() => { + let raf2 = 0; + const raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => setReady(true)); + }); + return () => { + cancelAnimationFrame(raf1); + if (raf2) cancelAnimationFrame(raf2); + }; + }, []); + + return ( +
+ {children} +
+ ); +} diff --git a/app/referral/components/ReferralShareCard.tsx b/app/referral/components/ReferralShareCard.tsx new file mode 100644 index 000000000..e932dbb49 --- /dev/null +++ b/app/referral/components/ReferralShareCard.tsx @@ -0,0 +1,349 @@ +'use client'; + +import { useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faXTwitter, faLinkedin, faBluesky } from '@fortawesome/free-brands-svg-icons'; +import { Copy, Check, QrCode } from 'lucide-react'; +import { PixelBackdrop } from '@/app/endowment/components/PixelBackdrop'; +import { CosmosPixelFade } from '@/app/give/components/CosmosPixelFade'; +import { QRCodeModal } from '@/components/Referral/QRCodeModal'; +import { useReferralShare } from './useReferralShare'; + +export function ReferralShareCard() { + const [isQrModalOpen, setIsQrModalOpen] = useState(false); + const { + currentUser, + isLoading, + referralLink, + isCopied, + copyLink, + logQrCodeClick, + shareOnX, + shareOnLinkedIn, + shareOnBlueSky, + } = useReferralShare(); + + const handleQrCodeClick = () => { + logQrCodeClick(); + setIsQrModalOpen(true); + }; + + return ( +
+ + + + +
+

+ Your personal referral link. +

+

+ Share it anywhere. Every funder who joins through your link earns you both a 10% bonus. +

+ +
+ {isLoading ? ( +
+
+
+
+
+
+
+ ) : !currentUser ? ( +

Sign in to get your referral link.

+ ) : ( + <> + + e.currentTarget.select()} + /> + +
+ + +
+ +
+ +
+ Share on + + + +
+ + )} +
+
+ + setIsQrModalOpen(false)} + referralLink={referralLink} + /> + + +
+ ); +} diff --git a/app/referral/components/ReferralTopBar.tsx b/app/referral/components/ReferralTopBar.tsx new file mode 100644 index 000000000..a02941609 --- /dev/null +++ b/app/referral/components/ReferralTopBar.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { ChevronLeft, Copy, Check } from 'lucide-react'; +import { Logo } from '@/components/ui/Logo'; +import { useSmartBack } from '@/hooks/useSmartBack'; +import { useReferralShare } from './useReferralShare'; + +export function ReferralTopBar() { + const goBack = useSmartBack(); + const { copyLink, isCopied } = useReferralShare(); + // True once the user has scrolled past the hero section. Drives both the + // white-background fade-in on the bar itself and the CTA visibility (kept + // hidden while the hero CTA is on-screen so there's only one prominent CTA + // at a time). + const [scrolled, setScrolled] = useState(false); + + useEffect(() => { + const hero = document.querySelector('.referral-hero') as HTMLElement | null; + if (!hero) return; + + const update = () => { + const heroBottom = hero.offsetTop + hero.offsetHeight; + setScrolled(window.scrollY >= heroBottom); + }; + + update(); + window.addEventListener('scroll', update, { passive: true }); + window.addEventListener('resize', update); + return () => { + window.removeEventListener('scroll', update); + window.removeEventListener('resize', update); + }; + }, []); + + return ( +
+
+
+ + + + +
+ +
+ +
+
+ + +
+ ); +} diff --git a/app/referral/components/useReferralShare.ts b/app/referral/components/useReferralShare.ts new file mode 100644 index 000000000..c8ee9f00f --- /dev/null +++ b/app/referral/components/useReferralShare.ts @@ -0,0 +1,124 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import toast from 'react-hot-toast'; +import { useUser } from '@/contexts/UserContext'; +import AnalyticsService, { LogEvent } from '@/services/analytics.service'; + +const SHARE_ANCHOR_ID = 'referral-share'; + +/** + * Centralizes referral link construction, clipboard copy, and social-share + * intents along with their analytics events, so the hero, top bar, and share + * card all stay in sync and emit identical tracking. + */ +export function useReferralShare() { + const { user: currentUser, isLoading, error } = useUser(); + const [isCopied, setIsCopied] = useState(false); + + const referralCode = currentUser?.referralCode; + const referralLink = `${process.env.NEXT_PUBLIC_SITE_URL || 'https://researchhub.com'}/referral/join?refr=${referralCode}`; + + const scrollToShare = useCallback(() => { + const target = document.getElementById(SHARE_ANCHOR_ID); + if (!target) return; + const prefersReducedMotion = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + target.scrollIntoView({ + behavior: prefersReducedMotion ? 'auto' : 'smooth', + block: 'start', + }); + }, []); + + const copyLink = useCallback(() => { + // Until the user (and therefore the referral code) has loaded, guide the + // user to the share section instead of copying a link with no code. + if (!referralCode) { + scrollToShare(); + return; + } + + navigator.clipboard.writeText(referralLink).then( + () => { + setIsCopied(true); + toast.success('Referral link copied to clipboard!'); + setTimeout(() => setIsCopied(false), 2000); + }, + (err) => { + console.error('Failed to copy text: ', err); + toast.error('Failed to copy referral link.'); + } + ); + + AnalyticsService.logEvent(LogEvent.CLICKED_SHARE_VIA_URL, { + action: 'USER_SHARED_REFERRAL', + referralCode, + }); + }, [referralCode, referralLink, scrollToShare]); + + const logQrCodeClick = useCallback(() => { + AnalyticsService.logEvent(LogEvent.CLICKED_SHARE_VIA_QR_CODE, { + action: 'USER_SHARED_REFERRAL', + referralCode, + }); + }, [referralCode]); + + const shareOnX = useCallback(() => { + AnalyticsService.logEvent(LogEvent.CLICKED_SHARE_VIA_X, { + action: 'USER_SHARED_REFERRAL', + referralCode, + }); + + const text = `Fund breakthrough science with me on @ResearchHub! + +Join with my link and we'll both receive a 10% bonus on every $RSC you donate over the next 6 months ๐Ÿ’ฐ๐Ÿงช + +`; + const url = `https://twitter.com/intent/tweet?url=${encodeURIComponent( + referralLink + )}&text=${encodeURIComponent(text)}`; + window.open(url, '_blank'); + }, [referralCode, referralLink]); + + const shareOnLinkedIn = useCallback(() => { + AnalyticsService.logEvent(LogEvent.CLICKED_SHARE_VIA_LINKEDIN, { + action: 'USER_SHARED_REFERRAL', + referralCode, + }); + + const text = `Fund breakthrough science with me on ResearchHub!\n\nJoin with my link and we'll both receive a 10% bonus on every $RSC you donate over the next 6 months. Let's accelerate science together! ๐Ÿ’ฐ๐Ÿงช\n\n${referralLink}`; + const url = `https://www.linkedin.com/feed/?shareActive=true&text=${encodeURIComponent(text)}`; + window.open(url, '_blank'); + }, [referralCode, referralLink]); + + const shareOnBlueSky = useCallback(() => { + AnalyticsService.logEvent(LogEvent.CLICKED_SHARE_VIA_BLUESKY, { + action: 'USER_SHARED_REFERRAL', + referralCode, + }); + + const text = `Fund breakthrough science with me on ResearchHub! + +Join with my link and we'll both receive a 10% bonus on every $RSC you donate over the next 6 months ๐Ÿ’ฐ๐Ÿงช + +${referralLink}`; + const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`; + window.open(url, '_blank'); + }, [referralCode, referralLink]); + + return { + currentUser, + isLoading, + error, + referralCode, + referralLink, + isCopied, + copyLink, + scrollToShare, + logQrCodeClick, + shareOnX, + shareOnLinkedIn, + shareOnBlueSky, + }; +} diff --git a/app/referral/layout.tsx b/app/referral/layout.tsx new file mode 100644 index 000000000..c72946efb --- /dev/null +++ b/app/referral/layout.tsx @@ -0,0 +1,7 @@ +export default function ReferralLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( +
+
{children}
+
+ ); +} diff --git a/app/referral/page.tsx b/app/referral/page.tsx index e3ea11eac..3bac09002 100644 --- a/app/referral/page.tsx +++ b/app/referral/page.tsx @@ -1,7 +1,14 @@ -import { Metadata } from 'next'; -import { ReferralDashboard } from '@/components/Referral'; -import { PageLayout } from '@/app/layouts/PageLayout'; import { getReferralMetadata } from '@/lib/metadata-helpers'; +import { ReferralReadyGate } from './components/ReferralReadyGate'; +import { ReferralTopBar } from './components/ReferralTopBar'; +import { ReferralHero } from './components/ReferralHero'; +import { ReferralShareCard } from './components/ReferralShareCard'; +import { ReferralHowItWorks } from './components/ReferralHowItWorks'; +import { ReferralImpact } from './components/ReferralImpact'; +import { ReferralNetworkList } from './components/ReferralNetworkList'; +import { ReferralCalculator } from './components/ReferralCalculator'; +import { ReferralFAQ } from './components/ReferralFAQ'; +import { LandingPageFooter } from '@/components/landing/LandingPageFooter'; export const metadata = getReferralMetadata({ url: '/referral', @@ -10,9 +17,17 @@ export const metadata = getReferralMetadata({ const ReferralPage = () => { return ( - - - + + + + + + + + + + + ); }; diff --git a/components/Referral/HowItWorksSection.tsx b/components/Referral/HowItWorksSection.tsx deleted file mode 100644 index 5cb238143..000000000 --- a/components/Referral/HowItWorksSection.tsx +++ /dev/null @@ -1,57 +0,0 @@ -'use client'; - -import Image from 'next/image'; - -export function HowItWorksSection() { - const steps = [ - { - image: '/referral/share_your_link.webp', - title: 'Share Your Link', - description: 'Share your unique referral link with potential funders, big or small', - alt: 'Person sharing referral link from laptop', - }, - { - image: '/referral/user_funds_research.webp', - title: 'User Funds Research', - description: 'Referred user funds a proposal on ResearchHub', - alt: 'Person funding research with flask', - }, - { - image: '/referral/you_both_rewarded.webp', - title: 'You Both Get Rewarded', - description: - 'You both receive 10% of their funded amount in credits to support more research', - alt: 'Two people holding reward coins', - }, - ]; - - return ( -
-

- How It Works -

- -
- {steps.map((step, index) => ( -
-
- {step.alt} -
-

- {step.title} -

-

- {step.description} -

-
- ))} -
-
- ); -} diff --git a/components/Referral/ReferralCalculator.tsx b/components/Referral/ReferralCalculator.tsx deleted file mode 100644 index e36dec683..000000000 --- a/components/Referral/ReferralCalculator.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Calculator } from 'lucide-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); - } - }; - - return ( -
-
-
- -

- Estimate Your Referral Bonus -

-
-
- -
- $ - -
- -
-
-
- -
-

You Receive

-
-

- $ - {referralBonus.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} -

-
-
- -
-

Funder Receives

-
-

- $ - {referralBonus.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} -

-
-
-
-
- ); -} diff --git a/components/Referral/ReferralDashboard.tsx b/components/Referral/ReferralDashboard.tsx deleted file mode 100644 index 272c3ba6f..000000000 --- a/components/Referral/ReferralDashboard.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; - -import { ReferralHeader } from './ReferralHeader'; -import { ReferralLinkCard } from './ReferralLinkCard'; -import { HowItWorksSection } from './HowItWorksSection'; -import { ReferralImpactSection } from './ReferralImpactSection'; -import { ReferredUsersList } from './ReferredUsersList'; -import { ReferralCalculator } from '.'; - -export function ReferralDashboard() { - return ( -
-
- - - - - - - - - - - -
-
- ); -} diff --git a/components/Referral/ReferralHeader.tsx b/components/Referral/ReferralHeader.tsx deleted file mode 100644 index 5c4ab6634..000000000 --- a/components/Referral/ReferralHeader.tsx +++ /dev/null @@ -1,21 +0,0 @@ -'use client'; - -import { UserPlus } from 'lucide-react'; - -export function ReferralHeader() { - return ( -
-
-
- -
-

- Refer a Funder, Accelerate Science -

-

- Earn funding credits by inviting funders to ResearchHub. -

-
-
- ); -} diff --git a/components/Referral/ReferralImpactSection.tsx b/components/Referral/ReferralImpactSection.tsx deleted file mode 100644 index 5fd1678b6..000000000 --- a/components/Referral/ReferralImpactSection.tsx +++ /dev/null @@ -1,145 +0,0 @@ -'use client'; - -import { useRef, useEffect } from 'react'; -import { Users, FlaskConical, List, Plus, AlertCircle } from 'lucide-react'; -import { Button } from '@/components/ui/Button'; -import { Tooltip } from '@/components/ui/Tooltip'; -import { useRouter } from 'next/navigation'; -import { gsap } from 'gsap'; -import { useReferralMetrics } from '@/hooks/useReferral'; -import { ReferralImpactSkeleton } from './ReferralImpactSkeleton'; - -export function ReferralImpactSection() { - const router = useRouter(); - const referredUsersRef = useRef(null); - const amountFundedRef = useRef(null); - const creditsEarnedRef = useRef(null); - - // Use the metrics hook - const { metrics, isLoading, error } = useReferralMetrics(); - - // Transform the metrics data - const displayData = { - referredUsersCount: metrics?.referralActivity.fundersInvited || 0, - amountFundedByReferred: metrics?.networkFundingPower.breakdown.networkFunding || 0, - creditsEarned: metrics?.yourFundingCredits.available || 0, - creditsUsed: metrics?.yourFundingCredits.used || 0, - totalEarned: metrics?.yourFundingCredits.totalEarned || 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(referredUsersEl, displayData.referredUsersCount, false), [], 0.1) - .call(() => animateValue(amountFundedEl, displayData.amountFundedByReferred, true), [], 0.2) - .call(() => animateValue(creditsEarnedEl, displayData.creditsEarned, true), [], 0.3); - }, [isLoading, displayData]); - - // Show skeleton while loading - if (isLoading) { - return ; - } - - // Show error state if there's an error - if (error) { - return ( -
-

Your Referral Impact

-
- -

Error loading metrics

-

{error}

- -
-
- ); - } - - return ( -
-

Your Referral Impact

-
-

- {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="text-sm text-gray-500 hover:text-gray-700 underline inline-block" - > - How can I use this? - -
-
-
- -
-
- -

- {displayData.referredUsersCount} -

-

Users Referred

-
-
- -

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

-

Funded by Your Referrals

-
-
-
- ); -} diff --git a/components/Referral/ReferralImpactSkeleton.tsx b/components/Referral/ReferralImpactSkeleton.tsx deleted file mode 100644 index b9ae52408..000000000 --- a/components/Referral/ReferralImpactSkeleton.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client'; - -import { Users, FlaskConical, CreditCard } from 'lucide-react'; - -export function ReferralImpactSkeleton() { - return ( -
-

Your Referral Impact

- - {/* Credits earned skeleton */} -
-
-
-
-
-
-
- - {/* First row - Funding Credits skeleton */} -
-
- -
-
-
-
- -
-
-
-
- - {/* Second row - Original metrics skeleton */} -
-
- -
-
-
-
- -
-
-
-
-
- ); -} diff --git a/components/Referral/ReferralLinkCard.tsx b/components/Referral/ReferralLinkCard.tsx deleted file mode 100644 index 8defd5acc..000000000 --- a/components/Referral/ReferralLinkCard.tsx +++ /dev/null @@ -1,172 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faXTwitter, faLinkedin, faBluesky } from '@fortawesome/free-brands-svg-icons'; -import { Button } from '@/components/ui/Button'; -import { Copy, Check, QrCode } from 'lucide-react'; -import Image from 'next/image'; -import toast from 'react-hot-toast'; -import { useUser } from '@/contexts/UserContext'; -import { ReferralLinkSkeleton } from './ReferralLinkSkeleton'; -import { QRCodeModal } from './QRCodeModal'; -import AnalyticsService, { LogEvent } from '@/services/analytics.service'; - -export function ReferralLinkCard() { - const [isCopied, setIsCopied] = useState(false); - const [isQrModalOpen, setIsQrModalOpen] = useState(false); - const { user: currentUser, isLoading, error } = useUser(); - - const referralCode = currentUser?.referralCode; - const referralLink = `${process.env.NEXT_PUBLIC_SITE_URL || 'https://researchhub.com'}/referral/join?refr=${referralCode}`; - - const handleCopy = () => { - navigator.clipboard.writeText(referralLink).then( - () => { - setIsCopied(true); - toast.success('Referral link copied to clipboard!'); - setTimeout(() => setIsCopied(false), 2000); - }, - (err) => { - console.error('Failed to copy text: ', err); - toast.error('Failed to copy referral link.'); - } - ); - - AnalyticsService.logEvent(LogEvent.CLICKED_SHARE_VIA_URL, { - action: 'USER_SHARED_REFERRAL', - referralCode, - }); - }; - - const handleQrCodeClick = () => { - AnalyticsService.logEvent(LogEvent.CLICKED_SHARE_VIA_QR_CODE, { - action: 'USER_SHARED_REFERRAL', - referralCode, - }); - setIsQrModalOpen(true); - }; - - const shareOnX = () => { - AnalyticsService.logEvent(LogEvent.CLICKED_SHARE_VIA_X, { - action: 'USER_SHARED_REFERRAL', - referralCode, - }); - - const text = `Fund breakthrough science with me on @ResearchHub! - -Join with my link and we'll both receive a 10% bonus on every $RSC you donate over the next 6 months ๐Ÿ’ฐ๐Ÿงช - -`; - const url = `https://twitter.com/intent/tweet?url=${encodeURIComponent( - referralLink - )}&text=${encodeURIComponent(text)}`; - window.open(url, '_blank'); - }; - - const shareOnLinkedIn = () => { - AnalyticsService.logEvent(LogEvent.CLICKED_SHARE_VIA_LINKEDIN, { - action: 'USER_SHARED_REFERRAL', - referralCode, - }); - - const text = `Fund breakthrough science with me on ResearchHub!\n\nJoin with my link and we'll both receive a 10% bonus on every $RSC you donate over the next 6 months. Let's accelerate science together! ๐Ÿ’ฐ๐Ÿงช\n\n${referralLink}`; - const url = `https://www.linkedin.com/feed/?shareActive=true&text=${encodeURIComponent(text)}`; - window.open(url, '_blank'); - }; - - const shareOnBlueSky = () => { - AnalyticsService.logEvent(LogEvent.CLICKED_SHARE_VIA_BLUESKY, { - action: 'USER_SHARED_REFERRAL', - referralCode, - }); - - const text = `Fund breakthrough science with me on ResearchHub! - -Join with my link and we'll both receive a 10% bonus on every $RSC you donate over the next 6 months ๐Ÿ’ฐ๐Ÿงช - -${referralLink}`; - const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`; - window.open(url, '_blank'); - }; - - // Show skeleton while loading - if (isLoading) { - return ; - } - - // Don't render if user is not authenticated - if (!currentUser) { - return null; - } - - return ( - <> -
-
-

Your Referral Link

-
- -
- - -
-
-
-

Share on:

- - - -
-
-
-
- Science lab illustration -
-
- - setIsQrModalOpen(false)} - referralLink={referralLink} - /> - - ); -} diff --git a/components/Referral/ReferralLinkSkeleton.tsx b/components/Referral/ReferralLinkSkeleton.tsx deleted file mode 100644 index 22ccf862e..000000000 --- a/components/Referral/ReferralLinkSkeleton.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; - -export function ReferralLinkSkeleton() { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ); -} diff --git a/components/Referral/ReferredUsersList.tsx b/components/Referral/ReferredUsersList.tsx deleted file mode 100644 index c844b6d23..000000000 --- a/components/Referral/ReferredUsersList.tsx +++ /dev/null @@ -1,216 +0,0 @@ -'use client'; - -import { Users, ChevronLeft, ChevronRight, Share2, Clock, AlertCircle } from 'lucide-react'; -import { Button } from '@/components/ui/Button'; -import { Avatar } from '@/components/ui/Avatar'; -import { AuthorTooltip } from '../ui/AuthorTooltip'; -import { ReferredUsersSkeleton } from './ReferredUsersSkeleton'; -import { useReferralNetworkDetails } from '@/hooks/useReferral'; -import { Tooltip } from '@/components/ui/Tooltip'; - -const USERS_PER_PAGE = 5; - -// Helper function to get days until expiration -const getDaysUntilExpiration = (expirationDate: string): number => { - const now = new Date(); - const diffTime = new Date(expirationDate).getTime() - now.getTime(); - return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); -}; - -export function ReferredUsersList() { - const { - networkDetails, - isLoading, - error, - currentPage, - totalPages, - hasNextPage, - hasPrevPage, - goToNextPage, - goToPrevPage, - } = useReferralNetworkDetails(USERS_PER_PAGE); - - // Transform the network details to the format expected by the component - const displayUsers = networkDetails.map((user) => { - const daysUntilExpiration = getDaysUntilExpiration(user.referralBonusExpirationDate); - - return { - name: user.fullName, - avatarUrl: user.profileImage, - totalFunded: user.totalFunded, - creditsEarned: user.referralBonusEarned, - dateJoined: new Date(user.signupDate).toLocaleDateString(), - authorId: user.authorId, - isExpired: user.isReferralBonusExpired, - daysUntilExpiration, - expirationDate: new Date(user.referralBonusExpirationDate).toLocaleDateString(), - signupDate: user.signupDate, - }; - }); - - // Show skeleton while loading - if (isLoading) { - return ; - } - - // Show error state if there's an error - if (error) { - return ( -
-

Your Referred Users

-
- -

Error loading users

-

{error}

- -
-
- ); - } - - return ( -
-

Your Referred Users

- - {displayUsers.length === 0 ? ( -
- -

No referred users yet

-

- Share your referral link to start building your network and earning credits. -

- -
- ) : ( - <> -
- {displayUsers.map((user, index) => ( -
- - - -
-
-

- {user.name} -

- {user.isExpired ? ( - -

Referral Expired

-

Benefits expired on {user.expirationDate}

-

No new credits will be earned

-
- } - position="top" - width="w-48" - > - - - ) : user.daysUntilExpiration <= 30 ? ( - -

Expiring Soon

-

- {user.daysUntilExpiration} days until expiration -

-

Expires: {user.expirationDate}

-

Credits will stop after expiration

-
- } - position="top" - width="w-48" - > - - - ) : null} -
-

Joined: {user.dateJoined}

- {!user.isExpired && user.daysUntilExpiration <= 30 && ( -

- Expires in {user.daysUntilExpiration} days -

- )} -
-
-
-

Total Funded

-

- {user.totalFunded.toLocaleString()} RSC -

-
-
-

Credits Earned

-

- {user.creditsEarned.toLocaleString()} RSC -

- {user.isExpired &&

(Final amount)

} -
-
-
- ))} -
- {totalPages > 1 && ( -
- - - Page {currentPage} of {totalPages} - - -
- )} - - )} - - ); -} diff --git a/components/Referral/ReferredUsersSkeleton.tsx b/components/Referral/ReferredUsersSkeleton.tsx deleted file mode 100644 index 0789f3a91..000000000 --- a/components/Referral/ReferredUsersSkeleton.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client'; - -import { Users } from 'lucide-react'; - -export function ReferredUsersSkeleton() { - return ( -
-

Your Referred Users

- -
- {Array.from({ length: 5 }).map((_, index) => ( -
- {/* Avatar skeleton */} -
- - {/* User info skeleton */} -
-
-
-
- - {/* Stats skeleton */} -
-
-
-
-
-
-
-
-
-
-
- ))} -
- - {/* Pagination skeleton */} -
-
-
-
-
-
- ); -} diff --git a/components/Referral/index.ts b/components/Referral/index.ts index 84d243961..081edf733 100644 --- a/components/Referral/index.ts +++ b/components/Referral/index.ts @@ -1,12 +1,2 @@ -export * from './ReferralDashboard'; -export * from './ReferralHeader'; -export * from './ReferralLinkCard'; -export * from './ReferralLinkSkeleton'; -export * from './HowItWorksSection'; -export * from './ReferralImpactSection'; -export * from './ReferralImpactSkeleton'; -export * from './ReferredUsersList'; -export * from './ReferredUsersSkeleton'; export * from './QRCodeModal'; export * from './IllustratedGuide'; -export * from './ReferralCalculator'; From 7dd996231644b70d9de99910bae2e64ceb2ba07e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 17 Jun 2026 02:20:38 +0000 Subject: [PATCH 2/4] Prevent CTA width shift when referral copy button shows Copied! Add a min-width to the hero and top-bar copy buttons so swapping the label to 'Copied!' no longer shrinks the button or nudges the adjacent 'See how it works' CTA. --- app/referral/components/ReferralHero.tsx | 3 +++ app/referral/components/ReferralTopBar.tsx | 3 +++ 2 files changed, 6 insertions(+) diff --git a/app/referral/components/ReferralHero.tsx b/app/referral/components/ReferralHero.tsx index bca4fdc48..4f5c850cb 100644 --- a/app/referral/components/ReferralHero.tsx +++ b/app/referral/components/ReferralHero.tsx @@ -241,6 +241,9 @@ export function ReferralHero() { justify-content: center; gap: 10px; height: 54px; + /* Reserve width for the longest label so the button doesn't shrink + (and shift the neighboring CTA) when it swaps to "Copied!". */ + min-width: 200px; padding: 0 28px; border-radius: 14px; font-size: 16px; diff --git a/app/referral/components/ReferralTopBar.tsx b/app/referral/components/ReferralTopBar.tsx index a02941609..341fef36d 100644 --- a/app/referral/components/ReferralTopBar.tsx +++ b/app/referral/components/ReferralTopBar.tsx @@ -159,8 +159,11 @@ export function ReferralTopBar() { .referral-topbar-cta { display: inline-flex; align-items: center; + justify-content: center; gap: 8px; height: 40px; + /* Hold width so swapping to "Copied!" doesn't resize the button. */ + min-width: 158px; padding: 0 20px; border-radius: 12px; font-size: 14px; From 863646c638f810de9464e8038d7b97fbee992324 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 17 Jun 2026 02:56:03 +0000 Subject: [PATCH 3/4] Give /referral hero its own identity + whole-number calculator Replace the give-style starfield + AnimatedGlobe hero visuals with a referral-specific treatment: a vibrant brand-blue field with a static connection-mesh background (ReferralMesh) and a custom animated referral-network centerpiece (ReferralNetworkGraphic) showing 'you' at the hub branching to the funders you refer, with RSC-orange pulses flowing outward. This keeps the page in the brand family while giving it a distinct hero from give (globe/cosmos) and endowment (tree/light). Also format the calculator's 'You receive' / 'Funder receives' amounts as whole dollars (no cents) to match the contribution input. --- .../components/ReferralCalculator.tsx | 4 +- app/referral/components/ReferralHero.tsx | 58 +--- app/referral/components/ReferralMesh.tsx | 134 ++++++++ .../components/ReferralNetworkGraphic.tsx | 288 ++++++++++++++++++ 4 files changed, 439 insertions(+), 45 deletions(-) create mode 100644 app/referral/components/ReferralMesh.tsx create mode 100644 app/referral/components/ReferralNetworkGraphic.tsx diff --git a/app/referral/components/ReferralCalculator.tsx b/app/referral/components/ReferralCalculator.tsx index 010e6fe95..5f6bf71f0 100644 --- a/app/referral/components/ReferralCalculator.tsx +++ b/app/referral/components/ReferralCalculator.tsx @@ -24,9 +24,9 @@ export function ReferralCalculator() { } }; + // Whole-number formatting to match the "contributes" input above (no cents). const formattedBonus = referralBonus.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, + maximumFractionDigits: 0, }); // Slider fill: percentage of the 0โ€“50k track that is "active", used to paint diff --git a/app/referral/components/ReferralHero.tsx b/app/referral/components/ReferralHero.tsx index 4f5c850cb..d2602c640 100644 --- a/app/referral/components/ReferralHero.tsx +++ b/app/referral/components/ReferralHero.tsx @@ -1,42 +1,15 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { Check, Copy, Users } from 'lucide-react'; import { Logo } from '@/components/ui/Logo'; -import AnimatedGlobe from '@/components/Globe/AnimatedGlobe'; -import { Starfield } from '@/app/give/components/Starfield'; +import { ReferralMesh } from './ReferralMesh'; +import { ReferralNetworkGraphic } from './ReferralNetworkGraphic'; import { useReferralShare } from './useReferralShare'; export function ReferralHero() { const { copyLink, isCopied } = useReferralShare(); - // The AnimatedGlobe canvas is sized in px, so we measure its column and clamp - // to keep it crisp on desktop while never overflowing on small screens. - const globeWrapRef = useRef(null); - const [globeSize, setGlobeSize] = useState(460); - - useEffect(() => { - const el = globeWrapRef.current; - if (!el) return; - const measure = () => { - const w = el.getBoundingClientRect().width; - setGlobeSize(Math.round(Math.max(260, Math.min(480, w)))); - }; - measure(); - let ro: ResizeObserver | null = null; - if (window.ResizeObserver) { - ro = new ResizeObserver(measure); - ro.observe(el); - } else { - window.addEventListener('resize', measure); - } - return () => { - if (ro) ro.disconnect(); - else window.removeEventListener('resize', measure); - }; - }, []); - const handleSeeHowItWorks = () => { const target = document.getElementById('referral-how'); if (!target) return; @@ -51,7 +24,7 @@ export function ReferralHero() { return (
- + -
- +
+
@@ -124,12 +97,14 @@ export function ReferralHero() { position: relative; padding: 160px 28px 120px; overflow: hidden; - /* Deep-space cosmos: an indigo nebula glow near the globe falling off - into near-black, so the starfield and the glowing globe read. */ + /* A vibrant brand-blue field (its own identity vs. the give cosmos and + the endowment light hero) that brightens around the network graphic + and settles to a deep indigo at the bottom so it meets the share + section's pixel-fade seam cleanly. */ background: - radial-gradient(circle at 72% 40%, rgba(67, 56, 202, 0.42), transparent 55%), - radial-gradient(circle at 28% 82%, rgba(49, 46, 129, 0.3), transparent 60%), - linear-gradient(168deg, #0b1238 0%, #101a45 55%, #1a235e 100%); + radial-gradient(circle at 74% 40%, rgba(57, 113, 255, 0.5), transparent 52%), + radial-gradient(circle at 16% 84%, rgba(37, 99, 235, 0.32), transparent 58%), + linear-gradient(168deg, #14379b 0%, #163291 46%, #142a78 74%, #1a235e 100%); color: #e2e8f0; min-height: min(calc(100vh - 60px), 905px); display: flex; @@ -157,15 +132,12 @@ export function ReferralHero() { gap: 64px; align-items: center; } - .referral-hero-globe { + .referral-hero-graphic { display: flex; align-items: center; justify-content: center; width: 100%; } - .referral-hero-globe :global(.referral-hero-globe-canvas) { - max-width: 100%; - } .referral-hero-blob { position: absolute; width: 620px; @@ -308,9 +280,9 @@ export function ReferralHero() { grid-template-columns: 1fr; gap: 32px; } - .referral-hero-globe { + .referral-hero-graphic { order: -1; - max-width: 420px; + max-width: 380px; margin: 0 auto; } .referral-hero-h1 { 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 ( + <> +

Refer a funder, - accelerate science. + get rewarded.

Invite funders to ResearchHub and you both earn 10% in Funding Credits on everything - they contribute over the next 6 months. + they contribute over the next 6 months โ€” credits you put toward the research you believe + in.