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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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
- **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
- **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
Expand Down Expand Up @@ -161,6 +161,7 @@ Modules that are experimental or not yet production-ready are controlled by envi
| `NEXT_PUBLIC_FEATURE_RESOURCES` | `true` | `true` | Gated resources at `/resources/*` |
| `NEXT_PUBLIC_FEATURE_ANALYTICS` | `false` | `false` | Analytics module (not yet built) |
| `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,9 @@ 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`) |

### Composable access rules

Expand All @@ -292,6 +296,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
10 changes: 10 additions & 0 deletions app/[communitySlug]/admin/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -738,6 +740,14 @@ export default function MembersPage() {
aria-label={`Select ${m.address}`}
/>
<AddressText address={m.address} className="text-sm" />
{features.profiles && (
<Link
href={`/members/${m.address}`}
className="text-xs text-primary underline-offset-4 hover:underline"
>
View profile
</Link>
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>Tier: {m.tier}</span>
Expand Down
18 changes: 15 additions & 3 deletions app/[communitySlug]/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -304,12 +305,23 @@ export default function DashboardPage() {
</div>
)}
</Section>

<Section title="Profile Summary">

<Section title="Profile">
{!address ? (
<DeniedState
title="Wallet connection required"
message="Connect your wallet to view and edit your profile."
/>
) : (
<ProfileEditor address={address} />
)}
</Section>

<Section title="Wallet Verification">
{!address ? (
<DeniedState
title="Wallet connection required"
message="Connect your wallet to load your profile and verification state."
message="Connect your wallet to load your verification state."
/>
) : isVerifying ? (
<LoadingState />
Expand Down
170 changes: 170 additions & 0 deletions app/members/[address]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
aria-hidden="true"
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-muted text-xl font-semibold text-muted-foreground"
>
{initial}
</div>
);
}

return (
// eslint-disable-next-line @next/next/no-img-element -- avatar URLs are arbitrary member-supplied values, not known at build time
<img
src={src}
alt=""
className="h-16 w-16 shrink-0 rounded-full object-cover"
onError={() => setFailed(true)}
/>
);
}

function SocialLinkList({ links }: { links: SocialLink[] }) {
return (
<ul className="flex flex-wrap gap-3">
{links.map((link) => (
<li key={link.platform}>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary underline-offset-4 hover:underline"
>
{link.platform}
</a>
</li>
))}
</ul>
);
}

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 (
<EmptyState
title="Invalid profile link"
message="This URL does not contain a valid wallet address."
/>
);
}

if (isLoading) {
return <LoadingState message="Loading profile…" />;
}

if (isError) {
return (
<ErrorState
title="Could not load profile"
message={safeErrorMessage(error)}
onRetry={() => refetch()}
/>
);
}

if (!profile) {
return (
<EmptyState
title="Member not found"
message="No profile exists for this address."
/>
);
}

const displayName = profile.displayName?.trim() || 'Unnamed member';

return (
<div className="space-y-4">
<Card>
<CardHeader className="flex flex-row items-center gap-4">
<Avatar src={profile.avatar} displayName={displayName} />
<div className="space-y-1">
<CardTitle>{displayName}</CardTitle>
<AddressText address={profile.address} className="text-sm text-muted-foreground" />
</div>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h2 className="text-sm font-medium text-muted-foreground">Bio</h2>
{profile.bio ? (
<p className="whitespace-pre-wrap text-sm">{profile.bio}</p>
) : (
<p className="text-sm italic text-muted-foreground">This member hasn&apos;t added a bio yet.</p>
)}
</div>

<div>
<h2 className="text-sm font-medium text-muted-foreground">Badges</h2>
{profile.badges.length > 0 ? (
<div className="flex flex-wrap gap-2">
{profile.badges.map((badge) => (
<Badge key={badge}>{badge}</Badge>
))}
</div>
) : (
<p className="text-sm italic text-muted-foreground">No badges yet.</p>
)}
</div>

<div>
<h2 className="text-sm font-medium text-muted-foreground">Links</h2>
{profile.socialLinks && profile.socialLinks.length > 0 ? (
<SocialLinkList links={profile.socialLinks} />
) : (
<p className="text-sm italic text-muted-foreground">No links shared yet.</p>
)}
</div>
</CardContent>
</Card>

<Link href="/dashboard" className={buttonVariants({ variant: 'outline' })}>
Back to Dashboard
</Link>
</div>
);
}

export default function MemberProfilePage() {
return (
<FeatureGate enabled={features.profiles} name="Member Profiles">
<MemberProfileView />
</FeatureGate>
);
}
Loading