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