Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ NEXT_PUBLIC_WALLET_CONNECTORS=injected
# NEXT_PUBLIC_FEATURE_GOVERNANCE=false # deferred — keep false until governance ships
# NEXT_PUBLIC_FEATURE_REWARDS=false # deferred — keep false until reward engine ships
# NEXT_PUBLIC_FEATURE_MULTI_COMMUNITY=false # deferred — nav stub only, no real multi-community logic yet
# NEXT_PUBLIC_FEATURE_PROFILES=false # deferred — keep false until rich profile customization (#254) ships
# NEXT_PUBLIC_FEATURE_ANALYTICS_ROLLOUT_PCT=25 # optional canary percentage, 0-100

# Uncomment and set to "true" to enable a module, or "false" to force-disable it
Expand All @@ -111,7 +112,7 @@ NEXT_PUBLIC_WALLET_CONNECTORS=injected
# NEXT_PUBLIC_FEATURE_GOVERNANCE=false
# NEXT_PUBLIC_FEATURE_REWARDS=false
# NEXT_PUBLIC_FEATURE_MULTI_COMMUNITY=false
# NEXT_PUBLIC_FEATURE_MULTI_COMMUNITY=false
# NEXT_PUBLIC_FEATURE_PROFILES=false
# NEXT_PUBLIC_FEATURE_ANALYTICS_ROLLOUT_PCT=25

# # Production Example
Expand Down
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ The main frontend MVP for the GuildPass ecosystem. Built with **Next.js 14 App R

## Features (MVP)

- **Member dashboard** — wallet connect, membership state, community & tier, expiration, badges placeholder, gated resources, profile summary
- **Admin dashboard** — overview, member list, role assignment, resource access policies, community settings
- **Member dashboard** — wallet connect, membership state, community & tier, expiration, badges, gated resources, wallet verification status, and a self-service profile editor (`NEXT_PUBLIC_FEATURE_PROFILES`)
- **Admin dashboard** — overview, member list, role assignment, resource access policies, community settings, analytics (`NEXT_PUBLIC_FEATURE_ANALYTICS`)
- **Access-gated experiences** — gated pages, gated content sections, event access, denied states, upgrade/renew placeholders
- **Wallet-aware UX** — connect flow, SIWE-authenticated admin experience, role-aware UI states, admin-only sections
- **SIWE authentication** — Sign-In with Ethereum (EIP-4361) for admin sessions; gasless off-chain signature; short-lived token attached to all mutations
Expand Down Expand Up @@ -159,8 +159,9 @@ Modules that are experimental or not yet production-ready are controlled by envi
| `NEXT_PUBLIC_FEATURE_ADMIN_SETTINGS` | `true` | `false` | Advanced admin community settings at `/admin/settings` (persistence deferred for MVP) |
| `NEXT_PUBLIC_FEATURE_EVENTS` | `true` | `false` | Event access page at `/events/*` |
| `NEXT_PUBLIC_FEATURE_RESOURCES` | `true` | `true` | Gated resources at `/resources/*` |
| `NEXT_PUBLIC_FEATURE_ANALYTICS` | `false` | `false` | Analytics module (not yet built) |
| `NEXT_PUBLIC_FEATURE_ANALYTICS` | `false` | `false` | Analytics at `/admin/analytics` — membership growth, role/tier distribution, computed client-side from `listMembers()`/`listWebhookEvents()`, no dedicated backend endpoint (#249) |
| `NEXT_PUBLIC_FEATURE_GOVERNANCE` | `false` | `false` | Governance module (not yet built) |
| `NEXT_PUBLIC_FEATURE_PROFILES` | `false` | `false` | Public member profile view at `/members/[address]` — rich profile customization (#254) |
| `NEXT_PUBLIC_FEATURE_<NAME>_ROLLOUT_PCT` | unset | unset | Optional 0–100 percentage rollout for the matching flag |

**How flags work:**
Expand Down Expand Up @@ -277,6 +278,10 @@ The diagram covers:
| `components/nav.tsx` | Navigation bar |
| `test/fixtures/openapi.json` | OpenAPI schema contract fixture representing core API models |
| `scripts/sync-api-types.js` | Zero-dependency compiler converting openapi.json to typescript types |
| `app/members/[address]/page.tsx` | Public, read-only member profile view (`NEXT_PUBLIC_FEATURE_PROFILES`) |
| `components/dashboard/profile-editor.tsx` | Self-service profile editor on the dashboard |
| `lib/validation/profile.ts` | Profile field validation (`validateProfile`) |
| `lib/api/analytics.ts` | Client-side analytics aggregation (`computeAnalyticsSummary`, `fetchAllMembers`) — no dedicated backend endpoint |

### Composable access rules

Expand All @@ -292,6 +297,15 @@ Access policies support an optional composable rule tree in addition to the lega

Primitive conditions are `tier` (tier ≥ X), `role` (has role Y), and `badge` (has badge Z); `and`/`or` nodes nest arbitrarily. When a policy sets `rule`, it takes precedence over `minTier`/`roles`; legacy policies are evaluated by wrapping them into an equivalent one-node tree, so behavior is unchanged. The recursive evaluator lives in [`lib/api/access-decision.ts`](./lib/api/access-decision.ts) (`evaluateAccessRule`), and the mock data seeds two demo policies (`mod-lounge` — a genuine AND, `insider-hub` — a genuine OR).

### Member profiles

Members can customize a `displayName`, `bio`, `avatar` (URL — image upload is a disabled "coming soon" stub, not implemented), and a list of `socialLinks` (`{ platform, url }`), in addition to the existing system-assigned `badges`. The feature is gated behind `NEXT_PUBLIC_FEATURE_PROFILES` (off by default in every environment, including mock mode — see [Feature Flags](#feature-flags)).

- **Editing** — the dashboard's "Profile" card (`components/dashboard/profile-editor.tsx`). Editing requires SIWE sign-in (reusing the same session as admin actions — see [docs/architecture.md](./docs/architecture.md#member-profile-edits-reuse-the-siwe-session-no-separate-auth-mechanism)), but *viewing* your own profile does not.
- **Public view** — `/members/[address]` (`app/members/[address]/page.tsx`) renders any member's customized fields read-only, with no wallet connection required and no `<Gated>` check (profile reads are public, matching `getProfile()`'s existing unauthenticated contract). Linked from the dashboard's Profile card and from each row in `/admin/members`.
- **Validation** — [`lib/validation/profile.ts`](./lib/validation/profile.ts) (`validateProfile()`), mirroring `validatePolicy()`'s `{valid, errors}` shape: length limits on `displayName`/`bio`, `http(s)`-only URL checks on `avatar` and each social link, and case-insensitive de-duplication of social link platforms.
- **`badges` is read-only** — it's system-assigned (community milestones), and `updateProfile()` never accepts client-submitted badges; both the mock and live clients always carry the existing value forward regardless of what's submitted.

---

## Integration Points
Expand Down Expand Up @@ -354,10 +368,13 @@ and troubleshooting.
- Core member and admin surfaces listed above
- Basic role assignment and policy editing
- Gated pages and states
- Rich profile customization (#254)
- Basic analytics — membership growth, role/tier distribution, computed client-side (#249)

**Deferred (intentionally)**:
- Advanced analytics and governance
- Rich profile customization and contribution history
- Governance
- Richer analytics (e.g. per-resource access/denial tracking — the admin event log does not capture resource-access attempts today, only membership-lifecycle and policy-update events, so this isn't derivable from existing data; see [Feature Flags](#feature-flags))
- Contribution history
- Social graph and advanced moderation
- Complex admin workflows, rewards visualization, full event management
- Complete billing/subscription management UX
Expand Down
198 changes: 117 additions & 81 deletions app/[communitySlug]/admin/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@ import { useAccount } from "wagmi";
import { useQuery } from "@tanstack/react-query";
import { getApi } from "@/lib/api";
import { useParams } from "next/navigation";
import type { AnalyticsSummary, MemberGrowthDataPoint, ResourceAccessCount } from "@/lib/api";
import { isApiError } from "@/lib/api/errors";
import {
computeAnalyticsSummary,
fetchAllMembers,
type ComputedAnalyticsSummary,
type RoleDistributionEntry,
type SignupsDataPoint,
type TierDistributionEntry,
} from "@/lib/api/analytics";
import { queryKeys } from "@/lib/query";
import { FeatureGate } from "@/components/feature-gate";
import { AdminGuard } from "@/components/admin-guard";
Expand All @@ -14,6 +21,7 @@ import { useSiweAuth } from "@/lib/wallet/providers";
import {
ErrorState,
LoadingState,
EmptyState,
safeErrorMessage,
} from "@/components/ui/api-states";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
Expand Down Expand Up @@ -49,39 +57,49 @@ function StatCard({
);
}

// ── Member growth bar chart (SVG, no external dependencies) ──────────────────
// ── Signups-over-time bar chart (SVG, no external dependencies) ──────────────

function MemberGrowthChart({ data }: { data: MemberGrowthDataPoint[] }) {
if (data.length === 0) return null;
function SignupsChart({ data }: { data: SignupsDataPoint[] }) {
if (data.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">New Members Over Time</CardTitle>
</CardHeader>
<CardContent>
<EmptyState
title="No signups recorded yet"
message="This chart is built from membership.created events in the admin event log — it will fill in as they occur."
/>
</CardContent>
</Card>
);
}

const maxNew = Math.max(...data.map((d) => d.newMembers), 1);
const maxCount = Math.max(...data.map((d) => d.count), 1);
const chartHeight = 80;
const barWidth = 8;
const gap = 2;
const barWidth = 16;
const gap = 6;
const totalWidth = data.length * (barWidth + gap) - gap;
const labelEvery = Math.ceil(data.length / 6);
const labelEvery = Math.max(1, Math.ceil(data.length / 8));
const totalSignups = data.reduce((sum, d) => sum + d.count, 0);

return (
<Card>
<CardHeader>
<CardTitle className="text-base">
New Members (Last 30 Days)
</CardTitle>
<CardTitle className="text-base">New Members Over Time</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${totalWidth} ${chartHeight + 20}`}
aria-label="Bar chart of new members joined per day over the last 30 days"
aria-label="Bar chart of new members per day, derived from membership.created events"
role="img"
className="w-full"
style={{ minWidth: totalWidth }}
>
{data.map((point, i) => {
const barH = Math.max(
2,
(point.newMembers / maxNew) * chartHeight,
);
const barH = Math.max(2, (point.count / maxCount) * chartHeight);
const x = i * (barWidth + gap);
const y = chartHeight - barH;
const showLabel = i % labelEvery === 0;
Expand All @@ -95,7 +113,7 @@ function MemberGrowthChart({ data }: { data: MemberGrowthDataPoint[] }) {
height={barH}
rx={1}
className="fill-primary"
aria-label={`${point.date}: ${point.newMembers} new members`}
aria-label={`${point.date}: ${point.count} new member${point.count === 1 ? "" : "s"}`}
/>
{showLabel && (
<text
Expand All @@ -114,69 +132,68 @@ function MemberGrowthChart({ data }: { data: MemberGrowthDataPoint[] }) {
</svg>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Total members at end of period:{" "}
Total signups shown:{" "}
<span className="font-medium text-foreground">
{data[data.length - 1]?.totalMembers ?? 0}
{totalSignups.toLocaleString()}
</span>
</p>
</CardContent>
</Card>
);
}

// ── Resource access table ─────────────────────────────────────────────────────
// ── Distribution bars (role / tier) ───────────────────────────────────────────

function DistributionBars({
title,
items,
}: {
title: string;
items: { label: string; count: number }[];
}) {
const max = Math.max(...items.map((i) => i.count), 1);

function ResourceAccessTable({ data }: { data: ResourceAccessCount[] }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Resource Access Summary</CardTitle>
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border text-left text-sm">
<thead className="bg-muted text-muted-foreground uppercase text-xs font-semibold tracking-wider">
<tr>
<th className="px-4 py-3">Resource</th>
<th className="px-4 py-3 text-right">Total Accesses</th>
<th className="px-4 py-3 text-right">Denied</th>
<th className="px-4 py-3 text-right">Denial Rate</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-transparent text-card-foreground">
{data.map((row) => {
const denialRate =
row.accessCount > 0
? ((row.deniedCount / row.accessCount) * 100).toFixed(1)
: "0.0";
return (
<tr key={row.resourceId} className="hover:bg-muted/50 transition-colors">
<td className="px-4 py-3 font-medium text-foreground">
{row.resourceTitle}
<span className="ml-1.5 font-mono text-xs text-muted-foreground">
({row.resourceId})
</span>
</td>
<td className="px-4 py-3 text-right tabular-nums">
{row.accessCount.toLocaleString()}
</td>
<td className="px-4 py-3 text-right tabular-nums text-red-600 dark:text-red-400">
{row.deniedCount.toLocaleString()}
</td>
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">
{denialRate}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<CardContent className="space-y-3">
{items.map((item) => (
<div key={item.label} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium capitalize text-foreground">
{item.label}
</span>
<span className="tabular-nums text-muted-foreground">
{item.count.toLocaleString()}
</span>
</div>
<div
role="img"
aria-label={`${item.label}: ${item.count}`}
className="h-2 w-full overflow-hidden rounded-full bg-muted"
>
<div
className="h-full rounded-full bg-primary"
style={{ width: `${(item.count / max) * 100}%` }}
/>
</div>
</div>
))}
</CardContent>
</Card>
);
}

function roleItems(distribution: RoleDistributionEntry[]) {
return distribution.map((entry) => ({ label: entry.role, count: entry.count }));
}

function tierItems(distribution: TierDistributionEntry[]) {
return distribution.map((entry) => ({ label: entry.tier, count: entry.count }));
}

// ── Session-expired re-auth helper ────────────────────────────────────────────

function SessionExpiredState() {
Expand Down Expand Up @@ -214,13 +231,18 @@ function AnalyticsContent() {
isError,
error,
refetch,
} = useQuery<AnalyticsSummary>({
} = useQuery<ComputedAnalyticsSummary>({
queryKey: [...queryKeys.analytics.summary(communitySlug), address, authSession?.token ?? "anonymous"],
queryFn: async ({ signal }) => {
try {
return await getApi(address, authSession?.token, communitySlug).getAnalyticsSummary(signal);
const api = getApi(address, authSession?.token, communitySlug);
const [members, events] = await Promise.all([
fetchAllMembers(api, signal),
api.listWebhookEvents(signal),
]);
return computeAnalyticsSummary(members, events);
} catch (err) {
if (isApiError(err) && err.code === 'aborted') throw err;
if (isApiError(err) && err.code === "aborted") throw err;
if (isApiError(err) && err.code === "unauthorized") {
markExpired();
}
Expand All @@ -229,7 +251,7 @@ function AnalyticsContent() {
},
enabled: !!address && sessionStatus === "authenticated",
retry: (failureCount, err) => {
if (isApiError(err) && err.code === 'aborted') return false;
if (isApiError(err) && err.code === "aborted") return false;
if (isApiError(err) && err.code === "unauthorized") return false;
return failureCount < 1;
},
Expand All @@ -247,10 +269,8 @@ function AnalyticsContent() {
Analytics
</h1>
<p className="text-sm text-muted-foreground">
Community growth and resource access overview.{" "}
<span className="font-medium text-yellow-600 dark:text-yellow-400">
Mock data — live endpoint pending backend confirmation (issue #157).
</span>
Community growth and membership overview, computed from live member
and event data — no dedicated analytics backend required.
</p>
</div>

Expand All @@ -261,7 +281,7 @@ function AnalyticsContent() {
<SessionExpiredState />
) : isLoading ? (
<LoadingState message="Loading analytics…" />
) : isError && !(isApiError(error) && error.code === 'aborted') ? (
) : isError && !(isApiError(error) && error.code === "aborted") ? (
<ErrorState
title="Error loading analytics"
message={safeErrorMessage(error)}
Expand All @@ -278,25 +298,41 @@ function AnalyticsContent() {
<StatCard
label="Active Members"
value={summary.activeMembers.toLocaleString()}
description={`${((summary.activeMembers / summary.totalMembers) * 100).toFixed(0)}% of total`}
description={
summary.totalMembers > 0
? `${((summary.activeMembers / summary.totalMembers) * 100).toFixed(0)}% of total`
: undefined
}
/>
<StatCard
label="New (30 Days)"
value={summary.memberGrowth
.reduce((s, d) => s + d.newMembers, 0)
label="Signups Recorded"
value={summary.signupsOverTime
.reduce((sum, d) => sum + d.count, 0)
.toLocaleString()}
description="From membership.created events"
/>
<StatCard
label="Resources Tracked"
value={summary.resourceAccess.length}
label="Admins"
value={
summary.roleDistribution.find((r) => r.role === "admin")?.count ?? 0
}
/>
</div>

{/* Member growth chart */}
<MemberGrowthChart data={summary.memberGrowth} />
{/* Signups-over-time chart */}
<SignupsChart data={summary.signupsOverTime} />

{/* Resource access table */}
<ResourceAccessTable data={summary.resourceAccess} />
{/* Role and tier distribution */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<DistributionBars
title="Role Distribution"
items={roleItems(summary.roleDistribution)}
/>
<DistributionBars
title="Tier Distribution"
items={tierItems(summary.tierDistribution)}
/>
</div>

{/* Generated-at footer */}
<p className="text-right text-xs text-muted-foreground">
Expand Down
Loading