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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/citizen-claim-widget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <code>
<current-page-url>
```

Invite creation uses the GoodWallet Base58 shortest-unused-prefix algorithm.
The InviteSDK remains responsible for InvitesV2 addresses, preconditions,
simulation, error mapping, joins, and single/batch bounty collection. Pending
invitees show their whitelist and minimum-days/minimum-claims diagnostics before
the collection control is enabled.
2 changes: 2 additions & 0 deletions packages/citizen-claim-widget/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@
},
"dependencies": {
"@goodsdks/citizen-sdk": "1.2.7",
"@goodsdks/invite-sdk": "1.0.3",
"@goodwidget/core": "workspace:*",
"@goodwidget/embed": "workspace:*",
"@goodwidget/ui": "workspace:*",
"bs58": "6.0.0",
"viem": "^2.0.0"
},
"devDependencies": {
Expand Down
61 changes: 34 additions & 27 deletions packages/citizen-claim-widget/src/CitizenClaimWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
} from '@goodwidget/ui'
import { SupportedChains } from '@goodsdks/citizen-sdk'
import { useCitizenClaimAdapter } from './adapter'
import { ClaimInviteJoinCard, InviteRewards } from './InviteRewards'
import { InviteRuntimeProvider } from './inviteAdapter'
import type {
CitizenClaimWidgetProps,
CitizenClaimWidgetSuccessDetail,
Expand Down Expand Up @@ -499,6 +501,7 @@ function CitizenClaimInner({ environment, onClaimSuccess, onClaimError }: Citize
/>
</ClaimDailyStatsRow>
</ClaimDailyStats>
<ClaimInviteJoinCard />
</YStack>
)
}
Expand Down Expand Up @@ -539,33 +542,37 @@ export function CitizenClaimWidget({
themeOverrides={themeOverrides}
defaultTheme={defaultTheme}
>
<WidgetTabs
tabs={[
{ id: 'claim', label: 'Claim' },
{ id: 'invite-rewards', label: 'Invite Rewards' },
{ id: 'news-feed', label: 'News' },
]}
activeTab={activeTab}
onTabChange={(tabId: string) => setActiveTab(tabId as CitizenClaimTab)}
chainId={chainId ?? 42220}
/>
{activeTab === 'claim' ? (
<>
<CitizenClaimInner
environment={environment}
// walletMode={walletMode}
onClaimSuccess={onClaimSuccess}
onClaimError={onClaimError}
/>
<ToastContainer />
</>
) : (
<Card width="100%">
<YStack alignItems="center" justifyContent="center" minHeight={320}>
<Text variant="body">Widget coming soon</Text>
</YStack>
</Card>
)}
<InviteRuntimeProvider environment={environment}>
<WidgetTabs
tabs={[
{ id: 'claim', label: 'Claim' },
{ id: 'invite-rewards', label: 'Invite Rewards' },
{ id: 'news-feed', label: 'News' },
]}
activeTab={activeTab}
onTabChange={(tabId: string) => setActiveTab(tabId as CitizenClaimTab)}
chainId={chainId ?? 42220}
/>
{activeTab === 'claim' ? (
<>
<CitizenClaimInner
environment={environment}
// walletMode={walletMode}
onClaimSuccess={onClaimSuccess}
onClaimError={onClaimError}
/>
<ToastContainer />
</>
) : activeTab === 'invite-rewards' ? (
<InviteRewards />
) : (
<Card width="100%">
<YStack alignItems="center" justifyContent="center" minHeight={320}>
<Text variant="body">Widget coming soon</Text>
</YStack>
</Card>
)}
</InviteRuntimeProvider>
</GoodWidgetProvider>
)
}
198 changes: 198 additions & 0 deletions packages/citizen-claim-widget/src/InviteRewards.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<Card padding="$4" gap="$3">
<Heading level={compact ? 4 : 3}>Have an invite code?</Heading>
<Text secondary>Enter your inviter&apos;s code to join their invite rewards.</Text>
<Input
value={code}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setCode(event.target.value)}
placeholder="Invite code"
autoCapitalize="none"
/>
{(validationError || state.error) && <Text color="$error">{validationError ?? state.error}</Text>}
<Button fullWidth disabled={!code.trim() || isPending} onPress={joinWithCode}>
<ButtonText>{isPending ? 'Joining…' : 'Join with code'}</ButtonText>
</Button>
</Card>
)
}

function InviteShareCard() {
const { state, actions } = useInviteRuntime()
const [shareFeedback, setShareFeedback] = useState<string | null>(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 (
<Card padding="$4" gap="$3">
<Heading level={3}>Create your invite code</Heading>
<Text secondary>
{isVerified
? 'Create a code to invite friends to claim G$.'
: 'Verify your identity before creating an invite code.'}
</Text>
<Button fullWidth disabled={!isVerified || state.status === 'joining'} onPress={() => actions.join()}>
<ButtonText>{state.status === 'joining' ? 'Creating…' : 'Create invite code'}</ButtonText>
</Button>
</Card>
)
}

return (
<Card padding="$4" gap="$3">
<Heading level={3}>Share your invite</Heading>
<Text secondary>Your invite code</Text>
<Text fontWeight="700">{decodeInviteCode(state.user.inviteCode)}</Text>
<Button fullWidth onPress={share}>
<ButtonText>Share or copy invite</ButtonText>
</Button>
{shareFeedback && <Text color={shareFeedback.startsWith('Could') ? '$error' : '$success'}>{shareFeedback}</Text>}
</Card>
)
}

function InviteeStatus() {
const { state, actions } = useInviteRuntime()
const collectable = hasCollectableInvitees(state.collectableInvitees)
const isCollecting = state.status === 'collecting'

return (
<Card padding="$4" gap="$3">
<Heading level={3}>Your invite rewards</Heading>
<Text secondary>{state.invitees.length} approved invitee{state.invitees.length === 1 ? '' : 's'}</Text>
<Text secondary>{state.pendingInvitees.length} pending reward{state.pendingInvitees.length === 1 ? '' : 's'}</Text>
{state.level && (
<Text secondary>
Earn {formatInviteBounty(state.level.bounty, state.chainId)} G$ per eligible invitee.
</Text>
)}
{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 <Text key={invitee} variant="caption" secondary>{`${invitee.slice(0, 6)}…${invitee.slice(-4)} — ${status}`}</Text>
})}
<Button fullWidth disabled={!collectable || isCollecting} onPress={actions.collectAll}>
<ButtonText>{isCollecting ? 'Collecting…' : 'Collect eligible rewards'}</ButtonText>
</Button>
</Card>
)
}

/** Full Invite Rewards hierarchy, using the shared provider-first InviteSDK runtime. */
export function InviteRewards() {
const { state, actions } = useInviteRuntime()

if (state.status === 'loading') {
return (
<Card padding="$6">
<YStack alignItems="center" gap="$3">
<Spinner size="lg" />
<Text secondary>Loading invite rewards…</Text>
</YStack>
</Card>
)
}

if (state.status === 'disconnected') {
return (
<Card padding="$4">
<Text secondary>Connect your wallet to view invite rewards.</Text>
</Card>
)
}

if (state.status === 'unsupported') {
return (
<Card padding="$4">
<Text secondary>Invite rewards are available on Celo and XDC. Switch networks to continue.</Text>
</Card>
)
}

if (state.status === 'error') {
return (
<Card padding="$4" gap="$3">
<Text color="$error">{state.error}</Text>
<Button onPress={actions.refresh}><ButtonText>Retry</ButtonText></Button>
</Card>
)
}

return (
<YStack gap="$4" padding="$4">
<Card padding="$4" gap="$2">
<Heading level={2}>Invite Rewards</Heading>
<Text secondary>Invite friends to claim GoodDollar and earn G$ rewards together.</Text>
<Separator />
<Heading level={4}>How it works</Heading>
<Text secondary>1. Share your code. 2. Your friend joins and claims. 3. After identity, claim-day, and minimum-claim requirements are met, collect your reward.</Text>
</Card>
{state.success && <Text color="$success">{state.success}</Text>}
<InviteShareCard />
<InviteJoinCard />
<InviteeStatus />
</YStack>
)
}

/** Claim-tab entry point backed by the same invite runtime as Invite Rewards. */
export function ClaimInviteJoinCard() {
return <InviteJoinCard compact />
}
47 changes: 26 additions & 21 deletions packages/citizen-claim-widget/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,30 @@ const CHAIN_CONFIGS: Record<number, Chain> = {
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<typeof custom>[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.
Expand Down Expand Up @@ -203,34 +227,15 @@ 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<typeof custom>[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],
)

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<typeof custom>[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])

// ---------------------------------------------------------------------------
Expand Down
Loading