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
48 changes: 48 additions & 0 deletions AGENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# AGENT.md — Stacks Wars Frontend

Canonical docs: https://docs.stackswars.com/

Do not invent architecture. Prefer docs + existing patterns in this repo.

## Scope

- App root: this `frontend/` package (Bun + Next.js).
- Games UI: `games/{gameId}/` registered via `games/boot.ts` + `games/registry.ts`.
- Keep `PLAYABLE_GAME_IDS` in sync with `boot.ts` imports.

## Architecture rules

1. **WebSocket-first** — lobby/room/presence updates flow through the multiplexed `/app` socket. Do not poll for state the socket already pushes.
2. **Server actions / API** — mutations that need secrets, Hiro, vault, or auth stay in `actions/` or Route Handlers; clients stay thin.
3. **No duplicated domain logic** — amounts, vault helpers, and formatters live in `lib/` (e.g. `lib/vault`, `lib/format`). Reuse them.
4. **Game isolation** — game-specific React stays under `games/{id}/`. Shared room chrome stays in `components/room/`.
5. **Registry only on the client** — `registerGame` is UI wiring; catalog metadata comes from the Rust API.

## Routing

- App shell: `app/(app)/…`
- Auth: `app/auth/`
- Profiles: `/profile/[username]`
- Prefer existing route groups; do not add parallel page trees for the same feature.

## UI

- Reuse `@/components/ui` and existing feature components.
- Match current visual language; do not introduce a second design system.
- Self-only controls (e.g. profile **Get ID**) must gate on session user id vs profile id so other visitors stay uncluttered.

## Realtime

- Use existing hooks/stores for connection status and room channels.
- After reconnect, rely on snapshot/resync paths already used by rooms — do not bespoke a second protocol.

## Games

- `gameId` must match backend `GameId` exactly.
- Required: `Room`. Optional: `LobbyPanel`, `Page`, `createActions`, `onMatchFinished`.
- Read https://docs.stackswars.com/develop/registration before adding a game.

## Don’t

- Use browser wallet extensions for play funds (custodial USDCx only).
- Commit secrets (mnemonics, KMS keys, cookie secrets).
2 changes: 1 addition & 1 deletion app/api/cron/lobby-ttl/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function apiBase() {
}

function cronAuthorized(request: Request): boolean {
const secret = process.env.CRON_SECRET?.trim() || process.env.INTERNAL_API_SECRET?.trim()
const secret = process.env.INTERNAL_API_SECRET?.trim()
if (!secret) return false
const header = request.headers.get("authorization")
const bearer = header?.startsWith("Bearer ") ? header.slice(7) : null
Expand Down
128 changes: 128 additions & 0 deletions components/profile/get-developer-id.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"use client"

import * as React from "react"
import { RiCheckLine, RiFileCopyLine, RiKey2Line } from "@remixicon/react"

import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui"
import { useNotificationsStore } from "@/stores/notifications"
import { useSessionStore } from "@/stores/session"

type GetDeveloperIdProps = {
/** Profile owner's platform user id (Neon `sub` / `UserId`). */
userId: string
}

/**
* Self-only control: opens a dialog so game developers can copy their
* platform user id for `GameMetadata.dev_id` registration.
* Hidden for other visitors so the profile stays uncluttered.
*/
export function GetDeveloperId({ userId }: GetDeveloperIdProps) {
const sessionUserId = useSessionStore((s) => s.user?.id)
const toast = useNotificationsStore((s) => s.toast)
const [open, setOpen] = React.useState(false)
const [copied, setCopied] = React.useState(false)

if (!sessionUserId || sessionUserId !== userId) {
return null
}

async function copyId() {
try {
await navigator.clipboard.writeText(userId)
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
toast({
title: "Developer ID copied",
body: "Paste it into your game metadata as dev_id.",
tone: "success",
})
} catch {
toast({
title: "Could not copy",
body: "Select the ID and copy it manually.",
tone: "danger",
})
}
}

return (
<>
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => setOpen(true)}
>
<RiKey2Line className="size-3.5" />
Get ID
</Button>

<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiKey2Line className="size-5 text-primary" />
Developer ID
</DialogTitle>
<DialogDescription>
Use this ID as{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
dev_id
</code>{" "}
when you register a game. It identifies you as the
developer for fee payouts. See{" "}
<a
href="https://docs.stackswars.com/develop/contributing"
className="text-foreground underline underline-offset-2"
target="_blank"
rel="noreferrer"
>
the developer docs
</a>
.
</DialogDescription>
</DialogHeader>

<div className="flex items-center gap-2 rounded-xl border border-border/70 bg-muted/40 p-3">
<code className="tnum min-w-0 flex-1 truncate font-mono text-xs sm:text-sm">
{userId}
</code>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Copy developer ID"
onClick={() => void copyId()}
>
{copied ? (
<RiCheckLine className="size-4 text-success" />
) : (
<RiFileCopyLine className="size-4" />
)}
</Button>
</div>

<DialogFooter>
<Button
type="button"
variant="primary"
onClick={() => void copyId().then(() => setOpen(false))}
>
Copy and close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
21 changes: 15 additions & 6 deletions components/profile/profile-header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { RiCalendarLine } from "@remixicon/react"

import { GetDeveloperId } from "@/components/profile/get-developer-id"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui"
import type { UserProfile } from "@/lib/api/types"
import { compact, displayNameFor, formatDate, ordinal } from "@/lib/format"
Expand All @@ -22,7 +23,7 @@ export function ProfileHeader({ profile }: { profile: UserProfile }) {
<div aria-hidden className="absolute inset-0 -z-10 bg-grid" />
<div
aria-hidden
className="absolute inset-0 -z-10 bg-gradient-to-r from-primary/15 via-transparent to-gold/10"
className="absolute inset-0 -z-10 bg-linear-to-r from-primary/15 via-transparent to-gold/10"
/>
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-6 p-5 sm:p-6">
<div className="flex min-w-0 items-center gap-4">
Expand All @@ -41,10 +42,13 @@ export function ProfileHeader({ profile }: { profile: UserProfile }) {
@{user.username}
</p>
) : null}
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<RiCalendarLine className="size-3.5" />
Joined {formatDate(user.createdAt)}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<RiCalendarLine className="size-3.5" />
Joined {formatDate(user.createdAt)}
</p>
<GetDeveloperId userId={user.id} />
</div>
</div>
</div>

Expand Down Expand Up @@ -90,7 +94,12 @@ function Headline({
<dt className="text-[11px] font-medium tracking-[0.16em] text-muted-foreground uppercase">
{label}
</dt>
<dd className={cn("tnum font-display text-2xl leading-none", accent)}>
<dd
className={cn(
"tnum font-display text-2xl leading-none",
accent
)}
>
{value}
</dd>
</div>
Expand Down
38 changes: 26 additions & 12 deletions components/shell/app-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,43 @@ const LINKS = [
{ href: "/games", label: "Games" },
{ href: "/lobbies", label: "Lobbies" },
{ href: "/leaderboard", label: "Leaderboard" },
{ href: "https://docs.stackswars.com", label: "Docs", external: true },
{ href: "https://t.me/stackswars", label: "Telegram", external: true },
]

export function AppFooter() {
return (
<footer className="mt-20 border-t border-border/60">
<div className="mx-auto flex w-full max-w-[1400px] flex-col gap-6 px-4 py-10 sm:px-6 md:flex-row md:items-center md:justify-between lg:px-8">
<div className="mx-auto flex w-full max-w-350 flex-col gap-6 px-4 py-10 sm:px-6 md:flex-row md:items-center md:justify-between lg:px-8">
<div className="space-y-2">
<Brand />
<p className="text-xs text-muted-foreground">
Skill-based multiplayer on Stacks. Entry fees are held in
an on-chain vault until a match settles.
Skill-based multiplayer on Stacks. Entry fees are held
in an on-chain vault until a match settles.
</p>
</div>
<nav className="flex flex-wrap gap-x-6 gap-y-2 text-sm text-muted-foreground">
{LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="hover:text-foreground"
>
{link.label}
</Link>
))}
{LINKS.map((link) =>
"external" in link && link.external ? (
<a
key={link.href}
href={link.href}
className="hover:text-foreground"
target="_blank"
rel="noreferrer"
>
{link.label}
</a>
) : (
<Link
key={link.href}
href={link.href}
className="hover:text-foreground"
>
{link.label}
</Link>
)
)}
</nav>
</div>
</footer>
Expand Down