From dc77d5bc84a49586551838b630084c40be6fabdc Mon Sep 17 00:00:00 2001 From: Pericena Date: Wed, 9 Sep 2026 03:27:28 -0400 Subject: [PATCH] feat: complete web hire flow --- apps/web/index.html | 8 +- apps/web/public/manifest.webmanifest | 32 ++ apps/web/public/sw.js | 36 ++ .../src/components/layout/app-bottom-nav.tsx | 92 +++++ apps/web/src/components/layout/app-header.tsx | 135 +++++++ .../src/components/layout/wallet-glyph.tsx | 62 ++++ apps/web/src/components/ui/button.tsx | 61 +++- apps/web/src/components/ui/dialog.tsx | 147 ++++++++ apps/web/src/components/ui/field.tsx | 156 ++++++++ apps/web/src/components/ui/heading.tsx | 36 ++ apps/web/src/components/ui/icon.tsx | 10 + apps/web/src/components/ui/index.ts | 18 + apps/web/src/components/ui/pagination.tsx | 100 ++++++ apps/web/src/components/ui/search-input.tsx | 64 ++++ apps/web/src/components/ui/state.tsx | 219 +++++++++--- apps/web/src/components/ui/toast.tsx | 89 +++++ .../src/features/account/activity-list.tsx | 55 +++ apps/web/src/features/account/hire-list.tsx | 80 +++++ apps/web/src/features/account/inbox.tsx | 193 ++++++++++ apps/web/src/features/account/notice-list.tsx | 110 ++++++ apps/web/src/features/account/wallet-card.tsx | 218 ++++++++++++ .../web/src/features/catalog/agent-avatar.tsx | 28 ++ apps/web/src/features/catalog/agent-card.tsx | 62 +++- .../web/src/features/catalog/category-meta.ts | 24 ++ .../web/src/features/catalog/category-nav.tsx | 166 +++++++-- .../src/features/catalog/filter-fields.tsx | 163 +++++++++ apps/web/src/features/catalog/marketplace.tsx | 267 ++++++++++++++ .../web/src/features/catalog/promo-banner.tsx | 72 ++++ apps/web/src/features/catalog/use-catalog.ts | 65 ++++ apps/web/src/features/hire/hire-confirm.tsx | 177 +++++++++ apps/web/src/features/hire/hire-cta.tsx | 335 ++++++++++++++---- .../src/features/signals/category-signal.tsx | 95 +++-- .../web/src/features/signals/signal-chart.tsx | 296 ++++++++++++++++ apps/web/src/lib/catalog.ts | 111 ++++++ apps/web/src/lib/format.ts | 65 ++++ apps/web/src/lib/inbox.ts | 91 +++++ apps/web/src/main.tsx | 6 + .../src/providers/wrong-network-banner.tsx | 9 +- apps/web/src/router.tsx | 31 ++ apps/web/src/routes/__root.tsx | 84 ++--- apps/web/src/routes/agents/$agentId.tsx | 175 +++++++-- apps/web/src/routes/browse/$category.tsx | 160 ++------- apps/web/src/routes/index.tsx | 109 ++++-- apps/web/src/routes/profile.tsx | 111 ++++++ apps/web/src/styles.css | 124 ++++++- apps/web/tsconfig.json | 3 +- 46 files changed, 4327 insertions(+), 423 deletions(-) create mode 100644 apps/web/public/manifest.webmanifest create mode 100644 apps/web/public/sw.js create mode 100644 apps/web/src/components/layout/app-bottom-nav.tsx create mode 100644 apps/web/src/components/layout/app-header.tsx create mode 100644 apps/web/src/components/layout/wallet-glyph.tsx create mode 100644 apps/web/src/components/ui/dialog.tsx create mode 100644 apps/web/src/components/ui/field.tsx create mode 100644 apps/web/src/components/ui/heading.tsx create mode 100644 apps/web/src/components/ui/icon.tsx create mode 100644 apps/web/src/components/ui/pagination.tsx create mode 100644 apps/web/src/components/ui/search-input.tsx create mode 100644 apps/web/src/components/ui/toast.tsx create mode 100644 apps/web/src/features/account/activity-list.tsx create mode 100644 apps/web/src/features/account/hire-list.tsx create mode 100644 apps/web/src/features/account/inbox.tsx create mode 100644 apps/web/src/features/account/notice-list.tsx create mode 100644 apps/web/src/features/account/wallet-card.tsx create mode 100644 apps/web/src/features/catalog/agent-avatar.tsx create mode 100644 apps/web/src/features/catalog/category-meta.ts create mode 100644 apps/web/src/features/catalog/filter-fields.tsx create mode 100644 apps/web/src/features/catalog/marketplace.tsx create mode 100644 apps/web/src/features/catalog/promo-banner.tsx create mode 100644 apps/web/src/features/catalog/use-catalog.ts create mode 100644 apps/web/src/features/hire/hire-confirm.tsx create mode 100644 apps/web/src/features/signals/signal-chart.tsx create mode 100644 apps/web/src/lib/catalog.ts create mode 100644 apps/web/src/lib/format.ts create mode 100644 apps/web/src/lib/inbox.ts create mode 100644 apps/web/src/routes/profile.tsx diff --git a/apps/web/index.html b/apps/web/index.html index c61adc2..56a6c66 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,9 +2,15 @@ - + pulse + + + + + + diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest new file mode 100644 index 0000000..fbdf613 --- /dev/null +++ b/apps/web/public/manifest.webmanifest @@ -0,0 +1,32 @@ +{ + "name": "pulse", + "short_name": "pulse", + "description": "Hire BNB Chain agents by the job they do.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#0B0E11", + "theme_color": "#0B0E11", + "lang": "en", + "icons": [ + { + "src": "/product/pulse-mark.svg", + "type": "image/svg+xml", + "sizes": "any", + "purpose": "any" + }, + { + "src": "/product/pulse-icon.png", + "type": "image/png", + "sizes": "192x192", + "purpose": "any" + }, + { + "src": "/product/pulse-icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "any maskable" + } + ] +} diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 0000000..e55cc8f --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,36 @@ +const CACHE = "pulse-shell-v1"; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(["/", "/manifest.webmanifest"])), + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))), + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + const url = new URL(event.request.url); + if (url.origin !== self.location.origin) return; + if (url.pathname.startsWith("/src/")) return; + + event.respondWith( + fetch(event.request) + .then((response) => { + if (response.ok && event.request.destination !== "document") { + const copy = response.clone(); + caches.open(CACHE).then((cache) => cache.put(event.request, copy)); + } + return response; + }) + .catch(() => caches.match(event.request).then((cached) => cached ?? caches.match("/"))), + ); +}); diff --git a/apps/web/src/components/layout/app-bottom-nav.tsx b/apps/web/src/components/layout/app-bottom-nav.tsx new file mode 100644 index 0000000..a1c9552 --- /dev/null +++ b/apps/web/src/components/layout/app-bottom-nav.tsx @@ -0,0 +1,92 @@ +import { Link, useRouterState } from "@tanstack/react-router"; +import { Bell, LayoutGrid, UserRound, Wallet } from "lucide-react"; +import { Icon } from "@/components/ui/icon"; +import { useInbox } from "@/features/account/inbox"; +import { cn } from "@/lib/cn"; + +export function AppBottomNav() { + const path = useRouterState({ select: (s) => s.location.pathname }); + const profileTab = useRouterState({ + select: (s) => { + const tab = (s.location.search as { tab?: string }).tab; + return typeof tab === "string" ? tab : ""; + }, + }); + const { unreadCount } = useInbox(); + const onProfile = path.startsWith("/profile"); + const onMarket = path === "/" || path.startsWith("/browse") || path.startsWith("/agents"); + + return ( + + ); +} + +function Item({ + to, + search, + label, + icon, + active, + badge = 0, +}: { + to: "/" | "/profile"; + search?: { tab: string }; + label: string; + icon: typeof LayoutGrid; + active: boolean; + badge?: number; +}) { + return ( +
  • + 0 ? `${label}, ${badge} unread` : label} + className={cn( + "relative flex min-h-12 flex-col items-center justify-center gap-0.5 text-[11px]", + active ? "text-accent" : "text-muted", + )} + > + + + {badge > 0 ? ( + + ) : null} + + {label} + +
  • + ); +} diff --git a/apps/web/src/components/layout/app-header.tsx b/apps/web/src/components/layout/app-header.tsx new file mode 100644 index 0000000..941ea3c --- /dev/null +++ b/apps/web/src/components/layout/app-header.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; +import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { Bell, UserRound } from "lucide-react"; +import { CATEGORY_LABELS } from "@era/domain"; +import { PulsePersistMark } from "@/components/brand/pulse-persist-mark"; +import { PulseWordmark } from "@/components/brand/pulse-wordmark"; +import { WalletGlyph } from "@/components/layout/wallet-glyph"; +import { SearchInput } from "@/components/ui"; +import { Icon } from "@/components/ui/icon"; +import { useInbox } from "@/features/account/inbox"; +import { CATEGORY_ICONS, CATEGORY_ORDER } from "@/features/catalog/category-meta"; +import { cn } from "@/lib/cn"; + +export function AppHeader() { + const navigate = useNavigate(); + const path = useRouterState({ select: (s) => s.location.pathname }); + const searchQ = useRouterState({ + select: (s) => { + const q = (s.location.search as { q?: string }).q; + return typeof q === "string" ? q : ""; + }, + }); + const profileTab = useRouterState({ + select: (s) => { + const tab = (s.location.search as { tab?: string }).tab; + return typeof tab === "string" ? tab : ""; + }, + }); + const { unreadCount } = useInbox(); + const [query, setQuery] = useState(searchQ); + + useEffect(() => { + setQuery(searchQ); + }, [searchQ]); + + function submitSearch(value: string) { + const q = value.trim() || undefined; + const browse = path.match(/^\/browse\/([^/]+)/); + const category = browse?.[1]; + if (category) { + void navigate({ to: "/browse/$category", params: { category }, search: { q } }); + } else { + void navigate({ to: "/", search: { q } }); + } + } + + const onProfile = path.startsWith("/profile"); + const onNotifications = onProfile && profileTab === "notifications"; + + return ( +
    +
    + + + + + + + + + + + + + +
    + 0 ? `Notifications, ${unreadCount} unread` : "Notifications"} + aria-current={onNotifications ? "page" : undefined} + className={cn("header-icon hidden sm:inline-flex", onNotifications && "text-accent")} + > + + {unreadCount > 0 ? ( + + ) : null} + + + + + +
    + +
    +
    +
    +
    + ); +} + +function navClass(active: boolean) { + return cn( + "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-sm transition-colors", + active ? "bg-ink-2 text-paper" : "text-muted hover:text-paper", + ); +} + +function NavLink({ to, active, children }: { to: "/"; active: boolean; children: string }) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/layout/wallet-glyph.tsx b/apps/web/src/components/layout/wallet-glyph.tsx new file mode 100644 index 0000000..0a2d70e --- /dev/null +++ b/apps/web/src/components/layout/wallet-glyph.tsx @@ -0,0 +1,62 @@ +import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { Wallet } from "lucide-react"; +import { Icon } from "@/components/ui/icon"; +import { cn } from "@/lib/cn"; + +export function WalletGlyph({ className }: { className?: string }) { + return ( + + {({ account, chain, openAccountModal, openChainModal, openConnectModal, mounted }) => { + const ready = mounted; + const connected = Boolean(ready && account && chain); + const wrong = Boolean(connected && chain?.unsupported); + + return ( +
    + {connected && account && chain && !wrong ? ( + + ) : connected && wrong ? ( + + ) : ( + + )} +
    + ); + }} +
    + ); +} + +function identColor(address: string): string { + const hue = Number.parseInt(address.slice(2, 8), 16) % 360; + return `hsl(${hue} 70% 48%)`; +} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 3e5c0ed..395fa4f 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -1,44 +1,85 @@ -import { type ButtonHTMLAttributes, type ReactNode } from "react"; +import { type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode } from "react"; +import { Loader2 } from "lucide-react"; import { cn } from "@/lib/cn"; +import { Icon } from "./icon"; -type Variant = "primary" | "ghost" | "danger"; +type Variant = "primary" | "secondary" | "ghost" | "danger"; export function Button({ className, variant = "primary", + loading = false, + children, + disabled, + type = "button", ...props -}: ButtonHTMLAttributes & { variant?: Variant }) { +}: ButtonHTMLAttributes & { + variant?: Variant; + loading?: boolean; +}) { return ( ); } export function Card({ className, children, + ...props }: { className?: string; children: ReactNode; -}) { +} & HTMLAttributes) { return ( -
    +
    {children}
    ); } -export function Badge({ children }: { children: ReactNode }) { +export function Badge({ + children, + tone = "muted", + className, +}: { + children: ReactNode; + tone?: "muted" | "accent" | "ok" | "danger"; + className?: string; +}) { return ( - + {children} ); diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..d1d0e25 --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,147 @@ +import { + useEffect, + useId, + useRef, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from "react"; +import { X } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { Button } from "./button"; +import { Icon } from "./icon"; + +type Placement = "center" | "bottom" | "right"; + +const FOCUSABLE = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export function Dialog({ + open, + onClose, + title, + description, + children, + footer, + placement = "center", + labelledBy, +}: { + open: boolean; + onClose: () => void; + title: string; + description?: string; + children?: ReactNode; + footer?: ReactNode; + placement?: Placement; + labelledBy?: string; +}) { + const panelRef = useRef(null); + const lastFocus = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const generatedId = useId(); + const titleId = labelledBy ?? generatedId; + + useEffect(() => { + if (!open) return; + lastFocus.current = document.activeElement as HTMLElement | null; + const panel = panelRef.current; + const first = panel?.querySelector(FOCUSABLE); + first?.focus(); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + function onKey(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + onCloseRef.current(); + return; + } + if (event.key !== "Tab" || !panel) return; + const nodes = [...panel.querySelectorAll(FOCUSABLE)].filter( + (node) => !node.hasAttribute("disabled") && node.tabIndex !== -1, + ); + if (nodes.length === 0) return; + const firstNode = nodes[0]; + const lastNode = nodes[nodes.length - 1]; + if (!firstNode || !lastNode) return; + if (event.shiftKey && document.activeElement === firstNode) { + event.preventDefault(); + lastNode.focus(); + } else if (!event.shiftKey && document.activeElement === lastNode) { + event.preventDefault(); + firstNode.focus(); + } + } + + document.addEventListener("keydown", onKey); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener("keydown", onKey); + lastFocus.current?.focus(); + }; + }, [open]); + + if (!open) return null; + + function onOverlayClick() { + onClose(); + } + + function onPanelKeyDown(event: ReactKeyboardEvent) { + event.stopPropagation(); + } + + return ( +
    + +
    +
    {children}
    + {footer ? ( +
    + {footer} +
    + ) : null} +
    + + ); +} diff --git a/apps/web/src/components/ui/field.tsx b/apps/web/src/components/ui/field.tsx new file mode 100644 index 0000000..518951e --- /dev/null +++ b/apps/web/src/components/ui/field.tsx @@ -0,0 +1,156 @@ +import { + type InputHTMLAttributes, + type ReactNode, + type SelectHTMLAttributes, + type TextareaHTMLAttributes, +} from "react"; +import { CircleAlert } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { Icon } from "./icon"; + +const controlClass = + "w-full rounded-2xl border bg-ink px-3 py-2.5 text-sm text-paper placeholder:text-muted transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"; + +function FieldShell({ + id, + label, + hint, + error, + children, +}: { + id: string; + label: string; + hint?: string; + error?: string; + children: ReactNode; +}) { + const hintId = hint ? `${id}-hint` : undefined; + const errorId = error ? `${id}-error` : undefined; + return ( +
    + + {children} + {hint && !error ? ( +

    + {hint} +

    + ) : null} + {error ? ( + + ) : null} +
    + ); +} + +function describedBy(hint?: string, error?: string, id?: string): string | undefined { + const parts: string[] = []; + if (error && id) parts.push(`${id}-error`); + else if (hint && id) parts.push(`${id}-hint`); + return parts.length ? parts.join(" ") : undefined; +} + +export function TextField({ + id, + label, + hint, + error, + className, + ...props +}: InputHTMLAttributes & { + id: string; + label: string; + hint?: string; + error?: string; +}) { + return ( + + + + ); +} + +export function TextAreaField({ + id, + label, + hint, + error, + className, + ...props +}: TextareaHTMLAttributes & { + id: string; + label: string; + hint?: string; + error?: string; +}) { + return ( + +