From 7d470fcdc557278af16a706c3290a6a1100d4f7f Mon Sep 17 00:00:00 2001 From: AugistineCreates Date: Wed, 27 May 2026 01:46:07 +0100 Subject: [PATCH 1/5] feat: display earned Scholar NFTs on learner profile page directly from contract --- src/hooks/useScholarNft.ts | 67 +++++++++++++++++++++++ src/index.css | 19 +++++++ src/pages/Profile.tsx | 106 ++++++++++++++++++------------------- 3 files changed, 139 insertions(+), 53 deletions(-) diff --git a/src/hooks/useScholarNft.ts b/src/hooks/useScholarNft.ts index 0f9fd18d..0e1f99ce 100644 --- a/src/hooks/useScholarNft.ts +++ b/src/hooks/useScholarNft.ts @@ -179,6 +179,18 @@ async function queryGetMetadata( } } +async function queryAllScholars(contractId: string): Promise { + try { + const { scValToNative } = await import("@stellar/stellar-sdk") + const retval = await simulateCall(contractId, "get_all_scholars") + if (!retval) return [] + return scValToNative(retval) as string[] + } catch (e) { + console.error("Error simulating get_all_scholars call:", e) + return [] + } +} + // --------------------------------------------------------------------------- // IPFS metadata fetch // --------------------------------------------------------------------------- @@ -373,3 +385,58 @@ export function useScholarNft( return { credential: data!, status: "success", error: null } } + +export interface UseUserScholarNftsResult { + credentials: CredentialData[] + isLoading: boolean + error: string | null + refetch: () => void +} + +export function useUserScholarNfts( + walletAddress: string | undefined, +): UseUserScholarNftsResult { + const contractId = CONTRACT_IDS.scholarNft + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["user-scholar-nfts", walletAddress], + queryFn: async () => { + if (!walletAddress || !contractId) return [] + + // 1. Query all scholar addresses from contract + const scholars = await queryAllScholars(contractId) + + // 2. Filter to find the 1-based token IDs belonging to this wallet + const tokenIds: number[] = [] + scholars.forEach((addr, index) => { + if (addr === walletAddress) { + tokenIds.push(index + 1) + } + }) + + if (tokenIds.length === 0) return [] + + // 3. Fetch metadata/IPFS details in parallel for each owned token ID + const fetchPromises = tokenIds.map(async (id) => { + try { + return await fetchCredentialData(id.toString()) + } catch (err) { + console.error(`Failed to fetch credential data for token ID ${id}`, err) + return null + } + }) + + const results = await Promise.all(fetchPromises) + return results.filter((item): item is CredentialData => item !== null) + }, + enabled: Boolean(walletAddress && contractId), + staleTime: 60_000, + retry: false, + }) + + return { + credentials: data ?? [], + isLoading, + error: error ? (error as Error).message || "Failed to fetch user Scholar NFTs" : null, + refetch, + } +} diff --git a/src/index.css b/src/index.css index e4471532..1c3d47d9 100644 --- a/src/index.css +++ b/src/index.css @@ -330,3 +330,22 @@ body { .light ::-webkit-scrollbar-thumb:hover { background: rgba(0, 150, 210, 0.4); } + +/* ── Newly Earned NFT Glow Animation ─────────────────────────────────────── */ +@keyframes newNftGlow { + 0%, + 100% { + box-shadow: 0 0 15px rgba(0, 210, 255, 0.35), inset 0 0 10px rgba(0, 210, 255, 0.15); + border-color: rgba(0, 210, 255, 0.6); + } + 50% { + box-shadow: 0 0 30px rgba(142, 45, 226, 0.75), inset 0 0 15px rgba(142, 45, 226, 0.3); + border-color: rgba(142, 45, 226, 0.9); + } +} + +.new-nft-card { + animation: newNftGlow 3s infinite alternate ease-in-out; + border: 2px solid transparent !important; +} + diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 6c76104e..d80e1c38 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useEffect, useState } from "react" +import React, { useContext, useEffect, useState } from "react" import { Helmet } from "react-helmet" import { useTranslation } from "react-i18next" import { Link } from "react-router-dom" @@ -11,10 +11,11 @@ import { ProfileSkeleton, } from "../components/SkeletonLoader" import { ErrorState } from "../components/states/errorState" -import { useScholarCredentials } from "../hooks/useScholarCredentials" +import { useUserScholarNfts } from "../hooks/useScholarNft" import { WalletContext } from "../providers/WalletProvider" import { formatDuration, getLearningTimeSummary } from "../util/learningTime" import { shortenAddress } from "../util/scholarshipApplications" +import confetti from "canvas-confetti" type UserNft = { id: string @@ -22,67 +23,63 @@ type UserNft = { program: string date: string artwork?: string + isNew?: boolean } const Profile: React.FC = () => { const { t } = useTranslation() const { address: walletAddress } = useContext(WalletContext) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) + const { credentials, isLoading, error, refetch } = useUserScholarNfts(walletAddress) const [nfts, setNfts] = useState([]) const [learningTimeLabel, setLearningTimeLabel] = useState("0m") - const fetchCredentials = useCallback(async () => { - if (!walletAddress) { - setNfts([]) - setIsLoading(false) - return - } - - try { - setIsLoading(true) - setError(null) + useEffect(() => { + if (credentials.length > 0) { + const now = Math.floor(Date.now() / 1000) + const seenNftsStr = localStorage.getItem("learnvault_seen_nfts") + let seenIds: string[] = [] + if (seenNftsStr) { + try { + seenIds = JSON.parse(seenNftsStr) as string[] + } catch {} + } - const response = await fetch(`/api/credentials/${walletAddress}`, { - method: "GET", + let hasNewNft = false + const mapped = credentials.map((cred) => { + const isMintedRecently = cred.issuedAt ? (now - cred.issuedAt < 600) : false + const isUnseen = seenIds.length > 0 && !seenIds.includes(cred.id) + const isNew = isMintedRecently || isUnseen + + if (isNew) { + hasNewNft = true + } + + return { + id: cred.id, + program: cred.programName, + date: cred.completionDate, + artwork: cred.artworkUrl || undefined, + isNew, + } }) - if (!response.ok) { - const payload = await response.json().catch(() => ({})) - throw new Error( - payload.message || payload.error || "Unable to load credentials", - ) + setNfts(mapped) + + if (hasNewNft) { + confetti({ + particleCount: 150, + spread: 80, + origin: { y: 0.6 }, + colors: ["#00d2ff", "#8e2de2", "#00ff80", "#3a7bd5"], + }) } - const data = await response.json() - setNfts( - Array.isArray(data.data) - ? data.data.map((item: any) => ({ - id: String(item.token_id ?? item.course_id ?? Math.random()), - course_id: item.course_id, - program: item.course_id ?? "Unknown course", - date: item.minted_at - ? new Date(item.minted_at).toLocaleDateString() - : "Unknown", - artwork: item.metadata_uri - ? `https://gateway.pinata.cloud/ipfs/${item.metadata_uri.replace("ipfs://", "")}` - : undefined, - })) - : [], - ) - } catch (err) { - console.error("[profile] error loading credentials", err) - setError( - err instanceof Error ? err.message : "Failed to load credentials", - ) - } finally { - setIsLoading(false) + const allIds = credentials.map((c) => c.id) + localStorage.setItem("learnvault_seen_nfts", JSON.stringify(allIds)) + } else { + setNfts([]) } - }, [walletAddress]) - - useEffect(() => { - void fetchCredentials() - }, [fetchCredentials]) + }, [credentials]) useEffect(() => { const summary = getLearningTimeSummary() @@ -111,7 +108,7 @@ const Profile: React.FC = () => { if (error) { return (
- +
) } @@ -182,9 +179,12 @@ const Profile: React.FC = () => { ) : (
{nfts.map((nft, index) => ( -
@@ -225,7 +225,7 @@ const Profile: React.FC = () => {
-
+ ))} )} From 109bd1f2132020b639660b2d9f28bbbac8ee7af9 Mon Sep 17 00:00:00 2001 From: AugistineCreates Date: Thu, 28 May 2026 10:52:03 +0100 Subject: [PATCH 2/5] feat: design advanced FAQ and help center portal --- src/App.tsx | 2 + src/components/FAQSection.test.tsx | 101 +++++++ src/components/FAQSection.tsx | 460 +++++++++++++++++++++++++++++ src/pages/FAQPage.tsx | 36 +++ 4 files changed, 599 insertions(+) create mode 100644 src/components/FAQSection.test.tsx create mode 100644 src/components/FAQSection.tsx create mode 100644 src/pages/FAQPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 9c742815..2fb42d39 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ const ScholarshipApply = lazy(() => import("./pages/ScholarshipApply")) const Treasury = lazy(() => import("./pages/Treasury")) const Wiki = lazy(() => import("./pages/Wiki")) const WikiPage = lazy(() => import("./pages/WikiPage")) +const FAQPage = lazy(() => import("./pages/FAQPage")) const renderRoute = (element: ReactNode) => ( @@ -69,6 +70,7 @@ function App() { )} /> )} /> )} /> + )} /> )} /> )} /> { + it("renders the help center header correctly", () => { + render() + + expect(screen.getByText("Help Center")).toBeInTheDocument() + expect(screen.getByText(/Got questions\? We've got/i)).toBeInTheDocument() + expect( + screen.getByPlaceholderText(/Search by keywords/i), + ).toBeInTheDocument() + }) + + it("renders the list of FAQs", () => { + render() + + // Check some of our questions exist + expect( + screen.getByText("What is Soroban and how does it integrate with LearnVault?"), + ).toBeInTheDocument() + expect( + screen.getByText("Which Web3 wallets are supported on LearnVault?"), + ).toBeInTheDocument() + }) + + it("can filter FAQ items by search keywords", () => { + render() + + const searchInput = screen.getByPlaceholderText(/Search by keywords/i) + + // Search for "freighter" (Web3 wallets tag/word) + fireEvent.change(searchInput, { target: { value: "freighter" } }) + + // The freighter question should remain + expect( + screen.getByText("Which Web3 wallets are supported on LearnVault?"), + ).toBeInTheDocument() + + // Soroban question should NOT be visible + expect( + screen.queryByText( + "What is Soroban and how does it integrate with LearnVault?", + ), + ).not.toBeInTheDocument() + }) + + it("can filter FAQ items by category tabs", () => { + render() + + // Click on "Soroban" tab + const sorobanTab = screen.getByRole("button", { name: /Soroban/i }) + fireEvent.click(sorobanTab) + + // Soroban question should be there + expect( + screen.getByText("What is Soroban and how does it integrate with LearnVault?"), + ).toBeInTheDocument() + + // Web3 Wallets question should NOT be there + expect( + screen.queryByText("Which Web3 wallets are supported on LearnVault?"), + ).not.toBeInTheDocument() + }) + + it("expands and collapses accordions on click", async () => { + render() + + const questionText = + "What is Soroban and how does it integrate with LearnVault?" + const questionButton = screen.getByRole("button", { name: new RegExp(questionText, "i") }) + + // Answer shouldn't be visible initially (not rendered or height 0 inside accordion) + expect(screen.queryByText(/native, high-performance smart contract platform/i)).not.toBeInTheDocument() + + // Click to expand + fireEvent.click(questionButton) + + // Answer should now be visible in the DOM + expect(await screen.findByText(/native, high-performance smart contract platform/i)).toBeInTheDocument() + }) + + it("can submit helpful feedback on individual FAQ items", async () => { + render() + + // First, expand the accordion to make the helpfulness buttons visible + const questionText = + "What is Soroban and how does it integrate with LearnVault?" + const questionButton = screen.getByRole("button", { name: new RegExp(questionText, "i") }) + fireEvent.click(questionButton) + + // Click Thumbs Up button + const thumbsUpButton = await screen.findByRole("button", { name: /yes/i }) + fireEvent.click(thumbsUpButton) + + // The thank you message should show up + expect(await screen.findByText("Thank you!")).toBeInTheDocument() + }) +}) diff --git a/src/components/FAQSection.tsx b/src/components/FAQSection.tsx new file mode 100644 index 00000000..8203636e --- /dev/null +++ b/src/components/FAQSection.tsx @@ -0,0 +1,460 @@ +import { AnimatePresence, motion } from "framer-motion" +import { + ArrowRight, + Cpu, + HelpCircle, + Lock, + Search, + Sparkles, + ThumbsDown, + ThumbsUp, + Wallet, + X, +} from "lucide-react" +import React, { useMemo, useState } from "react" + +interface FAQItem { + id: string + question: string + answer: string + category: "Soroban" | "Web3 Wallets" | "File Encryption" + tags: string[] +} + +const FAQ_ITEMS: FAQItem[] = [ + { + id: "soroban-1", + category: "Soroban", + question: "What is Soroban and how does it integrate with LearnVault?", + answer: "Soroban is Stellar's native, high-performance smart contract platform built with Rust. It provides predictable fees, state archiving, and full WASM execution. LearnVault leverages Soroban smart contracts to automate reputation tracking (our LRN token) and manage decentralized treasury distributions (e.g. scholarship disbursements) directly on-chain when learning milestones are successfully verified.", + tags: ["stellar", "soroban", "rust", "wasm", "smart contracts"], + }, + { + id: "soroban-2", + category: "Soroban", + question: "What are the gas/network fees for executing transactions on Soroban?", + answer: "Stellar and Soroban are designed to be extremely low-cost and efficient. While traditional networks suffer from unpredictable, high gas surges, a typical Soroban contract invocation costs a small fraction of a cent (paid in XLM). LearnVault also implements gas optimization patterns in our contracts so that scholars and contributors face minimal friction during active learning sprint interactions.", + tags: ["fees", "gas", "xlm", "transactions", "costs"], + }, + { + id: "soroban-3", + category: "Soroban", + question: "How do I deploy or test Soroban contracts locally?", + answer: "To test Soroban contracts locally, you can use the `stellar-cli` tool. First, ensure you have Rust and Cargo installed, then run `cargo install --locked stellar-cli`. You can start a local node with `stellar network start container` or spin up a local development network. The contracts are located inside the `/contracts` directory of our repository, where you can execute `cargo test` to run native unit tests.", + tags: ["deploy", "cli", "testing", "rust", "local dev"], + }, + { + id: "wallet-1", + category: "Web3 Wallets", + question: "Which Web3 wallets are supported on LearnVault?", + answer: "LearnVault integrates with `@creit.tech/stellar-wallets-kit`, enabling unified support for all major Stellar web3 wallets. This includes Freighter (the standard browser extension), Albedo, Rovo, Hana Wallet, and Lobstr. You can easily select your preferred wallet by clicking the 'Connect Wallet' button in the navigation bar.", + tags: ["wallet", "freighter", "albedo", "rovo", "connect"], + }, + { + id: "wallet-2", + category: "Web3 Wallets", + question: "Why does the wallet ask me to sign a transaction, and is it safe?", + answer: "In Web3, signing a transaction or message is how you cryptographically prove ownership of your stellar address without sharing your private key. When you enroll in a course, submit a milestone, or vote in the DAO, LearnVault submits a transaction payload to your wallet extension. Your extension prompts you to sign it securely. LearnVault never has access to your private key or seed phrase.", + tags: ["signing", "security", "private key", "transactions", "safety"], + }, + { + id: "wallet-3", + category: "Web3 Wallets", + question: "What is a Trustline and why is it required for LRN and USDC tokens?", + answer: "The Stellar network utilizes 'trustlines' to prevent spam assets from being deposited into accounts. A trustline is an explicit opt-in that tells the Stellar ledger your account is willing to hold a specific asset issued by a specific account. To receive LearnVault's native LRN reputation tokens or USDC milestone payouts, you must execute a quick 'Establish Trustline' transaction via your connected wallet.", + tags: ["trustline", "lrn", "usdc", "stellar asset", "spam"], + }, + { + id: "encrypt-1", + category: "File Encryption", + question: "How does LearnVault securely encrypt my milestone submissions?", + answer: "Your privacy is a core pillar of LearnVault. When you submit project files, code screenshots, or milestone answers, they are encrypted client-side in your browser using strong AES-GCM-256 symmetric encryption. The encrypted ciphertext is then uploaded to IPFS (InterPlanetary File System). The corresponding decryption key is never shared on public networks or stored on centralized databases.", + tags: ["aes", "ipfs", "encryption", "milestone", "security"], + }, + { + id: "encrypt-2", + category: "File Encryption", + question: "Who has the authority to decrypt and review my submitted coursework?", + answer: "Only the designated DAO sponsors or authorized peer reviewers who are cryptographically assigned to your scholarship milestone have the access rights to request decryption. When a reviewer opens your submission, their wallet validates their status using our Soroban governance contract, derives the secure key exchange payload, and decrypts the homework files dynamically in their secure browser sandbox.", + tags: ["reviewers", "decryption", "permissions", "dao", "cryptography"], + }, + { + id: "encrypt-3", + category: "File Encryption", + question: "What happens if I lose my Web3 wallet or private keys?", + answer: "Because LearnVault is a fully decentralized, non-custodial protocol, we do not store, manage, or have recovery options for your wallet credentials. Your private key and secret seed phrase are the sole access points to your on-chain reputation, LRN tokens, and encrypted files. We highly recommend using hardware wallets or secure, offline paper backups for your seed phrase.", + tags: ["recovery", "non-custodial", "private key", "loss", "seed phrase"], + }, +] + +const escapeRegExp = (string: string) => { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +export function FAQSection() { + const [searchQuery, setSearchQuery] = useState("") + const [activeCategory, setActiveCategory] = useState("All") + const [expandedItems, setExpandedItems] = useState>({}) + const [helpfulFeedback, setHelpfulFeedback] = useState>({}) + const [feedbackAnimation, setFeedbackAnimation] = useState(null) + + const categories = ["All", "Soroban", "Web3 Wallets", "File Encryption"] + + const filteredFAQ = useMemo(() => { + return FAQ_ITEMS.filter((item) => { + const matchesCategory = + activeCategory === "All" || item.category === activeCategory + + const cleanQuery = searchQuery.trim().toLowerCase() + if (!cleanQuery) return matchesCategory + + const matchesSearch = + item.question.toLowerCase().includes(cleanQuery) || + item.answer.toLowerCase().includes(cleanQuery) || + item.tags.some((tag) => tag.toLowerCase().includes(cleanQuery)) + + return matchesCategory && matchesSearch + }) + }, [activeCategory, searchQuery]) + + const toggleItem = (id: string) => { + setExpandedItems((prev) => ({ + ...prev, + [id]: !prev[id], + })) + } + + const handleHelpful = (id: string, type: "yes" | "no") => { + if (helpfulFeedback[id] === type) return // already selected + + setHelpfulFeedback((prev) => ({ + ...prev, + [id]: type, + })) + + setFeedbackAnimation(id) + setTimeout(() => setFeedbackAnimation(null), 1000) + } + + const getCategoryIcon = (category: string) => { + switch (category) { + case "Soroban": + return + case "Web3 Wallets": + return + case "File Encryption": + return + default: + return + } + } + + const highlightText = (text: string, search: string) => { + if (!search.trim()) return text + const regex = new RegExp(`(${escapeRegExp(search)})`, "gi") + const parts = text.split(regex) + return parts.map((part, index) => + regex.test(part) ? ( + + {part} + + ) : ( + part + ), + ) + } + + return ( +
+ {/* Header */} +
+
+ + + Help Center + +
+

+ Got questions? We've got answers. +

+

+ Find quick answers about Soroban contracts, Web3 credentials, and + highly secure client-side file encryption. +

+
+ + {/* Search and Filter Section */} +
+ {/* Background ambient glow */} +
+
+ +
+ {/* Search input */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search by keywords, tags (e.g. freighter, aes, rust)..." + className="w-full pl-12 pr-10 py-3.5 bg-white/5 border border-white/8 rounded-2xl text-white placeholder-white/30 focus:outline-hidden focus:border-brand-cyan/50 focus:ring-1 focus:ring-brand-cyan/30 focus:bg-white/8 transition-all" + /> + {searchQuery && ( + + )} +
+ + {/* Category filter tabs */} +
+ {categories.map((category) => { + const isActive = activeCategory === category + return ( + + ) + })} +
+
+
+ + {/* FAQ Accordion List */} +
+ + {filteredFAQ.length > 0 ? ( + filteredFAQ.map((item, index) => { + const isOpen = !!expandedItems[item.id] + const hasFeedback = helpfulFeedback[item.id] + const isAnimating = feedbackAnimation === item.id + + return ( + + {/* Accordion Header */} + + + {/* Accordion Content */} + + {isOpen && ( + +
+

+ {highlightText(item.answer, searchQuery)} +

+ + {/* Tags */} +
+ {item.tags.map((tag) => ( + + #{tag} + + ))} +
+ + {/* Helpfulness feedback rating */} +
+ Was this answer helpful? +
+ + + + {isAnimating && ( + + Thank you! + + )} +
+
+
+
+ )} +
+
+ ) + }) + ) : ( + +
+ +
+
+

+ No matches found +

+

+ We couldn't find any FAQs matching "{searchQuery}". Try using + different keywords or clearing filters. +

+
+ +
+ )} +
+
+ + {/* Contact Support Footer Card */} +
+ {/* Soft mesh background */} +
+ +
+

+ Still have questions? +

+

+ Our community of developers and instructors is active 24/7. Join the + LearnVault Discord server to get help with Stellar, Soroban, and homework encryption. +

+
+ + + Join Discord + +
+
+ ) +} diff --git a/src/pages/FAQPage.tsx b/src/pages/FAQPage.tsx new file mode 100644 index 00000000..ccef4fc5 --- /dev/null +++ b/src/pages/FAQPage.tsx @@ -0,0 +1,36 @@ +import React from "react" +import { Helmet } from "react-helmet" +import { FAQSection } from "../components/FAQSection" + +const FAQPage: React.FC = () => { + const siteUrl = "https://learnvault.app" + const title = "Help Center & FAQ — LearnVault Docs" + const description = + "Find answers to frequently asked questions about Soroban smart contracts, Web3 Stellar wallets, and IPFS client-side file encryption." + + return ( + <> + + {title} + + + + + + + + + {/* Background ambient mesh glows */} +
+
+
+
+ +
+ +
+ + ) +} + +export default FAQPage From 4119b7d8c19e2f61e9c7fdca6f63fa4e32109be3 Mon Sep 17 00:00:00 2001 From: AugistineCreates Date: Thu, 28 May 2026 10:56:57 +0100 Subject: [PATCH 3/5] feat: design interactive earnings and payout analytics charts --- .../donor/EarningsAnalytics.test.tsx | 76 ++++++ src/components/donor/EarningsAnalytics.tsx | 253 ++++++++++++++++++ src/hooks/useEarningsAnalytics.ts | 135 ++++++++++ src/pages/Donor.tsx | 3 + 4 files changed, 467 insertions(+) create mode 100644 src/components/donor/EarningsAnalytics.test.tsx create mode 100644 src/components/donor/EarningsAnalytics.tsx create mode 100644 src/hooks/useEarningsAnalytics.ts diff --git a/src/components/donor/EarningsAnalytics.test.tsx b/src/components/donor/EarningsAnalytics.test.tsx new file mode 100644 index 00000000..718bfeb2 --- /dev/null +++ b/src/components/donor/EarningsAnalytics.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { render } from "../../test/setup" +import { EarningsAnalytics } from "./EarningsAnalytics" + +// Mock recharts components to prevent layout engine (jsdom) measurement issues +vi.mock("recharts", () => { + return { + ResponsiveContainer: ({ children }: any) => ( +
{children}
+ ), + ComposedChart: ({ children, data }: any) => ( +
+ {children} +
+ ), + Area: () =>
, + Line: () =>
, + XAxis: () =>
, + YAxis: () =>
, + CartesianGrid: () =>
, + Tooltip: () =>
, + Legend: () =>
, + } +}) + +describe("EarningsAnalytics Component", () => { + it("renders the analytics header and title", () => { + render() + + expect(screen.getByText("Earnings & Payout Timelines")).toBeInTheDocument() + expect( + screen.getByText(/Track the historical USDC volume, gas overhead/i), + ).toBeInTheDocument() + }) + + it("renders summary statistics cards with correct defaults", () => { + render() + + expect(screen.getByText("Total Volume (USDC)")).toBeInTheDocument() + expect(screen.getByText("Net Royalties")).toBeInTheDocument() + expect(screen.getByText("Gas Overhead (XLM)")).toBeInTheDocument() + + // Verify volume value matches the calculated 30d default mock values + // 30 days yields consistent deterministic calculations + expect(screen.getByText(/\$/)).toBeInTheDocument() + }) + + it("renders the chart container and default Recharts mock points", () => { + render() + + const composedChart = screen.getByTestId("composed-chart") + expect(composedChart).toBeInTheDocument() + + // 30d default should render 30 data points + expect(composedChart.getAttribute("data-points")).toBe("30") + }) + + it("allows switching time intervals to 7 Days or Year-to-Date", async () => { + render() + + const sevenDaysTab = screen.getByRole("button", { name: /7 Days/i }) + const ytdTab = screen.getByRole("button", { name: /Year-To-Date/i }) + + // Click 7 Days + fireEvent.click(sevenDaysTab) + + // Wait briefly for interval load + const composedChart = screen.getByTestId("composed-chart") + expect(composedChart.getAttribute("data-points")).toBe("7") + + // Click YTD + fireEvent.click(ytdTab) + expect(composedChart.getAttribute("data-points")).not.toBe("7") // should transition to YTD months count + }) +}) diff --git a/src/components/donor/EarningsAnalytics.tsx b/src/components/donor/EarningsAnalytics.tsx new file mode 100644 index 00000000..0bedd5a2 --- /dev/null +++ b/src/components/donor/EarningsAnalytics.tsx @@ -0,0 +1,253 @@ +import { + Area, + CartesianGrid, + ComposedChart, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts" +import { useEarningsAnalytics } from "../../hooks/useEarningsAnalytics" +import { TrendingUp, Award, Zap, BarChart3, HelpCircle } from "lucide-react" + +export function EarningsAnalytics() { + const { data, summary, interval, setInterval, isLoading } = useEarningsAnalytics() + + const formatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }) + + const formatDecimal = (num: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, + }).format(num) + } + + return ( +
+ {/* Ambient glows inside card */} +
+
+ +
+ {/* Top Header & Selector */} +
+
+
+ + + Analytics + +
+

+ Earnings & Payout Timelines +

+

+ Track the historical USDC volume, gas overhead, and net educator/scholar royalties. +

+
+ + {/* Time interval selectors */} +
+ {(["7d", "30d", "ytd"] as const).map((opt) => { + const labels = { "7d": "7 Days", "30d": "30 Days", "ytd": "Year-To-Date" } + const isActive = interval === opt + return ( + + ) + })} +
+
+ + {/* Summary Metrics Row */} +
+
+
+ +
+
+

+ Total Volume (USDC) +

+

+ {formatter.format(summary.totalVolume)} +

+
+
+ +
+
+ +
+
+

+ Net Royalties +

+

+ {formatter.format(summary.totalNetRoyalties)} +

+
+
+ +
+
+ +
+
+

+ Gas Overhead (XLM) +

+

+ {formatDecimal(summary.totalGasCosts)} +

+
+
+
+ + {/* Chart Plot Visualizer */} +
+ {isLoading && ( +
+
+ Loading timelines... +
+
+ )} + + + + + + + + + + + + + + + + `$${value}`} + /> + } /> + + + + + + +
+
+
+ ) +} + +function CustomTooltip({ active, payload, label }: any) { + if (!active || !payload || !payload.length) return null + + return ( +
+

+ Timeline: {label} +

+
+ {payload.map((entry: any) => { + const labelColor = entry.name === "Volume" + ? "text-brand-cyan" + : entry.name === "Net Royalties" + ? "text-brand-emerald" + : "text-brand-purple" + + return ( +
+ + + {entry.name} + + + ${entry.value.toLocaleString(undefined, { minimumFractionDigits: 2 })} + +
+ ) + })} +
+ {payload[0] && payload[0].payload.transactions && ( +
+ TRANSACTIONS + + {payload[0].payload.transactions} operations + +
+ )} +
+ ) +} diff --git a/src/hooks/useEarningsAnalytics.ts b/src/hooks/useEarningsAnalytics.ts new file mode 100644 index 00000000..fb0073ee --- /dev/null +++ b/src/hooks/useEarningsAnalytics.ts @@ -0,0 +1,135 @@ +import { useMemo, useState } from "react" + +export type ChartInterval = "7d" | "30d" | "ytd" + +export interface AnalyticsDataPoint { + date: string + volume: number // total USDC contributed + gasCosts: number // USD equivalent of Soroban XLM fees + netRoyalties: number // volume - gasCosts + transactions: number // number of events +} + +export interface AnalyticsSummary { + totalVolume: number + totalGasCosts: number + totalNetRoyalties: number + avgTransactionValue: number + transactionCount: number +} + +export function useEarningsAnalytics() { + const [interval, setInterval] = useState("30d") + const [isLoading, setIsLoading] = useState(false) + + const data = useMemo(() => { + const today = new Date() + const result: AnalyticsDataPoint[] = [] + + if (interval === "7d") { + // Generate 7 days of daily data + const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + for (let i = 6; i >= 0; i--) { + const d = new Date() + d.setDate(today.getDate() - i) + const dayName = weekdays[d.getDay()] + const dateString = `${dayName} ${d.getDate()}` + + // Seed-based random values to keep them consistent + const seed = d.getDate() + const volume = Math.floor(1000 + (seed * 117) % 3500) + const gasCosts = parseFloat((0.15 + (seed * 13) % 0.85).toFixed(2)) + const netRoyalties = parseFloat((volume - gasCosts).toFixed(2)) + const transactions = Math.floor(5 + (seed * 3) % 15) + + result.push({ + date: dateString, + volume, + gasCosts, + netRoyalties, + transactions, + }) + } + } else if (interval === "30d") { + // Generate 30 days of daily data + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + for (let i = 29; i >= 0; i--) { + const d = new Date() + d.setDate(today.getDate() - i) + const dateString = `${months[d.getMonth()]} ${d.getDate()}` + + const seed = d.getDate() + d.getMonth() * 30 + const volume = Math.floor(1500 + (seed * 163) % 4500) + const gasCosts = parseFloat((0.2 + (seed * 27) % 1.1).toFixed(2)) + const netRoyalties = parseFloat((volume - gasCosts).toFixed(2)) + const transactions = Math.floor(8 + (seed * 7) % 20) + + result.push({ + date: dateString, + volume, + gasCosts, + netRoyalties, + transactions, + }) + } + } else { + // Year-to-Date (YTD) - Monthly data + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + const currentMonth = today.getMonth() + + for (let i = 0; i <= currentMonth; i++) { + const dateString = months[i] + const seed = i + 1 + const volume = Math.floor(35000 + (seed * 13451) % 65000) + const gasCosts = parseFloat((12.5 + (seed * 153) % 48.5).toFixed(2)) + const netRoyalties = parseFloat((volume - gasCosts).toFixed(2)) + const transactions = Math.floor(120 + (seed * 43) % 250) + + result.push({ + date: dateString, + volume, + gasCosts, + netRoyalties, + transactions, + }) + } + } + + return result + }, [interval]) + + const summary = useMemo(() => { + const totalVolume = data.reduce((acc, curr) => acc + curr.volume, 0) + const totalGasCosts = data.reduce((acc, curr) => acc + curr.gasCosts, 0) + const totalNetRoyalties = data.reduce((acc, curr) => acc + curr.netRoyalties, 0) + const transactionCount = data.reduce((acc, curr) => acc + curr.transactions, 0) + const avgTransactionValue = transactionCount > 0 + ? parseFloat((totalVolume / transactionCount).toFixed(2)) + : 0 + + return { + totalVolume, + totalGasCosts, + totalNetRoyalties, + avgTransactionValue, + transactionCount, + } + }, [data]) + + const changeInterval = (newInterval: ChartInterval) => { + setIsLoading(true) + setInterval(newInterval) + // Simulate small network delay for realistic responsiveness + setTimeout(() => { + setIsLoading(false) + }, 300) + } + + return { + data, + summary, + interval, + setInterval: changeInterval, + isLoading, + } +} diff --git a/src/pages/Donor.tsx b/src/pages/Donor.tsx index f6fb02e0..92528eff 100644 --- a/src/pages/Donor.tsx +++ b/src/pages/Donor.tsx @@ -8,6 +8,7 @@ import { ScholarsFunded } from "../components/donor/ScholarsFunded" import { useDonor } from "../hooks/useDonor" import { useUSDC } from "../hooks/useUSDC" import { useWallet } from "../hooks/useWallet" +import { EarningsAnalytics } from "../components/donor/EarningsAnalytics" const Donor: React.FC = () => { const { address } = useWallet() @@ -164,6 +165,8 @@ const Donor: React.FC = () => { {/* Main Content */}
+ + Date: Thu, 28 May 2026 11:01:04 +0100 Subject: [PATCH 4/5] feat: implement sponsor a student scholarship checkout --- src/App.tsx | 2 + .../sponsor/SponsorCheckout.test.tsx | 84 +++ src/components/sponsor/SponsorCheckout.tsx | 581 ++++++++++++++++++ src/pages/SponsorCheckoutPage.tsx | 36 ++ 4 files changed, 703 insertions(+) create mode 100644 src/components/sponsor/SponsorCheckout.test.tsx create mode 100644 src/components/sponsor/SponsorCheckout.tsx create mode 100644 src/pages/SponsorCheckoutPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 2fb42d39..e20251f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ const Treasury = lazy(() => import("./pages/Treasury")) const Wiki = lazy(() => import("./pages/Wiki")) const WikiPage = lazy(() => import("./pages/WikiPage")) const FAQPage = lazy(() => import("./pages/FAQPage")) +const SponsorCheckoutPage = lazy(() => import("./pages/SponsorCheckoutPage")) const renderRoute = (element: ReactNode) => ( @@ -71,6 +72,7 @@ function App() { )} /> )} /> )} /> + )} /> )} /> )} /> { + it("renders the sponsorship portal headers and checkout panels", () => { + render() + + expect(screen.getByText("Sponsorship Gateway")).toBeInTheDocument() + expect(screen.getByText(/Sponsor/i)).toBeInTheDocument() + expect(screen.getByPlaceholderText(/Paste student addresses here/i)).toBeInTheDocument() + expect(screen.getByText("2. Checkout Summary")).toBeInTheDocument() + }) + + it("validates Stellar public keys starting with G and exactly 56 chars", async () => { + render() + + const textarea = screen.getByPlaceholderText(/Paste student addresses here/i) + + // Type an invalid address (too short) + fireEvent.change(textarea, { target: { value: "GBR123" } }) + + expect(screen.getByText("Failed: Invalid length (6/56 characters)")).toBeInTheDocument() + + // Type an invalid prefix address + fireEvent.change(textarea, { target: { value: "ABR1234567890ABCDEFGHIJKLMN9876543210ZYXWVUTSRQPO123456" } }) + expect(screen.getByText("Failed: Must start with capital 'G'")).toBeInTheDocument() + + // Type a valid address + fireEvent.change(textarea, { target: { value: "GDCB7T43EX74M36DRE66TWRK3V66S6BOU5M64R33X3QYRE2YQ6677WRK" } }) + expect(screen.queryByText(/Failed:/i)).not.toBeInTheDocument() + expect(screen.getByText("Passed")).toBeInTheDocument() + }) + + it("computes subtotal volume and estimated gas fees in real-time", () => { + render() + + const textarea = screen.getByPlaceholderText(/Paste student addresses here/i) + + // Insert two valid addresses + fireEvent.change(textarea, { + target: { + value: "GDCB7T43EX74M36DRE66TWRK3V66S6BOU5M64R33X3QYRE2YQ6677WRK\nGBR75X63EX74M36DRE66TWRK3V66S6BOU5M64R33X3QYRE2YQ6677WRK", + }, + }) + + // Validate count matches + expect(screen.getByText("2 Valid")).toBeInTheDocument() + + // 2 students * 25 USDC = 50 USDC subtotal + expect(screen.getByText("50 USDC")).toBeInTheDocument() + + // 2 students * 0.05 XLM = 0.10 XLM gas fee + expect(screen.getByText("0.10 XLM")).toBeInTheDocument() + }) + + it("authorizes checkout and shows a comprehensive success receipt panel", async () => { + render() + + const textarea = screen.getByPlaceholderText(/Paste student addresses here/i) + + // Insert valid address + fireEvent.change(textarea, { + target: { + value: "GDCB7T43EX74M36DRE66TWRK3V66S6BOU5M64R33X3QYRE2YQ6677WRK", + }, + }) + + const authorizeButton = screen.getByRole("button", { name: /Authorize & Fund/i }) + expect(authorizeButton).toBeEnabled() + + // Click Authorize + fireEvent.click(authorizeButton) + + // Should show loading status first + expect(screen.getByText(/Signing Blockchain Payload/i)).toBeInTheDocument() + + // Wait for receipt transition + expect(await screen.findByText("Sponsorship Transaction Completed")).toBeInTheDocument() + expect(await screen.findByText("Invoice Receipt")).toBeInTheDocument() + expect(await screen.findByText("Stellar Transaction Hash")).toBeInTheDocument() + }) +}) diff --git a/src/components/sponsor/SponsorCheckout.tsx b/src/components/sponsor/SponsorCheckout.tsx new file mode 100644 index 00000000..37dacbbe --- /dev/null +++ b/src/components/sponsor/SponsorCheckout.tsx @@ -0,0 +1,581 @@ +import { AnimatePresence, motion } from "framer-motion" +import { + CheckCircle2, + ChevronRight, + Coins, + FileDown, + FileSpreadsheet, + HelpCircle, + Info, + Trash2, + Upload, + Users, + AlertCircle, + Copy, + Check, +} from "lucide-react" +import React, { useMemo, useRef, useState } from "react" + +interface ParsedAddress { + address: string + isValid: boolean + error?: string +} + +export function SponsorCheckout() { + const [rawInput, setRawInput] = useState("") + const [fileError, setFileError] = useState(null) + const [isProcessing, setIsProcessing] = useState(false) + const [checkoutStep, setCheckoutStep] = useState<"setup" | "receipt">("setup") + const [txHash] = useState("0x" + Array.from({ length: 64 }, () => Math.floor(Math.random() * 16).toString(16)).join("")) + const [copiedId, setCopiedId] = useState(null) + + const fileInputRef = useRef(null) + + const LICENSE_PRICE_USDC = 25 + const XLM_GAS_PER_STUDENT = 0.05 + const XLM_TO_USDC_RATE = 0.35 // 1 XLM = 0.35 USDC equivalent + + // Stellar Address validation regex + const isValidStellarAddress = (addr: string): { isValid: boolean; error?: string } => { + const clean = addr.trim() + if (!clean) return { isValid: false, error: "Address is empty" } + if (!clean.startsWith("G")) { + return { isValid: false, error: "Must start with capital 'G'" } + } + if (clean.length !== 56) { + return { isValid: false, error: `Invalid length (${clean.length}/56 characters)` } + } + if (!/^[A-Z2-7]+$/.test(clean)) { + return { isValid: false, error: "Contains invalid base32 characters" } + } + return { isValid: true } + } + + // Parse addresses dynamically from input + const parsedAddresses = useMemo(() => { + if (!rawInput.trim()) return [] + // Split by comma, semi-colon, whitespace, or newlines + const lines = rawInput.split(/[\s,;]+/) + const uniqueLines = Array.from(new Set(lines.map((l) => l.trim()))).filter(Boolean) + + return uniqueLines.map((addr) => { + const validation = isValidStellarAddress(addr) + return { + address: addr, + isValid: validation.isValid, + error: validation.error, + } + }) + }, [rawInput]) + + const counts = useMemo(() => { + const valid = parsedAddresses.filter((a) => a.isValid).length + const invalid = parsedAddresses.filter((a) => !a.isValid).length + return { + total: parsedAddresses.length, + valid, + invalid, + } + }, [parsedAddresses]) + + // Cost Calculations + const estimates = useMemo(() => { + const validCount = counts.valid + const subtotalLicenses = validCount * LICENSE_PRICE_USDC + const totalGasXLM = validCount * XLM_GAS_PER_STUDENT + const totalGasUSD = totalGasXLM * XLM_TO_USDC_RATE + const grandTotal = subtotalLicenses + totalGasUSD + + return { + subtotalLicenses, + totalGasXLM, + totalGasUSD, + grandTotal, + } + }, [counts.valid]) + + // Handle text file / CSV parsing + const handleFileUpload = (e: React.ChangeEvent) => { + setFileError(null) + const file = e.target.files?.[0] + if (!file) return + + if (!file.name.endsWith(".csv") && !file.name.endsWith(".txt")) { + setFileError("Only CSV or TXT files are supported") + return + } + + const reader = new FileReader() + reader.onload = (event) => { + const text = event.target?.result as string + if (!text) return + + // Extract all potential Stellar addresses (56-char strings starting with G) + // matching basic stellar address formats + const regex = /\b(G[A-D2-7][A-Z2-7]{54})\b/g + const matches = text.match(regex) + + if (matches && matches.length > 0) { + const uniqueMatches = Array.from(new Set(matches)) + // Append to current raw input + setRawInput((prev) => { + const existing = prev.trim() + return existing + ? `${existing}\n${uniqueMatches.join("\n")}` + : uniqueMatches.join("\n") + }) + } else { + setFileError("No valid Stellar public keys found in file") + } + } + reader.readAsText(file) + } + + const triggerFileSelect = () => { + fileInputRef.current?.click() + } + + const handleCheckout = () => { + if (counts.valid === 0) return + setIsProcessing(true) + // Simulate blockchain validation and minting + setTimeout(() => { + setIsProcessing(false) + setCheckoutStep("receipt") + }, 2000) + } + + const handleReset = () => { + setRawInput("") + setCheckoutStep("setup") + } + + const handleCopy = (id: string, text: string) => { + void navigator.clipboard.writeText(text) + setCopiedId(id) + setTimeout(() => setCopiedId(null), 1500) + } + + return ( +
+ {/* Steps switching */} + + {checkoutStep === "setup" ? ( + + {/* Header */} +
+
+ + + Sponsorship Gateway + +
+

+ Sponsor Student Licenses +

+

+ Purchase LearnVault education access licenses in bulk. Provide a list of Stellar + wallet addresses below to allocate access rights and automatically seed Soroban gas limits. +

+
+ + {/* Layout Grid: Inputs vs Estimations */} +
+ {/* Inputs side (2/3 width) */} +
+ {/* Text & CSV Uploader */} +
+
+

+ 1. Add Student Addresses +

+ + {/* CSV File Trigger */} +
+ + +
+
+ + {fileError && ( +
+ + {fileError} +
+ )} + + {/* Multi address list textarea */} +
+