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/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 896c6baf..17c47255 100644 --- a/packages/citizen-claim-widget/README.md +++ b/packages/citizen-claim-widget/README.md @@ -14,7 +14,7 @@ not be changed for this migration. ## Invite Rewards -The widget directly uses `@goodsdks/invite-sdk@1.0.1` with the provider-first +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. @@ -32,6 +32,35 @@ 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. Pending -invitees show their whitelist and minimum-days/minimum-claims diagnostics before -the collection control is enabled. +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/src/InviteRewards.tsx b/packages/citizen-claim-widget/src/InviteRewards.tsx index ff5054f8..be0151c9 100644 --- a/packages/citizen-claim-widget/src/InviteRewards.tsx +++ b/packages/citizen-claim-widget/src/InviteRewards.tsx @@ -1,18 +1,56 @@ import React, { useCallback, useState } from 'react' import { + Alert, + Badge, + BadgeText, Button, ButtonText, Card, + Drawer, Heading, + Icon, Input, - Separator, Spinner, Text, + XStack, YStack, } from '@goodwidget/ui' import { zeroHash } from 'viem' import { decodeInviteCode, formatInviteBounty, useInviteRuntime } from './inviteAdapter' -import { canAttachInviter, hasCollectableInvitees } from './inviteRules' +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() @@ -35,16 +73,16 @@ function InviteJoinCard({ compact = false }: { compact?: boolean }) { if (!canJoin || state.status === 'disconnected' || state.status === 'unsupported') return null return ( - - Have an invite code? + + Use invite code Enter your inviter's code to join their invite rewards. ) => setCode(event.target.value)} - placeholder="Invite code" + placeholder="Place your invite code here" autoCapitalize="none" /> - {(validationError || state.error) && {validationError ?? state.error}} + {validationError && } @@ -54,7 +92,7 @@ function InviteJoinCard({ compact = false }: { compact?: boolean }) { function InviteShareCard() { const { state, actions } = useInviteRuntime() - const [shareFeedback, setShareFeedback] = useState(null) + const [shareFeedback, setShareFeedback] = useState<{ message: string; ok: boolean } | null>(null) const hasCode = state.user?.inviteCode !== zeroHash const share = useCallback(async () => { @@ -68,9 +106,9 @@ function InviteShareCard() { } else { await navigator.clipboard.writeText(message) } - setShareFeedback('Invite message ready to send.') + setShareFeedback({ message: 'Invite message ready to send.', ok: true }) } catch { - setShareFeedback('Could not copy the invite message. Please retry.') + setShareFeedback({ message: 'Could not copy the invite message. Please retry.', ok: false }) } }, [hasCode, state.user]) @@ -79,29 +117,66 @@ function InviteShareCard() { if (!hasCode) { const isVerified = state.selfEligibility?.inviteeWhitelisted return ( - - Create your invite code - - {isVerified - ? 'Create a code to invite friends to claim G$.' - : 'Verify your identity before creating an invite code.'} - - + + 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 && {shareFeedback}} + {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$ ) } @@ -111,23 +186,40 @@ function InviteeStatus() { 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} approved invitee{state.invitees.length === 1 ? '' : 's'} - {state.pendingInvitees.length} pending reward{state.pendingInvitees.length === 1 ? '' : 's'} - {state.level && ( + - Earn {formatInviteBounty(state.level.bounty, state.chainId)} G$ per eligible invitee. + {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) => ( + + ))} + )} - {state.pendingInvitees.map((invitee) => { - const details = state.eligibility[invitee] - const status = details?.inviteeWhitelisted - ? `Waiting for ${details.minimumDays} days and ${details.minimumClaims} claims.` - : 'Waiting for identity verification.' - return {`${invitee.slice(0, 6)}…${invitee.slice(-4)} — ${status}`} - })} @@ -166,10 +258,10 @@ export function InviteRewards() { ) } - if (state.status === 'error') { + if (state.status === 'error' && !state.user) { return ( - {state.error} + ) @@ -177,17 +269,35 @@ export function InviteRewards() { return ( - + Invite Rewards - Invite friends to claim GoodDollar and earn G$ rewards together. - - 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. + + 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$. + + + )} + - {state.success && {state.success}} + {/* 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 && } ) } diff --git a/packages/citizen-claim-widget/src/index.ts b/packages/citizen-claim-widget/src/index.ts index 0d531361..998aabb7 100644 --- a/packages/citizen-claim-widget/src/index.ts +++ b/packages/citizen-claim-widget/src/index.ts @@ -28,14 +28,26 @@ export { 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 665a5c3b..d805ac16 100644 --- a/packages/citizen-claim-widget/src/integration.ts +++ b/packages/citizen-claim-widget/src/integration.ts @@ -1,7 +1,7 @@ export const citizenClaimIntegration = { id: 'citizen-claim', sdk: '@goodsdks/citizen-sdk', - inviteSdk: '@goodsdks/invite-sdk@1.0.1', + inviteSdk: '@goodsdks/invite-sdk@1.0.3', capabilitySource: 'citizenSdkCapabilities', uses: [ 'whitelistStatus', diff --git a/packages/citizen-claim-widget/src/inviteAdapter.ts b/packages/citizen-claim-widget/src/inviteAdapter.ts index b288c993..9b82e9b3 100644 --- a/packages/citizen-claim-widget/src/inviteAdapter.ts +++ b/packages/citizen-claim-widget/src/inviteAdapter.ts @@ -60,7 +60,13 @@ export interface InviteAdapterResult { actions: InviteActions } -const InviteRuntimeContext = createContext(null) +/** + * 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', @@ -116,6 +122,122 @@ export async function generateInviteCode( 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) { @@ -180,32 +302,13 @@ export function useInviteAdapter( try { const sdk = await getSdk() if (!sdk) throw new Error('Unable to initialize invite rewards.') - const user = await sdk.getUser(address as Address) - const [level, invitees, pendingInvitees, collectableInvitees, selfEligibility] = await Promise.all([ - sdk.getLevel(Number(user.level)), - sdk.getInvitees(address as Address), - sdk.getPendingInvitees(address as Address), - sdk.getCollectableInvitees(address as Address), - sdk.checkEligibilityDetails(address as Address), - ]) - const eligibilityEntries = await Promise.all( - pendingInvitees.map(async (invitee) => { - const { details } = await sdk.checkEligibilityDetails(invitee) - return [invitee, details] as const - }), - ) + const snapshot = await loadInviteSnapshot(sdk, address as Address) setState({ status: 'ready', address: address as Address, chainId, - user, - level, - invitees, - pendingInvitees, - collectableInvitees, - eligibility: Object.fromEntries(eligibilityEntries), - selfEligibility: selfEligibility.details, + ...snapshot, error: null, success: null, }) @@ -251,18 +354,21 @@ export function useInviteAdapter( setState((current) => ({ ...current, status: 'joining', error: null, success: null })) try { const validatedInviterCode = inviterCode ? await validateCode(inviterCode) : undefined - const ownCode = await getMyInviteCode( + const outcome = await performJoin( + sdk, + address as Address, state.user, - async () => encodeInviteCode(await generateInviteCode(address as Address, sdk.resolveCode.bind(sdk))), - ) - await sdk.join( - ownCode, - validatedInviterCode ? encodeInviteCode(validatedInviterCode) : zeroHash, + validatedInviterCode ? encodeInviteCode(validatedInviterCode) : undefined, ) - await refresh() + // 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, - success: validatedInviterCode ? 'Joined inviter successfully.' : 'Invite code created successfully.', + status: 'ready', + ...(outcome.snapshot ?? {}), + error: null, + success: outcome.successMessage, })) } catch (error: unknown) { setState((current) => ({ @@ -273,20 +379,24 @@ export function useInviteAdapter( })) } }, - [address, getSdk, refresh, validateCode], + [address, getSdk, state.user, validateCode], ) const collectAll = useCallback(async () => { const sdk = await getSdk() - if (!sdk) return + if (!sdk || !address) return setState((current) => ({ ...current, status: 'collecting', error: null, success: null })) try { - const results = await sdk.collectAllBounties() - await refresh() + 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, - success: results.length ? 'Invite rewards collected successfully.' : 'No rewards were collected.', + status: 'ready', + ...(outcome.snapshot ?? {}), + error: null, + success: outcome.successMessage, })) } catch (error: unknown) { setState((current) => ({ @@ -296,7 +406,7 @@ export function useInviteAdapter( success: null, })) } - }, [getSdk, refresh]) + }, [address, getSdk]) return useMemo( () => ({ state, actions: { refresh, join, collectAll, validateCode } }), diff --git a/packages/citizen-claim-widget/src/inviteRules.ts b/packages/citizen-claim-widget/src/inviteRules.ts index 6831bc1a..349af636 100644 --- a/packages/citizen-claim-widget/src/inviteRules.ts +++ b/packages/citizen-claim-widget/src/inviteRules.ts @@ -1,13 +1,17 @@ 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 && - user.inviteCode !== zeroHash && - isAddressEqual(user.invitedBy, zeroAddress) && - !user.bountyPaid, - ) + return Boolean(user && isAddressEqual(user.invitedBy, zeroAddress) && !user.bountyPaid) } export async function getMyInviteCode( @@ -21,3 +25,8 @@ export async function getMyInviteCode( 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/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..b47e989a --- /dev/null +++ b/tests/widgets/citizen-claim-widget/inviteRewards.spec.ts @@ -0,0 +1,240 @@ +/** + * 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/i)).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 index 0873bf27..4cf1be31 100644 --- a/tests/widgets/citizen-claim-widget/inviteRules.spec.ts +++ b/tests/widgets/citizen-claim-widget/inviteRules.spec.ts @@ -37,7 +37,13 @@ test('code-only registered users attach an inviter with their original code', as test('paid or already-attached users cannot attach an inviter', () => { expect(canAttachInviter(createUser({ bountyPaid: true }))).toBe(false) expect(canAttachInviter(createUser({ invitedBy: address }))).toBe(false) - expect(canAttachInviter(createUser({ inviteCode: zeroHash, joinedAt: 0n }))).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', () => { diff --git a/tests/widgets/citizen-claim-widget/states.spec.ts b/tests/widgets/citizen-claim-widget/states.spec.ts index ae4a3226..2be2b4d3 100644 --- a/tests/widgets/citizen-claim-widget/states.spec.ts +++ b/tests/widgets/citizen-claim-widget/states.spec.ts @@ -173,7 +173,10 @@ test('CitizenClaimWidget opens the Invite Rewards entry point', async ({ page }) await expect(inviteTab).toBeVisible() await inviteTab.click() - await expect(page.getByText('How it works', { exact: true })).toBeVisible() + // "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-05-invite-rewards.png', diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-05-invite-rewards.png b/tests/widgets/citizen-claim-widget/test-results/ccw-05-invite-rewards.png index e0a7fb65..ea652e66 100644 Binary files a/tests/widgets/citizen-claim-widget/test-results/ccw-05-invite-rewards.png and b/tests/widgets/citizen-claim-widget/test-results/ccw-05-invite-rewards.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..1b62a41a 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..03bbe0df 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..5e5f561e 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..3f4d6265 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..018b9a03 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..0d75081b 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..018b9a03 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..6e40b175 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..a71cf9d6 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..2e20d0b2 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..dfd2a677 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..6f85cbd7 Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-17-invite-not-whitelisted.png differ