Large amount of changes. Successfully broadcasts txs
This commit is contained in:
731
src/tui/screens/invitations/InvitationScreen.tsx
Normal file
731
src/tui/screens/invitations/InvitationScreen.tsx
Normal file
@@ -0,0 +1,731 @@
|
||||
/**
|
||||
* Invitation Screen - Manages invitations (create, import, view, monitor).
|
||||
*
|
||||
* Provides:
|
||||
* - Import invitation by ID with multi-step import flow
|
||||
* - View active invitations with detailed information
|
||||
* - Monitor invitation updates via SSE
|
||||
* - Fill missing requirements
|
||||
* - Sign and complete invitations
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { InputDialog } from '../../components/Dialog.js';
|
||||
import { ScrollableList, type ListItemData, type ListGroup } from '../../components/List.js';
|
||||
import { useNavigation } from '../../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
||||
import { useInvitations } from '../../hooks/useInvitations.js';
|
||||
import { colors, logoSmall, formatSatoshis } from '../../theme.js';
|
||||
import { copyToClipboard } from '../../utils/clipboard.js';
|
||||
import type { Invitation } from '../../../services/invitation.js';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
|
||||
import {
|
||||
getInvitationState,
|
||||
getStateColorName,
|
||||
getInvitationInputs,
|
||||
getInvitationOutputs,
|
||||
getInvitationVariables,
|
||||
getUserRole,
|
||||
formatInvitationListItem,
|
||||
formatInvitationId,
|
||||
} from '../../../utils/invitation-utils.js';
|
||||
|
||||
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
|
||||
|
||||
/**
|
||||
* Map state color name to theme color.
|
||||
*/
|
||||
function getStateColor(state: string): string {
|
||||
const colorName = getStateColorName(state);
|
||||
switch (colorName) {
|
||||
case 'info':
|
||||
return colors.info as string;
|
||||
case 'warning':
|
||||
return colors.warning as string;
|
||||
case 'success':
|
||||
return colors.success as string;
|
||||
case 'error':
|
||||
return colors.error as string;
|
||||
case 'muted':
|
||||
default:
|
||||
return colors.textMuted as string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action menu items.
|
||||
*/
|
||||
const actionItems: ListItemData<string>[] = [
|
||||
{ key: 'accept', label: 'Accept & Join', value: 'accept' },
|
||||
{ key: 'fill', label: 'Fill Requirements', value: 'fill' },
|
||||
{ key: 'sign', label: 'Sign Transaction', value: 'sign' },
|
||||
{ key: 'transaction', label: 'View Transaction', value: 'transaction' },
|
||||
{ key: 'copy', label: 'Copy Invitation ID', value: 'copy' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Invitation list item with invitation value or null for import action.
|
||||
*/
|
||||
type InvitationListItem = ListItemData<Invitation | null>;
|
||||
|
||||
/**
|
||||
* Groups for the invitation list.
|
||||
*/
|
||||
const invitationListGroups: ListGroup[] = [
|
||||
{ id: 'actions' },
|
||||
{ id: 'invitations', separator: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Invitation Screen Component.
|
||||
*/
|
||||
export function InvitationScreen(): React.ReactElement {
|
||||
const { navigate, data: navData } = useNavigation();
|
||||
const { appService, showError, showInfo } = useAppContext();
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
const invitations = useInvitations();
|
||||
|
||||
// ── UI state ─────────────────────────────────────────────────────────────
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||
const [focusedPanel, setFocusedPanel] = useState<'list' | 'actions'>('list');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// ── Import state ─────────────────────────────────────────────────────────
|
||||
// Two phases: first the ID input dialog, then the multi-step import flow.
|
||||
const [showIdDialog, setShowIdDialog] = useState(false);
|
||||
const [importingId, setImportingId] = useState<string | null>(null);
|
||||
|
||||
// ── Template cache ───────────────────────────────────────────────────────
|
||||
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
|
||||
|
||||
// Check if we should open import dialog on mount
|
||||
const initialMode = navData.mode as string | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (initialMode === 'import') {
|
||||
setShowIdDialog(true);
|
||||
}
|
||||
}, [initialMode]);
|
||||
|
||||
/**
|
||||
* Load templates for all invitations (for list display).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!appService) return;
|
||||
|
||||
invitations.forEach(inv => {
|
||||
const templateId = inv.data.templateIdentifier;
|
||||
if (!templateCache.has(templateId)) {
|
||||
appService.engine.getTemplate(templateId).then(template => {
|
||||
if (template) {
|
||||
setTemplateCache(prev => new Map(prev).set(templateId, template));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [invitations, appService, templateCache]);
|
||||
|
||||
/**
|
||||
* Build list items for ScrollableList.
|
||||
*/
|
||||
const listItems = useMemo((): InvitationListItem[] => {
|
||||
const importItem: InvitationListItem = {
|
||||
key: 'import',
|
||||
label: '+ Import Invitation',
|
||||
value: null,
|
||||
group: 'actions',
|
||||
color: 'info',
|
||||
};
|
||||
|
||||
const invitationItems: InvitationListItem[] = invitations.map(inv => {
|
||||
const template = templateCache.get(inv.data.templateIdentifier);
|
||||
const formatted = formatInvitationListItem(inv, template);
|
||||
|
||||
return {
|
||||
key: inv.data.invitationIdentifier,
|
||||
label: formatted.label,
|
||||
value: inv,
|
||||
group: 'invitations',
|
||||
color: formatted.statusColor,
|
||||
hidden: !formatted.isValid,
|
||||
};
|
||||
});
|
||||
|
||||
return [importItem, ...invitationItems];
|
||||
}, [invitations, templateCache]);
|
||||
|
||||
const selectedItem = listItems[selectedIndex];
|
||||
const selectedInvitation = selectedItem?.value ?? null;
|
||||
|
||||
/**
|
||||
* Load template for selected invitation.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!selectedInvitation || !appService) {
|
||||
setSelectedTemplate(null);
|
||||
return;
|
||||
}
|
||||
|
||||
appService.engine.getTemplate(selectedInvitation.data.templateIdentifier)
|
||||
.then(template => setSelectedTemplate(template ?? null));
|
||||
}, [selectedInvitation, appService]);
|
||||
|
||||
// ── Import flow callbacks ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ID dialog submitted — transition to the multi-step import flow.
|
||||
*/
|
||||
const handleImportIdSubmit = useCallback((invitationId: string) => {
|
||||
if (!invitationId.trim()) {
|
||||
setShowIdDialog(false);
|
||||
return;
|
||||
}
|
||||
setShowIdDialog(false);
|
||||
setImportingId(invitationId.trim());
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Import flow closed (completed or cancelled).
|
||||
*/
|
||||
const handleImportFlowClose = useCallback(() => {
|
||||
setImportingId(null);
|
||||
}, []);
|
||||
|
||||
// ── Action handlers ────────────────────────────────────────────────────
|
||||
|
||||
const acceptInvitation = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
showError('No invitation selected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setStatus('Accepting invitation...');
|
||||
|
||||
await selectedInvitation.accept();
|
||||
showInfo('Invitation accepted! You are now a participant.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
|
||||
setStatus('Ready');
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
if (errorMsg.toLowerCase().includes('already') || errorMsg.toLowerCase().includes('participant')) {
|
||||
showInfo('You have already accepted this invitation.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
|
||||
} else {
|
||||
showError(`Failed to accept: ${errorMsg}`);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||
|
||||
const signInvitation = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
showError('No invitation selected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setStatus('Signing invitation...');
|
||||
|
||||
await selectedInvitation.sign();
|
||||
showInfo('Invitation signed!');
|
||||
setStatus('Ready');
|
||||
} catch (error) {
|
||||
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||
|
||||
const copyId = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
showError('No invitation selected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await copyToClipboard(selectedInvitation.data.invitationIdentifier);
|
||||
showInfo(`Copied!\n\n${selectedInvitation.data.invitationIdentifier}`);
|
||||
} catch (error) {
|
||||
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}, [selectedInvitation, showInfo, showError]);
|
||||
|
||||
const fillRequirements = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
showError('No invitation selected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
setStatus('Checking available roles...');
|
||||
const roles = await selectedInvitation.getAvailableRoles();
|
||||
|
||||
if (roles.length === 0) {
|
||||
showInfo('You are already participating in this invitation. Checking if inputs are needed...');
|
||||
} else {
|
||||
const roleToTake = roles[0];
|
||||
showInfo(`Accepting invitation as role: ${roleToTake}`);
|
||||
setStatus(`Accepting as ${roleToTake}...`);
|
||||
|
||||
try {
|
||||
await selectedInvitation.accept();
|
||||
} catch (e) {
|
||||
showError(`Failed to accept role: ${e instanceof Error ? e.message : String(e)}`);
|
||||
setStatus('Ready');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setStatus('Analyzing invitation...');
|
||||
|
||||
let requiredAmount = 0n;
|
||||
const commits = selectedInvitation.data.commits || [];
|
||||
for (const commit of commits) {
|
||||
const variables = commit.data?.variables || [];
|
||||
for (const variable of variables) {
|
||||
if (variable.variableIdentifier?.toLowerCase().includes('satoshi')) {
|
||||
requiredAmount = BigInt(variable.value?.toString() || '0');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (requiredAmount > 0n) break;
|
||||
}
|
||||
|
||||
const fee = 500n;
|
||||
const dust = 546n;
|
||||
const totalNeeded = requiredAmount + fee + dust;
|
||||
|
||||
const utxos = await selectedInvitation.findSuitableResources({
|
||||
templateIdentifier: selectedInvitation.data.templateIdentifier,
|
||||
outputIdentifier: 'receiveOutput',
|
||||
});
|
||||
|
||||
if (utxos.length === 0) {
|
||||
showError('No suitable UTXOs found. Make sure your wallet has funds.');
|
||||
setStatus('Ready');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Selecting UTXOs...');
|
||||
|
||||
const selectedUtxos: Array<{
|
||||
outpointTransactionHash: string;
|
||||
outpointIndex: number;
|
||||
valueSatoshis: bigint;
|
||||
}> = [];
|
||||
let accumulated = 0n;
|
||||
const seenLockingBytecodes = new Set<string>();
|
||||
|
||||
for (const utxo of utxos) {
|
||||
const lockingBytecodeHex = utxo.lockingBytecode
|
||||
? typeof utxo.lockingBytecode === 'string'
|
||||
? utxo.lockingBytecode
|
||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||
: undefined;
|
||||
|
||||
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue;
|
||||
if (lockingBytecodeHex) seenLockingBytecodes.add(lockingBytecodeHex);
|
||||
|
||||
selectedUtxos.push({
|
||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||
outpointIndex: utxo.outpointIndex,
|
||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||
});
|
||||
accumulated += BigInt(utxo.valueSatoshis);
|
||||
|
||||
if (accumulated >= totalNeeded) break;
|
||||
}
|
||||
|
||||
if (accumulated < totalNeeded) {
|
||||
showError(`Insufficient funds. Need ${formatSatoshis(totalNeeded)}, have ${formatSatoshis(accumulated)}`);
|
||||
setStatus('Ready');
|
||||
return;
|
||||
}
|
||||
|
||||
const changeAmount = accumulated - requiredAmount - fee;
|
||||
|
||||
setStatus('Adding inputs...');
|
||||
await selectedInvitation.addInputs(
|
||||
selectedUtxos.map(u => ({
|
||||
outpointTransactionHash: new Uint8Array(Buffer.from(u.outpointTransactionHash, 'hex')),
|
||||
outpointIndex: u.outpointIndex,
|
||||
}))
|
||||
);
|
||||
|
||||
if (changeAmount >= dust) {
|
||||
setStatus('Adding change output...');
|
||||
await selectedInvitation.addOutputs([{
|
||||
valueSatoshis: changeAmount,
|
||||
}]);
|
||||
}
|
||||
|
||||
showInfo(
|
||||
`Requirements filled!\n\n` +
|
||||
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
|
||||
`• Total: ${formatSatoshis(accumulated)}\n` +
|
||||
`• Required: ${formatSatoshis(requiredAmount)}\n` +
|
||||
`• Fee: ${formatSatoshis(fee)}\n` +
|
||||
`• Change: ${formatSatoshis(changeAmount)}\n\n` +
|
||||
`Now use "Sign Transaction" to complete.`
|
||||
);
|
||||
setStatus('Ready');
|
||||
} catch (error) {
|
||||
showError(`Failed to fill requirements: ${error instanceof Error ? error.message : String(error)}`);
|
||||
setStatus('Ready');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||
|
||||
const handleAction = useCallback((action: string) => {
|
||||
switch (action) {
|
||||
case 'copy':
|
||||
copyId();
|
||||
break;
|
||||
case 'accept':
|
||||
acceptInvitation();
|
||||
break;
|
||||
case 'fill':
|
||||
fillRequirements();
|
||||
break;
|
||||
case 'sign':
|
||||
signInvitation();
|
||||
break;
|
||||
case 'transaction':
|
||||
if (selectedInvitation) {
|
||||
navigate('transaction', { invitationId: selectedInvitation.data.invitationIdentifier });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
|
||||
|
||||
const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => {
|
||||
if (item.key === 'import') {
|
||||
setShowIdDialog(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleActionItemActivate = useCallback((item: ListItemData<string>, _index: number) => {
|
||||
if (item.value) {
|
||||
handleAction(item.value);
|
||||
}
|
||||
}, [handleAction]);
|
||||
|
||||
// ── Keyboard navigation ──────────────────────────────────────────────────
|
||||
// Disabled when the ID dialog or import flow is open.
|
||||
const isOverlayOpen = showIdDialog || importingId !== null;
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.tab) {
|
||||
setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list');
|
||||
return;
|
||||
}
|
||||
|
||||
if (input === 'c' && selectedInvitation) {
|
||||
copyId();
|
||||
}
|
||||
|
||||
if (input === 'i') {
|
||||
setShowIdDialog(true);
|
||||
}
|
||||
}, { isActive: !isOverlayOpen });
|
||||
|
||||
// ── Render helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const renderInvitationListItem = useCallback((
|
||||
item: InvitationListItem,
|
||||
isSelected: boolean,
|
||||
isFocused: boolean
|
||||
): React.ReactNode => {
|
||||
if (item.key === 'import') {
|
||||
return (
|
||||
<Text
|
||||
color={isFocused ? colors.focus : colors.info}
|
||||
bold={isSelected}
|
||||
>
|
||||
{isFocused ? '▸ ' : ' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const inv = item.value;
|
||||
if (!inv) return null;
|
||||
|
||||
const state = getInvitationState(inv);
|
||||
const template = templateCache.get(inv.data.templateIdentifier);
|
||||
const templateName = template?.name ?? 'Unknown';
|
||||
|
||||
return (
|
||||
<Text
|
||||
color={isFocused ? colors.focus : colors.text}
|
||||
bold={isSelected}
|
||||
>
|
||||
{isFocused ? '▸ ' : ' '}
|
||||
<Text color={getStateColor(state)}>[{state}]</Text>
|
||||
{' '}{templateName}-{inv.data.actionIdentifier} ({formatInvitationId(inv.data.invitationIdentifier, 8)})
|
||||
</Text>
|
||||
);
|
||||
}, [templateCache]);
|
||||
|
||||
const renderDetails = () => {
|
||||
if (!selectedInvitation) {
|
||||
return <Text color={colors.textMuted}>Select an invitation to view details</Text>;
|
||||
}
|
||||
|
||||
const state = getInvitationState(selectedInvitation);
|
||||
const action = selectedTemplate?.actions?.[selectedInvitation.data.actionIdentifier];
|
||||
const inputs = getInvitationInputs(selectedInvitation);
|
||||
const outputs = getInvitationOutputs(selectedInvitation);
|
||||
const variables = getInvitationVariables(selectedInvitation);
|
||||
|
||||
const userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null;
|
||||
const userRole = getUserRole(selectedInvitation, userEntityId);
|
||||
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
||||
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Type & Status */}
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width="50%">
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.primary} bold>Type: </Text>
|
||||
<Text color={colors.text}>{selectedTemplate?.name ?? 'Unknown Template'}</Text>
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
{selectedTemplate?.description ?? 'No description available'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box width="50%">
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.primary} bold>Status: </Text>
|
||||
<Text color={getStateColor(state)}>{state}</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
Action: {action?.name ?? selectedInvitation.data.actionIdentifier}
|
||||
</Text>
|
||||
{action?.description && (
|
||||
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Your Role */}
|
||||
{userRole && (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text color={colors.primary} bold>Your Role: </Text>
|
||||
<Text color={colors.success}>{roleInfo?.name ?? userRole}</Text>
|
||||
{roleInfo?.description && (
|
||||
<Text color={colors.textMuted} dimColor>{roleInfo.description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Inputs & Outputs */}
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width="50%" flexDirection="column">
|
||||
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
|
||||
{inputs.length === 0 ? (
|
||||
<Text color={colors.textMuted}> No inputs yet</Text>
|
||||
) : (
|
||||
inputs.map((input, idx) => {
|
||||
const isUserInput = input.entityIdentifier === userEntityId;
|
||||
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
|
||||
return (
|
||||
<Text
|
||||
key={`input-${idx}`}
|
||||
color={isUserInput ? colors.success : colors.text}
|
||||
>
|
||||
{' '}{isUserInput ? '• ' : '○ '}
|
||||
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box width="50%" flexDirection="column">
|
||||
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
|
||||
{outputs.length === 0 ? (
|
||||
<Text color={colors.textMuted}> No outputs yet</Text>
|
||||
) : (
|
||||
outputs.map((output, idx) => {
|
||||
const isUserOutput = output.entityIdentifier === userEntityId;
|
||||
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
|
||||
return (
|
||||
<Text
|
||||
key={`output-${idx}`}
|
||||
color={isUserOutput ? colors.success : colors.text}
|
||||
>
|
||||
{' '}{isUserOutput ? '• ' : '○ '}
|
||||
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Variables */}
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
|
||||
{variables.length === 0 ? (
|
||||
<Text color={colors.textMuted}> No variables set</Text>
|
||||
) : (
|
||||
variables.map((variable, idx) => {
|
||||
const isUserVariable = variable.entityIdentifier === userEntityId;
|
||||
const varTemplate = selectedTemplate?.variables?.[variable.variableIdentifier];
|
||||
const displayValue = typeof variable.value === 'bigint'
|
||||
? variable.value.toString()
|
||||
: String(variable.value);
|
||||
return (
|
||||
<Text
|
||||
key={`var-${idx}`}
|
||||
color={isUserVariable ? colors.success : colors.text}
|
||||
>
|
||||
{' '}{isUserVariable ? '• ' : '○ '}
|
||||
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
||||
{varTemplate?.description && (
|
||||
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Main render ──────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{/* Header */}
|
||||
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
|
||||
<Text color={colors.primary} bold>{logoSmall} - Invitations</Text>
|
||||
</Box>
|
||||
|
||||
{/* Top row: List + Actions */}
|
||||
<Box flexDirection="row" marginTop={1} height={12}>
|
||||
{/* Left column: Invitation list */}
|
||||
<Box flexDirection="column" width="70%" paddingRight={1}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={focusedPanel === 'list' ? colors.focus : colors.primary}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Invitations </Text>
|
||||
<ScrollableList
|
||||
items={listItems}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
onActivate={handleListItemActivate}
|
||||
focus={focusedPanel === 'list' && !isOverlayOpen}
|
||||
maxVisible={6}
|
||||
groups={invitationListGroups}
|
||||
emptyMessage="No invitations yet"
|
||||
renderItem={renderInvitationListItem}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right column: Actions */}
|
||||
<Box flexDirection="column" width="30%" paddingLeft={1}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Actions </Text>
|
||||
<ScrollableList
|
||||
items={actionItems}
|
||||
selectedIndex={selectedActionIndex}
|
||||
onSelect={setSelectedActionIndex}
|
||||
onActivate={handleActionItemActivate}
|
||||
focus={focusedPanel === 'actions' && !isOverlayOpen}
|
||||
emptyMessage="No actions"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Bottom row: Details */}
|
||||
<Box flexDirection="column" marginTop={1} flexGrow={1}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={colors.border}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Details </Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{renderDetails()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Help text */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
Tab: Switch panel • ↑↓: Navigate • Enter: Select • i: Import • c: Copy ID • Esc: Back
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Import ID dialog */}
|
||||
{showIdDialog && (
|
||||
<Box
|
||||
position="absolute"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<InputDialog
|
||||
title="Import Invitation"
|
||||
prompt="Enter Invitation ID:"
|
||||
placeholder="Paste invitation ID..."
|
||||
onSubmit={handleImportIdSubmit}
|
||||
onCancel={() => setShowIdDialog(false)}
|
||||
isActive={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Multi-step import flow */}
|
||||
{importingId && appService && (
|
||||
<InvitationImportFlow
|
||||
invitationId={importingId}
|
||||
mode="dialog"
|
||||
appService={appService}
|
||||
onClose={handleImportFlowClose}
|
||||
showError={showError}
|
||||
showInfo={showInfo}
|
||||
setStatus={setStatus}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user