Skip to content
Closed
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
@@ -1,6 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react'
import { CitizenClaimWidget } from '@goodwidget/citizen-claim-widget'
import { CustodialLocalFixtureStory } from '../helpers/citizenClaimWidgetStories'
import {
CustodialLocalFixtureStory,
InviteCollectableStory,
InviteErrorStory,
InvitePendingStory,
InviteReadyStory,
InviteSuccessStory,
} from '../helpers/citizenClaimWidgetStories'

const meta: Meta<typeof CitizenClaimWidget> = {
title: 'QA/CitizenClaimWidget/Runtime Fixtures',
Expand All @@ -15,3 +22,23 @@ type Story = StoryObj<typeof meta>
export const CustodialLocalFixture: Story = {
render: () => <CustodialLocalFixtureStory />,
}

export const InviteReady: Story = {
render: () => <InviteReadyStory />,
}

export const InvitePending: Story = {
render: () => <InvitePendingStory />,
}

export const InviteCollectable: Story = {
render: () => <InviteCollectableStory />,
}

export const InviteSuccess: Story = {
render: () => <InviteSuccessStory />,
}

export const InviteError: Story = {
render: () => <InviteErrorStory />,
}
149 changes: 148 additions & 1 deletion examples/storybook/src/stories/helpers/citizenClaimWidgetStories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { useEffect, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { YStack } from '@goodwidget/ui'
import { CitizenClaimWidget } from '@goodwidget/citizen-claim-widget'
import type { InviteAdapterResult, InviteState } from '@goodwidget/citizen-claim-widget'
import { stringToHex, zeroAddress, type Address } from 'viem'
import {
getInjectedEip1193Provider,
isInjectedProviderUsable,
Expand All @@ -10,9 +12,11 @@ import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193'
function CitizenClaimWidgetStoryShell({
provider,
dataTestId,
inviteAdapterFactory,
}: {
provider: unknown
dataTestId: string
inviteAdapterFactory?: () => InviteAdapterResult
}) {
// const [activeTab, setActiveTab] = useState<CitizenClaimTab>('claim')
const [activeChainId, setActiveChainId] = useState<number | null>(null)
Expand Down Expand Up @@ -46,10 +50,153 @@ function CitizenClaimWidgetStoryShell({
environment="development"
data-testid={dataTestId}
chainId={activeChainId ?? 42220}
inviteAdapterFactory={inviteAdapterFactory}
/>
)
}

const CURRENT_ADDRESS = '0x1111111111111111111111111111111111111111' as Address
const INVITER_ADDRESS = '0x2222222222222222222222222222222222222222' as Address
const APPROVED_ADDRESS = '0x3333333333333333333333333333333333333333' as Address
const WAITING_ADDRESS = '0x4444444444444444444444444444444444444444' as Address
const COLLECTABLE_ADDRESS = '0x5555555555555555555555555555555555555555' as Address
const PERSONAL_CODE = stringToHex('GOOD-ABB', { size: 32 })

type InviteFixtureKind = 'ready' | 'pending' | 'collectable' | 'success' | 'error'

function createInviteFixtureState(kind: InviteFixtureKind): InviteState {
const hasWaiting = kind !== 'ready'
const hasCollectable = kind === 'collectable'
const pendingInvitees = [
...(hasWaiting ? [WAITING_ADDRESS] : []),
...(hasCollectable ? [COLLECTABLE_ADDRESS] : []),
]

return {
status: 'ready',
address: CURRENT_ADDRESS,
chainId: 42220,
user: {
invitedBy: zeroAddress,
inviteCode: PERSONAL_CODE,
bountyPaid: false,
level: 1n,
levelStarted: 1n,
totalApprovedInvites: 2n,
totalEarned: 15n * 10n ** 18n,
joinedAt: 1n,
bountyAtJoin: 5n * 10n ** 18n,
},
level: {
toNext: 5n,
bounty: 5n * 10n ** 18n,
daysToComplete: 30n,
},
invitees: [APPROVED_ADDRESS, ...pendingInvitees],
pendingInvitees,
collectableInvitees: hasCollectable ? [COLLECTABLE_ADDRESS] : [],
eligibility: Object.fromEntries(
pendingInvitees.map((invitee) => [
invitee,
{
isActive: true,
inviteeWhitelisted: true,
inviterWhitelisted: true,
minimumClaims: 3,
minimumDays: 7,
reverificationDue: false,
},
]),
),
selfEligibility: {
isActive: true,
inviteeWhitelisted: true,
inviterWhitelisted: null,
minimumClaims: 3,
minimumDays: 7,
reverificationDue: false,
},
error: kind === 'error' ? 'Invite transaction failed. Please retry.' : null,
success: kind === 'success' ? 'Joined inviter successfully.' : null,
}
}

function InviteFixtureStory({ kind }: { kind: InviteFixtureKind }) {
const [state, setState] = useState<InviteState>(() => createInviteFixtureState(kind))

const refresh = useCallback(async () => undefined, [])
const validateCode = useCallback(async (code: string) => {
if (code.trim() !== 'FRIEND-42') throw new Error('This invite code was not found.')
return code.trim()
}, [])
const join = useCallback(async (inviterCode?: string) => {
setState((current) => ({ ...current, status: 'joining', error: null, success: null }))
await Promise.resolve()
setState((current) => ({
...current,
status: 'ready',
user: current.user
? { ...current.user, invitedBy: inviterCode ? INVITER_ADDRESS : current.user.invitedBy }
: null,
success: inviterCode ? 'Joined inviter successfully.' : 'Invite code created successfully.',
}))
}, [])
const collectAll = useCallback(async () => {
setState((current) => ({ ...current, status: 'collecting', error: null, success: null }))
await Promise.resolve()
setState((current) => ({
...current,
status: 'ready',
pendingInvitees: current.pendingInvitees.filter(
(invitee) => invitee.toLowerCase() !== COLLECTABLE_ADDRESS.toLowerCase(),
),
collectableInvitees: [],
user: current.user
? {
...current.user,
totalApprovedInvites: current.user.totalApprovedInvites + 1n,
totalEarned: current.user.totalEarned + 5n * 10n ** 18n,
}
: null,
success: 'Invite rewards collected successfully.',
}))
}, [])

const adapter = useMemo<InviteAdapterResult>(
() => ({ state, actions: { refresh, validateCode, join, collectAll } }),
[collectAll, join, refresh, state, validateCode],
)
const adapterFactory = useCallback(() => adapter, [adapter])

return (
<CitizenClaimWidgetStoryShell
provider={null}
dataTestId={`CitizenClaimWidget-invite-${kind}`}
inviteAdapterFactory={adapterFactory}
/>
)
}

export function InviteReadyStory() {
return <InviteFixtureStory kind="ready" />
}

export function InvitePendingStory() {
return <InviteFixtureStory kind="pending" />
}

export function InviteCollectableStory() {
return <InviteFixtureStory kind="collectable" />
}

export function InviteSuccessStory() {
return <InviteFixtureStory kind="success" />
}

export function InviteErrorStory() {
return <InviteFixtureStory kind="error" />
}

export function InjectedWalletStory() {
const injectedProvider = getInjectedEip1193Provider()
const usableProvider = isInjectedProviderUsable(injectedProvider)
Expand Down
26 changes: 26 additions & 0 deletions packages/citizen-claim-widget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,29 @@ 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: <code>
<current-page-url>
```

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. The reward
summary uses the protocol-provided approved and total-earned values. Pending
invitee addresses remain visible with a waiting or collectable status, and the
collection control is enabled only when `getCollectableInvitees()` reports at
least one eligible pending invitee. Join and collection results remain visible
after the shared runtime refreshes.
2 changes: 2 additions & 0 deletions packages/citizen-claim-widget/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,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": {
Expand Down
62 changes: 35 additions & 27 deletions packages/citizen-claim-widget/src/CitizenClaimWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
} from '@goodwidget/ui'
import { SupportedChains } from '@goodsdks/citizen-sdk'
import { useCitizenClaimAdapter } from './adapter'
import { ClaimInviteJoinCard, InviteRewards } from './InviteRewards'
import { InviteRuntimeProvider } from './inviteAdapter'
import type {
CitizenClaimWidgetProps,
CitizenClaimWidgetSuccessDetail,
Expand Down Expand Up @@ -499,6 +501,7 @@ function CitizenClaimInner({ environment, onClaimSuccess, onClaimError }: Citize
/>
</ClaimDailyStatsRow>
</ClaimDailyStats>
<ClaimInviteJoinCard />
</YStack>
)
}
Expand Down Expand Up @@ -529,6 +532,7 @@ export function CitizenClaimWidget({
defaultTheme = 'dark',
onClaimSuccess,
onClaimError,
inviteAdapterFactory,
}: CitizenClaimWidgetProps) {
const [activeTab, setActiveTab] = useState<CitizenClaimTab>('claim')

Expand All @@ -539,33 +543,37 @@ export function CitizenClaimWidget({
themeOverrides={themeOverrides}
defaultTheme={defaultTheme}
>
<WidgetTabs
tabs={[
{ id: 'claim', label: 'Claim' },
{ id: 'invite-rewards', label: 'Invite Rewards' },
{ id: 'news-feed', label: 'News' },
]}
activeTab={activeTab}
onTabChange={(tabId: string) => setActiveTab(tabId as CitizenClaimTab)}
chainId={chainId ?? 42220}
/>
{activeTab === 'claim' ? (
<>
<CitizenClaimInner
environment={environment}
// walletMode={walletMode}
onClaimSuccess={onClaimSuccess}
onClaimError={onClaimError}
/>
<ToastContainer />
</>
) : (
<Card width="100%">
<YStack alignItems="center" justifyContent="center" minHeight={320}>
<Text variant="body">Widget coming soon</Text>
</YStack>
</Card>
)}
<InviteRuntimeProvider environment={environment} adapterFactory={inviteAdapterFactory}>
<WidgetTabs
tabs={[
{ id: 'claim', label: 'Claim' },
{ id: 'invite-rewards', label: 'Invite Rewards' },
{ id: 'news-feed', label: 'News' },
]}
activeTab={activeTab}
onTabChange={(tabId: string) => setActiveTab(tabId as CitizenClaimTab)}
chainId={chainId ?? 42220}
/>
{activeTab === 'claim' ? (
<>
<CitizenClaimInner
environment={environment}
// walletMode={walletMode}
onClaimSuccess={onClaimSuccess}
onClaimError={onClaimError}
/>
<ToastContainer />
</>
) : activeTab === 'invite-rewards' ? (
<InviteRewards />
) : (
<Card width="100%">
<YStack alignItems="center" justifyContent="center" minHeight={320}>
<Text variant="body">Widget coming soon</Text>
</YStack>
</Card>
)}
</InviteRuntimeProvider>
</GoodWidgetProvider>
)
}
Loading