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. +

+ +
+ ) +} 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' && ( + + )} +
+ ) + })()} + {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 ? ( +
+

Connected as

+ +
+ ) : ( + + )} +
+
+ ) +} + +// ── 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) +}