diff --git a/frontend/app/app/page.tsx b/frontend/app/app/page.tsx index 6e16a93..237252b 100644 --- a/frontend/app/app/page.tsx +++ b/frontend/app/app/page.tsx @@ -1,7 +1,5 @@ -"use client"; - import { WalletEntry } from "@/components/wallet/wallet-entry"; -export default function AppPage() { +export default function AppDashboardPage() { return ; } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 667deb4..456a425 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -360,6 +360,21 @@ body { background: rgba(255, 255, 255, 0.95); } +.secondary-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.secondary-button--danger { + border-color: rgba(177, 47, 63, 0.34); + color: #8f2a38; + background: rgba(255, 241, 244, 0.76); +} + +.secondary-button--danger:hover { + background: rgba(255, 227, 234, 0.94); +} + .loading-pulse { width: 2.05rem; height: 2.05rem; @@ -585,6 +600,15 @@ body { padding: 0.94rem; } +.dashboard-panel--stream-builder { + padding: clamp(0.95rem, 1.8vw, 1.35rem); + background: linear-gradient( + 155deg, + rgba(255, 255, 255, 0.82), + rgba(245, 252, 255, 0.68) + ); +} + .dashboard-panel__header { display: flex; justify-content: space-between; @@ -595,12 +619,14 @@ body { .dashboard-panel__header h3 { margin: 0; - font-size: 1.02rem; + font-size: clamp(1rem, 1.1vw, 1.16rem); + letter-spacing: 0.01em; } .dashboard-panel__header span { - font-size: 0.8rem; + font-size: 0.82rem; color: #4b6a89; + font-weight: 500; } .activity-list { @@ -697,6 +723,243 @@ body { justify-content: flex-end; } +.stream-template-layout { + display: grid; + grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); + gap: clamp(0.85rem, 1.3vw, 1.2rem); + margin-top: 1rem; + align-items: start; +} + +.stream-template-manager { + border: 1px solid rgba(19, 38, 61, 0.12); + border-radius: 0.95rem; + background: rgba(255, 255, 255, 0.78); + padding: clamp(0.75rem, 1.1vw, 0.95rem); + display: grid; + gap: 0.85rem; + box-shadow: 0 10px 24px rgba(13, 83, 120, 0.08); +} + +.stream-template-manager h4 { + margin: 0; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.09em; + color: #365979; +} + +.stream-template-manager p { + margin: 0; + font-size: 0.83rem; + color: #4a6785; + line-height: 1.52; + max-width: 34ch; +} + +.stream-template-editor { + display: grid; + gap: 0.56rem; +} + +.stream-template-editor input { + width: 100%; + border: 1px solid rgba(19, 38, 61, 0.16); + border-radius: 0.7rem; + height: 2.48rem; + padding: 0 0.76rem; + background: rgba(255, 255, 255, 0.88); + font-size: 0.9rem; +} + +.stream-template-editor__actions { + display: flex; + gap: 0.45rem; + flex-wrap: wrap; +} + +.stream-template-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.58rem; + max-height: 420px; + overflow: auto; + padding-right: 0.28rem; +} + +.stream-template-item { + border: 1px solid rgba(19, 38, 61, 0.12); + border-radius: 0.82rem; + padding: 0.68rem; + background: rgba(255, 255, 255, 0.9); + display: grid; + gap: 0.6rem; + transition: border-color 140ms ease, box-shadow 140ms ease, + transform 140ms ease; +} + +.stream-template-item:hover { + transform: translateY(-1px); + border-color: rgba(15, 122, 153, 0.28); + box-shadow: 0 8px 16px rgba(13, 83, 120, 0.08); +} + +.stream-template-item[data-active="true"] { + border-color: rgba(15, 122, 153, 0.42); + box-shadow: inset 0 0 0 1px rgba(15, 122, 153, 0.18); +} + +.stream-template-item__meta { + display: grid; + gap: 0.18rem; +} + +.stream-template-item__meta strong { + font-size: 0.92rem; + line-height: 1.35; +} + +.stream-template-item__meta small { + color: #4f6f8e; + font-size: 0.75rem; +} + +.stream-template-item__actions { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} + +.stream-template-item__actions .secondary-button { + height: 1.92rem; + padding: 0 0.62rem; + font-size: 0.79rem; + border-radius: 0.62rem; +} + +.stream-form { + border: 1px solid rgba(19, 38, 61, 0.12); + border-radius: 0.95rem; + background: rgba(255, 255, 255, 0.84); + padding: clamp(0.82rem, 1.2vw, 1.05rem); + display: grid; + gap: 0.82rem; + box-shadow: 0 10px 24px rgba(13, 83, 120, 0.08); +} + +.stream-form-message { + margin: 0.75rem 0 0; + border-radius: 0.72rem; + padding: 0.62rem 0.75rem; + font-size: 0.87rem; + line-height: 1.4; +} + +.stream-form-message[data-tone="info"] { + background: rgba(15, 122, 153, 0.11); + color: #0e587c; +} + +.stream-form-message[data-tone="success"] { + background: rgba(15, 124, 82, 0.12); + color: #0f6d4a; +} + +.stream-form-message[data-tone="error"] { + background: rgba(177, 47, 63, 0.12); + color: #8f2a38; +} + +.stream-form__meta { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.9rem; + padding: 0.12rem 0 0.22rem; + border-bottom: 1px solid rgba(19, 38, 61, 0.1); +} + +.stream-form__meta h4 { + margin: 0; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.09em; + color: #365979; +} + +.stream-form__meta p { + margin: 0.38rem 0 0.22rem; + font-size: 0.79rem; + color: #4f6d8b; + font-weight: 500; +} + +.stream-form__template-select { + width: min(280px, 100%); +} + +.stream-form label { + display: grid; + gap: 0.42rem; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #3f607e; + font-weight: 600; +} + +.stream-form input, +.stream-form textarea, +.stream-form select { + width: 100%; + border: 1px solid rgba(19, 38, 61, 0.16); + border-radius: 0.68rem; + min-height: 2.48rem; + padding: 0.62rem 0.76rem; + background: rgba(255, 255, 255, 0.9); + color: #1b3859; + font-size: 0.9rem; + text-transform: none; + letter-spacing: normal; + transition: border-color 140ms ease, box-shadow 140ms ease; +} + +.stream-form input:focus, +.stream-form textarea:focus, +.stream-form select:focus, +.stream-template-editor input:focus { + outline: none; + border-color: rgba(15, 122, 153, 0.46); + box-shadow: 0 0 0 3px rgba(15, 122, 153, 0.14); +} + +.stream-form textarea { + resize: vertical; + min-height: 6.2rem; +} + +.stream-form__row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.78rem; +} + +.stream-form__actions { + display: flex; + gap: 0.62rem; + align-items: center; + margin-top: 0.14rem; + padding-top: 0.28rem; + border-top: 1px solid rgba(19, 38, 61, 0.1); +} + +.stream-form__actions .wallet-button { + min-width: 168px; + margin-top: 0; +} + @media (max-width: 640px) { .wallet-panel { border-radius: 1.15rem; @@ -752,6 +1015,58 @@ body { .dashboard-actions { justify-content: flex-start; } + + .stream-form__row { + grid-template-columns: 1fr; + } + + .stream-form__meta { + flex-direction: column; + } + + .stream-form__template-select { + width: 100%; + } +} + +@media (max-width: 980px) { + .stream-template-layout { + grid-template-columns: 1fr; + } + + .stream-template-list { + max-height: 300px; + } +} + +@media (max-width: 600px) { + .dashboard-panel--stream-builder { + padding: 0.8rem; + } + + .stream-template-manager, + .stream-form { + border-radius: 0.82rem; + padding: 0.72rem; + } + + .stream-form input, + .stream-form textarea, + .stream-form select, + .stream-template-editor input { + font-size: 0.88rem; + } + + .stream-form__actions { + flex-direction: column; + align-items: stretch; + } + + .stream-form__actions .wallet-button, + .stream-form__actions .secondary-button { + width: 100%; + min-width: 0; + } } @keyframes panel-rise { diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 93022b7..f92b217 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -41,58 +41,44 @@ export default function RootLayout({ enableSystem={false} disableTransitionOnChange > - - -
-
-
- - FlowFi - -
- -
-
- {children} - - {children} -
- + +
+
+
+ + FlowFi + +
+ +
+
+ + {children} +
diff --git a/frontend/components/dashboard/dashboard-view.tsx b/frontend/components/dashboard/dashboard-view.tsx index f984db1..330cfd7 100644 --- a/frontend/components/dashboard/dashboard-view.tsx +++ b/frontend/components/dashboard/dashboard-view.tsx @@ -21,6 +21,31 @@ interface SidebarItem { label: string; } +interface StreamFormValues { + recipient: string; + token: string; + totalAmount: string; + startsAt: string; + endsAt: string; + cadenceSeconds: string; + note: string; +} + +interface StreamTemplate { + id: string; + name: string; + createdAt: string; + updatedAt: string; + values: StreamFormValues; +} + +type StreamFormMessageTone = "info" | "success" | "error"; + +interface StreamFormMessageState { + text: string; + tone: StreamFormMessageTone; +} + const SIDEBAR_ITEMS: SidebarItem[] = [ { id: "overview", label: "Overview" }, { id: "incoming", label: "Incoming" }, @@ -30,6 +55,18 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ { id: "settings", label: "Settings" }, ]; +const STREAM_TEMPLATES_STORAGE_KEY = "flowfi.stream.templates.v1"; + +const EMPTY_STREAM_FORM: StreamFormValues = { + recipient: "", + token: "USDC", + totalAmount: "", + startsAt: "", + endsAt: "", + cadenceSeconds: "1", + note: "", +}; + function formatCurrency(value: number): string { return new Intl.NumberFormat("en-US", { style: "currency", @@ -233,36 +270,123 @@ function renderRecentActivity(snapshot: DashboardSnapshot) { ); } +function safeLoadTemplates(): StreamTemplate[] { + if (typeof window === "undefined") { + return []; + } + + const stored = window.localStorage.getItem(STREAM_TEMPLATES_STORAGE_KEY); + if (!stored) { + return []; + } + + try { + const parsed = JSON.parse(stored); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter((item): item is StreamTemplate => { + return ( + typeof item?.id === "string" && + typeof item?.name === "string" && + typeof item?.createdAt === "string" && + typeof item?.updatedAt === "string" && + typeof item?.values === "object" && + typeof item.values?.recipient === "string" && + typeof item.values?.token === "string" && + typeof item.values?.totalAmount === "string" && + typeof item.values?.startsAt === "string" && + typeof item.values?.endsAt === "string" && + typeof item.values?.cadenceSeconds === "string" && + typeof item.values?.note === "string" + ); + }); + } catch { + return []; + } +} + +function persistTemplates(templates: StreamTemplate[]) { + if (typeof window === "undefined") { + return; + } + + window.localStorage.setItem( + STREAM_TEMPLATES_STORAGE_KEY, + JSON.stringify(templates), + ); +} + +function formatTemplateUpdatedAt(timestamp: string): string { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return "Unknown"; + } + + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function createTemplateId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + + return `template-${Date.now()}`; +} + export function DashboardView({ session, onDisconnect }: DashboardViewProps) { const [activeTab, setActiveTab] = React.useState("overview"); - const [showWizard, setShowWizard] = React.useState(false); - const [stats, setStats] = React.useState(null); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - const [prevKey, setPrevKey] = React.useState(session.publicKey); - - // Reset loading state during render if key changes - if (session.publicKey !== prevKey) { - setPrevKey(session.publicKey); - setLoading(true); - } + const [streamForm, setStreamForm] = React.useState( + EMPTY_STREAM_FORM, + ); + const [templates, setTemplates] = React.useState([]); + const [templatesHydrated, setTemplatesHydrated] = React.useState(false); + const [templateNameInput, setTemplateNameInput] = React.useState(""); + const [editingTemplateId, setEditingTemplateId] = React.useState< + string | null + >(null); + const [selectedTemplateId, setSelectedTemplateId] = React.useState< + string | null + >(null); + const [streamFormMessage, setStreamFormMessage] = + React.useState(null); + const stats = getMockDashboardStats(session.walletId); React.useEffect(() => { - async function loadData() { - try { - setError(null); - const data = await fetchDashboardData(session.publicKey); - setStats(data); - } catch (err) { - setError("Failed to load dashboard data. Please check your connection to the FlowFi backend."); - console.error(err); - } finally { - setLoading(false); - } + const loadedTemplates = safeLoadTemplates(); + setTemplates(loadedTemplates); + setTemplatesHydrated(true); + }, []); + + React.useEffect(() => { + if (!templatesHydrated) { + return; } + persistTemplates(templates); + }, [templates, templatesHydrated]); - loadData(); - }, [session.publicKey]); + const updateStreamForm = (field: keyof StreamFormValues, value: string) => { + setStreamForm((previous) => ({ ...previous, [field]: value })); + setStreamFormMessage(null); + }; + + const requiredFieldsCompleted = [ + streamForm.recipient, + streamForm.token, + streamForm.totalAmount, + streamForm.startsAt, + streamForm.endsAt, + ].filter((value) => value.trim().length > 0).length; + + const saveTemplateButtonLabel = editingTemplateId + ? "Update Template" + : "Save as Template"; + + const isTemplateNameValid = templateNameInput.trim().length > 0; const handleTopUp = (streamId: string) => { const amount = prompt(`Enter amount to add to stream ${streamId}:`); @@ -273,19 +397,148 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { } }; - const handleCreateStream = async (data: StreamFormData) => { - console.log("Creating stream with data:", data); - // TODO: Integrate with Soroban contract's create_stream function - // This would involve: - // 1. Converting duration to seconds - // 2. Calling the contract's create_stream function - // 3. Handling the transaction signing - // 4. Waiting for confirmation - - // For now, simulate success - await new Promise((resolve) => setTimeout(resolve, 1500)); - alert(`Stream created successfully!\n\nRecipient: ${data.recipient}\nToken: ${data.token}\nAmount: ${data.amount}\nDuration: ${data.duration} ${data.durationUnit}`); - setShowWizard(false); + const handleApplyTemplate = (templateId: string) => { + const template = templates.find((item) => item.id === templateId); + if (!template) { + return; + } + + setStreamForm({ ...template.values }); + setSelectedTemplateId(template.id); + setStreamFormMessage({ + text: `Applied template "${template.name}". You can still adjust any field.`, + tone: "success", + }); + }; + + const handleDeleteTemplate = (templateId: string) => { + const template = templates.find((item) => item.id === templateId); + if (!template) { + return; + } + + const shouldDelete = window.confirm( + `Delete stream template "${template.name}"?`, + ); + if (!shouldDelete) { + return; + } + + setTemplates((previous) => previous.filter((item) => item.id !== templateId)); + if (selectedTemplateId === templateId) { + setSelectedTemplateId(null); + } + if (editingTemplateId === templateId) { + setEditingTemplateId(null); + setTemplateNameInput(""); + } + }; + + const handleSaveTemplate = () => { + const cleanedName = templateNameInput.trim(); + if (!cleanedName) { + setStreamFormMessage({ + text: "Template name is required.", + tone: "error", + }); + return; + } + + const now = new Date().toISOString(); + + if (editingTemplateId) { + setTemplates((previous) => + previous.map((template) => + template.id === editingTemplateId + ? { + ...template, + name: cleanedName, + updatedAt: now, + values: { ...streamForm }, + } + : template, + ), + ); + setStreamFormMessage({ + text: `Template "${cleanedName}" updated.`, + tone: "success", + }); + setSelectedTemplateId(editingTemplateId); + setEditingTemplateId(null); + setTemplateNameInput(""); + return; + } + + const newTemplate: StreamTemplate = { + id: createTemplateId(), + name: cleanedName, + createdAt: now, + updatedAt: now, + values: { ...streamForm }, + }; + + setTemplates((previous) => [newTemplate, ...previous]); + setSelectedTemplateId(newTemplate.id); + setTemplateNameInput(""); + setStreamFormMessage({ + text: `Template "${cleanedName}" saved.`, + tone: "success", + }); + }; + + const handleEditTemplate = (templateId: string) => { + const template = templates.find((item) => item.id === templateId); + if (!template) { + return; + } + + setEditingTemplateId(template.id); + setTemplateNameInput(template.name); + setSelectedTemplateId(template.id); + setStreamForm({ ...template.values }); + setStreamFormMessage({ + text: `Editing template "${template.name}". Save to overwrite it.`, + tone: "info", + }); + }; + + const handleClearTemplateEditor = () => { + setEditingTemplateId(null); + setTemplateNameInput(""); + setStreamFormMessage(null); + }; + + const handleCreateStream = (event: React.FormEvent) => { + event.preventDefault(); + + const hasRequiredFields = + streamForm.recipient.trim() && + streamForm.token.trim() && + streamForm.totalAmount.trim() && + streamForm.startsAt.trim() && + streamForm.endsAt.trim(); + + if (!hasRequiredFields) { + setStreamFormMessage({ + text: "Complete all required fields before creating.", + tone: "error", + }); + return; + } + + alert( + `Stream prepared for ${streamForm.recipient} with ${streamForm.totalAmount} ${streamForm.token}. You can still edit any field before final submission integration.`, + ); + setStreamFormMessage({ + text: "Stream draft is ready for submission integration.", + tone: "success", + }); + }; + + const handleResetStreamForm = () => { + setStreamForm(EMPTY_STREAM_FORM); + setSelectedTemplateId(null); + setStreamFormMessage(null); }; const renderContent = () => { @@ -293,6 +546,247 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { return
; } + if (activeTab === "streams") { + return ( +
+
+
+

Create Stream

+ Save and reuse recurring configurations +
+ + {streamFormMessage ? ( +

+ {streamFormMessage.text} +

+ ) : null} + +
+
+

Template Library

+

+ Save recurring stream settings once, apply instantly, then + override before submitting. +

+ +
+ setTemplateNameInput(event.target.value)} + placeholder="e.g. Monthly Contributor Payroll" + aria-label="Template name" + /> +
+ + {editingTemplateId ? ( + + ) : null} +
+
+ + {templates.length === 0 ? ( +
+

No templates yet. Save your first stream setup.

+
+ ) : ( +
    + {templates.map((template) => ( +
  • +
    + {template.name} + + Updated {formatTemplateUpdatedAt(template.updatedAt)} + +
    +
    + + + +
    +
  • + ))} +
+ )} +
+ +
+
+
+

Stream Configuration

+

{requiredFieldsCompleted} / 5 required fields completed

+
+ +
+ + + +
+ + + +
+ +
+ + + +
+ + + +