diff --git a/src/components/ContactCombobox.stories.tsx b/src/components/ContactCombobox.stories.tsx
new file mode 100644
index 0000000..4a97e9c
--- /dev/null
+++ b/src/components/ContactCombobox.stories.tsx
@@ -0,0 +1,52 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react';
+import { within, userEvent, expect } from '@storybook/test';
+import { ContactCombobox, type ContactOption } from './ContactCombobox';
+
+const SAMPLE_OPTIONS: ContactOption[] = [
+ { address: 'st:xlm:AAAAPAYROLLONE', name: 'Alice (Payroll)' },
+ { address: 'st:xlm:BBBBPAYROLLTWO', name: 'Bob (Contractor)' },
+ { address: 'st:xlm:CCCCVENDORONE', name: 'Acme Vendor' },
+];
+
+function ControlledCombobox(props: Partial[0]>) {
+ const [value, setValue] = useState(props.value ?? '');
+ return (
+
+
+
+ );
+}
+
+const meta = {
+ title: 'Stellar/ContactCombobox',
+ component: ControlledCombobox,
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Empty: Story = {};
+
+export const WithValue: Story = {
+ args: { value: 'st:xlm:AAAAPAYROLLONE' },
+};
+
+export const Invalid: Story = {
+ args: { value: 'not-a-meta-address', invalid: true },
+};
+
+export const SuggestionsOpen: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole('combobox');
+ await userEvent.type(input, 'a');
+ await expect(canvas.getByRole('listbox')).toBeInTheDocument();
+ },
+};
diff --git a/src/components/ContactCombobox.tsx b/src/components/ContactCombobox.tsx
new file mode 100644
index 0000000..23faafa
--- /dev/null
+++ b/src/components/ContactCombobox.tsx
@@ -0,0 +1,175 @@
+import { useState, useRef, useId, useMemo, useEffect } from 'react';
+
+export interface ContactOption {
+ address: string;
+ name: string;
+}
+
+export interface ContactComboboxProps {
+ value: string;
+ onChange: (value: string) => void;
+ onSelectOption?: (option: ContactOption) => void;
+ options: ContactOption[];
+ placeholder?: string;
+ ariaLabel: string;
+ disabled?: boolean;
+ invalid?: boolean;
+ className?: string;
+}
+
+const MAX_SUGGESTIONS = 8;
+
+/**
+ * A text input that suggests matching entries from `options` as the person
+ * types, following the WAI-ARIA "combobox with list autocomplete" pattern:
+ * https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/
+ *
+ * Free text is always allowed — selecting a suggestion is a shortcut, not a
+ * requirement, since recipients won't always be saved contacts.
+ */
+export function ContactCombobox({
+ value,
+ onChange,
+ onSelectOption,
+ options,
+ placeholder,
+ ariaLabel,
+ disabled = false,
+ invalid = false,
+ className = '',
+}: ContactComboboxProps) {
+ const [open, setOpen] = useState(false);
+ const [activeIndex, setActiveIndex] = useState(-1);
+ const containerRef = useRef(null);
+ const baseId = useId();
+ const listboxId = `${baseId}-listbox`;
+
+ const filtered = useMemo(() => {
+ const query = value.trim().toLowerCase();
+ const matches = query
+ ? options.filter(
+ (o) => o.name.toLowerCase().includes(query) || o.address.toLowerCase().includes(query),
+ )
+ : options;
+ return matches.slice(0, MAX_SUGGESTIONS);
+ }, [options, value]);
+
+ const showListbox = open && !disabled && filtered.length > 0;
+
+ useEffect(() => {
+ if (!showListbox) setActiveIndex(-1);
+ }, [showListbox]);
+
+ useEffect(() => {
+ function handleClickOutside(e: MouseEvent) {
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ }
+ if (open) document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [open]);
+
+ const selectOption = (option: ContactOption) => {
+ onChange(option.address);
+ onSelectOption?.(option);
+ setOpen(false);
+ setActiveIndex(-1);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (!open) {
+ setOpen(true);
+ return;
+ }
+ setActiveIndex((i) => (filtered.length === 0 ? -1 : (i + 1) % filtered.length));
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (!open) {
+ setOpen(true);
+ return;
+ }
+ setActiveIndex((i) =>
+ filtered.length === 0 ? -1 : (i - 1 + filtered.length) % filtered.length,
+ );
+ } else if (e.key === 'Enter') {
+ if (showListbox && activeIndex >= 0 && activeIndex < filtered.length) {
+ e.preventDefault();
+ selectOption(filtered[activeIndex]);
+ }
+ } else if (e.key === 'Escape') {
+ if (open) {
+ e.preventDefault();
+ setOpen(false);
+ setActiveIndex(-1);
+ }
+ }
+ };
+
+ const activeOptionId =
+ showListbox && activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
+
+ return (
+
+
{
+ onChange(e.target.value);
+ setOpen(true);
+ }}
+ onFocus={() => setOpen(true)}
+ onKeyDown={handleKeyDown}
+ className={[
+ 'h-9 w-full border bg-surface px-2.5 font-mono text-xs text-primary placeholder:text-outline focus:outline-none',
+ invalid ? 'border-error' : 'border-outline-variant focus:border-primary',
+ disabled ? 'opacity-50' : '',
+ className,
+ ].join(' ')}
+ />
+
+ {showListbox && (
+
+ {filtered.map((option, i) => (
+
{
+ // preventDefault keeps focus on the input so the click doesn't
+ // fire a blur before the selection is applied.
+ e.preventDefault();
+ selectOption(option);
+ }}
+ onMouseEnter={() => setActiveIndex(i)}
+ className={[
+ 'flex flex-col gap-0.5 px-3 py-2 cursor-pointer',
+ i === activeIndex ? 'bg-surface-bright' : '',
+ ].join(' ')}
+ >
+ {option.name}
+ {option.address}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/TemplateImportConflictModal.tsx b/src/components/TemplateImportConflictModal.tsx
new file mode 100644
index 0000000..eb0407b
--- /dev/null
+++ b/src/components/TemplateImportConflictModal.tsx
@@ -0,0 +1,69 @@
+import { type TemplateImportConflict } from '@/store/splitTemplatesStore';
+
+interface TemplateImportConflictModalProps {
+ conflicts: TemplateImportConflict[];
+ onResolve: (action: 'keep-all' | 'overwrite-all') => void;
+ onClose: () => void;
+}
+
+export function TemplateImportConflictModal({
+ conflicts,
+ onResolve,
+ onClose,
+}: TemplateImportConflictModalProps) {
+ return (
+
+
+
+ Import Conflicts
+
+
+ {conflicts.length} template{conflicts.length !== 1 ? 's' : ''} already exist with
+ different content.
+
+
+
+ {conflicts.map((c) => (
+
+
+
+
+ Current
+
+
{c.existingName || '(empty)'}
+
+
+
+ Incoming
+
+
{c.incomingName || '(empty)'}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/lib/stellar/batchSend.test.ts b/src/lib/stellar/batchSend.test.ts
index 098b327..575c910 100644
--- a/src/lib/stellar/batchSend.test.ts
+++ b/src/lib/stellar/batchSend.test.ts
@@ -1,5 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { parseCsvRows, validateRow, validateRows, MAX_BATCH_ROWS } from './batchSend';
+import {
+ parseCsvRows,
+ serializeRowsToCsv,
+ validateRow,
+ validateRows,
+ MAX_BATCH_ROWS,
+} from './batchSend';
import type { BatchRow } from './batchSend';
// ---------------------------------------------------------------------------
@@ -294,6 +300,63 @@ describe('validateRows', () => {
});
});
+// ---------------------------------------------------------------------------
+// serializeRowsToCsv
+// ---------------------------------------------------------------------------
+
+describe('serializeRowsToCsv', () => {
+ it('returns empty string for empty input', () => {
+ expect(serializeRowsToCsv([])).toBe('');
+ });
+
+ it('serializes a two-column row when memo is empty', () => {
+ const csv = serializeRowsToCsv([{ metaAddress: 'st:xlm:AAA', amountRaw: '10', memo: '' }]);
+ expect(csv).toBe('st:xlm:AAA,10');
+ });
+
+ it('serializes a three-column row when memo is present', () => {
+ const csv = serializeRowsToCsv([
+ { metaAddress: 'st:xlm:AAA', amountRaw: '5.5', memo: 'payment-1' },
+ ]);
+ expect(csv).toBe('st:xlm:AAA,5.5,payment-1');
+ });
+
+ it('joins multiple rows with newlines', () => {
+ const csv = serializeRowsToCsv([
+ { metaAddress: 'st:xlm:AAA', amountRaw: '10', memo: '' },
+ { metaAddress: 'st:xlm:BBB', amountRaw: '5.5', memo: 'payment-1' },
+ ]);
+ expect(csv).toBe('st:xlm:AAA,10\nst:xlm:BBB,5.5,payment-1');
+ });
+
+ it('quotes fields containing commas', () => {
+ const csv = serializeRowsToCsv([
+ { metaAddress: 'st:xlm:AAA,extra', amountRaw: '10', memo: '' },
+ ]);
+ expect(csv).toBe('"st:xlm:AAA,extra",10');
+ });
+
+ it('escapes embedded double quotes', () => {
+ const csv = serializeRowsToCsv([{ metaAddress: 'st:xlm:AAA"B', amountRaw: '5', memo: '' }]);
+ expect(csv).toBe('"st:xlm:AAA""B",5');
+ });
+
+ it('round-trips through parseCsvRows', () => {
+ const original = [
+ { metaAddress: 'st:xlm:AAA', amountRaw: '10', memo: '' },
+ { metaAddress: 'st:xlm:BBB', amountRaw: '5.5', memo: 'payment-1' },
+ { metaAddress: 'st:xlm:AAA,extra', amountRaw: '2', memo: 'has "quotes"' },
+ ];
+ const parsed = parseCsvRows(serializeRowsToCsv(original));
+ expect(parsed).toHaveLength(original.length);
+ parsed.forEach((row, i) => {
+ expect(row.metaAddress).toBe(original[i].metaAddress);
+ expect(row.amountRaw).toBe(original[i].amountRaw);
+ expect(row.memo).toBe(original[i].memo);
+ });
+ });
+});
+
// ---------------------------------------------------------------------------
// MAX_BATCH_ROWS constant
// ---------------------------------------------------------------------------
diff --git a/src/lib/stellar/batchSend.ts b/src/lib/stellar/batchSend.ts
index 3231a5d..cf2f8f1 100644
--- a/src/lib/stellar/batchSend.ts
+++ b/src/lib/stellar/batchSend.ts
@@ -152,6 +152,34 @@ function isHeaderRow(cols: string[]): boolean {
);
}
+/** Quote a CSV field if it contains a comma, quote, or newline. */
+function escapeCsvField(value: string): string {
+ if (/[",\n]/.test(value)) {
+ return `"${value.replace(/"/g, '""')}"`;
+ }
+ return value;
+}
+
+/**
+ * Serialize rows back into CSV text — the inverse of {@link parseCsvRows}.
+ *
+ * Used to keep the CSV textarea in sync when rows are edited through a
+ * structured UI (e.g. the per-row recipient combobox) instead of pasted
+ * directly, so the existing validate/submit pipeline keeps working unchanged.
+ */
+export function serializeRowsToCsv(
+ rows: Array>,
+): string {
+ return rows
+ .map((row) => {
+ const cols = row.memo
+ ? [row.metaAddress, row.amountRaw, row.memo]
+ : [row.metaAddress, row.amountRaw];
+ return cols.map(escapeCsvField).join(',');
+ })
+ .join('\n');
+}
+
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
diff --git a/src/main.tsx b/src/main.tsx
index 80a0bf6..792c0d2 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -25,6 +25,7 @@ import { StellarWalletProvider } from '@/context/StellarWalletContext';
import { ThemeProvider, useTheme } from '@/context/ThemeContext';
import { ScanStrategyProvider } from '@/context/ScanStrategyContext';
import { ContactsProvider } from '@/store/contactsStore';
+import { SplitTemplatesProvider } from '@/store/splitTemplatesStore';
import { NameHistoryProvider } from '@/store/nameHistoryStore';
import { wagmiConfig } from '@/config';
import { App } from './App';
@@ -76,9 +77,11 @@ function Providers({ children }: { children: React.ReactNode }) {
-
- {children}
-
+
+
+ {children}
+
+
diff --git a/src/pages/StellarSplit.tsx b/src/pages/StellarSplit.tsx
index 67576af..6ea249d 100644
--- a/src/pages/StellarSplit.tsx
+++ b/src/pages/StellarSplit.tsx
@@ -1,4 +1,4 @@
-import { useState, useCallback, useMemo, useEffect } from 'react';
+import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useStellarWallet } from '@/context/StellarWalletContext';
import { StellarLink } from '@/components/StellarLink';
@@ -6,7 +6,22 @@ import { trackEvent } from '@/lib/telemetry';
import { STELLAR_NETWORK } from '@/config';
import type { StellarAssetKey } from '@/lib/stellar/assets';
import type { BatchRow, BatchSendResult } from '@/lib/stellar/batchSend';
-import { parseCsvRows, validateRows, sendBatch, MAX_BATCH_ROWS } from '@/lib/stellar/batchSend';
+import {
+ parseCsvRows,
+ serializeRowsToCsv,
+ validateRow,
+ validateRows,
+ sendBatch,
+ MAX_BATCH_ROWS,
+} from '@/lib/stellar/batchSend';
+import { useContacts } from '@/store/contactsStore';
+import {
+ useSplitTemplates,
+ type SplitTemplate,
+ type TemplateImportConflict,
+} from '@/store/splitTemplatesStore';
+import { ContactCombobox, type ContactOption } from '@/components/ContactCombobox';
+import { TemplateImportConflictModal } from '@/components/TemplateImportConflictModal';
// ---------------------------------------------------------------------------
// CSV placeholder
@@ -118,12 +133,28 @@ function StatusLabel({ status }: { status: BatchRow['status'] }) {
// Preview table
// ---------------------------------------------------------------------------
+type RowField = 'metaAddress' | 'amountRaw' | 'memo';
+
interface PreviewTableProps {
rows: BatchRow[];
assetKey: StellarAssetKey;
+ /** When set, Meta-Address/Amount/Memo become editable and a Remove column is shown. */
+ editable?: boolean;
+ contactOptions?: ContactOption[];
+ onFieldInput?: (rowIndex: number, field: RowField, value: string) => void;
+ onFieldCommit?: (rowIndex: number, field: RowField) => void;
+ onRemoveRow?: (rowIndex: number) => void;
}
-function PreviewTable({ rows, assetKey }: PreviewTableProps) {
+function PreviewTable({
+ rows,
+ assetKey,
+ editable = false,
+ contactOptions = [],
+ onFieldInput,
+ onFieldCommit,
+ onRemoveRow,
+}: PreviewTableProps) {
if (rows.length === 0) return null;
return (
@@ -143,9 +174,14 @@ function PreviewTable({ rows, assetKey }: PreviewTableProps) {
Amount ({assetKey})
|
-
- Error
+ |
+ {editable ? 'Memo' : 'Error'}
|
+ {editable && (
+
+ Remove
+ |
+ )}
@@ -168,21 +204,75 @@ function PreviewTable({ rows, assetKey }: PreviewTableProps) {
-
-
- {row.metaAddress || empty}
-
- |
-
- {row.amountRaw || —}
- |
- {row.error || null} |
+
+ {editable ? (
+ <>
+
+ onFieldInput?.(row.index, 'metaAddress', value)}
+ onSelectOption={() => onFieldCommit?.(row.index, 'metaAddress')}
+ />
+ |
+
+ onFieldInput?.(row.index, 'amountRaw', e.target.value)}
+ onBlur={() => onFieldCommit?.(row.index, 'amountRaw')}
+ className={[
+ 'h-9 w-full border bg-surface px-2.5 font-mono text-xs text-primary placeholder:text-outline focus:outline-none',
+ row.status === 'invalid'
+ ? 'border-error'
+ : 'border-outline-variant focus:border-primary',
+ ].join(' ')}
+ />
+ |
+
+ onFieldInput?.(row.index, 'memo', e.target.value)}
+ onBlur={() => onFieldCommit?.(row.index, 'memo')}
+ className="h-9 w-full border border-outline-variant bg-surface px-2.5 font-mono text-xs text-primary placeholder:text-outline focus:border-primary focus:outline-none"
+ />
+ |
+
+
+ |
+ >
+ ) : (
+ <>
+
+
+ {row.metaAddress || empty}
+
+ |
+
+ {row.amountRaw || —}
+ |
+ {row.error || null} |
+ >
+ )}
))}
@@ -275,6 +365,40 @@ export default function StellarSplit() {
const [error, setError] = useState('');
const [result, setResult] = useState(null);
+ // Set right before a programmatic setCsvText() so the csvText-changed effect
+ // below (which resets validation on manual textarea edits) skips this one —
+ // the rows already reflect the CSV we just wrote.
+ const skipNextCsvReset = useRef(false);
+
+ // Contacts feeding the per-row recipient combobox
+ const { contacts } = useContacts();
+ const contactOptions: ContactOption[] = useMemo(
+ () =>
+ contacts
+ .filter((c) => c.address.startsWith('st:xlm:'))
+ .map((c) => ({ address: c.address, name: c.name })),
+ [contacts],
+ );
+
+ // Saved batch templates
+ const {
+ templates,
+ saveTemplate,
+ renameTemplate,
+ deleteTemplate,
+ duplicateTemplate,
+ exportTemplates,
+ importTemplates,
+ } = useSplitTemplates();
+ const [showSaveTemplateDialog, setShowSaveTemplateDialog] = useState(false);
+ const [templateNameDraft, setTemplateNameDraft] = useState('');
+ const [renamingTemplateId, setRenamingTemplateId] = useState(null);
+ const [renameDraft, setRenameDraft] = useState('');
+ const [templateMessage, setTemplateMessage] = useState('');
+ const [pendingImportJson, setPendingImportJson] = useState(null);
+ const [importConflicts, setImportConflicts] = useState(null);
+ const templateFileInputRef = useRef(null);
+
// ---------------------------------------------------------------------------
// Derived state
// ---------------------------------------------------------------------------
@@ -368,6 +492,10 @@ export default function StellarSplit() {
// Re-run validation whenever CSV text changes after a first validation
useEffect(() => {
+ if (skipNextCsvReset.current) {
+ skipNextCsvReset.current = false;
+ return;
+ }
if (phase === 'validated' || phase === 'done') {
setPhase('idle');
setRows([]);
@@ -377,6 +505,194 @@ export default function StellarSplit() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [csvText]);
+ // ---------------------------------------------------------------------------
+ // Per-row editing (combobox / amount / memo)
+ // ---------------------------------------------------------------------------
+
+ /** Push an edited `rows` array back into the CSV textarea without triggering a re-validate. */
+ const syncRowsToCsv = useCallback((nextRows: BatchRow[]) => {
+ skipNextCsvReset.current = true;
+ setCsvText(serializeRowsToCsv(nextRows));
+ }, []);
+
+ const handleRowFieldInput = useCallback(
+ (rowIndex: number, field: RowField, value: string) => {
+ const next = rows.map((r) =>
+ r.index === rowIndex ? { ...r, [field]: value, status: 'idle' as const, error: '' } : r,
+ );
+ setRows(next);
+ syncRowsToCsv(next);
+ },
+ [rows, syncRowsToCsv],
+ );
+
+ const handleRowFieldCommit = useCallback(
+ (rowIndex: number) => {
+ const next = rows.map((r) => (r.index === rowIndex ? validateRow(r, ASSET_KEY) : r));
+ setRows(next);
+ syncRowsToCsv(next);
+ },
+ [rows, syncRowsToCsv],
+ );
+
+ const handleAddRow = useCallback(() => {
+ setResult(null);
+ setError('');
+ const nextIndex = rows.length ? Math.max(...rows.map((r) => r.index)) + 1 : 1;
+ const blank = validateRow(
+ { index: nextIndex, metaAddress: '', amountRaw: '', memo: '', error: '', status: 'idle' },
+ ASSET_KEY,
+ );
+ const next = [...rows, blank];
+ setRows(next);
+ syncRowsToCsv(next);
+ setPhase('validated');
+ }, [rows, syncRowsToCsv]);
+
+ const handleRemoveRow = useCallback(
+ (rowIndex: number) => {
+ const next = rows.filter((r) => r.index !== rowIndex);
+ setRows(next);
+ syncRowsToCsv(next);
+ },
+ [rows, syncRowsToCsv],
+ );
+
+ // ---------------------------------------------------------------------------
+ // Templates
+ // ---------------------------------------------------------------------------
+
+ const templateRows = useMemo(
+ () => rows.map((r) => ({ metaAddress: r.metaAddress, amountRaw: r.amountRaw, memo: r.memo })),
+ [rows],
+ );
+
+ const handleSaveTemplate = useCallback(() => {
+ if (!templateNameDraft.trim() || templateRows.length === 0) return;
+ saveTemplate(templateNameDraft, templateRows);
+ setTemplateNameDraft('');
+ setShowSaveTemplateDialog(false);
+ setTemplateMessage('Template saved.');
+ }, [templateNameDraft, templateRows, saveTemplate]);
+
+ const handleLoadTemplate = useCallback(
+ (template: SplitTemplate) => {
+ const loaded = validateRows(
+ template.rows.map((r, i) => ({
+ index: i + 1,
+ metaAddress: r.metaAddress,
+ amountRaw: r.amountRaw,
+ memo: r.memo ?? '',
+ error: '',
+ status: 'idle' as const,
+ })),
+ ASSET_KEY,
+ );
+ setResult(null);
+ setError('');
+ setRows(loaded);
+ syncRowsToCsv(loaded);
+ setPhase('validated');
+ setTemplateMessage(`Loaded "${template.name}".`);
+ trackEvent('batch_template_loaded');
+ },
+ [syncRowsToCsv],
+ );
+
+ const handleStartRenameTemplate = useCallback((template: SplitTemplate) => {
+ setRenamingTemplateId(template.id);
+ setRenameDraft(template.name);
+ }, []);
+
+ const handleConfirmRenameTemplate = useCallback(() => {
+ if (!renamingTemplateId || !renameDraft.trim()) return;
+ renameTemplate(renamingTemplateId, renameDraft);
+ setRenamingTemplateId(null);
+ setRenameDraft('');
+ }, [renamingTemplateId, renameDraft, renameTemplate]);
+
+ const handleDuplicateTemplate = useCallback(
+ (template: SplitTemplate) => {
+ duplicateTemplate(template.id);
+ setTemplateMessage(`Duplicated "${template.name}".`);
+ },
+ [duplicateTemplate],
+ );
+
+ const handleDeleteTemplate = useCallback(
+ (template: SplitTemplate) => {
+ deleteTemplate(template.id);
+ setTemplateMessage(`Deleted "${template.name}".`);
+ },
+ [deleteTemplate],
+ );
+
+ const handleExportTemplates = useCallback(() => {
+ const json = exportTemplates();
+ const blob = new Blob([json], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `wraith-split-templates-${new Date().toISOString().slice(0, 10)}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }, [exportTemplates]);
+
+ const runImport = useCallback(
+ (json: string, overwriteConflicts: boolean) => {
+ try {
+ const result = importTemplates(json, overwriteConflicts);
+ if (result.conflicts.length > 0) {
+ setPendingImportJson(json);
+ setImportConflicts(result.conflicts);
+ return;
+ }
+ setPendingImportJson(null);
+ setImportConflicts(null);
+ setTemplateMessage(
+ result.imported > 0
+ ? `Imported ${result.imported} template${result.imported !== 1 ? 's' : ''}.`
+ : 'Templates already up to date.',
+ );
+ } catch (err) {
+ setTemplateMessage(err instanceof Error ? err.message : 'Failed to import templates.');
+ }
+ },
+ [importTemplates],
+ );
+
+ const handleImportFileSelected = useCallback(
+ (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ e.target.value = '';
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = (ev) => {
+ const text = ev.target?.result;
+ if (typeof text === 'string') runImport(text, false);
+ };
+ reader.readAsText(file);
+ },
+ [runImport],
+ );
+
+ const handleResolveImportConflicts = useCallback(
+ (action: 'keep-all' | 'overwrite-all') => {
+ if (pendingImportJson) {
+ if (action === 'overwrite-all') {
+ runImport(pendingImportJson, true);
+ } else {
+ // Non-conflicting templates from this import were already saved on
+ // the first pass — only the conflicting ones are left as-is.
+ setTemplateMessage('Kept your existing templates.');
+ }
+ }
+ setImportConflicts(null);
+ setPendingImportJson(null);
+ },
+ [pendingImportJson, runImport],
+ );
+
// ---------------------------------------------------------------------------
// Not connected
// ---------------------------------------------------------------------------
@@ -479,6 +795,133 @@ export default function StellarSplit() {
+ {/* Templates */}
+
+
+
+ Templates
+
+
+
+
+
+
+
+
+ {templates.length === 0 ? (
+
+ No saved templates yet. Validate a batch below, then save it as a template to reuse next
+ time.
+
+ ) : (
+
+ {templates.map((template) => (
+ -
+ {renamingTemplateId === template.id ? (
+
+
+ setRenameDraft(e.target.value)}
+ autoFocus
+ className="h-8 flex-1 min-w-0 border border-outline-variant bg-surface px-2 font-mono text-xs text-primary focus:border-primary focus:outline-none"
+ />
+
+
+
+ ) : (
+ <>
+
+
+ {template.name}
+
+
+ {template.rows.length} recipient{template.rows.length !== 1 ? 's' : ''}
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ ))}
+
+ )}
+
+ {templateMessage && (
+
+ {templateMessage}
+
+ )}
+
+
{/* CSV input */}
@@ -516,14 +959,23 @@ export default function StellarSplit() {
{/* Validate button */}
{phase === 'idle' && (
-
+
+
+
+
)}
{/* Validation error */}
@@ -537,7 +989,71 @@ export default function StellarSplit() {
{hasRows && (
-
+
+ {phase === 'validated' && (
+
+
+ ·
+
+
+ )}
+
+ {showSaveTemplateDialog && (
+
+
+
setTemplateNameDraft(e.target.value)}
+ placeholder="e.g. Monthly payroll"
+ className="h-10 w-full border border-outline-variant bg-surface px-3 font-mono text-sm text-primary placeholder:text-outline focus:border-primary focus:outline-none"
+ autoFocus
+ />
+
+
+
+
+
+ )}
)}
@@ -617,6 +1133,17 @@ export default function StellarSplit() {
)}
+
+ {importConflicts && importConflicts.length > 0 && (
+ {
+ setImportConflicts(null);
+ setPendingImportJson(null);
+ }}
+ />
+ )}
);
}
diff --git a/src/store/splitTemplatesStore.test.ts b/src/store/splitTemplatesStore.test.ts
new file mode 100644
index 0000000..bae3ea6
--- /dev/null
+++ b/src/store/splitTemplatesStore.test.ts
@@ -0,0 +1,164 @@
+import { describe, it, expect } from 'vitest';
+import {
+ resolveTemplateImport,
+ templatesEqual,
+ isValidTemplate,
+ type SplitTemplate,
+} from './splitTemplatesStore';
+
+function makeTemplate(overrides: Partial = {}): SplitTemplate {
+ return {
+ id: 'tpl_1',
+ name: 'Payroll',
+ rows: [
+ { metaAddress: 'st:xlm:AAA', amountRaw: '10', memo: '' },
+ { metaAddress: 'st:xlm:BBB', amountRaw: '5.5', memo: 'payment-1' },
+ ],
+ createdAt: 1,
+ updatedAt: 1,
+ ...overrides,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// isValidTemplate
+// ---------------------------------------------------------------------------
+
+describe('isValidTemplate', () => {
+ it('accepts a well-formed template', () => {
+ expect(isValidTemplate(makeTemplate())).toBe(true);
+ });
+
+ it('accepts a template with rows that omit memo', () => {
+ expect(
+ isValidTemplate(makeTemplate({ rows: [{ metaAddress: 'st:xlm:AAA', amountRaw: '10' }] })),
+ ).toBe(true);
+ });
+
+ it.each([
+ ['null', null],
+ ['not an object', 'nope'],
+ ['missing id', { name: 'x', rows: [], createdAt: 1, updatedAt: 1 }],
+ ['missing rows', { id: '1', name: 'x', createdAt: 1, updatedAt: 1 }],
+ ['non-array rows', { id: '1', name: 'x', rows: 'nope', createdAt: 1, updatedAt: 1 }],
+ [
+ 'row missing amountRaw',
+ { id: '1', name: 'x', rows: [{ metaAddress: 'a' }], createdAt: 1, updatedAt: 1 },
+ ],
+ ])('rejects %s', (_label, value) => {
+ expect(isValidTemplate(value)).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// templatesEqual
+// ---------------------------------------------------------------------------
+
+describe('templatesEqual', () => {
+ it('treats identical templates as equal', () => {
+ expect(templatesEqual(makeTemplate(), makeTemplate())).toBe(true);
+ });
+
+ it('treats an empty memo and an omitted memo as equal', () => {
+ const a = makeTemplate({ rows: [{ metaAddress: 'st:xlm:AAA', amountRaw: '10', memo: '' }] });
+ const b = makeTemplate({ rows: [{ metaAddress: 'st:xlm:AAA', amountRaw: '10' }] });
+ expect(templatesEqual(a, b)).toBe(true);
+ });
+
+ it('detects a name difference', () => {
+ expect(templatesEqual(makeTemplate(), makeTemplate({ name: 'Vendors' }))).toBe(false);
+ });
+
+ it('detects a row difference', () => {
+ const b = makeTemplate({ rows: [{ metaAddress: 'st:xlm:AAA', amountRaw: '999', memo: '' }] });
+ expect(templatesEqual(makeTemplate(), b)).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// resolveTemplateImport — export/import round trip (issue #155 acceptance)
+// ---------------------------------------------------------------------------
+
+describe('resolveTemplateImport', () => {
+ it('round-trips a bare-array export back through import with no duplicates or conflicts', () => {
+ const existing = [makeTemplate({ id: 'a' }), makeTemplate({ id: 'b', name: 'Vendors' })];
+ const exported = JSON.stringify(existing);
+
+ const result = resolveTemplateImport(existing, exported);
+
+ expect(result.conflicts).toEqual([]);
+ expect(result.imported).toBe(0);
+ expect(result.skipped).toBe(2);
+ expect(result.next).toHaveLength(2);
+ });
+
+ it('round-trips the enveloped export format ({ type, version, templates })', () => {
+ const existing = [makeTemplate({ id: 'a' })];
+ const exported = JSON.stringify({
+ type: 'wraith-split-templates',
+ version: 1,
+ templates: existing,
+ });
+
+ const result = resolveTemplateImport(existing, exported);
+
+ expect(result.imported).toBe(0);
+ expect(result.skipped).toBe(1);
+ expect(result.next).toHaveLength(1);
+ });
+
+ it('imports templates that do not exist locally yet', () => {
+ const remote = JSON.stringify([makeTemplate({ id: 'remote-1', name: 'From teammate' })]);
+
+ const result = resolveTemplateImport([], remote);
+
+ expect(result.imported).toBe(1);
+ expect(result.skipped).toBe(0);
+ expect(result.conflicts).toEqual([]);
+ expect(result.next[0].name).toBe('From teammate');
+ });
+
+ it('reports a conflict when the same id exists locally with different content', () => {
+ const local = [makeTemplate({ id: 'a', name: 'Payroll' })];
+ const remote = JSON.stringify([makeTemplate({ id: 'a', name: 'Payroll (edited elsewhere)' })]);
+
+ const result = resolveTemplateImport(local, remote);
+
+ expect(result.imported).toBe(0);
+ expect(result.conflicts).toHaveLength(1);
+ expect(result.conflicts[0]).toEqual({
+ id: 'a',
+ existingName: 'Payroll',
+ incomingName: 'Payroll (edited elsewhere)',
+ });
+ // Existing template is left untouched until the caller resolves the conflict.
+ expect(result.next[0].name).toBe('Payroll');
+ });
+
+ it('overwrites conflicting templates when overwriteConflicts is true', () => {
+ const local = [makeTemplate({ id: 'a', name: 'Payroll' })];
+ const remote = JSON.stringify([makeTemplate({ id: 'a', name: 'Payroll (edited elsewhere)' })]);
+
+ const result = resolveTemplateImport(local, remote, true);
+
+ expect(result.imported).toBe(1);
+ expect(result.conflicts).toEqual([]);
+ expect(result.next[0].name).toBe('Payroll (edited elsewhere)');
+ });
+
+ it('skips malformed entries instead of throwing', () => {
+ const remote = JSON.stringify([{ not: 'a template' }, null, 42]);
+ const result = resolveTemplateImport([], remote);
+ expect(result.imported).toBe(0);
+ expect(result.skipped).toBe(3);
+ expect(result.next).toEqual([]);
+ });
+
+ it('throws a clear error for invalid JSON', () => {
+ expect(() => resolveTemplateImport([], 'not json')).toThrow(/valid JSON/i);
+ });
+
+ it('throws a clear error when the top level is neither an array nor an envelope', () => {
+ expect(() => resolveTemplateImport([], '{}')).toThrow(/array/i);
+ });
+});
diff --git a/src/store/splitTemplatesStore.tsx b/src/store/splitTemplatesStore.tsx
new file mode 100644
index 0000000..1361b1d
--- /dev/null
+++ b/src/store/splitTemplatesStore.tsx
@@ -0,0 +1,259 @@
+import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
+
+export interface TemplateRow {
+ metaAddress: string;
+ amountRaw: string;
+ memo?: string;
+}
+
+export interface SplitTemplate {
+ id: string;
+ name: string;
+ rows: TemplateRow[];
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface TemplateImportConflict {
+ id: string;
+ existingName: string;
+ incomingName: string;
+}
+
+export interface TemplateImportResult {
+ imported: number;
+ skipped: number;
+ conflicts: TemplateImportConflict[];
+}
+
+interface SplitTemplatesContextValue {
+ templates: SplitTemplate[];
+ saveTemplate: (name: string, rows: TemplateRow[]) => SplitTemplate;
+ renameTemplate: (id: string, name: string) => void;
+ deleteTemplate: (id: string) => void;
+ duplicateTemplate: (id: string) => void;
+ getTemplate: (id: string) => SplitTemplate | undefined;
+ exportTemplates: () => string;
+ importTemplates: (json: string, overwriteConflicts?: boolean) => TemplateImportResult;
+}
+
+const SplitTemplatesContext = createContext(null);
+const STORAGE_KEY = 'wraith-split-templates';
+
+function generateId() {
+ return `tpl_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
+}
+
+// ---------------------------------------------------------------------------
+// Pure import resolution (unit-testable without rendering React)
+// ---------------------------------------------------------------------------
+// Split out from the hook below so the export -> import round trip (and
+// conflict handling) can be tested directly, matching how the rest of the
+// codebase tests pure logic (see batchSend.ts) separately from the
+// React-context wiring around it.
+
+function isValidTemplateRow(value: unknown): value is TemplateRow {
+ if (typeof value !== 'object' || value === null) return false;
+ const row = value as Record;
+ return (
+ typeof row.metaAddress === 'string' &&
+ typeof row.amountRaw === 'string' &&
+ (row.memo === undefined || typeof row.memo === 'string')
+ );
+}
+
+export function isValidTemplate(value: unknown): value is SplitTemplate {
+ if (typeof value !== 'object' || value === null) return false;
+ const t = value as Record;
+ return (
+ typeof t.id === 'string' &&
+ t.id.length > 0 &&
+ typeof t.name === 'string' &&
+ Array.isArray(t.rows) &&
+ t.rows.every(isValidTemplateRow) &&
+ typeof t.createdAt === 'number' &&
+ typeof t.updatedAt === 'number'
+ );
+}
+
+export function templatesEqual(a: SplitTemplate, b: SplitTemplate): boolean {
+ return (
+ a.name === b.name &&
+ a.rows.length === b.rows.length &&
+ a.rows.every(
+ (row, i) =>
+ row.metaAddress === b.rows[i].metaAddress &&
+ row.amountRaw === b.rows[i].amountRaw &&
+ (row.memo ?? '') === (b.rows[i].memo ?? ''),
+ )
+ );
+}
+
+/**
+ * Resolve an import against the current template list.
+ *
+ * Matching is by `id`, so exporting and immediately re-importing the same
+ * file is a no-op (entries come back `skipped`, not duplicated). Templates
+ * that don't exist locally yet are added directly. Templates that share an
+ * id with a local template but differ are reported as `conflicts` unless
+ * `overwriteConflicts` is set, in which case the incoming version wins.
+ *
+ * Accepts either a bare array export or the enveloped
+ * `{ type, version, templates }` shape produced by `exportTemplates`.
+ */
+export function resolveTemplateImport(
+ existing: SplitTemplate[],
+ json: string,
+ overwriteConflicts: boolean = false,
+): { next: SplitTemplate[] } & TemplateImportResult {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(json);
+ } catch {
+ throw new Error('That file is not valid JSON.');
+ }
+
+ const incoming = Array.isArray(parsed) ? parsed : (parsed as { templates?: unknown })?.templates;
+ if (!Array.isArray(incoming)) {
+ throw new Error('Expected a JSON array of templates.');
+ }
+
+ const next = [...existing];
+ const conflicts: TemplateImportConflict[] = [];
+ let imported = 0;
+ let skipped = 0;
+
+ for (const raw of incoming) {
+ if (!isValidTemplate(raw)) {
+ skipped++;
+ continue;
+ }
+
+ const currentIndex = next.findIndex((t) => t.id === raw.id);
+ if (currentIndex === -1) {
+ next.push(raw);
+ imported++;
+ continue;
+ }
+
+ if (templatesEqual(next[currentIndex], raw)) {
+ skipped++;
+ continue;
+ }
+
+ if (overwriteConflicts) {
+ next[currentIndex] = raw;
+ imported++;
+ } else {
+ conflicts.push({ id: raw.id, existingName: next[currentIndex].name, incomingName: raw.name });
+ }
+ }
+
+ return { next, imported, skipped, conflicts };
+}
+
+// ---------------------------------------------------------------------------
+// React context
+// ---------------------------------------------------------------------------
+
+export function SplitTemplatesProvider({ children }: { children: ReactNode }) {
+ const [templates, setTemplates] = useState([]);
+
+ // Load templates from localStorage on mount
+ useEffect(() => {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ setTemplates(JSON.parse(stored));
+ }
+ } catch {
+ // Ignore parse errors
+ }
+ }, []);
+
+ // Save templates to localStorage when they change
+ useEffect(() => {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(templates));
+ }, [templates]);
+
+ const saveTemplate = (name: string, rows: TemplateRow[]) => {
+ const now = Date.now();
+ const newTemplate: SplitTemplate = {
+ id: generateId(),
+ name,
+ rows,
+ createdAt: now,
+ updatedAt: now,
+ };
+ setTemplates((prev) => [...prev, newTemplate]);
+ return newTemplate;
+ };
+
+ const renameTemplate = (id: string, name: string) => {
+ setTemplates((prev) =>
+ prev.map((t) => (t.id === id ? { ...t, name, updatedAt: Date.now() } : t)),
+ );
+ };
+
+ const deleteTemplate = (id: string) => {
+ setTemplates((prev) => prev.filter((t) => t.id !== id));
+ };
+
+ const duplicateTemplate = (id: string) => {
+ setTemplates((prev) => {
+ const original = prev.find((t) => t.id === id);
+ if (!original) return prev;
+ const now = Date.now();
+ const copy: SplitTemplate = {
+ ...original,
+ id: generateId(),
+ name: `${original.name} (copy)`,
+ createdAt: now,
+ updatedAt: now,
+ };
+ return [...prev, copy];
+ });
+ };
+
+ const getTemplate = (id: string) => templates.find((t) => t.id === id);
+
+ // Same envelope shape as the address-book export so files round-trip
+ // through either tool. Update the `type` field to match theirs exactly
+ // once you paste that code in.
+ const exportTemplates = () =>
+ JSON.stringify({ type: 'wraith-split-templates', version: 1, templates }, null, 2);
+
+ const importTemplates = (
+ json: string,
+ overwriteConflicts: boolean = false,
+ ): TemplateImportResult => {
+ // Throws on invalid JSON / shape (see resolveTemplateImport) so the
+ // caller can show a real error instead of a silent no-op.
+ const result = resolveTemplateImport(templates, json, overwriteConflicts);
+ setTemplates(result.next);
+ return { imported: result.imported, skipped: result.skipped, conflicts: result.conflicts };
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useSplitTemplates() {
+ const ctx = useContext(SplitTemplatesContext);
+ if (!ctx) throw new Error('useSplitTemplates must be used within SplitTemplatesProvider');
+ return ctx;
+}