diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index 87e46d5..d9c023e 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -66,13 +66,13 @@ jobs:
with:
node-version: 20
cache: 'npm'
- cache-dependency-path: apps/web/package-lock.json
+ cache-dependency-path: package-lock.json
- name: Install dependencies
- run: npm install --prefix apps/web
+ run: npm ci
- name: Lint
- run: npm run lint --prefix apps/web
+ run: npm run lint -w web
- name: Build
- run: npm run build --prefix apps/web
+ run: npm run build -w web
playwright-e2e:
name: E2E Tests (Playwright)
@@ -85,10 +85,8 @@ jobs:
node-version: 20
cache: 'npm'
cache-dependency-path: package-lock.json
- - name: Install root dependencies
- run: npm install
- - name: Install web dependencies
- run: npm install --prefix apps/web
+ - name: Install dependencies
+ run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..521a9f7
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+legacy-peer-deps=true
diff --git a/Cargo.lock b/Cargo.lock
index cbe7334..f0475b3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1739,6 +1739,9 @@ dependencies = [
"serde_urlencoded",
"similar",
"tokio",
+]
+
+[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 44126c9..06c503e 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -1,38 +1,119 @@
@import "tailwindcss";
:root {
- --background: #f8f5ef;
- --foreground: #0f172a;
- --font-sans-stack: "Avenir Next", "Segoe UI", sans-serif;
+ --background: #f7f4ed;
+ --foreground: #112032;
+ --card: rgba(255, 250, 243, 0.9);
+ --card-foreground: #112032;
+ --popover: rgba(255, 250, 243, 0.98);
+ --popover-foreground: #112032;
+ --primary: #0d7c66;
+ --primary-foreground: #effcf8;
+ --secondary: #e7dfd0;
+ --secondary-foreground: #112032;
+ --muted: #efe7db;
+ --muted-foreground: #536273;
+ --accent: #e3f6f1;
+ --accent-foreground: #0f4f43;
+ --destructive: #c65353;
+ --destructive-foreground: #fff8f7;
+ --border: rgba(17, 32, 50, 0.12);
+ --input: rgba(17, 32, 50, 0.16);
+ --ring: rgba(13, 124, 102, 0.42);
+ --chart-1: #0d7c66;
+ --chart-2: #e29a2f;
+ --chart-3: #173b63;
+ --chart-4: #58b89b;
+ --chart-5: #9d6132;
+ --radius: 1.25rem;
+ --font-sans-stack: "Sora", "Avenir Next", "Segoe UI", sans-serif;
--font-mono-stack: "SFMono-Regular", "SF Mono", "Roboto Mono", monospace;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --radius-sm: calc(var(--radius) - 8px);
+ --radius-md: calc(var(--radius) - 4px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
--font-sans: var(--font-sans-stack);
--font-mono: var(--font-mono-stack);
}
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0f172a;
- --foreground: #f8fafc;
- }
+.dark {
+ --background: #09131f;
+ --foreground: #edf6f7;
+ --card: rgba(10, 21, 34, 0.84);
+ --card-foreground: #edf6f7;
+ --popover: rgba(9, 19, 31, 0.96);
+ --popover-foreground: #edf6f7;
+ --primary: #45c1a2;
+ --primary-foreground: #052c24;
+ --secondary: rgba(89, 111, 131, 0.22);
+ --secondary-foreground: #edf6f7;
+ --muted: rgba(89, 111, 131, 0.2);
+ --muted-foreground: #98a8b8;
+ --accent: rgba(69, 193, 162, 0.14);
+ --accent-foreground: #d8fbf2;
+ --destructive: #f28585;
+ --destructive-foreground: #20090a;
+ --border: rgba(237, 246, 247, 0.1);
+ --input: rgba(237, 246, 247, 0.12);
+ --ring: rgba(69, 193, 162, 0.3);
+ --chart-1: #45c1a2;
+ --chart-2: #f4b860;
+ --chart-3: #87aef2;
+ --chart-4: #4f8b7a;
+ --chart-5: #ff9770;
}
-* {
- box-sizing: border-box;
-}
+@layer base {
+ * {
+ @apply border-border;
+ }
+
+ html {
+ scroll-behavior: smooth;
+ }
+
+ body {
+ @apply bg-background text-foreground;
+ min-height: 100vh;
+ font-family: var(--font-sans-stack);
+ background-image:
+ radial-gradient(circle at top left, rgba(226, 154, 47, 0.18), transparent 24%),
+ radial-gradient(circle at 90% 18%, rgba(13, 124, 102, 0.14), transparent 22%),
+ linear-gradient(180deg, rgba(247, 244, 237, 0.98), rgba(240, 245, 247, 0.95));
+ }
-html {
- scroll-behavior: smooth;
+ .dark body {
+ background-image:
+ radial-gradient(circle at top left, rgba(242, 183, 90, 0.12), transparent 20%),
+ radial-gradient(circle at 88% 16%, rgba(69, 193, 162, 0.16), transparent 22%),
+ linear-gradient(180deg, rgba(9, 19, 31, 0.98), rgba(8, 17, 29, 0.96));
+ }
}
-body {
- background: var(--background);
- color: var(--foreground);
- font-family: var(--font-sans-stack);
+* {
+ box-sizing: border-box;
}
a {
@@ -41,5 +122,27 @@ a {
}
::selection {
- background: rgba(245, 158, 11, 0.28);
+ background: rgba(69, 193, 162, 0.28);
+}
+
+.glass-surface {
+ background: color-mix(in srgb, var(--card) 82%, transparent);
+ backdrop-filter: blur(18px);
+}
+
+.noise-overlay {
+ position: relative;
+}
+
+.noise-overlay::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background-image:
+ linear-gradient(rgba(255, 255, 255, 0.024) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255, 255, 255, 0.024) 1px, transparent 1px);
+ background-size: 24px 24px;
+ mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.9), transparent);
+ opacity: 0.35;
}
diff --git a/apps/web/app/jobs/[id]/page.tsx b/apps/web/app/jobs/[id]/page.tsx
index a3b6da8..21316d3 100644
--- a/apps/web/app/jobs/[id]/page.tsx
+++ b/apps/web/app/jobs/[id]/page.tsx
@@ -72,11 +72,11 @@ export default function JobDetailsPage() {
setBusyAction(`accept-${bidId}`);
try {
- await api.bids.accept(id, bidId, {
+ const acceptedJob = await api.bids.accept(id, bidId, {
client_address: workspace.job.client_address,
});
- await workspace.refresh();
- router.push(`/jobs/${id}/fund`);
+ void workspace.refresh();
+ router.push(`/jobs/${acceptedJob.id}/fund`);
} catch {
alert("Failed to accept bid");
} finally {
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 2f47ccf..56781d1 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,10 +1,13 @@
import type { Metadata } from "next";
import "./globals.css";
+import { DashboardLayout } from "@/components/layout/dashboard-layout";
+import { Providers } from "@/components/providers";
import { ToastProvider } from "@/components/ui/toast-provider";
export const metadata: Metadata = {
- title: "Lance - Decentralized Freelance Marketplace",
- description: "Stellar-native freelance marketplace with AI-powered dispute resolution",
+ title: "Lance | Soroban Freelance Intelligence",
+ description:
+ "Soroban-native freelance operations with escrow, reputation, and dispute intelligence.",
};
export default function RootLayout({
@@ -13,10 +16,15 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
-
- {children}
+
+
+
+
+ {children}
+
+
);
}
+
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 1f90e6f..7164a02 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -1,30 +1,5 @@
-import Link from "next/link";
-import { ArrowRight, BriefcaseBusiness, Gavel, ShieldCheck, Star } from "lucide-react";
import { SiteShell } from "@/components/site-shell";
-
-const highlights = [
- {
- title: "Trustless Profiles",
- description:
- "Blend editable bios and portfolio links with Soroban reputation math so serious freelancers can market verified credibility everywhere.",
- href: "/profile/GD...CLIENT",
- icon: Star,
- },
- {
- title: "Live Job Workspaces",
- description:
- "Keep both sides aligned around milestones, evidence, escrow state, and payout actions inside a single shared dashboard.",
- href: "/jobs",
- icon: BriefcaseBusiness,
- },
- {
- title: "Neutral Dispute Center",
- description:
- "Explain evidence, AI reasoning, and final payout splits with courtroom-level clarity once cooperation breaks down.",
- href: "/jobs",
- icon: Gavel,
- },
-];
+import { RoleOverview } from "@/components/dashboard/role-overview";
export default function Home() {
return (
@@ -33,89 +8,7 @@ export default function Home() {
title="Premium freelance execution with escrow, verifiable reputation, and transparent AI arbitration."
description="Lance is the surface layer for serious clients and elite independents who want payment security, immutable trust signals, and fast dispute resolution without losing clarity."
>
-
-
-
-
- Trust by design
-
-
- Every page is built to make strong operators look stronger.
-
-
- Public profiles become acquisition funnels, active jobs become
- command centers, and disputes become legible instead of chaotic.
-
-
-
- Explore Job Board
-
-
-
- Post a Job
-
-
-
-
-
-
-
-
-
- {highlights.map((item) => {
- const Icon = item.icon;
- return (
-
-
-
-
-
- {item.title}
-
-
- {item.description}
-
-
- Open surface
-
-
-
- );
- })}
-
+
);
}
diff --git a/apps/web/components.json b/apps/web/components.json
new file mode 100644
index 0000000..3d296c4
--- /dev/null
+++ b/apps/web/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "stone",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/apps/web/components/auth/session-switcher.tsx b/apps/web/components/auth/session-switcher.tsx
new file mode 100644
index 0000000..05a27c0
--- /dev/null
+++ b/apps/web/components/auth/session-switcher.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { UserRound } from "lucide-react";
+import { useAuthStore, type UserRole } from "@/lib/store/use-auth-store";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+const SESSION_OPTIONS: Array<{
+ role: UserRole;
+ label: string;
+ description: string;
+}> = [
+ {
+ role: "logged-out",
+ label: "Visitor",
+ description: "See the public marketplace experience.",
+ },
+ {
+ role: "client",
+ label: "Client",
+ description: "Review hiring, escrow, and talent tools.",
+ },
+ {
+ role: "freelancer",
+ label: "Freelancer",
+ description: "Review discovery, contracts, and payouts.",
+ },
+];
+
+export function SessionSwitcher() {
+ const { role, setRole } = useAuthStore();
+
+ return (
+
+
+
+
+
+ Preview navigation by role
+
+ {SESSION_OPTIONS.map((option) => (
+ setRole(option.role)}
+ className="flex flex-col items-start gap-1"
+ >
+ {option.label}
+ {option.description}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/components/dashboard/role-overview.tsx b/apps/web/components/dashboard/role-overview.tsx
new file mode 100644
index 0000000..09ef10b
--- /dev/null
+++ b/apps/web/components/dashboard/role-overview.tsx
@@ -0,0 +1,145 @@
+"use client";
+
+import Link from "next/link";
+import { ArrowRight, BriefcaseBusiness, Gavel, ShieldCheck, Star } from "lucide-react";
+import { useAuthStore } from "@/lib/store/use-auth-store";
+import { Badge } from "@/components/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+
+const ROLE_COPY = {
+ "logged-out": {
+ pill: "Visitor mode",
+ title: "Explore the marketplace before you commit to a role.",
+ body:
+ "Preview public job discovery, trust signals, and dispute explainability from the same shell the product uses after sign-in.",
+ cta: { label: "Browse live jobs", href: "/jobs" },
+ },
+ client: {
+ pill: "Client mode",
+ title: "Run hiring, escrow, and milestone approvals from one surface.",
+ body:
+ "The client cockpit keeps brief intake, active registries, and payout confidence checks within a single operational flow.",
+ cta: { label: "Launch a new brief", href: "/jobs/new" },
+ },
+ freelancer: {
+ pill: "Freelancer mode",
+ title: "Scan better work and keep proof-of-work close to payouts.",
+ body:
+ "The freelancer workspace prioritizes opportunity discovery, active contracts, and legible dispute evidence without sacrificing speed.",
+ cta: { label: "Open the job registry", href: "/jobs" },
+ },
+} as const;
+
+const HIGHLIGHTS = [
+ {
+ title: "Trustless Profiles",
+ description:
+ "Blend editable bios with Soroban reputation math so serious freelancers can market verified credibility everywhere.",
+ href: "/profile/GD...CLIENT",
+ icon: Star,
+ },
+ {
+ title: "Live Job Workspaces",
+ description:
+ "Keep both sides aligned around milestones, evidence, escrow state, and payout actions in one shared dashboard.",
+ href: "/jobs",
+ icon: BriefcaseBusiness,
+ },
+ {
+ title: "Neutral Dispute Center",
+ description:
+ "Explain evidence, AI reasoning, and payout splits with courtroom-level clarity once cooperation breaks down.",
+ href: "/disputes/1",
+ icon: Gavel,
+ },
+];
+
+export function RoleOverview() {
+ const role = useAuthStore((state) => state.role);
+ const copy = ROLE_COPY[role];
+
+ return (
+ <>
+
+
+
+
+ {copy.pill}
+
+ {copy.title}
+
+ {copy.body}
+
+
+
+
+ {copy.cta.label}
+
+
+
+ Review dispute flow
+
+
+
+
+
+
+
+ Release posture
+
+ 4
+
+ Core surfaces aligned: profiles, marketplace, job overview, and dispute resolution.
+
+
+
+
+
+
+
Escrow-first workflow
+
+
+ Fund milestones, upload proof, approve releases, or escalate into a locked dispute flow with on-chain receipts.
+
+
+
+
+
+
+
+ {HIGHLIGHTS.map((item) => {
+ const Icon = item.icon;
+ return (
+
+
+
+
+
+
+ {item.title}
+ {item.description}
+
+ Open surface
+
+
+
+
+
+ );
+ })}
+
+ >
+ );
+}
diff --git a/apps/web/components/layout/dashboard-layout.tsx b/apps/web/components/layout/dashboard-layout.tsx
new file mode 100644
index 0000000..1de27e3
--- /dev/null
+++ b/apps/web/components/layout/dashboard-layout.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import React, { useState } from "react";
+import { TopNav } from "@/components/navigation/top-nav";
+import { Sidebar } from "@/components/navigation/sidebar";
+import { cn } from "@/lib/utils";
+
+interface DashboardLayoutProps {
+ children: React.ReactNode;
+}
+
+export function DashboardLayout({ children }: DashboardLayoutProps) {
+ const [isSidebarOpen, setIsSidebarOpen] = useState(false);
+
+ return (
+
+
setIsSidebarOpen(!isSidebarOpen)} />
+
+
+
+
+ {isSidebarOpen && (
+
setIsSidebarOpen(false)}
+ />
+ )}
+
+
+
+ {children}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/navigation/sidebar.tsx b/apps/web/components/navigation/sidebar.tsx
new file mode 100644
index 0000000..076a1d6
--- /dev/null
+++ b/apps/web/components/navigation/sidebar.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import Link from "next/link";
+import { useAuthStore } from "@/lib/store/use-auth-store";
+import { cn } from "@/lib/utils";
+import {
+ LayoutDashboard,
+ Briefcase,
+ MessageSquare,
+ FileText,
+ Settings,
+ PlusCircle,
+ Users,
+ Zap,
+ ShieldCheck,
+ Home,
+ TrendingUp,
+ BarChart2,
+ Search as SearchIcon
+} from "lucide-react";
+import { usePathname } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Separator } from "@/components/ui/separator";
+
+
+interface NavItemProps {
+ href: string;
+ icon: React.ReactNode;
+ label: string;
+ badge?: string;
+}
+
+function NavItem({ href, icon, label, badge }: NavItemProps) {
+ const pathname = usePathname();
+ const isActive = pathname === href || pathname.startsWith(`${href}/`);
+
+ return (
+
+
+ {icon}
+
+ {label}
+ {badge ? (
+
+ {badge}
+
+ ) : null}
+ {isActive && }
+
+ );
+}
+
+const DASHBOARD_LINKS: Record = {
+ client: [
+ { href: "/", icon: , label: "Overview" },
+ { href: "/jobs/new", icon: , label: "Post a Job" },
+ { href: "/jobs", icon: , label: "My Jobs", badge: "12" },
+ { href: "/profile/GD...CLIENT", icon: , label: "Find Talent" },
+ { href: "/disputes/1", icon: , label: "Disputes" },
+ { href: "/jobs/1", icon: , label: "Escrow Analytics" },
+ { href: "/profile/GD...CLIENT", icon: , label: "Settings" },
+ ],
+ freelancer: [
+ { href: "/", icon: , label: "Overview" },
+ { href: "/jobs", icon: , label: "Find Work", badge: "24" },
+ { href: "/jobs/1", icon: , label: "Active Contracts" },
+ { href: "/jobs/1/fund", icon: , label: "Milestones" },
+ { href: "/disputes/1", icon: , label: "Disputes" },
+ { href: "/profile/GD...CLIENT", icon: , label: "Profile Settings" },
+ ],
+ 'logged-out': [
+ { href: "/", icon: , label: "Home" },
+ { href: "/jobs", icon: , label: "Explore Lance" },
+ { href: "/disputes/1", icon: , label: "Trust & Safety" },
+ ],
+};
+
+
+
+export function Sidebar({ className }: { className?: string }) {
+ const { role, isLoggedIn, user } = useAuthStore();
+ const links = DASHBOARD_LINKS[role] || DASHBOARD_LINKS['logged-out'];
+
+ return (
+
+ );
+}
diff --git a/apps/web/components/navigation/top-nav.tsx b/apps/web/components/navigation/top-nav.tsx
new file mode 100644
index 0000000..e8d1758
--- /dev/null
+++ b/apps/web/components/navigation/top-nav.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import Link from "next/link";
+import { useAuthStore } from "@/lib/store/use-auth-store";
+import { Button } from "@/components/ui/button";
+import { Search, Bell, Menu, LogOut, BriefcaseBusiness } from "lucide-react";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Input } from "@/components/ui/input";
+import { SessionSwitcher } from "@/components/auth/session-switcher";
+import { ThemeToggle } from "@/components/theme/theme-toggle";
+
+export function TopNav({ onOpenSidebar }: { onOpenSidebar?: () => void }) {
+ const { isLoggedIn, logout, login, role, user } = useAuthStore();
+
+ return (
+
+
+
+
+
+
+
+ LN
+
+
+
+ Lance
+
+
+ {isLoggedIn ? `${role} workspace` : "Public network"}
+
+
+
+
+
+
+
+
+
+
+
+
+ {isLoggedIn ? (
+
+
+
+
+
+ {user?.name
+ ?.split(" ")
+ .map((part) => part[0])
+ .join("")
+ .slice(0, 2) ?? "LN"}
+
+
+
+
{user?.name}
+
{user?.email}
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/components/providers.tsx b/apps/web/components/providers.tsx
new file mode 100644
index 0000000..7ddf283
--- /dev/null
+++ b/apps/web/components/providers.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { ThemeProvider } from "next-themes";
+import React from "react";
+import { AuthBootstrap } from "@/components/state/auth-bootstrap";
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/components/site-shell.tsx b/apps/web/components/site-shell.tsx
index c7875a7..2f274c0 100644
--- a/apps/web/components/site-shell.tsx
+++ b/apps/web/components/site-shell.tsx
@@ -1,14 +1,7 @@
import Link from "next/link";
-import { BriefcaseBusiness, Gavel, ShieldCheck, UserRound } from "lucide-react";
+import { ArrowRight } from "lucide-react";
import type { ReactNode } from "react";
-const NAV_ITEMS = [
- { href: "/jobs", label: "Job Board", icon: BriefcaseBusiness },
- { href: "/jobs/new", label: "Post Job", icon: ShieldCheck },
- { href: "/profile/GD...CLIENT", label: "Profiles", icon: UserRound },
- { href: "/jobs", label: "Disputes", icon: Gavel },
-];
-
export function SiteShell({
eyebrow,
title,
@@ -21,54 +14,45 @@ export function SiteShell({
children: ReactNode;
}) {
return (
-