From f885ac73d3329fc4fcc8efe8e2de037883118e81 Mon Sep 17 00:00:00 2001
From: Moheeb Zara
Date: Fri, 15 May 2026 20:39:04 -0700
Subject: [PATCH] feat(ux): adopt shared Empty primitive across all empty
states
- one Empty/EmptyHeader/EmptyMedia/EmptyTitle/EmptyDescription
component replaces ~18 ad-hoc empty states
- projects gains a real empty state (was four bare columns) with a
create CTA wired to the existing handler
- governance/ops/customize/comms/comments empty states unified;
existing CTAs and conditions preserved (presentational only)
---
app/(app)/comms/comms-client.tsx | 24 +++-
app/(app)/contacts/contacts-client.tsx | 23 ++--
.../customize/panels/area-leads-panel.tsx | 10 +-
app/(app)/customize/panels/areas-panel.tsx | 12 +-
app/(app)/customize/panels/invites-panel.tsx | 12 +-
.../customize/panels/onboarding-panel.tsx | 11 ++
app/(app)/customize/panels/roles-panel.tsx | 10 +-
app/(app)/customize/panels/tiers-panel.tsx | 12 +-
app/(app)/dashboard/page.tsx | 25 +++-
app/(app)/financials/page.tsx | 10 +-
app/(app)/incidents/page.tsx | 15 ++-
app/(app)/members/members-client.tsx | 13 +-
app/(app)/ops/ops-client.tsx | 115 +++++++++++-------
app/(app)/payments/payments-client.tsx | 13 +-
app/(app)/projects/projects-client.tsx | 19 +++
app/(app)/proposals/page.tsx | 15 ++-
app/(app)/recruitment/page.tsx | 17 ++-
app/(app)/tasks/tasks-client.tsx | 26 ++--
components/comments/comment-thread.tsx | 8 +-
19 files changed, 297 insertions(+), 93 deletions(-)
diff --git a/app/(app)/comms/comms-client.tsx b/app/(app)/comms/comms-client.tsx
index 1d8c1da..304bddc 100644
--- a/app/(app)/comms/comms-client.tsx
+++ b/app/(app)/comms/comms-client.tsx
@@ -8,6 +8,7 @@ import { Hash, Users2, Send, ChevronLeft, Plus } from 'lucide-react'
import { createChannel } from '@/lib/actions'
import type { Tables } from '@/types/database'
import { PageTitle } from '@/components/ui/page-title'
+import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
type Channel = Tables<'comms_channels'>
type Message = Tables<'comms_messages'>
@@ -269,6 +270,9 @@ export default function CommsClient({ member, space, channels }: Props) {
{/* New channel inline form */}
+ {channels.length === 0 && (
+
No channels yet. Create the first one.
+ )}
{!showNewChannel ? (
diff --git a/app/(app)/contacts/contacts-client.tsx b/app/(app)/contacts/contacts-client.tsx
index 3c67890..64b89dd 100644
--- a/app/(app)/contacts/contacts-client.tsx
+++ b/app/(app)/contacts/contacts-client.tsx
@@ -1,10 +1,11 @@
'use client'
import { useState } from 'react'
-import { Plus, X, Pencil } from 'lucide-react'
+import { Plus, X, Pencil, Contact as ContactIcon } from 'lucide-react'
import { createContact, updateContact, deleteContact } from '@/lib/actions'
import type { Tables } from '@/types/database'
import { PageTitle } from '@/components/ui/page-title'
+import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyContent } from '@/components/ui/empty'
type Contact = Tables<'contacts'>
@@ -238,12 +239,20 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
))}
) : (
-
-
No contacts yet
-
-
+
+
+
+ No contacts yet
+
+
+
+
+
)}
diff --git a/app/(app)/customize/panels/area-leads-panel.tsx b/app/(app)/customize/panels/area-leads-panel.tsx
index 36df6b8..08115e1 100644
--- a/app/(app)/customize/panels/area-leads-panel.tsx
+++ b/app/(app)/customize/panels/area-leads-panel.tsx
@@ -3,8 +3,10 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
+import { UserCog } from 'lucide-react'
import { createAreaLeadRole, assignAreaLead, unassignAreaLead, deleteAreaLeadRole } from '@/lib/actions'
import { Card } from './card'
+import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
interface AreaLead {
id: string
@@ -64,7 +66,13 @@ export function AreaLeadsPanel({ isAdmin, areaLeads: initial, members }: Props)
)}
{rows.length === 0 ? (
-
No area-lead roles yet.
+
+
+
+ No area-lead roles yet
+ Create a role per shop area, then assign a member to it. Roles without a member show as Vacant.
+
+
) : (
{rows.map(r => (
diff --git a/app/(app)/customize/panels/areas-panel.tsx b/app/(app)/customize/panels/areas-panel.tsx
index d58e202..ea7de58 100644
--- a/app/(app)/customize/panels/areas-panel.tsx
+++ b/app/(app)/customize/panels/areas-panel.tsx
@@ -2,8 +2,10 @@
import { useState } from 'react'
import { toast } from 'sonner'
+import { LayoutGrid } from 'lucide-react'
import { createArea, updateArea, deleteArea } from '@/lib/actions'
import { Card } from './card'
+import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
import type { Area } from './types'
export function AreasPanel({ isAdmin, areas: initial }: { isAdmin: boolean; areas: Area[] }) {
@@ -36,8 +38,16 @@ export function AreasPanel({ isAdmin, areas: initial }: { isAdmin: boolean; area
)}
+ {areas.length === 0 && (
+
+
+
+ No areas yet
+ Areas tag tasks, projects, and knowledge base entries by shop space.
+
+
+ )}
- {areas.length === 0 &&
No areas yet.
}
{[...areas].sort((a, b) => a.sort_order - b.sort_order).map(a => (
{ const n = parseInt(e.target.value, 10); if (isNaN(n) || n === a.sort_order) return; const res = await updateArea({ areaId: a.id, sort_order: n }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1 focus:outline-none focus:border-primary" />
diff --git a/app/(app)/customize/panels/invites-panel.tsx b/app/(app)/customize/panels/invites-panel.tsx
index 1cec8aa..c00398b 100644
--- a/app/(app)/customize/panels/invites-panel.tsx
+++ b/app/(app)/customize/panels/invites-panel.tsx
@@ -2,8 +2,10 @@
import { useState } from 'react'
import { toast } from 'sonner'
+import { Ticket } from 'lucide-react'
import { createInvite, updateInvite, deleteInvite } from '@/lib/actions'
import { Card } from './card'
+import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
import type { Invite } from './types'
function inviteLink(code: string) {
@@ -42,8 +44,16 @@ export function InvitesPanel({ isAdmin, invites: initial }: { isAdmin: boolean;
)}
+ {invites.length === 0 && (
+
+
+
+ No invite codes yet
+ Create a code to let new members join, with an optional expiry or use cap.
+
+
+ )}
- {invites.length === 0 &&
No invites yet.
}
{invites.map(inv => {
const expired = inv.expires_at ? new Date(inv.expires_at) < new Date() : false
const usedUp = inv.max_uses !== null && inv.uses_count >= inv.max_uses
diff --git a/app/(app)/customize/panels/onboarding-panel.tsx b/app/(app)/customize/panels/onboarding-panel.tsx
index ee4df00..5c9c568 100644
--- a/app/(app)/customize/panels/onboarding-panel.tsx
+++ b/app/(app)/customize/panels/onboarding-panel.tsx
@@ -2,8 +2,10 @@
import { useState } from 'react'
import { toast } from 'sonner'
+import { ListChecks } from 'lucide-react'
import { createOnboardingStep, updateOnboardingStep, deleteOnboardingStep } from '@/lib/actions'
import { Card } from './card'
+import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
import type { Step } from './types'
export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean; steps: Step[] }) {
@@ -28,6 +30,15 @@ export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean;
) : undefined}
>
+ {steps.length === 0 && (
+
+
+
+ No steps yet
+ Add steps that run, in order, the first time a new member opens the app.
+
+
+ )}
{[...steps].sort((a, b) => a.sort_order - b.sort_order).map(s => (
diff --git a/app/(app)/customize/panels/roles-panel.tsx b/app/(app)/customize/panels/roles-panel.tsx
index 3749884..35bf3ce 100644
--- a/app/(app)/customize/panels/roles-panel.tsx
+++ b/app/(app)/customize/panels/roles-panel.tsx
@@ -5,7 +5,9 @@ import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { upsertRoleLabel, createCustomRole, updateCustomRole, deleteCustomRole } from '@/lib/actions'
import { BUILTIN_ROLES, DEFAULT_ROLE_LABELS } from '@/lib/role-labels'
+import { Tags } from 'lucide-react'
import { Card } from './card'
+import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
import type { RoleLabelRow, CustomRole } from './types'
export function RolesPanel({ isAdmin, roleLabels, customRoles: initial }: { isAdmin: boolean; roleLabels: RoleLabelRow[]; customRoles: CustomRole[] }) {
@@ -110,7 +112,13 @@ export function RolesPanel({ isAdmin, roleLabels, customRoles: initial }: { isAd
)}
{customRoles.length === 0 ? (
-
No custom roles yet.
+
+
+
+ No custom roles yet
+ Add labels for committees, mentors, or area leads. They are display-only and do not change permissions.
+
+
) : (
{customRoles.map(r => (
diff --git a/app/(app)/customize/panels/tiers-panel.tsx b/app/(app)/customize/panels/tiers-panel.tsx
index f0cc710..1bdf09a 100644
--- a/app/(app)/customize/panels/tiers-panel.tsx
+++ b/app/(app)/customize/panels/tiers-panel.tsx
@@ -2,8 +2,10 @@
import { useState } from 'react'
import { toast } from 'sonner'
+import { CreditCard } from 'lucide-react'
import { createTier, updateTier, deleteTier } from '@/lib/actions'
import { Card } from './card'
+import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
import type { Tier } from './types'
export function TiersPanel({ isAdmin, tiers: initial }: { isAdmin: boolean; tiers: Tier[] }) {
@@ -40,8 +42,16 @@ export function TiersPanel({ isAdmin, tiers: initial }: { isAdmin: boolean; tier
)}
+ {tiers.length === 0 && (
+
+
+
+ No tiers yet
+ Membership tiers set the plans members can be placed on, with prices and billing cadence.
+
+
+ )}
- {tiers.length === 0 &&
No tiers yet.
}
{tiers.map(t => (
diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx
index 9b3a253..25b1373 100644
--- a/app/(app)/dashboard/page.tsx
+++ b/app/(app)/dashboard/page.tsx
@@ -3,6 +3,7 @@ import Link from 'next/link'
import { redirect } from 'next/navigation'
import type { Tables } from '@/types/database'
import { PageTitle } from '@/components/ui/page-title'
+import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
function IcoUsers({ className }: { className?: string }) {
return
@@ -216,7 +217,13 @@ export default async function DashboardPage() {
)
}) : (
-
No open tasks — great work!
+
+
+
+ No open tasks
+ Nothing needs doing right now. New chores will show up here.
+
+
)}
@@ -244,7 +251,13 @@ export default async function DashboardPage() {
)) : (
-
No active projects
+
+
+
+ No active projects
+ Projects in progress, blocked, or in review appear here.
+
+
)}
@@ -278,7 +291,13 @@ export default async function DashboardPage() {
)) : (
-
No recent activity yet.
+
+
+
+ No recent activity yet
+ Actions taken in your space will be listed here.
+
+
)}
diff --git a/app/(app)/financials/page.tsx b/app/(app)/financials/page.tsx
index dae3563..774b10f 100644
--- a/app/(app)/financials/page.tsx
+++ b/app/(app)/financials/page.tsx
@@ -2,7 +2,9 @@ import { redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import type { FinancialVisibility, Payment, Space } from '@/lib/types'
+import { Receipt } from 'lucide-react'
import { PageTitle } from '@/components/ui/page-title'
+import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
export const dynamic = 'force-dynamic'
@@ -127,7 +129,13 @@ export default async function FinancialsPage() {
By platform
{platformRows.length === 0 ? (
-
No payments recorded yet.
+
+
+
+ No payments yet
+ Platform totals appear here once payments are recorded.
+
+
) : (
{platformRows.map(([platform, total]) => {
diff --git a/app/(app)/incidents/page.tsx b/app/(app)/incidents/page.tsx
index c5b4075..170d6c4 100644
--- a/app/(app)/incidents/page.tsx
+++ b/app/(app)/incidents/page.tsx
@@ -1,8 +1,10 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
+import { ShieldCheck } from 'lucide-react'
import { createClient } from '@/lib/supabase/server'
import type { Incident, IncidentStatus } from '@/lib/types'
import { PageTitle } from '@/components/ui/page-title'
+import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
export const dynamic = 'force-dynamic'
@@ -61,9 +63,9 @@ export default async function IncidentsPage() {
)}
-
-
-
+
+
+
)
@@ -75,7 +77,12 @@ function Section({ title, empty, rows }: { title: string; empty: string; rows: I