Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6004194
fix: Prevent Uniswap widget crashes on 100% price impact trades
supersonicwisd1 Jul 14, 2025
ad8846f
updated patch_reference.md
supersonicwisd1 Jul 14, 2025
3e16bb0
separate concerns
supersonicwisd1 Jul 14, 2025
f39e512
Revert "updated patch_reference.md"
supersonicwisd1 Jul 15, 2025
9b2be7e
Add clean uniswap patch for 100% price impact fix
supersonicwisd1 Aug 4, 2025
2c71b08
Add clean uniswap patch for price impact fix
supersonicwisd1 Aug 4, 2025
27225ef
fix: handle uniswap widget error
supersonicwisd1 Aug 5, 2025
c2d859b
Update gitignore
supersonicwisd1 Aug 5, 2025
4eb0910
updated gitignore file
supersonicwisd1 Aug 5, 2025
54c30ed
updates suggest by yhy the reviewer
supersonicwisd1 Aug 5, 2025
655c4c5
Update .gitignore
supersonicwisd1 Aug 6, 2025
d75cfa5
Merge branch 'GoodDollar:master' into master
supersonicwisd1 Sep 8, 2025
90e423a
feat: implement Buy G$ page with progress bar and Onramper widget
supersonicwisd1 Sep 13, 2025
0edd742
remove: removed fallback on posthog
supersonicwisd1 Sep 13, 2025
aa1dfac
chore: update localization catalogs
supersonicwisd1 Sep 13, 2025
42994ce
rm: Updated gitignore file
supersonicwisd1 Sep 15, 2025
16768e3
fix: fixing critical bugs and standards
supersonicwisd1 Sep 15, 2025
4b4fd71
rm: remove falback for posthog
supersonicwisd1 Sep 15, 2025
2d9cfb6
fix: feedback fix on security, performance, functionality and design
supersonicwisd1 Sep 15, 2025
2410d7d
fix: security and functionality fix
supersonicwisd1 Sep 17, 2025
c33295d
fix: address all Korbit AI review feedback for Buy G$ feature
supersonicwisd1 Oct 16, 2025
e96a55f
fix: connect widget events to progress bar and fix swap lock bug
supersonicwisd1 Oct 29, 2025
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
203 changes: 203 additions & 0 deletions src/components/BuyProgressBar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import React, { useEffect, useState, useMemo } from 'react'
import { Box, HStack, Circle, Text } from 'native-base'

export type BuyStep = 1 | 2 | 3

export interface StepConfig {
number: number
label: string
}

interface BuyProgressBarProps {
currentStep: BuyStep
isLoading?: boolean
steps?: StepConfig[]
}

const BuyProgressBar: React.FC<BuyProgressBarProps> = ({
currentStep,
isLoading = false,
steps = [
{ number: 1, label: 'Buy cUSD' },
{ number: 2, label: 'We swap cUSD to G$' },
{ number: 3, label: 'Done' },
],
}) => {
const [animatedWidth, setAnimatedWidth] = useState(0)

// Handle animated progress line
useEffect(() => {
if (isLoading && currentStep >= 1) {
// Explicitly reset animatedWidth to 0 at the start of a new loading phase
setAnimatedWidth(0)
// Animate progress line when loading
let progress = 0
const interval = setInterval(() => {
progress += 2
if (progress <= 100) {
setAnimatedWidth(progress)
} else {
clearInterval(interval)
}
}, 50) // 50ms intervals for smooth animation

return () => clearInterval(interval)
} else {
// Set to 100% if not loading (completed state)
setAnimatedWidth(100)
}
}, [isLoading, currentStep])

const getStepStatus = (stepNumber: number) => {
// Step 1 should ALWAYS be blue (active when current, completed when past)
if (stepNumber === 1) {
if (currentStep === 1) {
return isLoading ? 'loading' : 'active'
} else {
return 'completed' // Step 1 is completed when we're on step 2 or 3
}
}
// Steps 2 and 3 follow normal logic
if (stepNumber < currentStep) return 'completed'
if (stepNumber === currentStep) return isLoading ? 'loading' : 'active'
return 'pending'
}

// Memoize circle props objects to avoid recreation on every render
const circlePropsMap = useMemo(
() => ({
completed: {
size: '12',
mb: 2,
justifyContent: 'center',
alignItems: 'center',
bg: 'blue.500',
},
active: {
size: '12',
mb: 2,
justifyContent: 'center',
alignItems: 'center',
bg: 'blue.500',
},
loading: {
size: '12',
mb: 2,
justifyContent: 'center',
alignItems: 'center',
bg: 'blue.500',
borderWidth: 3,
borderColor: 'blue.200',
animation: 'pulse 2s infinite',
},
pending: {
size: '12',
mb: 2,
justifyContent: 'center',
alignItems: 'center',
bg: 'gray.300',
},
}),
[]
)

const getCircleProps = (status: string) => {
return circlePropsMap[status as keyof typeof circlePropsMap] || circlePropsMap.pending
}

const getLineProps = (stepNumber: number, lineIndex: number) => {
// Line between step 1 and 2 (lineIndex = 0)
if (lineIndex === 0) {
if (currentStep === 1 && isLoading) {
// Animation state: "1 Blue with progress bar animation"
return {
bg: 'blue.500',
width: `${animatedWidth}%`,
transition: 'width 0.1s ease-out',
}
} else if (currentStep >= 2) {
// Static line when step 2 or higher
return {
bg: 'blue.500',
width: '100%',
}
}
}

// Line between step 2 and 3 (lineIndex = 1)
if (lineIndex === 1) {
if (currentStep === 2 && isLoading) {
// Animation state: "2 Blue with progress bar animation"
return {
bg: 'blue.500',
width: `${animatedWidth}%`,
transition: 'width 0.1s ease-out',
}
} else if (currentStep >= 3) {
// Static line when step 3
return {
bg: 'blue.500',
width: '100%',
}
}
}

// Default: gray line (not active)
return {
bg: 'gray.300',
width: '100%',
}
}

const getTextColor = (status: string) => {
return status === 'pending' ? 'gray.500' : 'black'
}

return (
<Box width="100%" mb={6} mt={4} data-testid="custom-progress-bar">
<HStack justifyContent="space-between" alignItems="flex-start" position="relative">
{steps.map((step, index) => {
const status = getStepStatus(step.number)

return (
<React.Fragment key={step.number}>
<Box alignItems="center" flex={1} position="relative">
<Circle {...getCircleProps(status)}>
<Text color="white" fontWeight="bold" fontSize="md">
{step.number}
</Text>
</Circle>
<Text
textAlign="center"
fontSize="sm"
color={getTextColor(status)}
fontFamily="subheading"
maxWidth="120px"
lineHeight="tight"
>
{step.label}
</Text>
</Box>

{index < steps.length - 1 && (
<Box
position="absolute"
top="6"
left={`${33.33 * (index + 1) - 16.67}%`}
right={`${66.67 - 33.33 * (index + 1) + 16.67}%`}
height="2px"
bg="gray.300"
zIndex={-1}
>
<Box height="100%" {...getLineProps(step.number + 1, index)} borderRadius="1px" />
</Box>
)}
</React.Fragment>
)
})}
</HStack>
</Box>
)
}

export { BuyProgressBar }
177 changes: 177 additions & 0 deletions src/components/CustomGdOnramperWidget/CustomGdOnramperWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useEthers, useEtherBalance, useTokenBalance } from '@usedapp/core'
import { WebViewMessageEvent } from 'react-native-webview'
import { AsyncStorage, useBuyGd } from '@gooddollar/web3sdk-v2'
import { noop } from 'lodash'

import { useModal } from '@gooddollar/good-design/dist/hooks/useModal'
import { View, Text } from 'native-base'
import { WalletAndChainGuard } from '@gooddollar/good-design'
import { useSignWalletModal } from '@gooddollar/good-design/dist/hooks/useSignWalletModal'
import { CustomOnramper } from './CustomOnramper'

interface ErrorModalProps {
message?: string
}

const ErrorModal: React.FC<ErrorModalProps> = ({ message = 'Something went wrong.' }) => (
<View>
<Text>{message}</Text>
</View>
)

interface ICustomGdOnramperProps {
onEvents: (action: string, data?: any, error?: string) => void
selfSwap?: boolean
withSwap?: boolean
donateOrExecTo?: string
callData?: string
apiKey?: string
}

export const CustomGdOnramperWidget = ({
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting swap logic and small UI components into custom hooks and separate files to simplify the main component.

Here are two small, focused extractions that keep all existing behavior but dramatically slim down your component:

  1. Extract all swap logic (state, lock, triggerSwap, useEffect) into a custom hook:
// hooks/useGdOnramperSwap.ts
import { useState, useRef, useCallback, useEffect } from 'react'
import { useBuyGd } from '@gooddollar/web3sdk-v2'
import { AsyncStorage } from '@gooddollar/web3sdk-v2'

interface Params {
  selfSwap: boolean
  withSwap: boolean
  donateOrExecTo?: string
  callData: string
  apiKey?: string
  account?: string
  library?: any
  gdHelperAddress?: string
  onEvents: (action: string, data?: any, error?: string) => void
  showError: () => void
}

export function useGdOnramperSwap({
  selfSwap, withSwap, donateOrExecTo, callData,
  account, library, gdHelperAddress, onEvents, showError,
}: Params) {
  const [step, setStep] = useState(0)
  const lock = useRef(false)
  const {
    createAndSwap, swap, triggerSwapTx,
    swapState, createState,
  } = useBuyGd({ donateOrExecTo, callData, withSwap })

  const internalSwap = useCallback(async () => {
    if (lock.current) return
    lock.current = true
    try {
      setStep(3)
      let txPromise
      if (selfSwap && gdHelperAddress && library && account) {
        const code = await library.getCode(gdHelperAddress)
        txPromise = code.length <= 2
          ? createAndSwap(0)
          : swap(0)
        setStep(4)
      } else if (account) {
        setStep(4)
        txPromise = triggerSwapTx()
      }
      const res = await txPromise
      if ((res as any)?.status !== 1 && !(res as any)?.ok) throw new Error('reverted')
      setStep(5)
      onEvents('buy_success')
    } catch (e: any) {
      showError()
      onEvents('buygd_swap_failed', e.message)
      setStep(0)
    } finally {
      lock.current = false
    }
  }, [
    selfSwap, gdHelperAddress, library, account,
    createAndSwap, swap, triggerSwapTx, onEvents, showError,
  ])

  // trigger swap when any helper balance > 0
  useEffect(() => {
    if (!gdHelperAddress) return
    ;(async () => {
      const cusd = await AsyncStorage.getItem('gdOnrampSuccess')
      if (!cusd) return
      await AsyncStorage.removeItem('gdOnrampSuccess')
      internalSwap()
    })()
  }, [gdHelperAddress, internalSwap])

  return { step, swapState, createState, triggerSwap: internalSwap, setStep }
}

Then your component becomes:

import { useEthers, useEtherBalance, useTokenBalance } from '@usedapp/core'
import { useGdOnramperSwap } from './hooks/useGdOnramperSwap'
import { ErrorModal } from './components/ErrorModal'
import { useModal } from '@gooddollar/good-design/dist/hooks/useModal'
import { useOnramperCallback } from './hooks/useOnramperCallback'

export function CustomGdOnramperWidget(props: ICustomGdOnramperProps) {
  const { account, library } = useEthers()
  const { showModal, Modal } = useModal()
  const { onEvents, selfSwap, withSwap } = props
  const gdHelperAddress = /* get from useBuyGd or prop */
  const { step, swapState, createState, triggerSwap } = useGdOnramperSwap({
    ...props, account, library, gdHelperAddress,
    onEvents, showError: showModal
  })

  const celo = useEtherBalance(gdHelperAddress)
  const cusd = useTokenBalance(/*...*/)

  // webview callback
  const callback = useOnramperCallback(() => setStep(2))

  return (
    <>
      <Modal body={<ErrorModal />} />
      <WalletAndChainGuard validChains={[42220]}>
        <CustomOnramper
          onEvent={callback}
          step={step}
          setStep={setStep}
          /* ...other props */
        />
      </WalletAndChainGuard>
      <SignWalletModal txStatus={swapState.status}/>
      <SignWalletModal txStatus={createState.status}/>
    </>
  )
}
  1. Pull out your tiny UI bits into their own files:
// components/ErrorModal.tsx
import { View, Text } from 'native-base'
export const ErrorModal = () => (
  <View><Text>Something went wrong.</Text></View>
)
// hooks/useOnramperCallback.ts
import { useCallback } from 'react'
import { AsyncStorage } from '@gooddollar/web3sdk-v2'
export const useOnramperCallback = (onSuccess: () => void) =>
  useCallback(async e => {
    const data = JSON.parse(e.nativeEvent.data)
    if (data.title === 'success') {
      await AsyncStorage.setItem('gdOnrampSuccess', 'true')
      onSuccess()
    }
  }, [onSuccess])

These two extractions keep everything working but collapse ~200 loc of mixed concerns into small, testable hooks and components.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

valid comment @supersonicwisd1

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider refactoring the component by extracting the Onramper event handling and swap logic into custom hooks to simplify and declutter the main component.

Suggested change
export const CustomGdOnramperWidget = ({
// 1) Extract the Onramper iframe logic into a `useOnramper` hook.
// src/hooks/useOnramper.ts
import { useCallback, useState } from 'react'
import { WebViewMessageEvent } from 'react-native-webview'
import { AsyncStorage } from '@gooddollar/web3sdk-v2'
export function useOnramper(onEvents: (action: string, data?: any, error?: string) => void) {
const [step, setStep] = useState(0)
const onEvent = useCallback(async (event: WebViewMessageEvent) => {
try {
const data =
typeof event.nativeEvent.data === 'string'
? JSON.parse(event.nativeEvent.data)
: event.nativeEvent.data
if (data?.title === 'success') {
await AsyncStorage.setItem('gdOnrampSuccess', 'true')
setStep(2)
onEvents('onramp_success')
}
} catch {
/* ignore */
}
}, [onEvents])
return { step, setStep, onEvent }
}
// 2) Extract the swap-flow & lock logic into `useSwapFlow`.
// src/hooks/useSwapFlow.ts
import { useEffect, useRef } from 'react'
import { useBuyGd } from '@gooddollar/web3sdk-v2'
interface SwapParams {
selfSwap: boolean
withSwap: boolean
donateOrExecTo?: string
callData: string
account?: string
library?: any
gdHelperAddress?: string
onEvents: (action: string, data?: any, error?: string) => void
showError: () => void
setStep: (step: number) => void
}
export function useSwapFlow({
selfSwap,
withSwap,
donateOrExecTo,
callData,
account,
library,
gdHelperAddress,
onEvents,
showError,
setStep,
}: SwapParams) {
const swapLock = useRef(false)
const { createAndSwap, swap, swapState, createState, triggerSwapTx } = useBuyGd({
donateOrExecTo,
callData,
withSwap,
})
const triggerSwap = async () => {
if (swapLock.current) return
swapLock.current = true
try {
setStep(3)
let txPromise
if (selfSwap && gdHelperAddress && library && account) {
const code = await library.getCode(gdHelperAddress)
txPromise =
code.length <= 2 ? createAndSwap(0 /* oracle min */) : swap(0)
} else {
setStep(4)
txPromise = triggerSwapTx()
}
const res = await txPromise
if (res?.status !== 1 && !res?.ok) throw new Error('swap failed')
setStep(5)
onEvents('buy_success')
} catch (e: any) {
showError()
onEvents('buygd_swap_failed', e.message)
setStep(0)
} finally {
swapLock.current = false
}
}
return { swapState, createState, triggerSwap }
}
// 3) In your component, wire them together:
import { useOnramper } from 'src/hooks/useOnramper'
import { useSwapFlow } from 'src/hooks/useSwapFlow'
export const CustomGdOnramperWidget = (props: ICustomGdOnramperProps) => {
const { onEvents, selfSwap, withSwap, donateOrExecTo, callData, apiKey } = props
const { account, library } = useEthers()
const { showModal, Modal } = useModal()
const { SignWalletModal } = useSignWalletModal()
// 3.a Onramper
const { step, setStep, onEvent } = useOnramper(onEvents)
// 3.b Swap logic
const { swapState, createState, triggerSwap } = useSwapFlow({
selfSwap,
withSwap,
donateOrExecTo,
callData,
account,
library,
gdHelperAddress,
onEvents,
showError: () => showModal(),
setStep,
})
// 3.c Balance watch to auto-trigger
useEffect(() => {
if ((cusdBalance?.gt(0) || celoBalance?.gt(0))) {
AsyncStorage.removeItem('gdOnrampSuccess')
triggerSwap()
}
}, [cusdBalance, celoBalance])
return (
<>
<Modal body={<ErrorModal />} />
<WalletAndChainGuard validChains={[42220]}>
<CustomOnramper
onEvent={onEvent}
targetWallet={gdHelperAddress || ''}
step={step}
setStep={setStep}
targetNetwork="CELO"
widgetParams={{ onlyCryptos: 'CUSD_CELO', isAddressEditable: false }}
onGdEvent={onEvents}
apiKey={apiKey}
/>
</WalletAndChainGuard>
{/* single SignWalletModal covering both statuses */}
<SignWalletModal txStatuses={[swapState.status, createState.status]} />
</>
)
}

This splits the iframe parsing, AsyncStorage flag, swap logic, and modal orchestration into small focused hooks/components—maintaining all existing behavior while significantly flattening and decluttering the main component.

onEvents = noop,
selfSwap = false,
withSwap = true,
donateOrExecTo = undefined,
callData = '0x',
apiKey = undefined,
}: ICustomGdOnramperProps) => {
const cusd = '0x765de816845861e75a25fca122bb6898b8b1282a'
const { account, library } = useEthers()
const swapLock = useRef(false)

const { createAndSwap, swap, swapState, createState, gdHelperAddress, triggerSwapTx } = useBuyGd({
donateOrExecTo,
callData,
withSwap,
})

const { SignWalletModal } = useSignWalletModal()

const celoBalance = useEtherBalance(gdHelperAddress)
const cusdBalance = useTokenBalance(cusd, gdHelperAddress)

const { showModal, Modal } = useModal()

const [step, setStep] = useState(0)

/**
* callback to get event from onramper iframe
* Optimized to avoid unnecessary parsing and improve error handling
*/
const callback = useCallback(
async (event: WebViewMessageEvent) => {
const rawData = event.nativeEvent?.data
if (!rawData) return

let eventData
try {
// Only parse if it's a string, otherwise use directly
eventData = typeof rawData === 'string' ? JSON.parse(rawData) : rawData
} catch (error) {
// Silent fail for invalid JSON - expected for non-JSON messages
return
}

// Early return if no valid event data
if (!eventData?.type && !eventData?.title) return

// Handle different Onramper event types
switch (eventData.type || eventData.title) {
case 'initiated':
case 'opened':
// User opened/interacted with the widget
onEvents('widget_clicked')
break
case 'success':
await AsyncStorage.setItem('gdOnrampSuccess', 'true')
setStep(2)
break
default:
break
}
},
[onEvents]
)

const triggerSwap = async () => {
if (swapLock.current) return //prevent from useEffect retriggering this
swapLock.current = true

try {
setStep(3)
// Emit swap_started event to animate progress bar step 2
onEvents('swap_started')

//user sends swap tx
if (selfSwap && gdHelperAddress && library && account) {
const minAmount = 0 // we let contract use oracle for minamount, we might calculate it for more precision in the future
const code = await library.getCode(gdHelperAddress)
let swapTx
if (code.length <= 2) {
swapTx = createAndSwap(minAmount)
} else {
swapTx = swap(minAmount)
}

setStep(4)
// after tx sent progress the stepper
const res = await swapTx
if (res?.status !== 1) throw Error('reverted')
} else {
if (account) {
//or backends sends swap tx
setStep(4)
const tx = await triggerSwapTx()

if (!tx?.ok) throw Error('reverted')
}
}
// when done set stepper at final step
setStep(5)
swapLock.current = false
// Emit swap_completed event to move progress bar to step 3
onEvents('swap_completed')
onEvents('buy_success')
} catch (e: any) {
swapLock.current = false // Reset lock on error
showModal()
onEvents('buygd_swap_failed', e.message)
setStep(0)
}
}

// when the helper contract has some balance we trigger the swap
useEffect(() => {
if (cusdBalance?.gt(0) || celoBalance?.gt(0)) {
void AsyncStorage.removeItem('gdOnrampSuccess')
// Emit funds_received event to update progress bar to step 2
onEvents('funds_received')
triggerSwap().catch((e) => {
showModal()
onEvents('buygd_swap_failed', e.message)
})
}
}, [celoBalance, cusdBalance])

return (
<>
<Modal body={<ErrorModal />} _modalContainer={{ paddingBottom: 18, paddingLeft: 18, paddingRight: 18 }} />
<WalletAndChainGuard validChains={[42220]}>
<CustomOnramper
onEvent={callback}
targetWallet={gdHelperAddress || ''}
step={step}
setStep={setStep}
targetNetwork="CELO"
widgetParams={{ onlyCryptos: 'CUSD_CELO', isAddressEditable: false }}
onGdEvent={onEvents}
apiKey={apiKey}
/>
</WalletAndChainGuard>
<SignWalletModal txStatus={swapState?.status} />
<SignWalletModal txStatus={createState?.status} />
</>
)
}
Loading
Loading