/** * InvitationImportFlow — orchestrates the multi-step invitation import. * * Manages the step state machine, accumulates data from each step, and * injects it into the next step via props (dependency injection). * * Supports two display modes: * - `'dialog'`: renders as an absolute-positioned overlay (used when called from InvitationScreen) * - `'screen'`: renders as a full-screen component with header, step indicator, and button bar */ import React, { useState, useCallback } from 'react'; import { Box, Text } from 'ink'; import { colors, logoSmall } from '../../../theme.js'; import { StepIndicator, type Step } from '../../../components/ProgressBar.js'; import { FetchInvitationStep } from './steps/FetchInvitationStep.js'; import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js'; import { RoleSelectStep } from './steps/RoleSelectStep.js'; import { VariablesStep } from './steps/VariablesStep.js'; import { InputsSelectStep } from './steps/InputsSelectStep.js'; import { ReviewStep } from './steps/ReviewStep.js'; import { IMPORT_STEPS, type ImportFlowProps, type ImportStepType, type ImportVariableInput, type SelectableUTXO } from './types.js'; import type { Invitation } from '../../../../services/invitation.js'; import type { XOTemplate } from '@xo-cash/types'; import { DialogWrapper } from '../../../components/Dialog.js'; import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js'; import { hexToBin } from '@bitauth/libauth'; /** Default fee estimate in satoshis. */ const DEFAULT_FEE = 500n; /** Dust threshold — outputs below this are unspendable. */ const DUST_THRESHOLD = 546n; /** * Resolve the fixed index of a flow step from `IMPORT_STEPS`. * We centralize this so step transitions do not rely on magic numbers. */ function getStepIndex(type: ImportStepType): number { const index = IMPORT_STEPS.findIndex((step) => step.type === type); if (index === -1) { throw new Error(`Import step not found: ${type}`); } return index; } const PREVIEW_STEP_INDEX = getStepIndex('preview'); const ROLE_SELECT_STEP_INDEX = getStepIndex('role-select'); const VARIABLES_STEP_INDEX = getStepIndex('variables'); const INPUTS_SELECT_STEP_INDEX = getStepIndex('inputs-select'); const REVIEW_STEP_INDEX = getStepIndex('review'); export function InvitationImportFlow({ invitationId, mode, appService, onClose, showError, showInfo, setStatus, }: ImportFlowProps): React.ReactElement { // ── Accumulated state ──────────────────────────────────────────────────── const [currentStep, setCurrentStep] = useState(0); const [invitation, setInvitation] = useState(null); const [template, setTemplate] = useState(null); const [availableRoles, setAvailableRoles] = useState([]); const [selectedRole, setSelectedRole] = useState(null); const [variableInputs, setVariableInputs] = useState([]); const [selectedInputs, setSelectedInputs] = useState([]); const [changeAmount, setChangeAmount] = useState(0n); const [requiredAmount, setRequiredAmount] = useState(0n); // ── Cancel handler ─────────────────────────────────────────────────────── /** * Cleans up (removes the invitation if it was fetched) and signals the parent. */ const handleCancel = useCallback(async () => { if (invitation && appService) { try { await appService.removeInvitation(invitation); } catch { // Best-effort removal — don't block close on failure } } onClose(); }, [invitation, appService, onClose]); // ── Step completion callbacks ──────────────────────────────────────────── /** * FetchStep completed — invitation and template are now available. * Also pre-fetches available roles for the next steps. */ const handleFetchComplete = useCallback(async (inv: Invitation, tmpl: XOTemplate | null) => { setInvitation(inv); setTemplate(tmpl); try { const roles = await inv.getAvailableRoles(); setAvailableRoles(roles); } catch (err) { showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`); } setCurrentStep(PREVIEW_STEP_INDEX); // → Preview }, [showError]); /** PreviewStep completed — user reviewed the invitation state and wants to proceed. */ const handlePreviewComplete = useCallback(() => { setCurrentStep(ROLE_SELECT_STEP_INDEX); // → Role Select }, []); /** RoleSelectStep completed — user picked a role. */ const handleRoleComplete = useCallback((role: string) => { setSelectedRole(role); const action = template?.actions?.[invitation?.data.actionIdentifier ?? ""]; const roleRequirements = action?.roles?.[role]?.requirements?.variables ?? []; const hasRequiredVariables = roleRequirements.length > 0; if (!hasRequiredVariables) { setVariableInputs([]); setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select return; } const initializedVariables: ImportVariableInput[] = roleRequirements.map((variableId) => { const variableDefinition = template?.variables?.[variableId]; return { id: variableId, name: variableDefinition?.name ?? variableId, type: variableDefinition?.type ?? 'string', hint: variableDefinition?.hint, value: '', }; }); setVariableInputs(initializedVariables); setCurrentStep(VARIABLES_STEP_INDEX); // → Variables }, [template, invitation]); /** VariablesStep edited a field value. */ const handleVariableUpdate = useCallback((index: number, value: string) => { setVariableInputs((previous) => { const updated = [...previous]; const current = updated[index]; if (current) { updated[index] = { ...current, value }; } return updated; }); }, []); /** * Convert variable input value to its invitation payload representation. * Numeric variables are persisted as bigint so they match action wizard behavior. */ const parseVariableValue = useCallback((variable: ImportVariableInput) => { const variableHint = variable.hint?.toLowerCase(); const isNumeric = ['integer', 'number', 'satoshis'].includes(variable.type) || (variableHint !== undefined && ['satoshis', 'amount'].includes(variableHint)); if (!isNumeric) { return variable.value; } return BigInt(variable.value || '0'); }, []); /** VariablesStep completed — persist variables then continue to input selection. */ const handleVariablesComplete = useCallback(async () => { if (!invitation || !selectedRole) return; const emptyVariables = variableInputs.filter((variable) => variable.value.trim() === ''); if (emptyVariables.length > 0) { showError(`Please enter values for: ${emptyVariables.map((variable) => variable.name).join(', ')}`); return; } try { await invitation.addVariables( variableInputs.map((variable) => ({ variableIdentifier: variable.id, roleIdentifier: selectedRole, value: parseVariableValue(variable), })), ); setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select } catch (error) { showError( `Failed to add variables: ${error instanceof Error ? error.message : String(error)}`, ); } }, [invitation, selectedRole, variableInputs, parseVariableValue, showError]); /** InputsSelectStep completed — user selected UTXOs. */ const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => { setSelectedInputs(inputs); await invitation?.addInputs(inputs.map(input => ({ outpointTransactionHash: hexToBin(input.outpointTransactionHash), outpointIndex: input.outpointIndex, }))); // Compute totals from selected inputs const totalSelected = inputs.reduce((sum, u) => sum + u.valueSatoshis, 0n); // Determine required amount from invitation variables const requiredSats = await invitation?.getSatsOut() ?? 0n; setRequiredAmount(requiredSats); // Set the change amount for the review step const changeAmountSats = totalSelected - requiredSats - DEFAULT_FEE; setChangeAmount(changeAmountSats); // Add the change output if it exceeds the dust threshold if (changeAmountSats >= DUST_THRESHOLD) { await invitation?.addOutputs([{ valueSatoshis: changeAmountSats, }]); } setCurrentStep(REVIEW_STEP_INDEX); // → Review }, [invitation]); /** ReviewStep completed — invitation import is done. */ const handleReviewComplete = useCallback(() => { const roleName = (() => { if (!selectedRole || !template) return selectedRole ?? ''; const raw = template.roles?.[selectedRole]; return (raw && typeof raw === 'object' && 'name' in raw) ? String(raw.name) : selectedRole; })(); showInfo( `Invitation imported and accepted!\n\n` + `Role: ${roleName}\n` + `Template: ${template?.name ?? invitation?.data.templateIdentifier ?? 'Unknown'}\n` + `Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}` ); setStatus('Ready'); onClose(invitation?.data.invitationIdentifier); }, [selectedRole, template, invitation, showInfo, setStatus, onClose]); // ── Keyboard handling ──────────────────────────────────────────────────── // The import flow registers its own layer so it captures input above the // parent screen. Individual steps also register sub-layers when needed. useInputLayer('import-flow'); useLayeredInput('import-flow', (_input, key) => { if (currentStep !== 0) return; // Enter retries, Esc cancels — handled within FetchStep rendering, // but we also catch Esc here for safety. if (key.escape) handleCancel(); }); // ── Step router ────────────────────────────────────────────────────────── const renderStep = (): React.ReactNode => { const stepDef = IMPORT_STEPS[currentStep]; if (!stepDef) return null; switch (stepDef.type) { case 'fetch': return ( ); case 'preview': if (!invitation) return null; return ( ); case 'role-select': if (!invitation) return null; return ( ); case 'variables': return ( ); case 'inputs-select': if (!invitation || !selectedRole) return null; return ( ); case 'review': if (!invitation || !selectedRole) return null; return ( ); default: return null; } }; // ── Step indicator data ────────────────────────────────────────────────── const indicatorSteps: Step[] = IMPORT_STEPS.map(s => ({ label: s.name })); // ── Layout: dialog mode ────────────────────────────────────────────────── if (mode === 'dialog') { return ( {/* Step indicator (compact) */} {/* Step content */} {renderStep()} ); } // ── Layout: screen mode ────────────────────────────────────────────────── return ( {/* Header */} {logoSmall} - Import Invitation {template?.name ?? 'Loading...'} {selectedRole ? ` (as ${selectedRole})` : ''} {/* Step indicator */} {/* Step content */} {IMPORT_STEPS[currentStep]?.name ?? 'Unknown'} ({currentStep + 1}/{IMPORT_STEPS.length}) {renderStep()} {/* Help text */} Esc: Cancel import ); }