Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions dongle/__tests__/integration/category-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});

Expand All @@ -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);
}
});
});
Expand All @@ -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);
}
});

Expand All @@ -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);
}
});
});
Expand Down Expand Up @@ -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) {
Expand All @@ -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
);

Expand All @@ -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);
}
});
});
Expand Down
5 changes: 4 additions & 1 deletion dongle/__tests__/integration/discover-to-detail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
Expand Down
6 changes: 4 additions & 2 deletions dongle/__tests__/pages/Discover.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ vi.mock("@/hooks/useDiscoverParams", () => ({
searchInput,
searchQuery,
category,
tags: [],
sortBy,
page,
setTags: vi.fn(),
setSearchInput: (value: string) => {
setSearchInputState(value);
setSearchQuery(value);
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
});

Expand Down
3 changes: 2 additions & 1 deletion dongle/__tests__/pages/profile.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 5 additions & 4 deletions dongle/__tests__/services/project.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,23 @@ 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", () => {
expect(projectService.getProjectById("missing-project")).toBeNull();
});

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", () => {
Expand Down
21 changes: 19 additions & 2 deletions dongle/app/discover/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -21,10 +22,12 @@ function DiscoverContent() {
searchInput,
searchQuery,
category,
tags,
sortBy,
page,
setSearchInput,
setCategory,
setTags,
setSortBy,
loadNextPage,
clearFilters,
Expand All @@ -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;
Expand Down Expand Up @@ -133,6 +140,16 @@ function DiscoverContent() {
</select>
</div>
</div>

{/* Tags filtering */}
<div className="mt-4 max-w-xl">
<TagInput
label="Filter by Tags"
tags={tags}
onChange={setTags}
placeholder="Add tags to filter..."
/>
</div>
</div>

{/* Initial loading */}
Expand Down
126 changes: 116 additions & 10 deletions dongle/app/projects/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand All @@ -45,6 +48,8 @@ export default function ProjectDetailPage() {
const [showWalletGate, setShowWalletGate] = useState(false);
const [isAddingReview, setIsAddingReview] = useState(false);
const [editingReview, setEditingReview] = useState<Review | null>(null);
const [isReporting, setIsReporting] = useState(false);
const [reviewSort, setReviewSort] = useState<"newest" | "highest" | "lowest" | "mine">("newest");

useEffect(() => {
// Simulate data loading
Expand All @@ -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);
Expand Down Expand Up @@ -248,22 +289,73 @@ export default function ProjectDetailPage() {

{/* Reviews Section */}
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-3xl p-8">
<div className="flex items-center justify-between mb-6">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-8 gap-4">
<h2 className="text-2xl font-bold flex items-center gap-2">
<MessageSquare className="w-6 h-6" />
Reviews
</h2>
{!isAddingReview && (
<Button
variant="primary"
size="sm"
onClick={handleAddReview}
<div className="flex gap-2">
<select
value={reviewSort}
onChange={(e) => setReviewSort(e.target.value as any)}
className="bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg px-3 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500/20"
>
Write a Review
</Button>
)}
<option value="newest">Newest First</option>
<option value="highest">Highest Rating</option>
<option value="lowest">Lowest Rating</option>
{gate.publicKey && <option value="mine">My Reviews</option>}
</select>
{!isAddingReview && !isOwner && (
<Button
variant="primary"
onClick={handleAddReview}
>
Write a Review
</Button>
)}
</div>
</div>

{reviews.length > 0 && (
<div className="mb-8 p-6 bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl border border-zinc-200 dark:border-zinc-800 flex flex-col md:flex-row gap-8 items-center">
<div className="text-center md:text-left">
<div className="text-5xl font-black mb-1">{project?.rating}</div>
<div className="flex items-center justify-center md:justify-start gap-1 mb-2">
{[1, 2, 3, 4, 5].map((star) => (
<Star key={star} className={`w-4 h-4 ${star <= (project?.rating || 0) ? 'text-yellow-500 fill-yellow-500' : 'text-zinc-300 dark:text-zinc-700'}`} />
))}
</div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">{reviews.length} total reviews</div>
</div>
<div className="flex-1 w-full max-w-sm space-y-2">
{[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 (
<div key={star} className="flex items-center gap-3 text-sm">
<div className="w-12 text-zinc-500 dark:text-zinc-400 font-medium flex items-center gap-1">
{star} <Star className="w-3 h-3" />
</div>
<div className="flex-1 h-2.5 bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden">
<div className="h-full bg-yellow-500 rounded-full" style={{ width: `${percentage}%` }} />
</div>
<div className="w-10 text-right text-zinc-500 dark:text-zinc-400">
{percentage}%
</div>
</div>
);
})}
</div>
</div>
)}

{isOwner && (
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 rounded-xl border border-blue-100 dark:border-blue-900/50 text-sm flex items-start gap-3">
<Info className="w-5 h-5 shrink-0 mt-0.5" />
<p>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.</p>
</div>
)}

{/* Soft gate banner for disconnected users */}
{gate.state === "disconnected" && !showWalletGate && (
<WalletDisconnectedBanner
Expand Down Expand Up @@ -303,7 +395,7 @@ export default function ProjectDetailPage() {
</div>
)}
<ReviewList
reviews={reviews}
reviews={sortedReviews}
currentUserAddress={gate.publicKey}
onEdit={handleEdit}
onDelete={handleDelete}
Expand Down Expand Up @@ -362,11 +454,25 @@ export default function ProjectDetailPage() {
>
Request Verification
</Button>
<Button
variant="outline"
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/30 border-red-200 dark:border-red-900"
onClick={() => setIsReporting(true)}
>
Report Project
</Button>
</div>
</div>
</div>
</div>
</div>

<ReportProjectModal
isOpen={isReporting}
projectName={project?.name || ""}
onClose={() => setIsReporting(false)}
onSubmit={handleReportSubmit}
/>
</main>
);
}
Loading