/** * 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 { InputsSelectStep } from './steps/InputsSelectStep.js'; import { ReviewStep } from './steps/ReviewStep.js'; import { IMPORT_STEPS, type ImportFlowProps, 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 { InvitationBuilder } from '@xo-cash/engine'; 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; 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 [buildableInvitation, setBuildableInvitation] = useState(null); const [template, setTemplate] = useState(null); const [availableRoles, setAvailableRoles] = useState([]); const [selectedRole, setSelectedRole] = useState(null); 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); const builder = InvitationBuilder.fromInvitation(inv.data); setBuildableInvitation(builder); try { const roles = await inv.getAvailableRoles(); setAvailableRoles(roles); } catch (err) { showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`); } setCurrentStep(1); // → Preview }, [showError]); /** PreviewStep completed — user reviewed the invitation state and wants to proceed. */ const handlePreviewComplete = useCallback(() => { setCurrentStep(2); // → Role Select }, []); /** RoleSelectStep completed — user picked a role. */ const handleRoleComplete = useCallback((role: string) => { setSelectedRole(role); setCurrentStep(3); // → Inputs Select }, []); /** 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(4); // → Review }, [invitation, buildableInvitation, selectedInputs]); /** 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(); }, [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 '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 ); }