import { useState, useMemo, useCallback, useEffect } from "react"; import { useNavigation } from "../../../hooks/useNavigation.js"; import { useAppContext, useStatus } from "../../../hooks/useAppContext.js"; import { copyToClipboard } from "../../../utils/clipboard.js"; import { roleRequiresInputs } from "../../../../utils/invitation-flow.js"; import type { XOTemplate } from "@xo-cash/types"; import type { StepConfig, FlowContext, DataResult } from "../types.js"; import { createWizardFlow, type WizardFlow, DataWizardFlow, } from "../flows/index.js"; import { useRoleSelection } from "./useRoleSelection.js"; import { useVariableInputs } from "./useVariableInputs.js"; import { useUtxoSelection } from "./useUtxoSelection.js"; import { useInvitationManager } from "./useInvitationManager.js"; import { useWizardFocus } from "./useWizardFocus.js"; import { useWizardSteps } from "./useWizardSteps.js"; /** * Thin orchestrator that composes domain hooks and wires them * to step configs produced by the WizardFlow strategy. * * This replaces the original 861-line god-hook. */ export function useActionWizard() { const { goBack, data: navData } = useNavigation(); const { appService, showError, showInfo } = useAppContext(); const { setStatus } = useStatus(); if (!appService) { throw new Error("AppService not initialized"); } // ── Navigation data ─────────────────────────────────────────── const templateIdentifier = navData.templateIdentifier as string | undefined; const actionIdentifier = navData.actionIdentifier as string | undefined; const template = navData.template as XOTemplate | undefined; const actionRolesFromNavigation = navData.actionRoles as string[] | undefined; // ── Derived template data ───────────────────────────────────── const action = template?.actions?.[actionIdentifier ?? ""]; const actionName = action?.name || actionIdentifier || "Unknown"; // ── Flow strategy ───────────────────────────────────────────── const flow = useMemo(() => { // Create a default action if no action is found if (!action) { return createWizardFlow({ name: "", description: "" }); } // Create the flow from the action return createWizardFlow(action); }, [action]); // ── Domain hooks ────────────────────────────────────────────── const roleSelection = useRoleSelection( template, actionIdentifier, actionRolesFromNavigation, ); const variableInputs = useVariableInputs(); const utxoSelection = useUtxoSelection(); const invitationManager = useInvitationManager({ appService, showError, showInfo, setStatus, }); const focus = useWizardFocus(); // ── Data results (data-only flows) ──────────────────────────── const [dataResults, setDataResults] = useState([]); // ── Initialize variables when role becomes available ────────── useEffect(() => { if (template && actionIdentifier && roleSelection.effectiveRole) { const act = template.actions?.[actionIdentifier]; const role = act?.roles?.[roleSelection.effectiveRole]; const varIds = role?.requirements?.variables; if (varIds && varIds.length > 0) { variableInputs.initFromTemplate( template, actionIdentifier, roleSelection.effectiveRole, ); } } }, [ template, actionIdentifier, roleSelection.effectiveRole, variableInputs.initFromTemplate, ]); // ── Determine whether creator should provide inputs ─────────── const shouldCollectInputs = useMemo(() => { if (flow.type !== "transaction") return false; if (!template || !actionIdentifier || !roleSelection.effectiveRole) return false; const act = template.actions?.[actionIdentifier]; const totalActionRoles = Object.keys(act?.roles ?? {}).length; const isSingleRoleAction = totalActionRoles <= 1; return ( isSingleRoleAction && roleRequiresInputs( template, actionIdentifier, roleSelection.effectiveRole, ) ); }, [flow.type, template, actionIdentifier, roleSelection.effectiveRole]); // ── Build flow context for strategy methods ─────────────────── const flowContext = useMemo( () => ({ availableRoles: roleSelection.availableRoles, hasVariables: variableInputs.variables.length > 0, shouldCollectInputs, requirementsComplete: invitationManager.requirementsComplete, wizardCollectedInputs: shouldCollectInputs, hasSignedAndBroadcasted: invitationManager.hasSignedAndBroadcasted, }), [ roleSelection.availableRoles, variableInputs.variables.length, shouldCollectInputs, invitationManager.requirementsComplete, invitationManager.hasSignedAndBroadcasted, ], ); // ── Handle Enter inside a TextInput ─────────────────────────── const handleTextInputSubmit = useCallback(() => { if (focus.focusedInput < variableInputs.variables.length - 1) { focus.setFocusedInput((prev) => prev + 1); } else { focus.moveToButtons(); } }, [ focus.focusedInput, variableInputs.variables.length, focus.setFocusedInput, focus.moveToButtons, ]); // ── Copy invitation ID to clipboard ─────────────────────────── const copyId = useCallback(async () => { if (!invitationManager.invitationId) return; try { await copyToClipboard(invitationManager.invitationId); showInfo(`Copied to clipboard!\n\n${invitationManager.invitationId}`); } catch (error) { showError( `Failed to copy: ${error instanceof Error ? error.message : String(error)}`, ); } }, [invitationManager.invitationId, showInfo, showError]); // ── Helper: create invitation if it doesn't exist yet ───────── const ensureInvitation = useCallback( async (roleId?: string): Promise => { if (invitationManager.invitationId) return invitationManager.invitationId; const role = roleId ?? roleSelection.effectiveRole; if (!templateIdentifier || !actionIdentifier || !role || !template) return null; return invitationManager.createWithVariables( templateIdentifier, actionIdentifier, role, template, variableInputs.variables, ); }, [ invitationManager.invitationId, invitationManager.createWithVariables, roleSelection.effectiveRole, templateIdentifier, actionIdentifier, template, variableInputs.variables, ], ); // ── Helper: load UTXOs after invitation is created ──────────── const loadUtxosForInvitation = useCallback( async (invId: string) => { if (!appService || !templateIdentifier) return; const instance = appService.invitations.find( (inv) => inv.data.invitationIdentifier === invId, ); if (instance) { invitationManager.setIsProcessing(true); try { await utxoSelection.loadUtxos( instance, templateIdentifier, variableInputs.variables, setStatus, ); } finally { invitationManager.setIsProcessing(false); } } }, [ appService, templateIdentifier, variableInputs.variables, utxoSelection.loadUtxos, invitationManager.setIsProcessing, setStatus, ], ); // ── Build step configs from flow strategy ───────────────────── const stepConfigs = useMemo(() => { const stepTypes = flow.getStepTypes(flowContext); return stepTypes.map((type): StepConfig => { switch (type) { case "role-select": return { type, name: "Select Role", validate: () => { const selectedRole = roleSelection.availableRoles[roleSelection.selectedRoleIndex]; return selectedRole ? null : "Please select a role"; }, onNext: async () => { const selectedRole = roleSelection.availableRoles[roleSelection.selectedRoleIndex]; if (!selectedRole) return false; // Initialize variables for this role immediately if (template && actionIdentifier) { const act = template.actions?.[actionIdentifier]; const role = act?.roles?.[selectedRole]; const hasVars = (role?.requirements?.variables?.length ?? 0) > 0; if (hasVars) { variableInputs.initFromTemplate( template, actionIdentifier, selectedRole, ); } // If no variables step follows, create the invitation now (transaction flows only) if (!hasVars && flow.type === "transaction") { if (templateIdentifier && template) { const invId = await invitationManager.createWithVariables( templateIdentifier, actionIdentifier, selectedRole, template, [], ); if (!invId) return false; // Pre-load UTXOs if the inputs step follows const totalRoles = Object.keys(act?.roles ?? {}).length; const needsInputs = totalRoles <= 1 && roleRequiresInputs( template, actionIdentifier, selectedRole, ); if (needsInputs) { await loadUtxosForInvitation(invId); } } } } roleSelection.setRoleIdentifier(selectedRole); focus.resetToContent(); return true; }, }; case "variables": return { type, name: "Variables", validate: () => variableInputs.validate(), onNext: async () => { if (flow.type === "transaction") { if ( !templateIdentifier || !actionIdentifier || !template || !roleSelection.effectiveRole ) return false; const invId = await invitationManager.createWithVariables( templateIdentifier, actionIdentifier, roleSelection.effectiveRole, template, variableInputs.variables, ); if (!invId) return false; // Pre-load UTXOs if the inputs step follows if (shouldCollectInputs) { await loadUtxosForInvitation(invId); } } // For data flows, just advance — variables are used in the result step focus.resetToContent(); return true; }, }; case "inputs": return { type, name: "Select UTXOs", validate: () => utxoSelection.validate(), onNext: async () => { const selectedUtxos = utxoSelection.availableUtxos.filter( (u) => u.selected, ); const success = await invitationManager.addInputsAndOutputs( selectedUtxos, utxoSelection.changeAmount, ); if (success) focus.resetToContent(); return success; }, }; case "review": return { type, name: "Review", validate: () => null, onNext: async () => { // Ensure invitation exists (covers the case where no prior step created it) const invId = await ensureInvitation(); if (!invId) return false; await invitationManager.refreshRequirements(invId); focus.resetToContent(); return true; }, }; case "publish": return { type, name: "Publish", validate: () => null, onNext: async () => { if (flow.canFinalize(flowContext)) { await invitationManager.signAndBroadcast(); // Stay on publish step (it's the last step, stepper won't advance) return true; } goBack(); return true; }, }; case "result": return { type, name: "Result", validate: () => null, onNext: async () => { // Data-only flows: populate stubbed results, then exit if (flow instanceof DataWizardFlow) { const results: DataResult[] = flow.dataOutputs.map((dataId) => { const dataDef = template?.data?.[dataId]; return { id: dataId, name: dataDef?.hint ?? dataId, type: dataDef?.type ?? "unknown", hint: dataDef?.hint, value: null, // Engine-level data execution not yet implemented }; }); setDataResults(results); } goBack(); return true; }, }; default: return { type, name: type, validate: () => null, onNext: async () => true, }; } }); }, [ flow, flowContext, roleSelection, variableInputs, utxoSelection, invitationManager, focus, template, templateIdentifier, actionIdentifier, shouldCollectInputs, ensureInvitation, loadUtxosForInvitation, goBack, setStatus, ]); // ── Step navigation ─────────────────────────────────────────── const stepper = useWizardSteps(stepConfigs, goBack, showError); // ── Set initial status ──────────────────────────────────────── useEffect(() => { if (!template || !actionIdentifier) { showError("Missing wizard data"); goBack(); return; } setStatus( roleSelection.effectiveRole ? `${actionIdentifier}/${roleSelection.effectiveRole}` : actionIdentifier, ); }, [ template, actionIdentifier, roleSelection.effectiveRole, showError, goBack, setStatus, ]); // ── Convenience derived values ──────────────────────────────── const textInputHasFocus = stepper.currentStepData?.type === "variables" && focus.focusArea === "content"; const canSignAndBroadcast = flow.canFinalize(flowContext); const isLastStep = stepper.currentStep >= stepper.steps.length - 1; const lastStepType = stepper.currentStepData?.type; const nextButtonLabel = lastStepType === "publish" ? flow.getFinalActionLabel(flowContext) : lastStepType === "result" ? "Done" : "Next"; // ── Public API ──────────────────────────────────────────────── return { // Meta template, templateIdentifier, actionIdentifier, roleIdentifier: roleSelection.effectiveRole, action, actionName, flow, flowContext, // Role selection availableRoles: roleSelection.availableRoles, selectedRoleIndex: roleSelection.selectedRoleIndex, setSelectedRoleIndex: roleSelection.setSelectedRoleIndex, // Steps steps: stepper.steps, currentStep: stepper.currentStep, currentStepData: stepper.currentStepData, // Variables variables: variableInputs.variables, updateVariable: variableInputs.updateVariable, handleTextInputSubmit, // UTXOs availableUtxos: utxoSelection.availableUtxos, selectedUtxoIndex: utxoSelection.selectedUtxoIndex, setSelectedUtxoIndex: utxoSelection.setSelectedUtxoIndex, requiredAmount: utxoSelection.requiredAmount, fee: utxoSelection.fee, selectedAmount: utxoSelection.selectedAmount, changeAmount: utxoSelection.changeAmount, toggleUtxoSelection: utxoSelection.toggleSelection, selectAll: utxoSelection.selectAll, deselectAll: utxoSelection.deselectAll, // Invitation invitation: invitationManager.invitation, invitationId: invitationManager.invitationId, requirementsComplete: invitationManager.requirementsComplete, hasSignedAndBroadcasted: invitationManager.hasSignedAndBroadcasted, canSignAndBroadcast, // Data results dataResults, // UI focus focusedInput: focus.focusedInput, setFocusedInput: focus.setFocusedInput, focusedButton: focus.focusedButton, setFocusedButton: focus.setFocusedButton, focusArea: focus.focusArea, setFocusArea: focus.setFocusArea, isProcessing: invitationManager.isProcessing, textInputHasFocus, nextButtonLabel, isLastStep, // Actions nextStep: stepper.nextStep, previousStep: stepper.previousStep, cancel: stepper.cancel, copyId, } as const; } /** Convenience type so other files can type the return value. */ export type ActionWizardState = ReturnType;