From 7ebcd510b61f26ff788c04a23a4b357edca41294 Mon Sep 17 00:00:00 2001 From: psalmuel3 <265959007+psalmuel3@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:14:24 +0100 Subject: [PATCH] feat(projects): add project status lifecycle states Implements the project status lifecycle (active, paused, deprecated, archived, flagged, removed) with a status model, localStorage-backed status overrides, a lifecycle status badge, an owner/admin status updater on the project detail page, and a lifecycle-status filter on the Discover page. Also fixes two pre-existing issues in app/projects/[id]/page.tsx that blocked the ProjectDetail test suite from loading: a duplicate review-report.service import and a missing ClaimProjectModal import. Closes #242 --- .../ProjectLifecycleStatusBadge.test.tsx | 24 ++++ dongle/__tests__/pages/Discover.test.tsx | 44 ++++++- dongle/__tests__/pages/ProjectDetail.test.tsx | 46 ++++++++ .../services/project-status.service.test.ts | 110 ++++++++++++++++++ .../services/project.service.test.ts | 41 ++++++- dongle/app/discover/page.tsx | 45 ++++++- dongle/app/projects/[id]/page.tsx | 53 ++++++++- dongle/components/projects/ProjectCard.tsx | 2 + .../projects/ProjectLifecycleStatusBadge.tsx | 50 ++++++++ dongle/data/mockProjects.ts | 6 +- .../project/project-status.service.ts | 78 +++++++++++++ dongle/services/project/project.service.ts | 15 ++- dongle/types/project.ts | 51 ++++++++ 13 files changed, 554 insertions(+), 11 deletions(-) create mode 100644 dongle/__tests__/components/ProjectLifecycleStatusBadge.test.tsx create mode 100644 dongle/__tests__/services/project-status.service.test.ts create mode 100644 dongle/components/projects/ProjectLifecycleStatusBadge.tsx create mode 100644 dongle/services/project/project-status.service.ts diff --git a/dongle/__tests__/components/ProjectLifecycleStatusBadge.test.tsx b/dongle/__tests__/components/ProjectLifecycleStatusBadge.test.tsx new file mode 100644 index 0000000..e68bf39 --- /dev/null +++ b/dongle/__tests__/components/ProjectLifecycleStatusBadge.test.tsx @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ProjectLifecycleStatusBadge } from "@/components/projects/ProjectLifecycleStatusBadge"; +import { PROJECT_STATUS_LABELS } from "@/types/project"; + +describe("ProjectLifecycleStatusBadge", () => { + it("renders the label for a given status", () => { + render(); + expect(screen.getByText(PROJECT_STATUS_LABELS.archived)).toBeInTheDocument(); + }); + + it("defaults to Active when status is omitted", () => { + render(); + expect(screen.getByText(PROJECT_STATUS_LABELS.active)).toBeInTheDocument(); + }); + + it("renders every known lifecycle status", () => { + const { rerender } = render(); + for (const status of ["active", "paused", "deprecated", "archived", "flagged", "removed"] as const) { + rerender(); + expect(screen.getByText(PROJECT_STATUS_LABELS[status])).toBeInTheDocument(); + } + }); +}); diff --git a/dongle/__tests__/pages/Discover.test.tsx b/dongle/__tests__/pages/Discover.test.tsx index 6908871..5d5f961 100644 --- a/dongle/__tests__/pages/Discover.test.tsx +++ b/dongle/__tests__/pages/Discover.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest"; import React from "react"; import { render, screen, fireEvent, act } from "@testing-library/react"; import DiscoverPage from "@/app/discover/page"; @@ -6,6 +6,24 @@ import { mockProjects } from "@/data/mockProjects"; import { projectService } from "@/services/project/project.service"; import type { SortBy } from "@/hooks/useDiscoverParams"; +// jsdom in the current Node version does not implement window.matchMedia, but +// ProjectCard calls getPrefetchValue() which reads it. Provide a minimal stub. +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); +}); + // Mock soroban so verification fetches resolve immediately vi.mock("@/services/stellar/soroban.service", () => ({ sorobanService: { @@ -241,6 +259,30 @@ describe("Discover Page - High Risk Flows", () => { expect(screen.getByText(mockProjects[0].name)).toBeInTheDocument(); }); + it("shows lifecycle status badges on project cards", async () => { + render(); + await finishInitialLoad(); + + const deprecatedProjects = mockProjects.filter((p) => p.status === "deprecated"); + expect(deprecatedProjects.length).toBeGreaterThan(0); + expect(screen.getAllByText("Deprecated").length).toBeGreaterThan(0); + }); + + it("filters projects by lifecycle status", async () => { + render(); + await finishInitialLoad(); + + const statusSelect = screen.getByLabelText( + /Filter by lifecycle status/i, + ) as HTMLSelectElement; + fireEvent.change(statusSelect, { target: { value: "deprecated" } }); + + const deprecatedProject = mockProjects.find((p) => p.status === "deprecated"); + expect(deprecatedProject).toBeTruthy(); + expect(screen.getByText(deprecatedProject!.name)).toBeInTheDocument(); + expect(screen.queryByText(mockProjects[0].name)).not.toBeInTheDocument(); + }); + it("filters projects by category", async () => { render(); await finishInitialLoad(); diff --git a/dongle/__tests__/pages/ProjectDetail.test.tsx b/dongle/__tests__/pages/ProjectDetail.test.tsx index ae6d5ce..150f4e3 100644 --- a/dongle/__tests__/pages/ProjectDetail.test.tsx +++ b/dongle/__tests__/pages/ProjectDetail.test.tsx @@ -423,3 +423,49 @@ describe("Project Detail Page - layout shell", () => { expect(shell?.className).toMatch(/pb-24/); }); }); + +describe("Project Detail Page - lifecycle status", () => { + const mockProject = { + id: "test-project-id", + name: "Test Secure Project", + category: PROJECT_CATEGORIES.DEFI as ProjectCategory, + primaryCategory: PROJECT_CATEGORIES.DEFI as ProjectCategory, + description: "A test project description.", + rating: 4.8, + reviews: 5, + createdAt: "2026-01-01T00:00:00Z", + websiteUrl: "https://secure-test.xyz", + githubUrl: "https://github.com/secure-test/repo", + ownerAddress: "G_OWNER_123", + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + vi.mocked(useConfirm).mockReturnValue(vi.fn()); + vi.mocked(sorobanService.getVerificationStatus).mockResolvedValue("NONE"); + vi.mocked(projectService.getProjectById).mockReturnValue(mockProject); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows the lifecycle status badge on the detail page", async () => { + render(); + await finishInitialLoad(); + expect(screen.getAllByText("Active").length).toBeGreaterThan(0); + }); + + it("lets the owner update the project status", async () => { + render(); + await finishInitialLoad(); + + const statusSelect = screen.getByLabelText(/Update project status/i) as HTMLSelectElement; + expect(statusSelect).toBeTruthy(); + + fireEvent.change(statusSelect, { target: { value: "archived" } }); + expect(statusSelect.value).toBe("archived"); + expect(screen.getAllByText("Archived").length).toBeGreaterThan(0); + }); +}); diff --git a/dongle/__tests__/services/project-status.service.test.ts b/dongle/__tests__/services/project-status.service.test.ts new file mode 100644 index 0000000..803df1d --- /dev/null +++ b/dongle/__tests__/services/project-status.service.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { projectStatusService } from "@/services/project/project-status.service"; +import { mockProjects } from "@/data/mockProjects"; +import { + getProjectStatus, + getProjectStatusLabel, + isProjectActive, + PROJECT_STATUSES, +} from "@/types/project"; + +// The current Node/jsdom combo does not expose `localStorage` on the global +// object, so provide a tiny in-memory implementation for hermetic tests. +function createLocalStorageMock() { + let store = new Map(); + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => { + store.clear(); + }, + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { + return store.size; + }, + }; +} + +describe("projectStatusService", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", createLocalStorageMock()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns null when no override exists", () => { + expect(projectStatusService.getProjectStatusOverride("proj-0")).toBeNull(); + }); + + it("persists and reads a status override", () => { + projectStatusService.setProjectStatus("proj-0", "archived"); + expect(projectStatusService.getProjectStatusOverride("proj-0")).toBe("archived"); + }); + + it("ignores invalid persisted status values", () => { + projectStatusService.setProjectStatus("proj-0", "active"); + localStorage.setItem("dongle_project_status_overrides", JSON.stringify({ "proj-0": "bogus" })); + expect(projectStatusService.getProjectStatusOverride("proj-0")).toBeNull(); + }); + + it("clears a status override", () => { + projectStatusService.setProjectStatus("proj-0", "archived"); + projectStatusService.clearProjectStatus("proj-0"); + expect(projectStatusService.getProjectStatusOverride("proj-0")).toBeNull(); + }); + + it("applies override to a project copy without mutating the seed", () => { + const project = mockProjects[0]; + projectStatusService.setProjectStatus(project.id, "removed"); + const applied = projectStatusService.applyOverride(project); + expect(applied.status).toBe("removed"); + expect(applied).not.toBe(project); + expect(project.status).toBe("active"); + }); + + it("leaves the project unchanged when no override exists", () => { + const project = mockProjects[0]; + expect(projectStatusService.applyOverride(project)).toBe(project); + }); +}); + +describe("project status helpers", () => { + it("treats a missing status as active", () => { + expect(getProjectStatus(undefined)).toBe("active"); + expect(getProjectStatusLabel(undefined)).toBe("Active"); + expect(isProjectActive(undefined)).toBe(true); + }); + + it("maps every known status to a label", () => { + for (const status of PROJECT_STATUSES) { + expect(getProjectStatusLabel(status)).toEqual( + status.charAt(0).toUpperCase() + status.slice(1), + ); + } + }); + + it("flags non-active lifecycle states as inactive", () => { + expect(isProjectActive("paused")).toBe(false); + expect(isProjectActive("deprecated")).toBe(false); + expect(isProjectActive("archived")).toBe(false); + expect(isProjectActive("flagged")).toBe(false); + expect(isProjectActive("removed")).toBe(false); + expect(isProjectActive("active")).toBe(true); + }); +}); + +describe("mock project seed statuses", () => { + it("seeds a representative mix of lifecycle states", () => { + const statuses = new Set(mockProjects.map((p) => p.status)); + for (const inactive of ["deprecated", "paused", "archived", "flagged"] as const) { + expect(statuses.has(inactive)).toBe(true); + } + }); +}); diff --git a/dongle/__tests__/services/project.service.test.ts b/dongle/__tests__/services/project.service.test.ts index efb1889..e0fd4ee 100644 --- a/dongle/__tests__/services/project.service.test.ts +++ b/dongle/__tests__/services/project.service.test.ts @@ -1,8 +1,39 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { projectService } from "@/services/project/project.service"; +import { projectStatusService } from "@/services/project/project-status.service"; import { mockProjects } from "@/data/mockProjects"; +// The current Node/jsdom combo does not expose `localStorage` on the global +// object, so provide a tiny in-memory implementation for hermetic tests. +function createLocalStorageMock() { + let store = new Map(); + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => { + store.clear(); + }, + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { + return store.size; + }, + }; +} + describe("projectService", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", createLocalStorageMock()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("returns all mock projects", () => { const all = projectService.getAllProjects(); expect(all.length).toEqual(mockProjects.length); @@ -47,4 +78,12 @@ describe("projectService", () => { new Date(byNewest[1].createdAt).getTime(), ); }); + + it("reflects persisted lifecycle-status overrides", () => { + const project = mockProjects[0]; + projectStatusService.setProjectStatus(project.id, "archived"); + const all = projectService.getAllProjects(); + expect(all.find((p) => p.id === project.id)?.status).toBe("archived"); + expect(projectService.getProjectById(project.id)?.status).toBe("archived"); + }); }); diff --git a/dongle/app/discover/page.tsx b/dongle/app/discover/page.tsx index 7045708..eccd14b 100644 --- a/dongle/app/discover/page.tsx +++ b/dongle/app/discover/page.tsx @@ -11,6 +11,7 @@ import type { SortBy } from "@/hooks/useDiscoverParams"; import { TagInput } from "@/components/ui/TagInput"; import { sorobanService } from "@/services/stellar/soroban.service"; import type { VerificationStatus } from "@/components/projects/VerificationBadge"; +import { PROJECT_STATUSES, PROJECT_STATUS_LABELS, type ProjectStatus } from "@/types/project"; import { useRecentViews } from "@/hooks/useRecentViews"; import { RecentlyViewedProjects } from "@/components/projects/RecentlyViewedProjects"; import { useWalletPageGate } from "@/hooks/useWalletPageGate"; @@ -25,6 +26,7 @@ function DiscoverContent() { const [isLoadingMore, setIsLoadingMore] = useState(false); const [verificationStatuses, setVerificationStatuses] = useState>({}); const [verificationFilter, setVerificationFilter] = useState("ALL"); + const [statusFilter, setStatusFilter] = useState("ALL"); const gate = useWalletPageGate(); const { recentProjects, hasHistory } = useRecentViews(gate.publicKey || undefined); @@ -87,9 +89,13 @@ function DiscoverContent() { result = result.filter((p) => verificationStatuses[p.id] === verificationFilter); } + if (statusFilter !== "ALL") { + result = result.filter((p) => (p.status ?? "active") === statusFilter); + } + result = projectService.sortProjects(result, sortBy); return result; - }, [searchQuery, category, tags, sortBy, verificationFilter, verificationStatuses]); + }, [searchQuery, category, tags, sortBy, verificationFilter, verificationStatuses, statusFilter]); const filteredCount = filteredAndSortedProjects.length; const visibleCount = page * ITEMS_PER_PAGE; @@ -138,6 +144,15 @@ function DiscoverContent() { }); }; + const handleStatusFilterChange = (value: ProjectStatus | "ALL") => { + setStatusFilter(value); + trackFilter({ + filterType: "lifecycle-status", + filterValue: value, + source: "discover", + }); + }; + const handleTagsChange = (nextTags: string[]) => { setTags(nextTags); trackFilter({ @@ -155,6 +170,12 @@ function DiscoverContent() { }, 600); }; + const handleClearFilters = () => { + clearFilters(); + setStatusFilter("ALL"); + setVerificationFilter("ALL"); + }; + return (
@@ -201,6 +222,26 @@ function DiscoverContent() {
+ {/* Lifecycle Status Filter */} + + +
+ {/* Verification Filter */} handleStatusChange(e.target.value as ProjectStatus)} + className="w-full bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500/20" + > + {PROJECT_STATUSES.map((status) => ( + + ))} + + + ) : ( +

+ Only the project owner or an admin can update this status. +

+ )} +
+ {/* Actions */}

Actions

diff --git a/dongle/components/projects/ProjectCard.tsx b/dongle/components/projects/ProjectCard.tsx index 5027952..a1e305b 100644 --- a/dongle/components/projects/ProjectCard.tsx +++ b/dongle/components/projects/ProjectCard.tsx @@ -7,6 +7,7 @@ import ProjectImage from "@/components/projects/ProjectImage"; import { formatDate } from "@/lib/date"; import { Star, Plus, Check, Bookmark, BookmarkCheck } from "lucide-react"; import { VerificationBadge, VerificationStatus } from "@/components/projects/VerificationBadge"; +import { ProjectLifecycleStatusBadge } from "@/components/projects/ProjectLifecycleStatusBadge"; import { IconButton } from "@/components/ui/IconButton"; import { useComparison } from "@/context/comparison.context"; import { useSavedProjects } from "@/hooks/useSavedProjects"; @@ -113,6 +114,7 @@ export const ProjectCard = ({ {verificationStatus && ( )} +
diff --git a/dongle/components/projects/ProjectLifecycleStatusBadge.tsx b/dongle/components/projects/ProjectLifecycleStatusBadge.tsx new file mode 100644 index 0000000..d5a8446 --- /dev/null +++ b/dongle/components/projects/ProjectLifecycleStatusBadge.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Badge } from "@/components/ui/Badge"; +import { + CheckCircle2, + PauseCircle, + MinusCircle, + Archive, + AlertTriangle, + XCircle, +} from "lucide-react"; +import { ProjectStatus, PROJECT_STATUS_LABELS, getProjectStatus } from "@/types/project"; + +interface ProjectLifecycleStatusBadgeProps { + status?: ProjectStatus; + showIcon?: boolean; + className?: string; +} + +const statusConfig: Record< + ProjectStatus, + { label: string; variant: "primary" | "secondary" | "success" | "warning" | "error"; icon: React.ComponentType<{ className?: string }> } +> = { + active: { label: PROJECT_STATUS_LABELS.active, variant: "success", icon: CheckCircle2 }, + paused: { label: PROJECT_STATUS_LABELS.paused, variant: "warning", icon: PauseCircle }, + deprecated: { label: PROJECT_STATUS_LABELS.deprecated, variant: "secondary", icon: MinusCircle }, + archived: { label: PROJECT_STATUS_LABELS.archived, variant: "secondary", icon: Archive }, + flagged: { label: PROJECT_STATUS_LABELS.flagged, variant: "error", icon: AlertTriangle }, + removed: { label: PROJECT_STATUS_LABELS.removed, variant: "error", icon: XCircle }, +}; + +/** + * Renders a project's lifecycle status (active, paused, deprecated, archived, + * flagged, removed) as a colored pill. Omitted statuses are treated as + * "active" for display purposes. + */ +export const ProjectLifecycleStatusBadge = ({ + status, + showIcon = true, + className, +}: ProjectLifecycleStatusBadgeProps) => { + const config = statusConfig[getProjectStatus(status)]; + const Icon = config.icon; + + return ( + + {showIcon && } + {config.label} + + ); +}; diff --git a/dongle/data/mockProjects.ts b/dongle/data/mockProjects.ts index fb373e1..38fd497 100644 --- a/dongle/data/mockProjects.ts +++ b/dongle/data/mockProjects.ts @@ -1,6 +1,5 @@ import { Project, PROJECT_CATEGORIES } from "@/types/project"; import { extractDomain } from "@/lib/url"; - // Helper to generate deterministic but seemingly random dates const generateDate = (index: number) => { const baseDate = new Date("2023-01-01T00:00:00Z").getTime(); @@ -55,6 +54,7 @@ const baseProjects: Partial[] = [ description: "Instant cross-border payments powered by Stellar.", rating: 4.6, reviews: 175, + status: "deprecated", websiteUrl: "https://xlm-pay.example.com", }, { @@ -75,6 +75,7 @@ const baseProjects: Partial[] = [ description: "A censorship-resistant social network powered by Soroban.", rating: 4.1, reviews: 32, + status: "paused", websiteUrl: "https://stellar-social.example.com", }, { @@ -94,6 +95,7 @@ const baseProjects: Partial[] = [ description: "No-code platform to mint and manage Stellar tokens.", rating: 4.4, reviews: 88, + status: "archived", websiteUrl: "https://token-forge.example.com", githubUrl: "https://github.com/example/token-forge", docsUrl: "https://docs.token-forge.example.com", @@ -105,6 +107,7 @@ const baseProjects: Partial[] = [ description: "Maximize your returns with automated yield farming strategies.", rating: 4.3, reviews: 112, + status: "flagged", websiteUrl: "https://yield-farm.example.com", githubUrl: "https://github.com/example/yield-farm", }, @@ -133,6 +136,7 @@ export const mockProjects: Project[] = Array.from({ length: 60 }).map( base.reviews! + Math.floor(Math.cos(i) * 50) + iteration * 10, ), createdAt: generateDate(i), + status: base.status ?? "active", websiteUrl: base.websiteUrl, githubUrl: base.githubUrl, logoUrl: base.logoUrl, diff --git a/dongle/services/project/project-status.service.ts b/dongle/services/project/project-status.service.ts new file mode 100644 index 0000000..484e05c --- /dev/null +++ b/dongle/services/project/project-status.service.ts @@ -0,0 +1,78 @@ +/** + * project-status.service + * + * Persists lifecycle-status overrides for projects in localStorage so that an + * owner or admin can update a project's lifecycle state (active, paused, + * deprecated, archived, flagged, removed) without editing static seed data. + * + * Overrides are layered on top of the seed data by `projectService`, so the + * rest of the app only ever reads the merged result via the service. + */ + +import { Project, ProjectStatus, PROJECT_STATUSES } from "@/types/project"; + +const STORAGE_KEY_STATUS_OVERRIDES = "dongle_project_status_overrides"; + +function readOverrides(): Record { + if (typeof window === "undefined" || typeof localStorage === "undefined") return {}; + + const stored = localStorage.getItem(STORAGE_KEY_STATUS_OVERRIDES); + if (!stored) return {}; + + try { + const parsed = JSON.parse(stored) as unknown; + if (!parsed || typeof parsed !== "object") return {}; + return Object.fromEntries( + Object.entries(parsed as Record).filter( + ([, value]) => typeof value === "string" && Boolean(value), + ), + ) as Record; + } catch { + return {}; + } +} + +function writeOverrides(overrides: Record): void { + if (typeof window === "undefined" || typeof localStorage === "undefined") return; + localStorage.setItem(STORAGE_KEY_STATUS_OVERRIDES, JSON.stringify(overrides)); +} + +function isProjectStatus(value: string): value is ProjectStatus { + return (PROJECT_STATUSES as readonly string[]).includes(value); +} + +export const projectStatusService = { + /** + * Return the persisted lifecycle-status override for a project, or null + * when the project has never had its status changed. + */ + getProjectStatusOverride(projectId: string): ProjectStatus | null { + const value = readOverrides()[projectId]; + return value && isProjectStatus(value) ? value : null; + }, + + /** Persist a lifecycle-status override for a project. */ + setProjectStatus(projectId: string, status: ProjectStatus): void { + const overrides = readOverrides(); + overrides[projectId] = status; + writeOverrides(overrides); + }, + + /** Clear any persisted lifecycle-status override for a project. */ + clearProjectStatus(projectId: string): void { + const overrides = readOverrides(); + delete overrides[projectId]; + writeOverrides(overrides); + }, + + /** + * Return the project with its persisted lifecycle-status override applied. + * When no override exists the project is returned unchanged (so the seed + * status, defaulting to "active", is preserved). + */ + applyOverride(project: Project): Project { + const override = this.getProjectStatusOverride(project.id); + if (!override) return project; + return { ...project, status: override }; + }, +}; diff --git a/dongle/services/project/project.service.ts b/dongle/services/project/project.service.ts index fde66e0..e4d63e6 100644 --- a/dongle/services/project/project.service.ts +++ b/dongle/services/project/project.service.ts @@ -1,6 +1,7 @@ import { mockProjects } from "@/data/mockProjects"; import { Project } from "@/types/project"; import { projectOwnerService } from "./project-owner.service"; +import { projectStatusService } from "./project-status.service"; import { registry } from "@/services/data-access/registry"; /** @@ -18,7 +19,7 @@ export const projectService = { * Get all projects */ getAllProjects(): Project[] { - return mockProjects; + return mockProjects.map((p) => projectStatusService.applyOverride(p)); }, /** @@ -29,12 +30,14 @@ export const projectService = { const project = mockProjects.find((p) => p.id === id) ?? null; if (!project) return null; + let resolved: Project = projectStatusService.applyOverride(project); + const overrideOwner = projectOwnerService.getProjectOwnerOverride(project.id); if (overrideOwner) { - return { ...project, ownerAddress: overrideOwner }; + resolved = { ...resolved, ownerAddress: overrideOwner }; } - return project; + return resolved; }, /** @@ -106,15 +109,17 @@ export const projectService = { /** Async: fetch all projects via the active repository implementation. */ async fetchAll(): Promise { - return registry.projects.getAll(); + const projects = await registry.projects.getAll(); + return projects.map((p) => projectStatusService.applyOverride(p)); }, /** Async: fetch a single project by ID via the active repository. */ async fetchById(id: string): Promise { const project = await registry.projects.getById(id); if (!project) return null; + const resolved = projectStatusService.applyOverride(project); const overrideOwner = projectOwnerService.getProjectOwnerOverride(project.id); - return overrideOwner ? { ...project, ownerAddress: overrideOwner } : project; + return overrideOwner ? { ...resolved, ownerAddress: overrideOwner } : resolved; }, /** Async: fetch projects filtered by category via the active repository. */ diff --git a/dongle/types/project.ts b/dongle/types/project.ts index dc36855..fb33117 100644 --- a/dongle/types/project.ts +++ b/dongle/types/project.ts @@ -64,6 +64,55 @@ export const CATEGORY_FORM_OPTIONS = [ { value: "dao", label: PROJECT_CATEGORIES.DAO }, ]; +/** + * Lifecycle state for a project. + * + * Projects are no longer treated as always active. A project can be archived, + * deprecated, paused, flagged, or removed by an owner or admin. + */ +export type ProjectStatus = + | "active" + | "paused" + | "deprecated" + | "archived" + | "flagged" + | "removed"; + +export const PROJECT_STATUSES: ProjectStatus[] = [ + "active", + "paused", + "deprecated", + "archived", + "flagged", + "removed", +]; + +export const PROJECT_STATUS_LABELS: Record = { + active: "Active", + paused: "Paused", + deprecated: "Deprecated", + archived: "Archived", + flagged: "Flagged", + removed: "Removed", +}; + +/** + * The lifecycle status of a project, or "active" when the field is absent. + * Older records without a `status` field are treated as active. + */ +export function getProjectStatus(status?: ProjectStatus): ProjectStatus { + return status && status in PROJECT_STATUS_LABELS ? status : "active"; +} + +export function getProjectStatusLabel(status?: ProjectStatus): string { + return PROJECT_STATUS_LABELS[getProjectStatus(status)]; +} + +/** True only for projects that are currently active. */ +export function isProjectActive(status?: ProjectStatus): boolean { + return getProjectStatus(status) === "active"; +} + /** * Canonical Project interface. * Use `primaryCategory` everywhere — it holds the display-label form of the @@ -73,6 +122,8 @@ export interface Project { id: string; name: string; primaryCategory: ProjectCategory; + /** Lifecycle state; defaults to "active" when omitted. */ + status?: ProjectStatus; tags?: string[]; description: string; rating: number;