diff --git a/dongle/app/profile/page.tsx b/dongle/app/profile/page.tsx index 121e76f..0272c3c 100644 --- a/dongle/app/profile/page.tsx +++ b/dongle/app/profile/page.tsx @@ -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"; @@ -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; @@ -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([]); const [loadedVerificationKey, setLoadedVerificationKey] = useState(null); @@ -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 => Boolean(project)); useEffect(() => { if (!gate.publicKey) { @@ -243,6 +251,37 @@ export default function ProfilePage() { )} +
+
+

+ + Saved Projects +

+ {savedProjects.length} +
+ + {savedProjects.length > 0 ? ( +
+ {savedProjects.map((project) => ( + + ))} +
+ ) : ( +
+ +

No saved projects yet. Bookmark projects to revisit them later.

+ +
+ )} +
+

diff --git a/dongle/app/projects/[id]/page.tsx b/dongle/app/projects/[id]/page.tsx index fcaf4f6..afedc4f 100644 --- a/dongle/app/projects/[id]/page.tsx +++ b/dongle/app/projects/[id]/page.tsx @@ -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."; @@ -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); @@ -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") { @@ -282,7 +298,7 @@ export default function ProjectDetailPage() {
{/* Project Header */}
-
+
{project.category} @@ -304,6 +320,15 @@ export default function ProjectDetailPage() {
+
{/* Project Image */} diff --git a/dongle/components/projects/ProjectCard.tsx b/dongle/components/projects/ProjectCard.tsx index 54239fc..c385cfd 100644 --- a/dongle/components/projects/ProjectCard.tsx +++ b/dongle/components/projects/ProjectCard.tsx @@ -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) => { + event.preventDefault(); + event.stopPropagation(); + toggleSavedProject(project.id); + }; + return ( - - -
- - {project.category} - -
- - {project.rating} +
+ + + + +
+ + {project.category} + +
+ + {project.rating} +
+
+

+ {project.name} +

+

+ {project.description} +

+
+ {project.reviews} reviews + Added {formatDate(project.createdAt, "short")}
-
-

- {project.name} -

-

- {project.description} -

-
- {project.reviews} reviews - Added {formatDate(project.createdAt, "short")} -
- + +
); }; diff --git a/dongle/hooks/useSavedProjects.ts b/dongle/hooks/useSavedProjects.ts new file mode 100644 index 0000000..1e1953e --- /dev/null +++ b/dongle/hooks/useSavedProjects.ts @@ -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(() => + 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, + ], + ); +} \ No newline at end of file