diff --git a/examples/storybook/src/stories/citizen-claim-widget/InviteRewardsQA.stories.tsx b/examples/storybook/src/stories/citizen-claim-widget/InviteRewardsQA.stories.tsx new file mode 100644 index 00000000..0789605d --- /dev/null +++ b/examples/storybook/src/stories/citizen-claim-widget/InviteRewardsQA.stories.tsx @@ -0,0 +1,118 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { expect, userEvent, within } from '@storybook/test' +import { InviteRewardsFixtureStory } from '../helpers/inviteRewardsStories' + +const meta: Meta = { + title: 'QA/CitizenClaimWidget/Invite Rewards Fixtures', + component: InviteRewardsFixtureStory, + tags: ['autodocs', 'qa'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +// ─── Static states ───────────────────────────────────────────────────────── + +export const Loading: Story = { + render: () => , +} + +export const Disconnected: Story = { + render: () => , +} + +export const UnsupportedNetwork: Story = { + render: () => , +} + +export const ErrorNoData: Story = { + render: () => , +} + +export const Empty: Story = { + render: () => , +} + +export const NotWhitelisted: Story = { + render: () => ( + + ), +} + +export const PendingOnly: Story = { + render: () => , +} + +export const Collectable: Story = { + render: () => , +} + +export const JoinSuccessAfterCardHidden: Story = { + render: () => , +} + +export const CollectSuccess: Story = { + render: () => , +} + +export const CollectError: Story = { + render: () => , +} + +// ─── Interactive flows — demonstrate the deferred-inviter and ─────────────── +// collection-ready paths end-to-end without a live wallet/contract. + +export const DeferredInviterJoinFlow: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // The join card is offered because invitedBy is empty and the bounty is unpaid. + await expect(canvas.getByText('Use invite code')).toBeVisible() + + const input = canvas.getByPlaceholderText('Place your invite code here') + await userEvent.type(input, 'friendcode123') + await userEvent.click(canvas.getByRole('button', { name: /join with code/i })) + + // Success is shown, and reusing the same runtime, the join card disappears + // once an inviter is attached — yet the success banner must remain visible. + // The mock action resolves asynchronously, so use findByText (auto-retrying) + // rather than getByText (synchronous) to avoid a race with the state update. + await expect(await canvas.findByText('Joined inviter successfully.')).toBeVisible() + await expect(canvas.queryByText('Use invite code')).not.toBeInTheDocument() + }, +} + +export const CollectionReadyFlow: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + const collectButton = canvas.getByRole('button', { name: /collect eligible rewards/i }) + await expect(collectButton).toBeEnabled() + await expect(canvas.getByText('Ready to collect')).toBeVisible() + + await userEvent.click(collectButton) + + // The mock action resolves asynchronously, so use findByText (auto-retrying) + // rather than getByText (synchronous) to avoid a race with the state update. + await expect(await canvas.findByText('Invite rewards collected successfully.')).toBeVisible() + // Once collected, the same button is disabled again until a new invitee is ready. + await expect(canvas.getByRole('button', { name: /collect eligible rewards/i })).toBeDisabled() + }, +} + +export const CollectionNotReadyFlow: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + // No collectable invitees — the collect action must stay disabled. + await expect(canvas.getByRole('button', { name: /collect eligible rewards/i })).toBeDisabled() + await expect(canvas.queryByText('Ready to collect')).not.toBeInTheDocument() + }, +} diff --git a/examples/storybook/src/stories/helpers/citizenClaimWidgetStories.tsx b/examples/storybook/src/stories/helpers/citizenClaimWidgetStories.tsx index 3692fadb..7c8a5f38 100644 --- a/examples/storybook/src/stories/helpers/citizenClaimWidgetStories.tsx +++ b/examples/storybook/src/stories/helpers/citizenClaimWidgetStories.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { GoodWidgetProvider } from '@goodwidget/core' import { YStack } from '@goodwidget/ui' import { @@ -38,7 +38,9 @@ function createMockClaimExecutionClientBundle(chainId: number, shouldFail: boole }, estimateFeesPerGas: async () => ({ maxFeePerGas: 1n }), getBalance: async () => 1_000000000000000000n, - simulateContract: async () => ({ request: { to: '0x0000000000000000000000000000000000000001' } }), + simulateContract: async () => ({ + request: { to: '0x0000000000000000000000000000000000000001' }, + }), getTransactionReceipt: async ({ hash }: { hash: `0x${string}` }) => ({ transactionHash: hash, blockNumber: 1n, @@ -104,7 +106,7 @@ function CitizenClaimWidgetStoryShell({ provider={provider} environment="development" data-testid={dataTestId} - chainId={activeChainId ?? 42220} + chainId={activeChainId ?? 50} defaultTheme={defaultTheme} themeOverrides={themeOverrides} /> @@ -190,7 +192,9 @@ function CustodialExecutionClaimAllHarness() { }) const [running, setRunning] = useState(false) const [durationMs, setDurationMs] = useState(null) - const [results, setResults] = useState>([]) + const [results, setResults] = useState< + Array<{ chainId: number; status: string; message: string }> + >([]) const runClaimAll = async () => { setRunning(true) @@ -217,7 +221,11 @@ function CustodialExecutionClaimAllHarness() { } return ( - + diff --git a/examples/storybook/src/stories/helpers/inviteRewardsStories.tsx b/examples/storybook/src/stories/helpers/inviteRewardsStories.tsx new file mode 100644 index 00000000..95288a6c --- /dev/null +++ b/examples/storybook/src/stories/helpers/inviteRewardsStories.tsx @@ -0,0 +1,258 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { zeroAddress, zeroHash, type Address } from 'viem' +import { GoodWidgetProvider } from '@goodwidget/core' +import { + encodeInviteCode, + InviteRewards, + InviteRuntimeContext, + type InviteActions, + type InviteAdapterResult, + type InviteState, +} from '@goodwidget/citizen-claim-widget' + +// --------------------------------------------------------------------------- +// Deterministic fixture data — stable addresses/amounts so screenshots and +// Playwright assertions never depend on live wallet/RPC state. +// --------------------------------------------------------------------------- + +export const MOCK_INVITER_ADDRESS = '0x4444444444444444444444444444444444444444' as Address +const APPROVED_INVITEE = '0x1111111111111111111111111111111111111111' as Address +const WAITING_INVITEE = '0x2222222222222222222222222222222222222222' as Address +const COLLECTABLE_INVITEE = '0x3333333333333333333333333333333333333333' as Address +const MY_CODE = encodeInviteCode('mycode1234') + +function baseUser(overrides: Partial> = {}) { + return { + invitedBy: zeroAddress, + inviteCode: MY_CODE, + bountyPaid: false, + level: 0n, + levelStarted: 0n, + totalApprovedInvites: 1n, + totalEarned: 50_000_000_000_000_000_000n, // 50 G$ at 18 decimals + joinedAt: 1_700_000_000n, + bountyAtJoin: 100_000_000_000_000_000_000n, + ...overrides, + } +} + +const waitingDetails = { + isActive: true, + inviteeWhitelisted: false, + inviterWhitelisted: true, + minimumClaims: 5, + minimumDays: 3, + reverificationDue: false, +} + +const collectableDetails = { + ...waitingDetails, + inviteeWhitelisted: true, +} + +function baseState(overrides: Partial = {}): InviteState { + return { + status: 'ready', + address: MOCK_INVITER_ADDRESS, + chainId: 42220, + user: baseUser(), + level: { toNext: 5n, bounty: 100_000_000_000_000_000_000n, daysToComplete: 30n }, + invitees: [APPROVED_INVITEE, WAITING_INVITEE, COLLECTABLE_INVITEE], + pendingInvitees: [WAITING_INVITEE, COLLECTABLE_INVITEE], + collectableInvitees: [COLLECTABLE_INVITEE], + eligibility: { + [WAITING_INVITEE]: waitingDetails, + [COLLECTABLE_INVITEE]: collectableDetails, + }, + selfEligibility: { ...collectableDetails, inviterWhitelisted: null }, + error: null, + success: null, + ...overrides, + } +} + +export const inviteRewardsFixtures = { + loading: (): InviteState => ({ ...baseState(), status: 'loading' }), + disconnected: (): InviteState => ({ ...baseState(), status: 'disconnected', address: null, user: null }), + unsupported: (): InviteState => ({ ...baseState(), status: 'unsupported', chainId: 1, user: null }), + errorNoData: (): InviteState => ({ + ...baseState(), + status: 'error', + user: null, + invitees: [], + pendingInvitees: [], + collectableInvitees: [], + eligibility: {}, + error: 'Unable to reach the network. Check your connection and try again.', + }), + empty: (): InviteState => + baseState({ + user: baseUser({ inviteCode: zeroHash, totalApprovedInvites: 0n, totalEarned: 0n }), + invitees: [], + pendingInvitees: [], + collectableInvitees: [], + eligibility: {}, + selfEligibility: { ...waitingDetails, inviteeWhitelisted: true, inviterWhitelisted: null }, + }), + // A connected wallet that hasn't verified identity yet and has no personal + // code — matches the state reported from a live manual test of goodwallet.xyz. + notWhitelisted: (): InviteState => + baseState({ + user: baseUser({ inviteCode: zeroHash, totalApprovedInvites: 0n, totalEarned: 0n }), + invitees: [], + pendingInvitees: [], + collectableInvitees: [], + eligibility: {}, + selfEligibility: { ...waitingDetails, inviteeWhitelisted: false, inviterWhitelisted: null }, + }), + pendingOnly: (): InviteState => + baseState({ + invitees: [APPROVED_INVITEE, WAITING_INVITEE], + pendingInvitees: [WAITING_INVITEE], + collectableInvitees: [], + eligibility: { [WAITING_INVITEE]: waitingDetails }, + }), + collectable: (): InviteState => baseState(), + joinSuccess: (): InviteState => + baseState({ + // Inviter already attached — the join card is hidden — yet the success + // banner from the join action must remain visible (acceptance criterion). + user: baseUser({ invitedBy: APPROVED_INVITEE }), + success: 'Joined inviter successfully.', + }), + collectSuccess: (): InviteState => + baseState({ + pendingInvitees: [WAITING_INVITEE], + collectableInvitees: [], + eligibility: { [WAITING_INVITEE]: waitingDetails }, + success: 'Invite rewards collected successfully.', + }), + collectError: (): InviteState => + baseState({ + error: 'Invite transaction failed. Please retry.', + }), +} + +// --------------------------------------------------------------------------- +// Stateful mock runtime — a real hook so Storybook play functions / Playwright +// interactions can drive join/collect through the same UI action path. +// --------------------------------------------------------------------------- + +export interface MockInviteRuntimeOptions { + joinShouldFail?: boolean + collectShouldFail?: boolean +} + +function useMockInviteRuntime( + initialState: InviteState, + options: MockInviteRuntimeOptions = {}, +): InviteAdapterResult { + const [state, setState] = useState(initialState) + + const refresh = useCallback(async () => {}, []) + + const validateCode = useCallback(async (code: string): Promise => { + const normalized = code.trim() + if (!normalized) throw new Error('This invite code was not found.') + if (normalized === 'INVALID') throw new Error('This invite code was not found.') + return normalized + }, []) + + const join = useCallback( + async (inviterCode?: string) => { + setState((current) => ({ ...current, status: 'joining', error: null, success: null })) + await new Promise((resolve) => setTimeout(resolve, 30)) + if (options.joinShouldFail) { + setState((current) => ({ + ...current, + status: 'ready', + error: 'You have already joined an inviter.', + success: null, + })) + return + } + setState((current) => ({ + ...current, + status: 'ready', + user: current.user + ? { + ...current.user, + invitedBy: inviterCode ? MOCK_INVITER_ADDRESS : current.user.invitedBy, + inviteCode: current.user.inviteCode === zeroHash ? MY_CODE : current.user.inviteCode, + } + : current.user, + error: null, + success: inviterCode ? 'Joined inviter successfully.' : 'Invite code created successfully.', + })) + }, + [options.joinShouldFail], + ) + + const collectAll = useCallback(async () => { + setState((current) => ({ ...current, status: 'collecting', error: null, success: null })) + await new Promise((resolve) => setTimeout(resolve, 30)) + if (options.collectShouldFail) { + setState((current) => ({ + ...current, + status: 'ready', + error: 'Invite transaction failed. Please retry.', + success: null, + })) + return + } + setState((current) => ({ + ...current, + status: 'ready', + pendingInvitees: current.pendingInvitees.filter( + (invitee) => !current.collectableInvitees.includes(invitee), + ), + collectableInvitees: [], + error: null, + success: current.collectableInvitees.length + ? 'Invite rewards collected successfully.' + : 'No rewards were collected.', + })) + }, [options.collectShouldFail]) + + const actions: InviteActions = useMemo( + () => ({ refresh, join, collectAll, validateCode }), + [refresh, join, collectAll, validateCode], + ) + + return useMemo(() => ({ state, actions }), [state, actions]) +} + +export function MockInviteRuntimeProvider({ + initialState, + options, + children, +}: { + initialState: InviteState + options?: MockInviteRuntimeOptions + children: React.ReactNode +}) { + const runtime = useMockInviteRuntime(initialState, options) + return ( + {children} + ) +} + +export function InviteRewardsFixtureStory({ + fixture, + options, + dataTestId, +}: { + fixture: keyof typeof inviteRewardsFixtures + options?: MockInviteRuntimeOptions + dataTestId: string +}) { + return ( + +
+ + + +
+
+ ) +} diff --git a/packages/citizen-claim-widget/README.md b/packages/citizen-claim-widget/README.md index 3f1dea6c..17c47255 100644 --- a/packages/citizen-claim-widget/README.md +++ b/packages/citizen-claim-widget/README.md @@ -12,3 +12,55 @@ This package currently contains only the migration piping: The existing `@goodwidget/claim-widget` package remains the theme/demo widget and should not be changed for this migration. +## Invite Rewards + +The widget directly uses `@goodsdks/invite-sdk@1.0.3` with the provider-first +viem clients already used by the claim flow. Invite writes are available only on +Celo (42220) and XDC (50); the SDK maps `staging` to its development InvitesV2 +deployment. + +The Claim and Invite Rewards tabs share one invite runtime. A recipient can +enter an inviter code in either tab, and both use the same on-chain validation, +SDK prechecks, simulation, transaction, and refresh behavior. The widget does +not accept a host destination, callback, or invite URL configuration. Sharing +copies this message, using the page where the widget is currently loaded: + +```text +Claim GoodDollar with me. Open this page and use my invite code: + +``` + +Invite creation uses the GoodWallet Base58 shortest-unused-prefix algorithm. +The InviteSDK remains responsible for InvitesV2 addresses, preconditions, +simulation, error mapping, joins, and single/batch bounty collection. + +### Invitee counts and rewards + +Invite Rewards distinguishes three protocol-derived values, and never conflates +them: + +- **Invitees joined** — everyone who registered under the inviter's code + (`getInvitees()`), whether or not their bounty has been paid yet. +- **Approved** — `totalApprovedInvites` from the inviter's own `InviteUser` + record; this is the protocol's count of invitees whose bounty condition has + actually been met, not the total number of registered invitees. +- **Pending / collectable** — pending invitees (`getPendingInvitees()`) are + shown with their per-invitee whitelist and minimum-days/minimum-claims + diagnostics. Among those, only the subset InviteSDK's + `getCollectableInvitees()` reports as currently collectable is labelled + "Ready to collect"; the collect action is enabled only when that list is + non-empty. +- **Total earned** — `totalEarned` from the same `InviteUser` record, shown + as a running G$ total whenever the inviter has read access to their own + user record. + +### Feedback and deferred attachment + +Join and collection outcomes (success or error) are shown as a persistent +banner in the Invite Rewards view. The banner is driven by adapter state, not +by the presence of the join card or a specific sub-component, so it remains +visible after the underlying data refreshes and after a successful join makes +the "have an invite code?" card disappear (an attached inviter cannot be +changed). Deferred inviter attachment reuses the invitee's existing personal +invite code — it is only offered while `invitedBy` is still empty and the +invite bounty is unpaid, matching the InvitesV2 contract rule. diff --git a/packages/citizen-claim-widget/package.json b/packages/citizen-claim-widget/package.json index 1a56ceaf..0c0c722c 100644 --- a/packages/citizen-claim-widget/package.json +++ b/packages/citizen-claim-widget/package.json @@ -36,9 +36,11 @@ }, "dependencies": { "@goodsdks/citizen-sdk": "1.2.7", + "@goodsdks/invite-sdk": "1.0.3", "@goodwidget/core": "workspace:*", "@goodwidget/embed": "workspace:*", "@goodwidget/ui": "workspace:*", + "bs58": "6.0.0", "viem": "^2.0.0" }, "devDependencies": { diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx index ce73257e..89c681e6 100644 --- a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx +++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { GoodWidgetProvider, useWallet } from '@goodwidget/core' import type { EIP1193Provider } from '@goodwidget/core' import { @@ -22,6 +22,8 @@ import { } from '@goodwidget/ui' import { SupportedChains } from '@goodsdks/citizen-sdk' import { getChainDisplayName, useCitizenClaimAdapter } from './adapter' +import { ClaimInviteJoinCard, InviteRewards } from './InviteRewards' +import { InviteRuntimeProvider } from './inviteAdapter' import type { CitizenClaimWidgetProps, CitizenClaimWidgetSuccessDetail, @@ -436,24 +438,26 @@ function CitizenClaimInner({ )} {status !== 'unsupported_chain' && - (status === 'eligible' || status === 'claiming' || claimablesByChain.length > 0) && ( - <> - {/* + (status === 'eligible' || + status === 'claiming' || + claimablesByChain.length > 0) && ( + <> + {/* Declarative to claim status: an already_claimed wallet with other chains still available must not read as "no claims left" (the "Just a little longer" copy below is reserved for when every chain has actually been claimed for the day). */} - - {status === 'already_claimed' && claimablesByChain.length === 1 - ? `G$ Claim is still available on ${getChainDisplayName(claimablesByChain[0].chainId)}` - : status === 'already_claimed' && claimablesByChain.length > 1 - ? 'G$ Claim is still available on other chains' - : 'Ready to claim'} - - {displayAmount && } - - )} + + {status === 'already_claimed' && claimablesByChain.length === 1 + ? `G$ Claim is still available on ${getChainDisplayName(claimablesByChain[0].chainId)}` + : status === 'already_claimed' && claimablesByChain.length > 1 + ? 'G$ Claim is still available on other chains' + : 'Ready to claim'} + + {displayAmount && } + + )} {status === 'success' && ( @@ -565,6 +569,7 @@ function CitizenClaimInner({ /> +
) } @@ -594,40 +599,66 @@ function CitizenClaimShell({ onClaimError, initialTab, }: CitizenClaimShellProps) { - const { chainId } = useWallet() + const { chainId, isConnected, switchChain } = useWallet() + const autoSwitchAttemptRef = useRef(null) + + // Citizen claims and invite rewards are currently XDC-first. Use the shared + // wallet switch path so AppKit can handle the network selection while direct + // EIP-1193 wallets use wallet_switchEthereumChain. + useEffect(() => { + if (!isConnected || chainId === null) { + autoSwitchAttemptRef.current = null + return + } + if (chainId === SupportedChains.XDC) { + autoSwitchAttemptRef.current = null + return + } + if (autoSwitchAttemptRef.current === chainId) return + + autoSwitchAttemptRef.current = chainId + void switchChain(SupportedChains.XDC).catch(() => { + // Keep the wallet on its current chain; the existing manual switch action remains available. + }) + }, [chainId, isConnected, switchChain]) + // Initial tab only — not synced after mount, matching existing internal-state pattern. const [activeTab, setActiveTab] = useState(initialTab ?? 'claim') return ( <> - setActiveTab(tabId as CitizenClaimTab)} - chainId={chainId ?? fallbackChainId ?? SupportedChains.XDC} - /> - {activeTab === 'claim' ? ( - <> - - - - ) : ( - - - Widget coming soon - - - )} + + setActiveTab(tabId as CitizenClaimTab)} + chainId={chainId ?? fallbackChainId ?? SupportedChains.XDC} + /> + {activeTab === 'claim' ? ( + <> + + + + ) : activeTab === 'invite-rewards' ? ( + + ) : ( + + + Widget coming soon + + + )} + ) } diff --git a/packages/citizen-claim-widget/src/InviteRewards.tsx b/packages/citizen-claim-widget/src/InviteRewards.tsx new file mode 100644 index 00000000..be0151c9 --- /dev/null +++ b/packages/citizen-claim-widget/src/InviteRewards.tsx @@ -0,0 +1,308 @@ +import React, { useCallback, useState } from 'react' +import { + Alert, + Badge, + BadgeText, + Button, + ButtonText, + Card, + Drawer, + Heading, + Icon, + Input, + Spinner, + Text, + XStack, + YStack, +} from '@goodwidget/ui' +import { zeroHash } from 'viem' +import { decodeInviteCode, formatInviteBounty, useInviteRuntime } from './inviteAdapter' +import { canAttachInviter, hasCollectableInvitees, isInviteeCollectable } from './inviteRules' + +/** + * "How it works" — mirrors GoodWallet's InviteView, which opens the explainer + * in a Drawer from an inline info-icon link rather than showing it inline. + */ +function HowItWorksDrawer() { + const [open, setOpen] = useState(false) + + return ( + <> + + setOpen(false)}> + + How it works + 1. Share your code. + 2. Your friend joins and claims. + + 3. After identity, claim-day, and minimum-claim requirements are met, collect your + reward. + + + + + + ) +} + +function InviteJoinCard({ compact = false }: { compact?: boolean }) { + const { state, actions } = useInviteRuntime() + const [code, setCode] = useState('') + const [validationError, setValidationError] = useState(null) + + const canJoin = canAttachInviter(state.user) + const isPending = state.status === 'joining' + + const joinWithCode = useCallback(async () => { + try { + setValidationError(null) + await actions.validateCode(code) + await actions.join(code) + } catch (error: unknown) { + setValidationError(error instanceof Error ? error.message : 'Enter a valid invite code.') + } + }, [actions, code]) + + if (!canJoin || state.status === 'disconnected' || state.status === 'unsupported') return null + + return ( + + Use invite code + Enter your inviter's code to join their invite rewards. + ) => setCode(event.target.value)} + placeholder="Place your invite code here" + autoCapitalize="none" + /> + {validationError && } + + + ) +} + +function InviteShareCard() { + const { state, actions } = useInviteRuntime() + const [shareFeedback, setShareFeedback] = useState<{ message: string; ok: boolean } | null>(null) + const hasCode = state.user?.inviteCode !== zeroHash + + const share = useCallback(async () => { + if (!state.user || !hasCode) return + const inviteCode = state.user.inviteCode + const message = `Claim GoodDollar with me. Open this page and use my invite code: ${decodeInviteCode(inviteCode)}\n${window.location.href}` + + try { + if (navigator.share) { + await navigator.share({ text: message }) + } else { + await navigator.clipboard.writeText(message) + } + setShareFeedback({ message: 'Invite message ready to send.', ok: true }) + } catch { + setShareFeedback({ message: 'Could not copy the invite message. Please retry.', ok: false }) + } + }, [hasCode, state.user]) + + if (!state.user) return null + + if (!hasCode) { + const isVerified = state.selfEligibility?.inviteeWhitelisted + return ( + + Share your invite + {isVerified ? ( + <> + Create a code to invite friends to claim G$. + + + ) : ( + You need to be whitelisted and claim to get an invite link. + )} + + ) + } + + return ( + + Share your invite + Your invite code + {decodeInviteCode(state.user.inviteCode)} + + {shareFeedback && ( + + )} + + ) +} + +function InviteeRow({ invitee, isCollectable, details }: { + invitee: string + isCollectable: boolean + details?: { inviteeWhitelisted: boolean; minimumDays: number; minimumClaims: number } +}) { + const shortAddress = `${invitee.slice(0, 6)}…${invitee.slice(-4)}` + const waitingReason = details?.inviteeWhitelisted + ? `Waiting for ${details.minimumDays} days and ${details.minimumClaims} claims.` + : 'Waiting for identity verification.' + + return ( + + {shortAddress} + + {isCollectable ? 'Ready to collect' : waitingReason} + + + ) +} + +/** Mirrors GoodWallet's TotalEarnedBox: its own card, separate from the invitee list. */ +function TotalEarnedCard() { + const { state } = useInviteRuntime() + const totalEarned = formatInviteBounty(state.user?.totalEarned ?? 0n, state.chainId) + + return ( + + Total rewards earned + {totalEarned} G$ + + ) +} + +function InviteeStatus() { + const { state, actions } = useInviteRuntime() + const collectable = hasCollectableInvitees(state.collectableInvitees) + const isCollecting = state.status === 'collecting' + + // Protocol-provided counters — do not conflate "registered invitees" with "approved" ones. + const approvedCount = Number(state.user?.totalApprovedInvites ?? 0n) + + return ( + + Your invite rewards + + + {state.invitees.length} invitee{state.invitees.length === 1 ? '' : 's'} joined + + + {approvedCount} approved + + + + {state.pendingInvitees.length} pending + {state.collectableInvitees.length > 0 + ? ` (${state.collectableInvitees.length} collectable now)` + : ''} + + + + {state.pendingInvitees.length > 0 && ( + + {state.pendingInvitees.map((invitee) => ( + + ))} + + )} + + + ) +} + +/** Full Invite Rewards hierarchy, using the shared provider-first InviteSDK runtime. */ +export function InviteRewards() { + const { state, actions } = useInviteRuntime() + + if (state.status === 'loading') { + return ( + + + + Loading invite rewards… + + + ) + } + + if (state.status === 'disconnected') { + return ( + + Connect your wallet to view invite rewards. + + ) + } + + if (state.status === 'unsupported') { + return ( + + Invite rewards are available on Celo and XDC. Switch networks to continue. + + ) + } + + if (state.status === 'error' && !state.user) { + return ( + + + + + ) + } + + return ( + + + Invite Rewards + + Share your code, invite friends, and get rewarded when they join and claim. + + {state.level && ( + + + Get {formatInviteBounty(state.level.bounty, state.chainId)} G$ every time a friend + joins! + + + Your invitee will also receive{' '} + {formatInviteBounty(state.level.bounty / 2n, state.chainId)} G$. + + + )} + + + {/* Persistent action feedback — stays visible after a refresh or once the join + card disappears (e.g. an inviter is now attached), per acceptance criteria. */} + {state.success && } + {state.error && } + + + + {/* Mirrors GoodWallet's InviteesListBox, which is also omitted entirely + until there is at least one invitee to report on. */} + {state.invitees.length > 0 && } + + ) +} + +/** Claim-tab entry point backed by the same invite runtime as Invite Rewards. */ +export function ClaimInviteJoinCard() { + return +} diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index ba136847..68dfddef 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -63,6 +63,30 @@ const CHAIN_CONFIGS: Record = { const SUPPORTED_CHAINS = citizenSdkCapabilities.chains const AVAILABLE_ENVIRONMENTS = citizenSdkCapabilities.environments +/** + * Creates provider-first viem clients for a supported widget chain. + * Invite and claim SDK adapters share this factory so they always sign through + * the host's EIP-1193 provider. + */ +export function createCitizenWidgetClients( + provider: unknown, + address: string, + targetChainId: number, +) { + const chain = CHAIN_CONFIGS[targetChainId] + if (!chain) return null + + const transport = custom(provider as Parameters[0]) + const publicClient = createPublicClient({ chain, transport }) + const walletClient = createWalletClient({ + account: address as `0x${string}`, + chain, + transport, + }) + + return { publicClient, walletClient } +} + // Display names for chains a connected wallet can land on outside the 3 // citizen-sdk supports (e.g. the other networks this app's own wallet-connect // modal offers) — "unsupported chain" messaging only ever needs to name a @@ -159,7 +183,10 @@ function humanReadableError(err: unknown): string { const reasonMatch = msg.match(/reason:\s*(.+?)(?:\n|$)/) if (reasonMatch) { // Sanitize: strip control characters and cap length to avoid injection/overflow - const reason = reasonMatch[1].replace(/[^\x20-\x7E]/g, '').trim().slice(0, 80) + const reason = reasonMatch[1] + .replace(/[^\x20-\x7E]/g, '') + .trim() + .slice(0, 80) if (reason) { return `Transaction failed: ${reason}` } @@ -258,16 +285,7 @@ export function useCitizenClaimAdapter( const createProviderClientsForChain = useCallback( (targetChainId: number) => { if (!provider || !address) return null - const chain = CHAIN_CONFIGS[targetChainId] - if (!chain) return null - const transport = custom(provider as Parameters[0]) - const publicClient = createPublicClient({ chain, transport }) - const walletClient = createWalletClient({ - account: address as `0x${string}`, - chain, - transport, - }) - return { publicClient, walletClient } + return createCitizenWidgetClients(provider, address, targetChainId) }, [provider, address], ) @@ -298,11 +316,9 @@ export function useCitizenClaimAdapter( (targetChainId: number): PublicClient | null => { if (isCustodialExecution) { const configuredClients = claimExecution?.clientsByChain[targetChainId] - return ( - configuredClients?.publicClient ?? + return (configuredClients?.publicClient ?? configuredClients?.readClient ?? - null - ) as PublicClient | null + null) as PublicClient | null } const chain = CHAIN_CONFIGS[targetChainId] @@ -525,10 +541,7 @@ export function useCitizenClaimAdapter( const loadClaimStatus = useCallback(async () => { // These are best-effort UI reads. Start them without making the primary // wallet eligibility check wait for every auxiliary RPC response. - const auxiliaryReads = Promise.all([ - loadClaimablesByChain(), - loadDailyStats(), - ]) + const auxiliaryReads = Promise.all([loadClaimablesByChain(), loadDailyStats()]) if (!address) { await auxiliaryReads @@ -557,8 +570,9 @@ export function useCitizenClaimAdapter( // configured chain comes first, rather than gating on an "active chain" // that may never resolve to a supported one (or may not exist). const statusChainId = isCustodialExecution - ? SUPPORTED_CHAINS.find((supportedChainId) => claimExecution?.clientsByChain[supportedChainId]) ?? - null + ? (SUPPORTED_CHAINS.find( + (supportedChainId) => claimExecution?.clientsByChain[supportedChainId], + ) ?? null) : chainId if (isCustodialExecution && statusChainId === null) { @@ -572,7 +586,9 @@ export function useCitizenClaimAdapter( setStatus('error') setError( humanReadableError( - new CitizenClaimAdapterError('Claim execution is not configured for any supported chain.'), + new CitizenClaimAdapterError( + 'Claim execution is not configured for any supported chain.', + ), ), ) return @@ -603,7 +619,11 @@ export function useCitizenClaimAdapter( // connected — 'not_connected' would tell an already-connected user // to do something they've already done. setStatus('error') - setError(humanReadableError(new CitizenClaimAdapterError('Unable to load claim status for this chain right now.'))) + setError( + humanReadableError( + new CitizenClaimAdapterError('Unable to load claim status for this chain right now.'), + ), + ) return } @@ -669,7 +689,11 @@ export function useCitizenClaimAdapter( // Execute actions must stay within the chains the passed-down provider // can actually sign for right now. Custodial execution supplies its own // pre-configured per-chain clients and is not subject to this restriction. - if (!isCustodialExecution && availableChainIds && !availableChainIds.includes(targetChainId)) { + if ( + !isCustodialExecution && + availableChainIds && + !availableChainIds.includes(targetChainId) + ) { throw new CitizenClaimAdapterError( `Claim is not available on ${getChainDisplayName(targetChainId)} for this connection.`, ) @@ -696,7 +720,14 @@ export function useCitizenClaimAdapter( return sdk.claimSDK.claim() }, - [address, availableChainIds, createSdkInstancesForChain, isCustodialExecution, provider, switchChain], + [ + address, + availableChainIds, + createSdkInstancesForChain, + isCustodialExecution, + provider, + switchChain, + ], ) // --------------------------------------------------------------------------- @@ -854,13 +885,7 @@ export function useCitizenClaimAdapter( if (status === 'eligible') return 'claim' if (status === 'error') return 'refresh' return 'none' - }, [ - status, - address, - isConnected, - isCustodialExecution, - claimablesByChain, - ]) + }, [status, address, isConnected, isCustodialExecution, claimablesByChain]) const primaryLabel: string = useMemo(() => { switch (primaryAction) { diff --git a/packages/citizen-claim-widget/src/index.ts b/packages/citizen-claim-widget/src/index.ts index 2b1ed2dd..acb2e292 100644 --- a/packages/citizen-claim-widget/src/index.ts +++ b/packages/citizen-claim-widget/src/index.ts @@ -28,5 +28,32 @@ export { createCitizenClaimWidgetCustodialExecution } from './custodial' export { useCitizenClaimAdapter } from './adapter' export type { UseCitizenClaimAdapterOptions } from './adapter' +// Shared InviteSDK adapter contract and deterministic code helpers. +export { + decodeInviteCode, + encodeInviteCode, + formatInviteBounty, + generateInviteCode, + InviteRuntimeContext, + InviteRuntimeProvider, + loadInviteSnapshot, + useInviteAdapter, + useInviteRuntime, +} from './inviteAdapter' +export type { + InviteActions, + InviteAdapterResult, + InviteSnapshot, + InviteSnapshotSdk, + InviteState, + InviteStatus, +} from './inviteAdapter' + +// Pure invite rules — reused by adapter/component fixtures and tests. +export { canAttachInviter, getMyInviteCode, hasCollectableInvitees, isInviteeCollectable } from './inviteRules' + +// Invite Rewards presentation — exported so QA fixtures can mount it with a mocked runtime. +export { ClaimInviteJoinCard, InviteRewards } from './InviteRewards' + // Widget component export { CitizenClaimWidget } from './CitizenClaimWidget' diff --git a/packages/citizen-claim-widget/src/integration.ts b/packages/citizen-claim-widget/src/integration.ts index d2e8f12a..d805ac16 100644 --- a/packages/citizen-claim-widget/src/integration.ts +++ b/packages/citizen-claim-widget/src/integration.ts @@ -1,6 +1,7 @@ export const citizenClaimIntegration = { id: 'citizen-claim', sdk: '@goodsdks/citizen-sdk', + inviteSdk: '@goodsdks/invite-sdk@1.0.3', capabilitySource: 'citizenSdkCapabilities', uses: [ 'whitelistStatus', @@ -10,6 +11,10 @@ export const citizenClaimIntegration = { 'dailyStats', 'startVerification', 'claim', + 'invite.resolveCode', + 'invite.join', + 'invite.checkEligibilityDetails', + 'invite.collectAllBounties', ], chains: [122, 42220, 50], states: [ @@ -22,6 +27,13 @@ export const citizenClaimIntegration = { 'claiming', 'success', 'error', + 'invite_disconnected', + 'invite_unsupported', + 'invite_loading', + 'invite_ready', + 'invite_joining', + 'invite_collecting', + 'invite_error', ], events: ['claim-success', 'claim-error'], } as const diff --git a/packages/citizen-claim-widget/src/inviteAdapter.ts b/packages/citizen-claim-widget/src/inviteAdapter.ts new file mode 100644 index 00000000..9b82e9b3 --- /dev/null +++ b/packages/citizen-claim-widget/src/inviteAdapter.ts @@ -0,0 +1,437 @@ +import { + createContext, + createElement, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react' +import bs58 from 'bs58' +import { + formatBounty, + InviteSDK, + InviteSDKError, + isSupportedChain, + type BountyEligibilityDetails, + type InviteLevel, + type InviteUser, +} from '@goodsdks/invite-sdk' +import { hexToBytes, hexToString, stringToHex, zeroAddress, zeroHash, type Address } from 'viem' +import { useWallet } from '@goodwidget/core' +import { createCitizenWidgetClients } from './adapter' +import { getMyInviteCode } from './inviteRules' +import type { CitizenClaimWidgetEnvironment } from './widgetRuntimeContract' + +export type InviteStatus = + | 'disconnected' + | 'unsupported' + | 'loading' + | 'ready' + | 'joining' + | 'collecting' + | 'error' + +export interface InviteState { + status: InviteStatus + address: Address | null + chainId: number | null + user: InviteUser | null + level: InviteLevel | null + invitees: Address[] + pendingInvitees: Address[] + collectableInvitees: Address[] + eligibility: Record + selfEligibility: BountyEligibilityDetails | null + error: string | null + success: string | null +} + +export interface InviteActions { + refresh: () => Promise + join: (inviterCode?: string) => Promise + collectAll: () => Promise + validateCode: (code: string) => Promise +} + +export interface InviteAdapterResult { + state: InviteState + actions: InviteActions +} + +/** + * Test-support surface: lets Storybook fixtures and adapter/component tests supply a + * deterministic `InviteAdapterResult` (e.g. a hook-backed fake) via + * `` without touching InviteSDK, a wallet, or live RPC. + * Production code should use `InviteRuntimeProvider`, which always wraps the real adapter. + */ +export const InviteRuntimeContext = createContext(null) + +const initialInviteState: InviteState = { + status: 'disconnected', + address: null, + chainId: null, + user: null, + level: null, + invitees: [], + pendingInvitees: [], + collectableInvitees: [], + eligibility: {}, + selfEligibility: null, + error: null, + success: null, +} + +/** Converts the human-readable Base58 code to the bytes32 value expected by InviteSDK. */ +export function encodeInviteCode(code: string): `0x${string}` { + const normalizedCode = code.trim() + if (!normalizedCode || normalizedCode.length > 32) { + throw new Error('Enter a valid invite code.') + } + + return stringToHex(normalizedCode, { size: 32 }) +} + +/** Decodes the Base58 string stored in the contract's right-padded bytes32 field. */ +export function decodeInviteCode(code: `0x${string}`): string { + if (code === zeroHash) return '' + return hexToString(code, { size: 32 }).replace(/\0+$/, '') +} + +/** + * Mirrors GoodWallet's deterministic short-code algorithm while delegating each + * protocol lookup to InviteSDK.resolveCode. + */ +export async function generateInviteCode( + address: Address, + resolveCode: (code: `0x${string}`) => Promise
, +): Promise { + const encodedAddress = bs58.encode(hexToBytes(address)) + + for (let length = 10; length <= 30; length += 1) { + const candidate = encodedAddress.slice(0, length) + const owner = await resolveCode(encodeInviteCode(candidate)) + + if (owner.toLowerCase() === address.toLowerCase()) { + throw new Error('You already have an invite code. Refresh to view it.') + } + if (owner === zeroAddress) return candidate + } + + throw new Error('We could not generate an invite code. Please retry.') +} + +/** Subset of InviteSDK read methods used to build an invite state snapshot. Lets adapter tests inject a fake SDK. */ +export interface InviteSnapshotSdk { + getUser: InviteSDK['getUser'] + getLevel: InviteSDK['getLevel'] + getInvitees: InviteSDK['getInvitees'] + getPendingInvitees: InviteSDK['getPendingInvitees'] + getCollectableInvitees: InviteSDK['getCollectableInvitees'] + checkEligibilityDetails: InviteSDK['checkEligibilityDetails'] +} + +export interface InviteSnapshot { + user: InviteUser + level: InviteLevel + invitees: Address[] + pendingInvitees: Address[] + collectableInvitees: Address[] + eligibility: Record + selfEligibility: BountyEligibilityDetails | null +} + +/** + * Loads one consistent snapshot of an inviter's protocol-derived invite data. + * Extracted so both the refresh action and post-mutation reloads share one + * read path, and so adapter tests can exercise it against a fake SDK. + */ +export async function loadInviteSnapshot( + sdk: InviteSnapshotSdk, + address: Address, +): Promise { + const user = await sdk.getUser(address) + const [level, invitees, pendingInvitees, collectableInvitees, selfEligibility] = await Promise.all([ + sdk.getLevel(Number(user.level)), + sdk.getInvitees(address), + sdk.getPendingInvitees(address), + sdk.getCollectableInvitees(address), + sdk.checkEligibilityDetails(address), + ]) + const eligibilityEntries = await Promise.all( + pendingInvitees.map(async (invitee) => { + const { details } = await sdk.checkEligibilityDetails(invitee) + return [invitee, details] as const + }), + ) + + return { + user, + level, + invitees, + pendingInvitees, + collectableInvitees, + eligibility: Object.fromEntries(eligibilityEntries), + selfEligibility: selfEligibility.details, + } +} + +/** Subset of InviteSDK write methods used by join/collect actions, plus the snapshot reads. */ +export interface InviteWriteSdk extends InviteSnapshotSdk { + resolveCode: InviteSDK['resolveCode'] + join: InviteSDK['join'] + collectAllBounties: InviteSDK['collectAllBounties'] +} + +export interface InviteActionOutcome { + successMessage: string + /** Null when the write succeeded but the follow-up snapshot reload failed. */ + snapshot: InviteSnapshot | null +} + +/** + * Runs the deferred-inviter join write and reloads the invite snapshot. + * Reuses the caller's already-registered invite code (via `getMyInviteCode`) + * instead of generating a new one — the same original code used to attach an + * inviter later is the one the caller already shares. + */ +export async function performJoin( + sdk: InviteWriteSdk, + address: Address, + user: InviteUser | null, + inviterCode: `0x${string}` | undefined, +): Promise { + const ownCode = await getMyInviteCode(user, async () => + encodeInviteCode(await generateInviteCode(address, sdk.resolveCode.bind(sdk))), + ) + await sdk.join(ownCode, inviterCode ?? zeroHash) + const successMessage = inviterCode ? 'Joined inviter successfully.' : 'Invite code created successfully.' + + try { + const snapshot = await loadInviteSnapshot(sdk, address) + return { successMessage, snapshot } + } catch { + return { successMessage, snapshot: null } + } +} + +/** + * Runs the batch bounty collection write and reloads the invite snapshot. + * Collection eligibility itself is entirely delegated to InviteSDK/InvitesV2 — + * this only orchestrates the write followed by a state reload. + */ +export async function performCollectAll( + sdk: InviteWriteSdk, + address: Address, +): Promise { + const results = await sdk.collectAllBounties() + const successMessage = results.length + ? 'Invite rewards collected successfully.' + : 'No rewards were collected.' + + try { + const snapshot = await loadInviteSnapshot(sdk, address) + return { successMessage, snapshot } + } catch { + return { successMessage, snapshot: null } + } +} + +function inviteErrorMessage(error: unknown): string { + if (error instanceof InviteSDKError) { + switch (error.errorCode) { + case 'NOT_ACTIVE': + return 'Invite rewards are not active right now.' + case 'INVITE_CODE_IN_USE': + return 'That invite code was just used. Please retry.' + case 'SELF_INVITE': + return 'You cannot use your own invite code.' + case 'USER_ALREADY_JOINED': + case 'INVITER_ALREADY_ATTACHED': + return 'You have already joined an inviter.' + case 'BOUNTY_ALREADY_PAID': + return 'Your invite bounty has already been paid.' + case 'NOT_ELIGIBLE_BOUNTY': + return 'This reward is not ready to collect yet.' + default: + return 'Invite transaction failed. Please retry.' + } + } + + if (error instanceof Error) return error.message + return 'Invite request failed. Please retry.' +} + +/** + * Manages shared InviteSDK reads and writes for both citizen-claim entry points. + * All contract preconditions, simulation, and writes remain in InviteSDK. + */ +export function useInviteAdapter( + environment: CitizenClaimWidgetEnvironment, +): InviteAdapterResult { + const { address, chainId, isConnected, provider } = useWallet() + const [state, setState] = useState(initialInviteState) + + const getSdk = useCallback(async () => { + if (!address || !provider || !chainId || !isSupportedChain(chainId)) return null + const clients = createCitizenWidgetClients(provider, address, chainId) + if (!clients) return null + return InviteSDK.init({ ...clients, env: environment }) + }, [address, chainId, environment, provider]) + + const refresh = useCallback(async () => { + if (!isConnected || !address) { + setState(initialInviteState) + return + } + if (!chainId || !isSupportedChain(chainId)) { + setState({ ...initialInviteState, status: 'unsupported', address: address as Address, chainId }) + return + } + + setState((current) => ({ + ...current, + status: 'loading', + address: address as Address, + chainId, + error: null, + success: null, + })) + + try { + const sdk = await getSdk() + if (!sdk) throw new Error('Unable to initialize invite rewards.') + const snapshot = await loadInviteSnapshot(sdk, address as Address) + + setState({ + status: 'ready', + address: address as Address, + chainId, + ...snapshot, + error: null, + success: null, + }) + } catch (error: unknown) { + setState((current) => ({ + ...current, + status: 'error', + address: address as Address, + chainId, + error: inviteErrorMessage(error), + success: null, + })) + } + }, [address, chainId, getSdk, isConnected]) + + useEffect(() => { + void refresh() + }, [refresh]) + + const validateCode = useCallback( + async (code: string): Promise => { + const sdk = await getSdk() + if (!sdk || !address || !state.user) throw new Error('Connect on Celo or XDC to use invite rewards.') + const normalizedCode = code.trim() + const owner = await sdk.resolveCode(encodeInviteCode(normalizedCode)) + if (owner === zeroAddress) throw new Error('This invite code was not found.') + if (owner.toLowerCase() === address.toLowerCase()) { + throw new Error('You cannot use your own invite code.') + } + return normalizedCode + }, + [address, getSdk, state.user], + ) + + const join = useCallback( + async (inviterCode?: string) => { + const sdk = await getSdk() + if (!sdk || !address) { + setState((current) => ({ ...current, error: 'Connect on Celo or XDC to join.', success: null })) + return + } + + setState((current) => ({ ...current, status: 'joining', error: null, success: null })) + try { + const validatedInviterCode = inviterCode ? await validateCode(inviterCode) : undefined + const outcome = await performJoin( + sdk, + address as Address, + state.user, + validatedInviterCode ? encodeInviteCode(validatedInviterCode) : undefined, + ) + // The join transaction already succeeded at this point. A failure reloading + // the snapshot (outcome.snapshot === null) must not hide that outcome behind + // a hard error screen — keep the prior data and still surface the success message. + setState((current) => ({ + ...current, + status: 'ready', + ...(outcome.snapshot ?? {}), + error: null, + success: outcome.successMessage, + })) + } catch (error: unknown) { + setState((current) => ({ + ...current, + status: 'ready', + error: inviteErrorMessage(error), + success: null, + })) + } + }, + [address, getSdk, state.user, validateCode], + ) + + const collectAll = useCallback(async () => { + const sdk = await getSdk() + if (!sdk || !address) return + + setState((current) => ({ ...current, status: 'collecting', error: null, success: null })) + try { + const outcome = await performCollectAll(sdk, address as Address) + // Same reasoning as join(): the collection tx already succeeded, so a + // follow-up read failure (outcome.snapshot === null) should not hide that outcome. + setState((current) => ({ + ...current, + status: 'ready', + ...(outcome.snapshot ?? {}), + error: null, + success: outcome.successMessage, + })) + } catch (error: unknown) { + setState((current) => ({ + ...current, + status: 'ready', + error: inviteErrorMessage(error), + success: null, + })) + } + }, [address, getSdk]) + + return useMemo( + () => ({ state, actions: { refresh, join, collectAll, validateCode } }), + [collectAll, join, refresh, state, validateCode], + ) +} + +/** Shares one invite state machine between the Claim and Invite Rewards tabs. */ +export function InviteRuntimeProvider({ + children, + environment, +}: { + children: ReactNode + environment: CitizenClaimWidgetEnvironment +}) { + const inviteRuntime = useInviteAdapter(environment) + return createElement(InviteRuntimeContext.Provider, { value: inviteRuntime }, children) +} + +export function useInviteRuntime(): InviteAdapterResult { + const inviteRuntime = useContext(InviteRuntimeContext) + if (!inviteRuntime) throw new Error('InviteRuntimeProvider is required.') + return inviteRuntime +} + +export function formatInviteBounty(amount: bigint, chainId: number | null): string { + return chainId && isSupportedChain(chainId) ? formatBounty(amount, chainId) : '0.00' +} diff --git a/packages/citizen-claim-widget/src/inviteRules.ts b/packages/citizen-claim-widget/src/inviteRules.ts new file mode 100644 index 00000000..349af636 --- /dev/null +++ b/packages/citizen-claim-widget/src/inviteRules.ts @@ -0,0 +1,32 @@ +import type { InviteUser } from '@goodsdks/invite-sdk' +import { isAddressEqual, zeroAddress, zeroHash, type Address } from 'viem' + +/** + * A deferred inviter may be attached exactly while `invitedBy` is empty and the + * invite bounty is unpaid — the protocol rule this widget must not change. This + * does not require the caller to already have their own invite code: `join()` + * can create the caller's code and attach the inviter in the same transaction + * (see `performJoin`/`getMyInviteCode`), matching GoodWallet's own InvCodeBox, + * which offers deferred attachment regardless of whether the caller has a code + * or is whitelisted yet. + */ +export function canAttachInviter(user: InviteUser | null): boolean { + return Boolean(user && isAddressEqual(user.invitedBy, zeroAddress) && !user.bountyPaid) +} + +export async function getMyInviteCode( + user: InviteUser | null, + generateCode: () => Promise<`0x${string}`>, +): Promise<`0x${string}`> { + if (user?.inviteCode && user.inviteCode !== zeroHash) return user.inviteCode + return generateCode() +} + +export function hasCollectableInvitees(collectableInvitees: Address[]): boolean { + return collectableInvitees.length > 0 +} + +/** True when `invitee` is currently reported as collectable by the SDK. */ +export function isInviteeCollectable(invitee: Address, collectableInvitees: Address[]): boolean { + return collectableInvitees.some((collectable) => isAddressEqual(collectable, invitee)) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3111a2bf..822efb73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -390,6 +390,9 @@ importers: '@goodsdks/citizen-sdk': specifier: 1.2.7 version: 1.2.7(@swc/core@1.15.30)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))(wagmi@3.7.1(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(zod@4.1.11))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@4.1.11))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@4.1.11))(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@18.3.1))(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)))(yaml@2.8.3) + '@goodsdks/invite-sdk': + specifier: 1.0.3 + version: 1.0.3(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) '@goodwidget/core': specifier: workspace:* version: link:../core @@ -399,6 +402,9 @@ importers: '@goodwidget/ui': specifier: workspace:* version: link:../ui + bs58: + specifier: 6.0.0 + version: 6.0.0 viem: specifier: ^2.0.0 version: 2.48.4(typescript@5.9.3)(zod@4.1.11) @@ -2181,6 +2187,11 @@ packages: peerDependencies: viem: '*' + '@goodsdks/invite-sdk@1.0.3': + resolution: {integrity: sha512-tE3i9UTy0qQSlSSxgacWFYpJTBUDIsAyFWTtYTJEr9NioCBMf/ZvNxyucTmU7ITcjrsFTY6md+0hrXJUMhhCpg==} + peerDependencies: + viem: '*' + '@goodsdks/streaming-sdk@1.0.0': resolution: {integrity: sha512-g9+PRkBhrXg/ZK2i+piYVBny7B13AW4RB5Vd5CRCxZQhAy/KH7i/En1CgnJQVzTuTWKH3+QU9l7jhzOvbaRb2A==} peerDependencies: @@ -11567,6 +11578,10 @@ snapshots: - typescript - yaml + '@goodsdks/invite-sdk@1.0.3(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))': + dependencies: + viem: 2.48.4(typescript@5.9.3)(zod@4.1.11) + '@goodsdks/streaming-sdk@1.0.0(react@18.3.1)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))': dependencies: '@sfpro/sdk': 0.1.9(react@18.3.1)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) diff --git a/tests/widgets/citizen-claim-widget/inviteAdapter.spec.ts b/tests/widgets/citizen-claim-widget/inviteAdapter.spec.ts new file mode 100644 index 00000000..c400acb1 --- /dev/null +++ b/tests/widgets/citizen-claim-widget/inviteAdapter.spec.ts @@ -0,0 +1,166 @@ +import { test, expect } from '@playwright/test' +import { zeroAddress, zeroHash, type Address } from 'viem' +import { + encodeInviteCode, + loadInviteSnapshot, + performCollectAll, + performJoin, + type InviteWriteSdk, +} from '../../../packages/citizen-claim-widget/src/inviteAdapter' +import { isInviteeCollectable } from '../../../packages/citizen-claim-widget/src/inviteRules' + +const inviter = '0x1234567890123456789012345678901234567890' as Address +const invitee = '0x2222222222222222222222222222222222222222' as Address +const collectableInvitee = '0x3333333333333333333333333333333333333333' as Address +const myCode = `0x${'01'.repeat(32)}` as `0x${string}` +const inviterCode = `0x${'02'.repeat(32)}` as `0x${string}` + +function createUser(overrides: Partial> = {}) { + return { + invitedBy: zeroAddress, + inviteCode: myCode, + bountyPaid: false, + level: 0n, + levelStarted: 0n, + totalApprovedInvites: 0n, + totalEarned: 0n, + joinedAt: 1n, + bountyAtJoin: 0n, + ...overrides, + } +} + +/** Deterministic in-memory double for the subset of InviteSDK used by adapter actions. */ +function createFakeSdk(overrides: Partial = {}): InviteWriteSdk { + const eligibility = { + isActive: true, + inviteeWhitelisted: true, + inviterWhitelisted: true, + minimumClaims: 5, + minimumDays: 3, + reverificationDue: false, + } + + return { + getUser: async () => createUser(), + getLevel: async () => ({ toNext: 5n, bounty: 100n, daysToComplete: 30n }), + getInvitees: async () => [invitee, collectableInvitee], + getPendingInvitees: async () => [invitee, collectableInvitee], + getCollectableInvitees: async () => [collectableInvitee], + checkEligibilityDetails: async () => ({ eligible: true, details: eligibility }), + resolveCode: async () => zeroAddress, + join: async () => '0xjoin' as `0x${string}`, + collectAllBounties: async () => [], + ...overrides, + } +} + +test('loadInviteSnapshot distinguishes pending from collectable invitees', async () => { + const sdk = createFakeSdk() + const snapshot = await loadInviteSnapshot(sdk, inviter) + + expect(snapshot.invitees).toEqual([invitee, collectableInvitee]) + expect(snapshot.pendingInvitees).toEqual([invitee, collectableInvitee]) + expect(snapshot.collectableInvitees).toEqual([collectableInvitee]) + expect(isInviteeCollectable(invitee, snapshot.collectableInvitees)).toBe(false) + expect(isInviteeCollectable(collectableInvitee, snapshot.collectableInvitees)).toBe(true) +}) + +test('performJoin reuses the caller original code when attaching a deferred inviter', async () => { + let joinArgs: [`0x${string}`, `0x${string}`] | null = null + let resolveCodeCalls = 0 + const sdk = createFakeSdk({ + getUser: async () => createUser({ inviteCode: myCode, invitedBy: zeroAddress, bountyPaid: false }), + resolveCode: async () => { + resolveCodeCalls += 1 + return zeroAddress + }, + join: async (code, invCode) => { + joinArgs = [code, invCode] + return '0xjoin' as `0x${string}` + }, + }) + + const outcome = await performJoin(sdk, inviter, createUser({ inviteCode: myCode }), inviterCode) + + // The registered personal code is reused verbatim — no new code is generated. + expect(resolveCodeCalls).toBe(0) + expect(joinArgs).toEqual([myCode, inviterCode]) + expect(outcome.successMessage).toBe('Joined inviter successfully.') + expect(outcome.snapshot).not.toBeNull() +}) + +test('performJoin generates a code only when the caller has none yet', async () => { + let joinArgs: [`0x${string}`, `0x${string}`] | null = null + const sdk = createFakeSdk({ + // First candidate prefix is free. + resolveCode: async () => zeroAddress, + join: async (code, invCode) => { + joinArgs = [code, invCode] + return '0xjoin' as `0x${string}` + }, + }) + + const outcome = await performJoin(sdk, inviter, createUser({ inviteCode: zeroHash }), undefined) + + expect(joinArgs).not.toBeNull() + expect(joinArgs![1]).toBe(zeroHash) + expect(outcome.successMessage).toBe('Invite code created successfully.') +}) + +test('performJoin surfaces success even when the post-join snapshot reload fails', async () => { + const sdk = createFakeSdk({ + getUser: async () => { + throw new Error('RPC unavailable') + }, + }) + + // getUser only fails on the reload path in this fixture (join itself does not call getUser). + const outcome = await performJoin(sdk, inviter, createUser({ inviteCode: myCode }), inviterCode) + expect(outcome.successMessage).toBe('Joined inviter successfully.') + expect(outcome.snapshot).toBeNull() +}) + +test('performCollectAll reports no rewards collected when nothing was eligible', async () => { + const sdk = createFakeSdk({ collectAllBounties: async () => [] }) + const outcome = await performCollectAll(sdk, inviter) + expect(outcome.successMessage).toBe('No rewards were collected.') +}) + +test('performCollectAll reports success and refreshed collectable state after a payout', async () => { + const sdk = createFakeSdk({ + collectAllBounties: async () => [ + { + txHash: '0xabc' as `0x${string}`, + invitee: collectableInvitee, + inviter, + bountyPaid: 100n, + inviterLevel: 0n, + earnedLevel: false, + }, + ], + // After collection, the previously-collectable invitee is no longer pending. + getPendingInvitees: async () => [invitee], + getCollectableInvitees: async () => [], + }) + + const outcome = await performCollectAll(sdk, inviter) + + expect(outcome.successMessage).toBe('Invite rewards collected successfully.') + expect(outcome.snapshot?.pendingInvitees).toEqual([invitee]) + expect(outcome.snapshot?.collectableInvitees).toEqual([]) +}) + +test('encodeInviteCode round-trips through performJoin without mutating an unrelated code', async () => { + const encoded = encodeInviteCode('friendcode') + let joinArgs: [`0x${string}`, `0x${string}`] | null = null + const sdk = createFakeSdk({ + join: async (code, invCode) => { + joinArgs = [code, invCode] + return '0xjoin' as `0x${string}` + }, + }) + + await performJoin(sdk, inviter, createUser({ inviteCode: myCode }), encoded) + expect(joinArgs).toEqual([myCode, encoded]) +}) diff --git a/tests/widgets/citizen-claim-widget/inviteRewards.spec.ts b/tests/widgets/citizen-claim-widget/inviteRewards.spec.ts new file mode 100644 index 00000000..ec2002ca --- /dev/null +++ b/tests/widgets/citizen-claim-widget/inviteRewards.spec.ts @@ -0,0 +1,250 @@ +/** + * inviteRewards.spec.ts — Playwright coverage for the Invite Rewards tab states. + * + * Uses the deterministic QA fixtures under + * `QA/CitizenClaimWidget/Invite Rewards Fixtures` (see + * `examples/storybook/src/stories/helpers/inviteRewardsStories.tsx`), which mount + * `InviteRewards` directly against a mocked, hook-backed runtime — no live wallet, + * RPC, or InviteSDK call is involved, so these are fully deterministic and CI-safe. + * + * Running: + * pnpm storybook (in one terminal) + * pnpm test:demo (in another terminal) + * + * Screenshot evidence: tests/widgets/citizen-claim-widget/test-results/ccw-06 .. ccw-13 + */ +import { test, expect, Page } from '@playwright/test' + +function storyUrl(id: string): string { + return `/iframe.html?id=qa-citizenclaimwidget-invite-rewards-fixtures--${id}&viewMode=story` +} + +async function gotoStory(page: Page, id: string): Promise { + await page.goto(storyUrl(id)) + await page.waitForLoadState('domcontentloaded') +} + +// ─── Loading / connection states ──────────────────────────────────────────── + +test('Invite Rewards shows a loading spinner', async ({ page }) => { + await gotoStory(page, 'loading') + // Generous timeout: this is often the first story hit in a run, and Storybook's + // dev server needs to lazily compile the bundle on a cold first request. + await expect(page.getByText('Loading invite rewards…')).toBeVisible({ timeout: 20_000 }) + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-06-invite-loading.png', + fullPage: true, + }) +}) + +test('Invite Rewards prompts to connect when disconnected', async ({ page }) => { + await gotoStory(page, 'disconnected') + await expect(page.getByText('Connect your wallet to view invite rewards.')).toBeVisible() +}) + +test('Invite Rewards flags an unsupported network', async ({ page }) => { + await gotoStory(page, 'unsupported') + await expect( + page.getByText('Invite rewards are available on Celo and XDC. Switch networks to continue.'), + ).toBeVisible() +}) + +test('Invite Rewards shows a hard error with retry when the initial load fails', async ({ + page, +}) => { + await gotoStory(page, 'error-no-data') + await expect(page.getByText(/unable to reach the network/i)).toBeVisible() + await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible() + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-07-invite-error.png', + fullPage: true, + }) +}) + +// ─── Counts, labels, and empty state ──────────────────────────────────────── + +test('Invite Rewards empty state offers code creation and hides the invitee list, mirroring GoodWallet', async ({ + page, +}) => { + await gotoStory(page, 'empty') + await expect(page.getByRole('button', { name: 'Create invite code' })).toBeVisible() + await expect(page.getByText('Total rewards earned')).toBeVisible() + await expect(page.getByText('0.00 G$', { exact: true })).toBeVisible() + // With zero invitees, GoodWallet omits the invitee-list section entirely + // rather than showing an empty "0 approved / 0 pending" breakdown. + await expect(page.getByText('Your invite rewards')).toHaveCount(0) + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-16-invite-empty-state.png', + fullPage: true, + }) +}) + +test('Invite Rewards offers deferred join before whitelisting or having a personal code, matching goodwallet.xyz', async ({ + page, +}) => { + await gotoStory(page, 'not-whitelisted') + // Matches the live goodwallet.xyz flow: the share card shows a whitelist + // notice (no button), but the join-with-code card is still offered — the + // caller doesn't need their own code yet to attach a deferred inviter. + await expect( + page.getByText('You need to be whitelisted and claim to get an invite link.'), + ).toBeVisible() + await expect(page.getByText('Use invite code')).toBeVisible() + await expect(page.getByPlaceholder('Place your invite code here')).toBeVisible() + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-17-invite-not-whitelisted.png', + fullPage: true, + }) +}) + +test('Invite Rewards shows a more polished hero card before the action sections', async ({ + page, +}) => { + await gotoStory(page, 'collectable') + + await expect( + page.getByText('Share your code, invite friends, and get rewarded when they join and claim.'), + ).toBeVisible() + await expect(page.getByText('Use invite code')).toBeVisible() + await expect(page.getByText('Total rewards earned')).toBeVisible() +}) + +test('Invite Rewards labels approved/pending/collectable using protocol values, not raw invitee count', async ({ + page, +}) => { + await gotoStory(page, 'collectable') + + // 3 invitees total (getInvitees), but only 1 is protocol-approved (totalApprovedInvites) — + // the widget must not conflate "registered" with "approved". + await expect(page.getByText('3 invitees joined')).toBeVisible() + await expect(page.getByText('1 approved')).toBeVisible() + await expect(page.getByText(/2 pending \(1 collectable now\)/)).toBeVisible() + await expect(page.getByText('Total rewards')).toBeVisible() + await expect(page.getByText('50.00 G$', { exact: true })).toBeVisible() + + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-08-invite-collectable.png', + fullPage: true, + }) +}) + +test('Invite Rewards shows per-invitee waiting vs ready-to-collect status', async ({ page }) => { + await gotoStory(page, 'pending-only') + await expect(page.getByRole('button', { name: /collect eligible rewards/i })).toBeDisabled() + await expect(page.getByText(/waiting for identity verification/i)).toBeVisible() + await expect(page.getByText('Ready to collect')).toHaveCount(0) + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-09-invite-pending-only.png', + fullPage: true, + }) +}) + +// ─── Persistent feedback ───────────────────────────────────────────────────── + +test('Invite Rewards keeps the join success banner visible after the join card disappears', async ({ + page, +}) => { + await gotoStory(page, 'join-success-after-card-hidden') + await expect(page.getByText('Joined inviter successfully.')).toBeVisible() + await expect(page.getByText('Use invite code')).toHaveCount(0) + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-10-invite-join-success.png', + fullPage: true, + }) +}) + +test('Invite Rewards keeps the collection success banner visible', async ({ page }) => { + await gotoStory(page, 'collect-success') + await expect(page.getByText('Invite rewards collected successfully.')).toBeVisible() +}) + +test('Invite Rewards surfaces a collection error inline in the ready view', async ({ page }) => { + await gotoStory(page, 'collect-error') + await expect(page.getByText('Invite transaction failed. Please retry.')).toBeVisible() + // A mutation error must not replace the whole view with the hard error screen — + // cached counts/cards stay visible alongside the inline error banner. + await expect(page.getByText('Your invite rewards')).toBeVisible() + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-11-invite-collect-error.png', + fullPage: true, + }) +}) + +// ─── Deferred-inviter and collection-ready action paths (deterministic) ──── + +// Note: these two tests drive the interaction themselves, so they target the plain +// static fixture ('collectable') rather than the same-named QA story that carries its +// own Storybook `play` function — that play function auto-runs on iframe mount and is +// exercised separately by `pnpm test:storybook`. Pointing Playwright at the same story +// would race two independent action executions against one mock runtime instance. +test('Deferred-inviter join flow: enter a code, join, and see persistent success', async ({ + page, +}) => { + await gotoStory(page, 'collectable') + await expect(page.getByText('Use invite code')).toBeVisible() + + await page.getByPlaceholder('Place your invite code here').fill('friendcode123') + await page.getByRole('button', { name: /join with code/i }).click() + + await expect(page.getByText('Joined inviter successfully.')).toBeVisible() + await expect(page.getByText('Use invite code')).toHaveCount(0) + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-12-invite-deferred-join-flow.png', + fullPage: true, + }) +}) + +test('Collection-ready flow: collect only removes the eligible invitee', async ({ page }) => { + await gotoStory(page, 'collectable') + + const collectButton = page.getByRole('button', { name: /collect eligible rewards/i }) + await expect(collectButton).toBeEnabled() + await expect(page.getByText('Ready to collect')).toBeVisible() + + await collectButton.click() + + await expect(page.getByText('Invite rewards collected successfully.')).toBeVisible() + await expect(page.getByRole('button', { name: /collect eligible rewards/i })).toBeDisabled() + // The still-waiting invitee remains visible and untouched. + await expect(page.getByText(/waiting for identity verification/i)).toBeVisible() + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-13-invite-collection-ready-flow.png', + fullPage: true, + }) +}) + +// ─── How it works drawer ───────────────────────────────────────────────────── + +test('How it works opens in a Drawer, mirroring GoodWallet, rather than showing inline', async ({ + page, +}) => { + await gotoStory(page, 'collectable') + + // Not shown inline until the drawer is opened. + await expect(page.getByText('Share your code.')).toHaveCount(0) + + await page.getByRole('button', { name: 'How it works' }).click() + await expect(page.getByText('1. Share your code.')).toBeVisible() + await expect(page.getByText(/2\. Your friend joins and claims\./)).toBeVisible() + const closeButton = page.getByRole('button', { name: 'Close' }) + await expect(closeButton).toBeVisible() + await page.waitForTimeout(400) // let the sheet's slide-up animation settle + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-15-invite-how-it-works-drawer.png', + fullPage: true, + }) + + await closeButton.click() +}) + +// ─── Mobile layout ─────────────────────────────────────────────────────────── + +test('Invite Rewards remains usable at mobile width', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await gotoStory(page, 'collectable') + await expect(page.getByRole('button', { name: /collect eligible rewards/i })).toBeVisible() + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-14-invite-mobile-collectable.png', + fullPage: true, + }) +}) diff --git a/tests/widgets/citizen-claim-widget/inviteRules.spec.ts b/tests/widgets/citizen-claim-widget/inviteRules.spec.ts new file mode 100644 index 00000000..4cf1be31 --- /dev/null +++ b/tests/widgets/citizen-claim-widget/inviteRules.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test' +import { zeroAddress, zeroHash, type Address } from 'viem' +import { canAttachInviter, getMyInviteCode, hasCollectableInvitees } from '../../../packages/citizen-claim-widget/src/inviteRules' + +const address = '0x1234567890123456789012345678901234567890' as Address +const inviterCode = `0x${'01'.repeat(32)}` as `0x${string}` + +function createUser(overrides = {}) { + return { + invitedBy: zeroAddress, + inviteCode: inviterCode, + bountyPaid: false, + level: 0n, + levelStarted: 0n, + totalApprovedInvites: 0n, + totalEarned: 0n, + joinedAt: 1n, + bountyAtJoin: 0n, + ...overrides, + } +} + +test('code-only registered users attach an inviter with their original code', async () => { + const user = createUser() + let resolveCalls = 0 + + expect(canAttachInviter(user)).toBe(true) + await expect( + getMyInviteCode(user, async () => { + resolveCalls += 1 + return zeroHash + }), + ).resolves.toBe(inviterCode) + expect(resolveCalls).toBe(0) +}) + +test('paid or already-attached users cannot attach an inviter', () => { + expect(canAttachInviter(createUser({ bountyPaid: true }))).toBe(false) + expect(canAttachInviter(createUser({ invitedBy: address }))).toBe(false) +}) + +test('users without a personal code yet can still attach a deferred inviter', () => { + // Matches GoodWallet's InvCodeBox and the InvitesV2 join() contract call, which + // creates the caller's own code and attaches the inviter in one transaction — + // having a code first is not a precondition of the deferred-inviter rule. + expect(canAttachInviter(createUser({ inviteCode: zeroHash, joinedAt: 0n }))).toBe(true) +}) + +test('rewards are collectable only when the SDK reports collectable invitees', () => { + expect(hasCollectableInvitees([])).toBe(false) + expect(hasCollectableInvitees([address])).toBe(true) +}) diff --git a/tests/widgets/citizen-claim-widget/states.spec.ts b/tests/widgets/citizen-claim-widget/states.spec.ts index 251c9a07..9e6ac2b7 100644 --- a/tests/widgets/citizen-claim-widget/states.spec.ts +++ b/tests/widgets/citizen-claim-widget/states.spec.ts @@ -40,11 +40,7 @@ async function gotoStory(page: Page): Promise { } /** Poll the page until any of the given strings appears in the body text. */ -async function waitForText( - page: Page, - patterns: string[], - timeoutMs = 40_000, -): Promise { +async function waitForText(page: Page, patterns: string[], timeoutMs = 40_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { const text = await page.evaluate(() => document.body.innerText) @@ -61,9 +57,15 @@ test('CitizenClaimWidget shows loading spinner on mount', async ({ page }) => { // Route all RPC calls to hang (never respond, never abort). // This keeps the adapter in the `loading` state indefinitely, giving the Storybook // bundle time to fully mount even on a cold first run before we screenshot. - await page.route('https://forno.celo.org/**', () => { /* hang — never fulfill */ }) - await page.route('https://rpc.fuse.io/**', () => { /* hang — never fulfill */ }) - await page.route('https://rpc.ankr.com/**', () => { /* hang — never fulfill */ }) + await page.route('https://forno.celo.org/**', () => { + /* hang — never fulfill */ + }) + await page.route('https://rpc.fuse.io/**', () => { + /* hang — never fulfill */ + }) + await page.route('https://rpc.ankr.com/**', () => { + /* hang — never fulfill */ + }) await gotoStory(page) @@ -167,6 +169,29 @@ test('CitizenClaimWidget Retry button re-triggers the adapter', async ({ page }) expect(afterClickText).toBeTruthy() }) +// ─── Invite Rewards tab ────────────────────────────────────────────────────── +test('CitizenClaimWidget opens the Invite Rewards entry point', async ({ page }) => { + await gotoStory(page) + + // The custodial EIP-1193 fixture starts on Celo; the widget should request + // the XDC switch before exposing the connected flow. + await expect(page.getByText('XDC', { exact: true })).toBeVisible() + + const inviteTab = page.getByText('Invite Rewards', { exact: true }) + await expect(inviteTab).toBeVisible() + await inviteTab.click() + + // "How it works" now opens in a Drawer (see InviteRewardsQA.stories.tsx), so its + // trigger button is the reliable, unambiguous target rather than the text itself, + // which also appears as the (closed, off-screen) Drawer's own heading. + await expect(page.getByRole('button', { name: 'How it works' })).toBeVisible() + + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-18-invite-tab-entrypoint.png', + fullPage: true, + }) +}) + test('CitizenClaimWidget claimExecution claimAll reports per-chain success and failure', async ({ page, }) => { diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-01-loading.png b/tests/widgets/citizen-claim-widget/test-results/ccw-01-loading.png index b152f44c..1e614b8d 100644 Binary files a/tests/widgets/citizen-claim-widget/test-results/ccw-01-loading.png and b/tests/widgets/citizen-claim-widget/test-results/ccw-01-loading.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-02-not-whitelisted.png b/tests/widgets/citizen-claim-widget/test-results/ccw-02-not-whitelisted.png index 43c06c4a..b8c81530 100644 Binary files a/tests/widgets/citizen-claim-widget/test-results/ccw-02-not-whitelisted.png and b/tests/widgets/citizen-claim-widget/test-results/ccw-02-not-whitelisted.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-03-error.png b/tests/widgets/citizen-claim-widget/test-results/ccw-03-error.png index cbbf1067..71b31a8f 100644 Binary files a/tests/widgets/citizen-claim-widget/test-results/ccw-03-error.png and b/tests/widgets/citizen-claim-widget/test-results/ccw-03-error.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-04-retry-clicked.png b/tests/widgets/citizen-claim-widget/test-results/ccw-04-retry-clicked.png index cbbf1067..1e614b8d 100644 Binary files a/tests/widgets/citizen-claim-widget/test-results/ccw-04-retry-clicked.png and b/tests/widgets/citizen-claim-widget/test-results/ccw-04-retry-clicked.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-05-custodial-claim-all-contract.png b/tests/widgets/citizen-claim-widget/test-results/ccw-05-custodial-claim-all-contract.png index 0227343d..9115ba9b 100644 Binary files a/tests/widgets/citizen-claim-widget/test-results/ccw-05-custodial-claim-all-contract.png and b/tests/widgets/citizen-claim-widget/test-results/ccw-05-custodial-claim-all-contract.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-06-invite-loading.png b/tests/widgets/citizen-claim-widget/test-results/ccw-06-invite-loading.png new file mode 100644 index 00000000..f5fa6ccd Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-06-invite-loading.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-07-invite-error.png b/tests/widgets/citizen-claim-widget/test-results/ccw-07-invite-error.png new file mode 100644 index 00000000..2eca3bcc Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-07-invite-error.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-08-invite-collectable.png b/tests/widgets/citizen-claim-widget/test-results/ccw-08-invite-collectable.png new file mode 100644 index 00000000..c8f3797e Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-08-invite-collectable.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-09-invite-pending-only.png b/tests/widgets/citizen-claim-widget/test-results/ccw-09-invite-pending-only.png new file mode 100644 index 00000000..ffa42510 Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-09-invite-pending-only.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-10-invite-join-success.png b/tests/widgets/citizen-claim-widget/test-results/ccw-10-invite-join-success.png new file mode 100644 index 00000000..6fe7c300 Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-10-invite-join-success.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-11-invite-collect-error.png b/tests/widgets/citizen-claim-widget/test-results/ccw-11-invite-collect-error.png new file mode 100644 index 00000000..b5aa5562 Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-11-invite-collect-error.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-12-invite-deferred-join-flow.png b/tests/widgets/citizen-claim-widget/test-results/ccw-12-invite-deferred-join-flow.png new file mode 100644 index 00000000..6fe7c300 Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-12-invite-deferred-join-flow.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-13-invite-collection-ready-flow.png b/tests/widgets/citizen-claim-widget/test-results/ccw-13-invite-collection-ready-flow.png new file mode 100644 index 00000000..96604c22 Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-13-invite-collection-ready-flow.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-14-invite-mobile-collectable.png b/tests/widgets/citizen-claim-widget/test-results/ccw-14-invite-mobile-collectable.png new file mode 100644 index 00000000..1290613d Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-14-invite-mobile-collectable.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-15-invite-how-it-works-drawer.png b/tests/widgets/citizen-claim-widget/test-results/ccw-15-invite-how-it-works-drawer.png new file mode 100644 index 00000000..e797696a Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-15-invite-how-it-works-drawer.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-16-invite-empty-state.png b/tests/widgets/citizen-claim-widget/test-results/ccw-16-invite-empty-state.png new file mode 100644 index 00000000..e8232a96 Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-16-invite-empty-state.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-17-invite-not-whitelisted.png b/tests/widgets/citizen-claim-widget/test-results/ccw-17-invite-not-whitelisted.png new file mode 100644 index 00000000..78a4ba72 Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-17-invite-not-whitelisted.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-18-invite-tab-entrypoint.png b/tests/widgets/citizen-claim-widget/test-results/ccw-18-invite-tab-entrypoint.png new file mode 100644 index 00000000..bef0eddd Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-18-invite-tab-entrypoint.png differ