Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions apps/frontend/src/components/app/stellar/AssetPairEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
'use client';

import React, { useState } from 'react';
import type { AssetPair, StellarAsset, StellarAssetType } from '@craft/types';

interface AssetPairEditorProps {
pairs: AssetPair[];
onAdd: (pair: AssetPair) => void;
onRemove: (index: number) => void;
error?: string;
}

const EMPTY_ASSET: StellarAsset = { code: '', issuer: '', type: 'credit_alphanum4' };
const EMPTY_PAIR: AssetPair = { base: { ...EMPTY_ASSET }, counter: { ...EMPTY_ASSET } };

function assetLabel(asset: StellarAsset): string {
if (asset.type === 'native') return 'XLM (native)';
return asset.issuer ? `${asset.code}:${asset.issuer.slice(0, 8)}…` : asset.code;
}

function AssetFields({
prefix,
asset,
onChange,
}: {
prefix: string;
asset: StellarAsset;
onChange: (a: StellarAsset) => void;
}) {
const isNative = asset.type === 'native';
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<label htmlFor={`${prefix}-type`} className="text-xs text-on-surface-variant w-12 shrink-0">
Type
</label>
<select
id={`${prefix}-type`}
value={asset.type}
onChange={(e) =>
onChange({
...asset,
type: e.target.value as StellarAssetType,
code: e.target.value === 'native' ? 'XLM' : asset.code,
issuer: e.target.value === 'native' ? '' : asset.issuer,
})
}
className="flex-1 rounded border border-outline-variant/30 px-2 py-1 text-xs bg-surface-container-lowest text-on-surface"
>
<option value="native">Native (XLM)</option>
<option value="credit_alphanum4">Alphanum-4</option>
<option value="credit_alphanum12">Alphanum-12</option>
</select>
</div>
{!isNative && (
<>
<div className="flex gap-2 items-center">
<label htmlFor={`${prefix}-code`} className="text-xs text-on-surface-variant w-12 shrink-0">
Code
</label>
<input
id={`${prefix}-code`}
type="text"
value={asset.code}
onChange={(e) => onChange({ ...asset, code: e.target.value.toUpperCase() })}
placeholder="USDC"
maxLength={12}
className="flex-1 rounded border border-outline-variant/30 px-2 py-1 text-xs bg-surface-container-lowest text-on-surface placeholder:text-on-surface-variant/50"
/>
</div>
<div className="flex gap-2 items-center">
<label htmlFor={`${prefix}-issuer`} className="text-xs text-on-surface-variant w-12 shrink-0">
Issuer
</label>
<input
id={`${prefix}-issuer`}
type="text"
value={asset.issuer}
onChange={(e) => onChange({ ...asset, issuer: e.target.value })}
placeholder="G…"
className="flex-1 rounded border border-outline-variant/30 px-2 py-1 text-xs bg-surface-container-lowest text-on-surface placeholder:text-on-surface-variant/50 font-mono"
/>
</div>
</>
)}
</div>
);
}

export function AssetPairEditor({ pairs, onAdd, onRemove, error }: AssetPairEditorProps) {
const [draft, setDraft] = useState<AssetPair>(EMPTY_PAIR);
const [open, setOpen] = useState(false);

function handleAdd() {
onAdd(draft);
setDraft(EMPTY_PAIR);
setOpen(false);
}

const canAdd =
(draft.base.type === 'native' || (draft.base.code && draft.base.issuer)) &&
(draft.counter.type === 'native' || (draft.counter.code && draft.counter.issuer));

return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-on-surface">Asset Pairs</span>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="text-xs px-2.5 py-1 rounded border border-outline-variant/30 text-on-surface-variant hover:bg-surface-container-low transition-colors"
>
{open ? 'Cancel' : '+ Add pair'}
</button>
</div>

{error && (
<p role="alert" className="text-xs text-error">
{error}
</p>
)}

{pairs.length > 0 && (
<ul className="flex flex-col gap-1.5" aria-label="Asset pairs">
{pairs.map((pair, i) => (
<li
key={i}
className="flex items-center justify-between rounded-lg border border-outline-variant/20 px-3 py-2 bg-surface-container-lowest text-sm"
>
<span className="text-on-surface font-mono text-xs">
{assetLabel(pair.base)} / {assetLabel(pair.counter)}
</span>
<button
type="button"
onClick={() => onRemove(i)}
aria-label={`Remove pair ${assetLabel(pair.base)} / ${assetLabel(pair.counter)}`}
className="text-on-surface-variant hover:text-error transition-colors text-xs ml-2"
>
</button>
</li>
))}
</ul>
)}

{pairs.length === 0 && !open && (
<p className="text-xs text-on-surface-variant">No asset pairs configured.</p>
)}

{open && (
<div className="rounded-lg border border-outline-variant/30 p-4 bg-surface-container-low flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-on-surface-variant uppercase tracking-wide">
Base asset
</span>
<AssetFields
prefix="base"
asset={draft.base}
onChange={(a) => setDraft((d) => ({ ...d, base: a }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-on-surface-variant uppercase tracking-wide">
Counter asset
</span>
<AssetFields
prefix="counter"
asset={draft.counter}
onChange={(a) => setDraft((d) => ({ ...d, counter: a }))}
/>
</div>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleAdd}
disabled={!canAdd}
className="px-3 py-1.5 rounded-lg text-sm font-semibold text-on-primary primary-gradient shadow-sm disabled:opacity-40 disabled:cursor-not-allowed transition-all"
>
Add pair
</button>
</div>
</div>
)}
</div>
);
}
159 changes: 159 additions & 0 deletions apps/frontend/src/components/app/stellar/ContractAddressInputs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
'use client';

import React, { useState } from 'react';
import { validateContractAddress } from '@/lib/stellar/contract-validation';

interface ContractAddressInputsProps {
contracts: Record<string, string>;
onSet: (name: string, address: string) => void;
onRemove: (name: string) => void;
/** Field-level errors keyed by `stellar.contractAddresses.<name>` */
errors?: Map<string, string>;
}

export function ContractAddressInputs({
contracts,
onSet,
onRemove,
errors,
}: ContractAddressInputsProps) {
const [newName, setNewName] = useState('');
const [newAddress, setNewAddress] = useState('');
const [addError, setAddError] = useState<string | null>(null);
const [open, setOpen] = useState(false);

function handleAdd() {
const trimmedName = newName.trim();
const trimmedAddress = newAddress.trim();

if (!trimmedName) {
setAddError('Contract name is required');
return;
}
if (trimmedName in contracts) {
setAddError(`A contract named "${trimmedName}" already exists`);
return;
}
const result = validateContractAddress(trimmedAddress);
if (!result.valid) {
setAddError(result.reason);
return;
}

onSet(trimmedName, trimmedAddress);
setNewName('');
setNewAddress('');
setAddError(null);
setOpen(false);
}

const entries = Object.entries(contracts);

return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-on-surface">Contract Addresses</span>
<button
type="button"
onClick={() => {
setOpen((v) => !v);
setAddError(null);
}}
aria-expanded={open}
className="text-xs px-2.5 py-1 rounded border border-outline-variant/30 text-on-surface-variant hover:bg-surface-container-low transition-colors"
>
{open ? 'Cancel' : '+ Add contract'}
</button>
</div>

{entries.length > 0 && (
<ul className="flex flex-col gap-2" aria-label="Contract addresses">
{entries.map(([name, address]) => {
const fieldError = errors?.get(`stellar.contractAddresses.${name}`);
return (
<li key={name} className="flex flex-col gap-0.5">
<div className="flex items-center justify-between rounded-lg border border-outline-variant/20 px-3 py-2 bg-surface-container-lowest">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs font-semibold text-on-surface">{name}</span>
<span className="text-xs font-mono text-on-surface-variant truncate">
{address}
</span>
</div>
<button
type="button"
onClick={() => onRemove(name)}
aria-label={`Remove contract ${name}`}
className="text-on-surface-variant hover:text-error transition-colors text-xs ml-2 shrink-0"
>
</button>
</div>
{fieldError && (
<p role="alert" className="text-xs text-error pl-1">
{fieldError}
</p>
)}
</li>
);
})}
</ul>
)}

{entries.length === 0 && !open && (
<p className="text-xs text-on-surface-variant">No contract addresses configured.</p>
)}

{open && (
<div className="rounded-lg border border-outline-variant/30 p-4 bg-surface-container-low flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label htmlFor="contract-name" className="text-xs font-medium text-on-surface">
Contract name
</label>
<input
id="contract-name"
type="text"
value={newName}
onChange={(e) => {
setNewName(e.target.value);
setAddError(null);
}}
placeholder="e.g. amm_pool"
className="rounded border border-outline-variant/30 px-3 py-1.5 text-sm bg-surface-container-lowest text-on-surface placeholder:text-on-surface-variant/50"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="contract-address" className="text-xs font-medium text-on-surface">
Contract address
</label>
<input
id="contract-address"
type="text"
value={newAddress}
onChange={(e) => {
setNewAddress(e.target.value);
setAddError(null);
}}
placeholder="C…"
className="rounded border border-outline-variant/30 px-3 py-1.5 text-sm font-mono bg-surface-container-lowest text-on-surface placeholder:text-on-surface-variant/50"
/>
</div>
{addError && (
<p role="alert" className="text-xs text-error">
{addError}
</p>
)}
<div className="flex justify-end">
<button
type="button"
onClick={handleAdd}
disabled={!newName || !newAddress}
className="px-3 py-1.5 rounded-lg text-sm font-semibold text-on-primary primary-gradient shadow-sm disabled:opacity-40 disabled:cursor-not-allowed transition-all"
>
Add contract
</button>
</div>
</div>
)}
</div>
);
}
Loading