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
39 changes: 39 additions & 0 deletions dongle/app/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import LayoutWrapper from "@/components/layout/LayoutWrapper";
import { projectService } from "@/services/project/project.service";
import { reviewService } from "@/services/review/review.service";
import { verificationService, type VerificationRequest } from "@/services/stellar/verification.service";
import { Button } from "@/components/ui/Button";
Expand All @@ -23,9 +24,12 @@ import {
Clock,
XCircle,
Package,
Bookmark,
} from "lucide-react";
import AddressDisplay from "@/components/ui/AddressDisplay";
import { formatDate } from "@/lib/date";
import { ProjectCard } from "@/components/projects/ProjectCard";
import { useSavedProjects } from "@/hooks/useSavedProjects";

interface StellarNonNativeBalance {
asset_code?: string;
Expand All @@ -39,6 +43,7 @@ export default function ProfilePage() {
const router = useRouter();
const gate = useWalletPageGate({ requireFundedAccount: true });
const { balances } = useStellarAccount();
const { savedProjectIds } = useSavedProjects();
const [verificationRequests, setVerificationRequests] = useState<VerificationRequest[]>([]);
const [loadedVerificationKey, setLoadedVerificationKey] = useState<string | null>(null);

Expand All @@ -49,6 +54,9 @@ export default function ProfilePage() {
const displayedVerificationRequests = gate.publicKey ? verificationRequests : [];
const loadingVerifications =
Boolean(gate.publicKey) && loadedVerificationKey !== gate.publicKey;
const savedProjects = savedProjectIds
.map((projectId) => projectService.getProjectById(projectId))
.filter((project): project is NonNullable<typeof project> => Boolean(project));

useEffect(() => {
if (!gate.publicKey) {
Expand Down Expand Up @@ -243,6 +251,37 @@ export default function ProfilePage() {
)}
</div>

<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">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Bookmark className="w-6 h-6" />
Saved Projects
</h2>
<Badge variant="secondary">{savedProjects.length}</Badge>
</div>

{savedProjects.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{savedProjects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
) : (
<div className="text-center py-12 text-zinc-500 dark:text-zinc-400">
<Bookmark className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No saved projects yet. Bookmark projects to revisit them later.</p>
<Button
variant="outline"
size="sm"
onClick={() => router.push("/discover")}
className="mt-4"
>
Browse Projects
</Button>
</div>
)}
</div>

<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">
<h2 className="text-2xl font-bold flex items-center gap-2">
Expand Down
27 changes: 26 additions & 1 deletion dongle/app/projects/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ import {
Calendar,
AlertCircle,
Info,
Bookmark,
BookmarkCheck,
} from "lucide-react";
import { toast } from "sonner";
import { ReportProjectModal } from "@/components/projects/ReportProjectModal";
import { useSavedProjects } from "@/hooks/useSavedProjects";

const PROJECT_REVIEW_PURPOSE =
"Connect Freighter to write or manage reviews for this project.";
Expand All @@ -42,6 +45,7 @@ export default function ProjectDetailPage() {
const router = useRouter();
const gate = useWalletPageGate();
const confirm = useConfirm();
const { isProjectSaved, toggleSavedProject, canManageSavedProjects } = useSavedProjects();
const projectId = params.id as string;

const [isLoading, setIsLoading] = useState(true);
Expand Down Expand Up @@ -82,6 +86,18 @@ export default function ProjectDetailPage() {
}, [projectId]);

const isOwner = project && gate.publicKey && project.ownerAddress === gate.publicKey;
const isSaved = project ? isProjectSaved(project.id) : false;

const handleToggleSaved = () => {
if (!project) return;
if (!canManageSavedProjects) {
setShowWalletGate(true);
return;
}

const nextSaved = toggleSavedProject(project.id);
toast.success(nextSaved ? "Saved project" : "Removed from saved projects");
};

const handleAddReview = () => {
if (gate.state !== "ready") {
Expand Down Expand Up @@ -282,7 +298,7 @@ export default function ProjectDetailPage() {
<div className="lg:col-span-2 space-y-8">
{/* Project Header */}
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-3xl p-8">
<div className="flex items-start justify-between mb-6">
<div className="flex items-start justify-between mb-6 gap-4">
<div className="flex-1">
<Badge variant="primary" className="mb-3">
{project.category}
Expand All @@ -304,6 +320,15 @@ export default function ProjectDetailPage() {
</div>
</div>
</div>
<Button
variant={isSaved ? "secondary" : "outline"}
onClick={handleToggleSaved}
disabled={!project}
leftIcon={isSaved ? <BookmarkCheck className="w-4 h-4" /> : <Bookmark className="w-4 h-4" />}
className="shrink-0"
>
{isSaved ? "Saved" : "Save"}
</Button>
</div>

{/* Project Image */}
Expand Down
79 changes: 52 additions & 27 deletions dongle/components/projects/ProjectCard.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,67 @@
"use client";

import React from "react";
import Link from "next/link";
import { Project } from "@/types/project";
import ProjectImage from "@/components/projects/ProjectImage";
import { formatDate } from "@/lib/date";
import { Star } from "lucide-react";
import { Bookmark, BookmarkCheck, Star } from "lucide-react";
import { useSavedProjects } from "@/hooks/useSavedProjects";

interface ProjectCardProps {
project: Project;
}

export const ProjectCard = ({ project }: ProjectCardProps) => {
const { isProjectSaved, toggleSavedProject, canManageSavedProjects } = useSavedProjects();
const isSaved = isProjectSaved(project.id);

const handleToggleSaved = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
toggleSavedProject(project.id);
};

return (
<Link href={`/projects/${project.id}`} className="group bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-3xl p-6 hover:shadow-xl transition-all h-full flex flex-col cursor-pointer">
<ProjectImage
logoUrl={project.logoUrl}
name={project.name}
className="mb-6 shrink-0"
fallbackTextSize="text-lg"
/>
<div className="flex justify-between items-start mb-2">
<span className="text-xs font-semibold text-blue-500 bg-blue-50 dark:bg-blue-900/20 px-2 py-1 rounded">
{project.category}
</span>
<div className="flex items-center gap-1 text-sm font-bold">
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
{project.rating}
<div className="group relative bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-3xl p-6 hover:shadow-xl transition-all h-full flex flex-col cursor-pointer">
<button
type="button"
onClick={handleToggleSaved}
disabled={!canManageSavedProjects}
aria-pressed={isSaved}
aria-label={isSaved ? `Remove ${project.name} from saved projects` : `Save ${project.name}`}
className="absolute right-4 top-4 z-10 inline-flex items-center justify-center rounded-full border border-zinc-200 dark:border-zinc-700 bg-white/95 dark:bg-zinc-900/95 p-2 text-zinc-500 shadow-sm transition-colors hover:border-blue-400 hover:text-blue-500 disabled:cursor-not-allowed disabled:opacity-40"
>
{isSaved ? <BookmarkCheck className="w-4 h-4" /> : <Bookmark className="w-4 h-4" />}
</button>

<Link href={`/projects/${project.id}`} className="flex h-full flex-col">
<ProjectImage
logoUrl={project.logoUrl}
name={project.name}
className="mb-6 shrink-0"
fallbackTextSize="text-lg"
/>
<div className="flex justify-between items-start mb-2 pr-10">
<span className="text-xs font-semibold text-blue-500 bg-blue-50 dark:bg-blue-900/20 px-2 py-1 rounded">
{project.category}
</span>
<div className="flex items-center gap-1 text-sm font-bold">
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
{project.rating}
</div>
</div>
<h3 className="text-xl font-bold mb-2 group-hover:text-blue-500 transition-colors">
{project.name}
</h3>
<p className="text-zinc-500 dark:text-zinc-400 text-sm mb-6 line-clamp-2 grow">
{project.description}
</p>
<div className="flex justify-between items-center text-xs text-zinc-400 dark:text-zinc-500 mt-auto">
<span>{project.reviews} reviews</span>
<span>Added {formatDate(project.createdAt, "short")}</span>
</div>
</div>
<h3 className="text-xl font-bold mb-2 group-hover:text-blue-500 transition-colors">
{project.name}
</h3>
<p className="text-zinc-500 dark:text-zinc-400 text-sm mb-6 line-clamp-2 grow">
{project.description}
</p>
<div className="flex justify-between items-center text-xs text-zinc-400 dark:text-zinc-500 mt-auto">
<span>{project.reviews} reviews</span>
<span>Added {formatDate(project.createdAt, "short")}</span>
</div>
</Link>
</Link>
</div>
);
};
133 changes: 133 additions & 0 deletions dongle/hooks/useSavedProjects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import { useWallet } from "@/context/wallet.context";

const STORAGE_PREFIX = "dongle_saved_projects:";
const SAVED_PROJECTS_EVENT = "dongle:saved-projects-changed";

function getStorageKey(walletAddress: string) {
return `${STORAGE_PREFIX}${walletAddress}`;
}

function readSavedProjectIds(walletAddress: string | null): string[] {
if (typeof window === "undefined" || !walletAddress) {
return [];
}

try {
const raw = localStorage.getItem(getStorageKey(walletAddress));
if (!raw) return [];

const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];

return parsed.filter((id): id is string => typeof id === "string" && id.length > 0);
} catch (error) {
console.error("Failed to read saved projects:", error);
return [];
}
}

function writeSavedProjectIds(walletAddress: string, projectIds: string[]) {
localStorage.setItem(getStorageKey(walletAddress), JSON.stringify(projectIds));
}

function emitSavedProjectsChanged(walletAddress: string | null) {
if (typeof window === "undefined") return;

window.dispatchEvent(
new CustomEvent(SAVED_PROJECTS_EVENT, {
detail: { walletAddress },
}),
);
}

export function useSavedProjects() {
const { publicKey, isConnected } = useWallet();
const [savedProjectIds, setSavedProjectIds] = useState<string[]>(() =>
readSavedProjectIds(publicKey),
);

useEffect(() => {
setSavedProjectIds(readSavedProjectIds(publicKey));
}, [publicKey]);

useEffect(() => {
if (typeof window === "undefined") return;

const handleStorage = (event: StorageEvent) => {
if (!publicKey) return;
if (event.key !== getStorageKey(publicKey)) return;
setSavedProjectIds(readSavedProjectIds(publicKey));
};

const handleSavedProjectsChanged = (event: Event) => {
const customEvent = event as CustomEvent<{ walletAddress?: string | null }>;
if (customEvent.detail?.walletAddress && customEvent.detail.walletAddress !== publicKey) {
return;
}
setSavedProjectIds(readSavedProjectIds(publicKey));
};

window.addEventListener("storage", handleStorage);
window.addEventListener(SAVED_PROJECTS_EVENT, handleSavedProjectsChanged as EventListener);

return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(
SAVED_PROJECTS_EVENT,
handleSavedProjectsChanged as EventListener,
);
};
}, [publicKey]);

const isProjectSaved = useCallback(
(projectId: string) => savedProjectIds.includes(projectId),
[savedProjectIds],
);

const toggleSavedProject = useCallback(
(projectId: string) => {
if (!publicKey) return false;

const nextProjectIds = savedProjectIds.includes(projectId)
? savedProjectIds.filter((id) => id !== projectId)
: [...savedProjectIds, projectId];

writeSavedProjectIds(publicKey, nextProjectIds);
setSavedProjectIds(nextProjectIds);
emitSavedProjectsChanged(publicKey);
return nextProjectIds.includes(projectId);
},
[publicKey, savedProjectIds],
);

const clearSavedProjects = useCallback(() => {
if (!publicKey) return;

writeSavedProjectIds(publicKey, []);
setSavedProjectIds([]);
emitSavedProjectsChanged(publicKey);
}, [publicKey]);

return useMemo(
() => ({
walletAddress: publicKey,
isConnected,
savedProjectIds,
isProjectSaved,
toggleSavedProject,
clearSavedProjects,
canManageSavedProjects: Boolean(publicKey && isConnected),
}),
[
publicKey,
isConnected,
savedProjectIds,
isProjectSaved,
toggleSavedProject,
clearSavedProjects,
],
);
}