diff --git a/.env.example b/.env.example index 20219ff..6aac861 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -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 diff --git a/README.md b/README.md index d0ae225..da66025 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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__ROLLOUT_PCT` | unset | unset | Optional 0–100 percentage rollout for the matching flag | **How flags work:** @@ -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 @@ -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 `` 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 @@ -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 diff --git a/app/[communitySlug]/admin/analytics/page.tsx b/app/[communitySlug]/admin/analytics/page.tsx index 6b41434..b95b258 100644 --- a/app/[communitySlug]/admin/analytics/page.tsx +++ b/app/[communitySlug]/admin/analytics/page.tsx @@ -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"; @@ -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"; @@ -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 ( + + + New Members Over Time + + + + + + ); + } - 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 ( - - New Members (Last 30 Days) - + New Members Over Time
{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; @@ -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 && (

- Total members at end of period:{" "} + Total signups shown:{" "} - {data[data.length - 1]?.totalMembers ?? 0} + {totalSignups.toLocaleString()}

@@ -124,59 +142,58 @@ function MemberGrowthChart({ data }: { data: MemberGrowthDataPoint[] }) { ); } -// ── 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 ( - Resource Access Summary + {title} - -
- - - - - - - - - - - {data.map((row) => { - const denialRate = - row.accessCount > 0 - ? ((row.deniedCount / row.accessCount) * 100).toFixed(1) - : "0.0"; - return ( - - - - - - - ); - })} - -
ResourceTotal AccessesDeniedDenial Rate
- {row.resourceTitle} - - ({row.resourceId}) - - - {row.accessCount.toLocaleString()} - - {row.deniedCount.toLocaleString()} - - {denialRate}% -
-
+ + {items.map((item) => ( +
+
+ + {item.label} + + + {item.count.toLocaleString()} + +
+
+
+
+
+ ))} ); } +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() { @@ -214,13 +231,18 @@ function AnalyticsContent() { isError, error, refetch, - } = useQuery({ + } = useQuery({ 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(); } @@ -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; }, @@ -247,10 +269,8 @@ function AnalyticsContent() { Analytics

- Community growth and resource access overview.{" "} - - Mock data — live endpoint pending backend confirmation (issue #157). - + Community growth and membership overview, computed from live member + and event data — no dedicated analytics backend required.

@@ -261,7 +281,7 @@ function AnalyticsContent() { ) : isLoading ? ( - ) : isError && !(isApiError(error) && error.code === 'aborted') ? ( + ) : isError && !(isApiError(error) && error.code === "aborted") ? ( 0 + ? `${((summary.activeMembers / summary.totalMembers) * 100).toFixed(0)}% of total` + : undefined + } /> s + d.newMembers, 0) + label="Signups Recorded" + value={summary.signupsOverTime + .reduce((sum, d) => sum + d.count, 0) .toLocaleString()} + description="From membership.created events" /> r.role === "admin")?.count ?? 0 + } /> - {/* Member growth chart */} - + {/* Signups-over-time chart */} + - {/* Resource access table */} - + {/* Role and tier distribution */} +
+ + +
{/* Generated-at footer */}

diff --git a/app/[communitySlug]/admin/members/page.tsx b/app/[communitySlug]/admin/members/page.tsx index 339ef1f..83a043a 100644 --- a/app/[communitySlug]/admin/members/page.tsx +++ b/app/[communitySlug]/admin/members/page.tsx @@ -32,6 +32,8 @@ import { AddressText } from "@/components/wallet/address-text"; import { isWalletAddress, normalizeAddress } from "@/lib/wallet/address"; import { BulkActionToolbar, type BulkResult } from "@/components/ui/bulk-action-toolbar"; import { Users } from "lucide-react"; +import Link from "next/link"; +import { features } from "@/lib/features"; type AssignRoleInput = { address: string; @@ -738,6 +740,14 @@ export default function MembersPage() { aria-label={`Select ${m.address}`} /> + {features.profiles && ( + + View profile + + )}

Tier: {m.tier} diff --git a/app/[communitySlug]/dashboard/page.tsx b/app/[communitySlug]/dashboard/page.tsx index 4c57349..6c55f96 100644 --- a/app/[communitySlug]/dashboard/page.tsx +++ b/app/[communitySlug]/dashboard/page.tsx @@ -29,6 +29,7 @@ import { import { SyncStatusBanner } from "@/components/ui/sync-status-banner"; import { AddressText } from "@/components/wallet/address-text"; import { DisabledTooltip } from "@/components/ui/tooltip"; +import { ProfileEditor } from "@/components/dashboard/profile-editor"; import { features } from "@/lib/features"; /** @@ -304,12 +305,23 @@ export default function DashboardPage() {
)} - -
+ +
+ {!address ? ( + + ) : ( + + )} +
+ +
{!address ? ( ) : isVerifying ? ( diff --git a/app/members/[address]/page.tsx b/app/members/[address]/page.tsx new file mode 100644 index 0000000..fa9a931 --- /dev/null +++ b/app/members/[address]/page.tsx @@ -0,0 +1,170 @@ +'use client'; +import { useState } from 'react'; +import { useParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import Link from 'next/link'; +import { getApi, type SocialLink } from '@/lib/api'; +import { queryKeys } from '@/lib/query'; +import { FeatureGate } from '@/components/feature-gate'; +import { LoadingState, ErrorState, EmptyState, safeErrorMessage } from '@/components/ui/api-states'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { AddressText } from '@/components/wallet/address-text'; +import { buttonVariants } from '@/components/ui/button'; +import { features } from '@/lib/features'; +import { isWalletAddress } from '@/lib/wallet/address'; + +function Avatar({ src, displayName }: { src?: string; displayName: string }) { + const [failed, setFailed] = useState(false); + + if (!src || failed) { + const initial = displayName.trim().charAt(0).toUpperCase() || '?'; + return ( + + ); + } + + return ( + // eslint-disable-next-line @next/next/no-img-element -- avatar URLs are arbitrary member-supplied values, not known at build time + setFailed(true)} + /> + ); +} + +function SocialLinkList({ links }: { links: SocialLink[] }) { + return ( + + ); +} + +function MemberProfileView() { + const { address } = useParams() as { address: string }; + const addressValid = isWalletAddress(address); + + const { + data: profile, + isLoading, + isError, + error, + refetch, + } = useQuery({ + queryKey: queryKeys.profile.byAddress(address), + queryFn: ({ signal }) => getApi().getProfile(address, signal), + enabled: addressValid, + retry: 1, + }); + + if (!addressValid) { + return ( + + ); + } + + if (isLoading) { + return ; + } + + if (isError) { + return ( + refetch()} + /> + ); + } + + if (!profile) { + return ( + + ); + } + + const displayName = profile.displayName?.trim() || 'Unnamed member'; + + return ( +
+ + + +
+ {displayName} + +
+
+ +
+

Bio

+ {profile.bio ? ( +

{profile.bio}

+ ) : ( +

This member hasn't added a bio yet.

+ )} +
+ +
+

Badges

+ {profile.badges.length > 0 ? ( +
+ {profile.badges.map((badge) => ( + {badge} + ))} +
+ ) : ( +

No badges yet.

+ )} +
+ +
+

Links

+ {profile.socialLinks && profile.socialLinks.length > 0 ? ( + + ) : ( +

No links shared yet.

+ )} +
+
+
+ + + Back to Dashboard + +
+ ); +} + +export default function MemberProfilePage() { + return ( + + + + ); +} diff --git a/components/dashboard/profile-editor.tsx b/components/dashboard/profile-editor.tsx new file mode 100644 index 0000000..dc06cdb --- /dev/null +++ b/components/dashboard/profile-editor.tsx @@ -0,0 +1,395 @@ +'use client'; +import { useEffect, useId, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { getApi, type MemberProfile, type SocialLink } from '@/lib/api'; +import { AuthError } from '@/lib/api/live'; +import { isApiError } from '@/lib/api/errors'; +import { queryKeys } from '@/lib/query'; +import { applyOptimisticProfile } from '@/lib/api/optimistic'; +import { validateProfile, type ProfileValidationErrors } from '@/lib/validation/profile'; +import { + clearProfileDraft, + loadProfileDraft, + storeProfileDraft, +} from '@/lib/profile-drafts'; +import { useSiweAuth } from '@/lib/wallet/providers'; +import { features } from '@/lib/features'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { LoadingState, ErrorState, safeErrorMessage } from '@/components/ui/api-states'; + +function SignInToEditButton() { + const { signIn, isSigningIn } = useSiweAuth(); + return ( + + ); +} + +function SocialLinksEditor({ + links, + onChange, + disabled, + groupLabelId, +}: { + links: SocialLink[]; + onChange: (links: SocialLink[]) => void; + disabled: boolean; + groupLabelId: string; +}) { + const updateLink = (index: number, patch: Partial) => { + onChange(links.map((link, i) => (i === index ? { ...link, ...patch } : link))); + }; + const removeLink = (index: number) => { + onChange(links.filter((_, i) => i !== index)); + }; + + return ( +
+ {links.map((link, index) => ( +
+ updateLink(index, { platform: e.target.value })} + placeholder="Platform (e.g. twitter)" + disabled={disabled} + aria-label={`Social link ${index + 1} platform`} + className="sm:w-1/3" + /> + updateLink(index, { url: e.target.value })} + placeholder="https://…" + disabled={disabled} + aria-label={`Social link ${index + 1} URL`} + /> + +
+ ))} + +
+ ); +} + +export function ProfileForm({ + address, + initial, + onSave, + onCancel, + disabled, + errors, +}: { + address: string; + initial: MemberProfile | null; + onSave: (profile: MemberProfile) => void; + onCancel: () => void; + disabled: boolean; + errors: ProfileValidationErrors; +}) { + const formId = useId(); + const draft = loadProfileDraft(address); + const [displayName, setDisplayName] = useState(draft?.displayName ?? initial?.displayName ?? ''); + const [bio, setBio] = useState(draft?.bio ?? initial?.bio ?? ''); + const [avatar, setAvatar] = useState(draft?.avatar ?? initial?.avatar ?? ''); + const [socialLinks, setSocialLinks] = useState( + draft?.socialLinks ?? initial?.socialLinks ?? [], + ); + + useEffect(() => { + storeProfileDraft(address, { displayName, bio, avatar, socialLinks }); + }, [address, displayName, bio, avatar, socialLinks]); + + const displayNameId = `${formId}-display-name`; + const bioId = `${formId}-bio`; + const avatarId = `${formId}-avatar`; + const socialLinksLabelId = `${formId}-social-links`; + + const handleSubmit = () => { + onSave({ + address, + displayName: displayName.trim() || undefined, + bio: bio.trim() || undefined, + avatar: avatar.trim() || undefined, + socialLinks: socialLinks.length > 0 ? socialLinks : undefined, + // badges are system-assigned; carried through only for the type, never + // actually applied server-side (mock/live both preserve the existing value). + badges: initial?.badges ?? [], + }); + }; + + return ( +
+
+ + setDisplayName(e.target.value)} + disabled={disabled} + maxLength={200} + aria-invalid={errors.displayName ? true : undefined} + aria-describedby={errors.displayName ? `${displayNameId}-error` : undefined} + /> + {errors.displayName && ( + + )} +
+ +
+ +