Skip to content
Closed
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
14 changes: 14 additions & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
declare module "*.css" {
const value: { [className: string]: string };
export default value;
}

declare module "*.scss" {
const value: { [className: string]: string };
export default value;
}

declare module "*.sass" {
const value: { [className: string]: string };
export default value;
}
130 changes: 130 additions & 0 deletions src/app/components/AccountMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { useWallet } from "./useWallet";

export function AccountMenu() {
const { status, address, disconnect } = useWallet();
const [open, setOpen] = useState(false);
const [copyMessage, setCopyMessage] = useState("");
const buttonRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const firstItemRef = useRef<HTMLButtonElement | null>(null);

useEffect(() => {
if (!open) return;

function handleOutsideClick(event: MouseEvent) {
if (
menuRef.current &&
buttonRef.current &&
!menuRef.current.contains(event.target as Node) &&
!buttonRef.current.contains(event.target as Node)
) {
setOpen(false);
}
}

function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
}

document.addEventListener("mousedown", handleOutsideClick);
document.addEventListener("keydown", handleEscape);

return () => {
document.removeEventListener("mousedown", handleOutsideClick);
document.removeEventListener("keydown", handleEscape);
};
}, [open]);

useEffect(() => {
if (open) {
firstItemRef.current?.focus();
}
}, [open]);

const copyAddress = async () => {
if (!address) {
setCopyMessage("No address available");
return;
}

try {
await navigator.clipboard.writeText(address);
setCopyMessage("Copied");
} catch {
setCopyMessage("Copy failed");
}

window.setTimeout(() => {
setCopyMessage("");
}, 1500);
};

if (status !== "connected") {
return null;
}

return (
<div className="relative">
<button
ref={buttonRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls="account-menu"
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-slate-900/80 px-4 py-2 text-sm font-medium text-slate-100 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300/60"
onClick={() => setOpen((value) => !value)}
>
<span className="hidden sm:inline">Account</span>
<span>
{address ? `${address.slice(0, 6)}…${address.slice(-4)}` : "Account"}
</span>
<span aria-hidden="true" className="text-slate-500">
</span>
</button>

{open && (
<div
ref={menuRef}
id="account-menu"
role="menu"
aria-label="Account actions"
className="absolute right-0 z-10 mt-2 w-56 rounded-2xl border border-white/10 bg-slate-950/95 p-2 shadow-[0_24px_80px_rgba(0,0,0,0.3)] backdrop-blur-xl"
>
<button
ref={firstItemRef}
type="button"
role="menuitem"
onClick={copyAddress}
disabled={!address}
className="w-full rounded-2xl px-4 py-3 text-left text-sm text-slate-100 transition hover:bg-slate-900/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300/60 disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex items-center justify-between gap-2">
<span>Copy address</span>
<span className="text-xs text-slate-500">
{copyMessage || "Clipboard"}
</span>
</div>
</button>
<button
type="button"
role="menuitem"
onClick={() => {
disconnect();
setOpen(false);
}}
className="mt-2 w-full rounded-2xl px-4 py-3 text-left text-sm text-slate-100 transition hover:bg-slate-900/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300/60"
>
Disconnect
</button>
</div>
)}
</div>
);
}
133 changes: 133 additions & 0 deletions src/app/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"use client";

import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { WalletStatusBadge } from "./WalletStatusBadge";
import { AccountMenu } from "./AccountMenu";

export function Header() {
const [open, setOpen] = useState(false);
const closeButtonRef = useRef<HTMLButtonElement | null>(null);

useEffect(() => {
if (!open) return;

function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
}
}

document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open]);

return (
<>
<header className="border-b border-zinc-800 bg-zinc-950 backdrop-blur-xl">
<nav className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-5 py-4 sm:px-6">
<div className="flex flex-wrap items-center gap-4">
<Link
href="/"
className="text-lg font-semibold tracking-tight text-slate-100"
>
ChronoPay
</Link>
<div className="hidden sm:flex flex-wrap items-center gap-3 text-sm text-slate-400">
<Link
href="/"
className="rounded-full px-3 py-2 hover:bg-white/6 hover:text-slate-100 transition duration-200 ease-in-out"
>
Home
</Link>
<a
href="https://stellar.org"
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-white/10 px-3 py-2 hover:border-white/20 hover:bg-white/6 hover:text-slate-100 transition duration-200 ease-in-out"
>
Stellar
</a>
</div>
</div>

<div className="hidden sm:flex flex-wrap items-center gap-3">
<WalletStatusBadge />
<AccountMenu />
</div>

<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-zinc-900 px-4 py-2 text-sm font-medium text-slate-100 transition hover:bg-white/10 sm:hidden"
onClick={() => setOpen(true)}
aria-expanded={open}
aria-controls="mobile-menu"
aria-label="Open navigation menu"
>
Menu
</button>
</nav>
</header>

{open && (
<div
id="mobile-menu"
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 overflow-y-auto bg-neutral-600/50 p-6"
onClick={(event) => {
if (event.target === event.currentTarget) {
setOpen(false);
}
}}
>
<div className="mx-auto max-w-sm rounded-3xl border border-white/10 bg-zinc-950 p-6 shadow-2xl shadow-black/40">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-500">
Menu
</p>
<h2 className="mt-2 text-xl font-semibold text-slate-100">
ChronoPay
</h2>
</div>
<button
ref={closeButtonRef}
type="button"
className="rounded-full border border-white/10 bg-zinc-900 px-3 py-2 text-sm text-slate-100 hover:bg-white/10"
onClick={() => setOpen(false)}
aria-label="Close navigation menu"
>
Close
</button>
</div>

<div className="mt-6 space-y-4 text-sm">
<Link
href="/"
className="block rounded-2xl border border-white/10 bg-zinc-900 px-4 py-3 text-slate-100 hover:bg-white/5"
onClick={() => setOpen(false)}
>
Home
</Link>
<a
href="https://stellar.org"
target="_blank"
rel="noopener noreferrer"
className="block rounded-2xl border border-white/10 bg-zinc-900 px-4 py-3 text-slate-100 hover:bg-white/5"
onClick={() => setOpen(false)}
>
Stellar
</a>
</div>

<div className="mt-6 space-y-4">
<WalletStatusBadge />
<AccountMenu />
</div>
</div>
</div>
)}
</>
);
}
67 changes: 67 additions & 0 deletions src/app/components/WalletStatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"use client";

import { StatusChip } from "./ui/status-chip";
import { useWallet } from "./useWallet";

function formatAddress(address?: string) {
if (!address) return "Connected";
return `${address.slice(0, 6)}…${address.slice(-4)}`;
}

export function WalletStatusBadge() {
const { status, address, connect } = useWallet();

if (status === "loading") {
return (
<div
role="status"
aria-live="polite"
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-slate-900/80 px-3 py-2 text-sm text-slate-300"
>
<span
className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden="true"
/>
<span>Connecting wallet</span>
</div>
);
}

if (status === "error") {
return (
<div className="flex flex-wrap items-center gap-2">
<StatusChip tone="danger">Wallet error</StatusChip>
<button
type="button"
onClick={connect}
className="inline-flex items-center rounded-full border border-rose-300/20 bg-rose-500/10 px-4 py-2 text-sm font-medium text-rose-100 transition hover:bg-rose-500/20"
aria-label="Retry wallet connection"
>
Retry
</button>
</div>
);
}

if (status === "disconnected") {
return (
<div className="flex flex-wrap items-center gap-2">
<StatusChip tone="warning">Disconnected</StatusChip>
<button
type="button"
onClick={connect}
className="inline-flex items-center rounded-full border border-white/10 bg-slate-900/80 px-4 py-2 text-sm font-medium text-slate-100 transition hover:bg-white/10"
aria-label="Connect wallet"
>
Connect Wallet
</button>
</div>
);
}

return (
<span title={address ? `Connected wallet ${address}` : "Connected wallet"}>
<StatusChip tone="success">{formatAddress(address)}</StatusChip>
</span>
);
}
31 changes: 3 additions & 28 deletions src/app/components/dashboard-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,13 @@
import Link from "next/link";

type DashboardShellProps = {
children: React.ReactNode;
};

export function DashboardShell({ children }: DashboardShellProps) {
return (
<div className="app-shell min-h-screen text-slate-50">
<header className="border-b border-white/8 bg-slate-950/40 backdrop-blur-xl">
<nav className="mx-auto flex max-w-6xl items-center justify-between px-5 py-4 sm:px-6">
<div>
<Link href="/" className="text-lg font-semibold tracking-tight text-white">
ChronoPay
</Link>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Time economy dashboard
</p>
</div>
<div className="flex items-center gap-3 text-sm text-slate-300">
<Link href="/" className="rounded-full px-3 py-2 hover:bg-white/6 hover:text-white">
Home
</Link>
<a
href="https://stellar.org"
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-white/10 px-3 py-2 hover:border-cyan-200/30 hover:bg-white/6 hover:text-white"
>
Stellar
</a>
</div>
</nav>
</header>
<main className="mx-auto max-w-6xl px-5 py-10 sm:px-6 sm:py-14">{children}</main>
<main className="mx-auto max-w-6xl px-5 py-10 sm:px-6 sm:py-14">
{children}
</main>
</div>
);
}
Loading
Loading