diff --git a/app/app/analytics/page.tsx b/app/app/analytics/page.tsx index 3360b3c..32ad633 100644 --- a/app/app/analytics/page.tsx +++ b/app/app/analytics/page.tsx @@ -210,7 +210,10 @@ function buildSnapshot(streams: StreamData[], range: string): AnalyticsSnapshot } export default function AnalyticsPage() { - const { all } = useStreams({ enablePolling: false }) + // Issue #674: `loading` was never destructured, so the four stat cards + // below rendered misleading "0" values before real data resolved instead + // of a loading state. + const { all, loading } = useStreams({ enablePolling: false }) const { network } = useNetwork() const [range, setRange] = useState('30d') const [mounted, setMounted] = useState(false) @@ -260,9 +263,13 @@ export default function AnalyticsPage() { Total volume streamed - {snapshot.totalVolume > 0n - ? formatCompactAmount(snapshot.totalVolume, snapshot.tokenShares[0]?.decimals ?? 7) - : '0'} + {loading ? ( + + ) : snapshot.totalVolume > 0n ? ( + formatCompactAmount(snapshot.totalVolume, snapshot.tokenShares[0]?.decimals ?? 7) + ) : ( + '0' + )} @@ -272,7 +279,13 @@ export default function AnalyticsPage() { Active streams - {snapshot.activeCount} + + {loading ? ( + + ) : ( + snapshot.activeCount + )} + Currently streaming now @@ -281,7 +294,13 @@ export default function AnalyticsPage() { Total streams created - {snapshot.totalStreams} + + {loading ? ( + + ) : ( + snapshot.totalStreams + )} + All-time stream count @@ -291,7 +310,11 @@ export default function AnalyticsPage() { Average duration - {snapshot.averageDurationDays.toFixed(1)}d + {loading ? ( + + ) : ( + `${snapshot.averageDurationDays.toFixed(1)}d` + )} diff --git a/app/app/create/create-form.tsx b/app/app/create/create-form.tsx index 0008d06..ab70368 100644 --- a/app/app/create/create-form.tsx +++ b/app/app/create/create-form.tsx @@ -389,6 +389,13 @@ export function CreateForm() { setRecipientInput(draft.recipient); }, true, + // Issue #676: surface draft-save failures instead of silently + // discarding them. + () => + toast.warning("Your draft isn't being saved", { + description: + "Storage is full or unavailable — your progress won't be restored if you leave this page.", + }), ); // Check for existing draft on first mount @@ -888,7 +895,19 @@ export function CreateForm() { )} - {/* Issue #186: USD equivalent + per-second rate */} + {/* Issue #186: USD equivalent + per-second rate. + Issue #675: `priceLoading` used to be nested inside this + block, but by the time `usdEquivalent` is truthy the price + has already resolved (loading is always false here) — so + it could never actually render. Show a distinct loading + indicator whenever a price is being fetched and the user + has entered an amount, separately from the "here's the + USD value" case below. */} + {priceLoading && tokenAmountNum > 0 && !usdInputMode && ( +

+ Fetching price… +

+ )} {usdEquivalent && !usdInputMode && (

{copy.amountSection.usdEquivalent(usdEquivalent)} @@ -910,6 +929,15 @@ export function CreateForm() { )} )} +

+ )} + {/* Issue #675: distinct from the loading state above — the + price fetch has finished but no price is available for + this token (not XLM/USDC/EURC, or the fetch failed with no + cached fallback). */} + {!priceLoading && !supportsUsd && tokenAmountNum > 0 && !usdInputMode && ( +

+ Price unavailable for {selectedToken.symbol} {priceLoading && ( {copy.amountSection.fetchingPrice} diff --git a/components/streams/stream-card.tsx b/components/streams/stream-card.tsx index 1541cf1..8fe5641 100644 --- a/components/streams/stream-card.tsx +++ b/components/streams/stream-card.tsx @@ -62,7 +62,7 @@ function StreamCardInner({ const interval = getInterval(stream) const now = useNow(interval) const { address } = useWallet() - const { usdPrice } = useTokenPrice(stream.token.symbol) + const { usdPrice, loading: priceLoading } = useTokenPrice(stream.token.symbol) const [showUsd] = useShowUsd() const isCancelling = useIsStreamCancelling(stream.id) const { isBlocked, hideStream, unhideStream, blockSender, unblockSender } = useHiddenStreams() @@ -239,8 +239,15 @@ function StreamCardInner({ className="text-lg font-semibold" maxFractionDigits={2} /> - {usdValue !== null && ( -

{formatUsd(usdValue)}

+ {/* Issue #675: loading and unavailable previously rendered + identically (nothing) — show a distinct skeleton while the + price is still being fetched. */} + {showUsd && usdValue === null && priceLoading ? ( +
+ ) : ( + usdValue !== null && ( +

{formatUsd(usdValue)}

+ ) )}
diff --git a/components/webhooks/webhook-settings.tsx b/components/webhooks/webhook-settings.tsx index 8ff2c18..bf53c89 100644 --- a/components/webhooks/webhook-settings.tsx +++ b/components/webhooks/webhook-settings.tsx @@ -19,7 +19,14 @@ const ALL_EVENTS: { value: WebhookEventType; label: string }[] = [ ] export function WebhookSettings() { - const { webhooks, history, addWebhook, removeWebhook, toggleWebhook, testWebhook } = useWebhooks() + // Issue #677: surface webhook-config save failures instead of letting + // localStorage.setItem throw uncaught / fail silently. + const { webhooks, history, addWebhook, removeWebhook, toggleWebhook, testWebhook } = useWebhooks( + () => + toast.warning("Webhook settings aren't being saved", { + description: 'Storage is full or unavailable — your changes may not persist.', + }), + ) const [url, setUrl] = useState('') const [urlError, setUrlError] = useState('') diff --git a/hooks/use-form-draft.ts b/hooks/use-form-draft.ts index aaac4c6..4b157e5 100644 --- a/hooks/use-form-draft.ts +++ b/hooks/use-form-draft.ts @@ -14,9 +14,16 @@ export function useFormDraft( value: T, onChange: (draft: T) => void, enabled = true, + // Issue #676: draft-save failures (quota exceeded, private browsing) used + // to be silently discarded — the caller can surface this however fits + // (toast, inline notice, ...). Only fires once per failure streak, not on + // every debounced save, so it stays a lightweight one-time warning rather + // than spamming the user. + onSaveError?: () => void, ) { const debounceRef = useRef | null>(null) const isRestoringRef = useRef(false) + const hasWarnedRef = useRef(false) const storageKey = `flowstar_draft_${key}` @@ -25,11 +32,18 @@ export function useFormDraft( try { const entry: DraftEntry = { data, savedAt: Date.now() } localStorage.setItem(storageKey, JSON.stringify(entry)) + hasWarnedRef.current = false } catch { - // storage quota exceeded or unavailable — silently skip + // storage quota exceeded or unavailable — the draft itself is still + // silently skipped (nothing else we can do), but the user is now + // told their progress isn't being saved. + if (!hasWarnedRef.current) { + hasWarnedRef.current = true + onSaveError?.() + } } }, - [storageKey], + [storageKey, onSaveError], ) const discard = useCallback(() => { diff --git a/hooks/use-webhooks.ts b/hooks/use-webhooks.ts index 8df9388..27ba50e 100644 --- a/hooks/use-webhooks.ts +++ b/hooks/use-webhooks.ts @@ -1,6 +1,6 @@ 'use client' -import { useState, useCallback, useEffect } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' export type WebhookEventType = | 'stream.created' @@ -45,8 +45,18 @@ function loadWebhooks(): WebhookConfig[] { } } -function saveWebhooks(hooks: WebhookConfig[]) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(hooks)) +// Issue #677: these used to call localStorage.setItem unguarded, which can +// throw (quota exceeded, private browsing) and crash the settings UI +// interaction that triggered it. Now wrapped in try/catch, matching +// use-form-draft.ts's already-guarded pattern, and reports success so +// callers can surface a warning instead of silently losing the write. +function saveWebhooks(hooks: WebhookConfig[]): boolean { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(hooks)) + return true + } catch { + return false + } } function loadHistory(): WebhookDelivery[] { @@ -58,8 +68,13 @@ function loadHistory(): WebhookDelivery[] { } } -function saveHistory(history: WebhookDelivery[]) { - localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(0, MAX_HISTORY))) +function saveHistory(history: WebhookDelivery[]): boolean { + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(0, MAX_HISTORY))) + return true + } catch { + return false + } } async function deliverWithRetry( @@ -85,9 +100,27 @@ async function deliverWithRetry( return { statusCode: null, success: false } } -export function useWebhooks() { +export function useWebhooks(onSaveError?: () => void) { const [webhooks, setWebhooks] = useState([]) const [history, setHistory] = useState([]) + const hasWarnedRef = useRef(false) + + // Issue #677: only warn once per failure streak, resetting as soon as a + // save succeeds again — a lightweight one-time notice, not a toast per + // keystroke/action while storage stays unavailable. + const reportSaveResult = useCallback( + (ok: boolean) => { + if (ok) { + hasWarnedRef.current = false + return + } + if (!hasWarnedRef.current) { + hasWarnedRef.current = true + onSaveError?.() + } + }, + [onSaveError], + ) useEffect(() => { setWebhooks(loadWebhooks()) @@ -104,26 +137,26 @@ export function useWebhooks() { } setWebhooks((prev) => { const next = [...prev, hook] - saveWebhooks(next) + reportSaveResult(saveWebhooks(next)) return next }) - }, []) + }, [reportSaveResult]) const removeWebhook = useCallback((id: string) => { setWebhooks((prev) => { const next = prev.filter((h) => h.id !== id) - saveWebhooks(next) + reportSaveResult(saveWebhooks(next)) return next }) - }, []) + }, [reportSaveResult]) const toggleWebhook = useCallback((id: string) => { setWebhooks((prev) => { const next = prev.map((h) => (h.id === id ? { ...h, enabled: !h.enabled } : h)) - saveWebhooks(next) + reportSaveResult(saveWebhooks(next)) return next }) - }, []) + }, [reportSaveResult]) const fireEvent = useCallback( async (eventType: WebhookEventType, data: WebhookPayload) => { @@ -140,12 +173,12 @@ export function useWebhooks() { } setHistory((prev) => { const next = [delivery, ...prev] - saveHistory(next) + reportSaveResult(saveHistory(next)) return next }) } }, - [webhooks] + [webhooks, reportSaveResult] ) const testWebhook = useCallback(async (id: string): Promise => {