diff --git a/examples/html/src/shims/reactNativeSvg.tsx b/examples/html/src/shims/reactNativeSvg.tsx
new file mode 100644
index 00000000..496d1e48
--- /dev/null
+++ b/examples/html/src/shims/reactNativeSvg.tsx
@@ -0,0 +1,49 @@
+import React from 'react'
+
+type SvgElementProps = React.SVGProps & {
+ accessibilityRole?: string
+}
+
+type SvgGroupProps = React.SVGProps & {
+ rotation?: string | number
+ origin?: string
+}
+
+type SvgCircleProps = React.SVGProps & {
+ onPress?: () => void
+}
+
+/** Web implementation for the react-native-svg primitives used by GoodWidget dependencies. */
+function Svg({ accessibilityRole: _accessibilityRole, ...props }: SvgElementProps) {
+ return
+}
+
+/** Mirrors react-native-svg's G transform props with standard SVG attributes. */
+export function G({ rotation, origin, transform, ...props }: SvgGroupProps) {
+ const rotationTransform = rotation ? `rotate(${rotation} ${origin ?? ''})`.trim() : undefined
+ const combinedTransform = [transform, rotationTransform].filter(Boolean).join(' ')
+
+ return
+}
+
+/** Maps react-native-svg onPress to the browser SVG onClick event. */
+export function Circle({ onPress, ...props }: SvgCircleProps) {
+ return
+}
+
+/** Static SVG primitives used by @tamagui/lucide-icons need no prop translation. */
+function createPassthroughSvgPrimitive(tag: string) {
+ return function SvgPrimitive(props: React.SVGProps) {
+ return React.createElement(tag, props)
+ }
+}
+
+export const Path = createPassthroughSvgPrimitive('path')
+export const Line = createPassthroughSvgPrimitive('line')
+export const Rect = createPassthroughSvgPrimitive('rect')
+export const Polygon = createPassthroughSvgPrimitive('polygon')
+export const Polyline = createPassthroughSvgPrimitive('polyline')
+export const Ellipse = createPassthroughSvgPrimitive('ellipse')
+
+export { Svg }
+export default Svg
diff --git a/examples/html/vite.config.ts b/examples/html/vite.config.ts
index add9d78b..0913adb5 100644
--- a/examples/html/vite.config.ts
+++ b/examples/html/vite.config.ts
@@ -1,4 +1,7 @@
import { defineConfig } from 'vite'
+import { fileURLToPath } from 'node:url'
+
+const reactNativeSvgShim = fileURLToPath(new URL('./src/shims/reactNativeSvg.tsx', import.meta.url))
export default defineConfig({
define: {
@@ -8,6 +11,8 @@ export default defineConfig({
resolve: {
alias: {
'react-native': 'react-native-web',
+ // react-native-svg's Fabric native modules are not available in react-native-web.
+ 'react-native-svg': reactNativeSvgShim,
},
},
build: {
diff --git a/examples/react-web/src/shims/reactNativeSvg.tsx b/examples/react-web/src/shims/reactNativeSvg.tsx
new file mode 100644
index 00000000..496d1e48
--- /dev/null
+++ b/examples/react-web/src/shims/reactNativeSvg.tsx
@@ -0,0 +1,49 @@
+import React from 'react'
+
+type SvgElementProps = React.SVGProps & {
+ accessibilityRole?: string
+}
+
+type SvgGroupProps = React.SVGProps & {
+ rotation?: string | number
+ origin?: string
+}
+
+type SvgCircleProps = React.SVGProps & {
+ onPress?: () => void
+}
+
+/** Web implementation for the react-native-svg primitives used by GoodWidget dependencies. */
+function Svg({ accessibilityRole: _accessibilityRole, ...props }: SvgElementProps) {
+ return
+}
+
+/** Mirrors react-native-svg's G transform props with standard SVG attributes. */
+export function G({ rotation, origin, transform, ...props }: SvgGroupProps) {
+ const rotationTransform = rotation ? `rotate(${rotation} ${origin ?? ''})`.trim() : undefined
+ const combinedTransform = [transform, rotationTransform].filter(Boolean).join(' ')
+
+ return
+}
+
+/** Maps react-native-svg onPress to the browser SVG onClick event. */
+export function Circle({ onPress, ...props }: SvgCircleProps) {
+ return
+}
+
+/** Static SVG primitives used by @tamagui/lucide-icons need no prop translation. */
+function createPassthroughSvgPrimitive(tag: string) {
+ return function SvgPrimitive(props: React.SVGProps) {
+ return React.createElement(tag, props)
+ }
+}
+
+export const Path = createPassthroughSvgPrimitive('path')
+export const Line = createPassthroughSvgPrimitive('line')
+export const Rect = createPassthroughSvgPrimitive('rect')
+export const Polygon = createPassthroughSvgPrimitive('polygon')
+export const Polyline = createPassthroughSvgPrimitive('polyline')
+export const Ellipse = createPassthroughSvgPrimitive('ellipse')
+
+export { Svg }
+export default Svg
diff --git a/examples/react-web/vite.config.ts b/examples/react-web/vite.config.ts
index 4237508c..1fefcc07 100644
--- a/examples/react-web/vite.config.ts
+++ b/examples/react-web/vite.config.ts
@@ -1,5 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+import { fileURLToPath } from 'node:url'
+
+const reactNativeSvgShim = fileURLToPath(new URL('./src/shims/reactNativeSvg.tsx', import.meta.url))
export default defineConfig({
plugins: [react()],
@@ -11,6 +14,8 @@ export default defineConfig({
resolve: {
alias: {
'react-native': 'react-native-web',
+ // react-native-svg's Fabric native modules are not available in react-native-web.
+ 'react-native-svg': reactNativeSvgShim,
},
},
optimizeDeps: {
diff --git a/examples/storybook/package.json b/examples/storybook/package.json
index 8188cc93..909bf9b9 100644
--- a/examples/storybook/package.json
+++ b/examples/storybook/package.json
@@ -18,6 +18,7 @@
"@goodwidget/streaming-widget": "workspace:*",
"@goodwidget/staking-migration-widget": "workspace:*",
"@goodwidget/ai-credits-widget": "workspace:*",
+ "@goodwidget/superfluid-campaign-widget": "workspace:*",
"@goodwidget/embed": "workspace:*",
"react": "^18.3.0",
"react-dom": "^18.3.0",
diff --git a/examples/storybook/src/shims/reactNativeSvg.tsx b/examples/storybook/src/shims/reactNativeSvg.tsx
index 09c0d379..558dc925 100644
--- a/examples/storybook/src/shims/reactNativeSvg.tsx
+++ b/examples/storybook/src/shims/reactNativeSvg.tsx
@@ -13,8 +13,8 @@ type SvgCircleProps = React.SVGProps & {
onPress?: () => void
}
-/** Storybook web shim for the react-native-svg primitives used by the donut chart. */
-export default function Svg({ accessibilityRole: _accessibilityRole, ...props }: SvgElementProps) {
+/** Storybook web shim for the react-native-svg primitives used by the donut chart and @tamagui/lucide-icons. */
+function Svg({ accessibilityRole: _accessibilityRole, ...props }: SvgElementProps) {
return
}
@@ -30,3 +30,25 @@ export function G({ rotation, origin, transform, ...props }: SvgGroupProps) {
export function Circle({ onPress, ...props }: SvgCircleProps) {
return
}
+
+/**
+ * @tamagui/lucide-icons' generated icon components import Path/Line/Rect/Polygon/
+ * Polyline/Ellipse by name from react-native-svg purely to render static shapes —
+ * unlike Circle/G above there are no react-native-specific props to translate, so
+ * one passthrough factory covers all of them instead of five near-identical wrappers.
+ */
+function createPassthroughSvgPrimitive(tag: string) {
+ return function SvgPrimitive(props: React.SVGProps) {
+ return React.createElement(tag, props)
+ }
+}
+
+export const Path = createPassthroughSvgPrimitive('path')
+export const Line = createPassthroughSvgPrimitive('line')
+export const Rect = createPassthroughSvgPrimitive('rect')
+export const Polygon = createPassthroughSvgPrimitive('polygon')
+export const Polyline = createPassthroughSvgPrimitive('polyline')
+export const Ellipse = createPassthroughSvgPrimitive('ellipse')
+
+export { Svg }
+export default Svg
diff --git a/examples/storybook/src/stories/helpers/superfluidCampaignWidgetStories.tsx b/examples/storybook/src/stories/helpers/superfluidCampaignWidgetStories.tsx
new file mode 100644
index 00000000..7b43d0f7
--- /dev/null
+++ b/examples/storybook/src/stories/helpers/superfluidCampaignWidgetStories.tsx
@@ -0,0 +1,353 @@
+import React from 'react'
+import {
+ SuperfluidCampaignWidget,
+ type AirdropStatusAdapter,
+ type CampaignLeaderboardAdapter,
+ type ProgramSupTotalsAdapter,
+ type SuperfluidCampaignWidgetProps,
+ type SuperfluidCampaignView,
+} from '@goodwidget/superfluid-campaign-widget'
+import { MiniAppShell, YStack, type GoodWidgetThemeOverrides } from '@goodwidget/ui'
+import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193'
+import { getInjectedEip1193Provider, isInjectedProviderUsable } from '../../fixtures/injectedEip1193'
+
+/**
+ * Every real airdrop-status response sampled against the live endpoint so far
+ * came back "not whitelisted" (see useAirdropStatus.ts) — that shape is the
+ * only one used as a QA/Playwright default. The loading/error/eligible
+ * variants below are illustrative fixtures for exercising those UI states,
+ * not observed live responses.
+ */
+const AIRDROP_STATUS_FIXTURES = {
+ loading: (): ReturnType => ({ status: null, isLoading: true, error: null }),
+ requestFailed: (): ReturnType => ({
+ status: null,
+ isLoading: false,
+ error: 'Airdrop status request failed (500)',
+ }),
+ notWhitelisted: (): ReturnType => ({
+ status: { error: 'not whitelisted', walletData: { claims: '0', invites: '1000' } },
+ isLoading: false,
+ error: null,
+ }),
+ eligible: (): ReturnType => ({
+ status: { walletData: { claims: '3', invites: '1000' } },
+ isLoading: false,
+ error: null,
+ }),
+} as const
+
+function fixedAirdropStatusAdapter(scenario: keyof typeof AIRDROP_STATUS_FIXTURES): AirdropStatusAdapter {
+ return () => AIRDROP_STATUS_FIXTURES[scenario]()
+}
+
+/**
+ * Fixed campaign-leaderboard pages keyed by campaignId, shaped exactly like the
+ * live Superfluid Points API (cms.superfluid.pro/points) responses confirmed in
+ * change-request-3 — one entry per #127 reward pool (606 = GoodDollar actions,
+ * 614 = Ecosystem actions) so tab-switching shows distinct data.
+ */
+const LEADERBOARD_DATA_FIXTURES: Record['data']> = {
+ 606: {
+ summary: {
+ campaignId: 606,
+ name: 'GoodDollar Actions',
+ slug: 'good-dollar-actions',
+ totalPoints: 128450,
+ memberCount: 624,
+ totalEvents: 3891,
+ lastEventAt: '2026-07-29T18:42:00.000Z',
+ createdAt: '2026-01-05T00:00:00.000Z',
+ },
+ accounts: [
+ {
+ account: '0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b',
+ totalPoints: 4820,
+ eventCount: 96,
+ lastEventAt: '2026-07-29T12:00:00.000Z',
+ completedActivities: ['claim-ubi', 'invite-users', 'flow-state-vote'],
+ },
+ {
+ account: '0x2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c',
+ totalPoints: 4390,
+ eventCount: 88,
+ lastEventAt: '2026-07-29T11:00:00.000Z',
+ completedActivities: ['claim-ubi', 'invite-users'],
+ },
+ {
+ account: '0x3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
+ totalPoints: 3910,
+ eventCount: 79,
+ lastEventAt: '2026-07-29T10:00:00.000Z',
+ completedActivities: ['claim-ubi'],
+ },
+ ],
+ pagination: { page: 1, limit: 10, totalDocs: 624, totalPages: 63, hasNextPage: true, hasPrevPage: false },
+ },
+ 614: {
+ summary: {
+ campaignId: 614,
+ name: 'Ecosystem Contributions',
+ slug: 'ecosystem-funding-actions',
+ totalPoints: 84200,
+ memberCount: 318,
+ totalEvents: 1745,
+ lastEventAt: '2026-07-29T17:10:00.000Z',
+ createdAt: '2026-01-05T00:00:00.000Z',
+ },
+ accounts: [
+ {
+ account: '0x4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e',
+ totalPoints: 3420,
+ eventCount: 55,
+ lastEventAt: '2026-07-29T09:00:00.000Z',
+ completedActivities: ['flow-state-funding', 'gardens-donation', 'gardens-funding'],
+ },
+ {
+ account: '0x5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f',
+ totalPoints: 2985,
+ eventCount: 47,
+ lastEventAt: '2026-07-29T08:00:00.000Z',
+ completedActivities: ['gardens-donation'],
+ },
+ ],
+ pagination: { page: 1, limit: 10, totalDocs: 318, totalPages: 32, hasNextPage: true, hasPrevPage: false },
+ },
+}
+
+/** Named leaderboard scenarios exercised by the QA stories/Playwright spec below. */
+function fixedCampaignLeaderboardAdapter(
+ scenario: 'populated' | 'loading' | 'requestFailed',
+): CampaignLeaderboardAdapter {
+ return (campaignId) => {
+ if (scenario === 'loading') return { data: null, isLoading: true, error: null }
+ if (scenario === 'requestFailed') {
+ return { data: null, isLoading: false, error: 'Campaign leaderboard request failed (500)' }
+ }
+ return { data: LEADERBOARD_DATA_FIXTURES[campaignId] ?? null, isLoading: false, error: null }
+ }
+}
+
+/**
+ * Fixed SUP program totals keyed by campaignId. 606 (GoodDollar actions) is
+ * illustrative "healthy progress" data, not the live snapshot — the real
+ * program exists but funding hasn't started yet as of Season 6 launch, so
+ * its live totalClaimed is currently 0. totalAllocated is 217,700 SUP per the
+ * campaign spec. 614 (Ecosystem funding actions) has no matching entry on
+ * purpose, so RewardPoolSection falls back to its own placeholder—the same
+ * result produced when no pool address is passed to the live widget.
+ */
+const SUP_TOTALS_FIXTURES: Record['data']> = {
+ 606: { totalAllocated: 217700, totalClaimed: 128940, totalMembers: 712 },
+}
+
+/** Named SUP-totals scenarios exercised by the QA stories/Playwright spec below. */
+function fixedProgramSupTotalsAdapter(scenario: 'populated' | 'loading' | 'requestFailed'): ProgramSupTotalsAdapter {
+ return (campaignId) => {
+ if (scenario === 'loading') return { data: null, isLoading: true, error: null }
+ if (scenario === 'requestFailed') {
+ return { data: null, isLoading: false, error: 'SUP program totals request failed (500)' }
+ }
+ return { data: SUP_TOTALS_FIXTURES[campaignId] ?? null, isLoading: false, error: null }
+ }
+}
+
+function StoryShell({ children, dataTestId }: { children: React.ReactNode; dataTestId: string }) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function SuperfluidCampaignWidgetStoryShell({
+ provider,
+ dataTestId,
+ initialView = 'content',
+ airdropStatusAdapter,
+ leaderboardAdapter,
+ supTotalsAdapter = fixedProgramSupTotalsAdapter('populated'),
+}: {
+ provider: unknown
+ dataTestId: string
+ initialView?: SuperfluidCampaignView
+ airdropStatusAdapter?: AirdropStatusAdapter
+ leaderboardAdapter?: CampaignLeaderboardAdapter
+ supTotalsAdapter?: ProgramSupTotalsAdapter
+}) {
+ return (
+
+
+
+ )
+}
+
+/**
+ * Showcase story — real injected wallet (MetaMask, Rabby, etc.) with live API data.
+ * No adapter overrides are passed so all three data sources (airdrop status,
+ * leaderboard, SUP totals) hit the live endpoints.
+ */
+export function InjectedWalletStory({
+ defaultTheme,
+ themeOverrides,
+ initialView = 'content',
+}: {
+ defaultTheme?: 'light' | 'dark'
+ themeOverrides?: GoodWidgetThemeOverrides
+ initialView?: SuperfluidCampaignView
+} = {}) {
+ const injectedProvider = getInjectedEip1193Provider()
+ const usableProvider = isInjectedProviderUsable(injectedProvider)
+
+ if (!usableProvider) {
+ return (
+
+ No injected wallet found
+
+ Install/enable MetaMask (or another EIP-1193 wallet) in this browser, then refresh Storybook.
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+
+/**
+ * Showcase story — no wallet connected, live API data.
+ * Shows the public/disconnected view with real leaderboard and SUP-totals data.
+ */
+export function LiveDataNoWalletStory({
+ defaultTheme,
+ themeOverrides,
+ initialView = 'content',
+ poolAddresses,
+}: {
+ defaultTheme?: 'light' | 'dark'
+ themeOverrides?: GoodWidgetThemeOverrides
+ initialView?: SuperfluidCampaignView
+ poolAddresses?: SuperfluidCampaignWidgetProps['poolAddresses']
+} = {}) {
+ return (
+
+
+
+ )
+}
+
+/**
+ * QA fixture — deterministic custodial wallet, reproducible for Playwright
+ * screenshots. Defaults the airdrop-status card to the "not whitelisted"
+ * fixture (the one shape actually observed from the live endpoint) rather
+ * than leaving it to hit the network, which would make the leaderboard
+ * screenshot's airdrop card non-deterministic across CI runs.
+ */
+export function CustodialLocalFixtureStory({
+ initialView,
+ airdropStatusAdapter = fixedAirdropStatusAdapter('notWhitelisted'),
+ leaderboardAdapter = fixedCampaignLeaderboardAdapter('populated'),
+ supTotalsAdapter = fixedProgramSupTotalsAdapter('populated'),
+}: {
+ initialView?: SuperfluidCampaignView
+ airdropStatusAdapter?: AirdropStatusAdapter
+ leaderboardAdapter?: CampaignLeaderboardAdapter
+ supTotalsAdapter?: ProgramSupTotalsAdapter
+}) {
+ try {
+ const provider = createCustodialEip1193Provider()
+ return (
+
+ )
+ } catch (error: unknown) {
+ return (
+
+ Custodial fixture not configured
+ {error instanceof Error ? error.message : 'Set a local private key in custodialEip1193.ts'}
+
+ )
+ }
+}
+
+/** QA fixture — custodial wallet with the airdrop-status card fixed to a single scenario. */
+export function CustodialAirdropStatusStory({
+ scenario,
+}: {
+ scenario: keyof typeof AIRDROP_STATUS_FIXTURES
+}) {
+ return
+}
+
+/** QA fixture — no wallet connected, matches the disconnected (public) mockup. */
+export function NoWalletStory({
+ initialView,
+ leaderboardAdapter = fixedCampaignLeaderboardAdapter('populated'),
+ supTotalsAdapter = fixedProgramSupTotalsAdapter('populated'),
+}: {
+ initialView?: SuperfluidCampaignView
+ leaderboardAdapter?: CampaignLeaderboardAdapter
+ supTotalsAdapter?: ProgramSupTotalsAdapter
+}) {
+ return (
+
+ )
+}
+
+/** QA fixture — no wallet connected, SUP-totals fixed to a single scenario (requestFailed/populated). */
+export function NoWalletSupTotalsStory({
+ scenario,
+}: {
+ scenario: 'populated' | 'loading' | 'requestFailed'
+}) {
+ return
+}
+
+/** QA fixture — no wallet connected, leaderboard fixed to a single scenario (loading/error/populated). */
+export function NoWalletLeaderboardStory({
+ scenario,
+}: {
+ scenario: 'populated' | 'loading' | 'requestFailed'
+}) {
+ return
+}
diff --git a/examples/storybook/src/stories/superfluid-campaign-widget/SuperfluidCampaignWidget.mdx b/examples/storybook/src/stories/superfluid-campaign-widget/SuperfluidCampaignWidget.mdx
new file mode 100644
index 00000000..3115ea54
--- /dev/null
+++ b/examples/storybook/src/stories/superfluid-campaign-widget/SuperfluidCampaignWidget.mdx
@@ -0,0 +1,59 @@
+import { Canvas, Meta } from '@storybook/blocks';
+import * as ShowcaseStories from './SuperfluidCampaignWidget.stories';
+import { DocsCallout, DocsCard, DocsGrid, DocsPage, DocsSection } from '../docs/DocsLayout';
+
+
+
+
+
+
+
+
+
+
+ `NoWalletContent` / `NoWalletLeaderboard` match the disconnected mockups exactly (no
+ currentUserEntry, plain "Connect wallet" CTA). `CustodialLocalFixtureContent` /
+ `CustodialLocalFixtureLeaderboard` use a local custodial wallet to exercise the connected
+ view, including the highlighted current-user leaderboard row.
+
+
+
+
+
+
+ `ACTIVITY_ICON_MAP`'s exact icon names (`calendar`, `person-plus`, `megaphone`, `stream`,
+ `hand-coin`) are not yet in `@goodwidget/ui`'s Icon registry. `ActivityIcons` and
+ `ActionCard` substitute the closest already-registered glyph until the registry is
+ extended.
+
+
+ The leaderboard's search box filters the static fixture locally; pagination controls are
+ a disabled placeholder pending a real leaderboard API.
+
+
+
+
+
+
+ Use the showcase story to validate the product-facing integration flow (including the
+ embedded CitizenClaimWidget). Use the QA fixtures to compare pixel-for-pixel against the
+ approved #127 mockups, in both the disconnected and connected states.
+
+
+
diff --git a/examples/storybook/src/stories/superfluid-campaign-widget/SuperfluidCampaignWidget.stories.tsx b/examples/storybook/src/stories/superfluid-campaign-widget/SuperfluidCampaignWidget.stories.tsx
new file mode 100644
index 00000000..562f8aad
--- /dev/null
+++ b/examples/storybook/src/stories/superfluid-campaign-widget/SuperfluidCampaignWidget.stories.tsx
@@ -0,0 +1,62 @@
+import type { Meta, StoryObj } from '@storybook/react'
+import { SuperfluidCampaignWidget, type SuperfluidCampaignView } from '@goodwidget/superfluid-campaign-widget'
+import { InjectedWalletStory, LiveDataNoWalletStory } from '../helpers/superfluidCampaignWidgetStories'
+import { BRAND_PRESET_OPTIONS, brandPresetOverrides, type BrandPreset } from '../helpers/themeOverridePresets'
+
+interface SuperfluidCampaignWidgetStoryArgs {
+ defaultTheme: 'light' | 'dark'
+ brandPreset: BrandPreset
+ initialView: SuperfluidCampaignView
+}
+
+const meta: Meta = {
+ title: 'Widgets/SuperfluidCampaignWidget/Showcase',
+ component: SuperfluidCampaignWidget,
+ tags: ['integrator', 'manual', 'showcase'],
+ parameters: { layout: 'padded' },
+ argTypes: {
+ defaultTheme: {
+ control: 'radio',
+ options: ['dark', 'light'],
+ description: "Base theme applied via the widget's own defaultTheme prop.",
+ },
+ brandPreset: {
+ control: 'select',
+ options: BRAND_PRESET_OPTIONS,
+ description: 'Sample host-branding themeOverrides preset.',
+ },
+ initialView: {
+ control: 'radio',
+ options: ['content', 'leaderboard'],
+ description: 'View shown on first render.',
+ },
+ },
+ args: {
+ defaultTheme: 'dark',
+ brandPreset: 'None',
+ initialView: 'content',
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const InjectedWallet: Story = {
+ render: ({ defaultTheme, brandPreset, initialView }) => (
+
+ ),
+}
+
+export const NoWallet: Story = {
+ render: ({ defaultTheme, brandPreset, initialView }) => (
+
+ ),
+}
diff --git a/examples/storybook/src/stories/superfluid-campaign-widget/SuperfluidCampaignWidgetQA.stories.tsx b/examples/storybook/src/stories/superfluid-campaign-widget/SuperfluidCampaignWidgetQA.stories.tsx
new file mode 100644
index 00000000..476fb82c
--- /dev/null
+++ b/examples/storybook/src/stories/superfluid-campaign-widget/SuperfluidCampaignWidgetQA.stories.tsx
@@ -0,0 +1,95 @@
+import type { Meta, StoryObj } from '@storybook/react'
+import { SuperfluidCampaignWidget } from '@goodwidget/superfluid-campaign-widget'
+import {
+ CustodialAirdropStatusStory,
+ CustodialLocalFixtureStory,
+ LiveDataNoWalletStory,
+ NoWalletLeaderboardStory,
+ NoWalletStory,
+ NoWalletSupTotalsStory,
+} from '../helpers/superfluidCampaignWidgetStories'
+
+const meta: Meta = {
+ title: 'QA/SuperfluidCampaignWidget/Runtime Fixtures',
+ component: SuperfluidCampaignWidget,
+ tags: ['autodocs', 'qa'],
+ parameters: { layout: 'padded' },
+}
+
+export default meta
+type Story = StoryObj
+
+export const NoWalletContent: Story = {
+ render: () => ,
+}
+
+export const NoWalletLeaderboard: Story = {
+ render: () => ,
+}
+
+export const CustodialLocalFixtureContent: Story = {
+ render: () => ,
+}
+
+export const CustodialLocalFixtureLeaderboard: Story = {
+ render: () => ,
+}
+
+// Airdrop-status card states — each fixes the live endpoint's response via
+// airdropStatusAdapter so the leaderboard screenshot is deterministic.
+export const AirdropStatusLoading: Story = {
+ render: () => ,
+}
+
+export const AirdropStatusRequestFailed: Story = {
+ render: () => ,
+}
+
+export const AirdropStatusNotWhitelisted: Story = {
+ render: () => ,
+}
+
+export const AirdropStatusEligible: Story = {
+ render: () => ,
+}
+
+// Campaign leaderboard states — each fixes the Points API response via
+// leaderboardAdapter so the leaderboard/tabs screenshot is deterministic.
+export const LeaderboardLoading: Story = {
+ render: () => ,
+}
+
+export const LeaderboardRequestFailed: Story = {
+ render: () => ,
+}
+
+export const LeaderboardPopulated: Story = {
+ render: () => ,
+}
+
+// Unadapted story used with Playwright network routes to verify the real Points
+// API response contract, including per-account event enrichment.
+export const LeaderboardApiContract: Story = {
+ render: () => ,
+}
+
+// SUP-totals progress bar states — each fixes the protocol-subgraph response via
+// supTotalsAdapter so the reward-pool progress bar screenshot is deterministic.
+export const SupTotalsRequestFailed: Story = {
+ render: () => ,
+}
+
+export const SupTotalsPopulated: Story = {
+ render: () => ,
+}
+
+// Unadapted story used with a mocked subgraph response to verify that a passed
+// pool address drives both live distribution and current-member figures.
+export const SupTotalsSubgraphContract: Story = {
+ render: () => (
+
+ ),
+}
diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx
index 8876c7a2..2d1b9ad4 100644
--- a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx
+++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx
@@ -27,6 +27,7 @@ import type {
CitizenClaimWidgetSuccessDetail,
CitizenClaimWidgetErrorDetail,
CitizenClaimWidgetEnvironment,
+ CitizenClaimTab,
} from './widgetRuntimeContract'
// ---------------------------------------------------------------------------
@@ -506,7 +507,6 @@ function CitizenClaimInner({ environment, onClaimSuccess, onClaimError }: Citize
// ---------------------------------------------------------------------------
// Public component
// ---------------------------------------------------------------------------
-type CitizenClaimTab = 'claim' | 'invite-rewards' | 'news-feed'
/**
* CitizenClaimWidget — real SDK-backed GoodDollar UBI claim flow.
*
@@ -529,8 +529,10 @@ export function CitizenClaimWidget({
defaultTheme = 'dark',
onClaimSuccess,
onClaimError,
+ initialTab,
}: CitizenClaimWidgetProps) {
- const [activeTab, setActiveTab] = useState('claim')
+ // Initial tab only — not synced after mount, matching existing internal-state pattern.
+ const [activeTab, setActiveTab] = useState(initialTab ?? 'claim')
return (
Promise
+ disconnect: () => Promise
}
export type HostContextValue = HostState
export interface GoodWidgetContextValue extends GoodWidgetState {
connect: () => Promise
+ disconnect: () => Promise
}
export const WalletContext = React.createContext({
@@ -36,6 +38,7 @@ export const WalletContext = React.createContext({
isConnected: false,
provider: null,
connect: async () => {},
+ disconnect: async () => {},
})
export const HostContext = React.createContext({
@@ -51,11 +54,13 @@ export const GoodWidgetContext = React.createContext({
host: 'injected',
capabilities: DEFAULT_CAPABILITIES,
connect: async () => {},
+ disconnect: async () => {},
})
export function GoodWidgetProvider({
provider: explicitProvider,
connectOverride,
+ disconnectOverride,
config: authorConfig,
themeOverrides,
defaultTheme = 'dark',
@@ -124,9 +129,18 @@ export function GoodWidgetProvider({
const accounts = (await resolvedProvider.request({
method: 'eth_requestAccounts',
})) as string[]
- if (accounts.length > 0) setAddress(accounts[0])
+ if (accounts.length > 0) {
+ setAddress(accounts[0])
+ }
}, [connectOverride, resolvedProvider])
+ // Wallet session ownership stays with the integrator. Provider/account
+ // updates after the override resolves flow back through the normal EIP-1193
+ // accountsChanged event or a changed provider prop.
+ const disconnect = useCallback(async () => {
+ await disconnectOverride?.()
+ }, [disconnectOverride])
+
const mergedConfig = useMemo(() => {
const finalConfig = mergeThemeOverrides(authorConfig, themeOverrides)
return createGoodWidgetConfig(finalConfig ?? undefined)
@@ -139,8 +153,9 @@ export function GoodWidgetProvider({
isConnected: address !== null,
provider: resolvedProvider,
connect,
+ disconnect,
}),
- [address, chainId, resolvedProvider, connect],
+ [address, chainId, resolvedProvider, connect, disconnect],
)
const hostValue = useMemo(() => ({ host, capabilities }), [host, capabilities])
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 8dd9437c..a6f86f3e 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -38,6 +38,7 @@ export interface GoodWidgetState extends WalletState {
export interface GoodWidgetProviderProps {
provider?: EIP1193Provider
connectOverride?: () => Promise
+ disconnectOverride?: () => Promise
config?: GoodWidgetConfig
themeOverrides?: GoodWidgetThemeOverrides
defaultTheme?: 'light' | 'dark'
diff --git a/packages/superfluid-campaign-widget/package.json b/packages/superfluid-campaign-widget/package.json
new file mode 100644
index 00000000..28e84af2
--- /dev/null
+++ b/packages/superfluid-campaign-widget/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@goodwidget/superfluid-campaign-widget",
+ "version": "0.1.0-beta",
+ "description": "GoodWidget for the Superfluid Ecosystem Rewards campaign — campaign header, reward pools, public leaderboard, and FAQ",
+ "type": "module",
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ },
+ "./element": {
+ "types": "./dist/element.d.ts",
+ "import": "./dist/element.js",
+ "require": "./dist/element.cjs"
+ },
+ "./register": {
+ "types": "./dist/register.d.ts",
+ "import": "./dist/register.js",
+ "require": "./dist/register.cjs"
+ }
+ },
+ "scripts": {
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "lint": "eslint src/",
+ "clean": "rm -rf dist .turbo"
+ },
+ "peerDependencies": {
+ "react": ">=18.0.0",
+ "react-dom": ">=18.0.0"
+ },
+ "dependencies": {
+ "@goodwidget/citizen-claim-widget": "workspace:*",
+ "@goodwidget/core": "workspace:*",
+ "@goodwidget/embed": "workspace:*",
+ "@goodwidget/ui": "workspace:*",
+ "viem": "^2.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0",
+ "tsup": "^8.4.0",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx b/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx
new file mode 100644
index 00000000..a014c64e
--- /dev/null
+++ b/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx
@@ -0,0 +1,217 @@
+import React, { useState } from 'react'
+import { GoodWidgetProvider, useWallet } from '@goodwidget/core'
+import type { EIP1193Provider } from '@goodwidget/core'
+import { CitizenClaimWidget } from '@goodwidget/citizen-claim-widget'
+import { Card, Heading, Text, ToastContainer, YStack } from '@goodwidget/ui'
+import { CampaignHeader } from './components/CampaignHeader'
+import { FaqAccordion } from './components/FaqAccordion'
+import { LeaderboardSummary } from './components/LeaderboardSummary'
+import { LeaderboardView } from './components/LeaderboardView'
+import { RewardPoolSection } from './components/RewardPoolSection'
+import { useAirdropStatus } from './hooks/useAirdropStatus'
+import { DEFAULT_CAMPAIGN_MOCK_DATA } from './mockData'
+import type {
+ CampaignActionMockData,
+ SuperfluidCampaignView,
+ SuperfluidCampaignWidgetProps,
+} from './widgetRuntimeContract'
+
+/** Which embedded CitizenClaimWidget tab (if any) is currently open as a CTA overlay. */
+type EmbeddedClaimTab = 'claim' | 'invite-rewards' | null
+
+interface SuperfluidCampaignRuntimeProps {
+ data: SuperfluidCampaignWidgetProps['data']
+ citizenClaimEnvironment: SuperfluidCampaignWidgetProps['citizenClaimEnvironment']
+ initialView: SuperfluidCampaignView
+ poolAddresses?: SuperfluidCampaignWidgetProps['poolAddresses']
+ /** Forwarded to the embedded CitizenClaimWidget so it shares the same provider/config/theme context. */
+ provider?: SuperfluidCampaignWidgetProps['provider']
+ config?: SuperfluidCampaignWidgetProps['config']
+ themeOverrides?: SuperfluidCampaignWidgetProps['themeOverrides']
+ defaultTheme?: SuperfluidCampaignWidgetProps['defaultTheme']
+ hasDisconnectOverride: boolean
+ airdropStatusAdapter?: SuperfluidCampaignWidgetProps['airdropStatusAdapter']
+ leaderboardAdapter?: SuperfluidCampaignWidgetProps['leaderboardAdapter']
+ supTotalsAdapter?: SuperfluidCampaignWidgetProps['supTotalsAdapter']
+}
+
+/**
+ * Routes a CampaignActionMockData CTA press to its handler:
+ * - claim-widget-claim / claim-widget-invite → open the embedded CitizenClaimWidget
+ * on the matching tab
+ * - external-link → open the Flow State / Gardens URL in a new tab, mirroring the
+ * window.open(url, '_blank', 'noopener,noreferrer') pattern already used for
+ * external verification links in citizen-claim-widget/ai-credits-widget
+ */
+function handleActionCta(
+ action: CampaignActionMockData,
+ openClaimTab: (tab: EmbeddedClaimTab) => void,
+) {
+ switch (action.ctaKind) {
+ case 'claim-widget-claim':
+ openClaimTab('claim')
+ return
+ case 'claim-widget-invite':
+ openClaimTab('invite-rewards')
+ return
+ case 'external-link':
+ if (action.href) {
+ window.open(action.href, '_blank', 'noopener,noreferrer')
+ }
+ return
+ }
+}
+
+function SuperfluidCampaignRuntime({
+ data,
+ citizenClaimEnvironment,
+ initialView,
+ poolAddresses,
+ provider,
+ config,
+ themeOverrides,
+ defaultTheme,
+ hasDisconnectOverride,
+ airdropStatusAdapter,
+ leaderboardAdapter,
+ supTotalsAdapter,
+}: SuperfluidCampaignRuntimeProps) {
+ const { isConnected, connect, disconnect, address } = useWallet()
+ const [view, setView] = useState(initialView)
+ const [embeddedClaimTab, setEmbeddedClaimTab] = useState(null)
+
+ // Keyed on `address` alone (see useAirdropStatus) so this fires on connect, on
+ // load when already connected, and on address change — not on every render.
+ // airdropStatusAdapter, when supplied, replaces the live fetch with a fixed
+ // result for deterministic Storybook/Playwright fixtures.
+ const airdropStatus = useAirdropStatus(address, airdropStatusAdapter)
+
+ const campaignData = data ?? DEFAULT_CAMPAIGN_MOCK_DATA
+
+ if (embeddedClaimTab) {
+ return (
+
+
+ setEmbeddedClaimTab(null)}
+ cursor="pointer"
+ >
+ Back to campaign
+
+
+ )
+ }
+
+ if (view === 'leaderboard') {
+ return (
+ setView('content')}
+ airdropStatus={airdropStatus}
+ />
+ )
+ }
+
+ return (
+
+ {/* Disconnected-state CTA per #127 acceptance criteria now lives in the header's
+ top-right slot (see CampaignHeader) instead of its own row here. */}
+
+
+ setView('leaderboard')}
+ />
+
+
+ How to participate
+
+ Complete eligible actions to earn points. Your SUP share is based on your points. Use
+ Claim SUP rewards to create or update your rewards stream.
+
+
+
+ {/* Pools always stack vertically at every breakpoint — no responsive change here. */}
+
+ {campaignData.pools.map((pool) => (
+ handleActionCta(action, setEmbeddedClaimTab)}
+ supTotalsAdapter={supTotalsAdapter}
+ />
+ ))}
+
+
+
+
+ )
+}
+
+export function SuperfluidCampaignWidget({
+ provider,
+ connectOverride,
+ disconnectOverride,
+ themeOverrides,
+ config,
+ defaultTheme = 'dark',
+ data,
+ citizenClaimEnvironment = 'production',
+ initialView = 'content',
+ poolAddresses,
+ airdropStatusAdapter,
+ leaderboardAdapter,
+ supTotalsAdapter,
+}: SuperfluidCampaignWidgetProps) {
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/packages/superfluid-campaign-widget/src/components/ActionCard.tsx b/packages/superfluid-campaign-widget/src/components/ActionCard.tsx
new file mode 100644
index 00000000..39a8c18a
--- /dev/null
+++ b/packages/superfluid-campaign-widget/src/components/ActionCard.tsx
@@ -0,0 +1,110 @@
+import React from 'react'
+import { Badge, BadgeText, Button, ButtonText, Card, Text, XStack, YStack } from '@goodwidget/ui'
+import { ACTIVITY_ICON_MAP } from '../widgetRuntimeContract'
+import type { CampaignActionMockData } from '../widgetRuntimeContract'
+import { ACTIVITY_ICON_COMPONENT, resolveActivityIconColorToken } from './activityIconComponents'
+import { compactButtonProps } from './shared/styles'
+
+interface ActionCardProps {
+ action: CampaignActionMockData
+ onPressCta: (action: CampaignActionMockData) => void
+}
+
+/**
+ * A single reward-pool action row.
+ *
+ * At desktop widths, row 1 contains the title/source and row 2 keeps the icon,
+ * description, and action footer side by side. At the shared $sm breakpoint
+ * (480px and below), the icon joins the title row while row 2 becomes a column:
+ * description first, then the unchanged points-pill/button footer. This gives
+ * the longest labels enough room instead of clipping them inside the card.
+ *
+ * The whole card is a click target for the same action as the CTA button
+ * (mouse and keyboard), while the CTA button stays visible rather than being
+ * hidden behind an invisible overlay. The button's own onPress stops
+ * propagation so a direct click on it doesn't also fire the card's handler.
+ */
+export function ActionCard({ action, onPressCta }: ActionCardProps) {
+ const iconSpec = ACTIVITY_ICON_MAP[action.activity]
+ const ActivityIconComponent = ACTIVITY_ICON_COMPONENT[action.activity]
+ const iconColor = resolveActivityIconColorToken(iconSpec.colorVariant, true)
+
+ const handleCardActivate = () => onPressCta(action)
+
+ return (
+ {
+ // Space/Enter activate the card the same way native buttons do, matching
+ // the Accordion header's keyboard pattern elsewhere in this package.
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ handleCardActivate()
+ }
+ }}
+ >
+ {/* On mobile the icon moves beside a dedicated title/source column.
+ Keeping the copy in its own flexible column lets long ecosystem
+ titles wrap without pushing the icon onto a line by itself. */}
+
+
+
+
+
+ {action.title}
+
+ {action.source}
+
+
+
+
+ {/* The desktop action row switches to a vertical content/footer flow at
+ $sm. Button and badge sizing stay unchanged; only their placement
+ changes so neither can be squeezed or clipped by the description. */}
+
+
+
+
+
+ {action.description}
+
+
+
+ {action.pointsLabel}
+
+
+
+
+
+ )
+}
diff --git a/packages/superfluid-campaign-widget/src/components/ActivityIcons.tsx b/packages/superfluid-campaign-widget/src/components/ActivityIcons.tsx
new file mode 100644
index 00000000..ad812f46
--- /dev/null
+++ b/packages/superfluid-campaign-widget/src/components/ActivityIcons.tsx
@@ -0,0 +1,44 @@
+import React from 'react'
+import { XStack } from '@goodwidget/ui'
+import { ACTIVITY_ICON_MAP } from '../widgetRuntimeContract'
+import type { ActivityType } from '../widgetRuntimeContract'
+import { ACTIVITY_ICON_COMPONENT, resolveActivityIconColorToken } from './activityIconComponents'
+
+interface ActivityIconsProps {
+ /** Activities the row's owner has completed — drives the done/not-done glyph state. */
+ completedActivities: ActivityType[]
+ size?: 'xs' | 'sm'
+}
+
+/** Maps the ActivityIcons `size` prop to the pixel size lucide icons expect. */
+const ICON_PX: Record<'xs' | 'sm', number> = { xs: 16, sm: 20 }
+
+/**
+ * Renders the six fixed activity glyphs (order matches ACTIVITY_ICON_MAP)
+ * for a leaderboard row, dimming any activity absent from completedActivities.
+ * Desktop keeps all six inline; below $gtMd they wrap to a second line
+ * within the cell instead of truncating (see LeaderboardRow's cell wrapper).
+ */
+export function ActivityIcons({ completedActivities, size = 'sm' }: ActivityIconsProps) {
+ const completedSet = new Set(completedActivities)
+
+ return (
+
+ {Object.values(ACTIVITY_ICON_MAP).map((spec) => {
+ const isDone = completedSet.has(spec.activity)
+ const ActivityIconComponent = ACTIVITY_ICON_COMPONENT[spec.activity]
+ const color = resolveActivityIconColorToken(spec.colorVariant, isDone)
+
+ return (
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx b/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx
new file mode 100644
index 00000000..33d5c935
--- /dev/null
+++ b/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx
@@ -0,0 +1,95 @@
+import React from 'react'
+import { Badge, BadgeText, Button, ButtonText, Heading, Text, XStack, YStack } from '@goodwidget/ui'
+import type { CampaignMockData } from '../widgetRuntimeContract'
+import { ConnectWalletPrompt } from './ConnectWalletPrompt'
+import { compactButtonProps } from './shared/styles'
+import { WalletChip } from './shared/WalletChip'
+
+/** claim.superfluid.org is the Superfluid-operated claim app — always opened in a new tab, never embedded. */
+const SUPERFLUID_CLAIM_APP_URL = 'https://claim.superfluid.org/'
+
+interface CampaignHeaderProps {
+ data: Pick<
+ CampaignMockData,
+ 'seasonLabel' | 'title' | 'description' | 'supAllocatedLabel' | 'endsLabel'
+ >
+ address: string | null
+ isConnected: boolean
+ onConnect: () => void
+ onDisconnect?: () => Promise
+}
+
+/**
+ * Top-of-page header: "Superfluid" wordmark + season badge, top-right slot,
+ * title, description, and the two info pills. The top-right slot shows the
+ * "Connect wallet" CTA while disconnected, per #127 follow-up, and the same
+ * WalletChip (status dot + truncated address + dropdown chevron, opening a
+ * Disconnect menu) used on LeaderboardView's header once connected — there
+ * is no close affordance here since this screen has nothing to close.
+ */
+export function CampaignHeader({
+ data,
+ address,
+ isConnected,
+ onConnect,
+ onDisconnect,
+}: CampaignHeaderProps) {
+ return (
+
+ {/* Wraps below the wordmark/badge group on narrow viewports instead of staying
+ rigid — without this, the disconnected-state CTA (or the connected-state wave
+ art) is pushed past the card's right edge and silently clipped by the card's
+ own rounded-corner overflow at sub-480px widths. */}
+
+
+ Superfluid
+
+ {data.seasonLabel}
+
+
+
+ {isConnected ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Level 1 dominates the first screen on phone-width viewports, so it steps down
+ to the level-3 scale there while staying the largest text on the page. */}
+
+ {data.title}
+
+ {data.description}
+
+
+
+
+
+ {data.supAllocatedLabel}
+
+
+ {data.endsLabel}
+
+
+
+ {/* Prominent claim CTA, always blue (Button's default 'primary' variant), opens
+ the Superfluid-operated claim app in a new tab — never embedded in the widget. */}
+
+
+
+ )
+}
diff --git a/packages/superfluid-campaign-widget/src/components/ConnectWalletPrompt.tsx b/packages/superfluid-campaign-widget/src/components/ConnectWalletPrompt.tsx
new file mode 100644
index 00000000..c1cc7b57
--- /dev/null
+++ b/packages/superfluid-campaign-widget/src/components/ConnectWalletPrompt.tsx
@@ -0,0 +1,23 @@
+import React from 'react'
+import { Button, ButtonText } from '@goodwidget/ui'
+import { compactButtonProps } from './shared/styles'
+
+interface ConnectWalletPromptProps {
+ onConnect: () => void
+}
+
+/** Disconnected-state header CTA — replaced by a wallet chip once connected. */
+export function ConnectWalletPrompt({ onConnect }: ConnectWalletPromptProps) {
+ return (
+ // flexShrink={0} keeps this button at its true natural (min-content) size for
+ // both the header row's flexWrap line-fit decision and its rendered width —
+ // unlike flexBasis={0}, which let the row's flex algorithm shrink the button
+ // toward zero width and visibly break its "Connect wallet" label, since the
+ // Button's own rounded-corner clipping disables the browser's automatic
+ // minimum-size protection. The button's label doesn't reflow, so its natural
+ // size is also the correct minimum: wrap only when that size doesn't fit.
+
+ )
+}
diff --git a/packages/superfluid-campaign-widget/src/components/FaqAccordion.tsx b/packages/superfluid-campaign-widget/src/components/FaqAccordion.tsx
new file mode 100644
index 00000000..8e3aea50
--- /dev/null
+++ b/packages/superfluid-campaign-widget/src/components/FaqAccordion.tsx
@@ -0,0 +1,34 @@
+import React from 'react'
+import { Accordion, Text } from '@goodwidget/ui'
+import type { FaqItemMockData } from '../widgetRuntimeContract'
+
+interface FaqAccordionProps {
+ faq: FaqItemMockData[]
+}
+
+/**
+ * FAQ is one top-level collapsible ("FAQ") rather than N top-level accordion
+ * items — this keeps the collapsed page short on phone. Opening it reveals an
+ * inner Accordion with one item per question, each toggling independently.
+ */
+export function FaqAccordion({ faq }: FaqAccordionProps) {
+ return (
+ ({
+ id: `faq-${index}`,
+ title: item.question,
+ content: {item.answer},
+ }))}
+ />
+ ),
+ },
+ ]}
+ />
+ )
+}
diff --git a/packages/superfluid-campaign-widget/src/components/LeaderboardRow.tsx b/packages/superfluid-campaign-widget/src/components/LeaderboardRow.tsx
new file mode 100644
index 00000000..f062504a
--- /dev/null
+++ b/packages/superfluid-campaign-widget/src/components/LeaderboardRow.tsx
@@ -0,0 +1,65 @@
+import React from 'react'
+import { Badge, BadgeText, Card, Text, XStack, YStack } from '@goodwidget/ui'
+import type { LeaderboardEntryMockData } from '../widgetRuntimeContract'
+import { ActivityIcons } from './ActivityIcons'
+import { truncateAddress } from './shared/styles'
+
+interface LeaderboardRowProps {
+ entry: LeaderboardEntryMockData
+ /** Drives the highlighted/bordered treatment for the connected user's own row. */
+ isCurrentUser?: boolean
+}
+
+/**
+ * One leaderboard row: rank + address/ENS, points, and the six activity icons.
+ *
+ * Desktop: four inline table-row columns.
+ * Below $gtMd (<768px): the activity-icons cell wraps to a second line instead
+ * of truncating (ActivityIcons itself already wraps via flexWrap).
+ * Below $gtSm (<480px): the whole row becomes a stacked card, same four data
+ * groups in the same order (rank+address, then points, then activities).
+ */
+export function LeaderboardRow({ entry, isCurrentUser = false }: LeaderboardRowProps) {
+ const addressLabel = entry.ensName ?? truncateAddress(entry.address)
+
+ return (
+
+
+
+ {entry.rank}
+
+
+ {addressLabel}
+
+ {isCurrentUser && (
+
+ You
+
+ )}
+
+
+
+ {entry.points.toLocaleString()} pts
+
+
+ {/* Omitted for rows sourced from the live Points API, which has no
+ per-activity breakdown — hide the column rather than show misleading
+ all-dimmed icons. */}
+ {entry.completedActivities && (
+
+
+
+ )}
+
+ )
+}
diff --git a/packages/superfluid-campaign-widget/src/components/LeaderboardSummary.tsx b/packages/superfluid-campaign-widget/src/components/LeaderboardSummary.tsx
new file mode 100644
index 00000000..212ba78d
--- /dev/null
+++ b/packages/superfluid-campaign-widget/src/components/LeaderboardSummary.tsx
@@ -0,0 +1,54 @@
+import React from 'react'
+import { Button, ButtonText, Card, Heading, Icon, ProgressBar, Text, XStack, YStack } from '@goodwidget/ui'
+import type { LeaderboardMockData } from '../widgetRuntimeContract'
+import { compactButtonProps } from './shared/styles'
+
+interface LeaderboardSummaryProps {
+ leaderboard: LeaderboardMockData
+ onViewLeaderboard: () => void
+}
+
+/**
+ * Collapsed leaderboard summary card shown on the content page: trophy +
+ * heading + subtext on the left, "View Leaderboard" button on the right,
+ * then a green SUP-progress bar and participant/last-updated captions.
+ */
+export function LeaderboardSummary({ leaderboard, onViewLeaderboard }: LeaderboardSummaryProps) {
+ const progressLabel = `SUP allocated ${leaderboard.supDistributed.toLocaleString()} / ${leaderboard.supTotal.toLocaleString()} SUP`
+
+ return (
+
+
+
+
+
+ Leaderboard
+
+ Top contributors
+
+
+
+
+
+
+
+
+
+
+ {leaderboard.totalParticipants.toLocaleString()} participants
+
+
+ {leaderboard.lastUpdatedLabel}
+
+
+
+ )
+}
diff --git a/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx b/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx
new file mode 100644
index 00000000..45b4c7f9
--- /dev/null
+++ b/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx
@@ -0,0 +1,308 @@
+import React, { useState } from 'react'
+import {
+ Badge,
+ BadgeText,
+ Button,
+ ButtonText,
+ Card,
+ Heading,
+ Icon,
+ Input,
+ Text,
+ WidgetTabs,
+ XStack,
+ YStack,
+} from '@goodwidget/ui'
+import type { AirdropStatus } from '../hooks/useAirdropStatus'
+import type {
+ CampaignLeaderboardAdapter,
+ CampaignPointsAccount,
+ CampaignPointsPagination,
+} from '../hooks/useCampaignLeaderboard'
+import { useCampaignLeaderboard } from '../hooks/useCampaignLeaderboard'
+import type { CampaignPoolMockData, LeaderboardEntryMockData } from '../widgetRuntimeContract'
+import { LeaderboardRow } from './LeaderboardRow'
+import { compactButtonProps, truncateAddress } from './shared/styles'
+import { WalletChip } from './shared/WalletChip'
+
+interface LeaderboardViewProps {
+ /** Matches the "SEASON N" badge shown next to the wordmark on the content view's header. */
+ seasonLabel: string
+ /** #127's two fixed reward pools, one leaderboard tab each. */
+ pools: CampaignPoolMockData[]
+ address: string | null
+ leaderboardAdapter?: CampaignLeaderboardAdapter
+ isConnected: boolean
+ onConnect: () => void
+ onDisconnect?: () => Promise
+ onClose: () => void
+ airdropStatus: { status: AirdropStatus | null; isLoading: boolean; error: string | null }
+}
+
+/**
+ * Converts one campaign's ranked accounts page into the row shape LeaderboardRow
+ * expects. Rank is derived from the page offset because the Points API returns
+ * accounts pre-sorted by totalPoints but has no rank field of its own.
+ */
+function toLeaderboardEntries(
+ accounts: CampaignPointsAccount[],
+ pagination: CampaignPointsPagination | undefined,
+): LeaderboardEntryMockData[] {
+ const rankOffset = pagination ? (pagination.page - 1) * pagination.limit : 0
+ return accounts.map((account, index) => ({
+ rank: rankOffset + index + 1,
+ address: account.account,
+ points: account.totalPoints,
+ completedActivities: account.completedActivities,
+ }))
+}
+
+/**
+ * Full leaderboard view — one tab per campaign pool (GoodDollar actions /
+ * Ecosystem actions), each backed by its own live Superfluid Points
+ * API fetch via useCampaignLeaderboard. The hook is called a fixed number of
+ * times, once per pool in prop order, rather than in a loop over `pools`,
+ * since React requires the same hooks in the same order on every render and
+ * #127's two pools are a fixed structural constant.
+ *
+ * Search is a local filter over the active tab's fetched page only because the
+ * API has no server-side search endpoint. Pages are intentionally small: each
+ * account is enriched with its own event history to derive activity icons.
+ */
+export function LeaderboardView({
+ seasonLabel,
+ pools,
+ address,
+ leaderboardAdapter,
+ isConnected,
+ onConnect,
+ onDisconnect,
+ onClose,
+ airdropStatus,
+}: LeaderboardViewProps) {
+ const [searchQuery, setSearchQuery] = useState('')
+ const [activeCampaignTab, setActiveCampaignTab] = useState(pools[0]?.id ?? '')
+ const [pageByPoolId, setPageByPoolId] = useState>({})
+
+ const firstPool = pools[0]
+ const secondPool = pools[1]
+ const firstPoolResult = useCampaignLeaderboard(
+ firstPool?.campaignId ?? 0,
+ firstPool?.actions ?? [],
+ firstPool ? (pageByPoolId[firstPool.id] ?? 1) : 1,
+ Boolean(firstPool && activeCampaignTab === firstPool.id),
+ leaderboardAdapter,
+ )
+ const secondPoolResult = useCampaignLeaderboard(
+ secondPool?.campaignId ?? 0,
+ secondPool?.actions ?? [],
+ secondPool ? (pageByPoolId[secondPool.id] ?? 1) : 1,
+ Boolean(secondPool && activeCampaignTab === secondPool.id),
+ leaderboardAdapter,
+ )
+ const resultByPoolId: Record = {}
+ if (pools[0]) resultByPoolId[pools[0].id] = firstPoolResult
+ if (pools[1]) resultByPoolId[pools[1].id] = secondPoolResult
+
+ const activePool = pools.find((pool) => pool.id === activeCampaignTab) ?? pools[0]
+ const activeResult = activePool ? resultByPoolId[activePool.id] : undefined
+ const activePagination = activeResult?.data?.pagination
+ const rankedEntries = toLeaderboardEntries(
+ activeResult?.data?.accounts ?? [],
+ activePagination,
+ )
+
+ const currentUserEntry =
+ isConnected && address
+ ? (rankedEntries.find((entry) => entry.address.toLowerCase() === address.toLowerCase()) ??
+ null)
+ : null
+
+ const matchesQuery = (entry: LeaderboardEntryMockData) => {
+ if (!searchQuery.trim()) return true
+ return entry.address.toLowerCase().includes(searchQuery.trim().toLowerCase())
+ }
+
+ const visibleRows = rankedEntries.filter(matchesQuery)
+ const setActivePage = (page: number) => {
+ if (!activePool) return
+ setPageByPoolId((current) => ({ ...current, [activePool.id]: page }))
+ }
+
+ return (
+
+ {/* The close button is kept as its own flex item in a row that never wraps
+ (alignItems="flex-start" pins it to the top), so it always stays top-right —
+ only the inner wordmark/badge + wallet CTA group wraps to its own line
+ below when it doesn't fit. Without this split, the close button used to
+ wrap down together with the CTA since both lived inside one flex item. */}
+
+
+
+ Superfluid
+
+ {seasonLabel}
+
+
+ {isConnected ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ Leaderboard
+ See how you rank against other campaign participants.
+
+
+ ({ id: pool.id, label: pool.label }))}
+ activeTab={activePool?.id ?? ''}
+ onTabChange={setActiveCampaignTab}
+ />
+
+ {isConnected && currentUserEntry && (
+
+
+
+ Your position
+ #{currentUserEntry.rank}
+ {truncateAddress(currentUserEntry.address)}
+
+ You
+
+
+ Your points: {currentUserEntry.points.toLocaleString()}
+
+
+ )}
+
+ {/*
+ Live airdrop-eligibility check against the connected wallet. Deliberately
+ kept separate from the "Your position" points card above: the airdrop
+ endpoint reports claim/invite eligibility, not the campaign points total
+ (which comes from the live Points API leaderboard fetch above — see
+ useCampaignLeaderboard). Don't merge the two into one "points" figure.
+ */}
+ {isConnected && (
+
+ Airdrop status
+ {airdropStatus.isLoading && (
+ Checking your Superfluid airdrop status...
+ )}
+ {airdropStatus.error && {airdropStatus.error}}
+ {!airdropStatus.isLoading && !airdropStatus.error && airdropStatus.status && (
+
+ {airdropStatus.status.error === 'not whitelisted'
+ ? 'Not yet whitelisted for the SUP airdrop.'
+ : (airdropStatus.status.error ?? 'Eligible for the SUP airdrop.')}
+
+ )}
+ {airdropStatus.status?.walletData && (
+
+
+ Claims: {airdropStatus.status.walletData.claims}
+
+
+ Invites: {airdropStatus.status.walletData.invites}
+
+
+ )}
+
+ )}
+
+
+
+ {/* supDistributed/supTotal have no live source (Points API gap, reported
+ separately) — totalParticipants below is the live per-tab count. */}
+ {activeResult?.data && (
+
+ Total participants: {activeResult.data.summary.memberCount.toLocaleString()}
+
+ )}
+
+ {activeResult?.isLoading && Loading leaderboard...}
+ {activeResult?.error && {activeResult.error}}
+
+ {!activeResult?.isLoading && !activeResult?.error && (
+
+ {visibleRows.map((entry) => (
+
+ ))}
+
+ )}
+
+
+
+ Points update every few minutes.
+
+ {/* Keep pagination deliberately compact. Ten rows bounds the associated
+ per-account event requests while Previous/Next still expose the
+ complete leaderboard. */}
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/superfluid-campaign-widget/src/components/RewardPoolSection.tsx b/packages/superfluid-campaign-widget/src/components/RewardPoolSection.tsx
new file mode 100644
index 00000000..87b0259d
--- /dev/null
+++ b/packages/superfluid-campaign-widget/src/components/RewardPoolSection.tsx
@@ -0,0 +1,68 @@
+import React from 'react'
+import { Heading, ProgressBar, Text, YStack } from '@goodwidget/ui'
+import type { Address } from 'viem'
+import type { ProgramSupTotalsAdapter } from '../hooks/useProgramSupTotals'
+import { useProgramSupTotals } from '../hooks/useProgramSupTotals'
+import type { CampaignActionMockData, CampaignPoolMockData } from '../widgetRuntimeContract'
+import { ActionCard } from './ActionCard'
+
+interface RewardPoolSectionProps {
+ pool: CampaignPoolMockData
+ poolAddress?: Address
+ onPressActionCta: (action: CampaignActionMockData) => void
+ supTotalsAdapter?: ProgramSupTotalsAdapter
+}
+
+/**
+ * Renders one reward pool: heading, participant count, a green SUP-progress
+ * bar, then the pool's ActionCards stacked vertically. The two pools this
+ * feeds always stack vertically at every breakpoint — no responsive change
+ * needed at this level.
+ */
+export function RewardPoolSection({
+ pool,
+ poolAddress,
+ onPressActionCta,
+ supTotalsAdapter,
+}: RewardPoolSectionProps) {
+ // Live on-chain SUP totals for this pool's campaign, when a matching program
+ // exists (see useProgramSupTotals). While loading, on request failure, or
+ // when the integrator has not supplied a pool address, fall back to the
+ // pool's placeholder figures rather than making an unresolvable subgraph query.
+ const supTotals = useProgramSupTotals(
+ pool.campaignId,
+ poolAddress,
+ pool.supTotal,
+ supTotalsAdapter,
+ )
+ const supDistributed = supTotals.data?.totalClaimed ?? pool.supDistributed
+ const supTotal = supTotals.data?.totalAllocated ?? pool.supTotal
+ const participants = supTotals.data?.totalMembers ?? pool.participants
+
+ const progressLabel = `${supDistributed.toLocaleString()} / ${supTotal.toLocaleString()} SUP`
+
+ return (
+
+
+ {pool.label}
+
+ {participants.toLocaleString()} participants
+
+
+
+
+
+
+ {pool.actions.map((action) => (
+
+ ))}
+
+
+ )
+}
diff --git a/packages/superfluid-campaign-widget/src/components/activityIconComponents.ts b/packages/superfluid-campaign-widget/src/components/activityIconComponents.ts
new file mode 100644
index 00000000..64f33e8b
--- /dev/null
+++ b/packages/superfluid-campaign-widget/src/components/activityIconComponents.ts
@@ -0,0 +1,21 @@
+import { CalendarDays, HandCoins, Megaphone, UserPlus, Waves } from '@goodwidget/ui'
+import type { ActivityType } from '../widgetRuntimeContract'
+
+/** Exact glyph mapping from the approved design reference (#127) — do not substitute. */
+export const ACTIVITY_ICON_COMPONENT: Record = {
+ 'claim-ubi': CalendarDays,
+ 'invite-users': UserPlus,
+ 'flow-state-vote': Megaphone,
+ 'flow-state-funding': Waves,
+ 'gardens-donation': HandCoins,
+ 'gardens-funding': Waves,
+}
+
+/** Resolves an activity's done-state to the color token its lucide icon should render in. */
+export function resolveActivityIconColorToken(
+ colorVariant: 'blue' | 'green',
+ isDone: boolean,
+): string {
+ if (!isDone) return '$placeholderColor'
+ return colorVariant === 'green' ? '$success' : '$primary'
+}
diff --git a/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx b/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx
new file mode 100644
index 00000000..7b98f1f0
--- /dev/null
+++ b/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx
@@ -0,0 +1,96 @@
+import React, { useState } from 'react'
+import { Button, ButtonText, Icon, Text, XStack, YStack } from '@goodwidget/ui'
+import { truncateAddress } from './styles'
+
+interface WalletChipProps {
+ address: string | null
+ onDisconnect?: () => Promise
+}
+
+/**
+ * Connected-wallet chip (status dot + truncated address + chevron) shared by
+ * CampaignHeader and LeaderboardView so both headers stay identical instead
+ * of duplicating the markup. Pressing the chip opens a single-action
+ * "Disconnect" dropdown, following the same relative/absolute positioning
+ * pattern as InfoTooltip in ai-credits-widget rather than pulling in the
+ * heavier Drawer/ActionSheet primitives for one menu item.
+ */
+export function WalletChip({ address, onDisconnect }: WalletChipProps) {
+ const [isMenuOpen, setIsMenuOpen] = useState(false)
+ const [disconnectMessage, setDisconnectMessage] = useState(null)
+
+ return (
+
+ {
+ setDisconnectMessage(null)
+ setIsMenuOpen((open) => !open)
+ }}
+ aria-label="Wallet options"
+ >
+
+ {address ? truncateAddress(address) : ''}
+
+
+
+ {isMenuOpen && (
+ <>
+ {/* Invisible full-viewport layer so any outside press closes the menu,
+ same dismiss approach as ActionSheet's overlay. Sits below the menu
+ itself in z-index so the menu's own press still reaches its button. */}
+ setIsMenuOpen(false)}
+ />
+
+
+ {disconnectMessage && (
+
+ {disconnectMessage}
+
+ )}
+
+ >
+ )}
+
+ )
+}
diff --git a/packages/superfluid-campaign-widget/src/components/shared/styles.ts b/packages/superfluid-campaign-widget/src/components/shared/styles.ts
new file mode 100644
index 00000000..9c9dc4b6
--- /dev/null
+++ b/packages/superfluid-campaign-widget/src/components/shared/styles.ts
@@ -0,0 +1,14 @@
+// Mirrors the compactButtonProps convention established in
+// ai-credits-widget/src/components/shared/styles.ts — spread onto every
+//