@@ -201,6 +222,26 @@ function DiscoverContent() {
+ {/* Lifecycle Status Filter */}
+
+
+
+
{/* Verification Filter */}
diff --git a/dongle/app/projects/[id]/page.tsx b/dongle/app/projects/[id]/page.tsx
index 1c5a08c..7f11457 100644
--- a/dongle/app/projects/[id]/page.tsx
+++ b/dongle/app/projects/[id]/page.tsx
@@ -5,6 +5,9 @@ import {
useParams,
useRouter } from "next/navigation";
import { projectService } from "@/services/project/project.service";
+import { projectStatusService } from "@/services/project/project-status.service";
+import { PROJECT_STATUSES, PROJECT_STATUS_LABELS, getProjectStatusLabel, type ProjectStatus } from "@/types/project";
+import { useAdminAccess } from "@/hooks/useAdminAccess";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Spinner } from "@/components/ui/Spinner";
@@ -50,7 +53,7 @@ import {
import { toast } from "sonner";
import { ReportProjectModal } from "@/components/projects/ReportProjectModal";
import { ReportReviewModal } from "@/components/reviews/ReportReviewModal";
-import { reviewReportService } from "@/services/review/review-report.service";
+import { ClaimProjectModal } from "@/components/projects/ClaimProjectModal";
import { useSavedProjects } from "@/hooks/useSavedProjects";
import { updateService } from "@/services/update/update.service";
import { abbreviateStellarAddress } from "@/lib/stellar-address";
@@ -60,6 +63,7 @@ import UpdateList from "@/components/updates/UpdateList";
import UpdateForm from "@/components/updates/UpdateForm";
import { VerificationBadge } from "@/components/projects/VerificationBadge";
import { ProjectStatusBanner } from "@/components/projects/ProjectStatusBanner";
+import { ProjectLifecycleStatusBadge } from "@/components/projects/ProjectLifecycleStatusBadge";
import { shouldBypassLinkWarning, getExternalLinkWarningOptions } from "@/lib/externalLinkWarning";
import { recentViewsService } from "@/services/recent-views/recent-views.service";
import { trackProjectView, trackReviewSubmit } from "@/lib/analytics";
@@ -72,6 +76,7 @@ export default function ProjectDetailPage() {
const router = useRouter();
const gate = useWalletPageGate();
const confirm = useConfirm();
+ const { isAdmin } = useAdminAccess();
const { isProjectSaved, toggleSavedProject, canManageSavedProjects } = useSavedProjects();
const projectId = params.id as string;
@@ -157,8 +162,16 @@ export default function ProjectDetailPage() {
const actualReviewCount = reviews.length || project?.reviews || 0;
const isOwner = project && gate.publicKey && project.ownerAddress === gate.publicKey;
+ const canManageStatus = Boolean(project && gate.publicKey && (isOwner || isAdmin));
const isSaved = project ? isProjectSaved(project.id) : false;
+ const handleStatusChange = (status: ProjectStatus) => {
+ if (!project) return;
+ projectStatusService.setProjectStatus(project.id, status);
+ setProject({ ...project, status });
+ toast.success(`Project status updated to ${PROJECT_STATUS_LABELS[status]}`);
+ };
+
const handleToggleSaved = () => {
if (!project) return;
if (!canManageSavedProjects) {
@@ -530,6 +543,7 @@ export default function ProjectDetailPage() {
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;