- EmptyState: render <Link> as the button (a <button> inside <Link> was invalid and double-focusable); AboutPage <a href> -> <Link> (no full reload) - aria-label on icon-only controls: settings Back, conversation Refresh/Send, crew chat Send - Crew invite Accept/Decline raised to 44px touch targets
134 lines
4 KiB
TypeScript
134 lines
4 KiB
TypeScript
import type { ComponentType, ReactNode } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* Shared empty-state component.
|
|
*
|
|
* Before this existed the app had ~39 ad-hoc empty states, each free to drift
|
|
* in spacing, tone and — crucially — whether they offered a way *out*. The
|
|
* `action` slot is the valuable part: "No crews nearby → Create one" turns a
|
|
* dead-end into a loop. Prefer this component over hand-rolling new ones.
|
|
*/
|
|
|
|
export interface EmptyStateAction {
|
|
/** Button label. */
|
|
label: string;
|
|
/** Internal route to navigate to (renders a <Link>). */
|
|
to?: string;
|
|
/** Click handler (renders a <button>). Ignored if `to` is set. */
|
|
onClick?: () => void;
|
|
/** Optional leading icon for the action button. */
|
|
icon?: ComponentType<{ className?: string }>;
|
|
/** Visual emphasis. `primary` = filled brand button, `secondary` = outline. */
|
|
variant?: 'primary' | 'secondary';
|
|
/** Extra classes for bespoke accent colors (e.g. teal crews, lime spots). */
|
|
className?: string;
|
|
}
|
|
|
|
export interface EmptyStateProps {
|
|
/** Lucide-style icon component rendered in the header tile. */
|
|
icon?: ComponentType<{ className?: string }>;
|
|
/** Emoji alternative to `icon` (used by some sections, e.g. 🛹). */
|
|
emoji?: string;
|
|
/** Headline. Kept short and human. */
|
|
title: string;
|
|
/** One or two sentences of guidance. */
|
|
hint?: ReactNode;
|
|
/** Primary + optional secondary actions. First is the emphasized CTA. */
|
|
actions?: EmptyStateAction[];
|
|
/** Optional footer link (e.g. "Have questions? Check the FAQ →"). */
|
|
footer?: { label: string; to: string };
|
|
/** Extra classes on the outer wrapper. */
|
|
className?: string;
|
|
}
|
|
|
|
function ActionButton({ action }: { action: EmptyStateAction }) {
|
|
const Icon = action.icon;
|
|
const isPrimary = action.variant !== 'secondary';
|
|
|
|
const classes = cn(
|
|
'inline-flex w-full items-center justify-center gap-2 rounded-xl px-5 py-2.5 text-sm font-black uppercase tracking-wide transition-all active:scale-95 sm:w-auto',
|
|
isPrimary
|
|
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
|
: 'border border-border bg-white/5 text-foreground hover:bg-white/10',
|
|
action.className,
|
|
);
|
|
|
|
const inner = (
|
|
<>
|
|
{Icon && <Icon className="h-4 w-4" />}
|
|
{action.label}
|
|
</>
|
|
);
|
|
|
|
if (action.to) {
|
|
// Single interactive element — a <button> nested in a <Link> was invalid
|
|
// and double-focusable for assistive tech.
|
|
return (
|
|
<Link to={action.to} className={classes}>
|
|
{inner}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button type="button" onClick={action.onClick} className={classes}>
|
|
{inner}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function EmptyState({
|
|
icon: Icon,
|
|
emoji,
|
|
title,
|
|
hint,
|
|
actions,
|
|
footer,
|
|
className,
|
|
}: EmptyStateProps) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex flex-col items-center justify-center px-4 py-16 text-center',
|
|
className,
|
|
)}
|
|
>
|
|
{(Icon || emoji) && (
|
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5 text-3xl">
|
|
{emoji ? (
|
|
<span aria-hidden="true">{emoji}</span>
|
|
) : (
|
|
Icon && <Icon className="h-8 w-8 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<h3 className="mb-2 text-lg font-black text-foreground">{title}</h3>
|
|
|
|
{hint && (
|
|
<p className="mb-6 max-w-xs text-sm text-muted-foreground">{hint}</p>
|
|
)}
|
|
|
|
{actions && actions.length > 0 && (
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-center">
|
|
{actions.map((action) => (
|
|
<ActionButton key={action.label} action={action} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{footer && (
|
|
<Link
|
|
to={footer.to}
|
|
className="mt-5 text-xs text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground"
|
|
>
|
|
{footer.label}
|
|
</Link>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default EmptyState;
|