Format with prettier. Use screen mode for invitation import - dialog mode is broken.
This commit is contained in:
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -719,7 +719,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
{importingId && appService && (
|
||||
<InvitationImportFlow
|
||||
invitationId={importingId}
|
||||
mode="dialog"
|
||||
mode="screen"
|
||||
appService={appService}
|
||||
onClose={handleImportFlowClose}
|
||||
showError={showError}
|
||||
|
||||
@@ -15,7 +15,6 @@ export function FetchInvitationStep({
|
||||
invitationId,
|
||||
appService,
|
||||
onComplete,
|
||||
onCancel,
|
||||
isActive,
|
||||
}: FetchStepProps): React.ReactElement {
|
||||
const [status, setStatus] = useState<'loading' | 'error'>('loading');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -26,7 +26,6 @@ export function ReviewStep({
|
||||
selectedInputs,
|
||||
requiredAmount,
|
||||
changeAmount,
|
||||
appService,
|
||||
onComplete,
|
||||
onCancel,
|
||||
isActive,
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user