diff --git a/src/app/(app)/dashboard/setup/components/ens-name-card.tsx b/src/app/(app)/dashboard/setup/components/ens-name-card.tsx index 1e0528d..ea7151e 100644 --- a/src/app/(app)/dashboard/setup/components/ens-name-card.tsx +++ b/src/app/(app)/dashboard/setup/components/ens-name-card.tsx @@ -13,8 +13,13 @@ export function EnsNameCard({ ensName }: EnsNameCardProps) { {ensName.charAt(0).toUpperCase()} -
-

{ensName}.eth

+
+

+ {ensName}.eth +

Your ENS business name

diff --git a/src/app/(app)/dashboard/setup/components/payment-step.tsx b/src/app/(app)/dashboard/setup/components/payment-step.tsx index e9910c0..5c480a1 100644 --- a/src/app/(app)/dashboard/setup/components/payment-step.tsx +++ b/src/app/(app)/dashboard/setup/components/payment-step.tsx @@ -22,12 +22,11 @@ export function PaymentStep({ const isPaymentInProgress = isSendingPayment || isConfirmingPayment return ( -
-

Confirm Payment

-

- Send {parseFloat(costBreakdown.totalEth).toFixed(5)} ETH to the - StartupChain treasury to begin registration. This covers ENS - registration, Safe deployment, and service fees. +

+

Pay + Start

+

+ One payment starts the full launch flow. This covers ENS registration, + Safe deployment, and service fee.

{treasuryAddress && (

@@ -38,7 +37,7 @@ export function PaymentStep({ type="button" onClick={onSendPayment} disabled={isPaymentInProgress} - className="bg-primary text-background hover:bg-primary/90 w-full rounded-xl px-6 py-3 text-base font-semibold transition-all duration-200 disabled:opacity-50" + className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring w-full rounded-xl px-6 py-3 text-base font-semibold transition-all duration-200 disabled:opacity-50 focus-visible:ring-2 focus-visible:outline-none" > {isPaymentInProgress ? ( diff --git a/src/app/(app)/dashboard/setup/components/registration-progress-card.tsx b/src/app/(app)/dashboard/setup/components/registration-progress-card.tsx index 4c80d0b..94b664d 100644 --- a/src/app/(app)/dashboard/setup/components/registration-progress-card.tsx +++ b/src/app/(app)/dashboard/setup/components/registration-progress-card.tsx @@ -1,98 +1,248 @@ 'use client' -import { Loader2 } from 'lucide-react' - -const stepLabels: Record = { - checking: 'Checking availability...', - 'awaiting-payment': 'Waiting for payment...', - 'payment-pending': 'Confirming payment...', - committing: 'Submitting commitment transaction...', - waiting: 'Waiting for commitment window', - 'deploying-safe': 'Creating your Safe wallet...', - 'registering-ens': 'Registering ENS name to your Safe...', - 'awaiting-signature': 'Sign to record your company on-chain...', - 'signing-company': 'Confirming your transaction...', - completed: 'Registration complete!', - failed: 'Registration failed', +import { + AlertTriangle, + ArrowRight, + CheckCircle2, + Loader2, + RotateCcw, +} from 'lucide-react' + +import { + buildRegistrationPhaseView, + type RegistrationPhaseId, + type RegistrationStep, +} from '@/hooks/registration-progress-model' + +type RegistrationProgressCardProps = { + step: RegistrationStep + countdown: number | null + error?: string | null + failedPhase?: RegistrationPhaseId | null + paymentAmountEth?: string | null + isPaymentInFlight?: boolean + isSignSubmitting?: boolean + permissionDenied?: boolean + onPayAndStart?: () => void + onSign?: () => void + onRetry?: () => void + onBackToEdit?: () => void } -const stepProgress: Record = { - idle: 0, - checking: 5, - 'awaiting-payment': 10, - 'payment-pending': 15, - committing: 25, - waiting: 40, - 'deploying-safe': 55, - 'registering-ens': 70, - 'awaiting-signature': 85, - 'signing-company': 92, +const statusLabel: Record = { + waiting: 'Waiting', + running: 'Running', + completed: 'Completed', + failed: 'Failed', +} + +const rowProgress: Record = { + waiting: 16, + running: 56, completed: 100, - failed: 0, + failed: 100, } -interface RegistrationProgressCardProps { - step: string - countdown: number | null - paymentTxHash?: string | null - onSign?: () => void - isSignSubmitting?: boolean +function StateIcon({ state }: { state: string }) { + if (state === 'completed') { + return + } + + if (state === 'failed') { + return + } + + if (state === 'running') { + return + } + + return

} export function RegistrationProgressCard({ step, countdown, - paymentTxHash, - onSign, + error, + failedPhase, + paymentAmountEth, + isPaymentInFlight = false, isSignSubmitting = false, + permissionDenied = false, + onPayAndStart, + onSign, + onRetry, + onBackToEdit, }: RegistrationProgressCardProps) { - const label = - step === 'waiting' - ? `Waiting for commitment window (${countdown ?? 0}s remaining)` - : stepLabels[step] || step + const phases = buildRegistrationPhaseView({ + step, + countdown, + error, + failedPhase, + }) + const activePhase = phases.find((phase) => phase.state === 'running') + const activeDescription = + step === 'awaiting-payment' + ? 'Ready to start. One payment kicks off all three phases.' + : step === 'payment-pending' + ? 'Payment is being confirmed onchain.' + : step === 'waiting' + ? `ENS commitment submitted. Waiting for security window (${countdown ?? 0}s).` + : step === 'awaiting-signature' + ? 'Final step. Sign once to record your company.' + : step === 'failed' + ? 'One phase failed. You can safely retry.' + : step === 'completed' + ? 'All done. Redirecting to your dashboard.' + : activePhase + ? `${activePhase.label} is in progress.` + : 'Preparing launch state.' + + const canPayAndStart = + step === 'awaiting-payment' && + !permissionDenied && + Boolean(onPayAndStart) && + !isPaymentInFlight + + const canSign = step === 'awaiting-signature' && Boolean(onSign) + const canRetry = step === 'failed' && Boolean(onRetry) + + const primaryAction = canPayAndStart + ? { + label: paymentAmountEth ? `Pay + Start (${paymentAmountEth} ETH)` : 'Pay + Start', + action: onPayAndStart, + disabled: false, + } + : canSign + ? { + label: isSignSubmitting ? 'Submitting signature...' : 'Sign to finish', + action: onSign, + disabled: isSignSubmitting, + } + : canRetry + ? { + label: 'Retry this step', + action: onRetry, + disabled: false, + } + : { + label: + step === 'completed' + ? 'Completed' + : isPaymentInFlight + ? 'Confirming payment...' + : 'In progress', + action: undefined, + disabled: true, + } return ( -
-
- {step !== 'completed' && step !== 'failed' && ( - - )} - {label} -
-
-
-
- {step === 'payment-pending' && paymentTxHash && ( -

- Tx: {paymentTxHash.slice(0, 10)}...{paymentTxHash.slice(-8)} +

+
+ +
+

+ Company Launch +

+

+ Three steps. About two minutes. +

+

+ {activeDescription} +

+
+ +
    + {phases.map((phase) => { + const isActive = phase.state === 'running' + const isFailed = phase.state === 'failed' + + return ( +
  1. +
    + +
    +
    +

    {phase.label}

    + + {statusLabel[phase.state]} + +
    +

    {phase.message}

    + {phase.error && ( +

    + {phase.error} +

    + )} +
    +
    +
    +
    +
    +
  2. + ) + })} +
+ + {permissionDenied && step === 'awaiting-payment' && ( +

+ Connect your wallet to continue with payment.

)} - {step === 'awaiting-signature' && onSign && ( -
+
+ + + {step === 'awaiting-payment' && onBackToEdit && ( -

- Please sign the transaction in your wallet to record your company. -

-
- )} -
+ )} +
+ +
+ + View details + +

+ We run ENS registration, Safe creation, and StartupChain recording in order. + If anything fails, only that phase needs a retry. +

+
+
) } diff --git a/src/app/(app)/dashboard/setup/components/setup-wizard.tsx b/src/app/(app)/dashboard/setup/components/setup-wizard.tsx index 278b2e1..f76a6b7 100644 --- a/src/app/(app)/dashboard/setup/components/setup-wizard.tsx +++ b/src/app/(app)/dashboard/setup/components/setup-wizard.tsx @@ -13,9 +13,7 @@ import { useDraftStore } from '@/lib/store/draft' import { CostBreakdownCard } from './cost-breakdown-card' import { EnsNameCard } from './ens-name-card' import { FoundersForm } from './founders-form' -import { PaymentStep } from './payment-step' import { RegistrationProgressCard } from './registration-progress-card' -import { WizardStepsIndicator } from './wizard-steps-indicator' const LOG_PREFIX = '[UI:SetupWizard]' @@ -23,6 +21,18 @@ interface SetupWizardProps { initialEnsName: string } +const launchSteps = new Set([ + 'awaiting-payment', + 'payment-pending', + 'committing', + 'waiting', + 'deploying-safe', + 'registering-ens', + 'awaiting-signature', + 'signing-company', + 'completed', +]) + export function SetupWizard({ initialEnsName }: SetupWizardProps) { const router = useRouter() const { @@ -33,6 +43,7 @@ export function SetupWizard({ initialEnsName }: SetupWizardProps) { } = useWalletAuth() const { step, + failedPhase, countdown, error: registrationError, costBreakdown, @@ -47,6 +58,8 @@ export function SetupWizard({ initialEnsName }: SetupWizardProps) { isConfirmingPayment, signRecordCompany, isSubmittingCompanySignature, + retryCurrentPhase, + reset, } = useCompanyRegistration() const [isLoadingCosts, setIsLoadingCosts] = useState(false) @@ -137,23 +150,11 @@ export function SetupWizard({ initialEnsName }: SetupWizardProps) { LOG_PREFIX, 'canComplete=true, step=waiting -> calling completeRegistration' ) - completeRegistration() - .then((result) => { - console.log(LOG_PREFIX, 'completeRegistration success:', result) - // Only redirect when fully completed (after user signs recordCompany) - // If status is 'ready-to-record', stay on page for user to sign - if (result.status === 'completed') { - router.push('/dashboard/ens') - router.refresh() - } - // If 'ready-to-record', the hook will set step to 'awaiting-signature' - // and auto-trigger/show button for user signing - }) - .catch((err) => { - console.error(LOG_PREFIX, 'Failed to complete registration:', err) - }) + completeRegistration().catch((err) => { + console.error(LOG_PREFIX, 'Failed to complete registration:', err) + }) } - }, [canComplete, step, completeRegistration, router]) + }, [canComplete, step, completeRegistration]) // Redirect when registration is fully completed (after user signs recordCompany) useEffect(() => { @@ -203,6 +204,7 @@ export function SetupWizard({ initialEnsName }: SetupWizardProps) { console.log(LOG_PREFIX, 'Connected wallet:', user?.wallet?.address) console.log(LOG_PREFIX, 'Draft owner wallet:', draft?.ownerWallet) console.log(LOG_PREFIX, 'Draft shareholders:', draft?.shareholders) + if (!authenticated) { console.log(LOG_PREFIX, 'Not authenticated, calling connect()') await connect() @@ -233,90 +235,93 @@ export function SetupWizard({ initialEnsName }: SetupWizardProps) { ) console.log(LOG_PREFIX, 'Threshold:', threshold) - console.log(LOG_PREFIX, 'Calling initializeRegistration...') - const result = await initializeRegistration({ + await initializeRegistration({ ensName: initialEnsName, founders, threshold, durationYears: 1, }) - console.log(LOG_PREFIX, 'initializeRegistration result:', result) - - // Now in 'awaiting-payment' step - UI will show payment button } catch (err) { console.error(LOG_PREFIX, 'Failed to initialize registration:', err) } } - const handleSendPayment = () => { + const handleSendPayment = async () => { + if (!authenticated) { + await connect() + return + } + console.log(LOG_PREFIX, '=== handleSendPayment START ===') console.log(LOG_PREFIX, 'Sending payment to treasury:', treasuryAddress) console.log(LOG_PREFIX, 'Amount:', costBreakdown?.totalEth, 'ETH') sendPayment() } - const isRegistering = - step !== 'idle' && step !== 'failed' && step !== 'completed' - const isAwaitingPayment = step === 'awaiting-payment' + const handleRetryPhase = () => { + retryCurrentPhase().catch((err) => { + console.error(LOG_PREFIX, 'Retry failed:', err) + }) + } + const error = registrationError || localError + const isPaymentInFlight = isSendingPayment || isConfirmingPayment + const paymentAmountEth = costBreakdown + ? parseFloat(costBreakdown.totalEth).toFixed(5) + : null + + const showLaunchFailure = + step === 'failed' && + (Boolean(treasuryAddress) || + Boolean(paymentTxHash) || + failedPhase === 'safe' || + failedPhase === 'startupchain') + const showLaunchSurface = launchSteps.has(step) || showLaunchFailure const disableCreateButton = - isRegistering || + step !== 'idle' || isLoadingCosts || (!authenticated && draft.shareholders.some((founder) => !founder.walletAddress.trim())) || (draft.isMultipleFounders && Math.abs(totalEquity - 100) > 0.01) || (draft.registerToDifferentAddress && !draft.customAddress.trim()) - const createButtonClasses = [ - 'rounded-2xl px-8 py-4 text-lg font-semibold transition-all duration-200', - 'disabled:cursor-not-allowed disabled:opacity-50', - disableCreateButton - ? 'bg-muted text-muted-foreground' - : 'bg-primary text-background hover:bg-primary/90 hover:text-white', - ].join(' ') - return ( -
- - - {/* Show registration progress when in progress */} - {isRegistering && ( -
- -
- -
+
+
+
+
+

+ Create Company +

+

+ Launch {initialEnsName}.eth with confidence +

+

+ You’ll see every step clearly: ENS identity, Safe treasury, and final + StartupChain registration. +

- )} - - {/* Payment step - show when awaiting payment */} - {isAwaitingPayment && costBreakdown && ( -
- -
- -
+
+ + {showLaunchSurface ? ( +
+
- )} - - {/* Hide form fields during registration or payment */} - {!isRegistering && !isAwaitingPayment && ( + ) : (
@@ -334,17 +339,13 @@ export function SetupWizard({ initialEnsName }: SetupWizardProps) { />
-
- {/* Cost breakdown - only show when authenticated */} +
)}
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index c3a7cb6..1dca4bc 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -10,7 +10,7 @@ export default function AuthenticatedLayout({ -
{children}
+
{children}
) diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 19d78ba..3a0758c 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -309,7 +309,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
{ + it('maps user-rejected payment errors to friendly ENS copy', () => { + const output = toUserFacingRegistrationError( + 'User rejected the request', + 'ens' + ) + + expect(output).toBe('Transaction canceled. Nothing was sent. You can retry.') + }) + + it('maps 4001 signature errors to friendly startupchain copy', () => { + const output = toUserFacingRegistrationError( + 'MetaMask Tx Signature: User denied transaction signature. code=4001', + 'startupchain' + ) + + expect(output).toBe( + 'Signature canceled. Your company was not recorded yet. Retry when ready.' + ) + }) + + it('strips long viem request/details noise from non-cancel errors', () => { + const output = toUserFacingRegistrationError( + 'Execution reverted. Request Arguments: from: 0xabc to: 0xdef Details: MetaMask Tx Signature: something Version: viem@2.44.4', + 'safe' + ) + + expect(output).toBe('Execution reverted.') + }) + + it('returns fallback for unknown empty errors', () => { + const output = toUserFacingRegistrationError(undefined, 'ens') + expect(output).toBe('Something went wrong. Please retry.') + }) +}) diff --git a/src/hooks/registration-error-copy.ts b/src/hooks/registration-error-copy.ts new file mode 100644 index 0000000..c0a868a --- /dev/null +++ b/src/hooks/registration-error-copy.ts @@ -0,0 +1,70 @@ +import { type RegistrationPhaseId } from '@/hooks/registration-progress-model' + +const CANCEL_PATTERNS = [ + 'user rejected the request', + 'user denied transaction signature', + 'user denied', + 'action_rejected', + 'rejected', + '4001', +] + +const REQUEST_ARGS_PATTERN = /\s*request arguments:\s*/i +const DETAILS_PATTERN = /\s*details:\s*/i +const VERSION_PATTERN = /\s*version:\s*viem@/i + +function toMessageText(input: unknown): string { + if (typeof input === 'string') return input + if (input instanceof Error) return input.message + if (input && typeof input === 'object') { + const maybeMessage = (input as { message?: unknown }).message + if (typeof maybeMessage === 'string') return maybeMessage + } + return '' +} + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +function stripLowLevelDetails(message: string): string { + let output = message + + output = output.split(REQUEST_ARGS_PATTERN)[0] ?? output + output = output.split(DETAILS_PATTERN)[0] ?? output + output = output.split(VERSION_PATTERN)[0] ?? output + output = output.replace(/^error:\s*/i, '') + + return normalizeWhitespace(output) +} + +function isCancelError(message: string): boolean { + const normalized = message.toLowerCase() + return CANCEL_PATTERNS.some((pattern) => normalized.includes(pattern)) +} + +export function toUserFacingRegistrationError( + input: unknown, + phase: RegistrationPhaseId +): string { + const raw = normalizeWhitespace(toMessageText(input)) + + if (!raw) { + return 'Something went wrong. Please retry.' + } + + if (isCancelError(raw)) { + if (phase === 'startupchain') { + return 'Signature canceled. Your company was not recorded yet. Retry when ready.' + } + + return 'Transaction canceled. Nothing was sent. You can retry.' + } + + const cleaned = stripLowLevelDetails(raw) + if (!cleaned) { + return 'Something went wrong. Please retry.' + } + + return cleaned +} diff --git a/src/hooks/registration-progress-model.test.ts b/src/hooks/registration-progress-model.test.ts new file mode 100644 index 0000000..ad8f2b1 --- /dev/null +++ b/src/hooks/registration-progress-model.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' + +import { + buildRegistrationPhaseView, + inferFailedPhaseFromStep, +} from './registration-progress-model' + +describe('registration progress model', () => { + it('maps waiting window to ENS running and downstream waiting', () => { + const phases = buildRegistrationPhaseView({ + step: 'waiting', + countdown: 37, + }) + + expect(phases).toEqual([ + expect.objectContaining({ id: 'ens', state: 'running' }), + expect.objectContaining({ id: 'safe', state: 'waiting' }), + expect.objectContaining({ id: 'startupchain', state: 'waiting' }), + ]) + expect(phases[0].message).toContain('37s') + }) + + it('maps safe deploy step to ens completed and safe running', () => { + const phases = buildRegistrationPhaseView({ + step: 'deploying-safe', + countdown: null, + }) + + expect(phases).toEqual([ + expect.objectContaining({ id: 'ens', state: 'completed' }), + expect.objectContaining({ id: 'safe', state: 'running' }), + expect.objectContaining({ id: 'startupchain', state: 'waiting' }), + ]) + }) + + it('maps awaiting signature to startupchain running after ens and safe complete', () => { + const phases = buildRegistrationPhaseView({ + step: 'awaiting-signature', + countdown: null, + }) + + expect(phases).toEqual([ + expect.objectContaining({ id: 'ens', state: 'completed' }), + expect.objectContaining({ id: 'safe', state: 'completed' }), + expect.objectContaining({ id: 'startupchain', state: 'running' }), + ]) + }) + + it('pins failed ENS phase and keeps later phases waiting', () => { + const phases = buildRegistrationPhaseView({ + step: 'failed', + failedPhase: 'ens', + error: 'Payment confirmation failed', + countdown: null, + }) + + expect(phases).toEqual([ + expect.objectContaining({ id: 'ens', state: 'failed' }), + expect.objectContaining({ id: 'safe', state: 'waiting' }), + expect.objectContaining({ id: 'startupchain', state: 'waiting' }), + ]) + expect(phases[0].error).toBe('Payment confirmation failed') + }) + + it('pins failed Safe phase and keeps later StartupChain waiting', () => { + const phases = buildRegistrationPhaseView({ + step: 'failed', + failedPhase: 'safe', + error: 'Safe deployment reverted', + countdown: null, + }) + + expect(phases).toEqual([ + expect.objectContaining({ id: 'ens', state: 'completed' }), + expect.objectContaining({ id: 'safe', state: 'failed' }), + expect.objectContaining({ id: 'startupchain', state: 'waiting' }), + ]) + }) + + it('pins failed StartupChain phase with prior phases completed', () => { + const phases = buildRegistrationPhaseView({ + step: 'failed', + failedPhase: 'startupchain', + error: 'Transaction failed', + countdown: null, + }) + + expect(phases).toEqual([ + expect.objectContaining({ id: 'ens', state: 'completed' }), + expect.objectContaining({ id: 'safe', state: 'completed' }), + expect.objectContaining({ id: 'startupchain', state: 'failed' }), + ]) + }) +}) + +describe('inferFailedPhaseFromStep', () => { + it('infers safe for deploying-safe step', () => { + expect(inferFailedPhaseFromStep('deploying-safe')).toBe('safe') + }) + + it('infers startupchain for signature step', () => { + expect(inferFailedPhaseFromStep('awaiting-signature')).toBe('startupchain') + }) + + it('defaults to ens for payment and commit failures', () => { + expect(inferFailedPhaseFromStep('payment-pending')).toBe('ens') + expect(inferFailedPhaseFromStep('committing')).toBe('ens') + }) +}) diff --git a/src/hooks/registration-progress-model.ts b/src/hooks/registration-progress-model.ts new file mode 100644 index 0000000..03e96ad --- /dev/null +++ b/src/hooks/registration-progress-model.ts @@ -0,0 +1,198 @@ +export type RegistrationStep = + | 'idle' + | 'checking' + | 'awaiting-payment' + | 'payment-pending' + | 'committing' + | 'waiting' + | 'deploying-safe' + | 'registering-ens' + | 'awaiting-signature' + | 'signing-company' + | 'completed' + | 'failed' + +export type RegistrationPhaseId = 'ens' | 'safe' | 'startupchain' +export type PhaseState = 'waiting' | 'running' | 'completed' | 'failed' + +export type RegistrationPhaseView = { + id: RegistrationPhaseId + label: string + state: PhaseState + message: string + error?: string +} + +type BuildRegistrationPhaseViewParams = { + step: RegistrationStep + countdown: number | null + error?: string | null + failedPhase?: RegistrationPhaseId | null +} + +const PHASE_ORDER: RegistrationPhaseId[] = ['ens', 'safe', 'startupchain'] +const ENS_RUNNING_STEPS: RegistrationStep[] = [ + 'checking', + 'payment-pending', + 'committing', + 'waiting', + 'registering-ens', +] +const ENS_COMPLETED_STEPS: RegistrationStep[] = [ + 'deploying-safe', + 'awaiting-signature', + 'signing-company', + 'completed', +] +const SAFE_RUNNING_STEPS: RegistrationStep[] = ['deploying-safe'] +const SAFE_COMPLETED_STEPS: RegistrationStep[] = [ + 'awaiting-signature', + 'signing-company', + 'completed', +] +const STARTUPCHAIN_RUNNING_STEPS: RegistrationStep[] = [ + 'awaiting-signature', + 'signing-company', +] +const STARTUPCHAIN_COMPLETED_STEPS: RegistrationStep[] = ['completed'] + +function resolvePhaseStateForFailed( + phase: RegistrationPhaseId, + failedPhase: RegistrationPhaseId +): PhaseState { + const phaseIndex = PHASE_ORDER.indexOf(phase) + const failedIndex = PHASE_ORDER.indexOf(failedPhase) + + if (phaseIndex < failedIndex) return 'completed' + if (phaseIndex > failedIndex) return 'waiting' + return 'failed' +} + +export function inferFailedPhaseFromStep( + step: RegistrationStep, + error?: string | null +): RegistrationPhaseId { + const normalizedError = error?.toLowerCase() ?? '' + + if (step === 'deploying-safe' || normalizedError.includes('safe')) { + return 'safe' + } + + if ( + step === 'awaiting-signature' || + step === 'signing-company' || + normalizedError.includes('record') || + normalizedError.includes('signature') || + normalizedError.includes('wallet sign') || + normalizedError.includes('company transaction') + ) { + return 'startupchain' + } + + return 'ens' +} + +function resolvePhaseState( + phase: RegistrationPhaseId, + step: RegistrationStep, + failedPhase?: RegistrationPhaseId | null +): PhaseState { + if (step === 'failed') { + return resolvePhaseStateForFailed(phase, failedPhase ?? 'ens') + } + + if (phase === 'ens') { + if (ENS_RUNNING_STEPS.includes(step)) return 'running' + if (ENS_COMPLETED_STEPS.includes(step)) return 'completed' + return 'waiting' + } + + if (phase === 'safe') { + if (SAFE_RUNNING_STEPS.includes(step)) return 'running' + if (SAFE_COMPLETED_STEPS.includes(step)) return 'completed' + return 'waiting' + } + + if (STARTUPCHAIN_RUNNING_STEPS.includes(step)) return 'running' + if (STARTUPCHAIN_COMPLETED_STEPS.includes(step)) return 'completed' + return 'waiting' +} + +function resolveMessage( + phase: RegistrationPhaseId, + state: PhaseState, + step: RegistrationStep, + countdown: number | null +): string { + if (phase === 'ens') { + if (state === 'failed') return 'ENS registration failed. Please retry this step.' + if (state === 'completed') return 'ENS domain is now registered to your company Safe.' + if (state === 'running') { + if (step === 'payment-pending') return 'Confirming your payment onchain.' + if (step === 'committing') return 'Submitting ENS commitment.' + if (step === 'waiting') { + return `Waiting for ENS safety window (${countdown ?? 0}s remaining).` + } + if (step === 'registering-ens') { + return 'Registering your ENS domain to the Safe address.' + } + return 'Preparing ENS registration.' + } + if (step === 'awaiting-payment') return 'Waiting for your payment to begin.' + return 'This step starts first.' + } + + if (phase === 'safe') { + if (state === 'failed') return 'Safe creation failed. Please retry this step.' + if (state === 'completed') return 'Safe wallet created and linked.' + if (state === 'running') return 'Creating your Safe wallet.' + return 'Starts after ENS registration completes.' + } + + if (state === 'failed') { + return 'StartupChain registration failed. Please retry this step.' + } + if (state === 'completed') return 'Company recorded on StartupChain.' + if (state === 'running') { + if (step === 'awaiting-signature') { + return 'Waiting for your signature to finalize registration.' + } + return 'Submitting company registration transaction.' + } + return 'Starts after Safe creation completes.' +} + +export function buildRegistrationPhaseView({ + step, + countdown, + error, + failedPhase, +}: BuildRegistrationPhaseViewParams): RegistrationPhaseView[] { + const phases: Array<{ id: RegistrationPhaseId; label: string }> = [ + { id: 'ens', label: 'ENS domain registration' }, + { id: 'safe', label: 'Safe creation' }, + { id: 'startupchain', label: 'StartupChain company registration' }, + ] + + return phases.map(({ id, label }) => { + const state = resolvePhaseState(id, step, failedPhase) + const message = resolveMessage(id, state, step, countdown) + + if (state === 'failed' && error) { + return { + id, + label, + state, + message, + error, + } + } + + return { + id, + label, + state, + message, + } + }) +} diff --git a/src/hooks/use-company-registration.ts b/src/hooks/use-company-registration.ts index 0a4c00f..fe3766c 100644 --- a/src/hooks/use-company-registration.ts +++ b/src/hooks/use-company-registration.ts @@ -19,23 +19,15 @@ import { COMPANY_REGISTRATION_PENDING_MESSAGE, shouldBlockRecordCompanySubmit, } from '@/hooks/record-company-submit-guard' +import { toUserFacingRegistrationError } from '@/hooks/registration-error-copy' +import { + inferFailedPhaseFromStep, + type RegistrationPhaseId, + type RegistrationStep, +} from '@/hooks/registration-progress-model' const LOG_PREFIX = '[CLIENT:useCompanyRegistration]' -type RegistrationStep = - | 'idle' - | 'checking' - | 'awaiting-payment' - | 'payment-pending' - | 'committing' - | 'waiting' - | 'deploying-safe' - | 'registering-ens' - | 'awaiting-signature' - | 'signing-company' - | 'completed' - | 'failed' - type FounderInput = { address: string equity: string @@ -58,8 +50,21 @@ function toFounderPayload(founders: FounderInput[]) { })) } +function extractErrorMessage(input: unknown): string { + if (typeof input === 'string') return input + if (input instanceof Error) return input.message + if (input && typeof input === 'object') { + const maybeMessage = (input as { message?: unknown }).message + if (typeof maybeMessage === 'string') return maybeMessage + } + return '' +} + export function useCompanyRegistration() { const [step, setStep] = useState('idle') + const [failedPhase, setFailedPhase] = useState( + null + ) const [countdown, setCountdown] = useState(null) const [costBreakdown, setCostBreakdown] = useState(null) const [canComplete, setCanComplete] = useState(false) @@ -98,36 +103,53 @@ export function useCompanyRegistration() { hash: recordCompanyTxHash, }) + const transitionToStep = useCallback((nextStep: RegistrationStep) => { + if (nextStep !== 'failed') { + setFailedPhase(null) + } + setStep(nextStep) + }, []) + + const moveToFailed = useCallback( + (input: unknown, failedAt: RegistrationStep) => { + const rawMessage = extractErrorMessage(input) + const phase = inferFailedPhaseFromStep(failedAt, rawMessage) + const userFacingMessage = toUserFacingRegistrationError(input, phase) + setError(userFacingMessage) + setFailedPhase(phase) + setStep('failed') + }, + [] + ) + // Handle payment tx hash from wagmi useEffect(() => { if (txHash && step === 'awaiting-payment') { console.log(LOG_PREFIX, 'Payment tx hash received:', txHash) setPaymentTxHash(txHash) - setStep('payment-pending') + transitionToStep('payment-pending') } - }, [txHash, step]) + }, [txHash, step, transitionToStep]) // Handle payment errors useEffect(() => { if (sendError && step === 'awaiting-payment') { console.log(LOG_PREFIX, 'Payment send error:', sendError) - setError(sendError.message || 'Failed to send payment') - setStep('failed') + moveToFailed(sendError, 'awaiting-payment') } if (confirmError && step === 'payment-pending') { console.log(LOG_PREFIX, 'Payment confirm error:', confirmError) - setError(confirmError.message || 'Payment transaction failed') - setStep('failed') + moveToFailed(confirmError, 'payment-pending') } - }, [sendError, confirmError, step]) + }, [sendError, confirmError, step, moveToFailed]) // Handle company signing - track tx hash useEffect(() => { if (recordCompanyTxHash && step === 'awaiting-signature') { console.log(LOG_PREFIX, 'recordCompany tx hash received:', recordCompanyTxHash) - setStep('signing-company') + transitionToStep('signing-company') } - }, [recordCompanyTxHash, step]) + }, [recordCompanyTxHash, step, transitionToStep]) const releaseRecordCompanySubmitLock = useCallback(() => { recordCompanySubmitLockedRef.current = false @@ -141,7 +163,7 @@ export function useCompanyRegistration() { confirmRecordCompanyAction({ companyTxHash: recordCompanyTxHash }) .then(() => { console.log(LOG_PREFIX, 'Registration completed!') - setStep('completed') + transitionToStep('completed') setCanComplete(false) setCountdown(null) releaseRecordCompanySubmitLock() @@ -149,7 +171,7 @@ export function useCompanyRegistration() { .catch((err) => { console.log(LOG_PREFIX, 'Error confirming record:', err) // Still mark as completed since tx was confirmed on-chain - setStep('completed') + transitionToStep('completed') releaseRecordCompanySubmitLock() }) } @@ -158,20 +180,19 @@ export function useCompanyRegistration() { recordCompanyTxHash, step, releaseRecordCompanySubmitLock, + transitionToStep, ]) // Handle company signing errors useEffect(() => { if (recordCompanyError && step === 'awaiting-signature') { console.log(LOG_PREFIX, 'recordCompany sign error:', recordCompanyError) - setError(recordCompanyError.message || 'Failed to sign transaction') - setStep('failed') + moveToFailed(recordCompanyError, 'awaiting-signature') releaseRecordCompanySubmitLock() } if (companyConfirmError && step === 'signing-company') { console.log(LOG_PREFIX, 'recordCompany confirm error:', companyConfirmError) - setError(companyConfirmError.message || 'Transaction failed') - setStep('failed') + moveToFailed(companyConfirmError, 'signing-company') releaseRecordCompanySubmitLock() } }, [ @@ -179,6 +200,7 @@ export function useCompanyRegistration() { companyConfirmError, step, releaseRecordCompanySubmitLock, + moveToFailed, ]) // Countdown timer for waiting step @@ -242,29 +264,26 @@ export function useCompanyRegistration() { console.log(LOG_PREFIX, '=== initializeRegistration START ===') console.log(LOG_PREFIX, 'Input:', { ensName, founders, threshold, durationYears }) setError(null) - setStep('checking') + transitionToStep('checking') const invalidFounder = founders.find( (founder) => !isAddress(founder.address) ) if (invalidFounder) { const message = 'Please provide valid founder wallet addresses' - setError(message) - setStep('failed') + moveToFailed(message, 'checking') throw new Error(message) } if (founders.length === 0) { const message = 'Add at least one founder to continue' - setError(message) - setStep('failed') + moveToFailed(message, 'checking') throw new Error(message) } if (threshold > founders.length || threshold <= 0) { const message = 'Threshold must be between 1 and the number of founders' - setError(message) - setStep('failed') + moveToFailed(message, 'checking') throw new Error(message) } @@ -284,12 +303,12 @@ export function useCompanyRegistration() { await calculateCosts(ensName, durationYears, founders.length) } - setStep('awaiting-payment') + transitionToStep('awaiting-payment') console.log(LOG_PREFIX, '=== initializeRegistration COMPLETE - awaiting payment ===') return { treasuryAddress: treasury.address } }, - [costBreakdown, calculateCosts] + [costBreakdown, calculateCosts, moveToFailed, transitionToStep] ) // Step 2: Send payment to treasury @@ -315,12 +334,11 @@ export function useCompanyRegistration() { if (!ensNameRef.current) { const message = 'No pending registration found' - setError(message) - setStep('failed') + moveToFailed(message, 'committing') throw new Error(message) } - setStep('committing') + transitionToStep('committing') console.log(LOG_PREFIX, 'Calling commitEnsRegistrationAction...') try { @@ -337,19 +355,16 @@ export function useCompanyRegistration() { readyAtRef.current = result.readyAt setCountdown(Math.max(0, Math.ceil((result.readyAt - Date.now()) / 1000))) setCanComplete(false) - setStep(result.status === 'waiting' ? 'waiting' : 'committing') + transitionToStep(result.status === 'waiting' ? 'waiting' : 'committing') console.log(LOG_PREFIX, '=== proceedAfterPayment COMPLETE ===') return result } catch (err) { console.log(LOG_PREFIX, 'ERROR in proceedAfterPayment:', err) - const message = - err instanceof Error ? err.message : 'Failed to start registration' - setError(message) - setStep('failed') + moveToFailed(err, 'committing') throw err } - }, [paymentTxHash]) + }, [paymentTxHash, moveToFailed, transitionToStep]) // Handle payment confirmation - auto-proceed to commit useEffect(() => { @@ -365,13 +380,12 @@ export function useCompanyRegistration() { if (!ensNameRef.current) { const message = 'No pending registration found' console.log(LOG_PREFIX, 'ERROR:', message) - setError(message) - setStep('failed') + moveToFailed(message, 'deploying-safe') throw new Error(message) } setError(null) - setStep('deploying-safe') + transitionToStep('deploying-safe') console.log(LOG_PREFIX, 'Calling finalizeEnsRegistrationAction...') try { @@ -383,20 +397,20 @@ export function useCompanyRegistration() { // Update step based on status if (result.status === 'deploying-safe') { console.log(LOG_PREFIX, 'Status: deploying-safe') - setStep('deploying-safe') + transitionToStep('deploying-safe') } else if (result.status === 'registering') { console.log(LOG_PREFIX, 'Status: registering-ens') - setStep('registering-ens') + transitionToStep('registering-ens') } else if (result.status === 'ready-to-record') { // ENS registered, now user needs to sign recordCompany() console.log(LOG_PREFIX, 'Status: ready-to-record - awaiting user signature') if (result.safeAddress) { safeAddressRef.current = result.safeAddress } - setStep('awaiting-signature') + transitionToStep('awaiting-signature') } else if (result.status === 'completed') { console.log(LOG_PREFIX, 'Status: completed!') - setStep('completed') + transitionToStep('completed') setCanComplete(false) setCountdown(null) if (result.safeAddress) { @@ -408,13 +422,10 @@ export function useCompanyRegistration() { return result } catch (err) { console.log(LOG_PREFIX, 'ERROR in completeRegistration:', err) - const message = - err instanceof Error ? err.message : 'Failed to finalize registration' - setError(message) - setStep('failed') + moveToFailed(err, 'deploying-safe') throw err } - }, []) + }, [moveToFailed, transitionToStep]) // F1: Sign recordCompany() with user's wallet const signRecordCompany = useCallback(async () => { @@ -457,11 +468,8 @@ export function useCompanyRegistration() { }) } catch (err) { console.log(LOG_PREFIX, 'ERROR in signRecordCompany:', err) - const message = - err instanceof Error ? err.message : 'Failed to prepare transaction' releaseRecordCompanySubmitLock() - setError(message) - setStep('failed') + moveToFailed(err, 'awaiting-signature') } }, [ step, @@ -470,6 +478,7 @@ export function useCompanyRegistration() { recordCompanyTxHash, writeRecordCompany, releaseRecordCompanySubmitLock, + moveToFailed, ]) // Resume registration from session cookie on mount @@ -508,22 +517,22 @@ export function useCompanyRegistration() { // Restore step switch (pending.status) { case 'waiting': - setStep('waiting') + transitionToStep('waiting') setCountdown(Math.max(0, Math.ceil((pending.readyAt - Date.now()) / 1000))) setCanComplete(false) break case 'deploying-safe': - setStep('deploying-safe') + transitionToStep('deploying-safe') break case 'registering': // Mapped to registering-ens - setStep('registering-ens') + transitionToStep('registering-ens') break case 'ready-to-record': // Mapped to awaiting-signature - setStep('awaiting-signature') + transitionToStep('awaiting-signature') break default: // For committing, creating, etc. maybe just idle or specific steps - if (pending.status === 'committing') setStep('committing') + if (pending.status === 'committing') transitionToStep('committing') } } catch (err) { console.error(LOG_PREFIX, 'Failed to resume registration:', err) @@ -532,7 +541,36 @@ export function useCompanyRegistration() { resume() return () => { mounted = false } - }, []) + }, [transitionToStep]) + + const retryCurrentPhase = useCallback(async () => { + if (step !== 'failed') return + + setError(null) + + if (failedPhase === 'ens') { + if (paymentTxHash && ensNameRef.current) { + await proceedAfterPayment() + return + } + transitionToStep('awaiting-payment') + return + } + + if (failedPhase === 'safe' || failedPhase === 'startupchain') { + await completeRegistration() + return + } + + transitionToStep('awaiting-payment') + }, [ + step, + failedPhase, + paymentTxHash, + proceedAfterPayment, + completeRegistration, + transitionToStep, + ]) const reset = useCallback(() => { console.log(LOG_PREFIX, 'reset called') @@ -543,20 +581,21 @@ export function useCompanyRegistration() { foundersRef.current = [] thresholdRef.current = 1 durationYearsRef.current = 1 - setStep('idle') + transitionToStep('idle') setCountdown(null) setCanComplete(false) setCostBreakdown(null) setError(null) setTreasuryAddress(null) setPaymentTxHash(null) - }, [releaseRecordCompanySubmitLock]) + }, [releaseRecordCompanySubmitLock, transitionToStep]) const safeAddress = useMemo(() => safeAddressRef.current, []) return { // State step, + failedPhase, countdown, costBreakdown, canComplete, @@ -578,6 +617,7 @@ export function useCompanyRegistration() { sendPayment, completeRegistration, signRecordCompany, + retryCurrentPhase, reset, } }