Skip to content
Open
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
14 changes: 13 additions & 1 deletion app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
} from "@/lib/api";
import { isApiError } from "@/lib/api/errors";
import { mapVerificationState } from "@/lib/api/mappers";
import { queryKeys } from "@/lib/query";
import { queryKeys } from "@/lib/query"
import { type ContributionEvent } from "@/lib/api";
import ContributionHistory from "@/components/dashboard/contribution-history";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MembershipExpiryBadge } from "@/components/ui/membership-expiry-badge";
Expand Down Expand Up @@ -414,6 +416,16 @@ export default function DashboardPage() {
/>
)}
</Section>
<Section title="Contribution History">
{!address ? (
<DeniedState
title="Wallet connection required"
message="Connect your wallet to view your contribution history."
/>
) : (
<ContributionHistory address={address ?? ""} />
)}
</Section>
</div>
</div>
);
Expand Down
3 changes: 3 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import './globals.css'
import { RootProviders } from '@/lib/wallet/providers'
import { Nav } from '@/components/nav'
import { SwRegistrar } from '@/components/sw-registrar'
import SyncStatusBanner from '@/components/ui/sync-status-banner'

export const metadata: Metadata = {
title: {
Expand All @@ -25,6 +26,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<RootProviders>
{/* Registers the service worker for dashboard offline caching */}
<SwRegistrar />
{/* Offline/Degraded status banner */}
<SyncStatusBanner className="mb-4 w-full" />
<Nav />
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
</RootProviders>
Expand Down
44 changes: 44 additions & 0 deletions lib/api/backendStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { config } from '@/lib/config';
import { OfflineError } from '@/lib/api/errors';

let _online: boolean = true;
const listeners: Array<(online: boolean) => void> = [];

/** Reactive flag for backend status */
export const backendOnline = {
get: () => _online,
set: (value: boolean) => {
if (_online !== value) {
_online = value;
listeners.forEach((cb) => cb(_online));
}
},
subscribe: (cb: (online: boolean) => void) => {
listeners.push(cb);
// Return unsubscribe function
return () => {
const idx = listeners.indexOf(cb);
if (idx !== -1) listeners.splice(idx, 1);
};
},
};

/**
* Perform a lightweight health‑check against the core API. Throws {@link OfflineError}
* if the backend is unreachable or returns a non‑OK status. Updates the
* {@link backendOnline} flag accordingly.
*/
export async function ensureOnline(): Promise<void> {
// Fast‑path: if we already think it's online, still verify in case of stale state.
try {
const res = await fetch(`${config.apiUrl}/healthz`, { method: 'GET' });
if (res.ok) {
backendOnline.set(true);
return;
}
} catch {
// ignore – handled below
}
backendOnline.set(false);
throw new OfflineError();
}
65 changes: 41 additions & 24 deletions lib/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,25 @@ export type ApiErrorCode =
| 'service_unavailable'
| 'bad_request'
| 'unknown_error'
| 'aborted'
| 'aborted';

export interface ApiErrorOptions {
status?: number
code: ApiErrorCode
safeMessage: string
path?: string
retryable?: boolean
details?: Record<string, unknown>
cause?: unknown
status?: number;
code: ApiErrorCode;
safeMessage: string;
path?: string;
retryable?: boolean;
details?: Record<string, unknown>;
cause?: unknown;
}

export class ApiError extends Error {
readonly status?: number
readonly code: ApiErrorCode
readonly safeMessage: string
readonly path?: string
readonly retryable: boolean
readonly details?: Record<string, unknown>
readonly status?: number;
readonly code: ApiErrorCode;
readonly safeMessage: string;
readonly path?: string;
readonly retryable: boolean;
readonly details?: Record<string, unknown>;

constructor({
status,
Expand All @@ -38,21 +38,38 @@ export class ApiError extends Error {
details,
cause,
}: ApiErrorOptions) {
super(safeMessage)
this.name = 'ApiError'
this.status = status
this.code = code
this.safeMessage = safeMessage
this.path = path
this.retryable = retryable
this.details = details
super(safeMessage);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.safeMessage = safeMessage;
this.path = path;
this.retryable = retryable;
this.details = details;

if (cause !== undefined) {
;(this as Error & { cause?: unknown }).cause = cause
;(this as Error & { cause?: unknown }).cause = cause;
}
}
}

/**
* Represents an offline or degraded‑mode error. It is a specific kind of
* `ApiError` with the code `service_unavailable` and is not retryable. UI
* components can catch this type to display an offline banner.
*/
export class OfflineError extends ApiError {
constructor(message = 'The application is offline. Showing cached data.') {
super({
status: 503,
code: 'service_unavailable',
safeMessage: message,
retryable: false,
});
this.name = 'OfflineError';
}
}

export function isApiError(err: unknown): err is ApiError {
return err instanceof ApiError
return err instanceof ApiError;
}
23 changes: 22 additions & 1 deletion lib/api/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
AccessPolicySchema,
WebhookEventLogSchema,
SiweAuthSessionSchema,
ContributionEvent,
} from './types'
import {
mapCommunity,
Expand All @@ -43,6 +44,7 @@ import {
mapPolicy,
mapSession,
mapWebhookEvent,
mapContributionEvent,
} from './mappers'
import { ApiError } from './errors'
import {
Expand All @@ -63,6 +65,8 @@ export { ApiError as AuthError } from './errors'

import { PolicyValidationError, validatePolicy } from '../validation/policy'
import { config } from '../config'
import { ensureOnline, backendOnline } from '@/lib/api/backendStatus'
import { OfflineError } from '@/lib/api/errors'

const BASE = config.apiUrl

Expand Down Expand Up @@ -444,6 +448,8 @@ function isAbortError(err: unknown): boolean {
* counted by the circuit breaker.
*/
async function getJson<T>(path: string, options: RequestOptions = {}): Promise<T> {
// Abort if backend is offline
ensureOnline();
const {
schema,
prefixBase = true,
Expand Down Expand Up @@ -651,6 +657,20 @@ export class LiveAccessApi implements AccessApi {
return raw ? mapMemberProfile(raw, address) : null
}

async getContributionHistory(address: string, signal?: AbortSignal): Promise<ContributionEvent[]> {
const path = `/v1/members/${encodeURIComponent(address)}/events`
try {
const raw = await getJson<unknown[] | null>(path, { schema: z.array(z.unknown()).nullable(), signal })
if (!raw || !Array.isArray(raw)) return []
return raw.map((item) => mapContributionEvent(item, address))
} catch (err) {
if (isApiError(err) && (err.status === 404 || err.code === 'not_found')) {
return []
}
throw err
}
}

async listMembers(params?: { cursor?: string; limit?: number; filter?: string }, signal?: AbortSignal): Promise<MemberRow[] | PaginatedMembers> {
const query = new URLSearchParams()
if (params?.cursor) query.append('cursor', params.cursor)
Expand Down Expand Up @@ -697,7 +717,8 @@ export class LiveAccessApi implements AccessApi {
}

async getResource(id: string, signal?: AbortSignal): Promise<ResourceLookupResult> {
const path = `/v1/resources/${encodeURIComponent(id)}`
ensureOnline();
const path = `/v1/resources/${encodeURIComponent(id)}`
try {
const raw = await getJson<BackendResource>(path, { schema: ResourceSchema, signal })
if (raw && Object.keys(raw).length > 0) {
Expand Down
15 changes: 15 additions & 0 deletions lib/api/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
BackendMember,
BackendSession,
WalletVerification,
ContributionEvent,
} from './types'
import { isApiError } from './errors'

Expand Down Expand Up @@ -188,4 +189,18 @@ export function mapVerificationState(
message: 'This wallet is not yet verified.',
badgeVariant: 'warning',
}
}

// ── Contribution Event ────────────────────────────────────────────────────────

export function mapContributionEvent(raw: any, address: string): ContributionEvent {
return {
id: raw.id ?? `evt_${Math.random().toString(36).substring(2, 9)}`,
address: raw.address ?? address,
type: raw.type ?? raw.event_type ?? 'generic',
title: raw.title ?? raw.name ?? 'Activity Event',
description: raw.description,
timestamp: raw.timestamp ?? raw.created_at ?? new Date().toISOString(),
metadata: raw.metadata ?? raw.payload ?? raw.details,
}
}
98 changes: 98 additions & 0 deletions lib/api/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
WalletVerification,
WebhookEventLog,
WebhookEventUnsubscribe,
ContributionEvent,
} from './types'
import { ApiError } from './errors'
import {
Expand Down Expand Up @@ -255,6 +256,65 @@ for (let i = 0; i < 50000; i++) {
}
}

const DEFAULT_CONTRIBUTION_EVENTS: Record<string, ContributionEvent[]> = {
'0xabc': [
{
id: 'evt_01',
address: '0xabc',
type: 'badge_earned',
title: 'Earned "Beta Tester" Badge',
description: 'Awarded for active participation in the early preview release.',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(),
metadata: { badge: 'Beta Tester', category: 'community' },
},
{
id: 'evt_02',
address: '0xabc',
type: 'resource_access',
title: 'Accessed Alpha Docs',
description: 'Unlocked restricted community developer resources.',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5).toISOString(),
metadata: { resourceId: 'alpha', tier: 'standard' },
},
{
id: 'evt_03',
address: '0xabc',
type: 'membership_update',
title: 'Upgraded Tier to Pro',
description: 'Subscription upgraded from Standard to Pro tier.',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14).toISOString(),
metadata: { previousTier: 'standard', newTier: 'pro' },
},
{
id: 'evt_04',
address: '0xabc',
type: 'role_change',
title: 'Role Assigned: Member',
description: 'Granted initial Member role in GuildPass Demo Community.',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(),
metadata: { role: 'member', assignedBy: 'system' },
},
{
id: 'evt_05',
address: '0xabc',
type: 'attendance',
title: 'Attended Community Townhall Q3',
description: 'Verified attendance at the quarterly ecosystem governance call.',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 45).toISOString(),
metadata: { eventName: 'Q3 Townhall', verifiedVia: 'POAP' },
},
{
id: 'evt_06',
address: '0xabc',
type: 'contribution_score',
title: 'Contribution Score Milestone (+50 pts)',
description: 'Reached 500 total community contribution points.',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 60).toISOString(),
metadata: { points: 50, scoreTotal: 500 },
},
],
}

let community: Community = { ...DEFAULT_COMMUNITY }
let resources: Resource[] = [...DEFAULT_RESOURCES]
let policies: AccessPolicy[] = [...DEFAULT_POLICIES]
Expand Down Expand Up @@ -627,6 +687,44 @@ export class MockAccessApi implements AccessApi {
return data?.profile ?? null
}

async getContributionHistory(address: string, _signal?: AbortSignal): Promise<ContributionEvent[]> {
await initPromise
if (!address || address.toLowerCase().includes('empty')) {
return []
}
const normalizedAddr = address.toLowerCase()
const data = ensureAddress(address)
if (!data) return []

const events = DEFAULT_CONTRIBUTION_EVENTS[normalizedAddr] || DEFAULT_CONTRIBUTION_EVENTS['0xabc']?.map(evt => ({
...evt,
address,
})) || [
{
id: `evt_init_${address.slice(0, 6)}`,
address,
type: 'membership_update',
title: `Joined Community`,
description: `Active ${data.membership.tier} membership.`,
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(),
metadata: { tier: data.membership.tier },
},
{
id: `evt_role_${address.slice(0, 6)}`,
address,
type: 'role_change',
title: `Role Assigned: ${data.roles.join(', ') || 'member'}`,
description: 'Initial role configuration.',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(),
metadata: { roles: data.roles },
},
]

return [...events].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
)
}

async listMembers(params?: { cursor?: string; limit?: number; filter?: string }, _signal?: AbortSignal): Promise<MemberRow[] | PaginatedMembers> {
await initPromise
let list = Object.values(memberStore).map((m) => ({
Expand Down
Loading