v1.0

NotificationCard

Preview

Basic

Loading…

Preview

Code

ts
import NotificationCard from '@/components/overlay/NotificationCard';

src/components/overlay/NotificationCard.tsx

AI prompt

text
Build a notification card row component in React + TypeScript + Tailwind CSS.

## Look
- Card: `block w-full text-left p-3 border rounded-lg text-xs space-y-1 shadow-sm transition-colors` plus a tint (below).
- Line 1: `flex items-center justify-between gap-2 font-bold text-slate-800 dark:text-slate-100`.
  - Left (`flex items-center gap-1.5 min-w-0`): a 14px lucide Bell in the tint's icon colour, then the event label, truncating.
  - Right: the relative time, `text-[10px] font-normal text-slate-400 dark:text-slate-500 shrink-0`, with a `title` of the exact "YYYY-MM-DD HH:MM".
- Line 2: `text-[11px] leading-relaxed text-slate-600 dark:text-slate-400`. First the reference, if any, in `font-mono font-semibold text-indigo-600 dark:text-indigo-400` followed by a space. Then "by {actor}", or "by the system" when the actor is null.
- Tint by event type, for UNREAD cards only:
  - approved → card `bg-emerald-50 border-emerald-200 dark:bg-emerald-950/30 dark:border-emerald-900`, icon `text-emerald-600 dark:text-emerald-400`
  - rejected or failed → the same recipe in rose
  - partial or needs a human → amber
  - `request.*` → indigo
  - other workflow events → violet
  - unknown → neutral
- A READ card is always neutral: `bg-slate-50 border-slate-200 dark:bg-slate-800/40 dark:border-slate-700`, icon `text-slate-400 dark:text-slate-500`. Colour is what marks a card as still wanting attention; keeping it after the read would make it decorative.

## Behaviour
- Label: a type → text map ("request.created" → "Request created", "request.approved" → "Request approved"). Fall back to the raw type string rather than hiding an unstyled event.
- Relative time from the ISO `createdAt`: under a minute "Just now", then "Nm ago", "Nh ago" and "Nd ago" up to 7 days, after that the date (YYYY-MM-DD).
- With `href`: a link (`hover:brightness-95 dark:hover:brightness-110`) with `onClick={onActivate}`. Navigation is never blocked, so `disabled` is ignored.
- Without `href`: a button whose only job is marking itself read. It is disabled when `disabled` is set or the item is already read (`cursor-default`, no hover effect): a control that looks pressable but does nothing is worse than a flat card.

## API
- `item: HeaderNotification`, where `HeaderNotification = { eventId: number; type: string; createdAt: string; actor: string | null; ref: string | null; requestId: number | null; read: boolean }` (exported)
- `href?: string | null`, `onActivate?(): void`, `disabled?` = false

## Demo
Three cards stacked in an opaque card with `divide-y divide-slate-100 dark:divide-slate-800`, each linking to "#":
- REQ-4471, request.approved, by Grace Hopper, unread
- REQ-4470, request.submitted, by Alan Turing, unread
- REQ-4468, request.rejected, by Ada Lovelace, read

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

export type HeaderNotification = {
  /** The EVENT id — `notifications.id` is an implementation detail of read state. */
  eventId: number;
  type: string;
  createdAt: string;
  actor: string | null;
  ref: string | null;
  requestId: number | null;
  read: boolean;
};

/* Origin: bonus-adjustment (96S2), verbatim. */

import Link from 'next/link';
import { Bell } from 'lucide-react';
import { cn } from '@/lib/cn';
import { eventCardTint, eventLabel, relativeTime } from '@/lib/events';

/**
 * One notification, as the mockup's tinted card (docs/dashboard.html →
 * `renderNotifications()`): `p-3 <tint> border rounded-lg text-xs space-y-1
 * shadow-sm`, a bold title, the relative time on the right, and the ref and
 * actor beneath.
 *
 * Shared by the header bell and the full notifications page. It was the bell's
 * inline markup first; the page needs the identical row, and two copies of a
 * tint-plus-title recipe drift the moment one of them gains a field.
 *
 * A card that belongs to a request is a LINK; one that does not is a button
 * whose only job is to mark itself read, and it is disabled once it is — a
 * control that looks pressable but does nothing is worse than a flat card.
 */
export default function NotificationCard({
  item,
  href,
  onActivate,
  disabled = false,
}: {
  item: HeaderNotification;
  /** Where the card goes when it has somewhere to go. Null renders the button form. */
  href?: string | null;
  /** Marking read, closing the dropdown — whatever the caller does on a click. */
  onActivate?: () => void;
  /** Caller's in-flight state. Ignored by the link form: navigation must not block. */
  disabled?: boolean;
}) {
  const tint = eventCardTint(item.type, item.read);

  const body = (
    <>
      <div className="flex items-center justify-between font-bold text-slate-800 dark:text-slate-100 gap-2">
        <span className="flex items-center gap-1.5 min-w-0">
          <Bell className={cn('w-3.5 h-3.5 shrink-0', tint.icon)} />
          <span className="truncate">{eventLabel(item.type)}</span>
        </span>
        <span
          className="text-[10px] text-slate-400 dark:text-slate-500 font-normal shrink-0"
          title={item.createdAt.slice(0, 16).replace('T', ' ')}
        >
          {relativeTime(item.createdAt)}
        </span>
      </div>
      <p className="text-slate-600 dark:text-slate-400 text-[11px] leading-relaxed">
        {item.ref && (
          <span className="font-mono font-semibold text-indigo-600 dark:text-indigo-400">
            {item.ref}{' '}
          </span>
        )}
        {item.actor ? `by ${item.actor}` : 'by the system'}
      </p>
    </>
  );

  // The mockup's card recipe, verbatim.
  const cardClasses = cn(
    'block w-full text-left p-3 border rounded-lg text-xs space-y-1 shadow-sm transition-colors',
    tint.card,
  );

  if (href) {
    return (
      <Link
        href={href}
        className={cn(cardClasses, 'hover:brightness-95 dark:hover:brightness-110')}
        onClick={onActivate}
      >
        {body}
      </Link>
    );
  }

  return (
    <button
      type="button"
      disabled={disabled || item.read}
      onClick={onActivate}
      className={cn(
        cardClasses,
        item.read ? 'cursor-default' : 'hover:brightness-95 dark:hover:brightness-110',
      )}
    >
      {body}
    </button>
  );
}

Props

PropTypeDefaultDescription
item*HeaderNotification—
hrefstring | null—Where the card goes when it has somewhere to go. Null renders the button form.
onActivate() => void—Marking read, closing the dropdown — whatever the caller does on a click.
disabledbooleanfalseCaller's in-flight state. Ignored by the link form: navigation must not block.