Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<typeof InviteRewardsFixtureStory> = {
title: 'QA/CitizenClaimWidget/Invite Rewards Fixtures',
component: InviteRewardsFixtureStory,
tags: ['autodocs', 'qa'],
parameters: { layout: 'padded' },
}

export default meta
type Story = StoryObj<typeof meta>

// ─── Static states ─────────────────────────────────────────────────────────

export const Loading: Story = {
render: () => <InviteRewardsFixtureStory fixture="loading" dataTestId="InviteRewards-loading" />,
}

export const Disconnected: Story = {
render: () => <InviteRewardsFixtureStory fixture="disconnected" dataTestId="InviteRewards-disconnected" />,
}

export const UnsupportedNetwork: Story = {
render: () => <InviteRewardsFixtureStory fixture="unsupported" dataTestId="InviteRewards-unsupported" />,
}

export const ErrorNoData: Story = {
render: () => <InviteRewardsFixtureStory fixture="errorNoData" dataTestId="InviteRewards-error" />,
}

export const Empty: Story = {
render: () => <InviteRewardsFixtureStory fixture="empty" dataTestId="InviteRewards-empty" />,
}

export const NotWhitelisted: Story = {
render: () => (
<InviteRewardsFixtureStory fixture="notWhitelisted" dataTestId="InviteRewards-not-whitelisted" />
),
}

export const PendingOnly: Story = {
render: () => <InviteRewardsFixtureStory fixture="pendingOnly" dataTestId="InviteRewards-pending" />,
}

export const Collectable: Story = {
render: () => <InviteRewardsFixtureStory fixture="collectable" dataTestId="InviteRewards-collectable" />,
}

export const JoinSuccessAfterCardHidden: Story = {
render: () => <InviteRewardsFixtureStory fixture="joinSuccess" dataTestId="InviteRewards-join-success" />,
}

export const CollectSuccess: Story = {
render: () => <InviteRewardsFixtureStory fixture="collectSuccess" dataTestId="InviteRewards-collect-success" />,
}

export const CollectError: Story = {
render: () => <InviteRewardsFixtureStory fixture="collectError" dataTestId="InviteRewards-collect-error" />,
}

// ─── Interactive flows — demonstrate the deferred-inviter and ───────────────
// collection-ready paths end-to-end without a live wallet/contract.

export const DeferredInviterJoinFlow: Story = {
render: () => (
<InviteRewardsFixtureStory fixture="collectable" dataTestId="InviteRewards-deferred-join-flow" />
),
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: () => (
<InviteRewardsFixtureStory fixture="collectable" dataTestId="InviteRewards-collection-ready-flow" />
),
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: () => <InviteRewardsFixtureStory fixture="pendingOnly" dataTestId="InviteRewards-not-ready-flow" />,
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()
},
}
258 changes: 258 additions & 0 deletions examples/storybook/src/stories/helpers/inviteRewardsStories.tsx
Original file line number Diff line number Diff line change
@@ -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<NonNullable<InviteState['user']>> = {}) {
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> = {}): 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<InviteState>(initialState)

const refresh = useCallback(async () => {}, [])

const validateCode = useCallback(async (code: string): Promise<string> => {
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 (
<InviteRuntimeContext.Provider value={runtime}>{children}</InviteRuntimeContext.Provider>
)
}

export function InviteRewardsFixtureStory({
fixture,
options,
dataTestId,
}: {
fixture: keyof typeof inviteRewardsFixtures
options?: MockInviteRuntimeOptions
dataTestId: string
}) {
return (
<GoodWidgetProvider defaultTheme="dark">
<div data-testid={dataTestId} style={{ maxWidth: 480 }}>
<MockInviteRuntimeProvider initialState={inviteRewardsFixtures[fixture]()} options={options}>
<InviteRewards />
</MockInviteRuntimeProvider>
</div>
</GoodWidgetProvider>
)
}
Loading