Initial Commit
This commit is contained in:
497
src/tui/screens/ActionWizard.tsx
Normal file
497
src/tui/screens/ActionWizard.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Action Wizard Screen - Step-by-step walkthrough for template actions.
|
||||
*
|
||||
* Guides users through:
|
||||
* - Reviewing action requirements
|
||||
* - Entering variables (e.g., requestedSatoshis)
|
||||
* - Reviewing outputs
|
||||
* - Creating and publishing invitation
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import { StepIndicator, type Step } from '../components/ProgressBar.js';
|
||||
import { Button, ButtonRow } from '../components/Button.js';
|
||||
import { useNavigation } from '../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||
import { colors, logoSmall, formatSatoshis } from '../theme.js';
|
||||
import { copyToClipboard } from '../utils/clipboard.js';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
|
||||
/**
|
||||
* Wizard step types.
|
||||
*/
|
||||
type StepType = 'info' | 'variables' | 'review' | 'publish';
|
||||
|
||||
/**
|
||||
* Wizard step definition.
|
||||
*/
|
||||
interface WizardStep {
|
||||
name: string;
|
||||
type: StepType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable input state.
|
||||
*/
|
||||
interface VariableInput {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
hint?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action Wizard Screen Component.
|
||||
*/
|
||||
export function ActionWizardScreen(): React.ReactElement {
|
||||
const { navigate, goBack, data: navData } = useNavigation();
|
||||
const { walletController, invitationController, showError, showInfo } = useAppContext();
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
// Extract navigation data
|
||||
const templateIdentifier = navData.templateIdentifier as string | undefined;
|
||||
const actionIdentifier = navData.actionIdentifier as string | undefined;
|
||||
const roleIdentifier = navData.roleIdentifier as string | undefined;
|
||||
const template = navData.template as XOTemplate | undefined;
|
||||
|
||||
// State
|
||||
const [steps, setSteps] = useState<WizardStep[]>([]);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [variables, setVariables] = useState<VariableInput[]>([]);
|
||||
const [focusedInput, setFocusedInput] = useState(0);
|
||||
const [focusedButton, setFocusedButton] = useState<'back' | 'cancel' | 'next'>('next');
|
||||
const [focusArea, setFocusArea] = useState<'content' | 'buttons'>('content');
|
||||
const [invitationId, setInvitationId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
/**
|
||||
* Initialize wizard on mount.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!template || !actionIdentifier || !roleIdentifier) {
|
||||
showError('Missing wizard data');
|
||||
goBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// Build steps based on template
|
||||
const action = template.actions?.[actionIdentifier];
|
||||
const role = action?.roles?.[roleIdentifier];
|
||||
const requirements = role?.requirements;
|
||||
|
||||
const wizardSteps: WizardStep[] = [
|
||||
{ name: 'Welcome', type: 'info' },
|
||||
];
|
||||
|
||||
// Add variables step if needed
|
||||
if (requirements?.variables && requirements.variables.length > 0) {
|
||||
wizardSteps.push({ name: 'Variables', type: 'variables' });
|
||||
|
||||
// Initialize variable inputs
|
||||
const varInputs = requirements.variables.map(varId => {
|
||||
const varDef = template.variables?.[varId];
|
||||
return {
|
||||
id: varId,
|
||||
name: varDef?.name || varId,
|
||||
type: varDef?.type || 'string',
|
||||
hint: varDef?.hint,
|
||||
value: '',
|
||||
};
|
||||
});
|
||||
setVariables(varInputs);
|
||||
}
|
||||
|
||||
wizardSteps.push({ name: 'Review', type: 'review' });
|
||||
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
||||
|
||||
setSteps(wizardSteps);
|
||||
setStatus(`${actionIdentifier}/${roleIdentifier}`);
|
||||
}, [template, actionIdentifier, roleIdentifier, showError, goBack, setStatus]);
|
||||
|
||||
/**
|
||||
* Get current step data.
|
||||
*/
|
||||
const currentStepData = steps[currentStep];
|
||||
|
||||
/**
|
||||
* Navigate to next step.
|
||||
*/
|
||||
const nextStep = useCallback(async () => {
|
||||
if (currentStep >= steps.length - 1) return;
|
||||
|
||||
// If on review step, create invitation
|
||||
if (currentStepData?.type === 'review') {
|
||||
await createInvitation();
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentStep(prev => prev + 1);
|
||||
setFocusArea('content');
|
||||
setFocusedInput(0);
|
||||
}, [currentStep, steps.length, currentStepData]);
|
||||
|
||||
/**
|
||||
* Navigate to previous step.
|
||||
*/
|
||||
const previousStep = useCallback(() => {
|
||||
if (currentStep <= 0) {
|
||||
goBack();
|
||||
return;
|
||||
}
|
||||
setCurrentStep(prev => prev - 1);
|
||||
setFocusArea('content');
|
||||
setFocusedInput(0);
|
||||
}, [currentStep, goBack]);
|
||||
|
||||
/**
|
||||
* Cancel wizard.
|
||||
*/
|
||||
const cancel = useCallback(() => {
|
||||
goBack();
|
||||
}, [goBack]);
|
||||
|
||||
/**
|
||||
* Create invitation.
|
||||
*/
|
||||
const createInvitation = useCallback(async () => {
|
||||
if (!templateIdentifier || !actionIdentifier || !roleIdentifier) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setStatus('Creating invitation...');
|
||||
|
||||
try {
|
||||
// Create invitation
|
||||
const tracked = await invitationController.createInvitation(
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
);
|
||||
|
||||
const invId = tracked.invitation.invitationIdentifier;
|
||||
setInvitationId(invId);
|
||||
|
||||
// Add variables if any
|
||||
if (variables.length > 0) {
|
||||
const variableData = variables.map(v => ({
|
||||
variableIdentifier: v.id,
|
||||
value: v.type === 'number' || v.type === 'satoshis'
|
||||
? BigInt(v.value || '0')
|
||||
: v.value,
|
||||
}));
|
||||
await invitationController.addVariables(invId, variableData);
|
||||
}
|
||||
|
||||
// Publish to sync server
|
||||
await invitationController.publishAndSubscribe(invId);
|
||||
|
||||
setCurrentStep(prev => prev + 1);
|
||||
setStatus('Invitation created');
|
||||
} catch (error) {
|
||||
showError(`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [templateIdentifier, actionIdentifier, roleIdentifier, variables, invitationController, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Copy invitation ID to clipboard.
|
||||
*/
|
||||
const copyId = useCallback(async () => {
|
||||
if (!invitationId) return;
|
||||
|
||||
try {
|
||||
await copyToClipboard(invitationId);
|
||||
showInfo(`Copied to clipboard!\n\n${invitationId}`);
|
||||
} catch (error) {
|
||||
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}, [invitationId, showInfo, showError]);
|
||||
|
||||
/**
|
||||
* Update variable value.
|
||||
*/
|
||||
const updateVariable = useCallback((index: number, value: string) => {
|
||||
setVariables(prev => {
|
||||
const updated = [...prev];
|
||||
const variable = updated[index];
|
||||
if (variable) {
|
||||
updated[index] = { ...variable, value };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useInput((input, key) => {
|
||||
// Tab to switch between content and buttons
|
||||
if (key.tab) {
|
||||
if (focusArea === 'content') {
|
||||
// In variables step, tab cycles through inputs first
|
||||
if (currentStepData?.type === 'variables' && variables.length > 0) {
|
||||
if (focusedInput < variables.length - 1) {
|
||||
setFocusedInput(prev => prev + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setFocusArea('buttons');
|
||||
setFocusedButton('next');
|
||||
} else {
|
||||
// Cycle through buttons
|
||||
if (focusedButton === 'back') {
|
||||
setFocusedButton('cancel');
|
||||
} else if (focusedButton === 'cancel') {
|
||||
setFocusedButton('next');
|
||||
} else {
|
||||
setFocusArea('content');
|
||||
setFocusedInput(0);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift+Tab
|
||||
if (key.shift && key.tab) {
|
||||
if (focusArea === 'buttons') {
|
||||
if (focusedButton === 'next') {
|
||||
setFocusedButton('cancel');
|
||||
} else if (focusedButton === 'cancel') {
|
||||
setFocusedButton('back');
|
||||
} else {
|
||||
setFocusArea('content');
|
||||
if (currentStepData?.type === 'variables' && variables.length > 0) {
|
||||
setFocusedInput(variables.length - 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (focusedInput > 0) {
|
||||
setFocusedInput(prev => prev - 1);
|
||||
} else {
|
||||
setFocusArea('buttons');
|
||||
setFocusedButton('back');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys in buttons area
|
||||
if (focusArea === 'buttons') {
|
||||
if (key.leftArrow) {
|
||||
setFocusedButton(prev =>
|
||||
prev === 'next' ? 'cancel' : prev === 'cancel' ? 'back' : 'back'
|
||||
);
|
||||
} else if (key.rightArrow) {
|
||||
setFocusedButton(prev =>
|
||||
prev === 'back' ? 'cancel' : prev === 'cancel' ? 'next' : 'next'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Enter on buttons
|
||||
if (key.return && focusArea === 'buttons') {
|
||||
if (focusedButton === 'back') previousStep();
|
||||
else if (focusedButton === 'cancel') cancel();
|
||||
else if (focusedButton === 'next') nextStep();
|
||||
}
|
||||
|
||||
// 'c' to copy on publish step
|
||||
if (input === 'c' && currentStepData?.type === 'publish' && invitationId) {
|
||||
copyId();
|
||||
}
|
||||
});
|
||||
|
||||
// Get action details
|
||||
const action = template?.actions?.[actionIdentifier ?? ''];
|
||||
const actionName = action?.name || actionIdentifier || 'Unknown';
|
||||
|
||||
// Render step content
|
||||
const renderStepContent = () => {
|
||||
if (!currentStepData) return null;
|
||||
|
||||
switch (currentStepData.type) {
|
||||
case 'info':
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.primary} bold>Action: {actionName}</Text>
|
||||
<Text color={colors.textMuted}>{action?.description || 'No description'}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.text}>Your Role: </Text>
|
||||
<Text color={colors.accent}>{roleIdentifier}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Show requirements */}
|
||||
{action?.roles?.[roleIdentifier ?? '']?.requirements && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text}>Requirements:</Text>
|
||||
{action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => (
|
||||
<Text key={v} color={colors.textMuted}> • Variable: {v}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'variables':
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.text} bold>Enter required values:</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{variables.map((variable, index) => (
|
||||
<Box key={variable.id} flexDirection="column" marginBottom={1}>
|
||||
<Text color={colors.primary}>{variable.name}</Text>
|
||||
{variable.hint && (
|
||||
<Text color={colors.textMuted} dimColor>({variable.hint})</Text>
|
||||
)}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={focusArea === 'content' && focusedInput === index ? colors.focus : colors.border}
|
||||
paddingX={1}
|
||||
marginTop={1}
|
||||
>
|
||||
<TextInput
|
||||
value={variable.value}
|
||||
onChange={value => updateVariable(index, value)}
|
||||
focus={focusArea === 'content' && focusedInput === index}
|
||||
placeholder={`Enter ${variable.name}...`}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'review':
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.text} bold>Review your invitation:</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.textMuted}>Template: {template?.name}</Text>
|
||||
<Text color={colors.textMuted}>Action: {actionName}</Text>
|
||||
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
|
||||
|
||||
{variables.length > 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text}>Variables:</Text>
|
||||
{variables.map(v => (
|
||||
<Text key={v.id} color={colors.textMuted}>
|
||||
{' '}{v.name}: {v.value || '(empty)'}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.warning}>
|
||||
Press Next to create and publish the invitation.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'publish':
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.success} bold>✓ Invitation Created!</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text}>Invitation ID:</Text>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={colors.primary}
|
||||
paddingX={1}
|
||||
marginTop={1}
|
||||
>
|
||||
<Text color={colors.accent}>{invitationId}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>
|
||||
Share this ID with the other party to complete the transaction.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.warning}>Press 'c' to copy ID to clipboard</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Convert steps to StepIndicator format
|
||||
const stepIndicatorSteps: Step[] = steps.map(s => ({ label: s.name }));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{/* Header */}
|
||||
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1} flexDirection="column">
|
||||
<Text color={colors.primary} bold>{logoSmall} - Action Wizard</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{template?.name} {'>'} {actionName} (as {roleIdentifier})
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<Box marginTop={1} paddingX={1}>
|
||||
<StepIndicator steps={stepIndicatorSteps} currentStep={currentStep} />
|
||||
</Box>
|
||||
|
||||
{/* Content area */}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={focusArea === 'content' ? colors.focus : colors.primary}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
marginTop={1}
|
||||
marginX={1}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold>
|
||||
{' '}{currentStepData?.name} ({currentStep + 1}/{steps.length}){' '}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
{isCreating ? (
|
||||
<Text color={colors.info}>Creating invitation...</Text>
|
||||
) : (
|
||||
renderStepContent()
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Buttons */}
|
||||
<Box marginTop={1} marginX={1} justifyContent="space-between">
|
||||
<Box gap={1}>
|
||||
<Button
|
||||
label="Back"
|
||||
focused={focusArea === 'buttons' && focusedButton === 'back'}
|
||||
disabled={currentStepData?.type === 'publish'}
|
||||
/>
|
||||
<Button
|
||||
label="Cancel"
|
||||
focused={focusArea === 'buttons' && focusedButton === 'cancel'}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
label={currentStepData?.type === 'publish' ? 'Done' : 'Next'}
|
||||
focused={focusArea === 'buttons' && focusedButton === 'next'}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Help text */}
|
||||
<Box marginTop={1} marginX={1}>
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
Tab: Navigate • Enter: Select • Esc: Back
|
||||
{currentStepData?.type === 'publish' ? ' • c: Copy ID' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user