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 && (
-
- )}
+
+
+
+ {gate.publicKey && }
+
+ {!isAddingReview && !isOwner && (
+
+ )}
+
+ {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 (