Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>pulse</title>
<meta name="description" content="Hire BNB Chain agents by the job they do." />
<meta name="theme-color" content="#0B0E11" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="pulse" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/product/favicon.ico" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/product/pulse-mark.svg" />
<link rel="apple-touch-icon" href="/product/pulse-icon.png" />
Expand Down
32 changes: 32 additions & 0 deletions apps/web/public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
36 changes: 36 additions & 0 deletions apps/web/public/sw.js
Original file line number Diff line number Diff line change
@@ -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("/"))),
);
});
92 changes: 92 additions & 0 deletions apps/web/src/components/layout/app-bottom-nav.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav aria-label="App" className="bottom-nav lg:hidden">
<ul className="mx-auto grid max-w-6xl grid-cols-4">
<Item
to="/"
label="Market"
icon={LayoutGrid}
active={onMarket}
/>
<Item
to="/profile"
search={{ tab: "wallet" }}
label="Wallet"
icon={Wallet}
active={onProfile && profileTab === "wallet"}
/>
<Item
to="/profile"
search={{ tab: "notifications" }}
label="Alerts"
icon={Bell}
active={onProfile && profileTab === "notifications"}
badge={unreadCount}
/>
<Item
to="/profile"
search={{ tab: "overview" }}
label="Profile"
icon={UserRound}
active={onProfile && profileTab !== "wallet" && profileTab !== "notifications"}
/>
</ul>
</nav>
);
}

function Item({
to,
search,
label,
icon,
active,
badge = 0,
}: {
to: "/" | "/profile";
search?: { tab: string };
label: string;
icon: typeof LayoutGrid;
active: boolean;
badge?: number;
}) {
return (
<li>
<Link
to={to}
search={search}
aria-current={active ? "page" : undefined}
aria-label={badge > 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",
)}
>
<span className="relative inline-flex">
<Icon icon={icon} className="size-[1.15rem]" />
{badge > 0 ? (
<span className="absolute -top-0.5 -right-1 size-1.5 rounded-full bg-accent" aria-hidden />
) : null}
</span>
{label}
</Link>
</li>
);
}
135 changes: 135 additions & 0 deletions apps/web/src/components/layout/app-header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className="sticky top-0 z-40 border-b border-line/80 bg-ink/95 pt-[env(safe-area-inset-top)] backdrop-blur-md">
<div className="mx-auto flex h-12 max-w-6xl items-center gap-1 px-2 sm:h-14 sm:gap-3 sm:px-6">
<Link to="/" className="flex shrink-0 items-center px-1 text-paper" aria-label="pulse home">
<span className="sm:hidden">
<PulsePersistMark kind="mark" className="h-8 w-auto" />
</span>
<span className="hidden sm:block">
<PulseWordmark />
</span>
</Link>

<nav aria-label="Primary" className="hidden min-w-0 items-center gap-0.5 lg:flex">
<NavLink to="/" active={path === "/"}>
Marketplace
</NavLink>
{CATEGORY_ORDER.map((category) => (
<Link
key={category}
to="/browse/$category"
params={{ category }}
aria-current={path === `/browse/${category}` ? "page" : undefined}
className={navClass(path === `/browse/${category}`)}
>
<Icon icon={CATEGORY_ICONS[category]} className="size-3.5" />
{CATEGORY_LABELS[category]}
</Link>
))}
</nav>

<SearchInput
id="header-search"
value={query}
onChange={setQuery}
onSubmit={submitSearch}
placeholder="Search agents…"
className="min-w-0 flex-1 lg:max-w-xs"
/>

<div className="ml-auto flex shrink-0 items-center">
<Link
to="/profile"
search={{ tab: "notifications" }}
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
aria-current={onNotifications ? "page" : undefined}
className={cn("header-icon hidden sm:inline-flex", onNotifications && "text-accent")}
>
<Icon icon={Bell} />
{unreadCount > 0 ? (
<span className="absolute top-1.5 right-1.5 size-1.5 rounded-full bg-accent" aria-hidden />
) : null}
</Link>
<WalletGlyph className="lg:hidden" />
<Link
to="/profile"
search={{ tab: "overview" }}
aria-label="Profile"
aria-current={onProfile && !onNotifications ? "page" : undefined}
className={cn("header-icon lg:hidden", onProfile && !onNotifications && "text-accent")}
>
<Icon icon={UserRound} />
</Link>
<div className="hidden lg:block">
<ConnectButton showBalance={false} accountStatus="full" chainStatus="icon" />
</div>
</div>
</div>
</header>
);
}

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 (
<Link to={to} className={navClass(active)}>
{children}
</Link>
);
}
62 changes: 62 additions & 0 deletions apps/web/src/components/layout/wallet-glyph.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ConnectButton.Custom>
{({ account, chain, openAccountModal, openChainModal, openConnectModal, mounted }) => {
const ready = mounted;
const connected = Boolean(ready && account && chain);
const wrong = Boolean(connected && chain?.unsupported);

return (
<div className={cn("flex items-center", className)} aria-busy={!ready || undefined}>
{connected && account && chain && !wrong ? (
<button
type="button"
onClick={openAccountModal}
aria-label={`Wallet ${account.displayName}`}
className="header-icon"
>
<span
aria-hidden
className="flex size-7 items-center justify-center rounded-full text-[10px] font-semibold text-ink"
style={{ background: identColor(account.address) }}
>
{account.address.slice(2, 4).toUpperCase()}
</span>
<span className="absolute top-1 right-1 size-1.5 rounded-full bg-ok" />
</button>
) : connected && wrong ? (
<button
type="button"
onClick={openChainModal}
aria-label="Wrong network"
className="header-icon"
>
<Icon icon={Wallet} />
<span className="absolute top-1 right-1 size-1.5 rounded-full bg-danger" />
</button>
) : (
<button
type="button"
onClick={openConnectModal}
aria-label="Connect wallet"
className="header-icon"
>
<Icon icon={Wallet} />
</button>
)}
</div>
);
}}
</ConnectButton.Custom>
);
}

function identColor(address: string): string {
const hue = Number.parseInt(address.slice(2, 8), 16) % 360;
return `hsl(${hue} 70% 48%)`;
}
Loading
Loading