From f611546a0fe67efeb471d094210879f8b4286245 Mon Sep 17 00:00:00 2001 From: Afolami Anuoluwapo <161776085+aabxtract@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:33:58 +0000 Subject: [PATCH 1/3] feat: add invoices management page with filtering and searching capabilities feat: create dashboard layout with authentication and sidebar navigation feat: implement dashboard overview page with recent invoices and wallet information feat: add settings page for managing merchant account and webhook configuration feat: create reusable dashboard layout and navigation components feat: implement header component displaying wallet status and balance feat: add sidebar navigation for easy access to dashboard sections feat: create custom hook for fetching merchant wallet information --- legacy/webapp/app/dashboard/layout.tsx | 13 + legacy/webapp/app/invoices/layout.tsx | 13 + legacy/webapp/app/settings/layout.tsx | 13 + legacy/webapp/app/settings/page.tsx | 73 +++++ .../components/merchant-dashboard-shell.tsx | 153 ++++++++++ legacy/webapp/package-lock.json | 10 - web/app/dashboard/invoices/page.tsx | 256 ++++++++++++++++ web/app/dashboard/layout.tsx | 13 + web/app/dashboard/page.tsx | 282 ++++++++++++++++++ web/app/dashboard/settings/page.tsx | 197 ++++++++++++ web/app/globals.css | 24 +- web/app/layout.tsx | 3 +- web/components/dashboard-layout.tsx | 21 ++ web/components/dashboard-nav.ts | 34 +++ web/components/header.tsx | 120 ++++++++ web/components/sidebar.tsx | 56 ++++ web/hooks/use-merchant.ts | 68 +++++ 17 files changed, 1333 insertions(+), 16 deletions(-) create mode 100644 legacy/webapp/app/dashboard/layout.tsx create mode 100644 legacy/webapp/app/invoices/layout.tsx create mode 100644 legacy/webapp/app/settings/layout.tsx create mode 100644 legacy/webapp/app/settings/page.tsx create mode 100644 legacy/webapp/components/merchant-dashboard-shell.tsx create mode 100644 web/app/dashboard/invoices/page.tsx create mode 100644 web/app/dashboard/layout.tsx create mode 100644 web/app/dashboard/page.tsx create mode 100644 web/app/dashboard/settings/page.tsx create mode 100644 web/components/dashboard-layout.tsx create mode 100644 web/components/dashboard-nav.ts create mode 100644 web/components/header.tsx create mode 100644 web/components/sidebar.tsx create mode 100644 web/hooks/use-merchant.ts diff --git a/legacy/webapp/app/dashboard/layout.tsx b/legacy/webapp/app/dashboard/layout.tsx new file mode 100644 index 0000000..4f50be1 --- /dev/null +++ b/legacy/webapp/app/dashboard/layout.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from "react" +import { MerchantDashboardShell } from "@/components/merchant-dashboard-shell" + +export default function DashboardLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/legacy/webapp/app/invoices/layout.tsx b/legacy/webapp/app/invoices/layout.tsx new file mode 100644 index 0000000..8bc2958 --- /dev/null +++ b/legacy/webapp/app/invoices/layout.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from "react" +import { MerchantDashboardShell } from "@/components/merchant-dashboard-shell" + +export default function InvoicesLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/legacy/webapp/app/settings/layout.tsx b/legacy/webapp/app/settings/layout.tsx new file mode 100644 index 0000000..2a53a2f --- /dev/null +++ b/legacy/webapp/app/settings/layout.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from "react" +import { MerchantDashboardShell } from "@/components/merchant-dashboard-shell" + +export default function SettingsLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/legacy/webapp/app/settings/page.tsx b/legacy/webapp/app/settings/page.tsx new file mode 100644 index 0000000..36e4a41 --- /dev/null +++ b/legacy/webapp/app/settings/page.tsx @@ -0,0 +1,73 @@ +"use client" + +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { useEvmWallet } from "@/hooks/use-evm-wallet" +import { useAuthStore } from "@/hooks/use-auth-store" +import { ShieldCheck, LogOut, Wallet, ArrowRight } from "lucide-react" + +export default function SettingsPage() { + const { address, connected, displayAddress, disconnect } = useEvmWallet() + const { user } = useAuthStore() + + return ( +
+ + + + + Account Settings + + + Wallet access and dashboard preferences for the deployed legacy app. + + + +
+

Wallet

+

+ {connected ? displayAddress : "Not connected"} +

+

+ {address ? "Connected via your EVM wallet" : "Connect a wallet to authenticate"} +

+
+
+

Session

+

+ {user?.walletAddress || "No active session"} +

+

+ Session data is stored locally for the legacy frontend. +

+
+
+
+ + + + + + Quick Actions + + + Jump to common merchant tasks. + + + + + + + +
+ ) +} diff --git a/legacy/webapp/components/merchant-dashboard-shell.tsx b/legacy/webapp/components/merchant-dashboard-shell.tsx new file mode 100644 index 0000000..1611cad --- /dev/null +++ b/legacy/webapp/components/merchant-dashboard-shell.tsx @@ -0,0 +1,153 @@ +"use client" + +import Link from "next/link" +import { usePathname } from "next/navigation" +import { ReactNode } from "react" +import { FileText, PieChart, Settings } from "lucide-react" +import { ThemeToggle } from "@/components/theme-toggle" +import { WalletHeader } from "@/components/wallet-header" +import { cn } from "@/lib/utils" +import { useEvmWallet } from "@/hooks/use-evm-wallet" + +const NAV_ITEMS = [ + { + label: "Dashboard", + href: "/dashboard", + description: "Overview and wallet health", + icon: PieChart, + }, + { + label: "Invoices", + href: "/invoices", + description: "Create and manage invoices", + icon: FileText, + }, + { + label: "Settings", + href: "/settings", + description: "Wallet and merchant preferences", + icon: Settings, + }, +] + +function isActive(pathname: string, href: string) { + return pathname === href || pathname.startsWith(`${href}/`) +} + +interface MerchantDashboardShellProps { + children: ReactNode + title: string + description: string +} + +export function MerchantDashboardShell({ children, title, description }: MerchantDashboardShellProps) { + const pathname = usePathname() + const { address, connected, displayAddress } = useEvmWallet() + + return ( +
+ + +
+
+
+
+
+

+ Merchant Dashboard +

+

{title}

+

{description}

+
+ +
+ + +
+
+ + +
+
+ +
+ {children} +
+
+
+ ) +} diff --git a/legacy/webapp/package-lock.json b/legacy/webapp/package-lock.json index 53f118e..baa0115 100644 --- a/legacy/webapp/package-lock.json +++ b/legacy/webapp/package-lock.json @@ -9456,7 +9456,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9477,7 +9476,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9498,7 +9496,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9519,7 +9516,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9540,7 +9536,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9561,7 +9556,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9582,7 +9576,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9603,7 +9596,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9624,7 +9616,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9645,7 +9636,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ diff --git a/web/app/dashboard/invoices/page.tsx b/web/app/dashboard/invoices/page.tsx new file mode 100644 index 0000000..e4dea73 --- /dev/null +++ b/web/app/dashboard/invoices/page.tsx @@ -0,0 +1,256 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; +import { useMerchant } from '@/hooks/use-merchant'; +import { useWalletAuth } from '@/hooks/use-wallet-auth'; +import { Search, Plus, Filter } from 'lucide-react'; +import Link from 'next/link'; +import { useState } from 'react'; + +interface Invoice { + id: string; + number: string; + clientName: string; + clientEmail: string; + amount: string; + currency: string; + status: 'draft' | 'sent' | 'paid' | 'overdue'; + dueDate: string; + createdAt: string; +} + +export default function InvoicesPage() { + const { merchantId } = useMerchant(); + const { isAuthenticated } = useWalletAuth(); + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + + const { data: invoices = [], isLoading } = useQuery({ + queryKey: ['invoices', merchantId, searchTerm, statusFilter], + queryFn: async () => { + if (!merchantId) return []; + + try { + const params: { limit: number; q?: string } = { limit: 50 }; + if (searchTerm) params.q = searchTerm; + + const response = await apiClient.request({ + method: 'GET', + url: '/invoices', + params, + }); + + const rows = Array.isArray(response.data) ? response.data : []; + if (statusFilter === 'all') { + return rows; + } + + return rows.filter((invoice) => invoice.status === statusFilter); + } catch (err) { + console.error('Failed to fetch invoices:', err); + return []; + } + }, + enabled: !!merchantId && isAuthenticated, + }); + + const getStatusColor = (status: string) => { + const colors: Record = { + draft: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300', + sent: 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300', + paid: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300', + overdue: 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300', + }; + return colors[status] || colors.draft; + }; + + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + // Calculate stats + const stats = { + total: invoices.length, + paid: invoices.filter((inv) => inv.status === 'paid').length, + pending: invoices.filter((inv) => inv.status === 'sent' || inv.status === 'overdue').length, + draft: invoices.filter((inv) => inv.status === 'draft').length, + }; + + return ( +
+ {/* Header */} +
+
+

Invoices

+

+ Manage all your invoices in one place +

+
+ + + Create Invoice + +
+ + {/* Stats Cards */} +
+
+

Total

+

+ {isLoading ? '...' : stats.total} +

+
+
+

Paid

+

+ {isLoading ? '...' : stats.paid} +

+
+
+

Pending

+

+ {isLoading ? '...' : stats.pending} +

+
+
+

Draft

+

+ {isLoading ? '...' : stats.draft} +

+
+
+ + {/* Filters */} +
+
+ {/* Search */} +
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Status Filter */} +
+ + +
+
+
+ + {/* Invoices Table */} +
+ {isLoading ? ( +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ ) : invoices.length === 0 ? ( +
+

No invoices found

+ + Create Your First Invoice + +
+ ) : ( +
+ + + + + + + + + + + + + {invoices.map((invoice) => ( + + + + + + + + + ))} + +
+ Invoice + + Client + + Amount + + Due Date + + Status + + Action +
+ + {invoice.number} + + +
{invoice.clientName}
+
+ {invoice.clientEmail} +
+
+ {invoice.amount} {invoice.currency} + + {formatDate(invoice.dueDate)} + + + {invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)} + + + + View + +
+
+ )} +
+
+ ); +} diff --git a/web/app/dashboard/layout.tsx b/web/app/dashboard/layout.tsx new file mode 100644 index 0000000..8f5cb9b --- /dev/null +++ b/web/app/dashboard/layout.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { ReactNode } from 'react'; +import { DashboardLayout } from '@/components/dashboard-layout'; +import { RequireAuth } from '@/components/require-auth'; + +export default function DashboardRootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/web/app/dashboard/page.tsx b/web/app/dashboard/page.tsx new file mode 100644 index 0000000..0a0e0c9 --- /dev/null +++ b/web/app/dashboard/page.tsx @@ -0,0 +1,282 @@ +'use client'; + +import { useMerchant } from '@/hooks/use-merchant'; +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; +import { useWalletAuth } from '@/hooks/use-wallet-auth'; +import { TrendingUp, FileText, DollarSign, Clock } from 'lucide-react'; +import Link from 'next/link'; + +interface Invoice { + id: string; + number: string; + clientName: string; + amount: string; + currency: string; + status: 'draft' | 'sent' | 'paid' | 'overdue'; + createdAt: string; +} + +export default function DashboardPage() { + const { wallet, merchantId } = useMerchant(); + const { isAuthenticated } = useWalletAuth(); + + // Fetch recent invoices + const { data: invoices = [], isLoading: invoicesLoading } = useQuery({ + queryKey: ['invoices', merchantId], + queryFn: async () => { + if (!merchantId) return []; + + try { + const response = await apiClient.request({ + method: 'GET', + url: '/invoices', + params: { limit: 5 }, + }); + + return Array.isArray(response.data) ? response.data : []; + } catch (err) { + console.error('Failed to fetch invoices:', err); + return []; + } + }, + enabled: !!merchantId && isAuthenticated, + }); + + // Calculate stats + const stats = { + totalInvoices: invoices.length, + paidInvoices: invoices.filter((inv) => inv.status === 'paid').length, + pendingAmount: invoices + .filter((inv) => inv.status === 'sent' || inv.status === 'overdue') + .reduce((sum, inv) => sum + parseFloat(inv.amount || '0'), 0) + .toFixed(2), + overdueCount: invoices.filter((inv) => inv.status === 'overdue').length, + }; + + const getStatusColor = (status: string) => { + const colors: Record = { + draft: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300', + sent: 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300', + paid: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300', + overdue: 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300', + }; + return colors[status] || colors.draft; + }; + + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + return ( +
+ {/* Welcome Section */} +
+

+ Welcome back, {wallet?.name || 'Merchant'}! +

+

+ Manage your invoices and monitor your merchant wallet activity +

+
+ + {/* Stats Grid */} +
+ {/* Total Invoices */} +
+
+
+

Total Invoices

+

+ {invoicesLoading ? '...' : stats.totalInvoices} +

+
+ +
+
+ + {/* Paid Invoices */} +
+
+
+

Paid Invoices

+

+ {invoicesLoading ? '...' : stats.paidInvoices} +

+
+ +
+
+ + {/* Pending Amount */} +
+
+
+

Pending Amount

+

+ {invoicesLoading ? '...' : `$${stats.pendingAmount}`} +

+
+ +
+
+ + {/* Overdue */} +
+
+
+

Overdue

+

+ {invoicesLoading ? '...' : stats.overdueCount} +

+
+ +
+
+
+ + {/* Wallet Balance Section */} + {wallet && ( +
+

+ Wallet Information +

+
+
+

Wallet Address

+

+ {wallet.publicKey} +

+
+
+

Balance

+

+ {wallet.balance} {wallet.currency} +

+ {wallet.balanceUSD && ( +

+ ≈ ${wallet.balanceUSD} +

+ )} +
+
+

Merchant ID

+

+ {wallet.id} +

+
+
+
+ )} + + {/* Recent Invoices */} +
+
+

+ Recent Invoices +

+ + View All + +
+ + {invoicesLoading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ) : invoices.length === 0 ? ( +
+ +

No invoices yet

+ + Create Invoice + +
+ ) : ( +
+ + + + + + + + + + + + {invoices.map((invoice) => ( + + + + + + + + ))} + +
+ Invoice + + Client + + Amount + + Status + + Date +
+ + {invoice.number} + + + {invoice.clientName} + + {invoice.amount} {invoice.currency} + + + {invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)} + + + {formatDate(invoice.createdAt)} +
+
+ )} +
+
+ ); +} diff --git a/web/app/dashboard/settings/page.tsx b/web/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..9581021 --- /dev/null +++ b/web/app/dashboard/settings/page.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { useMerchant } from '@/hooks/use-merchant'; +import { useWalletAuth } from '@/hooks/use-wallet-auth'; +import { useState } from 'react'; +import { Save } from 'lucide-react'; + +export default function SettingsPage() { + const { wallet, isLoading } = useMerchant(); + const { publicKey, signOut } = useWalletAuth(); + const [isSaving, setIsSaving] = useState(false); + const [formData, setFormData] = useState({ + merchantName: wallet?.name || '', + email: '', + webhookUrl: '', + }); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleSave = async () => { + setIsSaving(true); + // Simulated save - integrate with actual API + await new Promise((resolve) => setTimeout(resolve, 1000)); + setIsSaving(false); + alert('Settings saved successfully!'); + }; + + return ( +
+ {/* Header */} +
+

Settings

+

+ Manage your merchant account and preferences +

+
+ + {/* Account Settings */} +
+

+ Account Settings +

+ +
+ {/* Merchant Name */} +
+ + +

+ The name displayed on your invoices +

+
+ + {/* Email */} +
+ + +

+ Used for invoice notifications and account recovery +

+
+ + {/* Save Button */} +
+ +
+
+
+ + {/* Wallet Settings */} +
+

+ Wallet Information +

+ +
+
+ +
+ + {publicKey || 'Not connected'} + +
+

+ This is your Stellar public key used for authentication +

+
+ + {wallet && ( +
+ +
+ + {wallet.id} + +
+

+ Unique identifier for your merchant account +

+
+ )} +
+
+ + {/* Webhook Settings */} +
+

+ Webhook Configuration +

+ +
+
+ + +

+ Receive payment notifications and invoice updates +

+
+ +
+

+ Webhook Events +

+
    +
  • Invoice created, sent, viewed
  • +
  • Payment received
  • +
  • Invoice reminder sent
  • +
+
+
+
+ + {/* Danger Zone */} +
+

Danger Zone

+

+ These actions cannot be undone. +

+ +
+
+ ); +} diff --git a/web/app/globals.css b/web/app/globals.css index a2dc41e..e05596f 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1,8 +1,8 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #f8fafc; + --foreground: #0f172a; } @theme inline { @@ -14,13 +14,27 @@ @media (prefers-color-scheme: dark) { :root { - --background: #0a0a0a; - --foreground: #ededed; + --background: #020617; + --foreground: #e2e8f0; } } +* { + box-sizing: border-box; +} + +html { + min-height: 100%; +} + body { + min-height: 100vh; + margin: 0; background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; + background-image: + radial-gradient(circle at top left, rgba(14, 165, 233, 0.08), transparent 30%), + radial-gradient(circle at top right, rgba(15, 23, 42, 0.05), transparent 28%); + background-attachment: fixed; } diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 6ce4e25..f5f22a0 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -24,8 +24,9 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + {children} diff --git a/web/components/dashboard-layout.tsx b/web/components/dashboard-layout.tsx new file mode 100644 index 0000000..4c927a5 --- /dev/null +++ b/web/components/dashboard-layout.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { ReactNode } from 'react'; +import { Sidebar } from '@/components/sidebar'; +import { Header } from '@/components/header'; + +export function DashboardLayout({ children }: { children: ReactNode }) { + return ( +
+ + +
+
+ +
+
{children}
+
+
+
+ ); +} diff --git a/web/components/dashboard-nav.ts b/web/components/dashboard-nav.ts new file mode 100644 index 0000000..f324592 --- /dev/null +++ b/web/components/dashboard-nav.ts @@ -0,0 +1,34 @@ +import type { LucideIcon } from 'lucide-react'; +import { LayoutDashboard, ReceiptText, Settings } from 'lucide-react'; + +export interface DashboardNavItem { + label: string; + href: string; + description: string; + icon: LucideIcon; +} + +export const DASHBOARD_NAV_ITEMS: DashboardNavItem[] = [ + { + label: 'Dashboard', + href: '/dashboard', + description: 'Overview and wallet health', + icon: LayoutDashboard, + }, + { + label: 'Invoices', + href: '/dashboard/invoices', + description: 'Create and manage invoices', + icon: ReceiptText, + }, + { + label: 'Settings', + href: '/dashboard/settings', + description: 'Wallet and merchant preferences', + icon: Settings, + }, +]; + +export function isDashboardNavActive(pathname: string, href: string): boolean { + return pathname === href || pathname.startsWith(`${href}/`); +} \ No newline at end of file diff --git a/web/components/header.tsx b/web/components/header.tsx new file mode 100644 index 0000000..3ac8249 --- /dev/null +++ b/web/components/header.tsx @@ -0,0 +1,120 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useMerchant } from '@/hooks/use-merchant'; +import { useWalletAuth } from '@/hooks/use-wallet-auth'; +import { DASHBOARD_NAV_ITEMS, isDashboardNavActive } from '@/components/dashboard-nav'; +import { LogOut, Wallet } from 'lucide-react'; + +export function Header() { + const { wallet, isLoading } = useMerchant(); + const { publicKey, status, signOut } = useWalletAuth(); + const pathname = usePathname(); + + const formatAddress = (addr: string) => { + if (!addr) return 'Wallet not connected'; + + if (addr.length <= 12) { + return addr; + } + + return `${addr.substring(0, 6)}...${addr.substring(addr.length - 4)}`; + }; + + const merchantName = wallet?.name || 'Merchant workspace'; + const merchantWallet = wallet?.publicKey || publicKey || ''; + const statusLabel = status === 'signed-in' ? 'Authenticated' : 'Waiting for wallet'; + const statusClass = + status === 'signed-in' + ? 'border-emerald-200 bg-emerald-50 text-emerald-700' + : 'border-amber-200 bg-amber-50 text-amber-700'; + + return ( +
+
+
+
+
+

+ Merchant Dashboard +

+ + {statusLabel} + +
+
+

+ {isLoading ? 'Loading wallet context...' : merchantName} +

+

+ {merchantWallet ? formatAddress(merchantWallet) : 'Connecting to backend wallet context'} +

+
+
+ +
+
+
+ +
+
+

Balance

+

+ {isLoading ? 'Loading...' : `${wallet?.balance || '0'} ${wallet?.currency || 'XLM'}`} +

+ {wallet?.balanceUSD && ( +

≈ ${wallet.balanceUSD}

+ )} +
+
+ + +
+
+ + + +
+
+

Wallet

+

{formatAddress(merchantWallet)}

+
+
+

Balance

+

+ {isLoading ? 'Loading...' : `${wallet?.balance || '0'} ${wallet?.currency || 'XLM'}`} +

+
+
+
+
+ ); +} diff --git a/web/components/sidebar.tsx b/web/components/sidebar.tsx new file mode 100644 index 0000000..9ffcbd6 --- /dev/null +++ b/web/components/sidebar.tsx @@ -0,0 +1,56 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { LogOut } from 'lucide-react'; +import { useWalletAuth } from '@/hooks/use-wallet-auth'; +import { DASHBOARD_NAV_ITEMS, isDashboardNavActive } from '@/components/dashboard-nav'; + +export function Sidebar() { + const pathname = usePathname(); + const { signOut } = useWalletAuth(); + + return ( + + ); +} diff --git a/web/hooks/use-merchant.ts b/web/hooks/use-merchant.ts new file mode 100644 index 0000000..10f5fc6 --- /dev/null +++ b/web/hooks/use-merchant.ts @@ -0,0 +1,68 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; +import { useWalletAuth } from './use-wallet-auth'; + +export interface MerchantWallet { + id: string; + name: string; + publicKey: string; + balance: string; + balanceUSD: string; + currency: string; + avatar?: string; +} + +export interface MerchantContext { + merchantId: string; + wallet: MerchantWallet | null; +} + +export function useMerchant() { + const { publicKey, isAuthenticated } = useWalletAuth(); + + const { data: wallet, isLoading, error } = useQuery({ + queryKey: ['merchant-wallet', publicKey], + queryFn: async () => { + if (!publicKey) return null; + + try { + const userResponse = await apiClient.request<{ + id: string; + merchantId?: string; + publicKey: string; + createdAt: string; + }>({ + method: 'GET', + url: '/auth/me', + }); + + const merchantId = userResponse.data?.merchantId || userResponse.data?.id; + const resolvedPublicKey = userResponse.data?.publicKey || publicKey; + const alias = resolvedPublicKey ? `Merchant ${resolvedPublicKey.slice(0, 6)}` : 'My Merchant'; + + return { + id: merchantId, + name: alias, + publicKey: resolvedPublicKey, + balance: '0', + balanceUSD: '0', + currency: 'XLM', + } as MerchantWallet; + } catch (err) { + console.error('Failed to fetch merchant wallet:', err); + return null; + } + }, + enabled: isAuthenticated && !!publicKey, + staleTime: 1000 * 60 * 5, // 5 minutes + }); + + return { + wallet, + isLoading, + error, + merchantId: wallet?.id, + }; +} From 556d027484309f471bbe79c56743791ab7a66b09 Mon Sep 17 00:00:00 2001 From: aabxtract Date: Fri, 19 Jun 2026 15:02:47 +0100 Subject: [PATCH 2/3] feat: implement merchant profile management and dashboard layout architecture --- backend/prisma.config.ts | 2 +- backend/prisma/schema.prisma | 3 + backend/src/app.module.ts | 2 + .../is-stellar-public-key.validator.ts | 31 ++ .../dtos/update-merchant-profile.dto.ts | 20 + backend/src/merchant/merchant.controller.ts | 26 ++ backend/src/merchant/merchant.module.ts | 12 + backend/src/merchant/merchant.service.ts | 46 ++- web/app/globals.css | 194 ++++++++- web/app/layout.tsx | 8 +- web/components/dashboard-layout.tsx | 48 ++- web/components/dashboard-nav.ts | 8 + web/components/header.tsx | 390 ++++++++++++++---- web/components/require-auth.tsx | 43 +- web/components/sidebar-context.tsx | 98 +++++ web/components/sidebar.tsx | 219 +++++++++- 16 files changed, 1015 insertions(+), 135 deletions(-) create mode 100644 backend/src/common/validators/is-stellar-public-key.validator.ts create mode 100644 backend/src/merchant/dtos/update-merchant-profile.dto.ts create mode 100644 backend/src/merchant/merchant.module.ts create mode 100644 web/components/sidebar-context.tsx diff --git a/backend/prisma.config.ts b/backend/prisma.config.ts index a39462b..516ea52 100644 --- a/backend/prisma.config.ts +++ b/backend/prisma.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'prisma/config'; +import { defineConfig } from '@prisma/config'; export default defineConfig({ datasource: { diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f9378f3..c877f7e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -14,6 +14,9 @@ model Merchant { id String @id @default(uuid()) name String stellarPublicKey String @unique @map("stellar_public_key") + businessEmail String? @map("business_email") + preferredAsset String? @map("preferred_asset") + payoutWallet String? @map("payout_wallet") webhookUrl String? @map("webhook_url") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 832deac..9f2344b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -21,6 +21,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module"; import { CustomThrottlerModule } from "./throttler/throttler.module"; import { BackfillModule } from "./backfill/backfill.module"; import { AdminAnalyticsModule } from "./admin-analytics/admin-analytics.module"; +import { MerchantModule } from "./merchant/merchant.module"; /** * Root application module @@ -103,6 +104,7 @@ import { AdminAnalyticsModule } from "./admin-analytics/admin-analytics.module"; WebhooksModule, BackfillModule, AdminAnalyticsModule, + MerchantModule, ], }) export class AppModule {} \ No newline at end of file diff --git a/backend/src/common/validators/is-stellar-public-key.validator.ts b/backend/src/common/validators/is-stellar-public-key.validator.ts new file mode 100644 index 0000000..658c39e --- /dev/null +++ b/backend/src/common/validators/is-stellar-public-key.validator.ts @@ -0,0 +1,31 @@ +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { StrKey } from '@stellar/stellar-sdk'; + +@ValidatorConstraint({ name: 'isStellarPublicKey', async: false }) +export class IsStellarPublicKeyConstraint implements ValidatorConstraintInterface { + validate(publicKey: any) { + if (typeof publicKey !== 'string') return false; + return StrKey.isValidEd25519PublicKey(publicKey); + } + + defaultMessage() { + return 'payoutWallet must be a valid Stellar public key (starting with G)'; + } +} + +export function IsStellarPublicKey(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: IsStellarPublicKeyConstraint, + }); + }; +} diff --git a/backend/src/merchant/dtos/update-merchant-profile.dto.ts b/backend/src/merchant/dtos/update-merchant-profile.dto.ts new file mode 100644 index 0000000..596a9a5 --- /dev/null +++ b/backend/src/merchant/dtos/update-merchant-profile.dto.ts @@ -0,0 +1,20 @@ +import { IsEmail, IsOptional, IsString } from 'class-validator'; +import { IsStellarPublicKey } from '../../common/validators/is-stellar-public-key.validator'; + +export class UpdateMerchantProfileDto { + @IsString() + @IsOptional() + name?: string; + + @IsEmail() + @IsOptional() + businessEmail?: string; + + @IsString() + @IsOptional() + preferredAsset?: string; + + @IsStellarPublicKey() + @IsOptional() + payoutWallet?: string; +} diff --git a/backend/src/merchant/merchant.controller.ts b/backend/src/merchant/merchant.controller.ts index 3eee4f6..2521125 100644 --- a/backend/src/merchant/merchant.controller.ts +++ b/backend/src/merchant/merchant.controller.ts @@ -5,6 +5,8 @@ import { Patch, Delete, UseGuards, + Param, + Body, } from "@nestjs/common"; import { JwtAuthGuard } from "../auth/guard/auth.guard"; @@ -12,10 +14,34 @@ import { MerchantMembershipGuard } from "../common/guards/merchant-membership.gu import { MerchantRolesGuard } from "../common/guards/merchant-roles.guard"; import { Roles } from "../common/decorators/roles.decorator"; import { MerchantRole } from "../common/enums/merchant-role.enum"; +import { MerchantService } from "./merchant.service"; +import { UpdateMerchantProfileDto } from "./dtos/update-merchant-profile.dto"; @UseGuards(JwtAuthGuard, MerchantMembershipGuard, MerchantRolesGuard) @Controller("merchants") export class MerchantController { + constructor(private readonly merchantService: MerchantService) {} + + @Get(":merchantId/profile") + @Roles( + MerchantRole.OWNER, + MerchantRole.ADMIN, + MerchantRole.OPERATOR, + MerchantRole.VIEWER, + ) + getProfile(@Param("merchantId") merchantId: string) { + return this.merchantService.getProfile(merchantId); + } + + @Patch(":merchantId/profile") + @Roles(MerchantRole.OWNER, MerchantRole.ADMIN) + updateProfile( + @Param("merchantId") merchantId: string, + @Body() data: UpdateMerchantProfileDto, + ) { + return this.merchantService.updateProfile(merchantId, data); + } + @Get(":merchantId/export") @Roles(MerchantRole.OWNER, MerchantRole.ADMIN) exportMerchantData() { diff --git a/backend/src/merchant/merchant.module.ts b/backend/src/merchant/merchant.module.ts new file mode 100644 index 0000000..5b377c6 --- /dev/null +++ b/backend/src/merchant/merchant.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { MerchantController } from './merchant.controller'; +import { MerchantService } from './merchant.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [MerchantController], + providers: [MerchantService], + exports: [MerchantService], +}) +export class MerchantModule {} diff --git a/backend/src/merchant/merchant.service.ts b/backend/src/merchant/merchant.service.ts index f5d1a58..b61db7a 100644 --- a/backend/src/merchant/merchant.service.ts +++ b/backend/src/merchant/merchant.service.ts @@ -1,4 +1,46 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { UpdateMerchantProfileDto } from "./dtos/update-merchant-profile.dto"; @Injectable() -export class MerchantService {} +export class MerchantService { + constructor(private prisma: PrismaService) {} + + async getProfile(merchantId: string) { + const merchant = await this.prisma.merchant.findUnique({ + where: { id: merchantId }, + select: { + id: true, + name: true, + stellarPublicKey: true, + businessEmail: true, + preferredAsset: true, + payoutWallet: true, + webhookUrl: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (!merchant) { + throw new NotFoundException('Merchant not found'); + } + + return merchant; + } + + async updateProfile(merchantId: string, data: UpdateMerchantProfileDto) { + const merchant = await this.prisma.merchant.findUnique({ + where: { id: merchantId }, + }); + + if (!merchant) { + throw new NotFoundException('Merchant not found'); + } + + return this.prisma.merchant.update({ + where: { id: merchantId }, + data, + }); + } +} diff --git a/web/app/globals.css b/web/app/globals.css index e05596f..c768150 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1,8 +1,69 @@ @import "tailwindcss"; +/* ── Design Tokens ─────────────────────────────────────────────── */ :root { - --background: #f8fafc; - --foreground: #0f172a; + /* Core palette */ + --color-bg-primary: #0a0e1a; + --color-bg-secondary: #0f1422; + --color-bg-elevated: #151b2e; + --color-bg-surface: #1a2138; + --color-bg-hover: #1e2743; + + --color-text-primary: #e8ecf4; + --color-text-secondary: #8892a8; + --color-text-muted: #5a6478; + --color-text-inverse: #0a0e1a; + + --color-border-default: rgba(255, 255, 255, 0.06); + --color-border-subtle: rgba(255, 255, 255, 0.04); + --color-border-strong: rgba(255, 255, 255, 0.12); + + /* Accent: Stellar-inspired cool blue-violet */ + --color-accent: #6366f1; + --color-accent-hover: #818cf8; + --color-accent-muted: rgba(99, 102, 241, 0.15); + --color-accent-glow: rgba(99, 102, 241, 0.25); + + /* Semantic */ + --color-success: #22c55e; + --color-success-muted: rgba(34, 197, 94, 0.12); + --color-warning: #f59e0b; + --color-warning-muted: rgba(245, 158, 11, 0.12); + --color-danger: #ef4444; + --color-danger-muted: rgba(239, 68, 68, 0.12); + --color-info: #3b82f6; + --color-info-muted: rgba(59, 130, 246, 0.12); + + /* Glassmorphism */ + --glass-bg: rgba(15, 20, 34, 0.72); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.32); + + /* Layout */ + --sidebar-width: 260px; + --sidebar-collapsed: 72px; + --header-height: 72px; + + /* Radius */ + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; + + /* Transitions */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-default: 250ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1); + + /* Fonts — set by Next.js */ + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + + /* For Tailwind theming */ + --background: var(--color-bg-primary); + --foreground: var(--color-text-primary); } @theme inline { @@ -12,13 +73,7 @@ --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #020617; - --foreground: #e2e8f0; - } -} - +/* ── Base Reset ────────────────────────────────────────────────── */ * { box-sizing: border-box; } @@ -30,11 +85,118 @@ html { body { min-height: 100vh; margin: 0; - background: var(--background); - color: var(--foreground); - font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; - background-image: - radial-gradient(circle at top left, rgba(14, 165, 233, 0.08), transparent 30%), - radial-gradient(circle at top right, rgba(15, 23, 42, 0.05), transparent 28%); - background-attachment: fixed; + background: var(--color-bg-primary); + color: var(--color-text-primary); + font-family: var(--font-sans), system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Scrollbar ─────────────────────────────────────────────────── */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--color-text-muted); + border-radius: var(--radius-full); +} +::-webkit-scrollbar-thumb:hover { + background: var(--color-text-secondary); +} + +/* ── Keyframe Animations ──────────────────────────────────────── */ +@keyframes slideInLeft { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} + +@keyframes slideOutLeft { + from { transform: translateX(0); } + to { transform: translateX(-100%); } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes fadeOut { + from { opacity: 1; } + to { opacity: 0; } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +@keyframes pulseGlow { + 0%, 100% { box-shadow: 0 0 0 0 var(--color-accent-glow); } + 50% { box-shadow: 0 0 0 6px transparent; } +} + +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-4px); } +} + +@keyframes scaleIn { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +/* ── Utility Classes ──────────────────────────────────────────── */ +.animate-slide-in-left { + animation: slideInLeft var(--transition-slow) forwards; +} + +.animate-fade-in { + animation: fadeIn var(--transition-default) forwards; +} + +.animate-scale-in { + animation: scaleIn var(--transition-default) forwards; +} + +.animate-shimmer { + background: linear-gradient( + 90deg, + var(--color-bg-surface) 25%, + var(--color-bg-hover) 50%, + var(--color-bg-surface) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} + +.animate-pulse-glow { + animation: pulseGlow 2s ease-in-out infinite; +} + +/* Glass panel */ +.glass-panel { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +/* Sidebar overlay for mobile */ +.sidebar-backdrop { + position: fixed; + inset: 0; + z-index: 39; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} + +/* Smooth page transitions */ +.page-enter { + animation: fadeIn var(--transition-default) forwards, scaleIn var(--transition-default) forwards; } diff --git a/web/app/layout.tsx b/web/app/layout.tsx index f5f22a0..d52eed5 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -14,8 +14,12 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Invoisio — Merchant Dashboard", + description: + "Privacy-focused AI invoice platform on the Stellar network. Manage invoices, track payments, and monitor your merchant wallet.", + other: { + "theme-color": "#0a0e1a", + }, }; export default function RootLayout({ diff --git a/web/components/dashboard-layout.tsx b/web/components/dashboard-layout.tsx index 4c927a5..de0df89 100644 --- a/web/components/dashboard-layout.tsx +++ b/web/components/dashboard-layout.tsx @@ -3,19 +3,57 @@ import { ReactNode } from 'react'; import { Sidebar } from '@/components/sidebar'; import { Header } from '@/components/header'; +import { SidebarProvider, useSidebar } from '@/components/sidebar-context'; + +function DashboardShell({ children }: { children: ReactNode }) { + const { isCollapsed } = useSidebar(); -export function DashboardLayout({ children }: { children: ReactNode }) { return ( -
+
-
+
+ {/* Set the CSS variable for content offset based on sidebar state */} + +
-
-
{children}
+
+
+ {children} +
); } + +export function DashboardLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/web/components/dashboard-nav.ts b/web/components/dashboard-nav.ts index f324592..d601119 100644 --- a/web/components/dashboard-nav.ts +++ b/web/components/dashboard-nav.ts @@ -3,26 +3,31 @@ import { LayoutDashboard, ReceiptText, Settings } from 'lucide-react'; export interface DashboardNavItem { label: string; + shortLabel: string; href: string; description: string; icon: LucideIcon; + badge?: string | number; } export const DASHBOARD_NAV_ITEMS: DashboardNavItem[] = [ { label: 'Dashboard', + shortLabel: 'Home', href: '/dashboard', description: 'Overview and wallet health', icon: LayoutDashboard, }, { label: 'Invoices', + shortLabel: 'Invoices', href: '/dashboard/invoices', description: 'Create and manage invoices', icon: ReceiptText, }, { label: 'Settings', + shortLabel: 'Settings', href: '/dashboard/settings', description: 'Wallet and merchant preferences', icon: Settings, @@ -30,5 +35,8 @@ export const DASHBOARD_NAV_ITEMS: DashboardNavItem[] = [ ]; export function isDashboardNavActive(pathname: string, href: string): boolean { + if (href === '/dashboard') { + return pathname === '/dashboard'; + } return pathname === href || pathname.startsWith(`${href}/`); } \ No newline at end of file diff --git a/web/components/header.tsx b/web/components/header.tsx index 3ac8249..cae7fe0 100644 --- a/web/components/header.tsx +++ b/web/components/header.tsx @@ -4,115 +4,335 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useMerchant } from '@/hooks/use-merchant'; import { useWalletAuth } from '@/hooks/use-wallet-auth'; -import { DASHBOARD_NAV_ITEMS, isDashboardNavActive } from '@/components/dashboard-nav'; -import { LogOut, Wallet } from 'lucide-react'; +import { useSidebar } from '@/components/sidebar-context'; +import { + DASHBOARD_NAV_ITEMS, + isDashboardNavActive, +} from '@/components/dashboard-nav'; +import { LogOut, Wallet, Menu, Globe, ChevronRight } from 'lucide-react'; export function Header() { const { wallet, isLoading } = useMerchant(); const { publicKey, status, signOut } = useWalletAuth(); + const { isCollapsed, openMobile } = useSidebar(); const pathname = usePathname(); const formatAddress = (addr: string) => { - if (!addr) return 'Wallet not connected'; + if (!addr) return '—'; + if (addr.length <= 12) return addr; + return `${addr.substring(0, 6)}…${addr.substring(addr.length - 4)}`; + }; - if (addr.length <= 12) { - return addr; - } + // Derive page title from pathname + const getPageTitle = () => { + const segments = pathname.split('/').filter(Boolean); + if (segments.length <= 1) return 'Dashboard'; + const last = segments[segments.length - 1]; + return last.charAt(0).toUpperCase() + last.slice(1); + }; - return `${addr.substring(0, 6)}...${addr.substring(addr.length - 4)}`; + // Derive breadcrumb segments + const getBreadcrumbs = () => { + const segments = pathname.split('/').filter(Boolean); + return segments.map((seg, i) => ({ + label: seg.charAt(0).toUpperCase() + seg.slice(1), + href: '/' + segments.slice(0, i + 1).join('/'), + isLast: i === segments.length - 1, + })); }; - const merchantName = wallet?.name || 'Merchant workspace'; const merchantWallet = wallet?.publicKey || publicKey || ''; - const statusLabel = status === 'signed-in' ? 'Authenticated' : 'Waiting for wallet'; - const statusClass = - status === 'signed-in' - ? 'border-emerald-200 bg-emerald-50 text-emerald-700' - : 'border-amber-200 bg-amber-50 text-amber-700'; + const isAuthenticated = status === 'signed-in'; return ( -
-
-
-
-
-

- Merchant Dashboard -

- - {statusLabel} - -
-
-

- {isLoading ? 'Loading wallet context...' : merchantName} -

-

- {merchantWallet ? formatAddress(merchantWallet) : 'Connecting to backend wallet context'} -

-
-
+
+
+ {/* ── Left: Mobile hamburger + Breadcrumb ─────── */} +
+ {/* Mobile menu button */} + -
-
-
- -
-
-

Balance

-

- {isLoading ? 'Loading...' : `${wallet?.balance || '0'} ${wallet?.currency || 'XLM'}`} -

- {wallet?.balanceUSD && ( -

≈ ${wallet.balanceUSD}

- )} -
+
+ {/* Breadcrumb */} +
+ {getBreadcrumbs().map((crumb) => ( + + {crumb.isLast ? ( + + {crumb.label} + + ) : ( + <> + + {crumb.label} + + + + )} + + ))}
- + {getPageTitle()} +
- - -
-
-

Wallet

-

{formatAddress(merchantWallet)}

+ Balance +

+

+ {isLoading + ? '...' + : `${wallet?.balance || '0'} ${wallet?.currency || 'XLM'}`} +

+
+ {wallet?.balanceUSD && ( + + ≈ ${wallet.balanceUSD} + + )}
-
-

Balance

-

- {isLoading ? 'Loading...' : `${wallet?.balance || '0'} ${wallet?.currency || 'XLM'}`} -

+ + {/* Wallet address chip */} +
+
+ + {merchantWallet ? formatAddress(merchantWallet) : 'Not connected'} +
+ + {/* Sign out button */} + +
+
+ + {/* ── Mobile bottom nav bar ──────────────────────── */} + + + {/* ── Mobile wallet info row ─────────────────────── */} +
+
+

+ Wallet +

+

+ {formatAddress(merchantWallet)} +

+
+
+

+ Balance +

+

+ {isLoading + ? '...' + : `${wallet?.balance || '0'} ${wallet?.currency || 'XLM'}`} +

diff --git a/web/components/require-auth.tsx b/web/components/require-auth.tsx index 072b9c9..8a87df8 100644 --- a/web/components/require-auth.tsx +++ b/web/components/require-auth.tsx @@ -21,8 +21,47 @@ export function RequireAuth({ children, redirectTo = '/login' }: RequireAuthProp if (isLoading) { return ( -
-
+
+
+ {/* Branded shimmer skeleton mimicking sidebar + header */} +
+ {/* Fake header bar */} +
+
+ + {/* Fake content cards */} +
+
+
+
+
+
+ +

+ Verifying wallet session… +

+
); } diff --git a/web/components/sidebar-context.tsx b/web/components/sidebar-context.tsx new file mode 100644 index 0000000..6c1e0c3 --- /dev/null +++ b/web/components/sidebar-context.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +const SIDEBAR_STORAGE_KEY = 'invoisio:sidebar-collapsed'; + +interface SidebarContextValue { + isCollapsed: boolean; + isMobileOpen: boolean; + toggleCollapsed: () => void; + openMobile: () => void; + closeMobile: () => void; +} + +const SidebarContext = createContext(null); + +export function SidebarProvider({ children }: { children: ReactNode }) { + const [isCollapsed, setIsCollapsed] = useState(false); + const [isMobileOpen, setIsMobileOpen] = useState(false); + + // Restore collapsed preference from localStorage + useEffect(() => { + if (typeof window === 'undefined') return; + const stored = window.localStorage.getItem(SIDEBAR_STORAGE_KEY); + if (stored === 'true') { + setIsCollapsed(true); + } + }, []); + + const toggleCollapsed = useCallback(() => { + setIsCollapsed((prev) => { + const next = !prev; + if (typeof window !== 'undefined') { + window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(next)); + } + return next; + }); + }, []); + + const openMobile = useCallback(() => { + setIsMobileOpen(true); + }, []); + + const closeMobile = useCallback(() => { + setIsMobileOpen(false); + }, []); + + // Close mobile drawer on Escape key + useEffect(() => { + if (!isMobileOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setIsMobileOpen(false); + } + }; + + document.addEventListener('keydown', handleKeyDown); + // Prevent body scroll when mobile drawer is open + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [isMobileOpen]); + + const value = useMemo( + () => ({ + isCollapsed, + isMobileOpen, + toggleCollapsed, + openMobile, + closeMobile, + }), + [isCollapsed, isMobileOpen, toggleCollapsed, openMobile, closeMobile], + ); + + return ( + {children} + ); +} + +export function useSidebar(): SidebarContextValue { + const context = useContext(SidebarContext); + if (context == null) { + throw new Error('useSidebar must be used within SidebarProvider'); + } + return context; +} diff --git a/web/components/sidebar.tsx b/web/components/sidebar.tsx index 9ffcbd6..83cf2a3 100644 --- a/web/components/sidebar.tsx +++ b/web/components/sidebar.tsx @@ -2,25 +2,60 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { LogOut } from 'lucide-react'; +import { LogOut, PanelLeftClose, PanelLeft, Zap } from 'lucide-react'; import { useWalletAuth } from '@/hooks/use-wallet-auth'; -import { DASHBOARD_NAV_ITEMS, isDashboardNavActive } from '@/components/dashboard-nav'; +import { useSidebar } from '@/components/sidebar-context'; +import { + DASHBOARD_NAV_ITEMS, + isDashboardNavActive, +} from '@/components/dashboard-nav'; export function Sidebar() { const pathname = usePathname(); const { signOut } = useWalletAuth(); + const { isCollapsed, isMobileOpen, toggleCollapsed, closeMobile } = + useSidebar(); - return ( - +
+ ); + + return ( + <> + {/* ── Desktop Sidebar ────────────────────────────── */} + + + {/* ── Mobile Drawer ──────────────────────────────── */} + {isMobileOpen && ( + <> + {/* Backdrop */} +