fix: register BlenderGen and TransactionBuilder in automerge store schema
BlenderGen and TransactionBuilder were added to Board.tsx's customShapeUtils but never registered in useAutomergeStoreV2.ts's CUSTOM_SHAPE_TYPES, imports, or shapeUtils array. This schema mismatch prevented tldraw's Editor from rendering any content - the store didn't know about these shape types even though the Tldraw component was told to use them. Also adds TransactionBuilder shape for Safe multisig transaction building and reduces verbose error logging in automerge sync. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
43007c07f8
commit
01fb250d29
|
|
@ -1,6 +1,23 @@
|
|||
import { TLRecord, RecordId, TLStore, IndexKey } from "@tldraw/tldraw"
|
||||
import * as Automerge from "@automerge/automerge"
|
||||
|
||||
// Track invalid index warnings to avoid console spam - log summary instead of per-shape
|
||||
let _invalidIndexCount = 0
|
||||
let _invalidIndexLogTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function logInvalidIndexThrottled(index: string, shapeId: string) {
|
||||
_invalidIndexCount++
|
||||
// Log a summary every 2 seconds instead of per-shape
|
||||
if (!_invalidIndexLogTimer) {
|
||||
_invalidIndexLogTimer = setTimeout(() => {
|
||||
if (_invalidIndexCount > 0) {
|
||||
console.warn(`Invalid index reset to 'a1' for ${_invalidIndexCount} shape(s) (e.g. "${index}" on ${shapeId})`)
|
||||
_invalidIndexCount = 0
|
||||
}
|
||||
_invalidIndexLogTimer = null
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to validate if a string is a valid tldraw IndexKey
|
||||
// tldraw uses fractional indexing based on https://observablehq.com/@dgreensp/implementing-fractional-indexing
|
||||
// The first letter encodes integer part length: a=1 digit, b=2 digits, c=3 digits, etc.
|
||||
|
|
@ -434,7 +451,6 @@ export function applyAutomergePatchesToTLStore(
|
|||
// Skip records with missing required fields
|
||||
return
|
||||
}
|
||||
console.error("Failed to sanitize record:", error, record)
|
||||
failedRecords.push(record)
|
||||
}
|
||||
})
|
||||
|
|
@ -443,7 +459,7 @@ export function applyAutomergePatchesToTLStore(
|
|||
// Log patch application for debugging
|
||||
|
||||
if (failedRecords.length > 0) {
|
||||
console.error("Failed to sanitize records:", failedRecords)
|
||||
console.warn(`Failed to sanitize ${failedRecords.length} record(s)`)
|
||||
}
|
||||
|
||||
// CRITICAL: Final safety check - ensure no geo shapes have w/h/geo at top level
|
||||
|
|
@ -583,11 +599,9 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
// DO NOT overwrite valid coordinates (including 0, which is a valid position)
|
||||
// Only set to 0 if the value is undefined, null, or NaN
|
||||
if (sanitized.x === undefined || sanitized.x === null || (typeof sanitized.x === 'number' && isNaN(sanitized.x))) {
|
||||
console.warn(`⚠️ Shape ${sanitized.id} (${sanitized.type}) has invalid x coordinate, defaulting to 0. Original value:`, sanitized.x)
|
||||
sanitized.x = 0
|
||||
}
|
||||
if (sanitized.y === undefined || sanitized.y === null || (typeof sanitized.y === 'number' && isNaN(sanitized.y))) {
|
||||
console.warn(`⚠️ Shape ${sanitized.id} (${sanitized.type}) has invalid y coordinate, defaulting to 0. Original value:`, sanitized.y)
|
||||
sanitized.y = 0
|
||||
}
|
||||
if (typeof sanitized.rotation !== 'number') sanitized.rotation = 0
|
||||
|
|
@ -605,7 +619,7 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
// Examples: "a1", "a2", "a10", "a1V", "a24sT", "a1V4rr" (fractional between a1 and a2)
|
||||
// Invalid: "c1", "b1", "z999" (old format - not valid fractional indices)
|
||||
if (!isValidIndexKey(sanitized.index)) {
|
||||
console.warn(`⚠️ Invalid index "${sanitized.index}" for shape ${sanitized.id}, resetting to 'a1'`)
|
||||
logInvalidIndexThrottled(sanitized.index, sanitized.id)
|
||||
sanitized.index = 'a1' as IndexKey
|
||||
}
|
||||
if (!sanitized.parentId) sanitized.parentId = 'page:page'
|
||||
|
|
@ -619,7 +633,7 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
} catch (e) {
|
||||
// If JSON serialization fails (e.g., due to functions or circular references),
|
||||
// create a shallow copy and recursively clean it
|
||||
console.warn(`⚠️ Could not deep copy props for shape ${sanitized.id}, using shallow copy:`, e)
|
||||
// Deep copy failed, using shallow copy
|
||||
const propsCopy: any = {}
|
||||
for (const key in sanitized.props) {
|
||||
try {
|
||||
|
|
@ -1183,7 +1197,7 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
}]
|
||||
}]
|
||||
}
|
||||
console.log(`🔧 AutomergeToTLStore: Converted props.text to richText for text shape ${sanitized.id}`)
|
||||
// Converted props.text to richText for text shape
|
||||
}
|
||||
// Preserve original text in meta for backward compatibility
|
||||
if (!sanitized.meta) sanitized.meta = {}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ function minimalSanitizeRecord(record: any): any {
|
|||
}
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`⚠️ MinimalSanitization: Invalid index format "${sanitized.index}" for shape ${sanitized.id}`)
|
||||
sanitized.index = 'a1'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ const CUSTOM_SHAPE_TYPES = [
|
|||
'PrivateWorkspace', // Private workspace for Google Export
|
||||
'GoogleItem', // Individual Google items
|
||||
'WorkflowBlock', // Workflow builder blocks
|
||||
'BlenderGen', // Blender 3D procedural generation
|
||||
'TransactionBuilder', // Safe multisig transaction builder
|
||||
]
|
||||
|
||||
// Combined set of all known shape types for validation
|
||||
|
|
@ -182,6 +184,8 @@ import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil"
|
|||
import { PrivateWorkspaceShape } from "@/shapes/PrivateWorkspaceShapeUtil"
|
||||
import { GoogleItemShape } from "@/shapes/GoogleItemShapeUtil"
|
||||
import { WorkflowBlockShape } from "@/shapes/WorkflowBlockShapeUtil"
|
||||
import { BlenderGenShape } from "@/shapes/BlenderGenShapeUtil"
|
||||
import { TransactionBuilderShape } from "@/shapes/TransactionBuilderShapeUtil"
|
||||
|
||||
export function useAutomergeStoreV2({
|
||||
handle,
|
||||
|
|
@ -226,6 +230,8 @@ export function useAutomergeStoreV2({
|
|||
PrivateWorkspaceShape, // Private workspace for Google Export
|
||||
GoogleItemShape, // Individual Google items
|
||||
WorkflowBlockShape, // Workflow builder blocks
|
||||
BlenderGenShape, // Blender 3D procedural generation
|
||||
TransactionBuilderShape, // Safe multisig transaction builder
|
||||
]
|
||||
|
||||
// Use the module-level CUSTOM_SHAPE_TYPES constant
|
||||
|
|
@ -358,64 +364,31 @@ export function useAutomergeStoreV2({
|
|||
const automergeDoc = handle.doc()
|
||||
applyAutomergePatchesToTLStore(payload.patches, store, automergeDoc)
|
||||
} catch (patchError) {
|
||||
console.error("Error applying patches batch, attempting individual patch application:", patchError)
|
||||
// Try applying patches one by one to identify problematic ones
|
||||
// This is a fallback - ideally we should fix the data at the source
|
||||
// Batch application failed - try individual patches silently, only log summary
|
||||
let successCount = 0
|
||||
let failedPatches: any[] = []
|
||||
// CRITICAL: Pass Automerge document to patch handler so it can read full records
|
||||
let failCount = 0
|
||||
const errorTypes: Record<string, number> = {}
|
||||
const automergeDoc = handle.doc()
|
||||
for (const patch of payload.patches) {
|
||||
try {
|
||||
applyAutomergePatchesToTLStore([patch], store, automergeDoc)
|
||||
successCount++
|
||||
} catch (individualPatchError) {
|
||||
failedPatches.push({ patch, error: individualPatchError })
|
||||
console.error(`Failed to apply individual patch:`, individualPatchError)
|
||||
|
||||
// Log the problematic patch for debugging
|
||||
const recordId = patch.path[1] as string
|
||||
console.error("Problematic patch details:", {
|
||||
action: patch.action,
|
||||
path: patch.path,
|
||||
recordId: recordId,
|
||||
value: 'value' in patch ? patch.value : undefined,
|
||||
errorMessage: individualPatchError instanceof Error ? individualPatchError.message : String(individualPatchError)
|
||||
})
|
||||
|
||||
// Try to get more context about the failing record
|
||||
try {
|
||||
const existingRecord = store.get(recordId as any)
|
||||
console.error("Existing record that failed:", existingRecord)
|
||||
|
||||
// If it's a geo shape missing props.geo, try to fix it
|
||||
if (existingRecord && (existingRecord as any).typeName === 'shape' && (existingRecord as any).type === 'geo') {
|
||||
const geoRecord = existingRecord as any
|
||||
if (!geoRecord.props || !geoRecord.props.geo) {
|
||||
// This won't help with the current patch, but might help future patches
|
||||
// The real fix should happen in AutomergeToTLStore sanitization
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Could not retrieve existing record:", e)
|
||||
}
|
||||
failCount++
|
||||
// Categorize errors for summary
|
||||
const msg = individualPatchError instanceof Error ? individualPatchError.message : String(individualPatchError)
|
||||
const category = msg.includes('props.geo') ? 'missing props.geo' :
|
||||
msg.includes('index') ? 'invalid index' :
|
||||
msg.includes('typeName') ? 'missing typeName' :
|
||||
'other'
|
||||
errorTypes[category] = (errorTypes[category] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Log summary
|
||||
if (failedPatches.length > 0) {
|
||||
console.error(`❌ Failed to apply ${failedPatches.length} out of ${payload.patches.length} patches`)
|
||||
// Most common issue: geo shapes missing props.geo - this should be fixed in sanitization
|
||||
const geoShapeErrors = failedPatches.filter(p =>
|
||||
p.error instanceof Error && p.error.message.includes('props.geo')
|
||||
)
|
||||
if (geoShapeErrors.length > 0) {
|
||||
console.error(`⚠️ ${geoShapeErrors.length} failures due to missing props.geo - this should be fixed in AutomergeToTLStore sanitization`)
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount < payload.patches.length || payload.patches.length > 5) {
|
||||
// Partial patches applied
|
||||
|
||||
// Log a single summary line instead of per-patch errors
|
||||
if (failCount > 0) {
|
||||
const errorSummary = Object.entries(errorTypes).map(([k, v]) => `${k}: ${v}`).join(', ')
|
||||
console.warn(`Patch sync: ${successCount}/${payload.patches.length} applied, ${failCount} failed (${errorSummary})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -492,11 +465,8 @@ export function useAutomergeStoreV2({
|
|||
return true
|
||||
})
|
||||
|
||||
// Log errors for any unknown shape types that were filtered out
|
||||
if (unknownShapeTypes.length > 0) {
|
||||
console.error(`❌ Unknown shape types filtered out (shapes not loaded):`, unknownShapeTypes)
|
||||
console.error(` These shapes exist in the document but are not registered in KNOWN_SHAPE_TYPES.`)
|
||||
console.error(` To fix: Add these types to CUSTOM_SHAPE_TYPES in useAutomergeStoreV2.ts`)
|
||||
console.warn(`Unknown shape types filtered out: ${unknownShapeTypes.join(', ')}`)
|
||||
}
|
||||
|
||||
if (filteredRecords.length > 0) {
|
||||
|
|
@ -1242,9 +1212,7 @@ export function useAutomergeStoreV2({
|
|||
} else {
|
||||
// Patches didn't come through - this should be rare if handler is set up before data load
|
||||
// Log a warning but don't show disruptive confirmation dialog
|
||||
console.warn(`⚠️ No patches received after ${maxAttempts} attempts for room initialization.`)
|
||||
console.warn(`⚠️ This may happen if Automerge doc was initialized with server data before handler was ready.`)
|
||||
console.warn(`⚠️ Store will remain empty - patches should handle data loading in normal operation.`)
|
||||
console.warn(`No patches received after ${maxAttempts} attempts - store may be empty`)
|
||||
|
||||
// Simplified fallback: Just log and continue with empty store
|
||||
// Patches should handle data loading, so if they don't come through,
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
const applyJsonSyncData = useCallback((data: TLStoreSnapshot & { deleted?: string[] }) => {
|
||||
const currentHandle = handleRef.current
|
||||
if (!currentHandle || (!data?.store && !data?.deleted)) {
|
||||
console.warn('⚠️ Cannot apply JSON sync - no handle or data')
|
||||
// No handle or data available for JSON sync - expected during initialization
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
import { useState } from "react"
|
||||
import {
|
||||
usePendingTransactions,
|
||||
useConfirmTransaction,
|
||||
useExecuteTransaction,
|
||||
type SafeTransaction,
|
||||
} from "../../hooks/useSafeTransaction"
|
||||
import { formatAddress } from "../../hooks/useWallet"
|
||||
|
||||
function TxCard({ tx, onRefresh }: { tx: SafeTransaction; onRefresh: () => void }) {
|
||||
const { confirm, isLoading: confirming } = useConfirmTransaction()
|
||||
const { execute, isLoading: executing } = useExecuteTransaction()
|
||||
const [signerKey, setSignerKey] = useState("")
|
||||
const [showActions, setShowActions] = useState(false)
|
||||
|
||||
const thresholdMet = tx.confirmations.length >= tx.confirmationsRequired
|
||||
const sigProgress = `${tx.confirmations.length}/${tx.confirmationsRequired}`
|
||||
|
||||
const valueEth = tx.value !== "0"
|
||||
? `${(Number(tx.value) / 1e18).toFixed(6)} ETH`
|
||||
: "Contract call"
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!signerKey) return
|
||||
await confirm(tx.safeTxHash, signerKey)
|
||||
setSignerKey("")
|
||||
setShowActions(false)
|
||||
onRefresh()
|
||||
}
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!signerKey) return
|
||||
await execute(tx.safeTxHash, signerKey)
|
||||
setSignerKey("")
|
||||
setShowActions(false)
|
||||
onRefresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #1e293b",
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
background: "#1e293b",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{/* Header Row */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600 }}>
|
||||
<span style={{ color: "#e2e8f0" }}>#{tx.nonce}</span>
|
||||
<span style={{ color: "#64748b", marginLeft: 8 }}>→ {formatAddress(tx.to)}</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
background: thresholdMet ? "#064e3b" : "#1e1b4b",
|
||||
color: thresholdMet ? "#34d399" : "#818cf8",
|
||||
}}
|
||||
>
|
||||
{sigProgress}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div style={{ fontSize: 11, color: "#94a3b8", marginTop: 4 }}>{valueEth}</div>
|
||||
|
||||
{/* Signers */}
|
||||
<div style={{ marginTop: 6, display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
{tx.confirmations.map((c) => (
|
||||
<span
|
||||
key={c.owner}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 3,
|
||||
background: "#064e3b",
|
||||
color: "#34d399",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{formatAddress(c.owner, 3)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions Toggle */}
|
||||
<button
|
||||
onClick={() => setShowActions(!showActions)}
|
||||
style={{
|
||||
marginTop: 6,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "#818cf8",
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
padding: 0,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{showActions ? "Hide" : thresholdMet ? "Execute" : "Confirm"}
|
||||
</button>
|
||||
|
||||
{/* Action Panel */}
|
||||
{showActions && (
|
||||
<div style={{ marginTop: 8, display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Signer private key (0x...)"
|
||||
value={signerKey}
|
||||
onChange={(e) => setSignerKey(e.target.value)}
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
border: "1px solid #334155",
|
||||
borderRadius: 4,
|
||||
background: "#0f172a",
|
||||
color: "#e2e8f0",
|
||||
fontSize: 11,
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{!thresholdMet && (
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={confirming || !signerKey}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 12px",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
background: confirming ? "#334155" : "#818cf8",
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
cursor: confirming ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{confirming ? "..." : "Confirm"}
|
||||
</button>
|
||||
)}
|
||||
{thresholdMet && (
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={executing || !signerKey}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 12px",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
background: executing ? "#334155" : "#12ff80",
|
||||
color: "#0f172a",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: executing ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{executing ? "..." : "Execute On-Chain"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PendingTransactions() {
|
||||
const { data: txs, isLoading, error, refetch } = usePendingTransactions()
|
||||
|
||||
if (isLoading) {
|
||||
return <div style={{ color: "#64748b", fontSize: 12, textAlign: "center" }}>Loading...</div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div style={{ color: "#fca5a5", fontSize: 12 }}>Error: {error}</div>
|
||||
}
|
||||
|
||||
if (txs.length === 0) {
|
||||
return (
|
||||
<div style={{ color: "#64748b", fontSize: 12, textAlign: "center", padding: 20 }}>
|
||||
No pending transactions
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: "#64748b", marginBottom: 8 }}>
|
||||
{txs.length} pending transaction{txs.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
{txs.map((tx) => (
|
||||
<TxCard key={tx.safeTxHash} tx={tx} onRefresh={refetch} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { useSafeInfo, useSafeBalances } from "../../hooks/useSafeTransaction"
|
||||
import { formatAddress } from "../../hooks/useWallet"
|
||||
|
||||
export function SafeHeader() {
|
||||
const { data: info, isLoading: infoLoading } = useSafeInfo()
|
||||
const { data: balances, isLoading: balLoading } = useSafeBalances()
|
||||
|
||||
if (infoLoading) {
|
||||
return (
|
||||
<div style={{ padding: 12, textAlign: "center", color: "#64748b", fontSize: 12 }}>
|
||||
Loading Safe info...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
return (
|
||||
<div style={{ padding: 12, textAlign: "center", color: "#f87171", fontSize: 12 }}>
|
||||
Treasury not configured
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Sum native balance from balances data
|
||||
const nativeBalance = balances?.balances?.find((b) => !b.tokenAddress)
|
||||
const nativeFormatted = nativeBalance
|
||||
? (Number(nativeBalance.balance) / 1e18).toFixed(4)
|
||||
: "..."
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
borderBottom: "1px solid #1e293b",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{/* Address + Chain */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
fontFamily: "monospace",
|
||||
color: "#12ff80",
|
||||
}}
|
||||
>
|
||||
{formatAddress(info.address, 6)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(info.address)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "#64748b",
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
padding: "2px 4px",
|
||||
}}
|
||||
title="Copy address"
|
||||
>
|
||||
copy
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 4,
|
||||
background: "#1e293b",
|
||||
color: "#94a3b8",
|
||||
}}
|
||||
>
|
||||
Chain {info.chainId}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Owners + Threshold + Balance */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#94a3b8" }}>
|
||||
<span>
|
||||
{info.threshold}/{info.owners.length} owners
|
||||
</span>
|
||||
<span>v{info.version}</span>
|
||||
<span>{nativeFormatted} ETH</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import { useState } from "react"
|
||||
import { useProposeTransaction, useSafeBalances } from "../../hooks/useSafeTransaction"
|
||||
import { useWalletConnection } from "../../hooks/useWallet"
|
||||
|
||||
export function TransactionComposer() {
|
||||
const { address, isConnected } = useWalletConnection()
|
||||
const { data: balances } = useSafeBalances()
|
||||
const { propose, isLoading, error } = useProposeTransaction()
|
||||
|
||||
const [recipient, setRecipient] = useState("")
|
||||
const [amount, setAmount] = useState("")
|
||||
const [tokenAddress, setTokenAddress] = useState("")
|
||||
const [title, setTitle] = useState("")
|
||||
const [signerKey, setSignerKey] = useState("")
|
||||
const [result, setResult] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!recipient || !amount || !signerKey) return
|
||||
|
||||
const res = await propose({
|
||||
recipientAddress: recipient,
|
||||
amount,
|
||||
tokenAddress: tokenAddress || undefined,
|
||||
title: title || undefined,
|
||||
signerPrivateKey: signerKey,
|
||||
})
|
||||
|
||||
if (res) {
|
||||
setResult(`Proposed: ${res.safeTxHash.slice(0, 16)}...`)
|
||||
setRecipient("")
|
||||
setAmount("")
|
||||
setTitle("")
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "8px 10px",
|
||||
border: "1px solid #334155",
|
||||
borderRadius: 6,
|
||||
background: "#1e293b",
|
||||
color: "#e2e8f0",
|
||||
fontSize: 12,
|
||||
fontFamily: "monospace",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "#94a3b8",
|
||||
marginBottom: 4,
|
||||
display: "block",
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label style={labelStyle}>Description (optional)</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder="Payment for..."
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recipient */}
|
||||
<div>
|
||||
<label style={labelStyle}>Recipient Address *</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder="0x..."
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token (optional) */}
|
||||
<div>
|
||||
<label style={labelStyle}>Token Address (empty = native ETH)</label>
|
||||
<select
|
||||
style={{ ...inputStyle, fontFamily: "Inter, system-ui, sans-serif" }}
|
||||
value={tokenAddress}
|
||||
onChange={(e) => setTokenAddress(e.target.value)}
|
||||
>
|
||||
<option value="">ETH (native)</option>
|
||||
{balances?.balances
|
||||
?.filter((b) => b.tokenAddress && b.token)
|
||||
.map((b) => (
|
||||
<option key={b.tokenAddress} value={b.tokenAddress!}>
|
||||
{b.token!.symbol} — {(Number(b.balance) / 10 ** b.token!.decimals).toFixed(4)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div>
|
||||
<label style={labelStyle}>Amount * {tokenAddress ? "(token units)" : "(wei)"}</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder={tokenAddress ? "100" : "1000000000000000"}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Signer Key */}
|
||||
<div>
|
||||
<label style={labelStyle}>Signer Private Key *</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="password"
|
||||
placeholder="0x..."
|
||||
value={signerKey}
|
||||
onChange={(e) => setSignerKey(e.target.value)}
|
||||
/>
|
||||
<span style={{ fontSize: 10, color: "#64748b", marginTop: 2, display: "block" }}>
|
||||
Must be a Safe owner. Key is sent to treasury-service for signing.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !recipient || !amount || !signerKey}
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
background: isLoading ? "#334155" : "#12ff80",
|
||||
color: isLoading ? "#94a3b8" : "#0f172a",
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
cursor: isLoading ? "not-allowed" : "pointer",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Proposing..." : "Propose Transaction"}
|
||||
</button>
|
||||
|
||||
{/* Result / Error */}
|
||||
{result && (
|
||||
<div style={{ padding: 8, borderRadius: 6, background: "#064e3b", color: "#34d399", fontSize: 11 }}>
|
||||
{result}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{ padding: 8, borderRadius: 6, background: "#450a0a", color: "#fca5a5", fontSize: 11 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import { useState } from "react"
|
||||
import { useTransactionHistory } from "../../hooks/useSafeTransaction"
|
||||
import { formatAddress } from "../../hooks/useWallet"
|
||||
|
||||
export function TransactionHistory() {
|
||||
const [page, setPage] = useState(1)
|
||||
const { data: txs, total, isLoading, error } = useTransactionHistory(page, 10)
|
||||
|
||||
if (isLoading) {
|
||||
return <div style={{ color: "#64748b", fontSize: 12, textAlign: "center" }}>Loading...</div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div style={{ color: "#fca5a5", fontSize: 12 }}>Error: {error}</div>
|
||||
}
|
||||
|
||||
if (txs.length === 0) {
|
||||
return (
|
||||
<div style={{ color: "#64748b", fontSize: 12, textAlign: "center", padding: 20 }}>
|
||||
No executed transactions
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: "#64748b", marginBottom: 8 }}>
|
||||
{total} total transaction{total !== 1 ? "s" : ""}
|
||||
</div>
|
||||
|
||||
{txs.map((tx) => {
|
||||
const valueEth =
|
||||
tx.value !== "0"
|
||||
? `${(Number(tx.value) / 1e18).toFixed(6)} ETH`
|
||||
: "Contract call"
|
||||
|
||||
const date = tx.executionDate
|
||||
? new Date(tx.executionDate).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "—"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tx.safeTxHash}
|
||||
style={{
|
||||
border: "1px solid #1e293b",
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
background: "#1e293b",
|
||||
marginBottom: 6,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600 }}>
|
||||
<span style={{ color: "#e2e8f0" }}>#{tx.nonce}</span>
|
||||
<span style={{ color: "#64748b", marginLeft: 8 }}>→ {formatAddress(tx.to)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#94a3b8", marginTop: 2 }}>{valueEth}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: tx.isSuccessful ? "#34d399" : "#f87171",
|
||||
}}
|
||||
>
|
||||
{tx.isSuccessful ? "Success" : "Failed"}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "#64748b", marginTop: 2 }}>{date}</div>
|
||||
{tx.transactionHash && (
|
||||
<a
|
||||
href={`https://basescan.org/tx/${tx.transactionHash}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 10, color: "#818cf8", textDecoration: "none" }}
|
||||
>
|
||||
{formatAddress(tx.transactionHash, 4)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Pagination */}
|
||||
{total > 10 && (
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 8, marginTop: 8 }}>
|
||||
<button
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
border: "1px solid #334155",
|
||||
borderRadius: 4,
|
||||
background: "transparent",
|
||||
color: page === 1 ? "#334155" : "#94a3b8",
|
||||
cursor: page === 1 ? "not-allowed" : "pointer",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span style={{ fontSize: 11, color: "#64748b", lineHeight: "28px" }}>
|
||||
{page}/{Math.ceil(total / 10)}
|
||||
</span>
|
||||
<button
|
||||
disabled={page * 10 >= total}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
border: "1px solid #334155",
|
||||
borderRadius: 4,
|
||||
background: "transparent",
|
||||
color: page * 10 >= total ? "#334155" : "#94a3b8",
|
||||
cursor: page * 10 >= total ? "not-allowed" : "pointer",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
/**
|
||||
* useSafeTransaction - Hooks for interacting with Safe treasury API
|
||||
*
|
||||
* Provides data fetching and mutation hooks for Safe multisig operations.
|
||||
* Talks to the treasury-service REST API on payment-infra.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
const TREASURY_API = import.meta.env.VITE_TREASURY_API_URL || 'http://localhost:3006'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface SafeInfo {
|
||||
address: string
|
||||
chainId: number
|
||||
threshold: number
|
||||
owners: string[]
|
||||
nonce: number
|
||||
modules: string[]
|
||||
guard: string
|
||||
fallbackHandler: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface SafeBalance {
|
||||
tokenAddress: string | null
|
||||
token: {
|
||||
name: string
|
||||
symbol: string
|
||||
decimals: number
|
||||
logoUri?: string
|
||||
} | null
|
||||
balance: string
|
||||
fiatBalance?: string
|
||||
}
|
||||
|
||||
export interface SafeConfirmation {
|
||||
owner: string
|
||||
signature: string
|
||||
signatureType: string
|
||||
submissionDate: string
|
||||
}
|
||||
|
||||
export interface SafeTransaction {
|
||||
safeTxHash: string
|
||||
to: string
|
||||
value: string
|
||||
data: string | null
|
||||
operation: number
|
||||
nonce: number
|
||||
confirmations: SafeConfirmation[]
|
||||
confirmationsRequired: number
|
||||
isExecuted: boolean
|
||||
isSuccessful: boolean | null
|
||||
executionDate: string | null
|
||||
transactionHash: string | null
|
||||
submissionDate: string
|
||||
proposer: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper
|
||||
// =============================================================================
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error((body as Record<string, string>).error || `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// useSafeInfo
|
||||
// =============================================================================
|
||||
|
||||
export function useSafeInfo() {
|
||||
const [data, setData] = useState<SafeInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetch_ = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const info = await fetchJson<SafeInfo>(`${TREASURY_API}/api/treasury/safe`)
|
||||
setData(info)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetch_() }, [fetch_])
|
||||
|
||||
return { data, isLoading, error, refetch: fetch_ }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// useSafeBalances
|
||||
// =============================================================================
|
||||
|
||||
export function useSafeBalances() {
|
||||
const [data, setData] = useState<{ balances: SafeBalance[] } | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetch_ = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await fetchJson<{ balances: SafeBalance[] }>(`${TREASURY_API}/api/treasury/balance`)
|
||||
setData(result)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetch_() }, [fetch_])
|
||||
|
||||
return { data, isLoading, error, refetch: fetch_ }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// usePendingTransactions
|
||||
// =============================================================================
|
||||
|
||||
export function usePendingTransactions(pollInterval = 15000) {
|
||||
const [data, setData] = useState<SafeTransaction[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetch_ = useCallback(async () => {
|
||||
try {
|
||||
const result = await fetchJson<{ transactions: SafeTransaction[] }>(`${TREASURY_API}/api/treasury/pending`)
|
||||
setData(result.transactions)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetch_()
|
||||
const interval = setInterval(fetch_, pollInterval)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetch_, pollInterval])
|
||||
|
||||
return { data, isLoading, error, refetch: fetch_ }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// useTransactionHistory
|
||||
// =============================================================================
|
||||
|
||||
export function useTransactionHistory(page = 1, limit = 20) {
|
||||
const [data, setData] = useState<SafeTransaction[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetch_ = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await fetchJson<{
|
||||
transactions: SafeTransaction[]
|
||||
pagination: { total: number }
|
||||
}>(`${TREASURY_API}/api/treasury/transactions?page=${page}&limit=${limit}`)
|
||||
setData(result.transactions)
|
||||
setTotal(result.pagination.total)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [page, limit])
|
||||
|
||||
useEffect(() => { fetch_() }, [fetch_])
|
||||
|
||||
return { data, total, isLoading, error, refetch: fetch_ }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// useProposeTransaction (mutation)
|
||||
// =============================================================================
|
||||
|
||||
export function useProposeTransaction() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const propose = useCallback(async (params: {
|
||||
recipientAddress: string
|
||||
amount: string
|
||||
tokenAddress?: string
|
||||
title?: string
|
||||
description?: string
|
||||
signerPrivateKey: string
|
||||
}) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await fetchJson<{ safeTxHash: string }>(`${TREASURY_API}/api/treasury/propose`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
return result
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { propose, isLoading, error }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// useConfirmTransaction (mutation)
|
||||
// =============================================================================
|
||||
|
||||
export function useConfirmTransaction() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const confirm = useCallback(async (safeTxHash: string, signerPrivateKey: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await fetchJson<SafeTransaction>(
|
||||
`${TREASURY_API}/api/treasury/confirm/${safeTxHash}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ signerPrivateKey }),
|
||||
}
|
||||
)
|
||||
return result
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { confirm, isLoading, error }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// useExecuteTransaction (mutation)
|
||||
// =============================================================================
|
||||
|
||||
export function useExecuteTransaction() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const execute = useCallback(async (safeTxHash: string, signerPrivateKey: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await fetchJson<{ transactionHash: string }>(
|
||||
`${TREASURY_API}/api/treasury/execute/${safeTxHash}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ signerPrivateKey }),
|
||||
}
|
||||
)
|
||||
return result
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { execute, isLoading, error }
|
||||
}
|
||||
|
|
@ -144,6 +144,7 @@ import { logActivity } from "../lib/activityLogger"
|
|||
import { ActivityPanel } from "../components/ActivityPanel"
|
||||
|
||||
import { WORKER_URL } from "../constants/workerUrl"
|
||||
import { TransactionBuilderShape } from "@/shapes/TransactionBuilderShapeUtil"
|
||||
|
||||
const customShapeUtils = [
|
||||
ChatBoxShape,
|
||||
|
|
@ -170,6 +171,7 @@ const customShapeUtils = [
|
|||
PrivateWorkspaceShape, // Private zone for Google Export data sovereignty
|
||||
GoogleItemShape, // Individual items from Google Export with privacy badges
|
||||
MapShape, // Open Mapping - OSM map shape
|
||||
TransactionBuilderShape, // Safe multisig transaction builder
|
||||
// Conditionally included based on feature flags:
|
||||
...(ENABLE_WORKFLOW ? [WorkflowBlockShape] : []), // Workflow Builder - dev only
|
||||
...(ENABLE_CALENDAR ? [CalendarShape, CalendarEventShape] : []), // Calendar - dev only
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
import { useState } from "react"
|
||||
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
import { usePinnedToView } from "../hooks/usePinnedToView"
|
||||
import { useMaximize } from "../hooks/useMaximize"
|
||||
import { SafeHeader } from "../components/safe/SafeHeader"
|
||||
import { TransactionComposer } from "../components/safe/TransactionComposer"
|
||||
import { PendingTransactions } from "../components/safe/PendingTransactions"
|
||||
import { TransactionHistory } from "../components/safe/TransactionHistory"
|
||||
|
||||
export type ITransactionBuilderShape = TLBaseShape<
|
||||
"TransactionBuilder",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
mode: "compose" | "pending" | "history"
|
||||
pinnedToView: boolean
|
||||
tags: string[]
|
||||
}
|
||||
>
|
||||
|
||||
export class TransactionBuilderShape extends BaseBoxShapeUtil<ITransactionBuilderShape> {
|
||||
static override type = "TransactionBuilder"
|
||||
|
||||
getDefaultProps(): ITransactionBuilderShape["props"] {
|
||||
return {
|
||||
w: 480,
|
||||
h: 620,
|
||||
mode: "pending",
|
||||
pinnedToView: false,
|
||||
tags: ["safe", "treasury"],
|
||||
}
|
||||
}
|
||||
|
||||
// Safe green theme
|
||||
static readonly PRIMARY_COLOR = "#12ff80"
|
||||
|
||||
indicator(shape: ITransactionBuilderShape) {
|
||||
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
|
||||
component(shape: ITransactionBuilderShape) {
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<"compose" | "pending" | "history">(shape.props.mode)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
const { isMaximized, toggleMaximize } = useMaximize({
|
||||
editor: this.editor,
|
||||
shapeId: shape.id,
|
||||
currentW: shape.props.w,
|
||||
currentH: shape.props.h,
|
||||
shapeType: "TransactionBuilder",
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
const handlePinToggle = () => {
|
||||
this.editor.updateShape<ITransactionBuilderShape>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
pinnedToView: !shape.props.pinnedToView,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ key: "compose" as const, label: "Compose" },
|
||||
{ key: "pending" as const, label: "Pending" },
|
||||
{ key: "history" as const, label: "History" },
|
||||
]
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Safe Treasury"
|
||||
primaryColor={TransactionBuilderShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={shape.props.w}
|
||||
height={shape.props.h}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
onMaximize={toggleMaximize}
|
||||
isMaximized={isMaximized}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
fontFamily: "Inter, system-ui, -apple-system, sans-serif",
|
||||
fontSize: 13,
|
||||
color: "#e2e8f0",
|
||||
background: "#0f172a",
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Safe Info Header */}
|
||||
<SafeHeader />
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderBottom: "1px solid #1e293b",
|
||||
padding: "0 8px",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
border: "none",
|
||||
background: activeTab === tab.key ? "#1e293b" : "transparent",
|
||||
color: activeTab === tab.key ? "#12ff80" : "#94a3b8",
|
||||
borderBottom: activeTab === tab.key ? "2px solid #12ff80" : "2px solid transparent",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
borderRadius: "4px 4px 0 0",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div style={{ flex: 1, overflow: "auto", padding: 12 }}>
|
||||
{activeTab === "compose" && <TransactionComposer />}
|
||||
{activeTab === "pending" && <PendingTransactions />}
|
||||
{activeTab === "history" && <TransactionHistory />}
|
||||
</div>
|
||||
</div>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue