Skip to content
Merged
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@hookform/resolvers": "3.9.1",
"@justaname.id/react": "0.3.180",
"@justaname.id/sdk": "0.2.177",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.5",
"@reduxjs/toolkit": "^2.5.0",
"@reown/appkit": "1.6.9",
Expand Down
451 changes: 297 additions & 154 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

28 changes: 19 additions & 9 deletions src/components/0_Bruddle/BaseInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,28 @@ type BaseInputVariant = 'sm' | 'md' | 'lg'

interface BaseInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
variant?: BaseInputVariant
rightContent?: React.ReactNode
}

const BaseInput = forwardRef<HTMLInputElement, BaseInputProps>(({ className, variant = 'md', ...props }, ref) => {
const variants: Record<BaseInputVariant, string> = {
sm: 'h-10 px-3',
md: 'h-16 px-5',
lg: 'h-20 px-6',
}
const BaseInput = forwardRef<HTMLInputElement, BaseInputProps>(
({ className, variant = 'md', rightContent, ...props }, ref) => {
const variants: Record<BaseInputVariant, string> = {
sm: 'h-10 px-3',
md: 'h-16 px-5',
lg: 'h-20 px-6',
}

const c = twMerge('input', variants[variant], className)
const c = twMerge('input', variants[variant], className)

return <input ref={ref} className={c} {...props} />
})
return (
<div className="relative w-full">
<input ref={ref} className={twMerge(c, !!rightContent && 'pr-15 md:pr-18')} {...props} />
{rightContent && (
<div className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2">{rightContent}</div>
)}
</div>
)
}
)

export default BaseInput
103 changes: 103 additions & 0 deletions src/components/0_Bruddle/BaseSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
'use client'

import { forwardRef } from 'react'
import {
Root,
Trigger,
Value,
Icon as SelectIcon,
Portal,
Content,
Viewport,
Item,
ItemText,
ItemIndicator,
} from '@radix-ui/react-select'
import { twMerge } from 'tailwind-merge'
import { Icon } from '@/components/Global/Icons/Icon'

export interface BaseSelectOption {
label: string
value: string
}

interface BaseSelectProps {
options: BaseSelectOption[]
placeholder?: string
value?: string
onValueChange?: (value: string) => void
onBlur?: () => void
className?: string
disabled?: boolean
error?: boolean
}

const BaseSelect = forwardRef<HTMLButtonElement, BaseSelectProps>(
({ options, placeholder = 'Select...', value, onValueChange, onBlur, className, disabled, error }, ref) => {
return (
<Root
value={value}
onValueChange={onValueChange}
disabled={disabled}
onOpenChange={(open) => {
// Trigger onBlur when the select closes
if (!open && onBlur) {
onBlur()
}
}}
>
<Trigger
ref={ref}
className={twMerge(
'flex h-12 w-full items-center justify-between rounded-sm border border-n-1 bg-white px-4 text-sm font-bold text-n-1 outline-none transition-colors placeholder:text-n-3',
'disabled:cursor-not-allowed disabled:opacity-50',
'focus:border-primary-1',
error && 'border-error',
className
)}
>
<Value placeholder={placeholder} className="text-n-1 data-[placeholder]:text-n-3" />
<SelectIcon>
<Icon name="chevron-down" className="size-4 text-n-1" />
</SelectIcon>
</Trigger>

<Portal>
<Content
className={twMerge(
'relative z-50 max-h-80 overflow-hidden rounded-sm border border-n-1 bg-white shadow-lg'
)}
position="popper"
sideOffset={4}
align="start"
style={{ width: 'var(--radix-select-trigger-width)' }}
>
<Viewport className="w-full p-1">
{options.map((option) => (
<Item
key={option.value}
value={option.value}
className={twMerge(
'relative flex w-full cursor-pointer select-none items-center rounded-sm px-3 py-2 text-sm font-bold outline-none',
'transition-colors',
'hover:bg-grey-2 focus:bg-grey-2',
'data-[state=checked]:bg-primary-1 data-[state=checked]:font-bold data-[state=checked]:text-white'
)}
>
<ItemText className="text-sm font-bold">{option.label}</ItemText>
<ItemIndicator className="ml-auto">
<Icon name="check" className="size-4" />
</ItemIndicator>
</Item>
))}
</Viewport>
</Content>
</Portal>
</Root>
)
}
)

BaseSelect.displayName = 'BaseSelect'

export default BaseSelect
1 change: 1 addition & 0 deletions src/components/0_Bruddle/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './Card'
export * from './Button'
export * from './BaseSelect'
81 changes: 73 additions & 8 deletions src/components/AddWithdraw/DynamicBankAccountForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useAuth } from '@/context/authContext'
import { Button } from '@/components/0_Bruddle/Button'
import { type AddBankAccountPayload, BridgeAccountOwnerType, BridgeAccountType } from '@/app/actions/types/users.types'
import BaseInput from '@/components/0_Bruddle/BaseInput'
import BaseSelect from '@/components/0_Bruddle/BaseSelect'
import { BRIDGE_ALPHA3_TO_ALPHA2, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts'
import { useParams, useRouter } from 'next/navigation'
import { validateIban, validateBic, isValidRoutingNumber } from '@/utils/bridge-accounts.utils'
Expand All @@ -19,6 +20,8 @@ import { useAppDispatch, useAppSelector } from '@/redux/hooks'
import { bankFormActions } from '@/redux/slices/bank-form-slice'
import { useDebounce } from '@/hooks/useDebounce'
import { Icon } from '../Global/Icons/Icon'
import { twMerge } from 'tailwind-merge'
import { MX_STATES, US_STATES } from '@/constants/stateCodes.consts'

const isIBANCountry = (country: string) => {
return BRIDGE_ALPHA3_TO_ALPHA2[country.toUpperCase()] !== undefined
Expand Down Expand Up @@ -80,6 +83,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
const router = useRouter()
const savedAccounts = useSavedAccounts()
const [isCheckingBICValid, setisCheckingBICValid] = useState(false)
const STREET_ADDRESS_MAX_LENGTH = 35 // From bridge docs: street address can be max 35 characters

let selectedCountry = (countryNameFromProps ?? (countryNameParams as string)).toLowerCase()

Expand Down Expand Up @@ -231,7 +235,9 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
rules: any,
type: string = 'text',
rightAdornment?: React.ReactNode,
onBlur?: (field: any) => Promise<void> | void
onBlur?: (field: any) => Promise<void> | void,
showCharCount?: boolean,
maxLength?: number
) => (
<div className="w-full">
<div className="relative">
Expand All @@ -244,7 +250,10 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
{...field}
type={type}
placeholder={placeholder}
className="h-12 w-full rounded-sm border border-n-1 bg-white px-4 text-sm"
className={twMerge(
'h-12 w-full rounded-sm border border-n-1 bg-white px-4 text-sm',
errors[name] && touchedFields[name] && 'border-error'
)}
onBlur={async (e) => {
// remove any whitespace from the input field
// note: @dev not a great fix, this should also be fixed in the backend
Expand All @@ -256,6 +265,13 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
await onBlur(field)
}
}}
rightContent={
showCharCount && maxLength ? (
<span className="text-xs">
{field.value?.length ?? 0}/{maxLength}
</span>
) : undefined
}
/>
)}
/>
Expand All @@ -266,6 +282,33 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
</div>
)

const renderSelect = (name: keyof IBankAccountDetails, placeholder: string, options: any[], rules: any) => (
<div className="w-full">
<Controller
name={name}
control={control}
rules={rules}
render={({ field }) => (
<BaseSelect
options={options}
placeholder={placeholder}
value={field.value}
onValueChange={field.onChange}
onBlur={field.onBlur}
error={!!(errors[name] && touchedFields[name])}
className={twMerge(
'h-12 w-full rounded-sm border border-n-1 bg-white px-4 text-sm',
errors[name] && touchedFields[name] && 'border-error'
)}
/>
)}
/>
<div className="mt-2 w-fit text-start">
{errors[name] && touchedFields[name] && <ErrorAlert description={errors[name]?.message ?? ''} />}
</div>
</div>
)

const countryCodeForFlag = useMemo(() => {
return ALL_COUNTRIES_ALPHA3_TO_ALPHA2[country.toUpperCase()] ?? country.toUpperCase()
}, [country])
Expand Down Expand Up @@ -414,15 +457,37 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D

{!isIban && (
<>
{renderInput('street', 'Your Street Address', {
required: 'Street address is required',
})}
{renderInput(
'street',
'Your Street Address',
{
required: 'Street address is required',
maxLength: {
value: STREET_ADDRESS_MAX_LENGTH,
message: 'Street address must be 35 characters or less',
},
minLength: { value: 4, message: 'Street address must be 4 characters or more' },
},
'text',
undefined,
undefined,
true,
STREET_ADDRESS_MAX_LENGTH
)}

{renderInput('city', 'Your City', { required: 'City is required' })}

{renderInput('state', 'Your State', {
required: 'State is required',
})}
{renderSelect(
'state',
'Select your state',
(isMx ? MX_STATES : US_STATES).map((state) => ({
label: state.name,
value: state.code,
})),
{
required: 'State is required',
}
)}

{renderInput('postalCode', 'Your Postal Code', {
required: 'Postal code is required',
Expand Down
1 change: 1 addition & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './query.consts'
export * from './zerodev.consts'
export * from './manteca.consts'
export * from './routes'
export * from './stateCodes.consts'
99 changes: 99 additions & 0 deletions src/constants/stateCodes.consts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* US States with ISO 3166-2 subdivision codes
* Format: US-XX where XX is the two-letter state code
*/
export const US_STATES = [
{ name: 'Alabama', code: 'AL' },
{ name: 'Alaska', code: 'AK' },
{ name: 'Arizona', code: 'AZ' },
{ name: 'Arkansas', code: 'AR' },
{ name: 'California', code: 'CA' },
{ name: 'Colorado', code: 'CO' },
{ name: 'Connecticut', code: 'CT' },
{ name: 'Delaware', code: 'DE' },
{ name: 'Florida', code: 'FL' },
{ name: 'Georgia', code: 'GA' },
{ name: 'Hawaii', code: 'HI' },
{ name: 'Idaho', code: 'ID' },
{ name: 'Illinois', code: 'IL' },
{ name: 'Indiana', code: 'IN' },
{ name: 'Iowa', code: 'IA' },
{ name: 'Kansas', code: 'KS' },
{ name: 'Kentucky', code: 'KY' },
{ name: 'Louisiana', code: 'LA' },
{ name: 'Maine', code: 'ME' },
{ name: 'Maryland', code: 'MD' },
{ name: 'Massachusetts', code: 'MA' },
{ name: 'Michigan', code: 'MI' },
{ name: 'Minnesota', code: 'MN' },
{ name: 'Mississippi', code: 'MS' },
{ name: 'Missouri', code: 'MO' },
{ name: 'Montana', code: 'MT' },
{ name: 'Nebraska', code: 'NE' },
{ name: 'Nevada', code: 'NV' },
{ name: 'New Hampshire', code: 'NH' },
{ name: 'New Jersey', code: 'NJ' },
{ name: 'New Mexico', code: 'NM' },
{ name: 'New York', code: 'NY' },
{ name: 'North Carolina', code: 'NC' },
{ name: 'North Dakota', code: 'ND' },
{ name: 'Ohio', code: 'OH' },
{ name: 'Oklahoma', code: 'OK' },
{ name: 'Oregon', code: 'OR' },
{ name: 'Pennsylvania', code: 'PA' },
{ name: 'Rhode Island', code: 'RI' },
{ name: 'South Carolina', code: 'SC' },
{ name: 'South Dakota', code: 'SD' },
{ name: 'Tennessee', code: 'TN' },
{ name: 'Texas', code: 'TX' },
{ name: 'Utah', code: 'UT' },
{ name: 'Vermont', code: 'VT' },
{ name: 'Virginia', code: 'VA' },
{ name: 'Washington', code: 'WA' },
{ name: 'West Virginia', code: 'WV' },
{ name: 'Wisconsin', code: 'WI' },
{ name: 'Wyoming', code: 'WY' },
] as const

export type USStateCode = (typeof US_STATES)[number]['code']

/**
* Mexican States with ISO 3166-2 subdivision codes
* Format: MX-XXX where XXX is the three-letter state code
*/
export const MX_STATES = [
{ name: 'Aguascalientes', code: 'AGU' },
{ name: 'Baja California', code: 'BCN' },
{ name: 'Baja California Sur', code: 'BCS' },
{ name: 'Campeche', code: 'CAM' },
{ name: 'Chiapas', code: 'CHP' },
{ name: 'Chihuahua', code: 'CHH' },
{ name: 'Ciudad de México', code: 'CMX' },
{ name: 'Coahuila', code: 'COA' },
{ name: 'Colima', code: 'COL' },
{ name: 'Durango', code: 'DUR' },
{ name: 'Guanajuato', code: 'GUA' },
{ name: 'Guerrero', code: 'GRO' },
{ name: 'Hidalgo', code: 'HID' },
{ name: 'Jalisco', code: 'JAL' },
{ name: 'México', code: 'MEX' },
{ name: 'Michoacán', code: 'MIC' },
{ name: 'Morelos', code: 'MOR' },
{ name: 'Nayarit', code: 'NAY' },
{ name: 'Nuevo León', code: 'NLE' },
{ name: 'Oaxaca', code: 'OAX' },
{ name: 'Puebla', code: 'PUE' },
{ name: 'Querétaro', code: 'QUE' },
{ name: 'Quintana Roo', code: 'ROO' },
{ name: 'San Luis Potosí', code: 'SLP' },
{ name: 'Sinaloa', code: 'SIN' },
{ name: 'Sonora', code: 'SON' },
{ name: 'Tabasco', code: 'TAB' },
{ name: 'Tamaulipas', code: 'TAM' },
{ name: 'Tlaxcala', code: 'TLA' },
{ name: 'Veracruz', code: 'VER' },
{ name: 'Yucatán', code: 'YUC' },
{ name: 'Zacatecas', code: 'ZAC' },
] as const

export type MXStateCode = (typeof MX_STATES)[number]['code']
Loading