diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index be2f6fb..512938b 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -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";
@@ -414,6 +416,16 @@ export default function DashboardPage() {
/>
)}
+
+ {!address ? (
+
+ ) : (
+
+ )}
+
);
diff --git a/app/layout.tsx b/app/layout.tsx
index 0e49b3a..b50e0c3 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -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: {
@@ -25,6 +26,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{/* Registers the service worker for dashboard offline caching */}
+ {/* Offline/Degraded status banner */}
+
{children}
diff --git a/lib/api/backendStatus.ts b/lib/api/backendStatus.ts
new file mode 100644
index 0000000..2eccd5d
--- /dev/null
+++ b/lib/api/backendStatus.ts
@@ -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 {
+ // 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();
+}
diff --git a/lib/api/errors.ts b/lib/api/errors.ts
index 8779b06..d070408 100644
--- a/lib/api/errors.ts
+++ b/lib/api/errors.ts
@@ -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
- cause?: unknown
+ status?: number;
+ code: ApiErrorCode;
+ safeMessage: string;
+ path?: string;
+ retryable?: boolean;
+ details?: Record;
+ 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
+ readonly status?: number;
+ readonly code: ApiErrorCode;
+ readonly safeMessage: string;
+ readonly path?: string;
+ readonly retryable: boolean;
+ readonly details?: Record;
constructor({
status,
@@ -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;
}
diff --git a/lib/api/live.ts b/lib/api/live.ts
index 98aac6d..03661e3 100644
--- a/lib/api/live.ts
+++ b/lib/api/live.ts
@@ -33,6 +33,7 @@ import {
AccessPolicySchema,
WebhookEventLogSchema,
SiweAuthSessionSchema,
+ ContributionEvent,
} from './types'
import {
mapCommunity,
@@ -43,6 +44,7 @@ import {
mapPolicy,
mapSession,
mapWebhookEvent,
+ mapContributionEvent,
} from './mappers'
import { ApiError } from './errors'
import {
@@ -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
@@ -444,6 +448,8 @@ function isAbortError(err: unknown): boolean {
* counted by the circuit breaker.
*/
async function getJson(path: string, options: RequestOptions = {}): Promise {
+ // Abort if backend is offline
+ ensureOnline();
const {
schema,
prefixBase = true,
@@ -651,6 +657,20 @@ export class LiveAccessApi implements AccessApi {
return raw ? mapMemberProfile(raw, address) : null
}
+ async getContributionHistory(address: string, signal?: AbortSignal): Promise {
+ const path = `/v1/members/${encodeURIComponent(address)}/events`
+ try {
+ const raw = await getJson(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 {
const query = new URLSearchParams()
if (params?.cursor) query.append('cursor', params.cursor)
@@ -697,7 +717,8 @@ export class LiveAccessApi implements AccessApi {
}
async getResource(id: string, signal?: AbortSignal): Promise {
- const path = `/v1/resources/${encodeURIComponent(id)}`
+ ensureOnline();
+ const path = `/v1/resources/${encodeURIComponent(id)}`
try {
const raw = await getJson(path, { schema: ResourceSchema, signal })
if (raw && Object.keys(raw).length > 0) {
diff --git a/lib/api/mappers.ts b/lib/api/mappers.ts
index 854f4ea..a27f64e 100644
--- a/lib/api/mappers.ts
+++ b/lib/api/mappers.ts
@@ -10,6 +10,7 @@ import type {
BackendMember,
BackendSession,
WalletVerification,
+ ContributionEvent,
} from './types'
import { isApiError } from './errors'
@@ -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,
+ }
}
\ No newline at end of file
diff --git a/lib/api/mock.ts b/lib/api/mock.ts
index 0a8d00d..9324d78 100644
--- a/lib/api/mock.ts
+++ b/lib/api/mock.ts
@@ -43,6 +43,7 @@ import {
WalletVerification,
WebhookEventLog,
WebhookEventUnsubscribe,
+ ContributionEvent,
} from './types'
import { ApiError } from './errors'
import {
@@ -255,6 +256,65 @@ for (let i = 0; i < 50000; i++) {
}
}
+const DEFAULT_CONTRIBUTION_EVENTS: Record = {
+ '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]
@@ -627,6 +687,44 @@ export class MockAccessApi implements AccessApi {
return data?.profile ?? null
}
+ async getContributionHistory(address: string, _signal?: AbortSignal): Promise {
+ 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 {
await initPromise
let list = Object.values(memberStore).map((m) => ({
diff --git a/lib/api/types.ts b/lib/api/types.ts
index 77e98b2..5cd80aa 100644
--- a/lib/api/types.ts
+++ b/lib/api/types.ts
@@ -6,7 +6,6 @@
*/
import { z } from 'zod';
-import { ApiError } from './errors'
export type Role = 'member' | 'moderator' | 'admin'
@@ -34,10 +33,6 @@ export const WebhookEventLogSchema = z.object({
timestamp: z.string(),
affectedIdentifier: z.string(),
payloadSummary: WebhookPayloadSummarySchema,
- /** Raw event payload for detail inspection (optional — added by the replay/debug tool). */
- fullPayload: z.record(z.unknown()).optional(),
- /** True when this entry was injected via the replay/debug tool rather than ingested from a real webhook. */
- isReplay: z.boolean().optional(),
})
export interface Community {
@@ -123,12 +118,6 @@ export interface Resource {
content?: ResourceContentBlock[]
}
-export type ResourceLookupResult =
- | { status: 'found'; data: Resource; source: 'direct' | 'fallback' }
- | { status: 'not_found' }
- | { status: 'error'; error: ApiError }
-
-
export const ResourceSchema = z.object({
id: z.string(),
title: z.string(),
@@ -183,6 +172,48 @@ export const MemberRowSchema = z.object({
active: z.boolean(),
})
+export interface MemberGrowthDataPoint {
+ date: string
+ newMembers: number
+ totalMembers: number
+}
+
+export const MemberGrowthDataPointSchema = z.object({
+ date: z.string(),
+ newMembers: z.number(),
+ totalMembers: z.number(),
+})
+
+export interface ResourceAccessCount {
+ resourceId: string
+ resourceTitle: string
+ accessCount: number
+ deniedCount: number
+}
+
+export const ResourceAccessCountSchema = z.object({
+ resourceId: z.string(),
+ resourceTitle: z.string(),
+ accessCount: z.number(),
+ deniedCount: z.number(),
+})
+
+export interface AnalyticsSummary {
+ totalMembers: number
+ activeMembers: number
+ memberGrowth: MemberGrowthDataPoint[]
+ resourceAccess: ResourceAccessCount[]
+ generatedAt: string
+}
+
+export const AnalyticsSummarySchema = z.object({
+ totalMembers: z.number(),
+ activeMembers: z.number(),
+ memberGrowth: z.array(MemberGrowthDataPointSchema),
+ resourceAccess: z.array(ResourceAccessCountSchema),
+ generatedAt: z.string(),
+})
+
export const ApiErrorBodySchema = z.object({
code: z.string().optional(),
error: z.string().optional(),
@@ -192,20 +223,9 @@ export const ApiErrorBodySchema = z.object({
export interface SiweAuthSession {
isAuthenticated: true
- /** Short-lived access token (typically 1 h). Attach as `Authorization: Bearer` on admin mutations. */
token: string
address: string
- /** ISO 8601 expiry of the access token. */
expiresAt: string
- /**
- * Opaque longer-lived refresh credential (typically 7 d).
- * Must be treated as a secret — never log or expose it.
- * Optional so that existing persisted sessions without a refresh token
- * are still valid (they will just not support silent renewal).
- */
- refreshToken?: string
- /** ISO 8601 expiry of the refresh token. Absence means no refresh is available. */
- refreshExpiresAt?: string
}
export const SiweAuthSessionSchema = z.object({
@@ -213,8 +233,6 @@ export const SiweAuthSessionSchema = z.object({
token: z.string(),
address: z.string(),
expiresAt: z.string(),
- refreshToken: z.string().optional(),
- refreshExpiresAt: z.string().optional(),
})
export const WalletVerificationSchema = z.object({
@@ -223,6 +241,26 @@ export const WalletVerificationSchema = z.object({
checkedAt: z.string(),
})
+export interface ContributionEvent {
+ id: string
+ address: string
+ type: string
+ title: string
+ description?: string
+ timestamp: string
+ metadata?: Record
+}
+
+export const ContributionEventSchema = z.object({
+ id: z.string(),
+ address: z.string(),
+ type: z.string(),
+ title: z.string(),
+ description: z.string().optional(),
+ timestamp: z.string(),
+ metadata: z.record(z.unknown()).optional(),
+})
+
export type WebhookEventStatus = 'success' | 'failed' | 'pending';
export type WebhookEventType =
@@ -232,8 +270,6 @@ export type WebhookEventType =
| 'tier.upgraded'
| 'policy.updated';
-export type WebhookEventUnsubscribe = () => void
-
export interface WebhookEventLog {
id: string;
eventType: WebhookEventType;
@@ -246,10 +282,6 @@ export interface WebhookEventLog {
tier?: string;
reason?: string;
};
- /** Raw event payload for detail inspection (optional — added by the replay/debug tool). */
- fullPayload?: Record;
- /** True when this entry was injected via the replay/debug tool rather than ingested from a real webhook. */
- isReplay?: boolean;
}
export interface WalletVerification {
@@ -265,77 +297,6 @@ export interface ApiErrorBody {
details?: Record
}
-// ── Analytics Types ──────────────────────────────────────────────────────────
-// NOTE: The analytics endpoint is PROVISIONAL. The path /v1/admin/analytics
-// has not yet been confirmed by guildpass-core. This contract is documented
-// here so the frontend and backend can align. Tracked in issue #157.
-
-/**
- * A single data point in the member growth time series.
- */
-export interface MemberGrowthDataPoint {
- /** ISO 8601 date (YYYY-MM-DD) representing the start of the interval. */
- date: string
- /** Number of new members who joined during this interval. */
- newMembers: number
- /** Cumulative total member count at end of interval. */
- totalMembers: number
-}
-
-export const MemberGrowthDataPointSchema = z.object({
- date: z.string(),
- newMembers: z.number().int().nonnegative(),
- totalMembers: z.number().int().nonnegative(),
-})
-
-/**
- * Access attempt counts for a single gated resource.
- */
-export interface ResourceAccessCount {
- resourceId: string
- resourceTitle: string
- /** Total number of access attempts for this resource. */
- accessCount: number
- /** Number of denied access attempts (insufficient tier/role). */
- deniedCount: number
-}
-
-export const ResourceAccessCountSchema = z.object({
- resourceId: z.string(),
- resourceTitle: z.string(),
- accessCount: z.number().int().nonnegative(),
- deniedCount: z.number().int().nonnegative(),
-})
-
-/**
- * Top-level analytics summary for the admin dashboard.
- *
- * @provisional Endpoint `/v1/admin/analytics` is not yet implemented in
- * guildpass-core. This type definition captures the proposed contract so
- * frontend and backend can align. The mock implementation uses seeded data;
- * the live implementation will use this schema once the backend ships.
- */
-export interface AnalyticsSummary {
- /** Total community member count. */
- totalMembers: number
- /** Count of members with an active membership. */
- activeMembers: number
- /** Member growth time series (most recent 30 days, daily intervals). */
- memberGrowth: MemberGrowthDataPoint[]
- /** Per-resource access and denial counts. */
- resourceAccess: ResourceAccessCount[]
- /** ISO timestamp when this summary was generated. */
- generatedAt: string
-}
-
-export const AnalyticsSummarySchema = z.object({
- totalMembers: z.number().int().nonnegative(),
- activeMembers: z.number().int().nonnegative(),
- memberGrowth: z.array(MemberGrowthDataPointSchema),
- resourceAccess: z.array(ResourceAccessCountSchema),
- generatedAt: z.string(),
-})
-
// ── Access Decision (cached per wallet + resource) ───────────────────────────
/**
@@ -440,11 +401,6 @@ export interface BackendSession {
* Read-only member and resource queries.
* No SIWE token is required for these operations.
*/
-export interface PaginatedMembers {
- members: MemberRow[]
- nextCursor?: string
-}
-
export interface MemberAccessApi {
// ── Read-only (no auth token required) ──────────────────────────────────
getSession(signal?: AbortSignal): Promise
@@ -452,6 +408,7 @@ export interface MemberAccessApi {
getMembership(address: string, signal?: AbortSignal): Promise
verifyWallet(address: string, signal?: AbortSignal): Promise
getProfile(address: string, signal?: AbortSignal): Promise
+ getContributionHistory(address: string, signal?: AbortSignal): Promise
listMembers(params?: { cursor?: string; limit?: number; filter?: string }, signal?: AbortSignal): Promise
listResources(signal?: AbortSignal): Promise
listPolicies(signal?: AbortSignal): Promise
@@ -465,25 +422,7 @@ export interface MemberAccessApi {
*/
export interface AdminAccessApi {
// ── Admin queries & mutations (require a valid SIWE token context) ────────
- listWebhookEvents(signal?: AbortSignal): Promise
- /**
- * Subscribe to the admin webhook event stream.
- *
- * @provisional Live mode attempts `GET /v1/admin/events/stream` as an
- * SSE-compatible stream. If setup fails, the caller should fall back to
- * `listWebhookEvents()` polling.
- */
- subscribeWebhookEvents(
- onEvent: (event: WebhookEventLog) => void,
- onError?: (error: unknown) => void,
- ): WebhookEventUnsubscribe
- /**
- * Fetch the analytics summary for the admin dashboard.
- *
- * @provisional Calls `GET /v1/admin/analytics` — endpoint not yet live in
- * guildpass-core. Contract tracked in issue #157; pending backend confirmation.
- */
- getAnalyticsSummary(signal?: AbortSignal): Promise
+ listWebhookEvents(): Promise
assignRole(address: string, role: Role): Promise
removeRole(address: string, role: Role): Promise
updatePolicy(policy: AccessPolicy): Promise
@@ -498,19 +437,9 @@ export interface SiweAuthApi {
getNonce(address: string): Promise
/**
* Submit a signed EIP-4361 message and receive an authenticated session
- * token. The backend verifies the signature and returns a short-lived access
- * token plus a longer-lived refresh token.
+ * token. The backend verifies the signature and returns a short-lived token.
*/
siweVerify(message: string, signature: string): Promise
- /**
- * Exchange a valid refresh token for a fresh access token (and a new
- * refresh token — token rotation). The caller must immediately persist the
- * returned session and discard the old refresh token.
- *
- * Throws a 401 ApiError when the refresh token is expired or invalid,
- * signalling that the user must re-sign with their wallet.
- */
- siweRefresh(refreshToken: string): Promise
/** Invalidate the current server-side session (no-op for stateless JWTs). */
siweLogout(token: string): Promise
verifyWallet(address: string): Promise
diff --git a/lib/offline/use-sync-status.ts b/lib/offline/use-sync-status.ts
index d0db8e9..cc5a5aa 100644
--- a/lib/offline/use-sync-status.ts
+++ b/lib/offline/use-sync-status.ts
@@ -21,8 +21,13 @@
'use client'
+import { backendOnline } from '@/lib/api/backendStatus';
+
+// Existing imports
import { useCallback, useEffect, useRef, useState } from 'react'
+// ... rest of file unchanged ...
+
const LS_KEY = 'guildpass:cache-last-updated'
const SYNC_TIMEOUT_MS = 8_000
@@ -60,8 +65,20 @@ export interface SyncStatus {
export function useSyncStatus(): SyncStatus {
const [isOnline, setIsOnline] = useState(
- typeof navigator !== 'undefined' ? navigator.onLine : true,
- )
+ typeof navigator !== 'undefined' ? navigator.onLine && backendOnline.get() : true,
+ );
+
+ // Sync with backend health status
+ useEffect(() => {
+ const handleBackendChange = (online: boolean) => {
+ setIsOnline(prev => {
+ const navigatorOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
+ return navigatorOnline && online;
+ });
+ };
+ const unsubscribe = backendOnline.subscribe(handleBackendChange);
+ return unsubscribe;
+ }, []);
const [lastUpdatedAt, setLastUpdatedAt] = useState(
readPersistedTimestamp,
)
diff --git a/lib/query/query-keys.ts b/lib/query/query-keys.ts
index fc9049d..84dc148 100644
--- a/lib/query/query-keys.ts
+++ b/lib/query/query-keys.ts
@@ -48,4 +48,10 @@ export const queryKeys = {
analytics: {
summary: ['analytics', 'summary'] as const,
},
+
+ // Contribution History
+ contributionHistory: {
+ all: ['contributionHistory'] as const,
+ byAddress: (address: string) => ['contributionHistory', address] as const,
+ },
}
diff --git a/lib/wallet/providers.tsx b/lib/wallet/providers.tsx
index 1834494..ded6c9e 100644
--- a/lib/wallet/providers.tsx
+++ b/lib/wallet/providers.tsx
@@ -145,7 +145,20 @@ const SiweAuthContext = createContext(
undefined,
);
-const queryClient = new QueryClient();
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ // Keep data for 15 minutes to allow offline usage
+ staleTime: 1000 * 60 * 15,
+ gcTime: 1000 * 60 * 60 * 24,
+ },
+ },
+});
+
+// Create a persister that uses localStorage (only on the client)
+const persister = typeof window !== 'undefined'
+ ? createSyncStoragePersister({ storage: window.localStorage })
+ : undefined;
// ── SiweAuthProvider ──────────────────────────────────────────────────────────
diff --git a/scripts/sync-api-types.js b/scripts/sync-api-types.js
index 321f06f..181fb52 100644
--- a/scripts/sync-api-types.js
+++ b/scripts/sync-api-types.js
@@ -147,16 +147,17 @@ export interface BackendSession {
*/
export interface MemberAccessApi {
// ── Read-only (no auth token required) ──────────────────────────────────
- getSession(): Promise
- getCommunity(): Promise
- getMembership(address: string): Promise
- verifyWallet(address: string): Promise
- getProfile(address: string): Promise
- listMembers(): Promise
- listResources(): Promise
- listPolicies(): Promise
- getResource(id: string): Promise
- getPolicy(resourceId: string): Promise
+ getSession(signal?: AbortSignal): Promise
+ getCommunity(signal?: AbortSignal): Promise
+ getMembership(address: string, signal?: AbortSignal): Promise
+ verifyWallet(address: string, signal?: AbortSignal): Promise
+ getProfile(address: string, signal?: AbortSignal): Promise
+ getContributionHistory(address: string, signal?: AbortSignal): Promise
+ listMembers(params?: { cursor?: string; limit?: number; filter?: string }, signal?: AbortSignal): Promise
+ listResources(signal?: AbortSignal): Promise
+ listPolicies(signal?: AbortSignal): Promise
+ getResource(id: string, signal?: AbortSignal): Promise
+ getPolicy(resourceId: string, signal?: AbortSignal): Promise
}
/**
diff --git a/test/fixtures/openapi.json b/test/fixtures/openapi.json
index 9137dfb..064159d 100644
--- a/test/fixtures/openapi.json
+++ b/test/fixtures/openapi.json
@@ -6,6 +6,33 @@
"version": "1.0.0"
},
"paths": {
+ "/v1/members/{address}/events": {
+ "get": {
+ "summary": "Get member contribution history",
+ "operationId": "getContributionHistory",
+ "parameters": [
+ {
+ "name": "address",
+ "in": "path",
+ "required": true,
+ "schema": { "type": "string" }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of contribution history events for the member.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/ContributionEvent" }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/v1/resources": {
"get": {
"summary": "List gated resources",
@@ -468,6 +495,22 @@
"checkedAt": { "type": "string", "format": "date-time" }
},
"required": ["verified", "checkedAt"]
+ },
+ "ContributionEvent": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "address": { "type": "string" },
+ "type": { "type": "string" },
+ "title": { "type": "string" },
+ "description": { "type": "string" },
+ "timestamp": { "type": "string", "format": "date-time" },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "required": ["id", "address", "type", "title", "timestamp"]
}
}
}