diff --git a/app/app/analytics/page.tsx b/app/app/analytics/page.tsx index b5df2fa..3360b3c 100644 --- a/app/app/analytics/page.tsx +++ b/app/app/analytics/page.tsx @@ -17,8 +17,14 @@ import { SectionErrorBoundary } from '@/components/error-boundary/section-error- import { useStreams } from '@/hooks/use-streams' import { useNetwork } from '@/components/providers/network-provider' import { getAllTokens } from '@/lib/stellar' -import { formatCompactAmount, SECONDS_PER_DAY } from '@/lib/stream-utils' -import type { StreamData } from '@/types/stream' +import { + formatCompactAmount, + getStreamStatus, + getUnlockedAmount, + SECONDS_PER_DAY, +} from '@/lib/stream-utils' +import { getFederationNameForAddress } from '@/lib/address-book' +import type { StreamData, StreamStatus } from '@/types/stream' const AnalyticsCharts = dynamic( () => import('@/components/analytics/charts').then((m) => m.AnalyticsCharts), @@ -50,9 +56,20 @@ interface AnalyticsSnapshot { averageDurationDays: number /** Fix #367 — `decimals` is now included so amounts format correctly for any token. */ tokenShares: Array<{ symbol: string; amount: bigint; count: number; decimals: number }> - series: Array<{ label: string; count: number }> + series: Array<{ label: string; count: number; volume: number }> /** Fix #367 — `decimals` is now included so amounts format correctly for any token. */ topTokens: Array<{ symbol: string; amount: bigint; count: number; decimals: number }> + /** Issue #152: stream counts grouped by lifecycle status, for the status breakdown chart. */ + statusBreakdown: Array<{ status: StreamStatus; count: number }> + /** Issue #152: highest-volume recipients, for the "Top Recipients" table. */ + topRecipients: Array<{ + address: string + federationName: string | null + count: number + totals: Array<{ symbol: string; amount: bigint; decimals: number }> + }> + /** Issue #152: aggregate unlocked vs. deposited across the filtered streams. */ + unlockProgress: { unlocked: bigint; deposited: bigint } } const RANGE_OPTIONS = [ @@ -106,18 +123,78 @@ function buildSnapshot(streams: StreamData[], range: string): AnalyticsSnapshot decimals: entry.decimals, })) - const seriesMap = new Map() + const seriesMap = new Map() filtered.forEach((stream) => { const day = new Date(Number(stream.startTime) * 1000).toISOString().slice(0, 10) - seriesMap.set(day, (seriesMap.get(day) ?? 0) + 1) + const entry = seriesMap.get(day) ?? { count: 0, volume: 0 } + entry.count += 1 + // Issue #152: per-day streamed volume for the "volume over time" area chart. + // Mixes tokens as display-unit floats (same simplification `totalVolume` + // already makes with raw bigints) since this is a directional trend chart. + entry.volume += Number(stream.depositedAmount) / 10 ** stream.token.decimals + seriesMap.set(day, entry) }) const series = Array.from(seriesMap.entries()) .sort(([a], [b]) => a.localeCompare(b)) - .map(([label, count]) => ({ label, count })) + .map(([label, entry]) => ({ label, count: entry.count, volume: entry.volume })) const topTokens = [...tokenShares].sort((a, b) => Number(b.amount - a.amount)).slice(0, 4) + // Issue #152: stream status breakdown (scheduled / streaming / completed / cancelled) + const statusOrder: StreamStatus[] = ['streaming', 'scheduled', 'completed', 'cancelled'] + const statusCounts = new Map() + filtered.forEach((stream) => { + const streamStatus = getStreamStatus(stream, now) + statusCounts.set(streamStatus, (statusCounts.get(streamStatus) ?? 0) + 1) + }) + const statusBreakdown = statusOrder.map((streamStatus) => ({ + status: streamStatus, + count: statusCounts.get(streamStatus) ?? 0, + })) + + // Issue #152: top recipients by streamed volume, with Federation names when known. + const recipientGroups = new Map< + string, + { count: number; totals: Map } + >() + filtered.forEach((stream) => { + const entry = recipientGroups.get(stream.recipient) ?? { + count: 0, + totals: new Map(), + } + entry.count += 1 + const tokenTotal = entry.totals.get(stream.token.symbol) ?? { + amount: 0n, + decimals: stream.token.decimals, + } + tokenTotal.amount += stream.depositedAmount + entry.totals.set(stream.token.symbol, tokenTotal) + recipientGroups.set(stream.recipient, entry) + }) + const topRecipients = Array.from(recipientGroups.entries()) + .map(([address, entry]) => ({ + address, + federationName: getFederationNameForAddress(address), + count: entry.count, + totals: Array.from(entry.totals.entries()).map(([symbol, t]) => ({ + symbol, + amount: t.amount, + decimals: t.decimals, + })), + })) + .sort((a, b) => b.count - a.count) + .slice(0, 5) + + // Issue #152: aggregate unlock progress across all filtered streams. + const unlockProgress = filtered.reduce( + (acc, stream) => ({ + unlocked: acc.unlocked + getUnlockedAmount(stream, now), + deposited: acc.deposited + stream.depositedAmount, + }), + { unlocked: 0n, deposited: 0n }, + ) + return { totalVolume, activeCount, @@ -126,6 +203,9 @@ function buildSnapshot(streams: StreamData[], range: string): AnalyticsSnapshot tokenShares, series, topTokens, + statusBreakdown, + topRecipients, + unlockProgress, } } @@ -226,6 +306,9 @@ export default function AnalyticsPage() { topTokens={snapshot.topTokens} tokenShares={snapshot.tokenShares} totalVolume={snapshot.totalVolume} + statusBreakdown={snapshot.statusBreakdown} + topRecipients={snapshot.topRecipients} + unlockProgress={snapshot.unlockProgress} /> diff --git a/app/app/create/create-form.tsx b/app/app/create/create-form.tsx index 979971f..809eb54 100644 --- a/app/app/create/create-form.tsx +++ b/app/app/create/create-form.tsx @@ -10,6 +10,7 @@ import { Loader2, Copy, Clock, + CheckCircle2, } from "lucide-react"; import Link from "next/link"; import { toast } from "sonner"; @@ -37,6 +38,7 @@ import { getAddressBookEntries, touchAddressBookEntry, } from "@/lib/address-book"; +import { isFederationAddress, resolveFederationAddress } from "@/lib/federation"; import { buildNextRunAt, saveRecurringRule, @@ -197,6 +199,71 @@ export function CreateForm() { }; }); + // Issue #155: Federation address (name*domain.com) support for the + // recipient field. `recipientInput` is exactly what the user typed + // (either a raw G-address or a Federation address); `form.recipient` + // always holds the resolved G-address actually used for the transaction. + const [recipientInput, setRecipientInput] = useState(form.recipient); + const [federationStatus, setFederationStatus] = useState< + "idle" | "loading" | "resolved" | "error" + >("idle"); + const [federationError, setFederationError] = useState(null); + const [federationResolved, setFederationResolved] = useState<{ + federationAddress: string; + accountId: string; + } | null>(null); + + useEffect(() => { + const raw = recipientInput.trim(); + + if (!raw) { + setFederationStatus("idle"); + setFederationError(null); + setFederationResolved(null); + set("recipient", ""); + return; + } + + if (!isFederationAddress(raw)) { + setFederationStatus("idle"); + setFederationError(null); + setFederationResolved(null); + set("recipient", raw); + return; + } + + setFederationStatus("loading"); + setFederationError(null); + let cancelled = false; + const timer = setTimeout(() => { + resolveFederationAddress(raw) + .then((result) => { + if (cancelled) return; + setFederationResolved({ + federationAddress: result.federationAddress, + accountId: result.accountId, + }); + setFederationStatus("resolved"); + set("recipient", result.accountId); + }) + .catch((err) => { + if (cancelled) return; + setFederationResolved(null); + setFederationStatus("error"); + setFederationError( + err instanceof Error ? err.message : "Federation lookup failed", + ); + set("recipient", ""); + }); + }, 500); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recipientInput]); + const [errors, setErrors] = useState< Partial> >({}); @@ -301,6 +368,7 @@ export function CreateForm() { form, (draft) => { setForm(draft); + setRecipientInput(draft.recipient); }, true, ); @@ -355,12 +423,19 @@ export function CreateForm() { function validate(): boolean { const newErrors: Partial> = {}; - // Issue #28: use StrKey for proper Stellar address validation - if ( + // Issue #155: block submission while a Federation address is still resolving + if (federationStatus === "loading") { + newErrors.recipient = "Still resolving the Federation address…"; + } else if (federationStatus === "error") { + newErrors.recipient = federationError ?? "Could not resolve Federation address"; + } else if ( + // Issue #28: use StrKey for proper Stellar address validation !form.recipient.trim() || !StrKey.isValidEd25519PublicKey(form.recipient.trim()) ) { - newErrors.recipient = "Invalid Stellar address format"; + newErrors.recipient = isFederationAddress(recipientInput.trim()) + ? "Federation address did not resolve to a valid Stellar account" + : "Invalid Stellar address format"; } // Issue #103: require warning acknowledgment for unfunded accounts if ( @@ -808,15 +883,40 @@ export function CreateForm() { Recipient
- - set("recipient", e.target.value)} - aria-invalid={!!errors.recipient} - className="font-mono text-xs" - /> + +
+ setRecipientInput(e.target.value)} + aria-invalid={!!errors.recipient} + className="font-mono text-xs pr-8" + /> + {federationStatus === "loading" && ( + + )} + {federationStatus === "resolved" && ( + + )} +
+ {/* Issue #155: Federation address (name*domain.com) resolution */} + {federationStatus === "resolved" && federationResolved && ( +
+ +
+

+ {federationResolved.federationAddress} resolved +

+

+ {federationResolved.accountId} +

+
+
+ )} + {federationStatus === "error" && federationError && ( +

{federationError}

+ )}
diff --git a/app/app/stream/[id]/page.tsx b/app/app/stream/[id]/page.tsx index 2abe08d..991cd7e 100644 --- a/app/app/stream/[id]/page.tsx +++ b/app/app/stream/[id]/page.tsx @@ -17,6 +17,7 @@ import { Share2, MessageCircle, Send, + QrCode, } from "lucide-react"; import { toast } from "sonner"; import { ConnectWalletButton } from "@/components/layout/connect-wallet-button"; @@ -66,15 +67,20 @@ import { UnlockChart } from "@/components/streams/unlock-chart"; import { StreamTimeline } from "@/components/streams/stream-timeline"; import { DownloadReceiptButton } from "@/components/streams/download-receipt-button"; import { bumpStreamTtl } from "@/lib/contract"; +import { getFederationNameForAddress } from "@/lib/address-book"; +import { QrShareDialog } from "@/components/streams/qr-share-dialog"; // ─── Address copy button ──────────────────────────────────────────────────── function CopyableAddress({ address, href, + federationName, }: { address: string; href?: string; + /** Issue #155: known Federation name (e.g. alice*domain.com) for this address, if any. */ + federationName?: string | null; }) { const [copied, setCopied] = useState(false); function copy() { @@ -88,10 +94,11 @@ function CopyableAddress({ type="button" onClick={copy} aria-label="Copy address" + title={federationName ? address : undefined} className="group inline-flex items-center gap-1.5 font-mono text-sm hover:text-primary transition-colors" > - {shortenAddress(address, 6)} + {federationName ?? shortenAddress(address, 6)} {copied ? ( @@ -807,9 +814,17 @@ function StreamDetailSkeleton() { // ─── Main page ─────────────────────────────────────────────────────────────── -function ShareButtons({ streamId }: { streamId: string }) { +function ShareButtons({ + stream, + status, +}: { + stream: import("@/types/stream").StreamData; + status: import("@/types/stream").StreamStatus; +}) { + const streamId = stream.id; const [copied, setCopied] = useState(false); const [showShare, setShowShare] = useState(false); + const [showQr, setShowQr] = useState(false); const streamUrl = typeof window !== "undefined" @@ -878,9 +893,29 @@ function ShareButtons({ streamId }: { streamId: string }) { Telegram +
)} + + {/* Issue #153: QR code sharing modal */} + ); } @@ -978,7 +1013,7 @@ function StreamDetail({ id }: { id: string }) { Dashboard - + {/* Connect prompt for unauthenticated visitors */} @@ -1148,6 +1183,7 @@ function StreamDetail({ id }: { id: string }) { diff --git a/components/analytics/charts.tsx b/components/analytics/charts.tsx index 4179b97..7db2f59 100644 --- a/components/analytics/charts.tsx +++ b/components/analytics/charts.tsx @@ -1,12 +1,28 @@ 'use client' +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Cell, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' -import { formatTokenAmount } from '@/lib/stream-utils' +import { formatTokenAmount, shortenAddress } from '@/lib/stream-utils' +import type { StreamStatus } from '@/types/stream' interface SeriesPoint { label: string count: number + volume: number } interface TokenShare { @@ -16,55 +32,303 @@ interface TokenShare { decimals: number } -/** Minimum bar width in pixels so very small values are still visible */ -const MIN_BAR_WIDTH_PX = 8 +interface RecipientSummary { + address: string + federationName: string | null + count: number + totals: Array<{ symbol: string; amount: bigint; decimals: number }> +} + +/** Minimum visible width (%) for progress/share bars so small values don't disappear */ +const MIN_BAR_WIDTH_PCT = 1.5 + +// Shared chart color tokens (defined once in globals.css, so they already +// render correctly in both light and dark themes). +const CHART_COLORS = [ + 'var(--color-chart-1)', + 'var(--color-chart-2)', + 'var(--color-chart-3)', + 'var(--color-chart-4)', + 'var(--color-chart-5)', +] + +const STATUS_LABEL: Record = { + streaming: 'Streaming', + scheduled: 'Scheduled', + completed: 'Completed', + cancelled: 'Cancelled', +} + +const STATUS_COLOR: Record = { + streaming: 'var(--color-chart-1)', + scheduled: 'var(--color-chart-3)', + completed: 'var(--color-chart-5)', + cancelled: 'var(--color-chart-4)', +} interface Props { series: SeriesPoint[] topTokens: TokenShare[] tokenShares: TokenShare[] totalVolume: bigint + statusBreakdown: Array<{ status: StreamStatus; count: number }> + topRecipients: RecipientSummary[] + unlockProgress: { unlocked: bigint; deposited: bigint } } -export function AnalyticsCharts({ series, topTokens, tokenShares, totalVolume }: Props) { - const maxCount = Math.max(...series.map((p) => p.count)) +/** Tooltip content shared by the recharts-based charts, styled to match the app's cards. */ +function ChartTooltip({ + active, + payload, + label, + formatter, +}: { + active?: boolean + payload?: Array<{ name?: string; value?: number | string; color?: string }> + label?: string + formatter?: (name: string, value: number | string) => string +}) { + if (!active || !payload?.length) return null + return ( +
+ {label &&

{label}

} + {payload.map((entry, i) => ( +

+ + {formatter && entry.name !== undefined && entry.value !== undefined + ? formatter(entry.name, entry.value) + : `${entry.name}: ${entry.value}`} +

+ ))} +
+ ) +} + +export function AnalyticsCharts({ + series, + topTokens, + tokenShares, + totalVolume, + statusBreakdown, + topRecipients, + unlockProgress, +}: Props) { + const hasStatusData = statusBreakdown.some((s) => s.count > 0) + const unlockPct = + unlockProgress.deposited > 0n + ? Number((unlockProgress.unlocked * 10000n) / unlockProgress.deposited) / 100 + : 0 return ( <> -
+ {/* Streaming volume over time — interactive area chart */} + + + Streaming volume over time + + Total tokens deposited into streams created per day, for the selected window. + + + + {series.length === 0 ? ( +

No stream activity yet for this period.

+ ) : ( + + + + + + + + + + + + + name === 'volume' + ? `Volume: ${Number(value).toLocaleString('en-US', { maximumFractionDigits: 2 })}` + : `${name}: ${value}` + } + /> + } + /> + + + + )} +
+
+ +
+ {/* Token distribution — donut chart */} - Streams created over time - - Daily stream creation activity for the selected window. - + Token distribution + Streams grouped by token across the current dataset. -
- {series.length === 0 ? ( -

- No stream activity yet for this period. -

- ) : ( - series.map((point) => ( -
-
- {point.label} - {point.count} -
-
-
0 ? (point.count / maxCount) * 100 : 0}%` }} - /> + {tokenShares.length === 0 ? ( +

No token distribution data available yet.

+ ) : ( +
+ + + Number(d.amount)} + nameKey="symbol" + innerRadius={50} + outerRadius={80} + paddingAngle={2} + strokeWidth={2} + stroke="var(--color-card)" + > + {tokenShares.map((entry, i) => ( + + ))} + + { + const share = tokenShares.find((t) => t.symbol === name) + return share + ? `${name}: ${formatTokenAmount(share.amount, share.decimals)}` + : `${name}: ${value}` + }} + /> + } + /> + + +
+ {tokenShares.map((token, i) => ( +
+ + + {token.symbol} + + + {formatTokenAmount(token.amount, token.decimals)} +
+ ))} +
+
+ )} + + + + {/* Stream status breakdown — bar chart */} + + + Stream status breakdown + Active, scheduled, completed, and cancelled streams. + + + {!hasStatusData ? ( +

No streams to break down yet.

+ ) : ( + + ({ ...s, name: STATUS_LABEL[s.status] }))} + margin={{ top: 8, right: 8, left: 0, bottom: 0 }} + > + + + + `${name}: ${value}`} />} /> + + {statusBreakdown.map((entry) => ( + + ))} + + + + )} +
+
+
+ +
+ {/* Top recipients */} + + + Top recipients + Addresses receiving the most streams in this window. + + + {topRecipients.length === 0 ? ( +

No recipients yet.

+ ) : ( + topRecipients.map((recipient) => ( +
+
+

+ {recipient.federationName ?? shortenAddress(recipient.address, 5)} +

+

+ {recipient.count} stream{recipient.count === 1 ? '' : 's'} +

- )) - )} -
+
+ {recipient.totals.map((t) => ( + + {formatTokenAmount(t.amount, t.decimals, 2)} {t.symbol} + + ))} +
+
+ )) + )} + {/* Top tokens by volume (kept from the original list view) */} Top tokens by volume @@ -93,40 +357,31 @@ export function AnalyticsCharts({ series, topTokens, tokenShares, totalVolume }:
-
- - - Token distribution - Visible token mix across the current dataset. - - - {tokenShares.length === 0 ? ( -

- No token distribution data available yet. -

- ) : ( - tokenShares.map((token) => ( -
-
- {token.symbol} - - {formatTokenAmount(token.amount, token.decimals)} - -
-
-
-
-
- )) - )} - - -
+ {/* Unlock progress — aggregate progress bar */} + + + Unlock progress + Total unlocked vs. locked across all streams in this window. + + + {unlockProgress.deposited === 0n ? ( +

No deposits to track yet.

+ ) : ( +
+
+ Unlocked + {unlockPct.toFixed(1)}% +
+
+
0 ? MIN_BAR_WIDTH_PCT : 0, unlockPct)}%` }} + /> +
+
+ )} + + ) } @@ -134,14 +389,16 @@ export function AnalyticsCharts({ series, topTokens, tokenShares, totalVolume }: export function ChartSkeleton() { return (
-
-
-
+
+
+
+
+
) } diff --git a/components/streams/qr-share-dialog.tsx b/components/streams/qr-share-dialog.tsx new file mode 100644 index 0000000..3056259 --- /dev/null +++ b/components/streams/qr-share-dialog.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Copy, Check, Download } from "lucide-react"; +import { QRCodeSVG, QRCodeCanvas } from "qrcode.react"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { StreamStatusBadge } from "@/components/streams/stream-status-badge"; +import { formatTokenAmount, shortenAddress } from "@/lib/stream-utils"; +import type { StreamData, StreamStatus } from "@/types/stream"; + +interface QrShareDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + streamUrl: string; + stream: StreamData; + status: StreamStatus; +} + +/** + * Issue #153 — QR code generation for stream sharing. + * + * Shows an SVG QR code (crisp at any size) encoding the stream URL, plus a + * hidden canvas rendering of the same QR used only to produce a PNG for + * download. Built on the existing `Dialog` primitive (@base-ui/react), which + * already provides a focus trap and Escape-to-dismiss. + */ +export function QrShareDialog({ + open, + onOpenChange, + streamUrl, + stream, + status, +}: QrShareDialogProps) { + const [copied, setCopied] = useState(false); + const canvasRef = useRef(null); + + function copyLink() { + navigator.clipboard.writeText(streamUrl); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + toast.success("Link copied to clipboard"); + } + + function downloadPng() { + const canvas = canvasRef.current; + if (!canvas) return; + const link = document.createElement("a"); + link.download = `flowstar-stream-${stream.id}-qr.png`; + link.href = canvas.toDataURL("image/png"); + link.click(); + toast.success("QR code downloaded"); + } + + return ( + + + + Share stream + + Scan this QR code to open stream #{stream.id} on any device. + + + +
+
+ +
+ {/* Hidden higher-resolution canvas, used only for the PNG download. */} + + +
+ + {streamUrl} + + +
+ +
+ +
+ +
+
+ Amount + + {formatTokenAmount(stream.depositedAmount, stream.token.decimals, 2)}{" "} + {stream.token.symbol} + +
+
+ Recipient + {shortenAddress(stream.recipient, 5)} +
+
+ Status + +
+
+
+
+
+ ); +} diff --git a/components/streams/stream-card.tsx b/components/streams/stream-card.tsx index c3d03e8..e50b2b6 100644 --- a/components/streams/stream-card.tsx +++ b/components/streams/stream-card.tsx @@ -19,6 +19,7 @@ import { TokenAmount } from '@/components/ui/token-amount' import { CountdownTimer } from '@/components/ui/countdown-timer' import { AccessibleCountdownTimer } from '@/components/ui/accessible-countdown-timer' import { StreamStatusBadge } from '@/components/streams/stream-status-badge' +import { getFederationNameForAddress } from '@/lib/address-book' import type { StreamData } from '@/types/stream' // Pick update interval based on a quick pre-check of stream state. @@ -56,6 +57,9 @@ function StreamCardInner({ stream, selectable, selected, onToggleSelect }: Strea const rate = formatRate(stream.amountPerSecond, stream.token.decimals, stream.token.symbol) const isOutgoing = address === stream.sender const counterparty = isOutgoing ? stream.recipient : stream.sender + // Issue #155: show a known Federation name (e.g. alice*domain.com) for the + // counterparty address when one was resolved earlier in the address book. + const counterpartyFederationName = getFederationNameForAddress(counterparty) const direction = isOutgoing ? 'Sending' : 'Receiving' const displayAmount = formatTokenAmount(stream.depositedAmount, stream.token.decimals, 2) const ariaLabel = `${direction} ${displayAmount} ${stream.token.symbol}, ${status}, ${(progress * 100).toFixed(0)}% unlocked` @@ -108,8 +112,11 @@ function StreamCardInner({ stream, selectable, selected, onToggleSelect }: Strea

{stream.metadata?.name ?? (isOutgoing ? 'Sending to' : 'Receiving from')}

-

- {shortenAddress(counterparty, 5)} +

+ {counterpartyFederationName ?? shortenAddress(counterparty, 5)}

diff --git a/e2e/analytics.spec.ts b/e2e/analytics.spec.ts index 531b993..e25a1f6 100644 --- a/e2e/analytics.spec.ts +++ b/e2e/analytics.spec.ts @@ -87,13 +87,21 @@ test.describe('Analytics page — chart rendering with mock data', () => { await expect(activeCard.locator('text=3')).toBeVisible() }) - test('renders the "Streams created over time" chart with data bars', async ({ + test('renders the "Streaming volume over time" chart with data', async ({ page, }) => { - await expect(page.locator('text=Streams created over time')).toBeVisible() + await expect(page.locator('text=Streaming volume over time')).toBeVisible() await expect(page.locator('text=No stream activity yet for this period.')).not.toBeVisible() }) + test('renders the stream status breakdown and top recipients sections', async ({ + page, + }) => { + await expect(page.locator('text=Stream status breakdown')).toBeVisible() + await expect(page.locator('text=Top recipients')).toBeVisible() + await expect(page.locator('text=Unlock progress')).toBeVisible() + }) + test('renders "Top tokens by volume" with the seeded token symbols', async ({ page, }) => { diff --git a/lib/address-book.ts b/lib/address-book.ts index cf221a5..9e5beb2 100644 --- a/lib/address-book.ts +++ b/lib/address-book.ts @@ -3,6 +3,8 @@ export interface AddressBookEntry { label: string address: string lastUsed: number + /** Federation address (e.g. `alice*stellarx.com`) this `address` resolved from, if any. */ + federationAddress?: string } const STORAGE_KEY = 'flowstar:address-book' @@ -35,6 +37,7 @@ export function addAddressBookEntry(entry: Omit item.address !== normalized.address)].slice(0, 50) writeEntries(next) @@ -53,16 +56,38 @@ export function deleteAddressBookEntry(id: string) { writeEntries(entries) } -export function touchAddressBookEntry(address: string, label?: string) { +export function touchAddressBookEntry(address: string, label?: string, federationAddress?: string) { const entries = readEntries() const existing = entries.find((entry) => entry.address === address) if (existing) { const next = entries.map((entry) => - entry.address === address ? { ...entry, label: label?.trim() || entry.label, lastUsed: Date.now() } : entry, + entry.address === address + ? { + ...entry, + label: label?.trim() || entry.label, + lastUsed: Date.now(), + federationAddress: federationAddress?.trim() || entry.federationAddress, + } + : entry, ) writeEntries(next) return next.find((entry) => entry.address === address) ?? null } if (!address.trim()) return null - return addAddressBookEntry({ label: label?.trim() || 'Saved recipient', address }) + return addAddressBookEntry({ label: label?.trim() || 'Saved recipient', address, federationAddress }) +} + +/** + * Reverse Federation lookup: returns the Federation name previously resolved + * for `address` (stored in the address book when it was first looked up), + * or `null` if this G-address has no known Federation name. + * + * This mirrors what real reverse-Federation lookups do in practice — the + * protocol has no universal "resolve name for account" endpoint without + * already knowing the domain, so FlowStar remembers the mapping locally the + * first time a name is resolved forward. + */ +export function getFederationNameForAddress(address: string): string | null { + const entry = readEntries().find((item) => item.address === address && item.federationAddress) + return entry?.federationAddress ?? null } diff --git a/lib/federation.ts b/lib/federation.ts new file mode 100644 index 0000000..4450412 --- /dev/null +++ b/lib/federation.ts @@ -0,0 +1,81 @@ +import { Federation, StrKey } from '@stellar/stellar-sdk' + +/** + * Stellar Federation addresses look like `name*domain.com` — a human-readable + * alias that resolves to a `G…` account via the domain's `stellar.toml` / + * `federation.json` server. + * + * @see https://developers.stellar.org/docs/learn/encyclopedia/network-configuration/federation + */ +const FEDERATION_ADDRESS_RE = /^[^*\s]+\*[^*\s]+\.[^*\s]+$/ + +/** Returns `true` when `value` has the `name*domain.tld` shape of a Federation address. */ +export function isFederationAddress(value: string): boolean { + return FEDERATION_ADDRESS_RE.test(value.trim()) +} + +export interface FederationLookupResult { + /** The original Federation address that was resolved (e.g. `alice*stellarx.com`). */ + federationAddress: string + /** The resolved Stellar account ID (`G…`). */ + accountId: string + /** Optional memo type the domain wants attached to payments (e.g. `'text'`, `'id'`). */ + memoType?: string + /** Optional memo value that should accompany payments to this address. */ + memo?: string +} + +/** Raised when a Federation address can't be resolved to an account. */ +export class FederationLookupError extends Error { + constructor(message: string) { + super(message) + this.name = 'FederationLookupError' + } +} + +/** + * Resolves a Federation address (`name*domain.com`) to a Stellar account via + * the standard Federation protocol: fetch `domain/.well-known/stellar.toml`, + * find the `FEDERATION_SERVER` entry, then query it for the name. + * + * Throws {@link FederationLookupError} with a user-facing message on any + * failure (invalid domain, name not found, network error, or a malformed + * response). + */ +export async function resolveFederationAddress(value: string): Promise { + const trimmed = value.trim() + if (!isFederationAddress(trimmed)) { + throw new FederationLookupError( + 'Not a valid Federation address — expected the form name*domain.com', + ) + } + + let record + try { + record = await Federation.Server.resolve(trimmed) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (/not\s*found|404/i.test(message)) { + throw new FederationLookupError(`No Federation record found for "${trimmed}"`) + } + if (/toml|federation server/i.test(message)) { + throw new FederationLookupError( + `"${trimmed.split('*')[1]}" does not publish a Federation server (missing stellar.toml)`, + ) + } + throw new FederationLookupError( + `Could not resolve "${trimmed}" — the domain may be unreachable`, + ) + } + + if (!record?.account_id || !StrKey.isValidEd25519PublicKey(record.account_id)) { + throw new FederationLookupError(`Federation server for "${trimmed}" returned an invalid account`) + } + + return { + federationAddress: trimmed, + accountId: record.account_id, + memoType: record.memo_type, + memo: record.memo, + } +} diff --git a/package-lock.json b/package-lock.json index cfaaade..c1848c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,8 +22,10 @@ "lucide-react": "^1.16.0", "next": "16.2.6", "next-themes": "^0.4.6", + "qrcode.react": "^4.2.0", "react": "^19", "react-dom": "^19", + "recharts": "^3.10.1", "shadcn": "^4.8.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", @@ -3477,6 +3479,32 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@resvg/resvg-wasm": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.4.0.tgz", @@ -4400,6 +4428,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@stellar/freighter-api": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@stellar/freighter-api/-/freighter-api-6.0.1.tgz", @@ -4934,6 +4974,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -5033,6 +5136,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -6886,6 +6995,127 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -6964,6 +7194,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -7721,7 +7957,6 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, "license": "MIT" }, "node_modules/events": { @@ -8679,6 +8914,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8733,6 +8978,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -11327,6 +11581,15 @@ "node": ">=10.13.0" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -11439,6 +11702,29 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -11494,6 +11780,36 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -11508,6 +11824,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -13537,6 +13868,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/package.json b/package.json index d5b781d..ec034fa 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,10 @@ "lucide-react": "^1.16.0", "next": "16.2.6", "next-themes": "^0.4.6", + "qrcode.react": "^4.2.0", "react": "^19", "react-dom": "^19", + "recharts": "^3.10.1", "shadcn": "^4.8.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1",