diff --git a/app/[communitySlug]/dashboard/page.tsx b/app/[communitySlug]/dashboard/page.tsx
index 9a8acb3..e09db8c 100644
--- a/app/[communitySlug]/dashboard/page.tsx
+++ b/app/[communitySlug]/dashboard/page.tsx
@@ -16,11 +16,9 @@ import { queryKeys } from "@/lib/query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MembershipExpiryBadge } from "@/components/ui/membership-expiry-badge";
-import { MembershipCardSkeleton } from "@/components/dashboard/membership-card-skeleton";
import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
import {
- LoadingState,
ErrorState,
EmptyState,
DeniedState,
@@ -465,5 +463,5 @@ export default function DashboardPage() {
- );
+ )
}
diff --git a/components/dashboard/community-section.tsx b/components/dashboard/community-section.tsx
new file mode 100644
index 0000000..48cddf2
--- /dev/null
+++ b/components/dashboard/community-section.tsx
@@ -0,0 +1,42 @@
+/**
+ * components/dashboard/community-section.tsx
+ *
+ * Server Component that fetches and renders community information.
+ * Streamed via Suspense — the page shell renders immediately while
+ * this data loads server-side.
+ */
+
+import { fetchCommunity } from '@/lib/api/fetch-server'
+import { CommunityCardSkeleton } from './skeletons'
+
+// Re-export the skeleton so the parent can use it as a Suspense fallback
+export { CommunityCardSkeleton }
+
+export default async function CommunitySection() {
+ let community
+ try {
+ community = await fetchCommunity()
+ } catch {
+ return (
+
+
Could not load community
+
+ Community information is temporarily unavailable.
+
+
+ )
+ }
+
+ return (
+
+
Community
+
{community.name}
+ {community.description && (
+
{community.description}
+ )}
+
+ Tiers: {community.tiers.map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(', ')}
+
+
+ )
+}
diff --git a/components/dashboard/resources-section.tsx b/components/dashboard/resources-section.tsx
new file mode 100644
index 0000000..ec2bbf4
--- /dev/null
+++ b/components/dashboard/resources-section.tsx
@@ -0,0 +1,70 @@
+/**
+ * components/dashboard/resources-section.tsx
+ *
+ * Server Component that fetches and renders the public resource listing.
+ * Streamed via Suspense — the page shell renders immediately while
+ * this data loads server-side.
+ *
+ * Note: Access gating (which resources the user can actually view) is
+ * still determined client-side based on the connected wallet's membership.
+ * This component renders the full list; the gating logic runs in the
+ * client-side WalletDashboardSections component.
+ */
+
+import { fetchResources } from '@/lib/api/fetch-server'
+import { ResourcesCardSkeleton } from './skeletons'
+
+// Re-export the skeleton so the parent can use it as a Suspense fallback
+export { ResourcesCardSkeleton }
+
+export default async function ResourcesSection() {
+ let resources
+ try {
+ resources = await fetchResources()
+ } catch {
+ return (
+
+
Could not load resources
+
+ Resource listing is temporarily unavailable.
+
+
+ )
+ }
+
+ if (!resources || resources.length === 0) {
+ return (
+
+
Gated Resources
+
+ No resources have been configured for this community yet.
+
+
+ )
+ }
+
+ return (
+
+
+ Gated Resources ({resources.length})
+
+
+ Resources are available based on your membership tier. Connect your
+ wallet to see which ones you can access.
+
+
+ {resources.map((r) => (
+
+
+ {r.title}
+ {r.minTier && (
+
+ {r.minTier.charAt(0).toUpperCase() + r.minTier.slice(1)}+
+
+ )}
+
+ ))}
+
+
+ )
+}
diff --git a/components/dashboard/skeletons.tsx b/components/dashboard/skeletons.tsx
new file mode 100644
index 0000000..4e04056
--- /dev/null
+++ b/components/dashboard/skeletons.tsx
@@ -0,0 +1,58 @@
+/**
+ * components/dashboard/skeletons.tsx
+ *
+ * Loading-placeholder components used as Suspense fallbacks during
+ * SSR streaming of the member dashboard. Each skeleton mirrors the
+ * layout of its corresponding data section.
+ */
+
+export function CommunityCardSkeleton() {
+ return (
+
+ )
+}
+
+export function ResourcesCardSkeleton() {
+ return (
+
+ )
+}
+
+export function ProfileCardSkeleton() {
+ return (
+
+ )
+}
+
+export function BadgesCardSkeleton() {
+ return (
+
+ )
+}
diff --git a/components/dashboard/wallet-sections.tsx b/components/dashboard/wallet-sections.tsx
new file mode 100644
index 0000000..3480f41
--- /dev/null
+++ b/components/dashboard/wallet-sections.tsx
@@ -0,0 +1,286 @@
+'use client'
+
+/**
+ * components/dashboard/wallet-sections.tsx
+ *
+ * Client-side wallet-dependent dashboard sections.
+ * These hydrate client-side after the SSR shell (community, resources)
+ * has streamed in. Wrapped in Suspense boundaries in the parent page.
+ */
+
+import { useAccount } from 'wagmi'
+import { useQuery } from '@tanstack/react-query'
+import {
+ getApi,
+ type MemberProfile,
+ type Membership,
+ type Session,
+ type WalletVerification,
+} from '@/lib/api'
+import { queryKeys } from '@/lib/query'
+import { Badge } from '@/components/ui/badge'
+import {
+ LoadingState,
+ ErrorState,
+ EmptyState,
+ DeniedState,
+ safeErrorMessage,
+} from '@/components/ui/api-states'
+import { AddressText } from '@/components/wallet/address-text'
+
+function Section({
+ title,
+ children,
+}: {
+ title: string
+ children: React.ReactNode
+}) {
+ return (
+
+
{title}
+ {children}
+
+ )
+}
+
+/**
+ * Reads the wallet address and environment, then renders the four
+ * wallet-dependent dashboard cards: community membership status,
+ * profile / verification, badges, and gated resources.
+ */
+export default function WalletDashboardSections() {
+ const { address, isConnected } = useAccount()
+
+ const {
+ data: session,
+ isLoading,
+ isError,
+ error,
+ refetch,
+ } = useQuery({
+ queryKey: queryKeys.session.byAddress(address ?? ''),
+ queryFn: () => getApi(address).getSession(),
+ enabled: !!address,
+ retry: 1,
+ })
+
+ const {
+ data: verification,
+ isLoading: isVerifying,
+ isError: verifyIsError,
+ error: verifyError,
+ refetch: refetchVerification,
+ } = useQuery({
+ queryKey: queryKeys.walletVerification.byAddress(address ?? ''),
+ queryFn: () => getApi(address).verifyWallet(address as string),
+ enabled: !!address,
+ retry: 1,
+ })
+
+ const {
+ data: profile,
+ isLoading: profileLoading,
+ isError: profileIsError,
+ error: profileError,
+ refetch: refetchProfile,
+ } = useQuery({
+ queryKey: queryKeys.profile.byAddress(address ?? ''),
+ queryFn: () => getApi(address).getProfile(address as string),
+ enabled: !!address,
+ retry: 1,
+ })
+
+ const membership: Membership | undefined = session?.membership
+
+ return (
+
+ {/* ── Membership status (wallet-dependent) ────────────────────────── */}
+
+ {!address ? (
+
+ ) : isLoading ? (
+
+ ) : isError ? (
+ refetch()}
+ />
+ ) : (
+
+
+ {session?.community?.name ?? 'Unknown'}
+
+
+ Tier:{' '}
+
+ {membership?.tier ?? '—'}
+
+
+
+ Status:{' '}
+ {membership?.active ? (
+ Active
+ ) : (
+ Inactive
+ )}
+
+
+ Expires:{' '}
+ {membership?.expiresAt
+ ? new Date(membership.expiresAt).toLocaleDateString()
+ : 'N/A'}
+
+
+ )}
+
+
+ {/* ── Profile & Verification (wallet-dependent) ──────────────────── */}
+
+ {!address ? (
+
+ ) : isVerifying ? (
+
+ ) : (
+
+ {(() => {
+ const display = mapVerificationState(verification, verifyError)
+ return (
+
+
+ Verification:{' '}
+
+ {display.title}
+
+
+
+ {display.message}
+
+ {display.status === 'failed' && (
+
refetchVerification()}
+ className="text-xs text-primary underline underline-offset-4"
+ >
+ Try again
+
+ )}
+
+ )
+ })()}
+ {verification && (
+
+ {verification.method ? (
+
+ Method: {verification.method}
+
+ ) : null}
+
+ Checked:{' '}
+ {new Date(verification.checkedAt).toLocaleString()}
+
+
+ )}
+
+ )}
+
+
+ {/* ── Badges (wallet-dependent) ──────────────────────────────────── */}
+
+ {!address ? (
+
+ ) : profileLoading ? (
+
+ ) : profileIsError ? (
+ refetchProfile()}
+ />
+ ) : profile && profile.badges.length > 0 ? (
+
+ {profile.badges.map((badge) => (
+ {badge}
+ ))}
+
+ ) : (
+
+ )}
+
+
+ {/* ── Address (wallet-dependent summary) ─────────────────────────── */}
+
+ {isConnected && address ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+// ── Verification state mapper (copied from old dashboard page) ──────────────
+
+interface VerificationDisplay {
+ title: string
+ message: string
+ badgeVariant: 'outline' | 'success' | 'destructive'
+ status: 'idle' | 'verified' | 'failed'
+}
+
+function mapVerificationState(
+ verification: WalletVerification | undefined,
+ error: Error | null,
+): VerificationDisplay {
+ if (error) {
+ return {
+ title: 'Verification failed',
+ message: safeErrorMessage(error),
+ badgeVariant: 'destructive',
+ status: 'failed',
+ }
+ }
+ if (!verification) {
+ return {
+ title: 'Pending',
+ message: 'Verification status is being checked…',
+ badgeVariant: 'outline',
+ status: 'idle',
+ }
+ }
+ if (verification.verified) {
+ return {
+ title: 'Verified',
+ message: 'Your wallet has been verified.',
+ badgeVariant: 'success',
+ status: 'verified',
+ }
+ }
+ return {
+ title: 'Unverified',
+ message: 'Your wallet has not been verified yet.',
+ badgeVariant: 'destructive',
+ status: 'failed',
+ }
+}
diff --git a/lib/api/fetch-server.ts b/lib/api/fetch-server.ts
new file mode 100644
index 0000000..0b448dc
--- /dev/null
+++ b/lib/api/fetch-server.ts
@@ -0,0 +1,105 @@
+/**
+ * lib/api/fetch-server.ts
+ *
+ * Server-only fetch helpers for wallet-independent API calls.
+ * These are safe to use in React Server Components because they
+ * import zero client-only modules (wagmi, React hooks, etc.).
+ *
+ * Data is fetched directly from the guildpass-core API using
+ * the configured base URL, avoiding CORS and extra client→server
+ * round trips.
+ */
+
+import 'server-only'
+import { z } from 'zod'
+
+import { config } from '@/lib/config'
+import type { Community, Resource } from './types'
+import { CommunitySchema, ResourceSchema } from './types'
+import { mapCommunity, mapResource } from './mappers'
+
+// ── Generic fetch wrapper (server-side only) ─────────────────────────────────
+
+class ServerFetchError extends Error {
+ readonly status: number
+ readonly path: string
+
+ constructor(message: string, status: number, path: string) {
+ super(message)
+ this.name = 'ServerFetchError'
+ this.status = status
+ this.path = path
+ }
+}
+
+async function serverFetchJson(
+ path: string,
+ init?: RequestInit,
+): Promise {
+ const url = `${config.apiUrl}${path}`
+
+ let res: Response
+ // Build the fetch init without Next.js extensions, then add `next` separately
+ const fetchInit: RequestInit & { next?: { revalidate: number } } = {
+ ...init,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(init?.headers ?? {}),
+ },
+ // Allow server-side caching (Next.js fetch cache extension)
+ next: { revalidate: 60 },
+ }
+
+ try {
+ res = await fetch(url, fetchInit)
+ } catch (cause) {
+ throw new ServerFetchError(
+ `Failed to fetch ${url}: ${(cause as Error).message}`,
+ 0,
+ path,
+ )
+ }
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => '')
+ throw new ServerFetchError(
+ `Server returned ${res.status} for ${path}: ${text.slice(0, 200)}`,
+ res.status,
+ path,
+ )
+ }
+
+ if (res.status === 204 || res.status === 205) {
+ return {} as T
+ }
+
+ return (await res.json()) as T
+}
+
+/**
+ * Fetch the community profile from the core API.
+ * This is a public, wallet-independent endpoint.
+ */
+export async function fetchCommunity(): Promise {
+ const raw = await serverFetchJson>('/v1/community')
+ const parsed = CommunitySchema.safeParse(raw)
+ if (!parsed.success) {
+ console.error('[server] Community schema mismatch:', parsed.error.message)
+ return raw as unknown as Community
+ }
+ return mapCommunity(parsed.data)
+}
+
+/**
+ * Fetch the public resource listing from the core API.
+ * This is a public, wallet-independent endpoint.
+ */
+export async function fetchResources(): Promise {
+ const raw = await serverFetchJson('/v1/resources')
+ const parsed = z.array(ResourceSchema).safeParse(raw)
+ if (!parsed.success) {
+ console.error('[server] Resources schema mismatch:', parsed.error.message)
+ return raw as Resource[]
+ }
+ return parsed.data.map(mapResource)
+}