Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
28 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
41e4994
Merge branch 'master' of https://github.com/GoodDollar/GoodProtocolUI…
L03TJ3 Dec 18, 2025
6c842be
Update src/components/CustomGdOnramperWidget/CustomOnramper.tsx
L03TJ3 Dec 18, 2025
2074013
fix: show wallet connection placeholder on Buy G$ page
supersonicwisd1 Dec 19, 2025
9431ac8
Merge branch 'feat/buy-gd' of github.com:supersonicwisd1/GoodProtocol…
supersonicwisd1 Dec 19, 2025
87d5232
fix: update BuyGD to use new wallet connection system
supersonicwisd1 Dec 19, 2025
7363f3e
refactor: use GdOnramperWidget from good-design package
supersonicwisd1 Jan 14, 2026
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
177 changes: 177 additions & 0 deletions src/components/BuyProgressBar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import React, { useEffect, useState } from 'react'
import { Box, HStack, Circle, Text } from 'native-base'

export type BuyStep = 1 | 2 | 3

interface BuyProgressBarProps {
currentStep: BuyStep
isLoading?: boolean
}

const BuyProgressBar: React.FC<BuyProgressBarProps> = ({ currentStep, isLoading = false }) => {
const [animatedWidth, setAnimatedWidth] = useState(0)
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 to use style maps and CSS transitions instead of manual state and interval logic for progress animation.

You can collapse most of the branching into simple style-maps and drop the manual setInterval entirely by using CSS transitions on width. For example:

// 1) Remove animatedWidth state + useEffect entirely.

// 2) Define a single status‐to‐styles map for circles & text:
const baseCircle = {
  size: '12',
  mb: 2,
  justifyContent: 'center',
  alignItems: 'center',
};
const statusStyles = {
  completed: { bg: 'blue.500', textColor: 'white' },
  active:    { bg: 'blue.500', textColor: 'white' },
  loading:   {
    bg: 'blue.500',
    borderWidth: 3,
    borderColor: 'blue.200',
    animation: 'pulse 2s infinite',
    textColor: 'white',
  },
  pending:   { bg: 'gray.300', textColor: 'gray.500' },
} as const;

// 3) A single helper to render Circle+Label:
function Step({
  step,
  status,
}: { step: { number: number; label: string }; status: keyof typeof statusStyles }) {
  const styles = statusStyles[status];
  return (
    <Box alignItems="center" flex={1}>
      <Circle {...baseCircle} bg={styles.bg} borderWidth={styles.borderWidth} borderColor={styles.borderColor} animation={styles.animation}>
        <Text color={styles.textColor} fontWeight="bold" fontSize="md">
          {step.number}
        </Text>
      </Circle>
      <Text textAlign="center" fontSize="sm" fontFamily="subheading" color={styles.textColor}>
        {step.label}
      </Text>
    </Box>
  );
}

// 4) Simplify lines to rely on CSS transitions:
const baseLine = {
  height: '2px',
  transition: 'width 300ms ease',
};
const lineStatusMap = {
  completed: { bg: 'blue.500', width: '100%' },
  loading:   { bg: 'blue.500', width: '100%' }, // triggers the transition
  pending:   { bg: 'gray.300', width: '0%' },
} as const;

// 5) In your render loop:
<React.Fragment key={step.number}>
  <Step step={step} status={getStepStatus(step.number)} />

  {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}%`}
      zIndex={-1}
    >
      <Box {...baseLine} {...lineStatusMap[getStepStatus(step.number + 1)]} borderRadius="1px" />
    </Box>
  )}
</React.Fragment>

This:

  • Drops the animatedWidth state + interval.
  • Uses a single style-map for circles/text & one for lines.
  • Leverages CSS transitions for smooth width animation.
  • Keeps your current getStepStatus so logic stays intact.


const steps = [
{ number: 1, label: 'Buy cUSD' },
{ number: 2, label: 'We swap cUSD to G$' },
{ number: 3, label: 'Done' },
]

This comment was marked as resolved.


// Handle animated progress line
useEffect(() => {
if (isLoading && currentStep > 1) {
// 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])
Comment on lines 29 to 49

This comment was marked as resolved.


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'
}

const getCircleProps = (status: string) => {
const baseProps = {
size: '12',
mb: 2,
justifyContent: 'center',
alignItems: 'center',
}

switch (status) {
case 'completed':
Copy link

Choose a reason for hiding this comment

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

issue: The 'animation' property in getCircleProps may not be supported by native-base.

Since native-base's Circle may not support the 'animation' property, consider replacing it with a component designed for loading states to ensure compatibility across platforms.

return { ...baseProps, bg: 'blue.500' }
case 'active':
return { ...baseProps, bg: 'blue.500' }
case 'loading':
return {
...baseProps,
bg: 'blue.500',
borderWidth: 3,
borderColor: 'blue.200',
animation: 'pulse 2s infinite',
}
default:
return { ...baseProps, bg: 'gray.300' }
}
}

This comment was marked as resolved.


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 }
141 changes: 141 additions & 0 deletions src/components/CustomGdOnramperWidget/CustomGdOnramperWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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'

const ErrorModal = () => (
<View>
<Text>Something went wrong.</Text>
</View>
)

This comment was marked as resolved.


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, { refresh: 1 })
const cusdBalance = useTokenBalance(cusd, gdHelperAddress, { refresh: 1 })

This comment was marked as resolved.


const { showModal, Modal } = useModal()

const [step, setStep] = useState(0)

/**
* callback to get event from onramper iframe
*/
const callback = useCallback(async (event: WebViewMessageEvent) => {
if ((event.nativeEvent.data as any).title === 'success') {
await AsyncStorage.setItem('gdOnrampSuccess', 'true')
//start the stepper
setStep(2)
}
}, [])

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

try {
setStep(3)
//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) {
console.log('deploying helper...')
swapTx = createAndSwap(minAmount)
} else {
swapTx = swap(minAmount)
}

setStep(4)
// after tx sent progress the stepper
const res = await swapTx
console.log('swap tx res:', res)
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
onEvents('buy_success')
} catch (e: any) {
console.log('swap error:', e.message, e)
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')
console.log('starting swap:', cusdBalance?.toString(), celoBalance?.toString())
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