diff --git a/src/app/(app)/dashboard/setup/actions.test.ts b/src/app/(app)/dashboard/setup/actions.test.ts index 5c0999d..d3fcb2b 100644 --- a/src/app/(app)/dashboard/setup/actions.test.ts +++ b/src/app/(app)/dashboard/setup/actions.test.ts @@ -233,6 +233,83 @@ describe('getEnsOwnerAction', () => { }) }) +describe('resolveFounderIdentityAction', () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.NEXT_PUBLIC_CHAIN_ID = '11155111' + process.env.ALCHEMY_API_KEY = 'https://example-rpc' + }) + + it('returns direct address payload for valid 0x address input', async () => { + const { resolveFounderIdentityAction } = await import('./actions.js') + const result = await resolveFounderIdentityAction( + '0x1234567890abcdef1234567890abcdef12345678' + ) + + expect(result).toEqual({ + input: '0x1234567890abcdef1234567890abcdef12345678', + source: 'address', + resolvedAddress: '0x1234567890abcdef1234567890abcdef12345678', + ensName: null, + error: null, + }) + expect(mockGetOwner).not.toHaveBeenCalled() + }) + + it('resolves owned ENS input to owner address', async () => { + mockGetOwner.mockResolvedValue({ + owner: '0x00000000000000000000000000000000000000aa', + }) + + const { resolveFounderIdentityAction } = await import('./actions.js') + const result = await resolveFounderIdentityAction('founder') + + expect(result).toEqual({ + input: 'founder', + source: 'ens', + resolvedAddress: '0x00000000000000000000000000000000000000aa', + ensName: 'founder.eth', + error: null, + }) + }) + + it('returns actionable message for invalid ENS format', async () => { + const { resolveFounderIdentityAction } = await import('./actions.js') + const result = await resolveFounderIdentityAction('ab') + + expect(result.resolvedAddress).toBeNull() + expect(result.error).toBe( + 'Enter a valid ENS name (like yourname.eth) or a 0x address.' + ) + }) + + it('returns actionable message for unregistered ENS names', async () => { + mockGetOwner.mockResolvedValue({ + owner: '0x0000000000000000000000000000000000000000', + }) + + const { resolveFounderIdentityAction } = await import('./actions.js') + const result = await resolveFounderIdentityAction('available-name') + + expect(result.resolvedAddress).toBeNull() + expect(result.error).toContain( + 'available-name.eth is not registered yet. Enter a registered ENS name or a 0x address.' + ) + }) + + it('returns actionable message when ENS resolution fails', async () => { + mockGetOwner.mockRejectedValue(new Error('rpc down')) + + const { resolveFounderIdentityAction } = await import('./actions.js') + const result = await resolveFounderIdentityAction('founder') + + expect(result.resolvedAddress).toBeNull() + expect(result.error).toBe( + 'Unable to resolve ENS right now. Try again or use a 0x address.' + ) + }) +}) + describe('finalizeEnsRegistrationAction', () => { const safeAddress = '0x00000000000000000000000000000000000000aa' as const const existingRegistrationTxHash = diff --git a/src/app/(app)/dashboard/setup/actions.ts b/src/app/(app)/dashboard/setup/actions.ts index 7b902de..4535359 100644 --- a/src/app/(app)/dashboard/setup/actions.ts +++ b/src/app/(app)/dashboard/setup/actions.ts @@ -157,6 +157,79 @@ export async function getEnsOwnerAction(name: string) { return { name: fullName, owner } } +export interface FounderIdentityResolution { + input: string + source: 'address' | 'ens' + resolvedAddress: string | null + ensName: string | null + error: string | null +} + +export async function resolveFounderIdentityAction( + input: string +): Promise { + const trimmedInput = input.trim() + + if (!trimmedInput) { + return { + input: trimmedInput, + source: 'ens', + resolvedAddress: null, + ensName: null, + error: 'Enter an ENS name or Ethereum address.', + } + } + + if (isAddress(trimmedInput)) { + return { + input: trimmedInput, + source: 'address', + resolvedAddress: trimmedInput, + ensName: null, + error: null, + } + } + + try { + const { fullName } = normalizeEnsInput(trimmedInput) + const result = await getOwner(ensPublicClient, { name: fullName }) + const owner = + result?.owner && result.owner !== ZERO_ADDRESS ? result.owner : null + + if (!owner) { + return { + input: trimmedInput, + source: 'ens', + resolvedAddress: null, + ensName: fullName, + error: `${fullName} is not registered yet. Enter a registered ENS name or a 0x address.`, + } + } + + return { + input: trimmedInput, + source: 'ens', + resolvedAddress: owner, + ensName: fullName, + error: null, + } + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown ENS resolution error' + const isFormatError = message.includes('Invalid ENS name') + + return { + input: trimmedInput, + source: 'ens', + resolvedAddress: null, + ensName: null, + error: isFormatError + ? 'Enter a valid ENS name (like yourname.eth) or a 0x address.' + : 'Unable to resolve ENS right now. Try again or use a 0x address.', + } + } +} + type PendingRecord = PendingRegistration export type EnsRegistrationRecord = PendingRegistration diff --git a/src/app/(app)/dashboard/setup/components/founder-validation-utils.test.ts b/src/app/(app)/dashboard/setup/components/founder-validation-utils.test.ts new file mode 100644 index 0000000..f4aecbf --- /dev/null +++ b/src/app/(app)/dashboard/setup/components/founder-validation-utils.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + clearFounderValidationTimer, + createIdleFounderValidation, + scheduleFounderValidation, + shouldApplyFounderResolution, +} from './founder-validation-utils' + +describe('founder-validation-utils', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + it('debounces founder validation and only fires latest input once', () => { + const timers: Record | undefined> = {} + const onDebouncedValidate = vi.fn() + + scheduleFounderValidation({ + founderId: 'f-1', + input: 'p', + delayMs: 350, + timers, + onDebouncedValidate, + }) + scheduleFounderValidation({ + founderId: 'f-1', + input: 'pe', + delayMs: 350, + timers, + onDebouncedValidate, + }) + scheduleFounderValidation({ + founderId: 'f-1', + input: 'peter.eth', + delayMs: 350, + timers, + onDebouncedValidate, + }) + + vi.advanceTimersByTime(349) + expect(onDebouncedValidate).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(onDebouncedValidate).toHaveBeenCalledTimes(1) + expect(onDebouncedValidate).toHaveBeenCalledWith('f-1', 'peter.eth') + }) + + it('applies resolution only when version and input still match', () => { + const shouldApplyCurrent = shouldApplyFounderResolution({ + requestVersion: 3, + latestVersion: 3, + requestInput: 'peter.eth', + latestInput: 'peter.eth', + }) + expect(shouldApplyCurrent).toBe(true) + + const shouldIgnoreStaleVersion = shouldApplyFounderResolution({ + requestVersion: 2, + latestVersion: 3, + requestInput: 'peter.eth', + latestInput: 'peter.eth', + }) + expect(shouldIgnoreStaleVersion).toBe(false) + + const shouldIgnoreStaleInput = shouldApplyFounderResolution({ + requestVersion: 3, + latestVersion: 3, + requestInput: 'pet', + latestInput: 'peter.eth', + }) + expect(shouldIgnoreStaleInput).toBe(false) + }) + + it('clears timer for removed founder row', () => { + const timers: Record | undefined> = {} + const onDebouncedValidate = vi.fn() + + scheduleFounderValidation({ + founderId: 'f-2', + input: 'remove.me.eth', + delayMs: 350, + timers, + onDebouncedValidate, + }) + + clearFounderValidationTimer('f-2', timers) + vi.advanceTimersByTime(400) + + expect(onDebouncedValidate).not.toHaveBeenCalled() + expect(timers['f-2']).toBeUndefined() + }) + + it('creates idle validation state when founder input is cleared', () => { + expect(createIdleFounderValidation('')).toEqual({ + status: 'idle', + input: '', + resolvedAddress: null, + ensName: null, + source: null, + error: null, + }) + }) +}) diff --git a/src/app/(app)/dashboard/setup/components/founder-validation-utils.ts b/src/app/(app)/dashboard/setup/components/founder-validation-utils.ts new file mode 100644 index 0000000..8b3bfa4 --- /dev/null +++ b/src/app/(app)/dashboard/setup/components/founder-validation-utils.ts @@ -0,0 +1,78 @@ +export type FounderValidationStatus = 'idle' | 'validating' | 'valid' | 'invalid' + +export interface FounderValidationState { + status: FounderValidationStatus + input: string + resolvedAddress: string | null + ensName: string | null + source: 'address' | 'ens' | null + error: string | null +} + +export function createIdleFounderValidation(input = ''): FounderValidationState { + return { + status: 'idle', + input, + resolvedAddress: null, + ensName: null, + source: null, + error: null, + } +} + +interface ScheduleFounderValidationParams { + founderId: string + input: string + delayMs: number + timers: Record | undefined> + onDebouncedValidate: (founderId: string, input: string) => void + setTimeoutFn?: typeof setTimeout + clearTimeoutFn?: typeof clearTimeout +} + +export function scheduleFounderValidation({ + founderId, + input, + delayMs, + timers, + onDebouncedValidate, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout, +}: ScheduleFounderValidationParams) { + const existingTimer = timers[founderId] + if (existingTimer) { + clearTimeoutFn(existingTimer) + } + + timers[founderId] = setTimeoutFn(() => { + onDebouncedValidate(founderId, input) + }, delayMs) +} + +export function clearFounderValidationTimer( + founderId: string, + timers: Record | undefined>, + clearTimeoutFn: typeof clearTimeout = clearTimeout +) { + const existingTimer = timers[founderId] + if (existingTimer) { + clearTimeoutFn(existingTimer) + delete timers[founderId] + } +} + +interface ShouldApplyFounderResolutionParams { + requestVersion: number + latestVersion: number + requestInput: string + latestInput: string +} + +export function shouldApplyFounderResolution({ + requestVersion, + latestVersion, + requestInput, + latestInput, +}: ShouldApplyFounderResolutionParams) { + return requestVersion === latestVersion && requestInput === latestInput.trim() +} diff --git a/src/app/(app)/dashboard/setup/components/founders-form.tsx b/src/app/(app)/dashboard/setup/components/founders-form.tsx index 195a4f1..a738761 100644 --- a/src/app/(app)/dashboard/setup/components/founders-form.tsx +++ b/src/app/(app)/dashboard/setup/components/founders-form.tsx @@ -1,8 +1,9 @@ 'use client' -import { Plus, Trash2 } from 'lucide-react' +import { AlertCircle, CheckCircle, Loader2, Plus, Trash2 } from 'lucide-react' import { type Shareholder } from '@/lib/store/draft' +import { type FounderValidationState } from './founder-validation-utils' interface FoundersFormProps { shareholders: Shareholder[] @@ -17,6 +18,8 @@ interface FoundersFormProps { field: 'walletAddress' | 'equityPercentage', value: string ) => void + onFounderInputChange: (id: string, value: string) => void + validationByFounderId: Record onRegisterToDifferentAddressChange: (checked: boolean) => void onCustomAddressChange: (value: string) => void } @@ -30,6 +33,8 @@ export function FoundersForm({ onAddFounder, onRemoveFounder, onUpdateFounder, + onFounderInputChange, + validationByFounderId, onRegisterToDifferentAddressChange, onCustomAddressChange, }: FoundersFormProps) { @@ -127,66 +132,80 @@ export function FoundersForm({
- {shareholders.map((founder, index) => ( -
-
-
- - onUpdateFounder( - founder.id, - 'walletAddress', - event.target.value - ) - } - className="border-border bg-background placeholder:text-muted-foreground focus:border-primary focus:ring-primary disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed w-full rounded-lg border px-3 py-2 text-sm transition-all duration-200 focus:ring-2" - /> -
+ {shareholders.map((founder, index) => { + const validation = validationByFounderId[founder.id] + const validationStatus = validation?.status ?? 'idle' + const isValidating = validationStatus === 'validating' + const isValid = validationStatus === 'valid' + const isInvalid = validationStatus === 'invalid' + + return ( +
+
+
+ + onFounderInputChange(founder.id, event.target.value) + } + className="border-border bg-background placeholder:text-muted-foreground focus:border-primary focus:ring-primary disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed w-full rounded-lg border px-3 py-2 pr-9 text-sm transition-all duration-200 focus:ring-2" + /> + {isValidating && ( + + )} + {isValid && ( + + )} + {isInvalid && ( + + )} +
- {isMultipleFounders && ( -
-
- - onUpdateFounder( - founder.id, - 'equityPercentage', - event.target.value - ) - } - className="border-border bg-background focus:border-primary focus:ring-primary w-full rounded-lg border px-2 py-2 pr-6 text-center text-sm transition-all duration-200 focus:ring-2" - /> -
- % + {isMultipleFounders && ( +
+
+ + onUpdateFounder( + founder.id, + 'equityPercentage', + event.target.value + ) + } + className="border-border bg-background focus:border-primary focus:ring-primary w-full rounded-lg border px-2 py-2 pr-6 text-center text-sm transition-all duration-200 focus:ring-2" + /> +
+ % +
-
- )} + )} + + {isMultipleFounders && shareholders.length > 1 && ( + + )} +
- {isMultipleFounders && shareholders.length > 1 && ( - - )}
-
- ))} + ) + })} {isMultipleFounders && (