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
27 changes: 27 additions & 0 deletions docs/empty-state-guidelines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Empty State Illustration Style + Copy Guidelines

## Design goals

- Help users understand slot availability, wallet readiness, and booking progress at a glance.
- Keep the primary action obvious without crowding the state with competing buttons.
- Use calm, trust-building language before asking for sensitive actions like wallet connection.

## Illustration rules

- Use abstract product-shaped panels instead of decorative mascots so the empty state still feels product-specific.
- Reserve the strongest accent for the most important action area.
- Keep illustrations non-essential for meaning; screen-reader users should get the same guidance from headings and body copy.

## Copy rules

- Headline: say what is missing in plain language.
- Body copy: explain why the state matters or what remains private.
- Guidance: use short supporting lines to reduce uncertainty.
- Action labels: start with a verb and match the next obvious step.

## Reusable implementation notes

- Shared shell: `src/app/components/dashboard-shell.tsx`
- Empty state card: `src/app/components/empty-state-card.tsx`
- Status chip: `src/app/components/ui/status-chip.tsx`
- Route-level states: `src/app/dashboard/loading.tsx`, `src/app/dashboard/error.tsx`
38 changes: 38 additions & 0 deletions src/app/components/dashboard-shell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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>
</div>
);
}
55 changes: 55 additions & 0 deletions src/app/components/empty-state-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { ReactNode } from "react";
import { EmptyStateIllustration } from "./empty-state-illustration";
import { StatusChip } from "./ui/status-chip";

type EmptyStateCardProps = {
eyebrow: string;
title: string;
description: string;
accentLabel: string;
status: {
label: string;
tone?: "info" | "warning" | "success" | "danger" | "neutral";
};
guidance: string[];
actions?: ReactNode;
};

export function EmptyStateCard({
eyebrow,
title,
description,
accentLabel,
status,
guidance,
actions,
}: EmptyStateCardProps) {
return (
<section className="glass-panel rounded-[2rem] p-5 sm:p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
{eyebrow}
</p>
<StatusChip tone={status.tone}>{status.label}</StatusChip>
</div>
<div className="mt-4">
<EmptyStateIllustration accentLabel={accentLabel} />
</div>
<div className="mt-5 space-y-3">
<h2 className="text-xl font-semibold text-white">{title}</h2>
<p className="max-w-xl text-sm leading-6 text-slate-300">{description}</p>
<ul className="space-y-2 text-sm text-slate-300" aria-label={`${title} guidance`}>
{guidance.map((item) => (
<li
key={item}
className="rounded-2xl border border-white/8 bg-white/4 px-4 py-3"
>
{item}
</li>
))}
</ul>
</div>
{actions ? <div className="mt-5 flex flex-wrap gap-3">{actions}</div> : null}
</section>
);
}
43 changes: 43 additions & 0 deletions src/app/components/empty-state-illustration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
type EmptyStateIllustrationProps = {
accentLabel: string;
};

export function EmptyStateIllustration({
accentLabel,
}: EmptyStateIllustrationProps) {
return (
<div
aria-hidden="true"
className="relative h-36 w-full overflow-hidden rounded-[1.75rem] border border-white/10 bg-[radial-gradient(circle_at_top,rgba(110,231,249,0.18),transparent_45%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(6,12,23,0.98))]"
>
<div className="absolute left-5 top-5 rounded-full border border-cyan-200/20 bg-cyan-300/12 px-3 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.22em] text-cyan-100">
{accentLabel}
</div>
<div className="absolute inset-x-6 bottom-6 top-14 rounded-[1.5rem] border border-white/10 bg-white/4 p-4">
<div className="grid h-full grid-cols-[1.35fr,0.8fr] gap-3">
<div className="rounded-[1.25rem] border border-dashed border-cyan-200/20 bg-slate-950/40 p-3">
<div className="flex h-full flex-col justify-between rounded-[1rem] border border-white/6 bg-white/4 p-3">
<div className="h-2.5 w-20 rounded-full bg-cyan-200/25" />
<div className="space-y-2">
<div className="h-2 rounded-full bg-white/10" />
<div className="h-2 w-4/5 rounded-full bg-white/8" />
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="rounded-[1.1rem] border border-amber-200/15 bg-amber-300/8 p-3">
<div className="h-10 rounded-full border border-dashed border-amber-200/20" />
</div>
<div className="flex-1 rounded-[1.1rem] border border-white/8 bg-slate-900/70 p-3">
<div className="space-y-2">
<div className="h-2 w-14 rounded-full bg-white/12" />
<div className="h-2 rounded-full bg-cyan-200/18" />
<div className="h-2 w-3/4 rounded-full bg-white/8" />
</div>
</div>
</div>
</div>
</div>
</div>
);
}
34 changes: 34 additions & 0 deletions src/app/components/ui/button-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Link from "next/link";
import type { LinkProps } from "next/link";
import type { ReactNode } from "react";

type ButtonLinkProps = LinkProps & {
children: ReactNode;
className?: string;
variant?: "primary" | "secondary" | "ghost";
};

const variantClasses = {
primary:
"bg-cyan-300 text-slate-950 hover:bg-cyan-200 shadow-[0_16px_34px_rgba(34,211,238,0.22)]",
secondary:
"border border-white/12 bg-white/6 text-slate-100 hover:border-cyan-200/30 hover:bg-white/10",
ghost:
"text-cyan-200 hover:bg-cyan-300/10 hover:text-cyan-100",
};

export function ButtonLink({
children,
className = "",
variant = "primary",
...props
}: ButtonLinkProps) {
return (
<Link
{...props}
className={`inline-flex items-center justify-center rounded-full px-4 py-2.5 text-sm font-medium ${variantClasses[variant]} ${className}`}
>
{children}
</Link>
);
}
22 changes: 22 additions & 0 deletions src/app/components/ui/status-chip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
type StatusChipProps = {
tone?: "info" | "warning" | "success" | "danger" | "neutral";
children: React.ReactNode;
};

const toneClasses = {
info: "border-cyan-300/25 bg-cyan-300/12 text-cyan-100",
warning: "border-amber-300/25 bg-amber-300/12 text-amber-100",
success: "border-emerald-300/25 bg-emerald-300/12 text-emerald-100",
danger: "border-rose-300/25 bg-rose-300/12 text-rose-100",
neutral: "border-white/10 bg-white/6 text-slate-200",
};

export function StatusChip({ tone = "neutral", children }: StatusChipProps) {
return (
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium tracking-[0.14em] uppercase ${toneClasses[tone]}`}
>
{children}
</span>
);
}
54 changes: 54 additions & 0 deletions src/app/dashboard/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use client";

import Link from "next/link";
import { useEffect } from "react";
import { DashboardShell } from "../components/dashboard-shell";

export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);

return (
<DashboardShell>
<section
role="alert"
className="glass-panel mx-auto max-w-2xl rounded-[2rem] p-6 sm:p-8"
>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-rose-300">
Error state
</p>
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-white">
We could not load your booking workspace
</h1>
<p className="mt-4 text-sm leading-7 text-slate-300">
Nothing has been published or charged. Try again to reload the dashboard, or return home if you want to restart from a safe point.
</p>
<div className="mt-6 rounded-[1.5rem] border border-rose-300/15 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">
{error.message || "Unexpected dashboard error."}
</div>
<div className="mt-6 flex flex-wrap gap-3">
<button
type="button"
onClick={reset}
className="inline-flex items-center justify-center rounded-full bg-cyan-300 px-4 py-2.5 text-sm font-medium text-slate-950 hover:bg-cyan-200"
>
Try again
</button>
<Link
href="/"
className="inline-flex items-center justify-center rounded-full border border-white/12 bg-white/6 px-4 py-2.5 text-sm font-medium text-slate-100 hover:border-cyan-200/30 hover:bg-white/10"
>
Go home
</Link>
</div>
</section>
</DashboardShell>
);
}
27 changes: 27 additions & 0 deletions src/app/dashboard/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { DashboardShell } from "../components/dashboard-shell";

export default function DashboardLoading() {
return (
<DashboardShell>
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]" aria-busy="true" aria-live="polite">
{[0, 1].map((item) => (
<section key={item} className="glass-panel rounded-[2rem] p-6 sm:p-8">
<div className="h-6 w-28 rounded-full bg-white/8" />
<div className="mt-5 h-10 max-w-xl rounded-2xl bg-white/10" />
<div className="mt-3 h-5 max-w-2xl rounded-full bg-white/6" />
<div className="mt-8 grid gap-3">
<div className="h-12 rounded-2xl bg-white/6" />
<div className="h-12 rounded-2xl bg-white/6" />
<div className="h-12 rounded-2xl bg-white/6" />
</div>
</section>
))}
</div>
<div className="mt-6 grid gap-4 md:grid-cols-3">
{[0, 1, 2].map((item) => (
<div key={item} className="glass-panel h-40 rounded-[1.5rem] bg-white/4" />
))}
</div>
</DashboardShell>
);
}
33 changes: 24 additions & 9 deletions src/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,22 +1,37 @@
@import "tailwindcss";

:root {
--background: #ffffff;
--foreground: #171717;
--background: #07111f;
--foreground: #f4f7fb;
--surface: rgba(11, 23, 40, 0.82);
--surface-strong: rgba(10, 20, 36, 0.96);
--border-subtle: rgba(148, 163, 184, 0.14);
--border-strong: rgba(125, 211, 252, 0.22);
--accent: #6ee7f9;
--accent-strong: #22d3ee;
--accent-warm: #f59e0b;
--success: #34d399;
--danger: #f87171;
--muted: #9fb0c7;
}

@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans:
"Segoe UI", "SF Pro Display", "Helvetica Neue", Arial, sans-serif;
--font-mono:
"SFMono-Regular", "Cascadia Code", "Fira Code", Consolas, monospace;
}

@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
* {
box-sizing: border-box;
}

html {
background:
radial-gradient(circle at top, rgba(34, 211, 238, 0.18), transparent 34%),
linear-gradient(180deg, #09111e 0%, #050b14 100%);
}

body {
Expand Down
17 changes: 1 addition & 16 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});

const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});

export const metadata: Metadata = {
title: "ChronoPay - Time Economy",
description: "Tokenize and trade human time on the Stellar network.",
Expand All @@ -24,11 +13,7 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
<body className="antialiased">{children}</body>
</html>
);
}
Loading