diff --git a/examples/storybook/src/stories/citizen-claim-widget/CitizenClaimWidgetQA.stories.tsx b/examples/storybook/src/stories/citizen-claim-widget/CitizenClaimWidgetQA.stories.tsx index a06642d0..1595707c 100644 --- a/examples/storybook/src/stories/citizen-claim-widget/CitizenClaimWidgetQA.stories.tsx +++ b/examples/storybook/src/stories/citizen-claim-widget/CitizenClaimWidgetQA.stories.tsx @@ -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 = { title: 'QA/CitizenClaimWidget/Runtime Fixtures', @@ -15,3 +22,23 @@ type Story = StoryObj export const CustodialLocalFixture: Story = { render: () => , } + +export const InviteReady: Story = { + render: () => , +} + +export const InvitePending: Story = { + render: () => , +} + +export const InviteCollectable: Story = { + render: () => , +} + +export const InviteSuccess: Story = { + render: () => , +} + +export const InviteError: Story = { + render: () => , +} diff --git a/examples/storybook/src/stories/helpers/citizenClaimWidgetStories.tsx b/examples/storybook/src/stories/helpers/citizenClaimWidgetStories.tsx index 2c96adbc..9f827e16 100644 --- a/examples/storybook/src/stories/helpers/citizenClaimWidgetStories.tsx +++ b/examples/storybook/src/stories/helpers/citizenClaimWidgetStories.tsx @@ -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, @@ -10,9 +12,11 @@ import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' function CitizenClaimWidgetStoryShell({ provider, dataTestId, + inviteAdapterFactory, }: { provider: unknown dataTestId: string + inviteAdapterFactory?: () => InviteAdapterResult }) { // const [activeTab, setActiveTab] = useState('claim') const [activeChainId, setActiveChainId] = useState(null) @@ -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(() => 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( + () => ({ state, actions: { refresh, validateCode, join, collectAll } }), + [collectAll, join, refresh, state, validateCode], + ) + const adapterFactory = useCallback(() => adapter, [adapter]) + + return ( + + ) +} + +export function InviteReadyStory() { + return +} + +export function InvitePendingStory() { + return +} + +export function InviteCollectableStory() { + return +} + +export function InviteSuccessStory() { + return +} + +export function InviteErrorStory() { + return +} + export function InjectedWalletStory() { const injectedProvider = getInjectedEip1193Provider() const usableProvider = isInjectedProviderUsable(injectedProvider) diff --git a/packages/citizen-claim-widget/README.md b/packages/citizen-claim-widget/README.md index 3f1dea6c..d90c6b83 100644 --- a/packages/citizen-claim-widget/README.md +++ b/packages/citizen-claim-widget/README.md @@ -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: + +``` + +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. diff --git a/packages/citizen-claim-widget/package.json b/packages/citizen-claim-widget/package.json index 3aafce86..3fb0fedb 100644 --- a/packages/citizen-claim-widget/package.json +++ b/packages/citizen-claim-widget/package.json @@ -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": { diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx index 8876c7a2..0bff1e84 100644 --- a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx +++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx @@ -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, @@ -499,6 +501,7 @@ function CitizenClaimInner({ environment, onClaimSuccess, onClaimError }: Citize /> + ) } @@ -529,6 +532,7 @@ export function CitizenClaimWidget({ defaultTheme = 'dark', onClaimSuccess, onClaimError, + inviteAdapterFactory, }: CitizenClaimWidgetProps) { const [activeTab, setActiveTab] = useState('claim') @@ -539,33 +543,37 @@ export function CitizenClaimWidget({ themeOverrides={themeOverrides} defaultTheme={defaultTheme} > - setActiveTab(tabId as CitizenClaimTab)} - chainId={chainId ?? 42220} - /> - {activeTab === 'claim' ? ( - <> - - - - ) : ( - - - Widget coming soon - - - )} + + setActiveTab(tabId as CitizenClaimTab)} + chainId={chainId ?? 42220} + /> + {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..f29103a4 --- /dev/null +++ b/packages/citizen-claim-widget/src/InviteRewards.tsx @@ -0,0 +1,245 @@ +import React, { useCallback, useState } from 'react' +import { + Button, + ButtonText, + Card, + Heading, + Input, + Separator, + Spinner, + Text, + YStack, +} from '@goodwidget/ui' +import { zeroHash } from 'viem' +import { decodeInviteCode, formatInviteBounty, useInviteRuntime } from './inviteAdapter' +import { canAttachInviter, hasCollectableInvitees } from './inviteRules' + +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 ( + + Have an invite code? + Enter your inviter's code to join their invite rewards. + ) => setCode(event.target.value)} + placeholder="Invite code" + autoCapitalize="none" + /> + {validationError && {validationError}} + + + ) +} + +function InviteShareCard() { + const { state, actions } = useInviteRuntime() + const [shareFeedback, setShareFeedback] = useState(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('Invite message ready to send.') + } catch { + setShareFeedback('Could not copy the invite message. Please retry.') + } + }, [hasCode, state.user]) + + if (!state.user) return null + + 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.'} + + + + ) + } + + return ( + + Share your invite + Your invite code + {decodeInviteCode(state.user.inviteCode)} + + {shareFeedback && ( + + {shareFeedback} + + )} + + ) +} + +function InviteeStatus() { + const { state, actions } = useInviteRuntime() + const collectable = hasCollectableInvitees(state.collectableInvitees) + const isCollecting = state.status === 'collecting' + const approvedInvites = state.user?.totalApprovedInvites ?? 0n + const collectableAddresses = new Set( + state.collectableInvitees.map((invitee) => invitee.toLowerCase()), + ) + + return ( + + Your invite rewards + + {approvedInvites.toString()} approved invite{approvedInvites === 1n ? '' : 's'} + + + {state.pendingInvitees.length} pending reward + {state.pendingInvitees.length === 1 ? '' : 's'} + + {state.collectableInvitees.length} ready to collect + {state.user && ( + + Total earned: {formatInviteBounty(state.user.totalEarned, state.chainId)} G$ + + )} + {state.level && ( + + Earn {formatInviteBounty(state.level.bounty, state.chainId)} G$ per eligible invitee. + + )} + {state.pendingInvitees.map((invitee) => { + const details = state.eligibility[invitee] + const status = collectableAddresses.has(invitee.toLowerCase()) + ? 'Ready to collect.' + : details?.inviteeWhitelisted + ? `Waiting for ${details.minimumDays} days and ${details.minimumClaims} claims.` + : 'Waiting for identity verification.' + return ( + {`${invitee.slice(0, 6)}…${invitee.slice(-4)} — ${status}`} + ) + })} + {state.pendingInvitees.length === 0 && ( + + No pending invite rewards. + + )} + + + ) +} + +/** 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') { + return ( + + {state.error} + + + ) + } + + 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. + + + {(state.success || state.error) && ( + + {state.error ?? state.success} + + )} + + + + + ) +} + +/** 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 19d6276c..2491ec9e 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -47,6 +47,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 } +} + // --------------------------------------------------------------------------- // humanReadableError — converts a raw SDK/viem error into a short, user-friendly // string. The full technical error is always logged to the console for debugging. @@ -203,16 +227,7 @@ export function useCitizenClaimAdapter( const createClientsForChain = 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], ) @@ -220,17 +235,7 @@ export function useCitizenClaimAdapter( const createClients = useCallback(() => { if (!chainId) return null if (!provider || !address) return null - const chain = CHAIN_CONFIGS[chainId] - if (!chain) return null - // chain may be undefined for unsupported networks; the SDK will throw clearly. - 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, chainId) }, [provider, address, chainId]) // --------------------------------------------------------------------------- diff --git a/packages/citizen-claim-widget/src/index.ts b/packages/citizen-claim-widget/src/index.ts index 8c229286..a6f61040 100644 --- a/packages/citizen-claim-widget/src/index.ts +++ b/packages/citizen-claim-widget/src/index.ts @@ -22,5 +22,21 @@ export type { export { useCitizenClaimAdapter } from './adapter' export type { UseCitizenClaimAdapterOptions } from './adapter' +// Shared InviteSDK adapter contract and deterministic code helpers. +export { + decodeInviteCode, + encodeInviteCode, + formatInviteBounty, + generateInviteCode, + useInviteAdapter, +} from './inviteAdapter' +export type { + InviteActions, + InviteAdapterFactory, + InviteAdapterResult, + InviteState, + InviteStatus, +} from './inviteAdapter' + // 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..665a5c3b 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.1', 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..6ad14a2c --- /dev/null +++ b/packages/citizen-claim-widget/src/inviteAdapter.ts @@ -0,0 +1,405 @@ +import { + createContext, + createElement, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react' +import bs58 from 'bs58' +import { + formatBounty, + InviteSDK, + InviteSDKError, + isSupportedChain, + type BountyResult, + 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, hasCollectableInvitees } 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 +} + +/** Deterministic adapter seam used by Storybook and component tests. */ +export type InviteAdapterFactory = () => InviteAdapterResult + +interface InviteJoinSdk { + resolveCode: (code: `0x${string}`) => Promise
+ join: (myCode: `0x${string}`, inviterCode: `0x${string}`) => Promise<`0x${string}`> +} + +interface InviteCollectionSdk { + collectAllBounties: () => Promise +} + +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.') +} + +export async function validateInviteCode( + sdk: Pick, + address: Address, + code: string, +): Promise { + 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 +} + +/** Shared write path used by the live adapter and deterministic adapter tests. */ +export async function submitInviteJoin({ + sdk, + address, + user, + inviterCode, +}: { + sdk: InviteJoinSdk + address: Address + user: InviteUser | null + inviterCode?: string +}): Promise { + const validatedInviterCode = inviterCode + ? await validateInviteCode(sdk, address, inviterCode) + : undefined + const ownCode = await getMyInviteCode(user, async () => + encodeInviteCode(await generateInviteCode(address, sdk.resolveCode.bind(sdk))), + ) + await sdk.join(ownCode, validatedInviterCode ? encodeInviteCode(validatedInviterCode) : zeroHash) + return Boolean(validatedInviterCode) +} + +/** Prevents batch collection unless InviteSDK reported an eligible pending invitee. */ +export async function collectEligibleInviteRewards( + sdk: InviteCollectionSdk, + collectableInvitees: Address[], +): Promise { + if (!hasCollectableInvitees(collectableInvitees)) return [] + return sdk.collectAllBounties() +} + +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 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 + }), + ) + + setState({ + status: 'ready', + address: address as Address, + chainId, + user, + level, + invitees, + pendingInvitees, + collectableInvitees, + eligibility: Object.fromEntries(eligibilityEntries), + selfEligibility: selfEligibility.details, + 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.') + return validateInviteCode(sdk, address as Address, code) + }, + [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 attachedInviter = await submitInviteJoin({ + sdk, + address: address as Address, + user: state.user, + inviterCode, + }) + await refresh() + setState((current) => ({ + ...current, + success: attachedInviter + ? 'Joined inviter successfully.' + : 'Invite code created successfully.', + })) + } catch (error: unknown) { + setState((current) => ({ + ...current, + status: 'ready', + error: inviteErrorMessage(error), + success: null, + })) + } + }, + [address, getSdk, refresh, state.user], + ) + + const collectAll = useCallback(async () => { + const sdk = await getSdk() + if (!sdk || !hasCollectableInvitees(state.collectableInvitees)) return + + setState((current) => ({ ...current, status: 'collecting', error: null, success: null })) + try { + const results = await collectEligibleInviteRewards(sdk, state.collectableInvitees) + await refresh() + setState((current) => ({ + ...current, + success: results.length + ? 'Invite rewards collected successfully.' + : 'No rewards were collected.', + })) + } catch (error: unknown) { + setState((current) => ({ + ...current, + status: 'ready', + error: inviteErrorMessage(error), + success: null, + })) + } + }, [getSdk, refresh, state.collectableInvitees]) + + 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, + adapterFactory, +}: { + children: ReactNode + environment: CitizenClaimWidgetEnvironment + adapterFactory?: InviteAdapterFactory +}) { + if (adapterFactory) { + return createElement(InviteRuntimeContext.Provider, { value: adapterFactory() }, children) + } + + return createElement(LiveInviteRuntimeProvider, { environment, children }) +} + +function LiveInviteRuntimeProvider({ + 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..6831bc1a --- /dev/null +++ b/packages/citizen-claim-widget/src/inviteRules.ts @@ -0,0 +1,23 @@ +import type { InviteUser } from '@goodsdks/invite-sdk' +import { isAddressEqual, zeroAddress, zeroHash, type Address } from 'viem' + +export function canAttachInviter(user: InviteUser | null): boolean { + return Boolean( + user && + user.inviteCode !== zeroHash && + 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 +} diff --git a/packages/citizen-claim-widget/src/widgetRuntimeContract.ts b/packages/citizen-claim-widget/src/widgetRuntimeContract.ts index 4abe3c88..cee36026 100644 --- a/packages/citizen-claim-widget/src/widgetRuntimeContract.ts +++ b/packages/citizen-claim-widget/src/widgetRuntimeContract.ts @@ -1,4 +1,5 @@ import type { GoodWidgetConfig, GoodWidgetThemeOverrides } from '@goodwidget/ui' +import type { InviteAdapterFactory } from './inviteAdapter' export type CitizenClaimWidgetEnvironment = 'production' | 'staging' | 'development' @@ -95,6 +96,8 @@ export interface CitizenClaimWidgetProps { clientFactory?: CitizenClaimWidgetClientFactory onClaimSuccess?: (detail: CitizenClaimWidgetSuccessDetail) => void onClaimError?: (detail: CitizenClaimWidgetErrorDetail) => void + /** Deterministic Invite Rewards adapter for stories and host-side testing. */ + inviteAdapterFactory?: InviteAdapterFactory // ---- Theming (optional, passed through to GoodWidgetProvider) ---- /** Token and theme overrides applied at the widget boundary. */ themeOverrides?: GoodWidgetThemeOverrides diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e20f4a9a..12947412 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,7 +110,7 @@ importers: version: 52.0.49(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1)))(graphql@16.14.0)(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1))(react@18.3.1) expo-router: specifier: ~4.0.0 - version: 4.0.22(h24uj5uxuamejkfd5syxg3rvii) + version: 4.0.22(28d2db59ed8f6d82240f761fd78dbce4) react: specifier: ^18.3.0 version: 18.3.1 @@ -300,7 +300,7 @@ importers: dependencies: '@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) + 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.4.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) '@goodwidget/core': specifier: workspace:* version: link:../core @@ -338,6 +338,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 @@ -347,6 +350,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) @@ -442,7 +448,7 @@ importers: version: 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11) '@reown/appkit-adapter-wagmi': specifier: ^1.8.22 - version: 1.8.22(v6adbyiuvbtrgqm65ozlnb246m) + version: 1.8.22(3433dae7a71e416211aa9250d784677b) '@tanstack/react-query': specifier: ^5.101.2 version: 5.101.2(react@18.3.1) @@ -1956,7 +1962,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==} @@ -2086,6 +2092,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: @@ -2333,30 +2344,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/keyring-linux-arm64-musl@1.2.0': resolution: {integrity: sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/keyring-linux-riscv64-gnu@1.2.0': resolution: {integrity: sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/keyring-linux-x64-gnu@1.2.0': resolution: {integrity: sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/keyring-linux-x64-musl@1.2.0': resolution: {integrity: sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/keyring-win32-arm64-msvc@1.2.0': resolution: {integrity: sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ==} @@ -2480,48 +2496,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-arm64-musl@0.111.0': resolution: {integrity: sha512-ARyfcMCIxVLDgLf6FQ8Oo1/TFySpnquV+vuSb4SFQZfYDqgMklzwv0NYXxWD0aB6enElyMDs6pQJBzusEKCkOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-transform/binding-linux-ppc64-gnu@0.111.0': resolution: {integrity: sha512-PKpVRrSvBNK3tv9vwxn7Fay+QWZmprPGlEqJcseBJllQc5mFMD4Q/w44chu5iR9ZLsDeSHzmNWrgMLo4J0sP2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-riscv64-gnu@0.111.0': resolution: {integrity: sha512-9bUml6rMgk+8GF5rvNMweFspkzSiCjqpV6HduwiUyexqfGKrmjq9IZOxxvnzkE2RGdQzP507NNDoVNYIoGQYuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-riscv64-musl@0.111.0': resolution: {integrity: sha512-tzGCohGxaeH6KRJjfYZd4mHCoGjCai6N+zZi1Oj+tSDMAAdyvs1dRzYb8PNUGnybCg3Te4M0jLPzWZaSmnKraQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-transform/binding-linux-s390x-gnu@0.111.0': resolution: {integrity: sha512-sRG1KIfZ0ML9ToEygm5aM/5GJeBA05uHlgW3M0Rx/DNWMJhuahLmqWuB02aWSmijndLfEKXLLXIWhvWupRG8lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-x64-gnu@0.111.0': resolution: {integrity: sha512-T0Kmvk+OdlUdABdXlDIf3MQReMzFfC75NEI9x8jxy5pKooACEFg0k0V8gyR3gq4DzbDCfucqFQDWNvSgIopAbQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-x64-musl@0.111.0': resolution: {integrity: sha512-EgoutsP3YfqzN8a9vpc9+XLr0bmBl0dA3uOMiP77+exATCPxJBkJErGmQkqk6RtTp5XqX6q6mB45qWQyKk6+pA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-transform/binding-openharmony-arm64@0.111.0': resolution: {integrity: sha512-d8J+ejc0j5WODbVwR/QxFaI65YMwvG0W53vcVCHwa6ja1QI5lpe7sislrefG2EFYgnY47voMRzlXab5d4gEcDw==} @@ -2820,24 +2844,28 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.1': resolution: {integrity: sha512-UvApLEGholmxw/HIwmUnLq3CwdydbhaHHllvWiCTNbyGom7wTwOtz5OAQbAKZYyiEOeIXZNPkM7nA4Dtng7CLw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.1': resolution: {integrity: sha512-uVctNgZHiGnJx5Fij7wHLhgw4uyZBVi6mykeWKOqE7bVy9Hcxn0fM/IuqdMwk6hXlaf9fFShDTFz2+YejP+x0A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.1': resolution: {integrity: sha512-T6Eg0xWwcxd/MzBcuv4Z37YVbUbJxy5cMNnbIt/Yr99wFwli30O4BPlY8hKeGyn6lWNtU0QioBS46lVzDN38bg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.1': resolution: {integrity: sha512-PuGZVS2xNJyLADeh2F04b+Cz4NwvpglbtWACgrDOa5YDTEHKwmiTDjoD5eZ9/ptXtcpeFrMqD2H4Zn33KAh1Eg==} @@ -2911,66 +2939,79 @@ packages: resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} @@ -3625,36 +3666,42 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.30': resolution: {integrity: sha512-1SYGs2l0Yyyi0pR/P/NKz/x0kqxkoiw+BXeJjLUdecSk/KasncWlJrc6hOvFSgKHOBrzgM5jwuluKtlT8dnrcA==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-ppc64-gnu@1.15.30': resolution: {integrity: sha512-TXREtiXeRhbfDFbmhnkIsXpKfzbfT73YkV2ZF6w0sfxgjC5zI2ZAbaCOq25qxvegofj2K93DtOpm9RLaBgqR2g==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] + libc: [glibc] '@swc/core-linux-s390x-gnu@1.15.30': resolution: {integrity: sha512-DCR2YYeyd6DQE4OuDhImouuNcjXEiEdnn1Y0DyGteugPEDvVuvYk8Xddi+4o2SgWH6jiW8/I+3emZvbep1NC+g==} engines: {node: '>=10'} cpu: [s390x] os: [linux] + libc: [glibc] '@swc/core-linux-x64-gnu@1.15.30': resolution: {integrity: sha512-5Pizw3NgfOJ5BJOBK8TIRa59xFW2avESTOBDPTAYwZYa1JNDs+KMF9lUfjJiJLM5HiMs/wPheA9eiT0q9m2AoA==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.30': resolution: {integrity: sha512-qyqydP/wyH8alcIP4a2hnGSjHLJjm9H7yDFup+CPy9oTahFgLLwnNcv5UHXqO2Qs3AIND+cls5f/Bb6hqpxdgA==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.30': resolution: {integrity: sha512-CaQENgDHVGOg1mSF5sQVgvfFHG9kjMor2rkLMLeLOkfZYNj13ppnJ9+lfaBZLZUMMbnlGQnavCJb8PVBUOso7Q==} @@ -4744,6 +4791,21 @@ packages: typescript: optional: true + '@wagmi/core@3.4.7': + resolution: {integrity: sha512-OdqGDB8ewTQzesrtf6BmixFa+BWeWw7G/htUyUGXlew89kT+E2NRDX4Sfb+6F/Oqj6y41o5zSr36xHtbVUt4NA==} + peerDependencies: + '@tanstack/query-core': '>=5.0.0' + accounts: ~0.8.1 + typescript: '>=5.7.3' + viem: 2.x + peerDependenciesMeta: + '@tanstack/query-core': + optional: true + accounts: + optional: true + typescript: + optional: true + '@wagmi/core@3.6.1': resolution: {integrity: sha512-lmMLIBq0V+WM98ypeG6f4skowPlyuf8n+bWCD9SVJw3tww5sZL7Ab9rq2Js9+Yif1w9OKPy7yLWboIRT/EhRIA==} peerDependencies: @@ -4842,7 +4904,7 @@ packages: '@xmldom/xmldom@0.7.13': resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==} engines: {node: '>=10.0.0'} - deprecated: this version is no longer supported, please update to at least 0.8.* + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} @@ -7166,24 +7228,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.27.0: resolution: {integrity: sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.27.0: resolution: {integrity: sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.27.0: resolution: {integrity: sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.27.0: resolution: {integrity: sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==} @@ -11418,6 +11484,22 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@goodsdks/citizen-sdk@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.4.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)': + dependencies: + lz-string: 1.5.0 + tsup: 8.5.1(@swc/core@1.15.30)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.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.4.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)) + transitivePeerDependencies: + - '@microsoft/api-extractor' + - '@swc/core' + - jiti + - postcss + - supports-color + - tsx + - typescript + - yaml + '@goodsdks/citizen-sdk@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)': dependencies: lz-string: 1.5.0 @@ -11448,6 +11530,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)) @@ -12288,7 +12374,7 @@ snapshots: '@renovatebot/pep440@4.2.1': {} - '@reown/appkit-adapter-wagmi@1.8.22(v6adbyiuvbtrgqm65ozlnb246m)': + '@reown/appkit-adapter-wagmi@1.8.22(3433dae7a71e416211aa9250d784677b)': dependencies: '@reown/appkit': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11) '@reown/appkit-common': 1.8.22(typescript@5.9.3)(zod@4.1.11) @@ -12297,13 +12383,13 @@ snapshots: '@reown/appkit-scaffold-ui': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@4.1.11) '@reown/appkit-utils': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@4.1.11) '@reown/appkit-wallet': 1.8.22(typescript@5.9.3) - '@wagmi/core': 3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) + '@wagmi/core': 3.4.7(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) '@walletconnect/universal-provider': 2.23.7(@vercel/blob@2.4.0)(typescript@5.9.3)(zod@4.1.11) valtio: 2.1.7(@types/react@18.3.28)(react@18.3.1) 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.4.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)) optionalDependencies: - '@wagmi/connectors': 8.0.8(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.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))(@wagmi/core@3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)))(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) + '@wagmi/connectors': 8.0.8(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.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))(@wagmi/core@3.4.7(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)))(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15942,9 +16028,9 @@ snapshots: '@safe-global/safe-apps-sdk': 9.1.0(typescript@5.9.3)(zod@4.1.11) typescript: 5.9.3 - '@wagmi/connectors@8.0.8(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.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))(@wagmi/core@3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)))(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))': + '@wagmi/connectors@8.0.8(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.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))(@wagmi/core@3.4.7(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)))(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))': dependencies: - '@wagmi/core': 3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) + '@wagmi/core': 3.4.7(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) viem: 2.48.4(typescript@5.9.3)(zod@4.1.11) optionalDependencies: '@coinbase/wallet-sdk': 4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11) @@ -15953,6 +16039,21 @@ snapshots: typescript: 5.9.3 optional: true + '@wagmi/core@3.4.7(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))': + dependencies: + eventemitter3: 5.0.1 + mipd: 0.0.7(typescript@5.9.3) + viem: 2.48.4(typescript@5.9.3)(zod@4.1.11) + zustand: 5.0.0(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)) + optionalDependencies: + '@tanstack/query-core': 5.101.2 + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - immer + - react + - use-sync-external-store + '@wagmi/core@3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))': dependencies: eventemitter3: 5.0.1 @@ -17842,7 +17943,7 @@ snapshots: dependencies: invariant: 2.2.4 - expo-router@4.0.22(h24uj5uxuamejkfd5syxg3rvii): + expo-router@4.0.22(28d2db59ed8f6d82240f761fd78dbce4): dependencies: '@expo/metro-runtime': 4.0.1(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1)) '@expo/server': 0.5.3 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..d6609ff9 --- /dev/null +++ b/tests/widgets/citizen-claim-widget/inviteRewards.spec.ts @@ -0,0 +1,107 @@ +import { expect, test, type Page } from '@playwright/test' + +const STORY_PREFIX = '/iframe.html?id=qa-citizenclaimwidget-runtime-fixtures--invite-' + +test.describe.configure({ timeout: 60_000 }) + +async function openInviteStory(page: Page, story: string): Promise { + await page.goto(`${STORY_PREFIX}${story}&viewMode=story`) + await page.waitForLoadState('domcontentloaded') + await page.getByText('Invite Rewards', { exact: true }).click() + await expect(page.getByText('How it works', { exact: true })).toBeVisible() +} + +test('ready state uses protocol totals and keeps collection disabled', async ({ page }) => { + await openInviteStory(page, 'ready') + + await expect(page.getByText('2 approved invites', { exact: true })).toBeVisible() + await expect(page.getByText('0 pending rewards', { exact: true })).toBeVisible() + await expect(page.getByText('0 ready to collect', { exact: true })).toBeVisible() + await expect(page.getByText('Total earned: 15.00 G$', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: 'Collect eligible rewards' })).toBeDisabled() + + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-06-invite-ready.png', + fullPage: true, + }) +}) + +test('pending state keeps the address and waiting reason visible', async ({ page }) => { + await openInviteStory(page, 'pending') + + await expect(page.getByText('1 pending reward', { exact: true })).toBeVisible() + await expect(page.getByText(/0x4444.*Waiting for 7 days and 3 claims/)).toBeVisible() + await expect(page.getByRole('button', { name: 'Collect eligible rewards' })).toBeDisabled() + + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-07-invite-pending.png', + fullPage: true, + }) +}) + +test('collectable state submits only when ready and leaves waiting invitees pending', async ({ + page, +}) => { + await openInviteStory(page, 'collectable') + + await expect(page.getByText('2 pending rewards', { exact: true })).toBeVisible() + await expect(page.getByText('1 ready to collect', { exact: true })).toBeVisible() + await expect(page.getByText(/0x5555.*Ready to collect/)).toBeVisible() + + const collectButton = page.getByRole('button', { name: 'Collect eligible rewards' }) + await expect(collectButton).toBeEnabled() + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-08-invite-collectable.png', + fullPage: true, + }) + + await collectButton.click() + await expect( + page.getByText('Invite rewards collected successfully.', { exact: true }), + ).toBeVisible() + await expect(page.getByText('1 pending reward', { exact: true })).toBeVisible() + await expect(page.getByText('0 ready to collect', { exact: true })).toBeVisible() + await expect(page.getByText(/0x4444.*Waiting for 7 days and 3 claims/)).toBeVisible() + await expect(page.getByText(/0x5555/)).toHaveCount(0) +}) + +test('deferred join success remains visible after the join card disappears', async ({ page }) => { + await openInviteStory(page, 'ready') + + await page.getByPlaceholder('Invite code').fill('FRIEND-42') + await page.getByRole('button', { name: 'Join with code' }).click() + + await expect(page.getByText('Joined inviter successfully.', { exact: true })).toBeVisible() + await expect(page.getByText('Have an invite code?', { exact: true })).toHaveCount(0) + + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-09-invite-success.png', + fullPage: true, + }) +}) + +test('action errors are visible outside the conditional join card', async ({ page }) => { + await openInviteStory(page, 'error') + + await expect( + page.getByText('Invite transaction failed. Please retry.', { exact: true }), + ).toBeVisible() + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-10-invite-error.png', + fullPage: true, + }) +}) + +test('collectable state remains usable at a mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await openInviteStory(page, 'collectable') + + await expect(page.getByText('1 ready to collect', { exact: true })).toBeVisible() + const collectButton = page.getByRole('button', { name: 'Collect eligible rewards' }) + await expect(collectButton).toBeVisible() + await collectButton.scrollIntoViewIfNeeded() + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-11-invite-mobile.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..860560f4 --- /dev/null +++ b/tests/widgets/citizen-claim-widget/inviteRules.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '@playwright/test' +import { zeroAddress, zeroHash, type Address } from 'viem' +import { + canAttachInviter, + getMyInviteCode, + hasCollectableInvitees, +} from '../../../packages/citizen-claim-widget/src/inviteRules' +import { + collectEligibleInviteRewards, + encodeInviteCode, + submitInviteJoin, +} from '../../../packages/citizen-claim-widget/src/inviteAdapter' + +const address = '0x1234567890123456789012345678901234567890' as Address +const inviterAddress = '0x9876543210987654321098765432109876543210' 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('deferred join action validates the inviter and reuses the registered personal code', async () => { + const calls: Array<{ myCode: `0x${string}`; inviter: `0x${string}` }> = [] + const sdk = { + resolveCode: async () => inviterAddress, + join: async (myCode: `0x${string}`, inviter: `0x${string}`) => { + calls.push({ myCode, inviter }) + return zeroHash + }, + } + + await expect( + submitInviteJoin({ + sdk, + address, + user: createUser(), + inviterCode: 'FRIEND-42', + }), + ).resolves.toBe(true) + expect(calls).toEqual([{ myCode: inviterCode, inviter: encodeInviteCode('FRIEND-42') }]) +}) + +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('rewards are collectable only when the SDK reports collectable invitees', () => { + expect(hasCollectableInvitees([])).toBe(false) + expect(hasCollectableInvitees([address])).toBe(true) +}) + +test('collection action does not submit an empty batch and preserves SDK-reported scope', async () => { + let collectionCalls = 0 + const sdk = { + collectAllBounties: async () => { + collectionCalls += 1 + return [ + { + txHash: zeroHash, + invitee: address, + inviter: inviterAddress, + bountyPaid: 5n, + inviterLevel: 1n, + earnedLevel: false, + }, + ] + }, + } + + await expect(collectEligibleInviteRewards(sdk, [])).resolves.toEqual([]) + expect(collectionCalls).toBe(0) + + const results = await collectEligibleInviteRewards(sdk, [address]) + expect(collectionCalls).toBe(1) + expect(results.map((result) => result.invitee)).toEqual([address]) +}) diff --git a/tests/widgets/citizen-claim-widget/states.spec.ts b/tests/widgets/citizen-claim-widget/states.spec.ts index 674ea5e6..ae4a3226 100644 --- a/tests/widgets/citizen-claim-widget/states.spec.ts +++ b/tests/widgets/citizen-claim-widget/states.spec.ts @@ -164,3 +164,19 @@ test('CitizenClaimWidget Retry button re-triggers the adapter', async ({ page }) // — both are valid depending on timing expect(afterClickText).toBeTruthy() }) + +// ─── Invite Rewards tab ────────────────────────────────────────────────────── +test('CitizenClaimWidget opens the Invite Rewards entry point', async ({ page }) => { + await gotoStory(page) + + const inviteTab = page.getByText('Invite Rewards', { exact: true }) + await expect(inviteTab).toBeVisible() + await inviteTab.click() + + await expect(page.getByText('How it works', { exact: true })).toBeVisible() + + await page.screenshot({ + path: 'tests/widgets/citizen-claim-widget/test-results/ccw-05-invite-rewards.png', + fullPage: true, + }) +}) 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 new file mode 100644 index 00000000..e0a7fb65 Binary files /dev/null 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-ready.png b/tests/widgets/citizen-claim-widget/test-results/ccw-06-invite-ready.png new file mode 100644 index 00000000..647e7183 Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-06-invite-ready.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-07-invite-pending.png b/tests/widgets/citizen-claim-widget/test-results/ccw-07-invite-pending.png new file mode 100644 index 00000000..967d30e7 Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-07-invite-pending.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..f5a01782 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-success.png b/tests/widgets/citizen-claim-widget/test-results/ccw-09-invite-success.png new file mode 100644 index 00000000..410c9445 Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-09-invite-success.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-10-invite-error.png b/tests/widgets/citizen-claim-widget/test-results/ccw-10-invite-error.png new file mode 100644 index 00000000..aca869b0 Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-10-invite-error.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-11-invite-mobile.png b/tests/widgets/citizen-claim-widget/test-results/ccw-11-invite-mobile.png new file mode 100644 index 00000000..a3d1c262 Binary files /dev/null and b/tests/widgets/citizen-claim-widget/test-results/ccw-11-invite-mobile.png differ