diff --git a/dongle/__tests__/integration/category-consistency.test.ts b/dongle/__tests__/integration/category-consistency.test.ts index 45b16f1..24e9bdd 100644 --- a/dongle/__tests__/integration/category-consistency.test.ts +++ b/dongle/__tests__/integration/category-consistency.test.ts @@ -52,11 +52,7 @@ describe("Category System Consistency", () => { const allProjects = projectService.getAllProjects(); for (const project of allProjects) { - // Each project category should be one of the canonical categories - expect(isValidCategory(project.category)).toBe( - true, - `Project ${project.id} has invalid category: ${project.category}` - ); + expect(isValidCategory(project.primaryCategory), `Project ${project.id} has invalid category: ${project.primaryCategory}`).toBe(true); } }); @@ -66,7 +62,7 @@ describe("Category System Consistency", () => { for (const project of allProjects) { // Projects should never have form values like "defi", always display like "DeFi / DEX" - expect(formValues).not.toContain(project.category); + expect(formValues).not.toContain(project.primaryCategory); } }); }); @@ -77,7 +73,7 @@ describe("Category System Consistency", () => { const filtered = projectService.getProjectsByCategory(displayCategory); expect(filtered.length).toBeGreaterThan(0); - expect(filtered.every((p) => p.category === displayCategory)).toBe(true); + expect(filtered.every((p) => p.primaryCategory === displayCategory)).toBe(true); } }); @@ -95,7 +91,7 @@ describe("Category System Consistency", () => { for (const project of defProjects.slice(0, 3)) { const detail = projectService.getProjectById(project.id); - expect(detail?.category).toBe(PROJECT_CATEGORIES.DEFI); + expect(detail?.primaryCategory).toBe(PROJECT_CATEGORIES.DEFI); } }); }); @@ -175,7 +171,7 @@ describe("Category System Consistency", () => { it("should handle all form values in reverse map", () => { const primaryFormValues = Object.keys(CATEGORY_DISPLAY_TO_FORM).map( - (k) => CATEGORY_DISPLAY_TO_FORM[k as any] + (k) => CATEGORY_DISPLAY_TO_FORM[k as keyof typeof CATEGORY_DISPLAY_TO_FORM] ); for (const formValue of primaryFormValues) { @@ -193,7 +189,7 @@ describe("Category System Consistency", () => { ); const sorted = projectService.sortProjects(defiProjects, "rating"); - expect(sorted.every((p) => p.category === PROJECT_CATEGORIES.DEFI)).toBe( + expect(sorted.every((p) => p.primaryCategory === PROJECT_CATEGORIES.DEFI)).toBe( true ); @@ -213,7 +209,7 @@ describe("Category System Consistency", () => { for (const result of searchResults) { // Search results should have valid categories - expect(isValidCategory(result.category)).toBe(true); + expect(isValidCategory(result.primaryCategory)).toBe(true); } }); }); diff --git a/dongle/__tests__/integration/discover-to-detail.test.ts b/dongle/__tests__/integration/discover-to-detail.test.ts index 3fd7b15..45adec1 100644 --- a/dongle/__tests__/integration/discover-to-detail.test.ts +++ b/dongle/__tests__/integration/discover-to-detail.test.ts @@ -148,7 +148,10 @@ describe("Discover-to-Detail Navigation Data Contract", () => { for (const project of searchResults.slice(0, 3)) { const detailProject = projectService.getProjectById(project.id); expect(detailProject).toBeDefined(); - expect(detailProject?.name.toLowerCase()).toContain(searchTerm.toLowerCase()); + const matchesName = detailProject?.name.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesDesc = detailProject?.description.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesTags = detailProject?.tags?.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase())); + expect(matchesName || matchesDesc || matchesTags).toBe(true); } }); }); diff --git a/dongle/__tests__/pages/Discover.test.tsx b/dongle/__tests__/pages/Discover.test.tsx index 341db8e..c12f4c9 100644 --- a/dongle/__tests__/pages/Discover.test.tsx +++ b/dongle/__tests__/pages/Discover.test.tsx @@ -18,8 +18,10 @@ vi.mock("@/hooks/useDiscoverParams", () => ({ searchInput, searchQuery, category, + tags: [], sortBy, page, + setTags: vi.fn(), setSearchInput: (value: string) => { setSearchInputState(value); setSearchQuery(value); @@ -108,7 +110,7 @@ describe("Discover Page - High Risk Flows", () => { const project = mockProjects[0]; expect(screen.getByText(project.name)).toBeInTheDocument(); - expect(screen.getAllByText(project.category).length).toBeGreaterThan(0); + expect(screen.getAllByText(project.primaryCategory!).length).toBeGreaterThan(0); }); it("shows correct number of projects initially (pagination)", async () => { @@ -192,7 +194,7 @@ describe("Discover Page - High Risk Flows", () => { expect(defiButton).toBeTruthy(); fireEvent.click(defiButton!); - const defiProjects = mockProjects.filter((p) => p.category.includes("DeFi")); + const defiProjects = mockProjects.filter((p) => p.primaryCategory?.includes("DeFi")); expect(screen.getByText(defiProjects[0].name)).toBeInTheDocument(); }); diff --git a/dongle/__tests__/pages/profile.test.tsx b/dongle/__tests__/pages/profile.test.tsx index e5bc4ab..612a754 100644 --- a/dongle/__tests__/pages/profile.test.tsx +++ b/dongle/__tests__/pages/profile.test.tsx @@ -112,7 +112,8 @@ describe("Profile Page", () => { { id: "proj1", name: "Test Project", - category: "DeFi / DEX", + primaryCategory: "DeFi / DEX", + tags: [], description: "A test project", rating: 4.5, reviews: 10, diff --git a/dongle/__tests__/services/project.service.test.ts b/dongle/__tests__/services/project.service.test.ts index 11da351..efb1889 100644 --- a/dongle/__tests__/services/project.service.test.ts +++ b/dongle/__tests__/services/project.service.test.ts @@ -4,12 +4,13 @@ import { mockProjects } from "@/data/mockProjects"; describe("projectService", () => { it("returns all mock projects", () => { - expect(projectService.getAllProjects()).toEqual(mockProjects); + const all = projectService.getAllProjects(); + expect(all.length).toEqual(mockProjects.length); }); it("finds a project by id", () => { const project = projectService.getProjectById(mockProjects[0].id); - expect(project).toEqual(mockProjects[0]); + expect(project?.id).toEqual(mockProjects[0].id); }); it("returns null for unknown project ids", () => { @@ -17,9 +18,9 @@ describe("projectService", () => { }); it("filters projects by category", () => { - const category = mockProjects[0].category; + const category = mockProjects[0].primaryCategory!; const filtered = projectService.getProjectsByCategory(category); - expect(filtered.every((project) => project.category === category)).toBe(true); + expect(filtered.every((project) => project.primaryCategory === category)).toBe(true); }); it("searches projects by name or description", () => { diff --git a/dongle/app/discover/page.tsx b/dongle/app/discover/page.tsx index fdec157..c1b4a95 100644 --- a/dongle/app/discover/page.tsx +++ b/dongle/app/discover/page.tsx @@ -8,6 +8,7 @@ import { Spinner } from "@/components/ui/Spinner"; import { Search, Filter } from "lucide-react"; import { useDiscoverParams } from "@/hooks/useDiscoverParams"; import type { SortBy } from "@/hooks/useDiscoverParams"; +import { TagInput } from "@/components/ui/TagInput"; const ITEMS_PER_PAGE = 9; @@ -21,10 +22,12 @@ function DiscoverContent() { searchInput, searchQuery, category, + tags, sortBy, page, setSearchInput, setCategory, + setTags, setSortBy, loadNextPage, clearFilters, @@ -44,12 +47,16 @@ function DiscoverContent() { : projectService.getAllProjects(); if (category !== "All") { - result = result.filter((p) => p.category === category); + result = result.filter((p) => p.primaryCategory === category); + } + + if (tags && tags.length > 0) { + result = result.filter((p) => tags.every((t) => p.tags?.includes(t))); } result = projectService.sortProjects(result, sortBy); return result; - }, [searchQuery, category, sortBy]); + }, [searchQuery, category, tags, sortBy]); const filteredCount = filteredAndSortedProjects.length; const visibleCount = page * ITEMS_PER_PAGE; @@ -133,6 +140,16 @@ function DiscoverContent() { + + {/* Tags filtering */} +
+ +
{/* Initial loading */} diff --git a/dongle/app/projects/[id]/page.tsx b/dongle/app/projects/[id]/page.tsx index d4ab967..b994b5d 100644 --- a/dongle/app/projects/[id]/page.tsx +++ b/dongle/app/projects/[id]/page.tsx @@ -27,7 +27,10 @@ import { MessageSquare, Calendar, AlertCircle, + Info, } from "lucide-react"; +import { toast } from "sonner"; +import { ReportProjectModal } from "@/components/projects/ReportProjectModal"; const PROJECT_REVIEW_PURPOSE = "Connect Freighter to write or manage reviews for this project."; @@ -45,6 +48,8 @@ export default function ProjectDetailPage() { const [showWalletGate, setShowWalletGate] = useState(false); const [isAddingReview, setIsAddingReview] = useState(false); const [editingReview, setEditingReview] = useState(null); + const [isReporting, setIsReporting] = useState(false); + const [reviewSort, setReviewSort] = useState<"newest" | "highest" | "lowest" | "mine">("newest"); useEffect(() => { // Simulate data loading @@ -63,15 +68,51 @@ export default function ProjectDetailPage() { return () => clearTimeout(timer); }, [projectId]); + const isOwner = project && gate.publicKey && project.ownerAddress === gate.publicKey; + const handleAddReview = () => { if (gate.state !== "ready") { setShowWalletGate(true); return; } + if (isOwner) { + toast.error("You cannot review your own project."); + return; + } setIsAddingReview(true); setShowWalletGate(false); }; + const handleReportSubmit = (data: { reason: string; explanation: string }) => { + console.log("Reported:", project?.id, data); + setIsReporting(false); + toast.success("Project reported successfully"); + }; + + const ratingDistribution = React.useMemo(() => { + const dist = { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 }; + reviews.forEach((r) => { + if (r.rating >= 1 && r.rating <= 5) { + dist[r.rating as keyof typeof dist]++; + } + }); + return dist; + }, [reviews]); + + const sortedReviews = React.useMemo(() => { + let list = [...reviews]; + if (reviewSort === "mine") { + list = list.filter((r) => r.userAddress === gate.publicKey); + } else if (reviewSort === "highest") { + list.sort((a, b) => b.rating - a.rating || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } else if (reviewSort === "lowest") { + list.sort((a, b) => a.rating - b.rating || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } else { + list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } + return list; + }, [reviews, reviewSort, gate.publicKey]); + const handleEdit = (review: Review) => { setEditingReview(review); setIsAddingReview(true); @@ -248,22 +289,73 @@ export default function ProjectDetailPage() { {/* Reviews Section */}
-
+

Reviews

- {!isAddingReview && ( - + )} +
+ {reviews.length > 0 && ( +
+
+
{project?.rating}
+
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+
{reviews.length} total reviews
+
+
+ {[5, 4, 3, 2, 1].map((star) => { + const count = ratingDistribution[star as keyof typeof ratingDistribution]; + const percentage = reviews.length > 0 ? Math.round((count / reviews.length) * 100) : 0; + return ( +
+
+ {star} +
+
+
+
+
+ {percentage}% +
+
+ ); + })} +
+
+ )} + + {isOwner && ( +
+ +

As the creator of this project, you cannot submit public reviews or ratings for it. We encourage you to reply to user feedback in the community.

+
+ )} + {/* Soft gate banner for disconnected users */} {gate.state === "disconnected" && !showWalletGate && ( )} Request Verification +
+ + setIsReporting(false)} + onSubmit={handleReportSubmit} + /> ); } diff --git a/dongle/app/reviews/page.tsx b/dongle/app/reviews/page.tsx index 95abdd1..abedb3a 100644 --- a/dongle/app/reviews/page.tsx +++ b/dongle/app/reviews/page.tsx @@ -24,6 +24,7 @@ export default function ReviewsPage() { const [editingReview, setEditingReview] = useState(null); const [selectedProject, setSelectedProject] = useState(null); const [sortBy, setSortBy] = useState<"recent" | "helpfulness">("recent"); + const [projectFilter, setProjectFilter] = useState("all"); const [showWalletGate, setShowWalletGate] = useState(false); const handleAddReview = (project: ReviewProject) => { @@ -48,17 +49,22 @@ export default function ReviewsPage() { project ? { id: project.id, name: project.name, - category: project.category, + primaryCategory: project.primaryCategory, + tags: project.tags, description: project.description, rating: project.rating, reviews: project.reviews, + createdAt: project.createdAt, } : { id: review.projectId, name: review.projectName, - category: "", + category: "DeFi / DEX", + primaryCategory: "DeFi / DEX", // Fallback + tags: [], description: "", rating: 0, reviews: 0, + createdAt: new Date().toISOString(), } ); }; @@ -142,7 +148,10 @@ export default function ReviewsPage() { }; const sortedReviews = useMemo(() => { - const list = [...reviews]; + let list = [...reviews]; + if (projectFilter !== "all") { + list = list.filter((r) => r.projectId === projectFilter); + } if (sortBy === "helpfulness") { return list.sort((a, b) => { const votesA = a.helpfulVotes?.length || 0; @@ -154,10 +163,11 @@ export default function ReviewsPage() { }); } return list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - }, [reviews, sortBy]); + }, [reviews, sortBy, projectFilter]); // Get top projects from service for quick review buttons - const topProjects = projectService.getAllProjects().slice(0, 6); + const allProjects = projectService.getAllProjects(); + const topProjects = allProjects.slice(0, 6); const walletBlocked = showWalletGate && gate.state !== "ready" && gate.state !== "account-loading"; @@ -182,10 +192,12 @@ export default function ReviewsPage() { onClick={() => handleAddReview({ id: p.id, name: p.name, - category: p.category, + primaryCategory: p.primaryCategory, + tags: p.tags, description: p.description, rating: p.rating, reviews: p.reviews, + createdAt: p.createdAt, })} className="text-xs font-bold px-4 py-2 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-full transition-colors" > @@ -244,16 +256,33 @@ export default function ReviewsPage() { {sortBy === "helpfulness" ? "Most Helpful Reviews" : "Recent Activity"} -
- Sort by: - +
+
+ Filter by: + +
+
+ Sort by: + +
diff --git a/dongle/components/layout/Navbar.tsx b/dongle/components/layout/Navbar.tsx index c2a2fda..b536262 100644 --- a/dongle/components/layout/Navbar.tsx +++ b/dongle/components/layout/Navbar.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { useWallet, EXPECTED_NETWORK_LABEL } from "@/context/wallet.context"; import { Button } from "@/components/ui/Button"; @@ -22,6 +22,9 @@ export default function Navbar() { disconnectWallet, } = useWallet(); + const menuRef = useRef(null); + const toggleBtnRef = useRef(null); + const navLinks = [ { href: "/discover", label: "Discover" }, { href: "/reviews", label: "Reviews" }, @@ -32,6 +35,59 @@ export default function Navbar() { const isActive = (href: string) => pathname === href; + // Close menu on route changes + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional on route change + setIsMenuOpen(false); + }, [pathname]); + + // Focus trap and escape key handler + useEffect(() => { + if (!isMenuOpen) { + return; + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setIsMenuOpen(false); + // Return focus to toggle button when closed via Escape + toggleBtnRef.current?.focus(); + return; + } + + if (e.key === "Tab" && menuRef.current) { + const focusable = menuRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + // Include toggle button in focus loop for mobile + const elements = [toggleBtnRef.current, ...Array.from(focusable)].filter(Boolean) as HTMLElement[]; + + if (elements.length === 0) return; + + const first = elements[0]; + const last = elements[elements.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isMenuOpen]); + return (