From e3081ec0a5d6a1dd74c54400be0e9b57824a9dce Mon Sep 17 00:00:00 2001 From: obanai9 Date: Thu, 25 Jun 2026 23:48:49 +0000 Subject: [PATCH] feat: add stream templates for common use cases (#82) Adds four pre-built template cards (Payroll, Token Vesting, Grant/Sponsorship, Subscription) to the create stream page. Clicking a template pre-fills the form with appropriate duration, cliff, and cliff amount values while keeping all fields editable. Also fixes pre-existing build errors caused by incomplete network-context refactoring. Closes #82 --- app/app/create/batch/page.tsx | 10 +-- app/app/create/page.tsx | 40 ++++++++- app/app/stream/[id]/page.tsx | 27 +++--- components/streams/stream-templates.tsx | 113 ++++++++++++++++++++++++ hooks/use-contract.ts | 22 ++--- hooks/use-notifications.ts | 21 +++-- hooks/use-streams.ts | 11 ++- lib/contract.ts | 13 ++- lib/mock-data.ts | 3 +- lib/stellar.ts | 3 - 10 files changed, 209 insertions(+), 54 deletions(-) create mode 100644 components/streams/stream-templates.tsx diff --git a/app/app/create/batch/page.tsx b/app/app/create/batch/page.tsx index faea83b..245eb66 100644 --- a/app/app/create/batch/page.tsx +++ b/app/app/create/batch/page.tsx @@ -16,13 +16,11 @@ import { SelectValue, } from '@/components/ui/select' import { useContract } from '@/hooks/use-contract' -import { KNOWN_TOKENS } from '@/lib/stellar' +import { useNetwork } from '@/components/providers/network-provider' import { parseTokenAmount, formatDateTime } from '@/lib/stream-utils' import { parseCsvBatch, type CsvBatchRow } from '@/lib/csv-parser' import type { TokenInfo } from '@/types/stream' -const TOKENS: TokenInfo[] = KNOWN_TOKENS.map((t) => ({ ...t })) - function parseTimestamp(value: string): bigint | null { const trimmed = value.trim() if (!trimmed) return null @@ -68,7 +66,9 @@ interface ParsedRow { export default function BatchCreatePage() { const { createStream, pending, error } = useContract() - const [selectedToken, setSelectedToken] = useState(TOKENS[0].address) + const { config } = useNetwork() + const TOKENS: TokenInfo[] = config.knownTokens.map((t) => ({ ...t })) + const [selectedToken, setSelectedToken] = useState('') const [fileText, setFileText] = useState('') const [rows, setRows] = useState([]) const [parseErrors, setParseErrors] = useState([]) @@ -77,7 +77,7 @@ export default function BatchCreatePage() { const [completedCount, setCompletedCount] = useState(0) const [queuedCount, setQueuedCount] = useState(0) const [executionErrors, setExecutionErrors] = useState([]) - const selectedTokenInfo = TOKENS.find((t) => t.address === selectedToken) ?? TOKENS[0] + const selectedTokenInfo = TOKENS.find((t) => t.address === selectedToken) ?? TOKENS[0] ?? { address: '', symbol: 'XLM', decimals: 7 } const isValidRow = useCallback( (row: ParsedRow) => row.errors.length === 0, diff --git a/app/app/create/page.tsx b/app/app/create/page.tsx index f2908b5..dde0575 100644 --- a/app/app/create/page.tsx +++ b/app/app/create/page.tsx @@ -19,11 +19,13 @@ import { } from '@/components/ui/select' import { useContract } from '@/hooks/use-contract' import { useWallet } from '@/hooks/use-wallet' +import { useNetwork } from '@/components/providers/network-provider' import { getAllTokens, saveCustomToken } from '@/lib/stellar' import { getTokenMetadata, getTokenBalance } from '@/lib/contract' import { parseTokenAmount, formatTokenAmount } from '@/lib/stream-utils' import { StreamPreview } from '@/components/streams/stream-preview' import { CreateConfirmation } from '@/components/streams/create-confirmation' +import { StreamTemplates, type StreamTemplate } from '@/components/streams/stream-templates' import type { TokenInfo } from '@/types/stream' const CUSTOM_VALUE = '__custom__' @@ -70,12 +72,13 @@ interface FormState { function CreateForm() { const router = useRouter() const { address: walletAddress } = useWallet() + const { network } = useNetwork() const { createStream, estimateFee, pending, error } = useContract() const [feeEstimate, setFeeEstimate] = useState(null) const [estimatingFee, setEstimatingFee] = useState(false) const [showConfirmation, setShowConfirmation] = useState(false) - const [tokens, setTokens] = useState(() => getAllTokens().map((t) => ({ ...t }))) + const [tokens, setTokens] = useState(() => getAllTokens(network).map((t) => ({ ...t }))) const [isCustom, setIsCustom] = useState(false) const [customAddress, setCustomAddress] = useState('') const [customLoading, setCustomLoading] = useState(false) @@ -101,6 +104,7 @@ function CreateForm() { }) const [errors, setErrors] = useState>>({}) + const [selectedTemplateId, setSelectedTemplateId] = useState(undefined) const selectedToken = isCustom && customToken ? customToken @@ -132,8 +136,8 @@ function CreateForm() { return } setCustomToken(meta) - saveCustomToken(meta) - setTokens(getAllTokens().map((t) => ({ ...t }))) + saveCustomToken(network, meta) + setTokens(getAllTokens(network).map((t) => ({ ...t }))) set('tokenAddress', meta.address) } catch { setCustomError('Failed to query token contract') @@ -238,6 +242,30 @@ function CreateForm() { } } + function handleTemplateSelect(template: StreamTemplate) { + setSelectedTemplateId(template.id) + const newStart = localDatetimeMin(60) + const newEnd = addDuration(newStart, template.durationSeconds) + const hasCliff = template.cliffSeconds > 0 + const newCliff = hasCliff ? addDuration(newStart, template.cliffSeconds) : newStart + + setForm((prev) => { + const amount = prev.amount + const cliffAmount = hasCliff && template.cliffPercent > 0 && amount + ? String(Math.floor(Number(amount) * template.cliffPercent / 100)) + : '' + return { + ...prev, + startDate: newStart, + endDate: newEnd, + hasCliff, + cliffDate: newCliff, + cliffAmount, + } + }) + setErrors({}) + } + const input = showConfirmation ? buildInput() : null const durationSeconds = (new Date(form.endDate).getTime() - new Date(form.startDate).getTime()) / 1000 const amountPerSecond = input && durationSeconds > 0 @@ -262,7 +290,11 @@ function CreateForm() {

-
+
+ +
+ +
{/* Token + Amount */}
diff --git a/app/app/stream/[id]/page.tsx b/app/app/stream/[id]/page.tsx index de7e40e..0b13863 100644 --- a/app/app/stream/[id]/page.tsx +++ b/app/app/stream/[id]/page.tsx @@ -51,7 +51,8 @@ import { shortenAddress, formatRate, } from '@/lib/stream-utils' -import { NETWORK, STREAM_CONTRACT_ID, explorerUrl } from '@/lib/stellar' +import { explorerUrl } from '@/lib/stellar' +import { useNetwork } from '@/components/providers/network-provider' import { useAutoWithdraw } from '@/hooks/use-auto-withdraw' import { UnlockChart } from '@/components/streams/unlock-chart' import { bumpStreamTtl } from '@/lib/contract' @@ -120,6 +121,7 @@ function WithdrawDialog({ token: { symbol: string; decimals: number; address: string } }) { const { withdraw, pending, error } = useContract() + const { network } = useNetwork() const [inputAmount, setInputAmount] = useState('') const [showFeeEstimate, setShowFeeEstimate] = useState(false) @@ -140,7 +142,7 @@ function WithdrawDialog({ ...(hash && { action: { label: 'View transaction', - onClick: () => window.open(explorerUrl('tx', hash), '_blank'), + onClick: () => window.open(explorerUrl(network, 'tx', hash), '_blank'), }, }), }) @@ -242,6 +244,7 @@ function CancelDialog({ streamId: string }) { const { cancel, pending, error } = useContract() + const { network } = useNetwork() const router = useRouter() const [showFeeEstimate, setShowFeeEstimate] = useState(false) @@ -257,7 +260,7 @@ function CancelDialog({ ...(hash && { action: { label: 'View transaction', - onClick: () => window.open(explorerUrl('tx', hash), '_blank'), + onClick: () => window.open(explorerUrl(network, 'tx', hash), '_blank'), }, }), }) @@ -534,6 +537,7 @@ function estimateDaysSinceLastWrite(stream: import('@/types/stream').StreamData, function TtlWarning({ stream, nowSeconds }: { stream: import('@/types/stream').StreamData; nowSeconds: number }) { const { address } = useWallet() + const { network } = useNetwork() const [bumping, setBumping] = useState(false) const [bumped, setBumped] = useState(false) @@ -546,7 +550,7 @@ function TtlWarning({ stream, nowSeconds }: { stream: import('@/types/stream').S if (!address) return setBumping(true) try { - await bumpStreamTtl(stream.id, address) + await bumpStreamTtl(network, stream.id, address) setBumped(true) toast.success('Storage TTL extended by 30 days') } catch { @@ -698,6 +702,7 @@ function ConnectPrompt() { function StreamDetail({ id }: { id: string }) { const { stream, loading } = useStream(id) const { address, isConnected } = useWallet() + const { network, config } = useNetwork() const now = useNow(1000) const [withdrawOpen, setWithdrawOpen] = useState(false) const [cancelOpen, setCancelOpen] = useState(false) @@ -884,25 +889,25 @@ function StreamDetail({ id }: { id: string }) { Details - + - +
{stream.token.symbol}
- {STREAM_CONTRACT_ID && ( + {config.streamContractId && ( )} @@ -937,7 +942,7 @@ function StreamDetail({ id }: { id: string }) { {formatDateTime(stream.endTime)} - {NETWORK.name} + {network}
diff --git a/components/streams/stream-templates.tsx b/components/streams/stream-templates.tsx new file mode 100644 index 0000000..e05e9e6 --- /dev/null +++ b/components/streams/stream-templates.tsx @@ -0,0 +1,113 @@ +'use client' + +import { Briefcase, Lock, Gift, CreditCard } from 'lucide-react' + +export interface StreamTemplate { + id: string + name: string + description: string + icon: React.ComponentType<{ className?: string }> + durationSeconds: number + cliffSeconds: number + cliffPercent: number + badge: string +} + +export const STREAM_TEMPLATES: StreamTemplate[] = [ + { + id: 'payroll', + name: 'Payroll', + description: 'Monthly salary streaming', + icon: Briefcase, + durationSeconds: 30 * 24 * 3600, + cliffSeconds: 0, + cliffPercent: 0, + badge: '1 month', + }, + { + id: 'vesting', + name: 'Token Vesting', + description: 'Standard vesting schedule', + icon: Lock, + durationSeconds: 2 * 365 * 24 * 3600, + cliffSeconds: 365 * 24 * 3600, + cliffPercent: 25, + badge: '2yr / 1yr cliff', + }, + { + id: 'grant', + name: 'Grant / Sponsorship', + description: 'Milestone-based grant', + icon: Gift, + durationSeconds: 90 * 24 * 3600, + cliffSeconds: 30 * 24 * 3600, + cliffPercent: 10, + badge: '3 months', + }, + { + id: 'subscription', + name: 'Subscription', + description: 'Continuous subscription payment', + icon: CreditCard, + durationSeconds: 30 * 24 * 3600, + cliffSeconds: 0, + cliffPercent: 0, + badge: '1 month', + }, +] + +interface StreamTemplatesProps { + onSelect: (template: StreamTemplate) => void + selectedId?: string +} + +export function StreamTemplates({ onSelect, selectedId }: StreamTemplatesProps) { + return ( +
+
+

+ Quick templates +

+ Select one to pre-fill the form +
+
+ {STREAM_TEMPLATES.map((tpl) => { + const Icon = tpl.icon + const isSelected = selectedId === tpl.id + return ( + + ) + })} +
+
+ ) +} diff --git a/hooks/use-contract.ts b/hooks/use-contract.ts index 4f89fad..7731f8d 100644 --- a/hooks/use-contract.ts +++ b/hooks/use-contract.ts @@ -10,6 +10,7 @@ import { import type { FeeEstimate } from '@/lib/contract' import { invalidateStreams } from '@/hooks/use-streams' import { useWallet } from '@/hooks/use-wallet' +import { useNetwork } from '@/components/providers/network-provider' import { getWithdrawableAmount } from '@/lib/stream-utils' import type { CreateStreamInput, StreamData } from '@/types/stream' @@ -20,6 +21,7 @@ export interface WithdrawAllResult { export function useContract() { const { address, isConnected } = useWallet() + const { network } = useNetwork() const [pending, setPending] = useState(false) const [error, setError] = useState(null) @@ -44,30 +46,30 @@ export function useContract() { ) const createStream = useCallback( - (input: CreateStreamInput) => run(() => createStreamCall(input, address!)), - [run, address], + (input: CreateStreamInput) => run(() => createStreamCall(network, input, address!)), + [run, network, address], ) const withdraw = useCallback( - (id: string, amount: bigint) => run(() => withdrawFromStream(id, amount)), - [run], + (id: string, amount: bigint) => run(() => withdrawFromStream(network, id, amount)), + [run, network], ) const cancel = useCallback( - (id: string) => run(() => cancelStreamCall(id)), - [run], + (id: string) => run(() => cancelStreamCall(network, id)), + [run, network], ) const estimateFee = useCallback( async (input: CreateStreamInput): Promise => { if (!isConnected || !address) return null try { - return await estimateCreateStreamFee(input, address) + return await estimateCreateStreamFee(network, input, address) } catch { return null } }, - [address, isConnected], + [network, address, isConnected], ) const withdrawAll = useCallback( @@ -92,7 +94,7 @@ export function useContract() { const s = withdrawable[i] try { const amount = getWithdrawableAmount(s, Math.floor(Date.now() / 1000)) - await withdrawFromStream(s.id, amount) + await withdrawFromStream(network, s.id, amount) succeeded++ } catch { failed++ @@ -108,7 +110,7 @@ export function useContract() { return { succeeded, failed } }, - [address, isConnected], + [network, address, isConnected], ) return { createStream, withdraw, cancel, withdrawAll, estimateFee, pending, error } diff --git a/hooks/use-notifications.ts b/hooks/use-notifications.ts index a6cf0e3..2d4a7aa 100644 --- a/hooks/use-notifications.ts +++ b/hooks/use-notifications.ts @@ -1,7 +1,7 @@ 'use client' import { useState, useEffect, useCallback, useRef } from 'react' -import { NETWORK, STREAM_CONTRACT_ID } from '@/lib/stellar' +import { useNetwork } from '@/components/providers/network-provider' const POLL_INTERVAL = 30_000 const STORAGE_KEY = 'flowstar:last-seen-ledger' @@ -81,8 +81,10 @@ function decodeEventTopic(topics: string[]): { async function fetchContractEvents( startLedger: number, + rpcUrl: string, + contractId: string, ): Promise<{ events: ContractEvent[]; latestLedger: number }> { - if (!STREAM_CONTRACT_ID) return { events: [], latestLedger: startLedger } + if (!contractId) return { events: [], latestLedger: startLedger } try { const body: Record = { @@ -94,14 +96,14 @@ async function fetchContractEvents( filters: [ { type: 'contract', - contractIds: [STREAM_CONTRACT_ID], + contractIds: [contractId], }, ], pagination: { limit: 100 }, }, } - const res = await fetch(NETWORK.rpcUrl, { + const res = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -124,6 +126,7 @@ async function fetchContractEvents( } export function useNotifications(walletAddress: string | null) { + const { config } = useNetwork() const [notifications, setNotifications] = useState(() => getSavedNotifications(), ) @@ -166,7 +169,9 @@ export function useNotifications(walletAddress: string | null) { }, []) useEffect(() => { - if (!walletAddress || !STREAM_CONTRACT_ID) return + const rpcUrl = config.rpcUrl + const contractId = config.streamContractId + if (!walletAddress || !contractId) return requestNotificationPermission() @@ -176,7 +181,7 @@ export function useNotifications(walletAddress: string | null) { if (startLedger === 0) { try { - const res = await fetch(NETWORK.rpcUrl, { + const res = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -194,7 +199,7 @@ export function useNotifications(walletAddress: string | null) { } } - const { events, latestLedger } = await fetchContractEvents(startLedger + 1) + const { events, latestLedger } = await fetchContractEvents(startLedger + 1, rpcUrl, contractId) for (const event of events) { const { eventType } = decodeEventTopic(event.topic ?? []) @@ -232,7 +237,7 @@ export function useNotifications(walletAddress: string | null) { return () => { if (pollingRef.current) clearInterval(pollingRef.current) } - }, [walletAddress, addNotification]) + }, [walletAddress, addNotification, config.rpcUrl, config.streamContractId]) return { notifications, unreadCount, markAllRead, clearAll } } diff --git a/hooks/use-streams.ts b/hooks/use-streams.ts index 3e1a676..afdbbcf 100644 --- a/hooks/use-streams.ts +++ b/hooks/use-streams.ts @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { fetchStreamsForAddress, fetchStream } from '@/lib/contract' import type { StreamData } from '@/types/stream' import { useWallet } from '@/hooks/use-wallet' +import { useNetwork } from '@/components/providers/network-provider' // ─── Refresh bus ───────────────────────────────────────────────────────────── // Components call `invalidateStreams()` after a write so all stream hooks @@ -43,6 +44,7 @@ interface UseStreamsOptions { export function useStreams(options?: UseStreamsOptions): CategorizedStreams { const { address } = useWallet() + const { network } = useNetwork() const [streams, setStreams] = useState([]) const [loading, setLoading] = useState(false) const pollIntervalRef = useRef(null) @@ -53,14 +55,14 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams { if (!address) { setStreams([]); return } setLoading(true) try { - const data = await fetchStreamsForAddress(address) + const data = await fetchStreamsForAddress(network, address) setStreams(data) } catch (e) { console.error('useStreams fetch error:', e) } finally { setLoading(false) } - }, [address]) + }, [address, network]) // Fetch on mount and when address changes useEffect(() => { fetch() }, [fetch]) @@ -96,6 +98,7 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams { } export function useStream(id: string): { stream: StreamData | null; loading: boolean; refetch: () => void } { + const { network } = useNetwork() const [stream, setStream] = useState(null) const [loading, setLoading] = useState(false) @@ -103,14 +106,14 @@ export function useStream(id: string): { stream: StreamData | null; loading: boo if (!id) return setLoading(true) try { - const data = await fetchStream(id) + const data = await fetchStream(network, id) setStream(data) } catch (e) { console.error('useStream fetch error:', e) } finally { setLoading(false) } - }, [id]) + }, [id, network]) useEffect(() => { fetch() }, [fetch]) useInvalidation(fetch) diff --git a/lib/contract.ts b/lib/contract.ts index e6ce35e..06634f8 100644 --- a/lib/contract.ts +++ b/lib/contract.ts @@ -124,8 +124,7 @@ async function invoke( // Submit the signed XDR directly via the RPC JSON-RPC endpoint. // We bypass TransactionBuilder.fromXDR because Freighter may return a // FeeBumpTransaction envelope (type 4) which fromXDR can't handle. - const rpcResponse = await fetchWithRetry(NETWORK.rpcUrl, { - const rpcResponse = await fetch(config.rpcUrl, { + const rpcResponse = await fetchWithRetry(config.rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -157,9 +156,7 @@ async function invoke( while (pollStatus !== 'SUCCESS' && pollStatus !== 'FAILED') { if (Date.now() >= pollDeadline) throw new Error('Transaction confirmation timed out after 60s') await new Promise((r) => setTimeout(r, 2000)) - const pollRes = await fetchWithRetry(NETWORK.rpcUrl, { - await new Promise((r) => setTimeout(r, 2000)) - const pollRes = await fetch(config.rpcUrl, { + const pollRes = await fetchWithRetry(config.rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -311,9 +308,8 @@ export async function createStream( // Step 1: approve the streaming contract to pull `totalAmount` from the sender. // The allowance needs to outlast the simulation ledger — set it to current + 500 ledgers. - const currentLedger = (await withRetry(() => server.getLatestLedger())).sequence const server = getServer(network) - const currentLedger = (await server.getLatestLedger()).sequence + const currentLedger = (await withRetry(() => server.getLatestLedger())).sequence const expirationLedger = currentLedger + 500 await invoke( @@ -347,11 +343,12 @@ export async function createStream( ), ) - const result = await invoke( + await invoke( network, 'create_stream', [new Address(sender).toScVal(), params], sender, + config.streamContractId, ) // SDK v13 can't parse TransactionMetaV4 (protocol 22+) so returnValue is void. diff --git a/lib/mock-data.ts b/lib/mock-data.ts index 8b8e9f7..3c6d795 100644 --- a/lib/mock-data.ts +++ b/lib/mock-data.ts @@ -1,5 +1,5 @@ import type { CreateStreamInput, StreamData } from '@/types/stream' -import { KNOWN_TOKENS } from '@/lib/stellar' +import { NETWORKS } from '@/lib/stellar' /** * In-memory mock store standing in for on-chain state. @@ -10,6 +10,7 @@ import { KNOWN_TOKENS } from '@/lib/stellar' * the hooks read directly from chain data. */ +const KNOWN_TOKENS = NETWORKS.testnet.knownTokens const XLM = KNOWN_TOKENS[0] const USDC = KNOWN_TOKENS[1] const EURC = KNOWN_TOKENS[2] diff --git a/lib/stellar.ts b/lib/stellar.ts index 177b060..7db5511 100644 --- a/lib/stellar.ts +++ b/lib/stellar.ts @@ -67,7 +67,6 @@ export function getNetworkConfig(network: NetworkName): NetworkConfig { streamContractId: contractId, } } -const CUSTOM_TOKENS_KEY = 'flowstar:custom-tokens' const FAVORITE_TOKENS_KEY = 'flowstar:favorite-tokens' // ─── Verified Token List ────────────────────────────────────────────────────── @@ -163,8 +162,6 @@ export function isFavoriteToken(address: string): boolean { return getFavoriteTokens().includes(address) } -const EXPLORER_NETWORK = NETWORK.name === 'testnet' ? 'testnet' : 'public' - export function getAllTokens(network: NetworkName): { address: string; symbol: string; decimals: number }[] { const config = getNetworkConfig(network) return [...config.knownTokens, ...getCustomTokens(network)]