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
10 changes: 10 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@
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
# ─── Error tracking (Sentry) ──────────────────────────────────────────────────
# Get your DSN from https://sentry.io → Project → Client Keys.
# The NEXT_PUBLIC_ prefix makes it available in the browser bundle.
Expand Down
71 changes: 71 additions & 0 deletions .github/workflows/staging.yml
Original file line number Diff line number Diff line change
@@ -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).`
})
153 changes: 145 additions & 8 deletions app/app/create/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -28,6 +27,7 @@ import { CreateConfirmation } from '@/components/streams/create-confirmation'
import { TxPreviewDialog } from '@/components/ui/tx-preview-dialog'
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'

Expand All @@ -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 },
Expand Down Expand Up @@ -127,6 +164,15 @@ function CreateForm() {
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
const [selectedTemplateId, setSelectedTemplateId] = useState<string | undefined>(undefined)

// Issue #168: draft state
const [showDraftBanner, setShowDraftBanner] = useState(false)
const [draftSavedAt, setDraftSavedAt] = useState<number | null>(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]
Expand All @@ -142,6 +188,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)')
Expand Down Expand Up @@ -271,6 +340,7 @@ function CreateForm() {
})
}

discard()
setShowConfirmation(false)
toast.success('Stream created', { description: `Stream #${id} is live.` })
router.push(`/app/stream/${id}`)
Expand Down Expand Up @@ -339,6 +409,45 @@ function CreateForm() {
</div>
)}

{/* Issue #168: Draft restore banner */}
{showDraftBanner && (
<div className="mt-4 flex items-center justify-between gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3">
<div className="flex items-center gap-2.5">
<Clock className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
<p className="text-sm text-amber-700 dark:text-amber-300">
You have an unsaved draft from{' '}
{draftSavedAt
? new Date(draftSavedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: 'earlier'}
.
</p>
</div>
<div className="flex gap-2 shrink-0">
<Button
size="sm"
variant="outline"
onClick={() => {
restore()
setShowDraftBanner(false)
toast.success('Draft restored')
}}
>
Restore
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
discard()
setShowDraftBanner(false)
}}
>
Discard
</Button>
</div>
</div>
)}

<div className="mt-6 grid gap-8 lg:grid-cols-[1fr_320px]">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Token + Amount */}
Expand Down Expand Up @@ -519,9 +628,31 @@ function CreateForm() {
Schedule
</h2>

{/* Issue #170: timezone selector */}
<div className="space-y-1.5">
<Label htmlFor="timezone" className="text-xs text-muted-foreground">
Timezone — dates below are interpreted in this timezone ({timezoneOffset})
</Label>
<Select value={selectedTimezone} onValueChange={setSelectedTimezone}>
<SelectTrigger id="timezone" className="w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMMON_TIMEZONES.map((tz) => (
<SelectItem key={tz} value={tz} className="text-xs">
{tz}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="startDate">Start date</Label>
<Label htmlFor="startDate">
Start date{' '}
<span className="font-normal text-muted-foreground text-xs">({timezoneOffset})</span>
</Label>
<Input
id="startDate"
type="datetime-local"
Expand All @@ -531,7 +662,10 @@ function CreateForm() {
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="endDate">End date</Label>
<Label htmlFor="endDate">
End date{' '}
<span className="font-normal text-muted-foreground text-xs">({timezoneOffset})</span>
</Label>
<Input
id="endDate"
type="datetime-local"
Expand Down Expand Up @@ -624,7 +758,10 @@ function CreateForm() {
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="cliffDate">Cliff date</Label>
<Label htmlFor="cliffDate">
Cliff date{' '}
<span className="font-normal text-muted-foreground text-xs">({timezoneOffset})</span>
</Label>
<Input
id="cliffDate"
type="datetime-local"
Expand Down
14 changes: 12 additions & 2 deletions app/app/stream/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -958,7 +958,12 @@ function StreamDetail({ id }: { id: string }) {
<RateDisplay stream={stream} />
</DetailRow>
<DetailRow label="Start">
{formatDateTime(stream.startTime)}
<span>
{formatDateTime(stream.startTime)}
<span className="ml-1.5 text-xs text-muted-foreground">
({new Date(Number(stream.startTime) * 1000).toUTCString().replace(' GMT', ' UTC')})
</span>
</span>
</DetailRow>
{stream.cliffTime > stream.startTime && (
<DetailRow label="Cliff">
Expand All @@ -971,7 +976,12 @@ function StreamDetail({ id }: { id: string }) {
</DetailRow>
)}
<DetailRow label="End">
{formatDateTime(stream.endTime)}
<span>
{formatDateTime(stream.endTime)}
<span className="ml-1.5 text-xs text-muted-foreground">
({new Date(Number(stream.endTime) * 1000).toUTCString().replace(' GMT', ' UTC')})
</span>
</span>
</DetailRow>
<DetailRow label="Network">
<span className="capitalize">{network}</span>
Expand Down
Loading
Loading