From 2f56bc028e57ad1550e0846940f224481cb3cda6 Mon Sep 17 00:00:00 2001 From: Ndifreke000 Date: Sat, 27 Jun 2026 01:50:39 +0100 Subject: [PATCH] feat: resolve issues #168, #170, #174, #177 - #168 Auto-save create stream form drafts to localStorage (debounced 500ms) with restore/discard banner on page load; drafts older than 24h expire automatically and are cleared on successful submission. - #170 Timezone-aware date/time picker: detect and display the user's timezone offset next to every date/time input; add timezone selector dropdown for scheduling in a different tz; replace toISOString().slice() with locale-safe local-time formatting; show UTC alongside local time on the stream detail page. - #174 Smart contract storage archival: add ArchiveSentBy/ArchiveReceivedBy index keys; on cancel or full withdrawal after end_time, stream IDs are moved from active to archive indexes so get_sent/received_streams only returns live streams; add get_archived_sent/received_streams queries and a cleanup_stream function for voluntary storage reclamation. - #177 Staging environment: add .github/workflows/staging.yml that builds and deploys to Vercel on pushes to the staging branch and on every PR (preview URL commented back on the PR); document required secrets and NEXT_PUBLIC_APP_ENV in .env.local.example. Co-Authored-By: Claude Sonnet 4.6 --- .env.local.example | 11 +++ .github/workflows/staging.yml | 71 +++++++++++++++ app/app/create/page.tsx | 153 +++++++++++++++++++++++++++++++-- app/app/stream/[id]/page.tsx | 14 ++- contracts/streaming/src/lib.rs | 91 +++++++++++++++++++- hooks/use-form-draft.ts | 98 +++++++++++++++++++++ 6 files changed, 426 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/staging.yml create mode 100644 hooks/use-form-draft.ts diff --git a/.env.local.example b/.env.local.example index 55ee0de..3227d19 100644 --- a/.env.local.example +++ b/.env.local.example @@ -17,3 +17,14 @@ # or use the public testnet deployment below to get started immediately. NEXT_PUBLIC_STREAM_CONTRACT_ID_TESTNET=CBNDCZTRFNTDAPQLPK2ESOKO4XFMSC4PX37QE75BBYFOYIEWIPMHAKFV NEXT_PUBLIC_STREAM_CONTRACT_ID_MAINNET= + +# ─── Staging environment ────────────────────────────────────────────────────── +# +# Used by the staging branch / PR preview deployments (see .github/workflows/staging.yml). +# Set these as GitHub Actions secrets (STAGING_CONTRACT_ID_TESTNET, VERCEL_TOKEN, +# VERCEL_ORG_ID, VERCEL_PROJECT_ID) in the "staging" environment in your repo settings. +# +# NEXT_PUBLIC_APP_ENV is set to "staging" automatically by the CI workflow; you +# can set it here to simulate a staging build locally. +# +# NEXT_PUBLIC_APP_ENV=staging diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml new file mode 100644 index 0000000..cc4e5b7 --- /dev/null +++ b/.github/workflows/staging.yml @@ -0,0 +1,71 @@ +name: Staging + +on: + push: + branches: [staging] + pull_request: + types: [opened, synchronize, reopened] + +jobs: + deploy-staging: + runs-on: ubuntu-latest + environment: staging + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - run: npm run build + env: + NEXT_PUBLIC_STREAM_CONTRACT_ID_TESTNET: ${{ secrets.STAGING_CONTRACT_ID_TESTNET }} + NEXT_PUBLIC_APP_ENV: staging + + - name: Install Vercel CLI + run: npm install -g vercel@latest + + # Deploy preview for PRs, production-like staging deploy for staging branch + - name: Deploy to Vercel (staging branch) + if: github.ref == 'refs/heads/staging' + run: | + vercel deploy \ + --token ${{ secrets.VERCEL_TOKEN }} \ + --env NEXT_PUBLIC_STREAM_CONTRACT_ID_TESTNET=${{ secrets.STAGING_CONTRACT_ID_TESTNET }} \ + --env NEXT_PUBLIC_APP_ENV=staging \ + --prod \ + --yes \ + > deployment-url.txt + echo "Deployed to: $(cat deployment-url.txt)" + + - name: Deploy preview (PR) + if: github.event_name == 'pull_request' + run: | + vercel deploy \ + --token ${{ secrets.VERCEL_TOKEN }} \ + --env NEXT_PUBLIC_STREAM_CONTRACT_ID_TESTNET=${{ secrets.STAGING_CONTRACT_ID_TESTNET }} \ + --env NEXT_PUBLIC_APP_ENV=staging \ + --yes \ + > deployment-url.txt + PREVIEW_URL=$(cat deployment-url.txt) + echo "Preview URL: $PREVIEW_URL" + echo "PREVIEW_URL=$PREVIEW_URL" >> $GITHUB_ENV + + - name: Comment PR with preview URL + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## Staging Preview\n\nDeployed to: ${{ env.PREVIEW_URL }}\n\nThis preview uses the staging testnet contract (isolated from the shared testnet deployment).` + }) diff --git a/app/app/create/page.tsx b/app/app/create/page.tsx index a8a6f85..08dcbd8 100644 --- a/app/app/create/page.tsx +++ b/app/app/create/page.tsx @@ -1,13 +1,12 @@ 'use client' -import { useState, useCallback, useEffect } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import { useRouter, useSearchParams } from 'next/navigation' -import { AlertTriangle, ArrowLeft, ArrowRight, Info, Loader2, Copy } from 'lucide-react' +import { AlertTriangle, ArrowLeft, ArrowRight, Info, Loader2, Copy, Clock } from 'lucide-react' import Link from 'next/link' import { toast } from 'sonner' import { StrKey } from '@stellar/stellar-sdk' import { RequireWallet } from '@/components/layout/require-wallet' -import { useNetwork } from '@/components/providers/network-provider' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -28,6 +27,7 @@ import { StreamPreview } from '@/components/streams/stream-preview' import { CreateConfirmation } from '@/components/streams/create-confirmation' import { addAddressBookEntry, getAddressBookEntries, touchAddressBookEntry } from '@/lib/address-book' import { buildNextRunAt, saveRecurringRule, type RecurrenceCadence } from '@/lib/recurring' +import { useFormDraft, clearExpiredDrafts } from '@/hooks/use-form-draft' import { StreamTemplates, type StreamTemplate } from '@/components/streams/stream-templates' import type { TokenInfo } from '@/types/stream' @@ -39,14 +39,51 @@ function toUnixSeconds(localDatetimeValue: string): bigint { function localDatetimeMin(offsetSeconds = 0): string { const d = new Date(Date.now() + offsetSeconds * 1000) - return d.toISOString().slice(0, 16) + // Format as YYYY-MM-DDTHH:mm in local time (not UTC) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` } function addDuration(baseDatetime: string, seconds: number): string { const base = new Date(baseDatetime) - return new Date(base.getTime() + seconds * 1000).toISOString().slice(0, 16) + const d = new Date(base.getTime() + seconds * 1000) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` } +function detectTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone + } catch { + return 'UTC' + } +} + +function getTimezoneOffset(): string { + const offset = -new Date().getTimezoneOffset() + const sign = offset >= 0 ? '+' : '-' + const h = Math.floor(Math.abs(offset) / 60) + const m = Math.abs(offset) % 60 + return `UTC${sign}${h}${m > 0 ? `:${String(m).padStart(2, '0')}` : ''}` +} + +const COMMON_TIMEZONES = [ + 'UTC', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Sao_Paulo', + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Asia/Dubai', + 'Asia/Kolkata', + 'Asia/Singapore', + 'Asia/Tokyo', + 'Australia/Sydney', +] as const + const DURATION_PRESETS = [ { label: '1 week', seconds: 7 * 24 * 3600 }, { label: '1 month', seconds: 30 * 24 * 3600 }, @@ -126,6 +163,15 @@ function CreateForm() { const [errors, setErrors] = useState>>({}) const [selectedTemplateId, setSelectedTemplateId] = useState(undefined) + // Issue #168: draft state + const [showDraftBanner, setShowDraftBanner] = useState(false) + const [draftSavedAt, setDraftSavedAt] = useState(null) + const isFirstMount = useRef(true) + + // Issue #170: timezone state + const [selectedTimezone, setSelectedTimezone] = useState(() => detectTimezone()) + const timezoneOffset = getTimezoneOffset() + const selectedToken = isCustom && customToken ? customToken : tokens.find((t) => t.address === form.tokenAddress) ?? tokens[0] @@ -141,6 +187,29 @@ function CreateForm() { .finally(() => setBalanceLoading(false)) }, [selectedToken?.address, walletAddress]) + // Issue #168: wire up draft hook + const { loadDraft, restore, discard } = useFormDraft( + `create-stream-${walletAddress ?? 'anonymous'}`, + form, + (draft) => { + setForm(draft) + }, + true, + ) + + // Check for existing draft on first mount + useEffect(() => { + if (!isFirstMount.current) return + isFirstMount.current = false + clearExpiredDrafts() + const entry = loadDraft() + if (entry) { + setDraftSavedAt(entry.savedAt) + setShowDraftBanner(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + async function handleCustomTokenLookup() { if (!customAddress || customAddress.length < 56) { setCustomError('Enter a valid Stellar contract address (56 chars, starts with C)') @@ -270,6 +339,7 @@ function CreateForm() { }) } + discard() setShowConfirmation(false) toast.success('Stream created', { description: `Stream #${id} is live.` }) router.push(`/app/stream/${id}`) @@ -338,6 +408,45 @@ function CreateForm() { )} + {/* Issue #168: Draft restore banner */} + {showDraftBanner && ( +
+
+ +

+ You have an unsaved draft from{' '} + {draftSavedAt + ? new Date(draftSavedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : 'earlier'} + . +

+
+
+ + +
+
+ )} +
{/* Token + Amount */} @@ -518,9 +627,31 @@ function CreateForm() { Schedule + {/* Issue #170: timezone selector */} +
+ + +
+
- +
- +
- + - {formatDateTime(stream.startTime)} + + {formatDateTime(stream.startTime)} + + ({new Date(Number(stream.startTime) * 1000).toUTCString().replace(' GMT', ' UTC')}) + + {stream.cliffTime > stream.startTime && ( @@ -971,7 +976,12 @@ function StreamDetail({ id }: { id: string }) { )} - {formatDateTime(stream.endTime)} + + {formatDateTime(stream.endTime)} + + ({new Date(Number(stream.endTime) * 1000).toUTCString().replace(' GMT', ' UTC')}) + + {network} diff --git a/contracts/streaming/src/lib.rs b/contracts/streaming/src/lib.rs index e05dca9..847697d 100644 --- a/contracts/streaming/src/lib.rs +++ b/contracts/streaming/src/lib.rs @@ -12,10 +12,14 @@ pub enum DataKey { NextId, /// Stream struct keyed by ID. Stored in Persistent. Stream(u64), - /// List of stream IDs where address is the sender. Stored in Persistent. + /// Active stream IDs where address is the sender. Stored in Persistent. SentBy(Address), - /// List of stream IDs where address is the recipient. Stored in Persistent. + /// Active stream IDs where address is the recipient. Stored in Persistent. ReceivedBy(Address), + /// Archived (completed/cancelled) stream IDs where address is the sender. + ArchiveSentBy(Address), + /// Archived (completed/cancelled) stream IDs where address is the recipient. + ArchiveReceivedBy(Address), } // ─── Types ─────────────────────────────────────────────────────────────────── @@ -311,6 +315,8 @@ impl StreamingContract { } stream.withdrawn_amount += amount; + let fully_drained = stream.withdrawn_amount >= stream.deposited_amount + && env.ledger().timestamp() >= stream.end_time; env.storage() .persistent() @@ -318,6 +324,14 @@ impl StreamingContract { Self::extend_stream_ttl(&env, stream_id); + // When a stream is fully drained after end_time, move it to the archive. + if fully_drained { + Self::remove_from_index(&env, DataKey::SentBy(stream.sender.clone()), stream_id); + Self::push_to_index(&env, DataKey::ArchiveSentBy(stream.sender.clone()), stream_id); + Self::remove_from_index(&env, DataKey::ReceivedBy(stream.recipient.clone()), stream_id); + Self::push_to_index(&env, DataKey::ArchiveReceivedBy(stream.recipient.clone()), stream_id); + } + let token_client = token::Client::new(&env, &stream.token); token_client.transfer( &env.current_contract_address(), @@ -356,6 +370,12 @@ impl StreamingContract { Self::extend_stream_ttl(&env, stream_id); + // Move from active to archive indexes. + Self::remove_from_index(&env, DataKey::SentBy(stream.sender.clone()), stream_id); + Self::push_to_index(&env, DataKey::ArchiveSentBy(stream.sender.clone()), stream_id); + Self::remove_from_index(&env, DataKey::ReceivedBy(stream.recipient.clone()), stream_id); + Self::push_to_index(&env, DataKey::ArchiveReceivedBy(stream.recipient.clone()), stream_id); + let token_client = token::Client::new(&env, &stream.token); // Send unlocked remainder to recipient (if any). @@ -452,6 +472,73 @@ impl StreamingContract { .unwrap_or(0) } + /// Get paginated archived stream IDs where `address` is the sender. + pub fn get_archived_sent_streams(env: Env, address: Address, offset: u32, limit: u32) -> Vec { + let all: Vec = env + .storage() + .persistent() + .get(&DataKey::ArchiveSentBy(address)) + .unwrap_or(Vec::new(&env)); + let start = core::cmp::min(offset, all.len()); + let end = core::cmp::min(offset + limit, all.len()); + let mut result = Vec::new(&env); + let mut i = start; + while i < end { + result.push_back(all.get(i).unwrap()); + i += 1; + } + result + } + + /// Get paginated archived stream IDs where `address` is the recipient. + pub fn get_archived_received_streams(env: Env, address: Address, offset: u32, limit: u32) -> Vec { + let all: Vec = env + .storage() + .persistent() + .get(&DataKey::ArchiveReceivedBy(address)) + .unwrap_or(Vec::new(&env)); + let start = core::cmp::min(offset, all.len()); + let end = core::cmp::min(offset + limit, all.len()); + let mut result = Vec::new(&env); + let mut i = start; + while i < end { + result.push_back(all.get(i).unwrap()); + i += 1; + } + result + } + + /// Manually remove a completed or cancelled stream's data and index entries. + /// + /// Either party (sender or recipient) may call this. The stream must be + /// cancelled or fully drained before cleanup is allowed. + pub fn cleanup_stream(env: Env, caller: Address, stream_id: u64) { + caller.require_auth(); + + let stream = Self::load_stream(&env, stream_id); + + // Only sender or recipient may clean up. + if caller != stream.sender && caller != stream.recipient { + panic!("only sender or recipient may clean up a stream"); + } + + let fully_drained = stream.withdrawn_amount >= stream.deposited_amount + && env.ledger().timestamp() >= stream.end_time; + + if !stream.cancelled && !fully_drained { + panic!("stream must be cancelled or fully completed before cleanup"); + } + + // Remove from all indexes (active + archive). + Self::remove_from_index(&env, DataKey::SentBy(stream.sender.clone()), stream_id); + Self::remove_from_index(&env, DataKey::ArchiveSentBy(stream.sender.clone()), stream_id); + Self::remove_from_index(&env, DataKey::ReceivedBy(stream.recipient.clone()), stream_id); + Self::remove_from_index(&env, DataKey::ArchiveReceivedBy(stream.recipient.clone()), stream_id); + + // Delete stream data to reclaim storage. + env.storage().persistent().remove(&DataKey::Stream(stream_id)); + } + // ── Write: Bump TTL ────────────────────────────────────────────────────── /// Extend the TTL of a stream's persistent storage without modifying data. diff --git a/hooks/use-form-draft.ts b/hooks/use-form-draft.ts new file mode 100644 index 0000000..aaac4c6 --- /dev/null +++ b/hooks/use-form-draft.ts @@ -0,0 +1,98 @@ +'use client' + +import { useEffect, useRef, useCallback } from 'react' + +const DRAFT_TTL_MS = 24 * 60 * 60 * 1000 + +interface DraftEntry { + data: T + savedAt: number +} + +export function useFormDraft( + key: string, + value: T, + onChange: (draft: T) => void, + enabled = true, +) { + const debounceRef = useRef | null>(null) + const isRestoringRef = useRef(false) + + const storageKey = `flowstar_draft_${key}` + + const save = useCallback( + (data: T) => { + try { + const entry: DraftEntry = { data, savedAt: Date.now() } + localStorage.setItem(storageKey, JSON.stringify(entry)) + } catch { + // storage quota exceeded or unavailable — silently skip + } + }, + [storageKey], + ) + + const discard = useCallback(() => { + localStorage.removeItem(storageKey) + }, [storageKey]) + + const loadDraft = useCallback((): { data: T; savedAt: number } | null => { + try { + const raw = localStorage.getItem(storageKey) + if (!raw) return null + const entry: DraftEntry = JSON.parse(raw) + if (Date.now() - entry.savedAt > DRAFT_TTL_MS) { + localStorage.removeItem(storageKey) + return null + } + return entry + } catch { + return null + } + }, [storageKey]) + + const restore = useCallback(() => { + const entry = loadDraft() + if (!entry) return + isRestoringRef.current = true + onChange(entry.data) + setTimeout(() => { + isRestoringRef.current = false + }, 0) + }, [loadDraft, onChange]) + + // Debounced auto-save on value changes + useEffect(() => { + if (!enabled || isRestoringRef.current) return + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => save(value), 500) + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current) + } + }, [value, enabled, save]) + + return { loadDraft, restore, discard } +} + +export function clearExpiredDrafts(prefix = 'flowstar_draft_') { + try { + const keysToRemove: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (!k?.startsWith(prefix)) continue + try { + const raw = localStorage.getItem(k) + if (!raw) continue + const entry: DraftEntry = JSON.parse(raw) + if (Date.now() - entry.savedAt > DRAFT_TTL_MS) { + keysToRemove.push(k) + } + } catch { + keysToRemove.push(k!) + } + } + keysToRemove.forEach((k) => localStorage.removeItem(k)) + } catch { + // localStorage unavailable + } +}