diff --git a/src/App.tsx b/src/App.tsx index 9c742815..447269d9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { ToastProvider } from "./components/Toast/ToastProvider" import { WalletToastWatcher } from "./components/WalletToastWatcher" const Admin = lazy(() => import("./pages/Admin")) +const AdminModeration = lazy(() => import("./pages/AdminModeration")) const Community = lazy(() => import("./pages/Community")) const Courses = lazy(() => import("./pages/Courses")) const Credential = lazy(() => import("./pages/Credential")) @@ -28,6 +29,8 @@ 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 SponsorCheckoutPage = lazy(() => import("./pages/SponsorCheckoutPage")) const renderRoute = (element: ReactNode) => ( @@ -66,9 +69,12 @@ function App() { path="/scholarships/apply" element={renderRoute()} /> - )} /> + )} /> + )} /> )} /> )} /> + )} /> + )} /> )} /> )} /> { + 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/components/admin/ModerationSection.tsx b/src/components/admin/ModerationSection.tsx new file mode 100644 index 00000000..0d1b4fa7 --- /dev/null +++ b/src/components/admin/ModerationSection.tsx @@ -0,0 +1,111 @@ +// src/components/admin/ModerationSection.tsx +import React, { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiFetchJson } from "../../lib/api"; +import { getAuthToken } from "../../util/auth"; + +interface FlaggedItem { + id: string; + contentId: string; + title: string; + reason: string; + reportedBy: string; + createdAt: string; +} + +const fetchFlagged = async (): Promise => { + const token = getAuthToken(); + return apiFetchJson("/api/moderation/flags", { auth: true }); +}; + +const actionMutation = async ({ id, action }: { id: string; action: string }) => { + return apiFetchJson<{ success: boolean }>(`/api/moderation/${id}/${action}`, { + method: "POST", + auth: true, + body: {}, + }); +}; + +export const ModerationSection: React.FC = () => { + const queryClient = useQueryClient(); + const { data: items = [], isLoading, error, refetch } = useQuery({ + queryKey: ["admin", "moderation"], + queryFn: fetchFlagged, + staleTime: 60 * 1000, + }); + + const mutation = useMutation(actionMutation, { + onSuccess: () => { + void queryClient.invalidateQueries(["admin", "moderation"]); + }, + }); + + const handleAction = (id: string, act: string) => { + mutation.mutate({ id, action: act }); + }; + + if (isLoading) { + return
Loading moderation items…
; + } + if (error) { + return ( +
+ Could not load moderation items. +
+ ); + } + + return ( +
+

Content Moderation

+
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
TitleReasonReported ByDateActions
{item.title}{item.reason}{item.reportedBy}{new Date(item.createdAt).toLocaleDateString()} +
+ + + +
+
+
+
+ ); +}; 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/components/sponsor/SponsorCheckout.test.tsx b/src/components/sponsor/SponsorCheckout.test.tsx new file mode 100644 index 00000000..1b591ffb --- /dev/null +++ b/src/components/sponsor/SponsorCheckout.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import { render } from "../../test/setup" +import { SponsorCheckout } from "./SponsorCheckout" + +describe("SponsorCheckout Component", () => { + 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 */} +
+