Format with prettier. Use screen mode for invitation import - dialog mode is broken.

This commit is contained in:
2026-03-23 10:15:48 +00:00
parent 7fd89c5663
commit b475b23beb
47 changed files with 1718 additions and 1098 deletions

View File

@@ -1,17 +1,21 @@
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';
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
@@ -25,7 +29,7 @@ export function useActionWizard() {
const { setStatus } = useStatus();
if (!appService) {
throw new Error('AppService not initialized');
throw new Error("AppService not initialized");
}
// ── Navigation data ───────────────────────────────────────────
@@ -35,14 +39,14 @@ export function useActionWizard() {
const actionRolesFromNavigation = navData.actionRoles as string[] | undefined;
// ── Derived template data ─────────────────────────────────────
const action = template?.actions?.[actionIdentifier ?? ''];
const actionName = action?.name || actionIdentifier || 'Unknown';
const action = template?.actions?.[actionIdentifier ?? ""];
const actionName = action?.name || actionIdentifier || "Unknown";
// ── Flow strategy ─────────────────────────────────────────────
const flow = useMemo<WizardFlow>(() => {
// Create a default action if no action is found
if (!action) {
return createWizardFlow({ name: '', description: '' });
return createWizardFlow({ name: "", description: "" });
}
// Create the flow from the action
@@ -50,10 +54,19 @@ export function useActionWizard() {
}, [action]);
// ── Domain hooks ──────────────────────────────────────────────
const roleSelection = useRoleSelection(template, actionIdentifier, actionRolesFromNavigation);
const roleSelection = useRoleSelection(
template,
actionIdentifier,
actionRolesFromNavigation,
);
const variableInputs = useVariableInputs();
const utxoSelection = useUtxoSelection();
const invitationManager = useInvitationManager({ appService, showError, showInfo, setStatus });
const invitationManager = useInvitationManager({
appService,
showError,
showInfo,
setStatus,
});
const focus = useWizardFocus();
// ── Data results (data-only flows) ────────────────────────────
@@ -66,37 +79,57 @@ export function useActionWizard() {
const role = act?.roles?.[roleSelection.effectiveRole];
const varIds = role?.requirements?.variables;
if (varIds && varIds.length > 0) {
variableInputs.initFromTemplate(template, actionIdentifier, roleSelection.effectiveRole);
variableInputs.initFromTemplate(
template,
actionIdentifier,
roleSelection.effectiveRole,
);
}
}
}, [template, actionIdentifier, roleSelection.effectiveRole, variableInputs.initFromTemplate]);
}, [
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;
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);
return (
isSingleRoleAction &&
roleRequiresInputs(
template,
actionIdentifier,
roleSelection.effectiveRole,
)
);
}, [flow.type, template, actionIdentifier, roleSelection.effectiveRole]);
// ── Build flow context for strategy methods ───────────────────
const flowContext = useMemo<FlowContext>(() => ({
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,
]);
const flowContext = useMemo<FlowContext>(
() => ({
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(() => {
@@ -105,7 +138,12 @@ export function useActionWizard() {
} else {
focus.moveToButtons();
}
}, [focus.focusedInput, variableInputs.variables.length, focus.setFocusedInput, focus.moveToButtons]);
}, [
focus.focusedInput,
variableInputs.variables.length,
focus.setFocusedInput,
focus.moveToButtons,
]);
// ── Copy invitation ID to clipboard ───────────────────────────
const copyId = useCallback(async () => {
@@ -121,38 +159,61 @@ export function useActionWizard() {
}, [invitationManager.invitationId, showInfo, showError]);
// ── Helper: create invitation if it doesn't exist yet ─────────
const ensureInvitation = useCallback(async (roleId?: string): Promise<string | null> => {
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,
]);
const ensureInvitation = useCallback(
async (roleId?: string): Promise<string | null> => {
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);
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]);
},
[
appService,
templateIdentifier,
variableInputs.variables,
utxoSelection.loadUtxos,
invitationManager.setIsProcessing,
setStatus,
],
);
// ── Build step configs from flow strategy ─────────────────────
const stepConfigs = useMemo<StepConfig[]>(() => {
@@ -160,39 +221,56 @@ export function useActionWizard() {
return stepTypes.map((type): StepConfig => {
switch (type) {
case 'role-select':
case "role-select":
return {
type,
name: 'Select Role',
name: "Select Role",
validate: () => {
const selectedRole = roleSelection.availableRoles[roleSelection.selectedRoleIndex];
return selectedRole ? null : 'Please select a role';
const selectedRole =
roleSelection.availableRoles[roleSelection.selectedRoleIndex];
return selectedRole ? null : "Please select a role";
},
onNext: async () => {
const selectedRole = roleSelection.availableRoles[roleSelection.selectedRoleIndex];
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;
const hasVars =
(role?.requirements?.variables?.length ?? 0) > 0;
if (hasVars) {
variableInputs.initFromTemplate(template, actionIdentifier, selectedRole);
variableInputs.initFromTemplate(
template,
actionIdentifier,
selectedRole,
);
}
// If no variables step follows, create the invitation now (transaction flows only)
if (!hasVars && flow.type === 'transaction') {
if (!hasVars && flow.type === "transaction") {
if (templateIdentifier && template) {
const invId = await invitationManager.createWithVariables(
templateIdentifier, actionIdentifier, selectedRole, template, [],
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);
const needsInputs =
totalRoles <= 1 &&
roleRequiresInputs(
template,
actionIdentifier,
selectedRole,
);
if (needsInputs) {
await loadUtxosForInvitation(invId);
}
@@ -206,18 +284,27 @@ export function useActionWizard() {
},
};
case 'variables':
case "variables":
return {
type,
name: 'Variables',
name: "Variables",
validate: () => variableInputs.validate(),
onNext: async () => {
if (flow.type === 'transaction') {
if (!templateIdentifier || !actionIdentifier || !template || !roleSelection.effectiveRole) return false;
if (flow.type === "transaction") {
if (
!templateIdentifier ||
!actionIdentifier ||
!template ||
!roleSelection.effectiveRole
)
return false;
const invId = await invitationManager.createWithVariables(
templateIdentifier, actionIdentifier, roleSelection.effectiveRole,
template, variableInputs.variables,
templateIdentifier,
actionIdentifier,
roleSelection.effectiveRole,
template,
variableInputs.variables,
);
if (!invId) return false;
@@ -233,23 +320,28 @@ export function useActionWizard() {
},
};
case 'inputs':
case "inputs":
return {
type,
name: 'Select UTXOs',
name: "Select UTXOs",
validate: () => utxoSelection.validate(),
onNext: async () => {
const selectedUtxos = utxoSelection.availableUtxos.filter((u) => u.selected);
const success = await invitationManager.addInputsAndOutputs(selectedUtxos, utxoSelection.changeAmount);
const selectedUtxos = utxoSelection.availableUtxos.filter(
(u) => u.selected,
);
const success = await invitationManager.addInputsAndOutputs(
selectedUtxos,
utxoSelection.changeAmount,
);
if (success) focus.resetToContent();
return success;
},
};
case 'review':
case "review":
return {
type,
name: 'Review',
name: "Review",
validate: () => null,
onNext: async () => {
// Ensure invitation exists (covers the case where no prior step created it)
@@ -261,10 +353,10 @@ export function useActionWizard() {
},
};
case 'publish':
case "publish":
return {
type,
name: 'Publish',
name: "Publish",
validate: () => null,
onNext: async () => {
if (flow.canFinalize(flowContext)) {
@@ -277,10 +369,10 @@ export function useActionWizard() {
},
};
case 'result':
case "result":
return {
type,
name: 'Result',
name: "Result",
validate: () => null,
onNext: async () => {
// Data-only flows: populate stubbed results, then exit
@@ -290,7 +382,7 @@ export function useActionWizard() {
return {
id: dataId,
name: dataDef?.hint ?? dataId,
type: dataDef?.type ?? 'unknown',
type: dataDef?.type ?? "unknown",
hint: dataDef?.hint,
value: null, // Engine-level data execution not yet implemented
};
@@ -303,13 +395,30 @@ export function useActionWizard() {
};
default:
return { type, name: type, validate: () => null, onNext: async () => true };
return {
type,
name: type,
validate: () => null,
onNext: async () => true,
};
}
});
}, [
flow, flowContext, roleSelection, variableInputs, utxoSelection,
invitationManager, focus, template, templateIdentifier, actionIdentifier,
shouldCollectInputs, ensureInvitation, loadUtxosForInvitation, goBack, setStatus,
flow,
flowContext,
roleSelection,
variableInputs,
utxoSelection,
invitationManager,
focus,
template,
templateIdentifier,
actionIdentifier,
shouldCollectInputs,
ensureInvitation,
loadUtxosForInvitation,
goBack,
setStatus,
]);
// ── Step navigation ───────────────────────────────────────────
@@ -318,7 +427,7 @@ export function useActionWizard() {
// ── Set initial status ────────────────────────────────────────
useEffect(() => {
if (!template || !actionIdentifier) {
showError('Missing wizard data');
showError("Missing wizard data");
goBack();
return;
}
@@ -327,22 +436,30 @@ export function useActionWizard() {
? `${actionIdentifier}/${roleSelection.effectiveRole}`
: actionIdentifier,
);
}, [template, actionIdentifier, roleSelection.effectiveRole, showError, goBack, setStatus]);
}, [
template,
actionIdentifier,
roleSelection.effectiveRole,
showError,
goBack,
setStatus,
]);
// ── Convenience derived values ────────────────────────────────
const textInputHasFocus =
stepper.currentStepData?.type === 'variables' && focus.focusArea === 'content';
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'
lastStepType === "publish"
? flow.getFinalActionLabel(flowContext)
: lastStepType === 'result'
? 'Done'
: 'Next';
: lastStepType === "result"
? "Done"
: "Next";
// ── Public API ────────────────────────────────────────────────
return {