From ab02162e31567b6136eed5b206a852647b50dd4d Mon Sep 17 00:00:00 2001 From: "Demilade." Date: Sun, 26 Apr 2026 08:15:26 +0000 Subject: [PATCH 1/4] feat(frontend): extract ChatInterface component from page layout add dedicated ChatInterface component with typed props move chat header, messages, typing indicator, and input form out of page move auto-scroll ref/effect logic into ChatInterface keep message sending behavior and chat styling consistent Closes #30 --- frontend/src/app/components/ChatInterface.tsx | 129 ++++++++++++++++ frontend/src/app/globals.css | 8 +- frontend/src/app/page.tsx | 146 +++--------------- 3 files changed, 153 insertions(+), 130 deletions(-) create mode 100644 frontend/src/app/components/ChatInterface.tsx diff --git a/frontend/src/app/components/ChatInterface.tsx b/frontend/src/app/components/ChatInterface.tsx new file mode 100644 index 0000000..3f696ff --- /dev/null +++ b/frontend/src/app/components/ChatInterface.tsx @@ -0,0 +1,129 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { AlertCircle, Bot, CheckCircle2, Send } from "lucide-react"; + +export interface ChatMessage { + id: number; + sender: "agent" | "user"; + text: string; + proactive?: boolean; + timestamp?: string; +} + +export interface ChatInterfaceProps { + messages: ChatMessage[]; + isTyping: boolean; + onSendMessage: (message: string) => void; + placeholder?: string; +} + +export function ChatInterface({ + messages, + isTyping, + onSendMessage, + placeholder = "Ask Smasage to adjust goals...", +}: ChatInterfaceProps) { + const [inputValue, setInputValue] = useState(""); + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, isTyping]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = inputValue.trim(); + if (!trimmed) return; + + onSendMessage(trimmed); + setInputValue(""); + }; + + return ( +
+
+ +
+

OpenClaw Agent

+
+
+
+
+ +
+ {messages.map((msg) => ( +
+ {msg.proactive && ( +
+
+ )} +
{msg.text}
+
+ ))} + + {isTyping && ( +
+ Agent is typing... + +
+ )} + +
+
+ +
+ setInputValue(e.target.value)} + aria-label="Message input" + /> + +
+
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index e23dfc7..deee427 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -472,7 +472,9 @@ h2 { .agent .message-bubble { background: rgba(255, 255, 255, 0.05); - border: 1px solid var(--border); + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); border-bottom-left-radius: 4px; color: var(--text-main); } @@ -501,7 +503,7 @@ h2 { border-radius: 12px; border-bottom-left-radius: 4px; width: fit-content; - border: 1px solid var(--border); + border: 1px solid rgba(255, 255, 255, 0.1); } .typing-indicator span { @@ -621,7 +623,7 @@ h2 { outline: none; /* replaced by explicit ring below */ border-color: var(--accent-primary); background: rgba(0, 0, 0, 0.45); - box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15), inset 0 0 8px rgba(139, 92, 246, 0.1); + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15); } .chat-input-container input:focus-visible { diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 26add5a..c3daaf1 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,13 +1,6 @@ "use client"; -import React, { useState, useEffect, useRef, useMemo } from "react"; -import { - Bot, - Send, - Target, - Activity, - CheckCircle2, - AlertCircle, -} from "lucide-react"; +import React, { useState, useEffect, useMemo } from "react"; +import { Target, Activity } from "lucide-react"; import { PortfolioStats } from "./components/PortfolioStats"; import { evaluateGoalStatus, @@ -35,14 +28,7 @@ import { PortfolioChartSkeleton, } from "./components/SkeletonLoader"; import { WalletModal } from "./components/WalletModal"; - -interface Message { - id: number; - sender: "agent" | "user"; - text: string; - proactive?: boolean; - timestamp?: string; -} +import { ChatInterface, type ChatMessage } from "./components/ChatInterface"; export default function Home() { const { @@ -53,14 +39,13 @@ export default function Home() { isConnecting } = useFreighter(); - const [messages, setMessages] = useState([ + const [messages, setMessages] = useState([ { id: 1, sender: "agent", text: "Welcome to Smasage! πŸ‘‹ I'm OpenClaw, your personal AI savings assistant natively built on Stellar. What financial goal can we crush today?", }, ]); - const [inputState, setInputState] = useState(""); const [isTyping, setIsTyping] = useState(false); const [allocations, setAllocations] = useState( @@ -69,7 +54,6 @@ export default function Home() { const [wsConnected, setWsConnected] = useState(false); const [isLoading, setIsLoading] = useState(true); - const messagesEndRef = useRef(null); // Goal data (Memoized to avoid unnecessary effect triggers) const goalData = useMemo(() => ({ @@ -100,14 +84,14 @@ export default function Home() { } else if (isAgentMessageNotification(notification)) { // payload is fully typed as AgentMessagePayload β€” no cast needed const { text, proactive, timestamp } = notification.payload; - const agentMsg: Message = { + const agentMsg: ChatMessage = { id: Date.now(), sender: "agent", text, proactive, timestamp, }; - setMessages((prev) => [...prev, agentMsg]); + setMessages((prev: ChatMessage[]) => [...prev, agentMsg]); // Parse allocations if present const parsedAllocations = parseAllocationsFromMessage(text); @@ -128,11 +112,6 @@ export default function Home() { return () => clearTimeout(t); }, []); - // Auto scroll - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages, isTyping]); - // Register goal with notification server on mount useEffect(() => { if (wsConnected) { @@ -146,28 +125,27 @@ export default function Home() { } }, [wsConnected, registerGoal, goalData]); - const handleSendMessage = (e: React.FormEvent) => { - e.preventDefault(); - if (!inputState.trim()) return; + const handleSendMessage = (message: string) => { + const trimmed = message.trim(); + if (!trimmed) return; - const userMsg: Message = { + const userMsg: ChatMessage = { id: Date.now(), sender: "user", - text: inputState, + text: trimmed, }; - setMessages((prev) => [...prev, userMsg]); - setInputState(""); + setMessages((prev: ChatMessage[]) => [...prev, userMsg]); setIsTyping(true); // Mock agent response delay setTimeout(() => { setIsTyping(false); - const agentMsg: Message = { + const agentMsg: ChatMessage = { id: Date.now() + 1, sender: "agent", text: "That's a great goal. I'll allocate 60% to Stellar Blend for safe yield, and the rest to Soroswap XLM/USDC LP to accelerate returns. Does that sound good?", }; - setMessages((prev) => [...prev, agentMsg]); + setMessages((prev: ChatMessage[]) => [...prev, agentMsg]); // Parse allocations from agent message const parsedAllocations = parseAllocationsFromMessage(agentMsg.text); @@ -299,97 +277,11 @@ export default function Home() { {/* Right Panel - Chat Agent */}
-
-
- -
-

- OpenClaw Agent -

-
-
-
-
- -
- {messages.map((msg) => ( -
- {msg.proactive && ( -
-
- )} -
{msg.text}
-
- ))} - {isTyping && ( -
- Agent is typing… - -
- )} -
-
- -
- setInputState(e.target.value)} - aria-label="Message input" - /> - -
-
+
From df613b358cbb0decd1dc3f591dae747b3afba6ba Mon Sep 17 00:00:00 2001 From: "Demilade." Date: Sun, 26 Apr 2026 08:23:51 +0000 Subject: [PATCH 2/4] feat(frontend): create reusable Button component system add typed Button component with primary and secondary variants implement built-in loading state with centered spinner and disabled behavior support standard HTML button attributes for accessibility and reuse refactor ConnectWalletButton and WalletModal close action to use shared Button Closes #33 --- frontend/src/app/components/Button.tsx | 49 ++++++++ .../app/components/ConnectWalletButton.tsx | 32 ++--- frontend/src/app/components/WalletModal.tsx | 6 +- frontend/src/app/globals.css | 116 ++++++++++-------- frontend/src/app/page.tsx | 10 +- 5 files changed, 138 insertions(+), 75 deletions(-) create mode 100644 frontend/src/app/components/Button.tsx diff --git a/frontend/src/app/components/Button.tsx b/frontend/src/app/components/Button.tsx new file mode 100644 index 0000000..f677a01 --- /dev/null +++ b/frontend/src/app/components/Button.tsx @@ -0,0 +1,49 @@ +import type { ButtonHTMLAttributes, JSX } from "react"; + +export type ButtonVariant = "primary" | "secondary"; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + isLoading?: boolean; + loadingLabel?: string; +} + +export function Button({ + variant = "primary", + isLoading = false, + disabled, + className, + children, + loadingLabel = "Loading", + ...rest +}: ButtonProps): JSX.Element { + const classes = ["btn", `btn-${variant}`, className].filter(Boolean).join(" "); + + return ( + + ); +} diff --git a/frontend/src/app/components/ConnectWalletButton.tsx b/frontend/src/app/components/ConnectWalletButton.tsx index 06d9fd7..ea54d81 100644 --- a/frontend/src/app/components/ConnectWalletButton.tsx +++ b/frontend/src/app/components/ConnectWalletButton.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Loader2 } from 'lucide-react'; +import { Button } from './Button'; export interface ConnectWalletButtonProps { onClick: () => void; @@ -12,10 +12,10 @@ function truncatePublicKey(key: string) { return key.slice(0, 4) + '...' + key.slice(-4); } -export const ConnectWalletButton: React.FC = ({ - onClick, - publicKey, - isConnecting = false +export const ConnectWalletButton: React.FC = ({ + onClick, + publicKey, + isConnecting = false }) => { const ariaLabel = isConnecting ? 'Connecting wallet, please wait' @@ -24,22 +24,16 @@ export const ConnectWalletButton: React.FC = ({ : 'Connect Stellar wallet'; return ( - + {publicKey ? truncatePublicKey(publicKey) : 'Connect Wallet'} + ); }; diff --git a/frontend/src/app/components/WalletModal.tsx b/frontend/src/app/components/WalletModal.tsx index 9735e30..e9702fe 100644 --- a/frontend/src/app/components/WalletModal.tsx +++ b/frontend/src/app/components/WalletModal.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef } from 'react'; import { X } from 'lucide-react'; +import { Button } from './Button'; interface WalletModalProps { isOpen: boolean; @@ -114,12 +115,13 @@ export const WalletModal: React.FC = ({ isOpen, onClose }) => Install Freighter - +
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index deee427..abe5ad7 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -133,54 +133,93 @@ body { animation: ws-blink 1.2s step-start infinite; } -/* Connect Wallet Button */ -.connect-wallet-btn { - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); - color: white; - border: none; - padding: 0.6rem 1.25rem; +/* Button System */ +.btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.65rem; border-radius: 12px; - font-weight: 600; + border: 1px solid transparent; + padding: 0.72rem 1.25rem; font-size: 0.9rem; + font-weight: 600; + line-height: 1; + color: var(--text-main); cursor: pointer; - transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + user-select: none; + transition: transform 0.18s ease, box-shadow 0.25s ease, background 0.25s ease, border-color 0.25s ease, filter 0.2s ease; +} + +.btn:active:not(:disabled) { + transform: scale(0.95); +} + +.btn:disabled { + cursor: not-allowed; + opacity: 0.8; +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + border-color: rgba(255, 255, 255, 0.1); + color: #fff; box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3); - display: flex; - align-items: center; - gap: 0.75rem; - position: relative; - overflow: hidden; } -.connect-wallet-btn:hover:not(:disabled) { - transform: translateY(-2px); - filter: brightness(1.1); - box-shadow: 0 6px 16px rgba(139, 92, 246, 0.4); +.btn-primary:hover:not(:disabled) { + background: linear-gradient(135deg, #9f75ff, #22d3ee); + box-shadow: 0 0 20px rgba(139, 92, 246, 0.4); + filter: saturate(1.1); } -.connect-wallet-btn:active:not(:disabled) { - transform: translateY(0) scale(0.98); +.btn-secondary { + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); } -.connect-wallet-btn:disabled { - cursor: not-allowed; - opacity: 0.8; +.btn-secondary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); + color: #fff; } -.connect-wallet-btn.connecting { - background: linear-gradient(135deg, #4c1d95, #164e63); /* Darker/dimmed version */ - pointer-events: none; +.btn-content { + display: inline-flex; + align-items: center; + justify-content: center; + transition: opacity 0.18s ease; +} + +.btn-content.is-hidden { + opacity: 0; } -.connect-wallet-btn .spinner { - animation: rotate 2s linear infinite; +.btn-loader-wrap { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; } -@keyframes rotate { +.btn-loader { + width: 1rem; + height: 1rem; + animation: btn-spin 0.85s linear infinite; +} + +@keyframes btn-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } +.connect-wallet-btn { + min-width: 140px; +} + /* Base Layout Container */ .app-container { display: grid; @@ -989,29 +1028,8 @@ h2 { } .close-modal { - display: block; width: 100%; - background: transparent; - color: var(--text-muted); - border: 1px solid var(--border); - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; margin-top: 1rem; - font-weight: 500; - transition: all 0.2s ease; -} - -.close-modal:hover { - background: rgba(255, 255, 255, 0.05); - color: #fff; - border-color: rgba(255, 255, 255, 0.2); -} - -.close-modal:focus-visible { - outline: 2px solid var(--accent-primary); - outline-offset: 3px; - background: rgba(255, 255, 255, 0.05); } /* ── WS status indicator animations (Issue #48) ─────────────── */ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index c3daaf1..fdc3973 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -31,12 +31,12 @@ import { WalletModal } from "./components/WalletModal"; import { ChatInterface, type ChatMessage } from "./components/ChatInterface"; export default function Home() { - const { - publicKey, - connect, - showInstallModal, + const { + publicKey, + connect, + showInstallModal, setShowInstallModal, - isConnecting + isConnecting } = useFreighter(); const [messages, setMessages] = useState([ From 9c65e33274eb4f4d47d1f489ea238a84c337638a Mon Sep 17 00:00:00 2001 From: "Demilade." Date: Sun, 26 Apr 2026 08:27:17 +0000 Subject: [PATCH 3/4] style(frontend): improve mobile responsiveness across dashboard and chat refine tablet breakpoint at 1024px for tighter layout and panel sizing add 768px mobile rules for chat spacing, message/input text scaling, and card density enforce 44px minimum touch targets for primary interactive controls prevent horizontal scrolling and make modal sizing mobile-safe Closes #38 --- frontend/src/app/globals.css | 124 +++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index abe5ad7..93cf287 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -48,6 +48,7 @@ body { min-height: 100vh; display: flex; flex-direction: column; + overflow-x: hidden; } /* Dashboard Header */ @@ -236,6 +237,129 @@ body { .app-container { grid-template-columns: 1fr; height: auto; + max-width: 100%; + padding: 1.5rem; + gap: 1.5rem; + } + + .glass-panel { + width: 100%; + min-width: 0; + padding: 1.5rem; + } + + .stats-grid { + gap: 1rem; + } + + .goal-section { + padding: 1.5rem; + } +} + +@media (max-width: 768px) { + .header-content { + padding: 0.85rem 1rem; + gap: 0.75rem; + } + + .brand { + gap: 0.65rem; + } + + .brand-name { + font-size: 1.25rem; + } + + .status-pill { + padding: 0.35rem 0.6rem; + } + + .app-container { + padding: 1rem; + gap: 1rem; + width: 100%; + } + + .glass-panel { + padding: 1rem; + border-radius: 18px; + } + + h1 { + font-size: 1.85rem; + } + + h2 { + font-size: 1.2rem; + margin-bottom: 1rem; + } + + .stats-grid { + grid-template-columns: 1fr; + gap: 0.85rem; + margin-bottom: 1.5rem; + } + + .stat-card, + .goal-section { + padding: 1rem; + } + + .stat-value { + font-size: 1.7rem; + } + + .chat-header { + gap: 0.75rem; + margin-bottom: 1rem; + padding-bottom: 1rem; + } + + .chat-messages { + gap: 1rem; + padding-right: 0.2rem; + } + + .message { + max-width: 92%; + } + + .message-bubble { + padding: 0.85rem 1rem; + font-size: 0.9rem; + line-height: 1.45; + } + + .chat-input-container { + margin-top: 1rem; + } + + .chat-input-container input { + font-size: 0.95rem; + padding: 0.9rem 3.5rem 0.9rem 1rem; + } + + .btn, + .send-button, + .modal-close-icon, + .install-link, + .error-retry-btn { + min-height: 44px; + } + + .btn { + padding: 0.78rem 1.05rem; + } + + .connect-wallet-btn { + min-width: 132px; + } + + .modal-content { + min-width: 0; + width: min(92vw, 420px); + padding: 2.25rem 1.25rem; } } From ef2568dd3c7ee95d30f22935b8f3481da11efc33 Mon Sep 17 00:00:00 2001 From: "Demilade." Date: Sun, 26 Apr 2026 08:31:37 +0000 Subject: [PATCH 4/4] feat(frontend): extract GoalTracker into reusable component create typed GoalTracker component for goal title, target, status, progress, and remaining amount move inline goal-section JSX out of page layout into the new component encapsulate status display handling while preserving semantic status visuals keep animated gradient progress bar and shimmer behavior unchanged Closes #29 --- frontend/src/app/components/GoalTracker.tsx | 98 +++++++++++++++++++++ frontend/src/app/page.tsx | 62 +++---------- 2 files changed, 108 insertions(+), 52 deletions(-) create mode 100644 frontend/src/app/components/GoalTracker.tsx diff --git a/frontend/src/app/components/GoalTracker.tsx b/frontend/src/app/components/GoalTracker.tsx new file mode 100644 index 0000000..8a9d6b7 --- /dev/null +++ b/frontend/src/app/components/GoalTracker.tsx @@ -0,0 +1,98 @@ +import { Target } from "lucide-react"; +import { getStatusColor, type ProjectionResult } from "../../utils/goalProjection"; + +export interface GoalTrackerProps { + goalName: string; + targetAmount: number; + targetDate: string; + status: ProjectionResult["status"]; + progressPercentage: number; + remainingAmount: number; +} + +function getStatusClass(status: ProjectionResult["status"]): string { + switch (status) { + case "Ahead": + return "ahead"; + case "On Track": + return "on-track"; + case "Falling Behind": + return "falling-behind"; + default: + return "on-track"; + } +} + +function formatCurrency(value: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }).format(value); +} + +function formatTargetDate(targetDate: string): string { + const parsed = new Date(targetDate); + if (Number.isNaN(parsed.getTime())) { + return targetDate; + } + + return parsed.toLocaleDateString("en-US", { + month: "short", + year: "numeric", + }); +} + +export function GoalTracker({ + goalName, + targetAmount, + targetDate, + status, + progressPercentage, + remainingAmount, +}: GoalTrackerProps) { + const statusColor = getStatusColor(status); + const clampedProgress = Math.max(0, Math.min(100, progressPercentage)); + + return ( +
+
+
+

{goalName}

+

+ Target: {formatCurrency(targetAmount)} by {formatTargetDate(targetDate)} +

+
+ Status: {status} +
+
+ +
+ +
+
+
+ +
+ {Math.round(clampedProgress)}% Completed + {formatCurrency(remainingAmount)} Remaining +
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index fdc3973..f5f6967 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,10 +1,9 @@ "use client"; import React, { useState, useEffect, useMemo } from "react"; -import { Target, Activity } from "lucide-react"; +import { Activity } from "lucide-react"; import { PortfolioStats } from "./components/PortfolioStats"; import { evaluateGoalStatus, - getStatusColor, type GoalData, } from "../utils/goalProjection"; import PortfolioChart from "./PortfolioChart"; @@ -29,6 +28,7 @@ import { } from "./components/SkeletonLoader"; import { WalletModal } from "./components/WalletModal"; import { ChatInterface, type ChatMessage } from "./components/ChatInterface"; +import { GoalTracker } from "./components/GoalTracker"; export default function Home() { const { @@ -193,56 +193,14 @@ export default function Home() { {isLoading ? ( ) : ( -
-
-
-

- European Vacation -

-

- Target: $18,000 by Aug 2026 -

-

- Status: {goalStatus} -

-
- -
-
-
-
-
- 68% Completed - $5,550 Remaining -
-
+ )}