diff --git a/packages/citizen-claim-widget/README.md b/packages/citizen-claim-widget/README.md index 3f1dea6c..896c6baf 100644 --- a/packages/citizen-claim-widget/README.md +++ b/packages/citizen-claim-widget/README.md @@ -12,3 +12,26 @@ 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.1` 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. Pending +invitees show their whitelist and minimum-days/minimum-claims diagnostics before +the collection control is enabled. 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..76ad2d36 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 /> + ) } @@ -539,33 +542,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..ff5054f8 --- /dev/null +++ b/packages/citizen-claim-widget/src/InviteRewards.tsx @@ -0,0 +1,198 @@ +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 || state.error) && {validationError ?? state.error}} + + + ) +} + +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' + + return ( + + Your invite rewards + {state.invitees.length} approved invitee{state.invitees.length === 1 ? '' : 's'} + {state.pendingInvitees.length} pending reward{state.pendingInvitees.length === 1 ? '' : 's'} + {state.level && ( + + Earn {formatInviteBounty(state.level.bounty, state.chainId)} G$ per eligible invitee. + + )} + {state.pendingInvitees.map((invitee) => { + const details = state.eligibility[invitee] + const status = details?.inviteeWhitelisted + ? `Waiting for ${details.minimumDays} days and ${details.minimumClaims} claims.` + : 'Waiting for identity verification.' + return {`${invitee.slice(0, 6)}…${invitee.slice(-4)} — ${status}`} + })} + + + ) +} + +/** 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.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..0d531361 100644 --- a/packages/citizen-claim-widget/src/index.ts +++ b/packages/citizen-claim-widget/src/index.ts @@ -22,5 +22,20 @@ 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, + 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..b288c993 --- /dev/null +++ b/packages/citizen-claim-widget/src/inviteAdapter.ts @@ -0,0 +1,327 @@ +import { + createContext, + createElement, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react' +import bs58 from 'bs58' +import { + formatBounty, + InviteSDK, + InviteSDKError, + isSupportedChain, + type BountyEligibilityDetails, + type InviteLevel, + type InviteUser, +} from '@goodsdks/invite-sdk' +import { hexToBytes, hexToString, stringToHex, zeroAddress, zeroHash, type Address } from 'viem' +import { useWallet } from '@goodwidget/core' +import { createCitizenWidgetClients } from './adapter' +import { getMyInviteCode } from './inviteRules' +import type { CitizenClaimWidgetEnvironment } from './widgetRuntimeContract' + +export type InviteStatus = + | 'disconnected' + | 'unsupported' + | 'loading' + | 'ready' + | 'joining' + | 'collecting' + | 'error' + +export interface InviteState { + status: InviteStatus + address: Address | null + chainId: number | null + user: InviteUser | null + level: InviteLevel | null + invitees: Address[] + pendingInvitees: Address[] + collectableInvitees: Address[] + eligibility: Record + selfEligibility: BountyEligibilityDetails | null + error: string | null + success: string | null +} + +export interface InviteActions { + refresh: () => Promise + join: (inviterCode?: string) => Promise + collectAll: () => Promise + validateCode: (code: string) => Promise +} + +export interface InviteAdapterResult { + state: InviteState + actions: InviteActions +} + +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.') +} + +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.') + const normalizedCode = code.trim() + const owner = await sdk.resolveCode(encodeInviteCode(normalizedCode)) + if (owner === zeroAddress) throw new Error('This invite code was not found.') + if (owner.toLowerCase() === address.toLowerCase()) { + throw new Error('You cannot use your own invite code.') + } + return normalizedCode + }, + [address, getSdk, state.user], + ) + + const join = useCallback( + async (inviterCode?: string) => { + const sdk = await getSdk() + if (!sdk || !address) { + setState((current) => ({ ...current, error: 'Connect on Celo or XDC to join.', success: null })) + return + } + + setState((current) => ({ ...current, status: 'joining', error: null, success: null })) + try { + const validatedInviterCode = inviterCode ? await validateCode(inviterCode) : undefined + const ownCode = await getMyInviteCode( + state.user, + async () => encodeInviteCode(await generateInviteCode(address as Address, sdk.resolveCode.bind(sdk))), + ) + await sdk.join( + ownCode, + validatedInviterCode ? encodeInviteCode(validatedInviterCode) : zeroHash, + ) + await refresh() + setState((current) => ({ + ...current, + success: validatedInviterCode ? 'Joined inviter successfully.' : 'Invite code created successfully.', + })) + } catch (error: unknown) { + setState((current) => ({ + ...current, + status: 'ready', + error: inviteErrorMessage(error), + success: null, + })) + } + }, + [address, getSdk, refresh, validateCode], + ) + + const collectAll = useCallback(async () => { + const sdk = await getSdk() + if (!sdk) return + + setState((current) => ({ ...current, status: 'collecting', error: null, success: null })) + try { + const results = await sdk.collectAllBounties() + 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]) + + return useMemo( + () => ({ state, actions: { refresh, join, collectAll, validateCode } }), + [collectAll, join, refresh, state, validateCode], + ) +} + +/** Shares one invite state machine between the Claim and Invite Rewards tabs. */ +export function InviteRuntimeProvider({ + children, + environment, +}: { + children: ReactNode + environment: CitizenClaimWidgetEnvironment +}) { + const inviteRuntime = useInviteAdapter(environment) + return createElement(InviteRuntimeContext.Provider, { value: inviteRuntime }, children) +} + +export function useInviteRuntime(): InviteAdapterResult { + const inviteRuntime = useContext(InviteRuntimeContext) + if (!inviteRuntime) throw new Error('InviteRuntimeProvider is required.') + return inviteRuntime +} + +export function formatInviteBounty(amount: bigint, chainId: number | null): string { + return chainId && isSupportedChain(chainId) ? formatBounty(amount, chainId) : '0.00' +} diff --git a/packages/citizen-claim-widget/src/inviteRules.ts b/packages/citizen-claim-widget/src/inviteRules.ts new file mode 100644 index 00000000..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/pnpm-lock.yaml b/pnpm-lock.yaml index f0a29897..be9e4ea0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,6 +320,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.6.8(@tanstack/query-core@5.100.6)(@tanstack/react-query@5.100.6(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 @@ -329,6 +332,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) @@ -2044,6 +2050,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: @@ -4548,6 +4559,9 @@ packages: bare-abort-controller: optional: true + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -4610,6 +4624,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -10388,6 +10405,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)) @@ -14038,6 +14059,8 @@ snapshots: bare-events@2.9.1: {} + base-x@5.0.1: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.10: {} @@ -14099,6 +14122,10 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + bser@2.1.1: dependencies: node-int64: 0.4.0 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..0873bf27 --- /dev/null +++ b/tests/widgets/citizen-claim-widget/inviteRules.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '@playwright/test' +import { zeroAddress, zeroHash, type Address } from 'viem' +import { canAttachInviter, getMyInviteCode, hasCollectableInvitees } from '../../../packages/citizen-claim-widget/src/inviteRules' + +const address = '0x1234567890123456789012345678901234567890' as Address +const inviterCode = `0x${'01'.repeat(32)}` as `0x${string}` + +function createUser(overrides = {}) { + return { + invitedBy: zeroAddress, + inviteCode: inviterCode, + bountyPaid: false, + level: 0n, + levelStarted: 0n, + totalApprovedInvites: 0n, + totalEarned: 0n, + joinedAt: 1n, + bountyAtJoin: 0n, + ...overrides, + } +} + +test('code-only registered users attach an inviter with their original code', async () => { + const user = createUser() + let resolveCalls = 0 + + expect(canAttachInviter(user)).toBe(true) + await expect( + getMyInviteCode(user, async () => { + resolveCalls += 1 + return zeroHash + }), + ).resolves.toBe(inviterCode) + expect(resolveCalls).toBe(0) +}) + +test('paid or already-attached users cannot attach an inviter', () => { + expect(canAttachInviter(createUser({ bountyPaid: true }))).toBe(false) + expect(canAttachInviter(createUser({ invitedBy: address }))).toBe(false) + 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) +}) 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-01-loading.png b/tests/widgets/citizen-claim-widget/test-results/ccw-01-loading.png index dcd7c0c9..8af49b79 100644 Binary files a/tests/widgets/citizen-claim-widget/test-results/ccw-01-loading.png and b/tests/widgets/citizen-claim-widget/test-results/ccw-01-loading.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-02-not-whitelisted.png b/tests/widgets/citizen-claim-widget/test-results/ccw-02-not-whitelisted.png index 42b0d9d0..574db8f7 100644 Binary files a/tests/widgets/citizen-claim-widget/test-results/ccw-02-not-whitelisted.png and b/tests/widgets/citizen-claim-widget/test-results/ccw-02-not-whitelisted.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-03-error.png b/tests/widgets/citizen-claim-widget/test-results/ccw-03-error.png index 17afc5b8..3c140003 100644 Binary files a/tests/widgets/citizen-claim-widget/test-results/ccw-03-error.png and b/tests/widgets/citizen-claim-widget/test-results/ccw-03-error.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-04-retry-clicked.png b/tests/widgets/citizen-claim-widget/test-results/ccw-04-retry-clicked.png index 17afc5b8..3c140003 100644 Binary files a/tests/widgets/citizen-claim-widget/test-results/ccw-04-retry-clicked.png and b/tests/widgets/citizen-claim-widget/test-results/ccw-04-retry-clicked.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-05-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