- 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 (
+
+
+
+
+
+
{phase.label}
+
+ {statusLabel[phase.state]}
+
+
+
{phase.message}
+ {phase.error && (
+
+ {phase.error}
+
+ )}
+
+
+
+
+ )
+ })}
+
+
+ {permissionDenied && step === 'awaiting-payment' && (
+
+ Connect your wallet to continue with payment.
)}
- {step === 'awaiting-signature' && onSign && (
-
+
+
+ {step === 'failed' ? : }
+ {primaryAction.label}
+
+
+ {step === 'awaiting-payment' && onBackToEdit && (
- {isSignSubmitting ? (
-
-
- Submitting...
-
- ) : (
- 'Sign Transaction'
- )}
+ Back to edit founders
-
- 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 */}
+
{authenticated && (
-
+
)}
{error && (
-
+
)}
@@ -353,17 +354,19 @@ export function SetupWizard({ initialEnsName }: SetupWizardProps) {
type="button"
onClick={handleCreateBusiness}
disabled={disableCreateButton}
- className={`${createButtonClasses} w-full`}
+ className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring w-full rounded-2xl px-6 py-4 text-base font-semibold transition duration-200 ease-out disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none"
>
{isLoadingCosts
- ? 'Calculating...'
+ ? 'Preparing launch...'
: !authenticated
? 'Connect Wallet'
- : costBreakdown
- ? `Pay ${parseFloat(costBreakdown.totalEth).toFixed(5)} ETH`
- : 'Create Business'}
+ : 'Continue to launch'}
-
+
+ Next screen will ask for one payment, then guide you through the 3
+ launch phases.
+
+
)}
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,
}
}