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,5 +1,5 @@
import type { FlowContext, StepType } from '../types.js';
import { WizardFlow } from './WizardFlow.js';
import type { FlowContext, StepType } from "../types.js";
import { WizardFlow } from "./WizardFlow.js";
/**
* Flow strategy for data-only actions (e.g. sign, verify).
@@ -12,7 +12,7 @@ import { WizardFlow } from './WizardFlow.js';
* The result step is currently stubbed.
*/
export class DataWizardFlow extends WizardFlow {
readonly type = 'data' as const;
readonly type = "data" as const;
/** The data field identifiers this action produces (from action.data). */
readonly dataOutputs: string[];
@@ -24,9 +24,9 @@ export class DataWizardFlow extends WizardFlow {
getStepTypes(context: FlowContext): StepType[] {
const steps: StepType[] = [];
if (context.availableRoles.length > 1) steps.push('role-select');
if (context.hasVariables) steps.push('variables');
steps.push('result');
if (context.availableRoles.length > 1) steps.push("role-select");
if (context.hasVariables) steps.push("variables");
steps.push("result");
return steps;
}
@@ -35,6 +35,6 @@ export class DataWizardFlow extends WizardFlow {
}
getFinalActionLabel(): string {
return 'Done';
return "Done";
}
}

View File

@@ -1,5 +1,5 @@
import type { FlowContext, StepType } from '../types.js';
import { WizardFlow } from './WizardFlow.js';
import type { FlowContext, StepType } from "../types.js";
import { WizardFlow } from "./WizardFlow.js";
/**
* Flow strategy for transaction-based actions.
@@ -10,15 +10,15 @@ import { WizardFlow } from './WizardFlow.js';
* another party to complete.
*/
export class TransactionWizardFlow extends WizardFlow {
readonly type = 'transaction' as const;
readonly type = "transaction" as const;
getStepTypes(context: FlowContext): StepType[] {
const steps: StepType[] = [];
if (context.availableRoles.length > 1) steps.push('role-select');
if (context.hasVariables) steps.push('variables');
if (context.shouldCollectInputs) steps.push('inputs');
steps.push('review');
steps.push('publish');
if (context.availableRoles.length > 1) steps.push("role-select");
if (context.hasVariables) steps.push("variables");
if (context.shouldCollectInputs) steps.push("inputs");
steps.push("review");
steps.push("publish");
return steps;
}
@@ -31,6 +31,6 @@ export class TransactionWizardFlow extends WizardFlow {
}
getFinalActionLabel(context: FlowContext): string {
return this.canFinalize(context) ? 'Sign & Broadcast' : 'Done';
return this.canFinalize(context) ? "Sign & Broadcast" : "Done";
}
}

View File

@@ -1,4 +1,4 @@
import type { FlowContext, StepType } from '../types.js';
import type { FlowContext, StepType } from "../types.js";
/**
* Abstract strategy that defines the shape of a wizard flow.
@@ -9,7 +9,7 @@ import type { FlowContext, StepType } from '../types.js';
* produced from these methods.
*/
export abstract class WizardFlow {
abstract readonly type: 'transaction' | 'data';
abstract readonly type: "transaction" | "data";
/** Determine which step types this flow needs given the current context. */
abstract getStepTypes(context: FlowContext): StepType[];

View File

@@ -1,11 +1,11 @@
import type { XOTemplateAction } from '@xo-cash/types';
import { TransactionWizardFlow } from './TransactionWizardFlow.js';
import { DataWizardFlow } from './DataWizardFlow.js';
import type { WizardFlow } from './WizardFlow.js';
import type { XOTemplateAction } from "@xo-cash/types";
import { TransactionWizardFlow } from "./TransactionWizardFlow.js";
import { DataWizardFlow } from "./DataWizardFlow.js";
import type { WizardFlow } from "./WizardFlow.js";
export { WizardFlow } from './WizardFlow.js';
export { TransactionWizardFlow } from './TransactionWizardFlow.js';
export { DataWizardFlow } from './DataWizardFlow.js';
export { WizardFlow } from "./WizardFlow.js";
export { TransactionWizardFlow } from "./TransactionWizardFlow.js";
export { DataWizardFlow } from "./DataWizardFlow.js";
/**
* Inspect a template action and return the appropriate wizard flow strategy.

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 {

View File

@@ -1,12 +1,16 @@
import { useState, useCallback } from 'react';
import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
import type { VariableInput, SelectableUTXO } from '../types.js';
import { useState, useCallback } from "react";
import type {
XOTemplate,
XOInvitation,
XOTemplateTransactionOutput,
} from "@xo-cash/types";
import type { VariableInput, SelectableUTXO } from "../types.js";
import {
getTransactionOutputIdentifier,
isInvitationRequirementsComplete,
resolveProvidedLockingBytecodeHex,
} from '../../../../utils/invitation-flow.js';
import type { AppService } from '../../../../services/app.js';
} from "../../../../utils/invitation-flow.js";
import type { AppService } from "../../../../services/app.js";
interface InvitationManagerDeps {
appService: AppService;
@@ -32,26 +36,27 @@ export function useInvitationManager(deps: InvitationManagerDeps) {
const [isProcessing, setIsProcessing] = useState(false);
/** Re-check whether all invitation requirements are satisfied. */
const refreshRequirements = useCallback(async (
identifier: string | null = invitationId,
): Promise<boolean> => {
if (!identifier || !appService) {
setRequirementsComplete(false);
return false;
}
const refreshRequirements = useCallback(
async (identifier: string | null = invitationId): Promise<boolean> => {
if (!identifier || !appService) {
setRequirementsComplete(false);
return false;
}
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === identifier,
);
if (!instance) {
setRequirementsComplete(false);
return false;
}
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === identifier,
);
if (!instance) {
setRequirementsComplete(false);
return false;
}
const complete = await isInvitationRequirementsComplete(instance);
setRequirementsComplete(complete);
return complete;
}, [appService, invitationId]);
const complete = await isInvitationRequirementsComplete(instance);
setRequirementsComplete(complete);
return complete;
},
[appService, invitationId],
);
/**
* Create an invitation, persist variable values, and add
@@ -59,177 +64,201 @@ export function useInvitationManager(deps: InvitationManagerDeps) {
*
* @returns The invitation identifier on success, or null on failure.
*/
const createWithVariables = useCallback(async (
templateIdentifier: string,
actionIdentifier: string,
roleIdentifier: string,
template: XOTemplate,
variables: VariableInput[],
): Promise<string | null> => {
if (!appService) return null;
const createWithVariables = useCallback(
async (
templateIdentifier: string,
actionIdentifier: string,
roleIdentifier: string,
template: XOTemplate,
variables: VariableInput[],
): Promise<string | null> => {
if (!appService) return null;
setIsProcessing(true);
setStatus('Creating invitation...');
setIsProcessing(true);
setStatus("Creating invitation...");
try {
// Create via the engine
const xoInvitation = await appService.engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
// Wrap and track
const invitationInstance = await appService.createInvitation(xoInvitation);
let inv = invitationInstance.data;
const invId = inv.invitationIdentifier;
setInvitationId(invId);
// Persist variable values
if (variables.length > 0) {
setStatus('Adding variables...');
const variableData = variables.map((v) => {
const isNumeric =
['integer', 'number', 'satoshis'].includes(v.type) ||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
return {
variableIdentifier: v.id,
roleIdentifier,
value: isNumeric ? BigInt(v.value || '0') : v.value,
};
try {
// Create via the engine
const xoInvitation = await appService.engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
await invitationInstance.addVariables(variableData);
inv = invitationInstance.data;
}
// Build variable values lookup for output resolution
const variableValuesByIdentifier = variables.reduce((acc, variable) => {
if (typeof variable.value === 'string' && variable.value.trim().length > 0) {
acc[variable.id] = variable.value;
// Wrap and track
const invitationInstance =
await appService.createInvitation(xoInvitation);
let inv = invitationInstance.data;
const invId = inv.invitationIdentifier;
setInvitationId(invId);
// Persist variable values
if (variables.length > 0) {
setStatus("Adding variables...");
const variableData = variables.map((v) => {
const isNumeric =
["integer", "number", "satoshis"].includes(v.type) ||
(v.hint && ["satoshis", "amount"].includes(v.hint));
return {
variableIdentifier: v.id,
roleIdentifier,
value: isNumeric ? BigInt(v.value || "0") : v.value,
};
});
await invitationInstance.addVariables(variableData);
inv = invitationInstance.data;
}
return acc;
}, {} as Record<string, string>);
// Add template-required transaction outputs
const act = template.actions?.[actionIdentifier];
const transaction = act?.transaction
? template.transactions?.[act.transaction]
: null;
if (transaction?.outputs && transaction.outputs.length > 0) {
setStatus('Adding required outputs...');
const outputsToAdd = await Promise.all(
transaction.outputs.map(async (output: XOTemplateTransactionOutput) => {
const outputIdentifier = getTransactionOutputIdentifier(output);
if (!outputIdentifier) {
throw new Error('Invalid transaction output definition');
// Build variable values lookup for output resolution
const variableValuesByIdentifier = variables.reduce(
(acc, variable) => {
if (
typeof variable.value === "string" &&
variable.value.trim().length > 0
) {
acc[variable.id] = variable.value;
}
const providedHex = resolveProvidedLockingBytecodeHex(
template,
outputIdentifier,
variableValuesByIdentifier,
);
const lockingBytecodeHex =
providedHex ?? await invitationInstance.generateLockingBytecode(outputIdentifier, roleIdentifier);
return { outputIdentifier, lockingBytecode: lockingBytecodeHex };
}),
return acc;
},
{} as Record<string, string>,
);
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOutputs accept a hex string. 3. Have addOutputs handle lockscript generation.
await invitationInstance.addOutputs(
outputsToAdd.map((output) => ({
outputIdentifier: output.outputIdentifier,
lockingBytecode: new Uint8Array(Buffer.from(output.lockingBytecode, 'hex')),
})),
);
// Add template-required transaction outputs
const act = template.actions?.[actionIdentifier];
const transaction = act?.transaction
? template.transactions?.[act.transaction]
: null;
inv = invitationInstance.data;
if (transaction?.outputs && transaction.outputs.length > 0) {
setStatus("Adding required outputs...");
const outputsToAdd = await Promise.all(
transaction.outputs.map(
async (output: XOTemplateTransactionOutput) => {
const outputIdentifier = getTransactionOutputIdentifier(output);
if (!outputIdentifier) {
throw new Error("Invalid transaction output definition");
}
const providedHex = resolveProvidedLockingBytecodeHex(
template,
outputIdentifier,
variableValuesByIdentifier,
);
const lockingBytecodeHex =
providedHex ??
(await invitationInstance.generateLockingBytecode(
outputIdentifier,
roleIdentifier,
));
return {
outputIdentifier,
lockingBytecode: lockingBytecodeHex,
};
},
),
);
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOutputs accept a hex string. 3. Have addOutputs handle lockscript generation.
await invitationInstance.addOutputs(
outputsToAdd.map((output) => ({
outputIdentifier: output.outputIdentifier,
lockingBytecode: new Uint8Array(
Buffer.from(output.lockingBytecode, "hex"),
),
})),
);
inv = invitationInstance.data;
}
setInvitation(inv);
await refreshRequirements(invId);
setStatus("Invitation created");
return invId;
} catch (error) {
showError(
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
} finally {
setIsProcessing(false);
}
setInvitation(inv);
await refreshRequirements(invId);
setStatus('Invitation created');
return invId;
} catch (error) {
showError(
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
} finally {
setIsProcessing(false);
}
}, [appService, showError, setStatus, refreshRequirements]);
},
[appService, showError, setStatus, refreshRequirements],
);
/**
* Add the selected UTXOs as inputs and a change output to the invitation.
*
* @returns true on success, false on failure.
*/
const addInputsAndOutputs = useCallback(async (
selectedUtxos: SelectableUTXO[],
changeAmount: bigint,
): Promise<boolean> => {
if (!invitationId || !appService) return false;
const addInputsAndOutputs = useCallback(
async (
selectedUtxos: SelectableUTXO[],
changeAmount: bigint,
): Promise<boolean> => {
if (!invitationId || !appService) return false;
setIsProcessing(true);
setStatus('Adding inputs and outputs...');
setIsProcessing(true);
setStatus("Adding inputs and outputs...");
try {
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === invitationId,
);
if (!instance) throw new Error('Invitation not found');
try {
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === invitationId,
);
if (!instance) throw new Error("Invitation not found");
const inputs = selectedUtxos.map((utxo) => ({
outpointTransactionHash: new Uint8Array(
Buffer.from(utxo.outpointTransactionHash, 'hex'),
),
outpointIndex: utxo.outpointIndex,
}));
const inputs = selectedUtxos.map((utxo) => ({
outpointTransactionHash: new Uint8Array(
Buffer.from(utxo.outpointTransactionHash, "hex"),
),
outpointIndex: utxo.outpointIndex,
}));
await instance.addInputs(inputs);
await instance.addOutputs([{ valueSatoshis: changeAmount }]);
await refreshRequirements(invitationId);
setStatus('Inputs and outputs added');
return true;
} catch (error) {
showError(
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
} finally {
setIsProcessing(false);
}
}, [invitationId, appService, showError, setStatus, refreshRequirements]);
await instance.addInputs(inputs);
await instance.addOutputs([{ valueSatoshis: changeAmount }]);
await refreshRequirements(invitationId);
setStatus("Inputs and outputs added");
return true;
} catch (error) {
showError(
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
} finally {
setIsProcessing(false);
}
},
[invitationId, appService, showError, setStatus, refreshRequirements],
);
/** Sign the invitation and broadcast the transaction. */
const signAndBroadcast = useCallback(async (): Promise<boolean> => {
if (!invitationId || !appService) return false;
setIsProcessing(true);
setStatus('Signing invitation...');
setStatus("Signing invitation...");
try {
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === invitationId,
);
if (!instance) throw new Error('Invitation not found');
if (!instance) throw new Error("Invitation not found");
const complete = await refreshRequirements(invitationId);
if (!complete) {
showError('Invitation requirements are not complete yet.');
showError("Invitation requirements are not complete yet.");
return false;
}
await instance.sign();
setStatus('Broadcasting transaction...');
setStatus("Broadcasting transaction...");
await instance.broadcast();
setHasSignedAndBroadcasted(true);
setStatus('Transaction signed and broadcasted');
showInfo('Transaction signed and broadcasted.');
setStatus("Transaction signed and broadcasted");
showInfo("Transaction signed and broadcasted.");
await refreshRequirements(invitationId);
return true;
} catch (error) {
@@ -240,7 +269,14 @@ export function useInvitationManager(deps: InvitationManagerDeps) {
} finally {
setIsProcessing(false);
}
}, [invitationId, appService, setStatus, showError, showInfo, refreshRequirements]);
}, [
invitationId,
appService,
setStatus,
showError,
showInfo,
refreshRequirements,
]);
return {
invitation,

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import type { XOTemplate } from '@xo-cash/types';
import { resolveActionRoles } from '../../../../utils/invitation-flow.js';
import { useState, useEffect, useMemo } from "react";
import type { XOTemplate } from "@xo-cash/types";
import { resolveActionRoles } from "../../../../utils/invitation-flow.js";
/**
* Manages role selection state for the wizard.
@@ -18,13 +18,17 @@ export function useRoleSelection(
/** Roles that can start this action, derived from template start entries. */
const availableRoles = useMemo(() => {
return resolveActionRoles(template, actionIdentifier, actionRolesFromNavigation);
return resolveActionRoles(
template,
actionIdentifier,
actionRolesFromNavigation,
);
}, [template, actionIdentifier, actionRolesFromNavigation]);
/** The role to use for the flow — either explicitly selected or auto-selected when only one exists. */
const effectiveRole = roleIdentifier ?? (
availableRoles.length === 1 ? availableRoles[0] : undefined
);
const effectiveRole =
roleIdentifier ??
(availableRoles.length === 1 ? availableRoles[0] : undefined);
// Auto-select when only one role exists.
useEffect(() => {

View File

@@ -1,11 +1,11 @@
import { useState, useCallback, useMemo } from 'react';
import type { SelectableUTXO, VariableInput } from '../types.js';
import type { Invitation } from '../../../../services/invitation.js';
import { formatSatoshis } from '../../../theme.js';
import { useState, useCallback, useMemo } from "react";
import type { SelectableUTXO, VariableInput } from "../types.js";
import type { Invitation } from "../../../../services/invitation.js";
import { formatSatoshis } from "../../../theme.js";
import {
autoSelectGreedyUtxos,
mapUnspentOutputsToSelectable,
} from '../../../../utils/invitation-flow.js';
} from "../../../../utils/invitation-flow.js";
/**
* Manages UTXO selection state for the wizard's inputs step.
@@ -20,7 +20,10 @@ export function useUtxoSelection() {
const [fee, setFee] = useState<bigint>(500n);
const selectedAmount = useMemo(
() => availableUtxos.filter((u) => u.selected).reduce((sum, u) => sum + u.valueSatoshis, 0n),
() =>
availableUtxos
.filter((u) => u.selected)
.reduce((sum, u) => sum + u.valueSatoshis, 0n),
[availableUtxos],
);
@@ -55,38 +58,41 @@ export function useUtxoSelection() {
* Query the invitation instance for suitable UTXOs and auto-select
* greedily to meet the required amount.
*/
const loadUtxos = useCallback(async (
invitationInstance: Invitation,
templateIdentifier: string,
variables: VariableInput[],
setStatus: (msg: string) => void,
): Promise<void> => {
setStatus('Finding suitable UTXOs...');
const loadUtxos = useCallback(
async (
invitationInstance: Invitation,
templateIdentifier: string,
variables: VariableInput[],
setStatus: (msg: string) => void,
): Promise<void> => {
setStatus("Finding suitable UTXOs...");
// Derive required amount from variables that look like satoshi/amount fields.
const requestedVar = variables.find(
(v) =>
v.id.toLowerCase().includes('satoshi') ||
v.id.toLowerCase().includes('amount'),
);
const requested = requestedVar ? BigInt(requestedVar.value || '0') : 0n;
setRequiredAmount(requested);
// Derive required amount from variables that look like satoshi/amount fields.
const requestedVar = variables.find(
(v) =>
v.id.toLowerCase().includes("satoshi") ||
v.id.toLowerCase().includes("amount"),
);
const requested = requestedVar ? BigInt(requestedVar.value || "0") : 0n;
setRequiredAmount(requested);
const unspentOutputs = await invitationInstance.findSuitableResources({
templateIdentifier,
});
const unspentOutputs = await invitationInstance.findSuitableResources({
templateIdentifier,
});
const mapped = mapUnspentOutputsToSelectable(unspentOutputs);
const autoSelected = autoSelectGreedyUtxos(mapped, requested + fee);
setAvailableUtxos(autoSelected as SelectableUTXO[]);
setStatus('Ready');
}, [fee]);
const mapped = mapUnspentOutputsToSelectable(unspentOutputs);
const autoSelected = autoSelectGreedyUtxos(mapped, requested + fee);
setAvailableUtxos(autoSelected as SelectableUTXO[]);
setStatus("Ready");
},
[fee],
);
/** Validate that the selection meets the required amounts. */
const validate = useCallback((): string | null => {
const selected = availableUtxos.filter((u) => u.selected);
if (selected.length === 0) {
return 'Please select at least one UTXO';
return "Please select at least one UTXO";
}
if (selectedAmount < requiredAmount + fee) {
return `Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`;

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import type { XOTemplate } from '@xo-cash/types';
import type { VariableInput } from '../types.js';
import { useState, useCallback } from "react";
import type { XOTemplate } from "@xo-cash/types";
import type { VariableInput } from "../types.js";
/**
* Manages the variable input state for the wizard's variables step.
@@ -15,27 +15,30 @@ export function useVariableInputs() {
* Populate the variable list from the template's role requirements.
* Calling this again replaces the current variables entirely.
*/
const initFromTemplate = useCallback((
template: XOTemplate,
actionIdentifier: string,
roleIdentifier: string,
) => {
const action = template.actions?.[actionIdentifier];
const role = action?.roles?.[roleIdentifier];
const varIds = role?.requirements?.variables ?? [];
const initFromTemplate = useCallback(
(
template: XOTemplate,
actionIdentifier: string,
roleIdentifier: string,
) => {
const action = template.actions?.[actionIdentifier];
const role = action?.roles?.[roleIdentifier];
const varIds = role?.requirements?.variables ?? [];
const varInputs: VariableInput[] = varIds.map((varId) => {
const varDef = template.variables?.[varId];
return {
id: varId,
name: varDef?.name || varId,
type: varDef?.type || 'string',
hint: varDef?.hint,
value: '',
};
});
setVariables(varInputs);
}, []);
const varInputs: VariableInput[] = varIds.map((varId) => {
const varDef = template.variables?.[varId];
return {
id: varId,
name: varDef?.name || varId,
type: varDef?.type || "string",
hint: varDef?.hint,
value: "",
};
});
setVariables(varInputs);
},
[],
);
/** Update a single variable's value by index. */
const updateVariable = useCallback((index: number, value: string) => {
@@ -51,9 +54,11 @@ export function useVariableInputs() {
/** Returns an error message if any required variable is empty, or null if valid. */
const validate = useCallback((): string | null => {
const emptyVars = variables.filter((v) => !v.value || v.value.trim() === '');
const emptyVars = variables.filter(
(v) => !v.value || v.value.trim() === "",
);
if (emptyVars.length > 0) {
return `Please enter values for: ${emptyVars.map((v) => v.name).join(', ')}`;
return `Please enter values for: ${emptyVars.map((v) => v.name).join(", ")}`;
}
return null;
}, [variables]);

View File

@@ -1,24 +1,24 @@
import { useState, useCallback } from 'react';
import type { FocusArea, ButtonFocus } from '../types.js';
import { useState, useCallback } from "react";
import type { FocusArea, ButtonFocus } from "../types.js";
/**
* Manages which area of the wizard UI has keyboard focus and
* which specific element within that area is highlighted.
*/
export function useWizardFocus() {
const [focusArea, setFocusArea] = useState<FocusArea>('content');
const [focusedButton, setFocusedButton] = useState<ButtonFocus>('next');
const [focusArea, setFocusArea] = useState<FocusArea>("content");
const [focusedButton, setFocusedButton] = useState<ButtonFocus>("next");
const [focusedInput, setFocusedInput] = useState(0);
/** Reset focus to the content area at the first element. */
const resetToContent = useCallback(() => {
setFocusArea('content');
setFocusArea("content");
setFocusedInput(0);
}, []);
/** Move focus to the button bar. */
const moveToButtons = useCallback((button: ButtonFocus = 'next') => {
setFocusArea('buttons');
const moveToButtons = useCallback((button: ButtonFocus = "next") => {
setFocusArea("buttons");
setFocusedButton(button);
}, []);

View File

@@ -1,5 +1,5 @@
import { useBlockableInput } from '../../../hooks/useInputLayer.js';
import type { ActionWizardState } from './useActionWizard.js';
import { useBlockableInput } from "../../../hooks/useInputLayer.js";
import type { ActionWizardState } from "./useActionWizard.js";
/**
* Keyboard input handler for the action wizard.
@@ -18,30 +18,34 @@ export function useWizardKeyboard(wizard: ActionWizardState): void {
}
// ── Content-area: step-specific input handling ────────
if (wizard.focusArea === 'content') {
if (wizard.currentStepData?.type === 'role-select') {
if (wizard.focusArea === "content") {
if (wizard.currentStepData?.type === "role-select") {
handleRoleSelectInput(wizard, input, key);
return;
}
if (wizard.currentStepData?.type === 'inputs') {
if (wizard.currentStepData?.type === "inputs") {
handleInputsStepInput(wizard, input, key);
return;
}
}
// ── Button bar navigation + activation ────────────────
if (wizard.focusArea === 'buttons') {
if (wizard.focusArea === "buttons") {
handleButtonBarInput(wizard, key);
}
// ── Global shortcuts ──────────────────────────────────
if (input === 'c' && wizard.currentStepData?.type === 'publish' && wizard.invitationId) {
if (
input === "c" &&
wizard.currentStepData?.type === "publish" &&
wizard.invitationId
) {
wizard.copyId();
}
if (input === 'a' && wizard.currentStepData?.type === 'inputs') {
if (input === "a" && wizard.currentStepData?.type === "inputs") {
wizard.selectAll();
}
if (input === 'n' && wizard.currentStepData?.type === 'inputs') {
if (input === "n" && wizard.currentStepData?.type === "inputs") {
wizard.deselectAll();
}
},
@@ -52,10 +56,10 @@ export function useWizardKeyboard(wizard: ActionWizardState): void {
// ── Tab cycling ─────────────────────────────────────────────────
function handleTab(wizard: ActionWizardState): void {
if (wizard.focusArea === 'content') {
if (wizard.focusArea === "content") {
// Within role-select, tab through roles before moving to buttons
if (
wizard.currentStepData?.type === 'role-select' &&
wizard.currentStepData?.type === "role-select" &&
wizard.availableRoles.length > 0 &&
wizard.selectedRoleIndex < wizard.availableRoles.length - 1
) {
@@ -65,7 +69,7 @@ function handleTab(wizard: ActionWizardState): void {
// Within inputs, tab through UTXOs before moving to buttons
if (
wizard.currentStepData?.type === 'inputs' &&
wizard.currentStepData?.type === "inputs" &&
wizard.availableUtxos.length > 0 &&
wizard.selectedUtxoIndex < wizard.availableUtxos.length - 1
) {
@@ -74,16 +78,16 @@ function handleTab(wizard: ActionWizardState): void {
}
// Move to button bar
wizard.setFocusArea('buttons');
wizard.setFocusedButton('next');
wizard.setFocusArea("buttons");
wizard.setFocusedButton("next");
} else {
// Cycle through buttons, then wrap back to content
if (wizard.focusedButton === 'back') {
wizard.setFocusedButton('cancel');
} else if (wizard.focusedButton === 'cancel') {
wizard.setFocusedButton('next');
if (wizard.focusedButton === "back") {
wizard.setFocusedButton("cancel");
} else if (wizard.focusedButton === "cancel") {
wizard.setFocusedButton("next");
} else {
wizard.setFocusArea('content');
wizard.setFocusArea("content");
wizard.setFocusedInput(0);
wizard.setSelectedUtxoIndex(0);
wizard.setSelectedRoleIndex(0);
@@ -120,7 +124,7 @@ function handleInputsStepInput(
wizard.setSelectedUtxoIndex((p) =>
Math.min(wizard.availableUtxos.length - 1, p + 1),
);
} else if (key.return || input === ' ') {
} else if (key.return || input === " ") {
wizard.toggleUtxoSelection(wizard.selectedUtxoIndex);
}
}
@@ -133,17 +137,17 @@ function handleButtonBarInput(
): void {
if (key.leftArrow) {
wizard.setFocusedButton((p) =>
p === 'next' ? 'cancel' : p === 'cancel' ? 'back' : 'back',
p === "next" ? "cancel" : p === "cancel" ? "back" : "back",
);
} else if (key.rightArrow) {
wizard.setFocusedButton((p) =>
p === 'back' ? 'cancel' : p === 'cancel' ? 'next' : 'next',
p === "back" ? "cancel" : p === "cancel" ? "next" : "next",
);
}
if (key.return) {
if (wizard.focusedButton === 'back') wizard.previousStep();
else if (wizard.focusedButton === 'cancel') wizard.cancel();
else if (wizard.focusedButton === 'next') wizard.nextStep();
if (wizard.focusedButton === "back") wizard.previousStep();
else if (wizard.focusedButton === "cancel") wizard.cancel();
else if (wizard.focusedButton === "next") wizard.nextStep();
}
}

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useMemo } from 'react';
import type { StepConfig, WizardStep } from '../types.js';
import { useState, useCallback, useMemo } from "react";
import type { StepConfig, WizardStep } from "../types.js";
/**
* Generic step navigation driven by an array of StepConfig objects.

View File

@@ -1,5 +1,5 @@
export * from './ActionWizardScreen.js';
export * from './hooks/useActionWizard.js';
export * from './types.js';
export * from './steps/index.js';
export * from './flows/index.js';
export * from "./ActionWizardScreen.js";
export * from "./hooks/useActionWizard.js";
export * from "./types.js";
export * from "./steps/index.js";
export * from "./flows/index.js";

View File

@@ -1,6 +1,6 @@
export * from './RoleSelectStep.js';
export * from './VariablesStep.js';
export * from './InputsStep.js';
export * from './ReviewStep.js';
export * from './PublishStep.js';
export * from './DataResultStep.js';
export * from "./RoleSelectStep.js";
export * from "./VariablesStep.js";
export * from "./InputsStep.js";
export * from "./ReviewStep.js";
export * from "./PublishStep.js";
export * from "./DataResultStep.js";

View File

@@ -3,7 +3,13 @@
*/
/** Supported step types in the wizard. */
export type StepType = 'role-select' | 'variables' | 'inputs' | 'review' | 'publish' | 'result';
export type StepType =
| "role-select"
| "variables"
| "inputs"
| "review"
| "publish"
| "result";
/** A step displayed in the wizard's progress indicator. */
export interface WizardStep {
@@ -57,10 +63,10 @@ export interface SelectableUTXO {
}
/** Which area of the wizard UI currently has keyboard focus. */
export type FocusArea = 'content' | 'buttons';
export type FocusArea = "content" | "buttons";
/** Which button in the bottom bar is focused. */
export type ButtonFocus = 'back' | 'cancel' | 'next';
export type ButtonFocus = "back" | "cancel" | "next";
/** A computed data result from a data-only action. */
export interface DataResult {

View File

@@ -719,7 +719,7 @@ export function InvitationScreen(): React.ReactElement {
{importingId && appService && (
<InvitationImportFlow
invitationId={importingId}
mode="dialog"
mode="screen"
appService={appService}
onClose={handleImportFlowClose}
showError={showError}

View File

@@ -15,7 +15,6 @@ export function FetchInvitationStep({
invitationId,
appService,
onComplete,
onCancel,
isActive,
}: FetchStepProps): React.ReactElement {
const [status, setStatus] = useState<'loading' | 'error'>('loading');

View File

@@ -22,8 +22,6 @@ const DUST_THRESHOLD = 546n;
export function InputsSelectStep({
invitation,
template,
selectedRole,
appService,
onComplete,
onCancel,
@@ -93,8 +91,6 @@ export function InputsSelectStep({
outputIdentifiers.add(output.output);
}
console.log('outputIdentifiers', Array.from(outputIdentifiers));
// Create a map of the utxoID to suitable resource
const utxoIdToSuitableResource = new Map<string, UnspentOutputData>();
for (const outputIdentifier of outputIdentifiers) {
@@ -102,14 +98,11 @@ export function InputsSelectStep({
outputIdentifier,
});
console.log('suitableResources', outputIdentifier, JSON.stringify(suitableResources, null, 2));
for (const suitableResource of suitableResources) {
utxoIdToSuitableResource.set(suitableResource.outpointTransactionHash + ':' + suitableResource.outpointIndex, suitableResource);
}
}
console.log('utxoIdToSuitableResource', JSON.stringify(utxoIdToSuitableResource, null, 2));
const selectable = mapUnspentOutputsToSelectable(Array.from(utxoIdToSuitableResource.values()));
const autoSelected = autoSelectGreedyUtxos(selectable, required + fee);
setUtxos(autoSelected as SelectableUTXO[]);
@@ -155,7 +148,7 @@ export function InputsSelectStep({
setUtxos(prev => prev.map(u => ({ ...u, selected: true })));
} else if (input === 'n') {
setUtxos(prev => prev.map(u => ({ ...u, selected: false })));
} else if (key.tab) {
} else if (key.return) {
if (hasEnough) {
onComplete(utxos.filter(u => u.selected));
}
@@ -239,7 +232,7 @@ export function InputsSelectStep({
{/* Navigation hint */}
<Box marginTop={1}>
<Text color={colors.textMuted}>
: Navigate Space: Toggle a: All n: None Tab: Confirm Esc: Cancel
: Navigate Space: Toggle a: All n: None return: Confirm Esc: Cancel
</Text>
</Box>
</Box>

View File

@@ -108,21 +108,23 @@ export function PreviewInvitationStep({
<Text color={colors.primary} bold>Roles Filled ({filledRoles.size}):</Text>
</Box>
{filledRoles.size === 0 ? (
<Box>
<Text color={colors.textMuted}> None yet</Text>
</Box>
) : (
Array.from(filledRoles).map(role => {
const roleInfoRaw = template?.roles?.[role];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
return (
<Box key={role}>
<Text color={colors.text}> {roleInfo?.name ?? role}</Text>
</Box>
);
})
)}
<Box marginLeft={1}>
{filledRoles.size === 0 ? (
<Box>
<Text color={colors.textMuted}> None yet</Text>
</Box>
) : (
Array.from(filledRoles).map(role => {
const roleInfoRaw = template?.roles?.[role];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
return (
<Box key={role}>
<Text color={colors.text}> {roleInfo?.name ?? role}</Text>
</Box>
);
})
)}
</Box>
</Box>
{/* Inputs */}
@@ -131,48 +133,52 @@ export function PreviewInvitationStep({
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
</Box>
{inputs.length === 0 ? (
<Box>
<Text color={colors.textMuted}> None yet</Text>
</Box>
) : (
inputs.map((input, idx) => {
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
return (
<Box key={`input-${idx}`}>
<Text color={colors.text}>
{' '} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`}
</Text>
</Box>
);
})
)}
<Box marginLeft={1}>
{inputs.length === 0 ? (
<Box>
<Text color={colors.textMuted}> None yet</Text>
</Box>
) : (
inputs.map((input, idx) => {
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
return (
<Box key={`input-${idx}`}>
<Text color={colors.text}>
{' '} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`}
</Text>
</Box>
);
})
)}
</Box>
</Box>
{/* Outputs */}
<Box flexDirection="column" marginBottom={1}>
<Box flexDirection="column" marginBottom={1} marginLeft={1}>
<Box>
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
</Box>
{outputs.length === 0 ? (
<Box>
<Text color={colors.textMuted}> None yet</Text>
</Box>
) : (
outputs.map((output, idx) => {
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
return (
<Box key={`output-${idx}`}>
<Text color={colors.text}>
{' '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
</Text>
</Box>
);
})
)}
<Box marginLeft={1}>
{outputs.length === 0 ? (
<Box>
<Text color={colors.textMuted}>None yet</Text>
</Box>
) : (
outputs.map((output, idx) => {
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
return (
<Box key={`output-${idx}`}>
<Text color={colors.text}>
{' '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
</Text>
</Box>
);
})
)}
</Box>
</Box>
{/* Variables */}
@@ -181,25 +187,27 @@ export function PreviewInvitationStep({
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
</Box>
{variables.length === 0 ? (
<Box>
<Text color={colors.textMuted}> None set</Text>
</Box>
) : (
variables.map((variable, idx) => {
const varTemplate = template?.variables?.[variable.variableIdentifier];
const displayValue = typeof variable.value === 'bigint'
? variable.value.toString()
: String(variable.value);
return (
<Box key={`var-${idx}`}>
<Text color={colors.text}>
{' '} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
</Text>
</Box>
);
})
)}
<Box marginLeft={1}>
{variables.length === 0 ? (
<Box>
<Text color={colors.textMuted}> None set</Text>
</Box>
) : (
variables.map((variable, idx) => {
const varTemplate = template?.variables?.[variable.variableIdentifier];
const displayValue = typeof variable.value === 'bigint'
? variable.value.toString()
: String(variable.value);
return (
<Box key={`var-${idx}`}>
<Text color={colors.text}>
{' '} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
</Text>
</Box>
);
})
)}
</Box>
</Box>
{/* Navigation hint */}

View File

@@ -26,7 +26,6 @@ export function ReviewStep({
selectedInputs,
requiredAmount,
changeAmount,
appService,
onComplete,
onCancel,
isActive,

View File

@@ -5,14 +5,19 @@
* The flow controller (`InvitationImportFlow`) accumulates data and passes it forward.
*/
import type { Invitation } from '../../../../services/invitation.js';
import type { AppService } from '../../../../services/app.js';
import type { XOTemplate } from '@xo-cash/types';
import type { Invitation } from "../../../../services/invitation.js";
import type { AppService } from "../../../../services/app.js";
import type { XOTemplate } from "@xo-cash/types";
// ── Step definitions ─────────────────────────────────────────────────────────
/** Identifies each step in the import flow. */
export type ImportStepType = 'fetch' | 'preview' | 'role-select' | 'inputs-select' | 'review';
export type ImportStepType =
| "fetch"
| "preview"
| "role-select"
| "inputs-select"
| "review";
/** A single step descriptor used by the flow controller and step indicator. */
export interface ImportStep {
@@ -22,17 +27,17 @@ export interface ImportStep {
/** The ordered list of steps in the import flow. */
export const IMPORT_STEPS: ImportStep[] = [
{ name: 'Fetch', type: 'fetch' },
{ name: 'Preview', type: 'preview' },
{ name: 'Select Role', type: 'role-select' },
{ name: 'Select Inputs', type: 'inputs-select' },
{ name: 'Review', type: 'review' },
{ name: "Fetch", type: "fetch" },
{ name: "Preview", type: "preview" },
{ name: "Select Role", type: "role-select" },
{ name: "Select Inputs", type: "inputs-select" },
{ name: "Review", type: "review" },
];
// ── Display mode ─────────────────────────────────────────────────────────────
/** Controls whether the import flow renders as a dialog overlay or a full screen. */
export type ImportFlowMode = 'dialog' | 'screen';
export type ImportFlowMode = "dialog" | "screen";
// ── UTXO selection ───────────────────────────────────────────────────────────