+ {showDeleteConfirm && ( + void handleDelete()} + onCancel={() => setShowDeleteConfirm(false)} + isDestructive + /> + )} {comment.is_pinned && ( Pinned by Author @@ -266,11 +195,9 @@ const CommentCard: React.FC = ({ - + + {shortenAddress(comment.author_address)} + {isAuthor && ( Author @@ -284,30 +211,6 @@ const CommentCard: React.FC = ({ - {isOwnComment && ( - <> - { - setIsEditing((v) => !v) - setEditText(comment.content) - setEditError(null) - }} - className="text-[10px] font-black uppercase text-white/70 hover:text-brand-cyan transition-colors" - > - {isEditing ? "Close" : "Edit"} - - void handleDelete()} - className="text-[10px] font-black uppercase text-white/70 hover:text-red-400 transition-colors" - > - Delete - - > - )} {canPin && !comment.is_pinned && ( = ({ Pin )} + {canDelete && ( + setShowDeleteConfirm(true)} + className="text-[10px] font-black uppercase text-red-400/70 hover:text-red-400 transition-colors" + > + Delete + + )} {!isReply && ( = ({ Reply )} - setIsFlagging(!isFlagging)} - className="text-[10px] font-black uppercase text-white/70 hover:text-red-400 transition-colors" - > - Flag - - {isEditing ? ( - - { - setEditText(e.target.value) - if (editError) setEditError(null) - }} - data-testid="comment-edit-field" - className="w-full h-32 bg-black/40 border border-white/10 rounded-2xl p-4 text-sm text-white focus:outline-none focus:border-brand-cyan/40" - /> - {editError && ( - - {editError} - - )} - - { - setIsEditing(false) - setEditText(comment.content) - setEditError(null) - }} - className="px-5 py-2 text-[10px] font-black uppercase text-white/70 border border-white/10 rounded-full hover:bg-white/5 transition-colors" - > - Cancel - - void handleSaveEdit()} - disabled={!editText.trim()} - className="px-5 py-2 bg-brand-cyan text-black text-[10px] font-black uppercase tracking-widest rounded-full hover:scale-105 transition-all disabled:opacity-50" - > - Save - - - - ) : ( - - {comment.content} - - )} + + + + {comment.content} + )} - - {isFlagging && ( - - - Report Comment - - - Please describe why you're reporting this comment (minimum 10 characters). - - { - setFlagReason(event.target.value) - if (flagError) { - setFlagError(null) - } - }} - placeholder="Explain why you're reporting this comment..." - className="w-full h-24 bg-black/40 border border-white/10 rounded-2xl p-4 text-xs text-white focus:outline-none focus:border-red-500/40" - aria-invalid={Boolean(flagError)} - /> - {flagError && ( - - {flagError} - - )} - - { - setIsFlagging(false) - setFlagReason("") - setFlagError(null) - }} - className="px-5 py-2 text-[10px] font-black uppercase text-white/70 border border-white/10 rounded-full hover:bg-white/5 transition-colors" - > - Cancel - - void handleFlag()} - disabled={!flagReason.trim()} - className="px-5 py-2 bg-red-600 text-white text-[10px] font-black uppercase tracking-widest rounded-full hover:scale-105 transition-all disabled:opacity-50" - > - Submit Report - - - - )} ) } diff --git a/src/components/CommentSection.tsx b/src/components/CommentSection.tsx index 7c798c9c..db44ade5 100644 --- a/src/components/CommentSection.tsx +++ b/src/components/CommentSection.tsx @@ -1,4 +1,9 @@ +<<<<<<< HEAD import { useEffect, useId, useState, useCallback } from "react" +======= +import { useEffect, useId, useState } from "react" +import { formatDistanceToNow } from "date-fns" +>>>>>>> main import { useTranslation } from "react-i18next" import { useWallet } from "../hooks/useWallet" import { getAuthToken } from "../util/auth" @@ -23,6 +28,9 @@ interface CommentSectionProps { proposalAuthor?: string } +<<<<<<< HEAD +function CommentSection({ +======= const API_URL = ( (import.meta.env.VITE_API_URL as string | undefined) ?? (import.meta.env.VITE_SERVER_URL as string | undefined) ?? @@ -30,11 +38,15 @@ const API_URL = ( ).replace(/\/$/, "") const CommentSection: React.FC = ({ +>>>>>>> main proposalId, proposalAuthor, -}) => { +}: CommentSectionProps) { const { t } = useTranslation() +<<<<<<< HEAD +======= const { address } = useWallet() +>>>>>>> main const pollInterval = Number(import.meta.env.VITE_COMMENT_POLL_MS) || 15000 const [lastUpdated, setLastUpdated] = useState(new Date()) const commentInputId = useId() @@ -48,6 +60,38 @@ const CommentSection: React.FC = ({ const [submissionError, setSubmissionError] = useState(null) const [submissionStatus, setSubmissionStatus] = useState(null) +<<<<<<< HEAD + const fetchComments = useCallback( + async (isSilent = false) => { + if (!isSilent) setLoading(true) + try { + const res = await fetch( + `${import.meta.env.VITE_SERVER_URL}/api/proposals/${proposalId}/comments`, + ) + if (!res.ok) throw new Error("Failed to fetch comments") + const data = await res.json() + setComments(data) + setLastUpdated(new Date()) + } catch (err) { + console.error("Failed to fetch comments", err) + } finally { + if (!isSilent) setLoading(false) + } + }, + [proposalId], + ) + + useEffect(() => { + let isMounted = true + const safeFetch = async (silent: boolean) => { + if (!isMounted) return + await fetchComments(silent) + } + + void safeFetch(false) + + const interval = setInterval(() => void safeFetch(true), pollInterval) +======= const fetchComments = async () => { setLoading(true) try { @@ -71,6 +115,7 @@ const CommentSection: React.FC = ({ void safeFetch() const interval = setInterval(() => void safeFetch(), pollInterval) +>>>>>>> main return () => { isMounted = false clearInterval(interval) @@ -246,6 +291,7 @@ const CommentSection: React.FC = ({ comment={comment} isAuthor={comment.author_address === proposalAuthor} canPin={proposalAuthor === address} + canDelete={comment.author_address === address} onUpdate={fetchComments} /> @@ -254,6 +300,7 @@ const CommentSection: React.FC = ({ key={reply.id} comment={reply} isReply + canDelete={reply.author_address === address} onUpdate={fetchComments} /> ))} diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx new file mode 100644 index 00000000..5bcff4d4 --- /dev/null +++ b/src/components/ConfirmDialog.tsx @@ -0,0 +1,100 @@ +import React, { useEffect } from "react" + +interface ConfirmDialogProps { + title: string + description: string + confirmLabel?: string + cancelLabel?: string + onConfirm: () => void + onCancel: () => void + isDestructive?: boolean +} + +/** + * A reusable, keyboard-accessible confirmation dialog. + * Features: + * - Glassmorphic design to match LearnVault aesthetics + * - Esc key to close (Cancel) + * - Highlights safe action (Cancel) as primary + * - Red styling for destructive actions + */ +const ConfirmDialog: React.FC = ({ + title, + description, + confirmLabel = "Confirm", + cancelLabel = "Cancel", + onConfirm, + onCancel, + isDestructive = true, +}) => { + // Handle Escape key + useEffect(() => { + const handleEsc = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onCancel() + } + } + window.addEventListener("keydown", handleEsc) + return () => window.removeEventListener("keydown", handleEsc) + }, [onCancel]) + + return ( + + + + + {isDestructive ? "⚠" : "ℹ"} + + + + + {title} + + + + {description} + + + + + {confirmLabel} + + + {cancelLabel} + + + + + ) +} + +export default ConfirmDialog diff --git a/src/components/CourseCard.tsx b/src/components/CourseCard.tsx index af95aaca..14f86287 100644 --- a/src/components/CourseCard.tsx +++ b/src/components/CourseCard.tsx @@ -1,6 +1,8 @@ import React from "react" import { Link } from "react-router-dom" +import BookmarkButton from "./BookmarkButton" + interface CourseCardProps { id: string title: string @@ -72,6 +74,10 @@ const CourseCard: React.FC = ({ {difficultyData.label} + {/* Bookmark toggle (hidden when wallet not connected) */} + + + {/* Card Content */} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 96fd56c3..84078748 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,7 +1,5 @@ import React, { Component, type ErrorInfo, type ReactNode } from "react" -import { generateRequestId } from "../utils/errors" - -const SUPPORT_EMAIL = "support@learnvault.app" +import { logger } from "../utils/logger" interface Props { children?: ReactNode @@ -10,18 +8,17 @@ interface Props { interface State { hasError: boolean error: Error | null - requestId: string | null } export default class ErrorBoundary extends Component { public state: State = { hasError: false, error: null, - requestId: null, } public static getDerivedStateFromError(error: Error): State { - return { hasError: true, error, requestId: generateRequestId() } + // Update state so the next render will show the fallback UI. + return { hasError: true, error } } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { @@ -29,27 +26,19 @@ export default class ErrorBoundary extends Component { } private handleRetry = () => { - this.setState({ hasError: false, error: null, requestId: null }) + this.setState({ hasError: false, error: null }) + } + + private handleReport = () => { + // Keep local diagnostics in development until a real reporting service lands. + logger.info("Error reported:", this.state.error) + alert("Error has been reported to the team. Thank you!") } public render() { if (this.state.hasError) { - const { error, requestId } = this.state - const subject = encodeURIComponent("LearnVault Error Report") - const bodyText = [ - `Error: ${error?.message ?? "Unknown error"}`, - `Request ID: ${requestId ?? "N/A"}`, - "", - "Steps to reproduce:", - "[please describe what you were doing]", - ].join("\n") - const mailtoLink = `mailto:${SUPPORT_EMAIL}?subject=${subject}&body=${encodeURIComponent(bodyText)}` - return ( - + { Something went wrong - - The application encountered an unexpected error. Try refreshing the - page — if the problem persists, contact support with the reference - ID below. + + We apologize for the inconvenience. The application encountered an + unexpected error. - {requestId && ( - - Ref: {requestId} - - )} - + Try Again window.history.back()} - className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg font-medium transition-colors cursor-pointer" - data-testid="error-boundary-go-back" + onClick={this.handleReport} + className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg font-medium border border-slate-700 transition-colors cursor-pointer" > - Go back + Report Issue - - Go Home - - - Contact Support - ) diff --git a/src/components/FollowButton.tsx b/src/components/FollowButton.tsx new file mode 100644 index 00000000..1237cb34 --- /dev/null +++ b/src/components/FollowButton.tsx @@ -0,0 +1,106 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { UserPlus, UserMinus, UserCheck } from "lucide-react" +import React, { useState } from "react" +import { useWallet } from "../hooks/useWallet" + +interface FollowButtonProps { + targetAddress: string + isFollowingInitial?: boolean + onStatusChange?: (isFollowing: boolean) => void + className?: string +} + +export const FollowButton: React.FC = ({ + targetAddress, + isFollowingInitial = false, + onStatusChange, + className = "", +}) => { + const { address: currentUserAddress } = useWallet() + const queryClient = useQueryClient() + const [isHovered, setIsHovered] = useState(false) + + const isOwnProfile = + currentUserAddress?.toLowerCase() === targetAddress.toLowerCase() + + const mutation = useMutation({ + mutationFn: async (shouldFollow: boolean) => { + const method = shouldFollow ? "POST" : "DELETE" + const response = await fetch(`/api/scholars/${targetAddress}/follow`, { + method, + headers: { + Authorization: `Bearer ${localStorage.getItem("authToken") || ""}`, + }, + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error( + error.error || `Failed to ${shouldFollow ? "follow" : "unfollow"}`, + ) + } + + return response.json() + }, + onSuccess: (data) => { + const isFollowing = data.data.isFollowing + // Invalidate queries to refresh counts + void queryClient.invalidateQueries({ + queryKey: ["scholarProfile", targetAddress], + }) + if (currentUserAddress) { + void queryClient.invalidateQueries({ + queryKey: ["scholarProfile", currentUserAddress], + }) + } + onStatusChange?.(isFollowing) + }, + }) + + if (!currentUserAddress || isOwnProfile) return null + + const isFollowing = mutation.isIdle + ? isFollowingInitial + : !!mutation.variables + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + mutation.mutate(!isFollowing) + } + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + disabled={mutation.isPending} + className={`group relative flex items-center gap-2 px-6 py-2.5 rounded-full font-black uppercase tracking-widest text-[10px] transition-all duration-300 shadow-lg ${ + isFollowing + ? "bg-white/5 border border-white/20 text-white/70 hover:bg-red-500/10 hover:border-red-500/50 hover:text-red-500" + : "bg-brand-cyan text-black hover:scale-105 hover:shadow-brand-cyan/20" + } ${mutation.isPending ? "opacity-50 cursor-wait" : ""} ${className}`} + > + {isFollowing ? ( + <> + {isHovered ? ( + <> + + Unfollow + > + ) : ( + <> + + Following + > + )} + > + ) : ( + <> + + Follow + > + )} + + ) +} diff --git a/src/components/GlobalSearch.tsx b/src/components/GlobalSearch.tsx index 8e305dd9..92cd8142 100644 --- a/src/components/GlobalSearch.tsx +++ b/src/components/GlobalSearch.tsx @@ -1,17 +1,14 @@ import { Icon } from "@stellar/design-system" import React, { useState, useEffect, useRef } from "react" -import { useNavigate } from "react-router-dom" +import { Link, useNavigate } from "react-router-dom" import { useCourses } from "../hooks/useCourses" import { useWikiPages } from "../hooks/useWiki" const GlobalSearch: React.FC = () => { const [query, setQuery] = useState("") const [isOpen, setIsOpen] = useState(false) - const [activeIndex, setActiveIndex] = useState(-1) const navigate = useNavigate() const containerRef = useRef(null) - const inputRef = useRef(null) - const listboxId = "global-search-listbox" const { courses = [] } = useCourses() const { data: wikiPages = [] } = useWikiPages() @@ -46,11 +43,6 @@ const GlobalSearch: React.FC = () => { ].slice(0, 8) : [] - // Reset active index whenever results change - useEffect(() => { - setActiveIndex(-1) - }, [results.length, query]) - useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( @@ -58,7 +50,6 @@ const GlobalSearch: React.FC = () => { !containerRef.current.contains(event.target as Node) ) { setIsOpen(false) - setActiveIndex(-1) } } document.addEventListener("mousedown", handleClickOutside) @@ -68,44 +59,9 @@ const GlobalSearch: React.FC = () => { const handleSelect = (link: string) => { setQuery("") setIsOpen(false) - setActiveIndex(-1) void navigate(link) } - const handleKeyDown = (e: React.KeyboardEvent) => { - if (!isOpen || results.length === 0) { - if (e.key === "Escape") { - setIsOpen(false) - setActiveIndex(-1) - } - return - } - - switch (e.key) { - case "ArrowDown": - e.preventDefault() - setActiveIndex((prev) => (prev < results.length - 1 ? prev + 1 : 0)) - break - case "ArrowUp": - e.preventDefault() - setActiveIndex((prev) => (prev > 0 ? prev - 1 : results.length - 1)) - break - case "Enter": - e.preventDefault() - if (activeIndex >= 0 && activeIndex < results.length) { - handleSelect(results[activeIndex].link) - } - break - case "Escape": - e.preventDefault() - setIsOpen(false) - setActiveIndex(-1) - break - } - } - - const showDropdown = isOpen && query.length >= 2 - return ( @@ -114,16 +70,9 @@ const GlobalSearch: React.FC = () => { size="sm" /> = 0 ? `search-option-${activeIndex}` : undefined - } placeholder="Search..." + aria-label="Search" className="glass border border-white/10 rounded-xl pl-10 pr-4 py-2 text-sm w-[180px] focus:w-[240px] focus:border-brand-cyan/40 focus:outline-none transition-all" value={query} onChange={(e) => { @@ -131,30 +80,18 @@ const GlobalSearch: React.FC = () => { setIsOpen(true) }} onFocus={() => setIsOpen(true)} - onKeyDown={handleKeyDown} /> - {showDropdown && ( - + {isOpen && query.length >= 2 && ( + {results.length > 0 ? ( - {results.map((result, index) => ( + {results.map((result) => ( handleSelect(result.link)} - onMouseEnter={() => setActiveIndex(index)} - className={`flex items-center justify-between px-4 py-3 text-left border-b border-white/5 last:border-none transition-colors group ${ - index === activeIndex ? "bg-white/10" : "hover:bg-white/5" - }`} + className="flex items-center justify-between px-4 py-3 hover:bg-white/5 text-left border-b border-white/5 last:border-none transition-colors group" > diff --git a/src/components/GuessTheNumber.tsx b/src/components/GuessTheNumber.tsx index f4806792..3916c3a1 100644 --- a/src/components/GuessTheNumber.tsx +++ b/src/components/GuessTheNumber.tsx @@ -71,28 +71,36 @@ export const GuessTheNumber = () => { setResult("loading") - // TODO: Create a transaction using the contract client - // const tx = await game.guess( - // { a_number: BigInt(guess), guesser: address }, - // // @ts-expect-error js-stellar-sdk has bad typings; publicKey is, in fact, allowed - // { publicKey: address }, - // ) - - // // Send the transaction to the current network - // const { result } = await tx.signAndSend({ signTransaction }) - - // // Handle result and update wallet balance - // if (result.isErr()) { - // console.error(result.unwrapErr()) - // } else { - // setResult(result.unwrap() ? "success" : "failure") - // await updateBalances() - // } - - // Placeholder: simulate success - setTimeout(() => { - setResult(Math.random() > 0.5 ? "success" : "failure") - }, 1000) + // Create a transaction using the contract client + const game = await loadGuessClient() + if (!game) { + setErrorMessage(missingClientMessage) + setResult("failure") + return + } + + try { + const tx = await game.guess( + { a_number: BigInt(guess), guesser: address }, + { publicKey: address }, + ) + + // Send the transaction to the current network + const { result: txResult } = await tx.signAndSend({ signTransaction }) + + // Handle result and update wallet balance + if (txResult.isErr()) { + console.error(txResult.unwrapErr()) + setResult("failure") + } else { + setResult(txResult.unwrap() ? "success" : "failure") + await updateBalances() + } + } catch (error) { + console.error("Error submitting guess:", error) + setErrorMessage("An error occurred while submitting your guess.") + setResult("failure") + } } const reset = () => { diff --git a/src/components/LanguageSelector.tsx b/src/components/LanguageSelector.tsx index b4b64577..489f74e8 100644 --- a/src/components/LanguageSelector.tsx +++ b/src/components/LanguageSelector.tsx @@ -42,6 +42,9 @@ export const LanguageSelector: React.FC = () => { 🇺🇸 English + + 🇪🇸 Español + 🇫🇷 Français diff --git a/src/components/LessonContent.tsx b/src/components/LessonContent.tsx index 4a0af82e..aeb43484 100644 --- a/src/components/LessonContent.tsx +++ b/src/components/LessonContent.tsx @@ -1,5 +1,5 @@ import { Button } from "@stellar/design-system" -import React, { useEffect, useRef } from "react" +import React from "react" import ReactMarkdown from "react-markdown" import { Link } from "react-router-dom" import { type CourseLesson as Lesson } from "../types/courses" @@ -29,7 +29,6 @@ interface LessonContentProps { isCompleting: boolean timeSpentLabel?: string | null onMarkComplete: () => void - onScrolledToBottom?: () => void prevLessonId: number | null nextLessonId: number | null isNextLocked: boolean @@ -42,38 +41,10 @@ const LessonContent: React.FC = ({ isCompleting, timeSpentLabel, onMarkComplete, - onScrolledToBottom, prevLessonId, nextLessonId, isNextLocked, }) => { - const sentinelRef = useRef(null) - const firedRef = useRef(false) - - // Reset the fired flag whenever the lesson changes - useEffect(() => { - firedRef.current = false - }, [lesson.id]) - - // Fire onScrolledToBottom once when the bottom sentinel comes into view - useEffect(() => { - if (!onScrolledToBottom || isLoading) return - const el = sentinelRef.current - if (!el) return - - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting && !firedRef.current) { - firedRef.current = true - onScrolledToBottom() - } - }, - { threshold: 0.1 }, - ) - observer.observe(el) - return () => observer.disconnect() - }, [onScrolledToBottom, isLoading, lesson.id]) - if (isLoading) { return ( @@ -98,9 +69,6 @@ const LessonContent: React.FC = ({ {lesson.content} - {/* Sentinel: when visible, lesson has been scrolled to the bottom */} - - {prevLessonId ? ( @@ -137,6 +105,7 @@ const LessonContent: React.FC = ({ = ({ courseId, lessons, completedMilestones, - readLessonIds = [], currentLessonId, }) => { const totalLessons = lessons.length @@ -22,15 +20,6 @@ const LessonSidebar: React.FC = ({ const progressPercent = totalLessons > 0 ? (completedCount / totalLessons) * 100 : 0 - // Locally read but not yet server-confirmed - const localReadOnly = readLessonIds.filter( - (id) => !completedMilestones.includes(id), - ) - const readPercent = - totalLessons > 0 - ? ((completedCount + localReadOnly.length) / totalLessons) * 100 - : 0 - return ( - {/* Two-layer bar: local-read (soft cyan) behind server-complete (solid cyan) */} - - + @@ -66,7 +44,6 @@ const LessonSidebar: React.FC = ({ {lessons.map((l, index) => { const isCompleted = completedMilestones.includes(l.id) - const isLocalRead = !isCompleted && readLessonIds.includes(l.id) const isCurrent = l.id === currentLessonId const previousCompleted = index === 0 || @@ -110,20 +87,12 @@ const LessonSidebar: React.FC = ({ className={`mt-1 w-6 h-6 flex shrink-0 items-center justify-center rounded-full border ${ isCompleted ? "bg-brand-emerald/20 border-brand-emerald text-brand-emerald" - : isLocalRead - ? "bg-brand-cyan/10 border-brand-cyan/40 text-brand-cyan/60" - : isCurrent - ? "border-brand-cyan text-brand-cyan" - : "border-white/20 text-white/40" + : isCurrent + ? "border-brand-cyan text-brand-cyan" + : "border-white/20 text-white/40" }`} - title={ - isLocalRead - ? "Lesson read (not yet completed on-chain)" - : undefined - } > {isCompleted ? ( - // Hard checkmark — server-confirmed completion = ({ clipRule="evenodd" /> - ) : isLocalRead ? ( - // Soft checkmark — locally read but not on-chain yet - - - ) : ( {index + 1} )} diff --git a/src/components/MilestoneReportForm.tsx b/src/components/MilestoneReportForm.tsx index 9853beab..ba4b43bd 100644 --- a/src/components/MilestoneReportForm.tsx +++ b/src/components/MilestoneReportForm.tsx @@ -5,7 +5,6 @@ import { type MilestoneReportFormValues } from "../types/milestone" type MilestoneReportFormProps = { isSubmitting: boolean onSubmit: (values: MilestoneReportFormValues) => Promise - initialValues?: Partial } const emptyValues: MilestoneReportFormValues = { @@ -20,12 +19,8 @@ const emptyValues: MilestoneReportFormValues = { export default function MilestoneReportForm({ isSubmitting, onSubmit, - initialValues, }: MilestoneReportFormProps) { - const [values, setValues] = useState({ - ...emptyValues, - ...initialValues, - }) + const [values, setValues] = useState(emptyValues) const [error, setError] = useState(null) const updateValue = ( @@ -68,9 +63,7 @@ export default function MilestoneReportForm({ try { await onSubmit(values) - if (!initialValues) { - setValues(emptyValues) - } + setValues(emptyValues) } catch (error) { setError( error instanceof Error diff --git a/src/components/MyBookmarks.tsx b/src/components/MyBookmarks.tsx new file mode 100644 index 00000000..a5b5b343 --- /dev/null +++ b/src/components/MyBookmarks.tsx @@ -0,0 +1,110 @@ +import React from "react" +import { Link, useNavigate } from "react-router-dom" + +import { useBookmarks } from "../hooks/useBookmarks" +import { useCourses } from "../hooks/useCourses" +import CourseCard from "./CourseCard" + +/** + * Inner render — only mounted when we actually have bookmarks to display, so + * the underlying /api/courses fetch only runs when we need it to resolve + * bookmark IDs to full course metadata. + */ +const MyBookmarksList: React.FC<{ bookmarkedIds: Set }> = ({ + bookmarkedIds, +}) => { + const { courses, isLoading: isLoadingCourses } = useCourses() + const navigate = useNavigate() + + const bookmarkedCourses = courses.filter((c) => bookmarkedIds.has(c.id)) + + if (isLoadingCourses) { + return ( + + {[1, 2].map((i) => ( + + ))} + + ) + } + + return ( + + {bookmarkedCourses.map((course) => ( + + navigate(`/courses?highlight=${encodeURIComponent(course.id)}`) + } + /> + ))} + + ) +} + +/** + * "My Bookmarks" section for the Dashboard. Shows courses the learner + * has hearted for later. Hidden entirely when the wallet isn't connected. + * Defers the /api/courses fetch until the learner actually has bookmarks, + * so users with no bookmarks don't pay for the full catalog load. + */ +const MyBookmarks: React.FC = () => { + const { bookmarks, isLoading: isLoadingBookmarks, address } = useBookmarks() + + if (!address) return null + + const bookmarkedIds = new Set(bookmarks.map((b) => b.course_id)) + const isLoading = isLoadingBookmarks + + return ( + + + + 💙 + + My Bookmarks + + + {isLoading ? ( + + {[1, 2].map((i) => ( + + ))} + + ) : bookmarks.length > 0 ? ( + + ) : ( + + + You haven't bookmarked any courses yet. Tap the heart on a course to + save it for later. + + + Browse courses → + + + )} + + ) +} + +export default MyBookmarks diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index 76c175e4..90312dc4 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -1,5 +1,5 @@ import { useQueryClient } from "@tanstack/react-query" -import { useEffect, useId, useState, useCallback } from "react" +import { useCallback, useEffect, useId, useState } from "react" import { useTranslation } from "react-i18next" import { NavLink } from "react-router-dom" import { fetchCourses } from "../hooks/useCourses" @@ -12,16 +12,18 @@ import { import { useWallet } from "../hooks/useWallet" import { fetchHistory } from "../pages/History" import GlobalSearch from "./GlobalSearch" +import { LanguageSelector } from "./LanguageSelector" +import NetworkIndicator from "./NetworkIndicator" import { NotificationBell } from "./NotificationBell" import { ReputationBadge } from "./ReputationBadge" import { ThemeToggle } from "./ThemeToggle" import { WalletButton } from "./WalletButton" +import { getAuthToken } from "../util/auth" export default function NavBar() { const [menuOpen, setMenuOpen] = useState(false) const mobileMenuId = useId() const { t } = useTranslation() - const token = localStorage.getItem("auth_token") ?? undefined useEffect(() => { if (typeof document === "undefined") return @@ -61,6 +63,7 @@ export default function NavBar() { const queryClient = useQueryClient() const { address } = useWallet() + const token = getAuthToken() const handlePrefetch = useCallback( (to: string) => { @@ -128,6 +131,7 @@ export default function NavBar() { handlePrefetch(to)} className={({ isActive }) => `px-3 py-2 rounded-xl text-xs font-black uppercase tracking-widest transition-all ${ @@ -146,7 +150,13 @@ export default function NavBar() { + + + + + + + + + + + + + + + Language + + + diff --git a/src/components/NetworkIndicator.tsx b/src/components/NetworkIndicator.tsx new file mode 100644 index 00000000..cfc0e3ce --- /dev/null +++ b/src/components/NetworkIndicator.tsx @@ -0,0 +1,61 @@ +import { useNetwork } from "../providers/NetworkProvider" + +interface NetworkIndicatorProps { + className?: string + showLabel?: boolean +} + +export function NetworkIndicator({ + className = "", + showLabel = true, +}: NetworkIndicatorProps) { + const { network, config, isTestnet } = useNetwork() + + const getNetworkColor = () => { + switch (network) { + case "PUBLIC": + return "bg-emerald-500/20 text-emerald-400 border-emerald-500/30" + case "TESTNET": + return "bg-amber-500/20 text-amber-400 border-amber-500/30" + case "FUTURENET": + return "bg-purple-500/20 text-purple-400 border-purple-500/30" + case "LOCAL": + return "bg-blue-500/20 text-blue-400 border-blue-500/30" + default: + return "bg-slate-500/20 text-slate-400 border-slate-500/30" + } + } + + const getNetworkDot = () => { + switch (network) { + case "PUBLIC": + return "bg-emerald-400" + case "TESTNET": + return "bg-amber-400" + case "FUTURENET": + return "bg-purple-400" + case "LOCAL": + return "bg-blue-400" + default: + return "bg-slate-400" + } + } + + return ( + + + {showLabel && {config.name}} + + ) +} + +export default NetworkIndicator diff --git a/src/components/NetworkSwitcher.tsx b/src/components/NetworkSwitcher.tsx new file mode 100644 index 00000000..b4dd3de9 --- /dev/null +++ b/src/components/NetworkSwitcher.tsx @@ -0,0 +1,136 @@ +import { + useNetwork, + NETWORK_CONFIGS, + type StellarNetwork, +} from "../providers/NetworkProvider" + +export function NetworkSwitcher() { + const { + network, + config, + switchNetwork, + canSwitchNetwork, + availableNetworks, + } = useNetwork() + + const handleNetworkChange = (newNetwork: StellarNetwork) => { + if (newNetwork !== network) { + switchNetwork(newNetwork) + } + } + + return ( + + + + Network Settings + + Select the Stellar network to connect to + + + {!canSwitchNetwork && ( + + Production Build + + )} + + + + {availableNetworks.map((netId) => { + const netConfig = NETWORK_CONFIGS[netId] + const isSelected = netId === network + + return ( + handleNetworkChange(netId)} + disabled={!canSwitchNetwork && !netConfig.isProduction} + className={` + w-full flex items-center justify-between p-4 rounded-xl border transition-all + ${ + isSelected + ? "bg-brand-cyan/10 border-brand-cyan/30" + : "bg-white/5 border-white/10 hover:bg-white/10" + } + ${!canSwitchNetwork && !netConfig.isProduction ? "opacity-50 cursor-not-allowed" : ""} + `} + > + + + + {netConfig.name} + + {netConfig.passphrase} + + + + {isSelected && ( + + Active + + )} + + ) + })} + + + {/* Current Network Details */} + + + Current Network Details + + + + Network: + {config.name} + + + RPC URL: + + {config.rpcUrl} + + + + Horizon URL: + + {config.horizonUrl} + + + + Explorer: + + View Explorer → + + + + + + {/* Warning for testnet */} + {!config.isProduction && ( + + + Warning: You are on a + test network. Tokens have no real value and transactions are for + testing only. + + + )} + + ) +} + +export default NetworkSwitcher diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx new file mode 100644 index 00000000..d5b73af8 --- /dev/null +++ b/src/components/OnboardingTour.tsx @@ -0,0 +1,99 @@ +import { driver } from "driver.js" +import "driver.js/dist/driver.css" +import { useEffect } from "react" +import { useLocation, useNavigate } from "react-router-dom" +import { useWallet } from "../hooks/useWallet" + +const TOUR_COMPLETE_KEY = "learnvault:tour-complete" + +export const OnboardingTour = () => { + const { address } = useWallet() + const location = useLocation() + const navigate = useNavigate() + + useEffect(() => { + const isTourComplete = localStorage.getItem(TOUR_COMPLETE_KEY) + if (isTourComplete) return + + // Small delay to ensure DOM is settled + const timer = setTimeout(() => { + const driverObj = driver({ + showProgress: true, + allowClose: true, + overlayColor: "rgba(0, 0, 0, 0.75)", + steps: [ + { + element: "#connect-wallet-button", + popover: { + title: "Connect Your Wallet", + description: + "Start by connecting your Stellar wallet to track your progress on-chain.", + side: "bottom", + align: "start", + }, + }, + { + element: "#courses-nav-link", + popover: { + title: "Explore Courses", + description: + "Browse our catalog of courses and find a learning track that interests you.", + side: "bottom", + align: "start", + }, + }, + { + element: "#course-card-0", + popover: { + title: "Start Learning", + description: + "Pick a course and jump into your first lesson to start earning rewards.", + side: "top", + align: "center", + }, + }, + { + element: "#mark-complete-button", + popover: { + title: "Complete Milestones", + description: + "Finish your lesson and mark it as complete to record your achievement on the blockchain.", + side: "top", + align: "center", + }, + }, + ], + onDestroyed: () => { + // We only mark it complete if they reached the end or explicitly closed it? + // Actually, the request says "Show tour only on first visit". + // So once they interact with it, we can mark it. + // To be safe, we mark it complete so it doesn't annoy them again. + localStorage.setItem(TOUR_COMPLETE_KEY, "true") + }, + }) + + // Logic to trigger steps based on location and state + if (location.pathname === "/" && !address) { + if (document.querySelector("#connect-wallet-button")) { + driverObj.drive(0) + } + } else if (location.pathname === "/" && address) { + if (document.querySelector("#courses-nav-link")) { + driverObj.drive(1) + } + } else if (location.pathname === "/courses") { + if (document.querySelector("#course-card-0")) { + driverObj.drive(2) + } + } else if (location.pathname.includes("/lessons/")) { + if (document.querySelector("#mark-complete-button")) { + driverObj.drive(3) + } + } + }, 1000) + + return () => clearTimeout(timer) + }, [address, location.pathname]) + + return null +} diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx index 0d6066f4..724b9905 100644 --- a/src/components/Pagination.tsx +++ b/src/components/Pagination.tsx @@ -45,6 +45,7 @@ const Pagination: React.FC = ({ @@ -60,6 +61,7 @@ const Pagination: React.FC = ({ ) : ( onPageChange(item as number)} + aria-label={`Page ${item}`} className={`min-w-[44px] h-10 rounded-xl border text-xs font-black uppercase tracking-widest transition-all active:scale-[0.97] ${ item === page @@ -77,6 +79,7 @@ const Pagination: React.FC = ({ diff --git a/src/components/ProposalCard.tsx b/src/components/ProposalCard.tsx index 7cd7b793..2441ec65 100644 --- a/src/components/ProposalCard.tsx +++ b/src/components/ProposalCard.tsx @@ -1,7 +1,6 @@ import { Card, Badge, Button } from "@stellar/design-system" import React from "react" import { shortenAddress } from "../util/contract" -import AddressDisplay from "./AddressDisplay" import ProposalCountdown from "./ProposalCountdown" export interface ProposalCardProps { @@ -65,11 +64,9 @@ export const ProposalCard: React.FC = ({ {title} - + + {shortenAddress(proposerAddress)} + {`${amountUsdc} USDC`} diff --git a/src/components/SafeMarkdown.tsx b/src/components/SafeMarkdown.tsx new file mode 100644 index 00000000..4a992cfc --- /dev/null +++ b/src/components/SafeMarkdown.tsx @@ -0,0 +1,30 @@ +import React from "react" +import ReactMarkdown from "react-markdown" + +import { + SAFE_MARKDOWN_ELEMENTS, + safeMarkdownUrlTransform, + sanitizeMarkdownContent, +} from "../util/safeMarkdown" + +interface SafeMarkdownProps { + content: string +} + +const SafeMarkdown: React.FC = ({ content }) => { + const sanitizedContent = sanitizeMarkdownContent(content) + + return ( + + {sanitizedContent} + + ) +} + +export default SafeMarkdown diff --git a/src/components/SkeletonLoader.tsx b/src/components/SkeletonLoader.tsx index e62e4b04..6bc5fef4 100644 --- a/src/components/SkeletonLoader.tsx +++ b/src/components/SkeletonLoader.tsx @@ -213,40 +213,3 @@ export const NoCredentialsEmptyState: React.FC = () => ( ctaHref="/learn" /> ) -// ─── Stat Card Skeleton ─────────────────────────────────────────────────────── -// Issue #732 — Matching shape of StatCard on Treasury / Dashboard pages - -export const StatCardSkeleton: React.FC = () => ( - - - - - - -) - -// ─── Activity Feed Item Skeleton ────────────────────────────────────────────── -// Issue #732 — Matching shape of ActivityFeed rows on Treasury - -export const ActivityFeedSkeleton: React.FC<{ rows?: number }> = ({ - rows = 3, -}) => ( - - {Array.from({ length: rows }).map((_, i) => ( - - - - - - - - - - - - ))} - -) diff --git a/src/components/TestnetBanner.tsx b/src/components/TestnetBanner.tsx new file mode 100644 index 00000000..b6f6e363 --- /dev/null +++ b/src/components/TestnetBanner.tsx @@ -0,0 +1,82 @@ +import { useState } from "react" +import { useNetwork } from "../providers/NetworkProvider" + +export function TestnetBanner() { + const { isTestnet, config, canSwitchNetwork } = useNetwork() + const [isDismissed, setIsDismissed] = useState(false) + + // Only show for testnet networks + if (!isTestnet || isDismissed) { + return null + } + + return ( + + + + + + + + + + + + + You are on {config.name} + + + • + + + Tokens have no real value. Transactions are for testing only. + + + + + {canSwitchNetwork && ( + + Switch Network + + )} + setIsDismissed(true)} + className="p-1 hover:bg-amber-500/20 rounded-lg transition-colors" + aria-label="Dismiss banner" + > + + + + + + + + + + ) +} + +export default TestnetBanner diff --git a/src/components/WalletAddressPill.tsx b/src/components/WalletAddressPill.tsx index 54708a4f..e5e550a3 100644 --- a/src/components/WalletAddressPill.tsx +++ b/src/components/WalletAddressPill.tsx @@ -1,18 +1,112 @@ -import React from "react" -import AddressDisplay from "./AddressDisplay" +import { motion, AnimatePresence } from "framer-motion" +import { useState } from "react" +import { stellarNetwork } from "../contracts/util" + +import { useWallet } from "../hooks/useWallet" +import { shortenAddress } from "../util/contract" interface Props { address: string showLink?: boolean } -export const WalletAddressPill = ({ address, showLink = true }: Props) => { +export const WalletAddressPill = ({ address, showLink = false }: Props) => { + const { network: walletNetwork } = useWallet() + const [copied, setCopied] = useState(false) + + const copyToClipboard = async (e: React.MouseEvent) => { + e.stopPropagation() + try { + await navigator.clipboard.writeText(address) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error("Failed to copy:", err) + } + } + + const getExplorerUrl = () => { + const activeNetwork = (walletNetwork || stellarNetwork).toLowerCase() + + if (activeNetwork === "public" || activeNetwork === "mainnet") { + return `https://stellar.expert/explorer/public/account/${address}` + } + + if (activeNetwork === "futurenet") { + return `https://futurenet.stellar.expert/explorer/futurenet/account/${address}` + } + + // Default to Testnet for everything else (TESTNET, LOCAL, etc.) + return `https://testnet.stellar.expert/explorer/testnet/account/${address}` + } + return ( - + + + + {shortenAddress(address)} + + + + {/* I'll use a direct SVG for the copy icon to ensure it works regardless of specific package exports */} + + + + + + + + {copied && ( + + Copied! + + )} + + + + {showLink && ( + + + + + + + + )} + ) } diff --git a/src/components/WalletButton.tsx b/src/components/WalletButton.tsx index cfbdffc3..31ba2fb0 100644 --- a/src/components/WalletButton.tsx +++ b/src/components/WalletButton.tsx @@ -1,25 +1,19 @@ -import { Button, Icon } from "@stellar/design-system" +import { Button, Icon, Text, Modal, Profile } from "@stellar/design-system" import { useState } from "react" import { useTranslation } from "react-i18next" -import { useWallet } from "../hooks/useWallet" -import { WalletInfoModal } from "./WalletInfoModal" import { motion } from "framer-motion" +import { Link } from "react-router-dom" +import ConfirmDialog from "./ConfirmDialog" +import { useWallet } from "../hooks/useWallet" -/** - * Wallet control button for the navigation bar. - * In disconnected state: triggers wallet connection. - * In connected state: shows compact identity and triggers info modal. - */ export const WalletButton = () => { - const [showModal, setShowModal] = useState(false) + const [showDisconnectModal, setShowDisconnectModal] = useState(false) const { address, isPending, isReconnecting, balances } = useWallet() const { t } = useTranslation() - const buttonLabel = isPending || isReconnecting ? t("wallet.loading") : t("wallet.connect") const handleConnect = async () => { - // Dynamic import to keep main bundle size small const { connectWallet } = await import("../util/wallet") await connectWallet() } @@ -27,17 +21,17 @@ export const WalletButton = () => { const handleDisconnect = async () => { const { disconnectWallet } = await import("../util/wallet") await disconnectWallet() - setShowModal(false) + setShowDisconnectModal(false) } if (!address) { return ( void handleConnect()} disabled={isReconnecting} - id="nav-connect-wallet" > {buttonLabel} @@ -46,19 +40,38 @@ export const WalletButton = () => { } return ( - <> - {/* Compact trigger with premium glassmorphic style */} + + + {t("wallet.balance", { amount: balances?.lrn?.balance ?? "-" })} + + + + {showDisconnectModal && ( + void handleDisconnect()} + onCancel={() => setShowDisconnectModal(false)} + isDestructive + /> + )} + setShowDisconnectModal(true)} + className="flex items-center gap-4 px-4 py-2 bg-white/5 border border-white/10 rounded-2xl hover:bg-white/10 transition-all group" + whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} - onClick={() => setShowModal(true)} - className="glass flex items-center gap-4 px-4 py-2 rounded-2xl border border-white/10 hover:border-brand-cyan/30 transition-all bg-white/5 shadow-[0_8px_32px_-8px_rgba(0,0,0,0.3)] group relative overflow-hidden" - aria-label={t("wallet.view_details", "View wallet details")} - id="nav-wallet-trigger" > - {/* Inner glow effect on hover */} - - Wallet @@ -77,11 +90,7 @@ export const WalletButton = () => { - setShowModal(false)} - onDisconnect={() => void handleDisconnect()} - /> - > + + ) } diff --git a/src/components/WalletToastWatcher.tsx b/src/components/WalletToastWatcher.tsx index 99a21ef1..c97dc9cd 100644 --- a/src/components/WalletToastWatcher.tsx +++ b/src/components/WalletToastWatcher.tsx @@ -27,7 +27,7 @@ export function WalletToastWatcher() { if (!prev && address) { showSuccess( - `Wallet connected: ${address.slice(0, 4)}...${address.slice(-4)}`, + `Wallet connected: ${address.slice(0, 6)}...${address.slice(-4)}`, ) } else if (prev && !address) { showInfo("Wallet disconnected") diff --git a/src/components/donor/ScholarsFunded.tsx b/src/components/donor/ScholarsFunded.tsx index cc764a07..9544397d 100644 --- a/src/components/donor/ScholarsFunded.tsx +++ b/src/components/donor/ScholarsFunded.tsx @@ -1,6 +1,5 @@ import React from "react" import { type Scholar } from "../../hooks/useDonor" -import AddressDisplay from "../AddressDisplay" interface ScholarsFundedProps { scholars: Scholar[] @@ -36,11 +35,9 @@ export const ScholarsFunded: React.FC = ({ scholars }) => { {scholar.name} - + + {scholar.id} + void - currentAddress: string | null - isAdmin: boolean + courseId: string + threadId: number + onBack: () => void + currentAddress: string | null + isAdmin: boolean } -export const ThreadDetail: React.FC = ({ courseId, threadId, onBack, currentAddress, isAdmin }) => { - const { data: thread, isLoading, error } = useForumThreadDetail(courseId, threadId) - const queryClient = useQueryClient() - const [replyContent, setReplyContent] = useState("") - const [isSubmitting, setIsSubmitting] = useState(false) - - const handleReply = async (e: React.FormEvent) => { - e.preventDefault() - if (!replyContent.trim()) return - - try { - setIsSubmitting(true) - await replyToThread(courseId, threadId, replyContent) - await queryClient.invalidateQueries({ queryKey: ["forum", "thread", courseId, threadId] }) - await queryClient.invalidateQueries({ queryKey: ["forum", "threads", courseId] }) - setReplyContent("") - } catch (err) { - console.error("Failed to post reply", err) - } finally { - setIsSubmitting(false) - } - } - - const handleDeleteThread = async () => { - if (!confirm("Are you sure you want to delete this thread?")) return - try { - await deleteThread(courseId, threadId) - await queryClient.invalidateQueries({ queryKey: ["forum", "threads", courseId] }) - onBack() - } catch (err) { - console.error("Failed to delete thread", err) - } - } - - const handleDeleteReply = async (replyId: number) => { - if (!confirm("Are you sure you want to delete this reply?")) return - try { - await deleteReply(courseId, replyId) - await queryClient.invalidateQueries({ queryKey: ["forum", "thread", courseId, threadId] }) - } catch (err) { - console.error("Failed to delete reply", err) - } - } - - if (isLoading) { - return Loading discussion... - } - - if (error || !thread) { - return ( - - ← Back to Discussions - Thread not found or failed to load. - - ) - } - - return ( - - - - ← Back to Discussions - - - {thread.title} - { (isAdmin || currentAddress === thread.author_address) && ( - - Delete Thread - - )} - - - - • - {new Date(thread.created_at).toLocaleString()} - - - - {thread.content} - - - - - Replies ({thread.replies?.length || 0}) - - {thread.replies?.length === 0 ? ( - - No replies yet. - - ) : ( - - {thread.replies.map(reply => ( - - - - - • - {new Date(reply.created_at).toLocaleString()} - - { (isAdmin || currentAddress === reply.author_address) && ( - handleDeleteReply(reply.id)} - title="Delete reply" - > - × - - )} - - - {reply.content} - - - ))} - - )} - - - {currentAddress ? ( - - Add a Reply - - setReplyContent(e.target.value)} - rows={4} - className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/30 focus:outline-hidden focus:border-brand-cyan transition-colors font-mono text-sm" - /> - - - {isSubmitting ? "Posting..." : "Post Reply"} - - - - - ) : ( - - You must be connected to reply. - - )} - - ) +export const ThreadDetail: React.FC = ({ + courseId, + threadId, + onBack, + currentAddress, + isAdmin, +}) => { + const { data: thread, isLoading, error } = useForumThreadDetail( + courseId, + threadId, + ) + const queryClient = useQueryClient() + const [replyContent, setReplyContent] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleReply = async (e: React.FormEvent) => { + e.preventDefault() + if (!replyContent.trim()) return + + try { + setIsSubmitting(true) + await replyToThread(courseId, threadId, replyContent) + await queryClient.invalidateQueries({ + queryKey: ["forum", "thread", courseId, threadId], + }) + await queryClient.invalidateQueries({ + queryKey: ["forum", "threads", courseId], + }) + setReplyContent("") + } catch (err) { + console.error("Failed to post reply", err) + } finally { + setIsSubmitting(false) + } + } + + const handleDeleteThread = async () => { + if (!confirm("Are you sure you want to delete this thread?")) return + try { + await deleteThread(courseId, threadId) + await queryClient.invalidateQueries({ + queryKey: ["forum", "threads", courseId], + }) + onBack() + } catch (err) { + console.error("Failed to delete thread", err) + } + } + + const handleDeleteReply = async (replyId: number) => { + if (!confirm("Are you sure you want to delete this reply?")) return + try { + await deleteReply(courseId, replyId) + await queryClient.invalidateQueries({ + queryKey: ["forum", "thread", courseId, threadId], + }) + } catch (err) { + console.error("Failed to delete reply", err) + } + } + + if (isLoading) { + return Loading discussion... + } + + if (error || !thread) { + return ( + + + ← Back to Discussions + + + Thread not found or failed to load. + + + ) + } + + return ( + + + + ← Back to Discussions + + + + {thread.title} + + {(isAdmin || currentAddress === thread.author_address) && ( + + Delete Thread + + )} + + + + + • + {new Date(thread.created_at).toLocaleString()} + + + + {thread.content} + + + + + + Replies ({thread.replies?.length || 0}) + + + {thread.replies?.length === 0 ? ( + + No replies yet. + + ) : ( + + {thread.replies.map((reply) => ( + + + + + • + + {new Date(reply.created_at).toLocaleString()} + + + + {(isAdmin || currentAddress === reply.author_address) && ( + handleDeleteReply(reply.id)} + title="Delete reply" + aria-label="Delete reply" + > + × + + )} + + + + {reply.content} + + + ))} + + )} + + + {currentAddress ? ( + + Add a Reply + + + setReplyContent(e.target.value)} + rows={4} + className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/30 focus:outline-hidden focus:border-brand-cyan transition-colors font-mono text-sm" + /> + + + + {isSubmitting ? "Posting..." : "Post Reply"} + + + + + ) : ( + + + You must be connected to reply. + + + )} + + ) } diff --git a/src/components/forum/ThreadList.tsx b/src/components/forum/ThreadList.tsx index 534d5191..8f58d5af 100644 --- a/src/components/forum/ThreadList.tsx +++ b/src/components/forum/ThreadList.tsx @@ -1,212 +1,217 @@ +import React, { useState } from "react" import { Button } from "@stellar/design-system" import { useQueryClient } from "@tanstack/react-query" -import React, { useState } from "react" import ReactMarkdown from "react-markdown" import { - createThread, - deleteThread, - useForumThreads, + createThread, + deleteThread, + useForumThreads, } from "../../hooks/useForum" import { WalletAddressPill } from "../WalletAddressPill" interface ThreadListProps { - courseId: string - onSelectThread: (id: number) => void - currentAddress: string | null - isAdmin: boolean + courseId: string + onSelectThread: (id: number) => void + currentAddress: string | null + isAdmin: boolean } export const ThreadList: React.FC = ({ - courseId, - onSelectThread, - currentAddress, - isAdmin, + courseId, + onSelectThread, + currentAddress, + isAdmin, }) => { - const { data: threads, isLoading, error } = useForumThreads(courseId) - const [isComposing, setIsComposing] = useState(false) - const queryClient = useQueryClient() - - const [title, setTitle] = useState("") - const [content, setContent] = useState("") - const [isSubmitting, setIsSubmitting] = useState(false) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!title.trim() || !content.trim()) return - - try { - setIsSubmitting(true) - await createThread(courseId, title, content) - await queryClient.invalidateQueries({ - queryKey: ["forum", "threads", courseId], - }) - setTitle("") - setContent("") - setIsComposing(false) - } catch (err) { - console.error("Failed to create thread", err) - } finally { - setIsSubmitting(false) - } - } - - const handleDelete = async (e: React.MouseEvent, threadId: number) => { - e.stopPropagation() - if (!confirm("Are you sure you want to delete this thread?")) return - - try { - await deleteThread(courseId, threadId) - await queryClient.invalidateQueries({ - queryKey: ["forum", "threads", courseId], - }) - } catch (err) { - console.error("Failed to delete thread", err) - } - } - - if (isLoading) { - return ( - - Loading discussion threads... - - ) - } - - if (error) { - return ( - - Failed to load discussions. Please try again later. - - ) - } - - return ( - - - Discussion Forum - {!isComposing && currentAddress && ( - setIsComposing(true)} - > - Start a Discussion - - )} - - - {isComposing && ( - - New Thread - - - setTitle(e.target.value)} - className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/30 focus:outline-hidden focus:border-brand-cyan transition-colors" - /> - - - setContent(e.target.value)} - rows={6} - className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/30 focus:outline-hidden focus:border-brand-cyan transition-colors font-mono text-sm" - /> - - - setIsComposing(false)} - disabled={isSubmitting} - > - Cancel - - - {isSubmitting ? "Posting..." : "Post Thread"} - - - - - )} - - {!threads?.length && !isComposing && ( - - 💭 - - No discussions yet. Be the first to start a conversation about this - course! - - - )} - - - {threads?.map((thread) => ( - onSelectThread(thread.id)} - className="w-full text-left glass-card p-5 rounded-2xl border border-white/10 hover:border-brand-cyan/50 transition-all flex flex-col gap-3 group" - > - - - {thread.title} - - - - {new Date(thread.created_at).toLocaleDateString()} - - {(isAdmin || currentAddress === thread.author_address) && ( - { - e.stopPropagation() - void handleDelete( - e as unknown as React.MouseEvent, - thread.id, - ) - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - e.stopPropagation() - void handleDelete( - e as unknown as React.MouseEvent, - thread.id, - ) - } - }} - aria-label="Delete thread" - > - × - - )} - - - - {thread.content} - - - - - {thread.reply_count}{" "} - {thread.reply_count === 1 ? "reply" : "replies"} - - - - ))} - - - ) -} + const { data: threads, isLoading, error } = useForumThreads(courseId) + const [isComposing, setIsComposing] = useState(false) + const queryClient = useQueryClient() + + const [title, setTitle] = useState("") + const [content, setContent] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!title.trim() || !content.trim()) return + + try { + setIsSubmitting(true) + await createThread(courseId, title, content) + await queryClient.invalidateQueries({ + queryKey: ["forum", "threads", courseId], + }) + setTitle("") + setContent("") + setIsComposing(false) + } catch (err) { + console.error("Failed to create thread", err) + } finally { + setIsSubmitting(false) + } + } + + const handleDelete = async (e: React.MouseEvent, threadId: number) => { + e.stopPropagation() + if (!confirm("Are you sure you want to delete this thread?")) return + + try { + await deleteThread(courseId, threadId) + await queryClient.invalidateQueries({ + queryKey: ["forum", "threads", courseId], + }) + } catch (err) { + console.error("Failed to delete thread", err) + } + } + + if (isLoading) { + return ( + + Loading discussion threads... + + ) + } + + if (error) { + return ( + + Failed to load discussions. Please try again later. + + ) + } + + return ( + + + Discussion Forum + + {!isComposing && currentAddress && ( + setIsComposing(true)} + > + Start a Discussion + + )} + + + {isComposing && ( + + New Thread + + + setTitle(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/30 focus:outline-hidden focus:border-brand-cyan transition-colors" + /> + + setContent(e.target.value)} + rows={6} + className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/30 focus:outline-hidden focus:border-brand-cyan transition-colors font-mono text-sm" + /> + + + setIsComposing(false)} + disabled={isSubmitting} + > + Cancel + + + + {isSubmitting ? "Posting..." : "Post Thread"} + + + + + )} + + {!threads?.length && !isComposing && ( + + 💭 + + No discussions yet. Be the first to start a conversation about this + course! + + + )} + + + {threads?.map((thread) => ( + onSelectThread(thread.id)} + className="w-full text-left glass-card p-5 rounded-2xl border border-white/10 hover:border-brand-cyan/50 transition-all flex flex-col gap-3 group" + > + + + {thread.title} + + + + + {new Date(thread.created_at).toLocaleDateString()} + + + {(isAdmin || currentAddress === thread.author_address) && ( + { + e.stopPropagation() + void handleDelete( + e as unknown as React.MouseEvent, + thread.id + ) + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + e.stopPropagation() + void handleDelete( + e as unknown as React.MouseEvent, + thread.id + ) + } + }} + aria-label="Delete thread" + > + × + + )} + + + + + {thread.content} + + + + + + {thread.reply_count}{" "} + {thread.reply_count === 1 ? "reply" : "replies"} + + + + ))} + + + ) +} \ No newline at end of file diff --git a/src/components/states/errorState.tsx b/src/components/states/errorState.tsx index c6954274..1a923783 100644 --- a/src/components/states/errorState.tsx +++ b/src/components/states/errorState.tsx @@ -1,60 +1,27 @@ import { AlertCircle } from "lucide-react" -const SUPPORT_EMAIL = "support@learnvault.app" - interface ErrorStateProps { message?: string onRetry?: () => void - requestId?: string - showContactSupport?: boolean } export function ErrorState({ - message = "An unexpected error occurred. Please try again.", + message = "Something went wrong.", onRetry, - requestId, - showContactSupport, }: ErrorStateProps) { - const subject = encodeURIComponent("LearnVault Support Request") - const bodyText = [ - `Error: ${message}`, - requestId ? `Request ID: ${requestId}` : "", - "", - "Steps to reproduce:", - "[please describe what you were doing]", - ] - .filter(Boolean) - .join("\n") - const mailtoLink = `mailto:${SUPPORT_EMAIL}?subject=${subject}&body=${encodeURIComponent(bodyText)}` - return ( Failed to load {message} - {requestId && ( - - Request ID: {requestId} - + {onRetry && ( + + Try again + )} - - {onRetry && ( - - Try again - - )} - {showContactSupport && ( - - Contact support - - )} - ) } diff --git a/src/contracts/governance_token.ts b/src/contracts/governance_token.ts index 6c68a630..7481e8dc 100644 --- a/src/contracts/governance_token.ts +++ b/src/contracts/governance_token.ts @@ -8,16 +8,4 @@ export default { async get_balance() { return 0n }, - async get_voting_power() { - return 0n - }, - async get_delegate() { - return null - }, - async delegate() { - return { result: null } - }, - async undelegate() { - return { result: null } - }, } diff --git a/src/hooks/useActivityFeed.ts b/src/hooks/useActivityFeed.ts index 656ec06e..940382fe 100644 --- a/src/hooks/useActivityFeed.ts +++ b/src/hooks/useActivityFeed.ts @@ -11,7 +11,7 @@ export type ActivityEventType = | "vote_cast" | "funds_disbursed" -export type ActivityEventFilter = "deposit" | "disburse" | "all" +export type ActivityEventFilter = "deposit" | "disburse" | "followed" | "all" export interface ActivityEvent { id: string @@ -83,85 +83,88 @@ async function fetchActivityEvents( walletAddress: string | undefined, limit: number, filter?: ActivityEventFilter, - contractIds?: { - learnToken?: string - courseMilestone?: string - scholarNft?: string - governanceToken?: string - milestoneEscrow?: string - }, ): Promise { - const ids = [ - contractIds?.learnToken, - contractIds?.courseMilestone, - contractIds?.scholarNft, - contractIds?.governanceToken, - contractIds?.milestoneEscrow, - ].filter((v): v is string => Boolean(v)) - - if (!ids.length) return [] - - const response = await fetch(rpcUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: "activity-feed", - method: "getEvents", - params: { - filters: [{ type: "contract", contractIds: ids }], - pagination: { limit: 100 }, - }, - }), + const params = new URLSearchParams() + params.append("limit", limit.toString()) + + if (walletAddress && filter !== "followed") { + params.append("address", walletAddress) + } + + if (filter === "followed") { + params.append("followed_only", "true") + } + + if (filter === "deposit") { + params.append("type", "LearnToken::Mint") // Example, backend might need adjustment if more types + } + + const response = await fetch(`/api/events?${params.toString()}`, { + headers: { + Authorization: `Bearer ${localStorage.getItem("authToken") || ""}`, + }, }) if (!response.ok) return [] const payload = (await response.json()) as { - result?: { events?: RpcEvent[] } - } - const events = payload.result?.events ?? [] - - const relevant = walletAddress - ? events.filter((e) => - JSON.stringify({ - topic: e.topics ?? e.topic, - value: e.value, - }) - .toLowerCase() - .includes(walletAddress.toLowerCase()), - ) - : events - - // Apply filter by event type - let filtered = relevant - if (filter && filter !== "all") { - filtered = relevant.filter((e) => { - const type = classifyEvent(e) - if (filter === "deposit") { - // Deposits are token mint/transfer events - return type === "lrn_minted" - } - if (filter === "disburse") { - // Disbursements are funds_disbursed events - return type === "funds_disbursed" - } - return true - }) + data?: Array<{ + id: number + contract: string + event_type: string + data: any + ledger_sequence: string + created_at: string + tx_hash: string | null + }> } + const events = payload.data ?? [] - return filtered.slice(0, limit).map((event, idx) => { - const type = classifyEvent(event) + return events.map((event) => { + const type = classifyBackendEvent(event.event_type, event.data) return { - id: event.id ?? `activity-${idx}`, + id: String(event.id), type, - description: describeEvent(type, event), - timestamp: event.ledgerCloseTime ?? new Date().toISOString(), - txHash: event.txHash, + description: describeBackendEvent(type, event.data), + timestamp: event.created_at, + txHash: event.tx_hash || undefined, } }) } +function classifyBackendEvent(eventType: string, data: any): ActivityEventType { + const text = (eventType + JSON.stringify(data)).toLowerCase() + if (text.includes("mint") && text.includes("nft")) return "scholar_nft_minted" + if (text.includes("mint") || text.includes("transfer")) return "lrn_minted" + if (text.includes("enroll")) return "course_enrolled" + if (text.includes("complete") || text.includes("milestone")) + return "milestone_completed" + if (text.includes("vote")) return "vote_cast" + if (text.includes("disburse") || text.includes("escrow")) + return "funds_disbursed" + return "lrn_minted" +} + +function describeBackendEvent(type: ActivityEventType, data: any): string { + const text = JSON.stringify(data).toLowerCase() + switch (type) { + case "lrn_minted": + return "Earned LRN for completing a lesson" + case "course_enrolled": + return "Enrolled in a new course" + case "milestone_completed": + return "Completed a milestone" + case "scholar_nft_minted": + return "Earned a ScholarNFT credential" + case "vote_cast": + return "Cast a governance vote" + case "funds_disbursed": + return "Received scholarship funds" + default: + return "Activity recorded" + } +} + export function useActivityFeed( address: string | undefined, limit = 10, @@ -178,14 +181,7 @@ export function useActivityFeed( const { data, isLoading, error } = useQuery({ queryKey: ["activity-feed", address, filter], - queryFn: () => - fetchActivityEvents(address, 100, filter, { - learnToken, - courseMilestone, - scholarNft, - governanceToken, - milestoneEscrow, - }), + queryFn: () => fetchActivityEvents(address, 100, filter), enabled: true, staleTime: 30_000, refetchInterval: 60_000, diff --git a/src/hooks/useAdmin.ts b/src/hooks/useAdmin.ts index 9ab9f152..3ad7ec85 100644 --- a/src/hooks/useAdmin.ts +++ b/src/hooks/useAdmin.ts @@ -1,28 +1,21 @@ +<<<<<<< HEAD +import { useState, useCallback } from "react" +======= import { useCallback, useRef, useState } from "react" import { apiFetchJson, buildApiUrl, createAuthHeaders } from "../lib/api" +>>>>>>> main export interface AdminStats { pendingMilestones: number approvedToday: number rejectedToday: number +<<<<<<< HEAD +======= totalScholars: number totalLrnMinted: string openProposals: number treasuryBalanceUsdc: string -} - -export interface ValidatorAnalytics { - validatorAddress: string - milestonesReviewed: number - averageReviewTimeSeconds: number - approvalRate: number - appealReversalRate: number -} - -export interface ValidatorReviewQueue { - pendingReviews: number - threshold: number - exceeded: boolean +>>>>>>> main } export interface MilestoneSubmission { @@ -32,9 +25,6 @@ export interface MilestoneSubmission { evidenceLink: string submittedAt: string status: "pending" | "approved" | "rejected" - /** Non-binding peer review counts (inform admin decisions). */ - peerApprovalCount: number - peerRejectionCount: number } export interface PaginatedMilestones { @@ -72,23 +62,6 @@ type AdminStatsResponse = { treasury_balance_usdc: string } -type ValidatorAnalyticsApi = { - validator_address: string - milestones_reviewed: number - average_review_time_seconds: number - approval_rate: number - appeal_reversal_rate: number -} - -type ValidatorAnalyticsResponse = { - validators: ValidatorAnalyticsApi[] - review_queue: { - pending_reviews: number - threshold: number - exceeded: boolean - } -} - type MilestoneSubmissionApi = { id: number scholar_address: string @@ -98,8 +71,6 @@ type MilestoneSubmissionApi = { evidence_description?: string | null submitted_at: string status: "pending" | "approved" | "rejected" - peer_approval_count?: number - peer_rejection_count?: number } type PaginatedMilestonesApi = { @@ -143,8 +114,6 @@ const mapMilestoneSubmission = ( "", submittedAt: milestone.submitted_at, status: milestone.status, - peerApprovalCount: milestone.peer_approval_count ?? 0, - peerRejectionCount: milestone.peer_rejection_count ?? 0, }) const mapBatchMilestoneResult = ( @@ -167,6 +136,12 @@ export function useAdminStats() { setLoading(true) setError(null) try { +<<<<<<< HEAD + const res = await fetch("/api/admin/stats") + if (!res.ok) throw new Error("Failed to fetch admin stats") + const data: AdminStats = await res.json() + setStats(data) +======= const data = await apiFetchJson("/api/admin/stats", { auth: true, }) @@ -179,6 +154,7 @@ export function useAdminStats() { openProposals: Number(data.open_proposals ?? 0), treasuryBalanceUsdc: data.treasury_balance_usdc ?? "0", }) +>>>>>>> main } catch (err: unknown) { setError(err instanceof Error ? err.message : "Unknown error") } finally { @@ -189,66 +165,17 @@ export function useAdminStats() { return { stats, loading, error, fetchStats } } -export function useValidatorAnalytics() { - const [analytics, setAnalytics] = useState([]) - const [reviewQueue, setReviewQueue] = useState( - null, - ) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - - const fetchAnalytics = useCallback(async () => { - setLoading(true) - setError(null) - try { - const data = await apiFetchJson( - "/api/admin/validators/analytics", - { - auth: true, - }, - ) - - setAnalytics( - (data.validators ?? []).map((item) => ({ - validatorAddress: item.validator_address, - milestonesReviewed: Number(item.milestones_reviewed ?? 0), - averageReviewTimeSeconds: Number( - item.average_review_time_seconds ?? 0, - ), - approvalRate: Number(item.approval_rate ?? 0), - appealReversalRate: Number(item.appeal_reversal_rate ?? 0), - })), - ) - - setReviewQueue({ - pendingReviews: Number(data.review_queue?.pending_reviews ?? 0), - threshold: Number(data.review_queue?.threshold ?? 0), - exceeded: Boolean(data.review_queue?.exceeded), - }) - } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Unknown error") - } finally { - setLoading(false) - } - }, []) - - return { - analytics, - reviewQueue, - loading, - error, - fetchAnalytics, - } -} - export function useAdminMilestones() { const [milestones, setMilestones] = useState([]) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) +<<<<<<< HEAD +======= const filtersRef = useRef<{ course?: string; status?: string }>({}) const pageRef = useRef(1) +>>>>>>> main const PAGE_SIZE = 10 @@ -259,8 +186,11 @@ export function useAdminMilestones() { ) => { setLoading(true) setError(null) +<<<<<<< HEAD +======= filtersRef.current = filters pageRef.current = pageNum +>>>>>>> main try { const params = new URLSearchParams({ page: String(pageNum), @@ -268,6 +198,12 @@ export function useAdminMilestones() { ...(filters.course ? { course: filters.course } : {}), ...(filters.status ? { status: filters.status } : {}), }) +<<<<<<< HEAD + const res = await fetch(`/api/admin/milestones?${params.toString()}`) + if (!res.ok) throw new Error("Failed to fetch milestones") + const result: PaginatedMilestones = await res.json() + setMilestones(result.data) +======= const result = await apiFetchJson( `/api/admin/milestones?${params.toString()}`, { @@ -275,6 +211,7 @@ export function useAdminMilestones() { }, ) setMilestones(result.data.map(mapMilestoneSubmission)) +>>>>>>> main setTotal(result.total) setPage(result.page) } catch (err: unknown) { @@ -286,6 +223,50 @@ export function useAdminMilestones() { [], ) +<<<<<<< HEAD + const approveMilestone = useCallback(async (id: string): Promise => { + // Optimistic update + setMilestones((prev) => + prev.map((m) => (m.id === id ? { ...m, status: "approved" } : m)), + ) + try { + const res = await fetch(`/api/admin/milestones/${id}/approve`, { + method: "POST", + }) + if (!res.ok) throw new Error("Approval failed") + return true + } catch (err: unknown) { + // Rollback on failure + setMilestones((prev) => + prev.map((m) => (m.id === id ? { ...m, status: "pending" } : m)), + ) + setError(err instanceof Error ? err.message : "Approval failed") + return false + } + }, []) + + const rejectMilestone = useCallback(async (id: string): Promise => { + // Optimistic update + setMilestones((prev) => + prev.map((m) => (m.id === id ? { ...m, status: "rejected" } : m)), + ) + try { + const res = await fetch(`/api/admin/milestones/${id}/reject`, { + method: "POST", + }) + if (!res.ok) throw new Error("Rejection failed") + return true + } catch (err: unknown) { + // Rollback on failure + setMilestones((prev) => + prev.map((m) => (m.id === id ? { ...m, status: "pending" } : m)), + ) + setError(err instanceof Error ? err.message : "Rejection failed") + return false + } + }, []) + +======= const refreshMilestones = useCallback(async () => { await fetchMilestones(pageRef.current, filtersRef.current) }, [fetchMilestones]) @@ -411,6 +392,7 @@ export function useAdminMilestones() { [runBatchMilestones], ) +>>>>>>> main return { milestones, total, @@ -421,7 +403,10 @@ export function useAdminMilestones() { fetchMilestones, approveMilestone, rejectMilestone, +<<<<<<< HEAD +======= batchApproveMilestones, batchRejectMilestones, +>>>>>>> main } } diff --git a/src/hooks/useBookmarks.ts b/src/hooks/useBookmarks.ts new file mode 100644 index 00000000..782b2e35 --- /dev/null +++ b/src/hooks/useBookmarks.ts @@ -0,0 +1,159 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { createAuthHeaders } from "../lib/api" +import { useWallet } from "./useWallet" + +export interface Bookmark { + bookmark_id: number + course_id: string + created_at: string +} + +const BOOKMARKS_QUERY_KEY = ["bookmarks"] as const + +/** + * Build headers for authenticated bookmark requests. Delegates to the shared + * `createAuthHeaders` / `getAuthToken` pair so we stay in sync with the + * codebase's "authToken" + "auth_token" storage-key fallback. When no token + * is present, `createAuthHeaders()` omits the Authorization header entirely + * — it does NOT send a malformed `Authorization: Bearer` with an empty value. + */ +function authHeaders(): Headers { + const headers = createAuthHeaders() + headers.set("Content-Type", "application/json") + return headers +} + +/** + * Apply a single-item toggle to a bookmarks list. Used for both optimistic + * mutation and error rollback so they're symmetric and never snapshot the + * whole list — that way concurrent toggles on different course IDs don't + * clobber each other's in-flight state on failure. + */ +function applyToggle( + list: Bookmark[], + courseId: string, + next: "on" | "off", +): Bookmark[] { + if (next === "off") { + return list.filter((b) => b.course_id !== courseId) + } + if (list.some((b) => b.course_id === courseId)) return list + return [ + { + bookmark_id: -1, // server fills real id on refetch + course_id: courseId, + created_at: new Date().toISOString(), + }, + ...list, + ] +} + +/** + * List + toggle bookmarks for the connected wallet. + * + * Server is the source of truth — bookmarks persist across devices and + * sessions automatically. Toggling uses optimistic updates so the heart + * icon flips immediately, rolling back if the server call fails. + */ +export function useBookmarks() { + const { address } = useWallet() + const queryClient = useQueryClient() + + const bookmarksQuery = useQuery({ + queryKey: [...BOOKMARKS_QUERY_KEY, address], + queryFn: async () => { + const response = await fetch("/api/me/bookmarks", { + method: "GET", + headers: authHeaders(), + }) + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error ?? "Failed to fetch bookmarks") + } + const body = (await response.json()) as { data: Bookmark[] } + return body.data + }, + enabled: !!address, + staleTime: 60 * 1000, // 1 minute + }) + + const bookmarkedCourseIds = new Set( + (bookmarksQuery.data ?? []).map((b) => b.course_id), + ) + + const isBookmarked = (courseId: string) => bookmarkedCourseIds.has(courseId) + + const toggleMutation = useMutation< + void, + Error, + { courseId: string; next: "on" | "off" } + >({ + mutationFn: async ({ courseId, next }) => { + const url = + next === "on" + ? "/api/me/bookmarks" + : `/api/me/bookmarks/${encodeURIComponent(courseId)}` + const method = next === "on" ? "POST" : "DELETE" + const body = + next === "on" ? JSON.stringify({ course_id: courseId }) : undefined + + const response = await fetch(url, { + method, + headers: authHeaders(), + body, + }) + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error ?? "Failed to toggle bookmark") + } + }, + // Granular optimistic updates: we never snapshot/restore the whole list. + // Instead we apply the single-item delta on mutate, and reverse the same + // single-item delta on error. That way two concurrent toggles on + // different course IDs don't clobber each other on rollback. + onMutate: async ({ courseId, next }) => { + await queryClient.cancelQueries({ + queryKey: [...BOOKMARKS_QUERY_KEY, address], + }) + queryClient.setQueryData( + [...BOOKMARKS_QUERY_KEY, address], + (old = []) => applyToggle(old, courseId, next), + ) + }, + onError: (_err, { courseId, next }) => { + // Reverse the specific delta we applied — don't touch other rows + const reverse = next === "on" ? "off" : "on" + queryClient.setQueryData( + [...BOOKMARKS_QUERY_KEY, address], + (current = []) => applyToggle(current, courseId, reverse), + ) + }, + onSettled: () => { + void queryClient.invalidateQueries({ + queryKey: [...BOOKMARKS_QUERY_KEY, address], + }) + }, + }) + + const toggleBookmark = (courseId: string) => { + if (!address) return + toggleMutation.mutate({ + courseId, + next: isBookmarked(courseId) ? "off" : "on", + }) + } + + return { + bookmarks: bookmarksQuery.data ?? [], + isLoading: bookmarksQuery.isLoading, + error: + bookmarksQuery.error instanceof Error + ? bookmarksQuery.error.message + : null, + isBookmarked, + toggleBookmark, + isToggling: toggleMutation.isPending, + address, + } +} diff --git a/src/hooks/useDonor.test.tsx b/src/hooks/useDonor.test.tsx index 96a62921..7ee1ba1b 100644 --- a/src/hooks/useDonor.test.tsx +++ b/src/hooks/useDonor.test.tsx @@ -62,7 +62,11 @@ describe("useDonor", () => { ...baseContracts, scholarshipTreasury: undefined, governanceToken: undefined, +<<<<<<< HEAD + isDeployed: () => false, +======= isDeployed: (_id: string | undefined): _id is string => false, +>>>>>>> main } as ReturnType) const { result } = renderHook(() => useDonor()) @@ -70,7 +74,11 @@ describe("useDonor", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.contributions).toHaveLength(0) - expect(result.current.stats.total_contributed).toBe(0n) +<<<<<<< HEAD + expect(result.current.stats.totalContributed).toBe(0) +======= + expect(result.current.stats.total_contributed).toBe(0) +>>>>>>> main expect(result.current.isEmpty).toBe(true) }) @@ -97,7 +105,11 @@ describe("useDonor", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.contributions.length).toBeGreaterThan(0) +<<<<<<< HEAD + expect(result.current.stats.totalContributed).toBeGreaterThan(0) +======= expect(result.current.stats.total_contributed).toBeGreaterThan(0) +>>>>>>> main }) it("handles fetch errors gracefully", async () => { diff --git a/src/hooks/useDonor.ts b/src/hooks/useDonor.ts index bc9c7902..9e3494ea 100644 --- a/src/hooks/useDonor.ts +++ b/src/hooks/useDonor.ts @@ -5,6 +5,10 @@ import { type DonorData, type DonorContribution, type DonorStats, +<<<<<<< HEAD +======= + type DonorImpact, +>>>>>>> main type Vote, type RpcEvent, } from "../types/contracts" @@ -14,6 +18,7 @@ import { useWallet } from "./useWallet" export type { DonorContribution, DonorStats, + DonorImpact, Vote, Scholar, DonorData, @@ -27,6 +32,7 @@ const emptyStats: DonorStats = { const makeEmptyData = (): DonorData => ({ stats: emptyStats, + impact: null, contributions: [], votes: [], scholars: [], @@ -52,6 +58,16 @@ const extractNumber = (value: unknown): number => { return match ? Number.parseInt(match[1] ?? "0", 10) : 0 } +const fetchDonorImpact = async (address: string): Promise => { + try { + const response = await fetch(`/api/donors/${address}/impact`) + if (!response.ok) return null + return await response.json() + } catch { + return null + } +} + const readContractEvents = async ( contractIds: string[], walletAddress: string, @@ -103,7 +119,10 @@ export const useDonor = (): DonorData => { const contractIds = [scholarshipTreasury, governanceToken].filter( (id): id is string => Boolean(id), ) - const events = await readContractEvents(contractIds, address) + const [events, impact] = await Promise.all([ + readContractEvents(contractIds, address), + fetchDonorImpact(address), + ]) const contributions: DonorContribution[] = events .filter((evt) => stringify({ @@ -154,6 +173,7 @@ export const useDonor = (): DonorData => { votes_cast: votes.length, scholars_funded: scholarsFunded, }, + impact, contributions, votes, scholars: [], diff --git a/src/hooks/useLeaderboard.ts b/src/hooks/useLeaderboard.ts index 6c68b383..16d2d384 100644 --- a/src/hooks/useLeaderboard.ts +++ b/src/hooks/useLeaderboard.ts @@ -1,5 +1,4 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query" -import { useEffect } from "react" +import { useQuery } from "@tanstack/react-query" import { API_URL } from "../lib/api" export type LeaderboardApiEntry = { @@ -25,50 +24,9 @@ export async function fetchLeaderboard( } export function useLeaderboard(address?: string) { - const queryClient = useQueryClient() - const queryKey = ["leaderboard", address] - - const query = useQuery({ - queryKey, + return useQuery({ + queryKey: ["leaderboard", address], queryFn: () => fetchLeaderboard(address), staleTime: 300 * 1000, // 5 minutes }) - - useEffect(() => { - // Subscribe to real-time updates via SSE - const streamUrl = new URL(`${API_URL}/api/leaderboard/stream`) - if (address) { - streamUrl.searchParams.append("viewer_address", address) - } - - const eventSource = new EventSource(streamUrl.toString()) - - eventSource.onmessage = (event) => { - try { - const data = JSON.parse(event.data) as LeaderboardData - // Update the query cache with fresh data from SSE - queryClient.setQueryData(["leaderboard", address], data) - } catch (err) { - console.error("[SSE] Failed to parse leaderboard update:", err) - } - } - - eventSource.onerror = (err) => { - console.error("[SSE] Leaderboard stream error:", err) - eventSource.close() - - // Simple fallback: invalidate query - setTimeout(() => { - void queryClient.invalidateQueries({ - queryKey: ["leaderboard", address], - }) - }, 5000) - } - - return () => { - eventSource.close() - } - }, [address, queryClient]) - - return query } diff --git a/src/hooks/useLearnToken.ts b/src/hooks/useLearnToken.ts index aab1da97..6ca996e2 100644 --- a/src/hooks/useLearnToken.ts +++ b/src/hooks/useLearnToken.ts @@ -1,10 +1,5 @@ import { type Api } from "@stellar/stellar-sdk/rpc" -import { - useMutation, - useQueries, - useQuery, - useQueryClient, -} from "@tanstack/react-query" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useCallback } from "react" import { useToast } from "../components/Toast/ToastProvider" import { type LearnTokenInfo } from "../types/contracts" @@ -310,45 +305,3 @@ export function useLearnToken(address?: string): UseLearnTokenResult { isMinting, } } - -/** - * Sum of LRN across several Stellar addresses. Uses the same query keys as - * {@link useLearnToken} so the balance cache is shared. - */ -export function useLrnTotalForLinkedWallets(addresses: string[]) { - const { learnToken: contractId, isDeployed } = useContractIds() - const contractReady = isDeployed(contractId) - - const results = useQueries({ - queries: addresses.map((targetAddress) => ({ - queryKey: [...BALANCE_QUERY_KEY_PREFIX, targetAddress] as const, - queryFn: async (): Promise => { - const client = await loadLearnTokenClient() - if (!client || !contractReady) return 0n - - const fn = toMethod(client, "balance") - if (!fn) return 0n - - const raw = await fn({ account: targetAddress, id: targetAddress }) - const resolved = unwrapResult(raw) - if ( - resolved !== null && - typeof resolved === "object" && - typeof (resolved as ContractRecord).isErr === "function" && - ((resolved as ContractRecord).isErr as () => boolean)() - ) { - return 0n - } - - return toBigInt(resolved) - }, - enabled: contractReady && targetAddress.length > 0, - staleTime: BALANCE_STALE_TIME, - })), - }) - - const isLoading = results.some((r) => r.isLoading) - const total = results.reduce((acc, r) => acc + toBigInt(r.data), 0n) - - return { total, isLoading } -} diff --git a/src/hooks/useLearnerProfile.ts b/src/hooks/useLearnerProfile.ts index a5004d13..b9d1cedb 100644 --- a/src/hooks/useLearnerProfile.ts +++ b/src/hooks/useLearnerProfile.ts @@ -1,12 +1,8 @@ import { useQuery } from "@tanstack/react-query" import { useWallet } from "./useWallet" -export type LinkedWalletInfo = { address: string; isPrimary: boolean } - export interface LearnerProfile { address: string - /** Stellar keys tied to the same app account; primary is the canonical id. */ - wallets: LinkedWalletInfo[] } /** @@ -37,11 +33,7 @@ export function useLearnerProfile() { throw new Error(error.error || "Failed to fetch learner profile") } - const json = (await response.json()) as LearnerProfile & { wallets?: LinkedWalletInfo[] } - const wallets = Array.isArray(json.wallets) && json.wallets.length > 0 - ? json.wallets - : [{ address: json.address, isPrimary: true }] - return { address: json.address, wallets } + return response.json() as Promise }, enabled: !!address, staleTime: 5 * 60 * 1000, // 5 minutes diff --git a/src/hooks/useProposals.ts b/src/hooks/useProposals.ts index 326e9b88..89b5cc29 100644 --- a/src/hooks/useProposals.ts +++ b/src/hooks/useProposals.ts @@ -110,11 +110,7 @@ async function readJson(response: Response): Promise { } if (!response.ok) { - throw new Error( - data.message || - data.error || - `Request failed (status ${response.status}). Check your connection and try again.`, - ) + throw new Error(data.message || data.error || "Request failed") } return data @@ -214,9 +210,7 @@ export function useProposals() { support: boolean }) => { if (!address) { - throw new Error( - "Wallet not connected — connect your wallet using the button in the navigation to vote.", - ) + throw new Error("Connect your wallet to vote") } const response = await fetch(`${API_BASE}/api/governance/vote`, { @@ -250,6 +244,37 @@ export function useProposals() { }, }) + const cancelProposalMutation = useMutation({ + mutationFn: async (proposalId: number) => { + if (!address) { + throw new Error("Connect your wallet to cancel proposal") + } + + const response = await fetch( + `${API_BASE}/api/proposals/${proposalId}/cancel`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + author_address: address, + }), + }, + ) + + return readJson<{ message: string }>(response) + }, + onSuccess: async (_data, proposalId) => { + await queryClient.invalidateQueries({ + queryKey: ["proposals"], + }) + await queryClient.invalidateQueries({ + queryKey: ["proposal", proposalId], + }) + }, + }) + return { proposals: proposalsQuery.data?.proposals ?? [], total: proposalsQuery.data?.total ?? 0, @@ -264,6 +289,8 @@ export function useProposals() { isSubmittingProposal: createProposalMutation.isPending, castVote: castVoteMutation.mutateAsync, isVoting: castVoteMutation.isPending, + cancelProposal: cancelProposalMutation.mutateAsync, + isCancelling: cancelProposalMutation.isPending, walletAddress: address, } } diff --git a/src/hooks/useScholarProfile.ts b/src/hooks/useScholarProfile.ts new file mode 100644 index 00000000..b22f111b --- /dev/null +++ b/src/hooks/useScholarProfile.ts @@ -0,0 +1,39 @@ +import { useQuery } from "@tanstack/react-query" + +export interface ScholarProfile { + address: string + lrn_balance: string + enrolled_courses: number + completed_milestones: number + pending_milestones: number + credentials: any[] + joined_at: string + follower_count: number + following_count: number + is_following: boolean +} + +export function useScholarProfile(address: string | undefined) { + return useQuery({ + queryKey: ["scholarProfile", address], + queryFn: async () => { + if (!address) throw new Error("Address is required") + + const response = await fetch(`/api/scholars/${address}`, { + headers: { + Authorization: `Bearer ${localStorage.getItem("authToken") || ""}`, + }, + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.error || "Failed to fetch scholar profile") + } + + const data = await response.json() + return data as ScholarProfile + }, + enabled: !!address, + staleTime: 30_000, + }) +} diff --git a/src/hooks/uselocalizeDocumentAttributes.ts b/src/hooks/uselocalizeDocumentAttributes.ts new file mode 100644 index 00000000..9050206e --- /dev/null +++ b/src/hooks/uselocalizeDocumentAttributes.ts @@ -0,0 +1,31 @@ +import { useEffect } from "react" +import { useTranslation } from "react-i18next" + +/** + * Synchronizes the document's lang and dir attributes with the + * active i18next locale. This ensures correct text direction (LTR/RTL) + * for accessibility, SEO, and browser rendering. + * + * Also localizes the document title if a translation key "app_title" + * is defined in the active locale. + */ +export function useLocalizeDocumentAttributes() { + const { t, i18n } = useTranslation() + + useEffect(() => { + if (i18n.resolvedLanguage) { + document.documentElement.lang = i18n.resolvedLanguage + document.documentElement.dir = i18n.dir(i18n.resolvedLanguage) + } + + // Localize document title when translation key exists + try { + const title = t("app_title", { defaultValue: "LearnVault" }) + if (title && title !== "LearnVault") { + document.title = title + } + } catch { + // Keep default title if translation is missing + } + }, [i18n, i18n.resolvedLanguage, t]) +} \ No newline at end of file diff --git a/src/i18n.ts b/src/i18n.ts index 5aae779b..259ab9ea 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -3,13 +3,17 @@ import LanguageDetector from "i18next-browser-languagedetector" import { initReactI18next } from "react-i18next" import en from "./locales/en.json" +import es from "./locales/es.json" import fr from "./locales/fr.json" import sw from "./locales/sw.json" +import ps from "./locales/ps.json" const resources = { en: { translation: en }, + es: { translation: es }, fr: { translation: fr }, sw: { translation: sw }, + ps: { translation: ps }, } void i18n diff --git a/src/index.css b/src/index.css index 2b6bfdbd..368afe69 100644 --- a/src/index.css +++ b/src/index.css @@ -167,38 +167,6 @@ body { } } -/* ── Skip-to-content link ─────────────────────────────────────────────────── */ -.skip-to-content { - position: absolute; - top: -100%; - left: 1rem; - z-index: 9999; - padding: 0.5rem 1rem; - background: #00d2ff; - color: #000; - font-weight: 700; - font-size: 0.875rem; - border-radius: 0 0 0.5rem 0.5rem; - text-decoration: none; - transition: top 0.15s ease; -} - -.skip-to-content:focus { - top: 0; -} - -/* ── Global focus-visible styles ──────────────────────────────────────────── */ -:focus-visible { - outline: 2px solid #00d2ff; - outline-offset: 2px; - border-radius: 4px; -} - -/* Remove outline for mouse/touch interactions; keep only for keyboard */ -:focus:not(:focus-visible) { - outline: none; -} - /* ── Light mode body background ───────────────────────────────────────────── */ .sds-theme-light body, .light body { @@ -362,3 +330,79 @@ body { .light ::-webkit-scrollbar-thumb:hover { background: rgba(0, 150, 210, 0.4); } +/* ── Driver.js Custom Styling ────────────────────────────────────────────── */ +.driver-popover { + background-color: var(--color-glass-bg) !important; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--color-glass-border) !important; + border-radius: 1.5rem !important; + color: var(--color-app-text) !important; + padding: 20px !important; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3) !important; +} + +.driver-popover-title { + font-family: "Inter", sans-serif !important; + font-weight: 900 !important; + font-size: 1.25rem !important; + color: var(--color-brand-cyan) !important; + margin-bottom: 8px !important; +} + +.driver-popover-description { + font-family: "Inter", sans-serif !important; + font-weight: 500 !important; + font-size: 0.9rem !important; + color: var(--color-app-text) !important; + opacity: 0.8; + line-height: 1.5 !important; +} + +.driver-popover-close-btn { + color: var(--color-app-text) !important; + opacity: 0.5; +} + +.driver-popover-close-btn:hover { + opacity: 1; + color: #ff4d4d !important; +} + +.driver-popover-arrow { + border-color: var(--color-glass-bg) !important; +} + +.driver-popover-footer button { + background: transparent !important; + border: 1px solid var(--color-glass-border) !important; + border-radius: 0.75rem !important; + color: var(--color-app-text) !important; + text-shadow: none !important; + font-weight: 700 !important; + padding: 6px 12px !important; + transition: all 0.2s ease !important; +} + +.driver-popover-footer button:hover { + background: var(--color-brand-cyan) !important; + color: #000 !important; + border-color: var(--color-brand-cyan) !important; +} + +.driver-popover-next-btn { + background: var(--color-brand-cyan) !important; + color: #000 !important; + border: none !important; +} + +.driver-popover-prev-btn { + margin-right: 8px !important; +} + +.driver-popover-progress-text { + color: var(--color-app-text) !important; + opacity: 0.5; + font-size: 11px !important; + font-weight: 700 !important; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 847584d0..e1c8e625 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,5 +1,4 @@ import { getAuthToken } from "../util/auth" -import { generateRequestId } from "../utils/errors" const readEnv = (...keys: string[]): string => { for (const key of keys) { @@ -43,25 +42,16 @@ export async function apiFetchJson( options: RequestInit & { auth?: boolean } = {}, ): Promise { const { auth = false, headers, ...init } = options - const requestId = generateRequestId() - const baseHeaders = auth - ? createAuthHeaders(headers) - : new Headers(headers as HeadersInit) - baseHeaders.set("X-Request-ID", requestId) - const response = await fetch(buildApiUrl(path), { ...init, - headers: baseHeaders, + headers: auth ? createAuthHeaders(headers) : headers, }) const payload = (await response.json().catch(() => ({}))) as T & { error?: string } if (!response.ok) { - const serverMessage = payload.error || `Request failed for ${path}` - const err = new Error(`${serverMessage} (ref: ${requestId})`) - ;(err as Error & { requestId: string }).requestId = requestId - throw err + throw new Error(payload.error || `Request failed for ${path}`) } return payload diff --git a/src/locales/es.json b/src/locales/es.json new file mode 100644 index 00000000..3354f456 --- /dev/null +++ b/src/locales/es.json @@ -0,0 +1,138 @@ +{ + "nav": { + "contractExplorer": "Explorador de Contratos", + "txExplorer": "Explorador de Transacciones", + "github": "GitHub", + "tutorial": "Tutorial", + "viewDocs": "Ver Documentación", + "debug": "Depurar Contratos", + "learn": "Aprender", + "dao": "DAO", + "leaderboard": "Clasificación", + "profile": "Mi Perfil", + "courses": "Cursos", + "treasury": "Tesorería", + "discord": "Discord", + "twitter": "Twitter", + "docs": "Documentación" + }, + "home": { + "heroTitle": "¡Yay! Estás en Stellar", + "heroDesc": "Una plantilla de desarrollo local diseñada para ayudarte a crear dApps en la red Stellar. Este entorno te permite probar fácilmente conexiones de billetera, interacciones con contratos inteligentes, verificaciones de transacciones, etc.", + "courseProgress": { + "title": "Progreso del Curso (Prueba del Issue 55)", + "desc": "Sigue tu viaje de aprendizaje y gana recompensas LRN completando hitos en la cadena." + }, + "milestones": { + "1": "Completar Lección 1", + "2": "Aprobar Cuestionario 1", + "3": "Construir tu primer contrato", + "locked": "Completa los hitos anteriores para desbloquear.", + "inProgress": "Actualmente trabajando en este hito.", + "submittingText": "Enviando TX...", + "markComplete": "Marcar como Completado", + "completedText": "¡Completado exitosamente!", + "tx": "TX", + "lrnReward": "+{{amount}} LRN" + }, + "sampleContracts": { + "title": "Contratos de Ejemplo", + "guess": "Adivina el Número:", + "guessDesc1": "Interactúa con el contrato de ejemplo del ", + "guessLink": "Tutorial de LearnVault", + "guessDesc2": " usando un cliente de contrato generado automáticamente.", + "other": "O echa un vistazo a otros contratos de ejemplo para empezar:", + "oz": "Contratos de ejemplo de OpenZeppelin", + "soroban": "Contratos de ejemplo de Soroban" + }, + "startBuilding": { + "title": "Empieza a Construir", + "step1": "Agrega tu contrato bajo ", + "step2": "Los contratos se compilan por LearnVault cuando ejecutas ", + "step3": "Los cambios se recompilan automáticamente por ", + "step4": "Interactúa con tu contrato inmediatamente en el Explorador de Contratos", + "watch": "Mira el proceso completo en nuestro ", + "youtube": "tutorial de YouTube", + "inspired": "Inspírate con nuestra vitrina de ", + "examples": "frontends de ejemplo", + "deploy": "¿Listo para desplegar? ", + "mainnet": "Lee la guía de despliegue en mainnet" + }, + "footer": { + "invoke": "Invoca tu contrato inteligente usando el ", + "contractLink": "Explorador de Contratos", + "browse": "Navega tus transacciones locales con el ", + "txLink": "Explorador de Transacciones" + } + }, + "connect": { + "connectWallet": "Conectar Billetera", + "fund": "Fondear Cuenta", + "faucetLoading": "Fondeando...", + "funded": "¡Cuenta fondeada exitosamente!", + "fundErrorDetail": "Error al fondear la cuenta: {{detail}}", + "fundErrorUnknown": "Error al fondear la cuenta: Error desconocido", + "fundErrorRetry": "Error al fondear la cuenta. Por favor, inténtalo de nuevo.", + "alreadyFunded": "La cuenta ya está fondeada", + "local": "Local", + "networkConnect": "Conecta tu billetera usando esta red.", + "networkMismatch": "La billetera está en {{wallet}}, conéctate a {{app}} en su lugar." + }, + "network": { + "testnetWarning": "Estás en Stellar Testnet — los tokens no tienen valor real", + "futurenetWarning": "Estás en Stellar Futurenet — red experimental para pruebas", + "localWarning": "Estás en Red Local — solo para desarrollo" + }, + "usdc": { + "getTestUSDC": "Obtener USDC de Prueba", + "minting": "Acuñando...", + "mintSuccess": "{{amount}} USDC acuñados exitosamente a {{address}}...", + "mintError": "Error al acuñar USDC: {{error}}", + "tooltip": "Obtén {{amount}} tokens USDC de prueba para probar donaciones y operaciones de tesorería" + }, + "wallet": { + "loading": "Cargando...", + "connect": "Conectar Billetera", + "balance": "Saldo de Billetera: {{amount}} LRN", + "connectedAs": "Conectado como ", + "disconnectPrompt": ". ¿Quieres desconectarte?", + "disconnect": "Desconectar", + "cancel": "Cancelar" + }, + "pages": { + "learn": { + "title": "Aprender", + "desc": "Esta es la página de Aprender." + }, + "dao": { + "title": "DAO", + "desc": "Esta es la página de DAO.", + "lastUpdated": "Última actualización: {{time}}" + }, + "leaderboard": { + "title": "Clasificación", + "desc": "Los mejores estudiantes clasificados por saldo de tokens LRN. Gana tu lugar.", + "rank": "Clasificación", + "learner": "Estudiante", + "lrnBalance": "Saldo LRN", + "courses": "Cursos", + "joined": "Unido", + "searchPlaceholder": "Buscar por dirección de billetera...", + "filterAll": "Todos los Tiempos", + "filterMonth": "Este Mes", + "filterWeek": "Esta Semana", + "myRank": "Mi Clasificación", + "loadMore": "Cargar Más", + "prev": "Anterior", + "next": "Siguiente", + "noResults": "No se encontraron estudiantes que coincidan con tu búsqueda.", + "connectPrompt": "Conecta tu billetera para ver tu clasificación.", + "notRanked": "Aún no clasificado", + "page": "Página {{current}} de {{total}}" + }, + "profile": { + "title": "Mi Perfil", + "desc": "Esta es la página de Mi Perfil." + } + } +} diff --git a/src/locales/ps.json b/src/locales/ps.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/locales/ps.json @@ -0,0 +1 @@ +{} diff --git a/src/main.tsx b/src/main.tsx index da323311..eb1f3fc9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -14,28 +14,6 @@ import { NotificationProvider } from "./providers/NotificationProvider.tsx" import { WalletProvider } from "./providers/WalletProvider.tsx" import "./i18n" import { parseError } from "./util/error" -import { initSentry } from "./lib/sentry" - -// Initialize Sentry for error monitoring -initSentry({ - dsn: import.meta.env.VITE_SENTRY_DSN, - environment: import.meta.env.VITE_SENTRY_ENVIRONMENT || "development", - release: - import.meta.env.VITE_SENTRY_RELEASE || - import.meta.env.VITE_GIT_COMMIT_HASH, - tracesSampleRate: - import.meta.env.VITE_SENTRY_TRACES_SAMPLE_RATE - ? parseFloat(import.meta.env.VITE_SENTRY_TRACES_SAMPLE_RATE) - : 0.1, - replaysSessionSampleRate: - import.meta.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE - ? parseFloat(import.meta.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE) - : 0.1, - replaysOnErrorSampleRate: - import.meta.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE - ? parseFloat(import.meta.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE) - : 1.0, -}) // Issue #61 — FOUC prevention: apply theme before first render ;(function () { diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index f37befa0..8c1cc8b4 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -1,19 +1,20 @@ +<<<<<<< HEAD +import React, { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" +======= import { useQuery } from "@tanstack/react-query" import React, { useEffect, useMemo, useState } from "react" import ReactMarkdown from "react-markdown" +>>>>>>> main import { useNavigate } from "react-router-dom" -import { - RadialBarChart, - RadialBar, - Legend, - ResponsiveContainer, - Tooltip, -} from "recharts" -import AddressDisplay from "../components/AddressDisplay" import TxHashLink from "../components/TxHashLink" import { useAdminStats, useAdminMilestones, +<<<<<<< HEAD + type MilestoneSubmission, +} from "../hooks/useAdmin" +======= type BatchMilestoneResponse, type MilestoneSubmission, } from "../hooks/useAdmin" @@ -32,20 +33,106 @@ import { import { apiFetchJson } from "../lib/api" import { getAuthToken } from "../util/auth" import { shortenContractId } from "../util/contract" - -const API_BASE = import.meta.env.VITE_SERVER_URL || "http://localhost:4000" +>>>>>>> main type AdminSection = | "courses" | "milestones" | "users" +<<<<<<< HEAD +======= | "wiki" +>>>>>>> main | "treasury" - | "scholarships" | "contracts" type CourseStatus = "draft" | "published" interface AdminCourse { +<<<<<<< HEAD + id: number + title: string + status: CourseStatus + students: number +} + +interface UserProfilePreview { + address: string + balance: string + enrollment: string + tier: string +} + +interface ContractRecord { + name: string + tag: string + address: string + updated: string +} + +interface CourseImportRow { + title: string + slug: string + track: string + difficulty: string + description?: string + coverImage?: string | null + published?: boolean +} + +interface BulkImportResult { + row: number + slug: string + success: boolean + errors: string[] +} + +const initialCourses: AdminCourse[] = [ + { id: 1, title: "Soroban Basics", status: "published", students: 84 }, + { id: 2, title: "Stellar Security", status: "draft", students: 0 }, +] + +const contractRecords: ContractRecord[] = [ + { + name: "Scholarship Treasury", + tag: "prod", + address: "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + updated: "2026-03-20", + }, + { + name: "Governance Token", + tag: "prod", + address: "CYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + updated: "2026-03-20", + }, +] + +const COURSES = [ + "All", + "Soroban Basics", + "Stellar Security", + "Web3 Dev", + "DeFi", + "Frontend Dev", +] +const STATUSES = ["pending", "approved", "rejected"] + +// --------------------------------------------------------------------------- +// Confirmation dialog +// --------------------------------------------------------------------------- +interface ConfirmDialogProps { + action: "approve" | "reject" + milestone: MilestoneSubmission + onConfirm: () => void + onCancel: () => void +} + +const ConfirmDialog: React.FC = ({ + action, + milestone, + onConfirm, + onCancel, +}) => ( +======= id: string slug: string title: string @@ -85,31 +172,10 @@ const sectionDescriptions: Record = { milestones: "Review milestone reports and approvals.", users: "Lookup learner profiles by wallet address.", wiki: "Create and edit platform documentation and guides.", - treasury: "Monitor and manage treasury controls.", - scholarships: "View scholarship program health metrics.", - contracts: "Inspect deployed on-chain contract records.", + treasury: "Monitor and manage live treasury controls.", + contracts: "Inspect deployed contract addresses and on-chain state.", } -const initialCourses: AdminCourse[] = [ - { id: 1, title: "Soroban Basics", status: "published", students: 84 }, - { id: 2, title: "Stellar Security", status: "draft", students: 0 }, -] - -const contractRecords: ContractRecord[] = [ - { - name: "Scholarship Treasury", - tag: "prod", - address: "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - updated: "2026-03-20", - }, - { - name: "Governance Token", - tag: "prod", - address: "CYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", - updated: "2026-03-20", - }, -] - const STATUSES = ["pending", "approved", "rejected"] as const const formatDate = (value: string | undefined): string => { @@ -128,18 +194,6 @@ const formatDate = (value: string | undefined): string => { const formatCount = (value: number): string => value.toLocaleString("en-US", { maximumFractionDigits: 0 }) -const formatPercent = (value: number): string => { - if (!Number.isFinite(value)) return "0.0%" - return `${value.toFixed(1)}%` -} - -const formatReviewTime = (seconds: number): string => { - if (!Number.isFinite(seconds) || seconds < 0) return "-" - if (seconds < 60) return `${Math.round(seconds)}s` - if (seconds < 3600) return `${(seconds / 60).toFixed(1)}m` - return `${(seconds / 3600).toFixed(1)}h` -} - const renderAddress = (value: string | undefined) => value ? shortenContractId(value, 6, 6) : "Not available" @@ -176,6 +230,7 @@ const ConfirmDialog: React.FC<{ onConfirm: () => void onCancel: () => void }> = ({ action, milestone, onConfirm, onCancel }) => ( +>>>>>>> main Learner:{" "} - + {milestone.learnerAddress} @@ -205,7 +257,11 @@ const ConfirmDialog: React.FC<{ > {action} {" "} +<<<<<<< HEAD + this submission? This action cannot be undone. +======= this submission? +>>>>>>> main { ] return ( +<<<<<<< HEAD + + {error && ( + +======= {error && ( - Could not load stats — {error}. Refresh the page to try again. +>>>>>>> main + Failed to load stats: {error} )} {items.map((item) => ( @@ -283,6 +345,8 @@ const MilestoneStatsBar: React.FC = () => { ) } +<<<<<<< HEAD +======= const EvidenceLink: React.FC<{ value: string }> = ({ value }) => { if (!value) { @@ -304,8 +368,26 @@ const EvidenceLink: React.FC<{ value: string }> = ({ value }) => { return } +>>>>>>> main const Admin: React.FC = () => { +<<<<<<< HEAD + const { t } = useTranslation() + const [activeSection, setActiveSection] = useState("courses") + const [isAdmin, setIsAdmin] = useState(false) + const navigate = useNavigate() + + useEffect(() => { + const token = localStorage.getItem("admin_token") + if (token === "mock-admin-jwt") { + setIsAdmin(true) + return + } + void navigate("/") + }, [navigate]) + + if (!isAdmin) return null +======= const [activeSection, setActiveSection] = useState("courses") const navigate = useNavigate() const authToken = getAuthToken() @@ -317,21 +399,25 @@ const Admin: React.FC = () => { }, [authToken, navigate]) if (!authToken) return null +>>>>>>> main return ( +>>>>>>> main ) } const CourseManagement: React.FC = () => { +<<<<<<< HEAD + const { t } = useTranslation() + const [courses, setCourses] = useState(initialCourses) + const [fileName, setFileName] = useState("") + const [previewRows, setPreviewRows] = useState([]) + const [previewErrors, setPreviewErrors] = useState([]) + const [importResults, setImportResults] = useState([]) + const [isSubmitting, setIsSubmitting] = useState(false) + const [alertMessage, setAlertMessage] = useState(null) + + const handleFileUpload = async (event: React.ChangeEvent) => { + setImportResults([]) + setAlertMessage(null) + const file = event.target.files?.[0] + if (!file) { + return + } + + setFileName(file.name) + const contents = await file.text() + let rows: CourseImportRow[] = [] + if (file.name.toLowerCase().endsWith(".json")) { + try { + const parsed = JSON.parse(contents) + rows = Array.isArray(parsed) ? parsed : parsed.courses ?? [] + } catch { + setPreviewErrors([t("admin.import.invalidJson")]) + return + } + } else { + rows = parseCsvText(contents) + } + + const errors: string[] = [] + const normalizedRows = rows.map((row, index) => { + const normalized = { + ...row, + title: row.title?.trim() ?? "", + slug: row.slug?.trim() ?? "", + track: row.track?.trim() ?? "", + difficulty: row.difficulty?.trim() ?? "", + description: row.description?.trim(), + coverImage: row.coverImage?.trim() || null, + published: Boolean(row.published), + } + + if (!isCourseRowValid(normalized)) { + errors.push(`${t("admin.import.invalidRow")} ${index + 1}`) + } + + return normalized + }) + + setPreviewRows(normalizedRows) + setPreviewErrors(errors) + } + + const handleImport = async () => { + setIsSubmitting(true) + setImportResults([]) + setAlertMessage(null) + const token = localStorage.getItem("admin_token") ?? "" + + try { + const response = await fetch("/api/admin/courses/bulk-import", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ courses: previewRows }), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || t("admin.import.importFailed")) + } + + const data = (await response.json()) as { + results: BulkImportResult[] + total: number + imported: number + } + setImportResults(data.results) + setAlertMessage(t("admin.import.importSuccess", { count: data.imported })) + } catch (error) { + setAlertMessage(String(error)) + } finally { + setIsSubmitting(false) + } + } + + return ( + + +