Skip to content
ilinxa/pro-ui

Registration Card

alphav0.2.0

Event registration status card — capacity progress, spots-left counter, status-aware call to action, and a share slot.

Category: Data DisplayUpdated: 2026-08-11Created: 2026-05-02Author: ilinxa

Context

Use for event registration sidebars, course / webinar signup cards, training enrollment, conference tickets, beta / waitlist signups, fundraising goal cards. Architecturally independent of event timing — host owns date logic and passes `closed: true` when registration window has ended. Migration origin: kasder kas-social-front-v0 events/[id]/page.tsx Kayıt Durumu sidebar card. Composes naturally with event-card helpers + share-bar in the actions slot.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/registration-card

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/registration-card-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Kayıt Durumu

Kontenjan77 yer kaldı
423 kayıtlı500 kapasite

Demo source

demo.tsxtsx
"use client"; import { Bookmark, CalendarPlus, Share2 } from "lucide-react";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { RegistrationCard } from "./registration-card";import {  dummyRegistrationClosed,  dummyRegistrationFull,  dummyRegistrationLastSpots,  dummyRegistrationOpen,  dummyRegistrationUrgent,  fundraisingLabels,  trLabels,} from "./dummy-data"; const noop = () => {}; export default function RegistrationCardDemo() {  return (    <Tabs defaultValue="default" className="w-full">      <SwipeTabsList>        <TabsTrigger value="default">Default (TR)</TabsTrigger>        <TabsTrigger value="states">All states</TabsTrigger>        <TabsTrigger value="no-quota">No-quota</TabsTrigger>        <TabsTrigger value="actions">Custom actions</TabsTrigger>        <TabsTrigger value="bare">Bare + relabeled</TabsTrigger>      </SwipeTabsList>       {/* 1. Default (TR) — kasder Kayıt Durumu verbatim */}      <TabsContent value="default" className="mt-6">        <div className="max-w-sm mx-auto">          <RegistrationCard            heading="Kayıt Durumu"            capacity={dummyRegistrationLastSpots.capacity}            registered={dummyRegistrationLastSpots.registered}            onRegister={noop}            onShare={noop}            labels={trLabels}          />        </div>      </TabsContent>       {/* 2. All states — open / lastSpots / urgent / full / closed stacked */}      <TabsContent value="states" className="mt-6">        <div className="max-w-sm mx-auto space-y-6">          <RegistrationCard            heading="Open (142/500)"            capacity={dummyRegistrationOpen.capacity}            registered={dummyRegistrationOpen.registered}            onRegister={noop}            onShare={noop}          />          <RegistrationCard            heading="Last spots (423/500 — ≥80% full)"            capacity={dummyRegistrationLastSpots.capacity}            registered={dummyRegistrationLastSpots.registered}            onRegister={noop}            onShare={noop}          />          <RegistrationCard            heading="Urgent (95/100 — ≤10 spots → destructive color)"            capacity={dummyRegistrationUrgent.capacity}            registered={dummyRegistrationUrgent.registered}            onRegister={noop}            onShare={noop}          />          <RegistrationCard            heading="Full (50/50 — sold out)"            capacity={dummyRegistrationFull.capacity}            registered={dummyRegistrationFull.registered}            onRegister={noop}            onShare={noop}          />          <RegistrationCard            heading="Closed (host-driven — e.g. event expired)"            capacity={dummyRegistrationClosed.capacity}            registered={dummyRegistrationClosed.registered}            closed={dummyRegistrationClosed.closed}            onRegister={noop}            onShare={noop}          />        </div>      </TabsContent>       {/* 3. No-quota — capacity-less, just CTA + share */}      <TabsContent value="no-quota" className="mt-6">        <div className="max-w-sm mx-auto space-y-2">          <p className="text-sm text-muted-foreground">            No <code>capacity</code> / <code>registered</code> — bar +            counter rows hide; CTA + share remain. Useful for unlimited /            drop-in events.          </p>          <RegistrationCard            heading="Sign up"            onRegister={noop}            onShare={noop}          />        </div>      </TabsContent>       {/* 4. Custom actions — replaces default share with cluster */}      <TabsContent value="actions" className="mt-6">        <div className="max-w-sm mx-auto space-y-2">          <p className="text-sm text-muted-foreground">            <code>actions</code> slot — fully replaces the default share            button. Drop in calendar + save + share cluster, or compose with{" "}            <code>share-bar</code>.          </p>          <RegistrationCard            heading="Registration"            capacity={200}            registered={172}            onRegister={noop}            actions={              <div className="grid grid-cols-3 gap-2">                <Button                  type="button"                  variant="outline"                  size="sm"                  className="gap-1"                  onClick={noop}                >                  <CalendarPlus aria-hidden="true" className="size-4" />                  Cal                </Button>                <Button                  type="button"                  variant="outline"                  size="sm"                  className="gap-1"                  onClick={noop}                >                  <Bookmark aria-hidden="true" className="size-4" />                  Save                </Button>                <Button                  type="button"                  variant="outline"                  size="sm"                  className="gap-1"                  onClick={noop}                >                  <Share2 aria-hidden="true" className="size-4" />                  Share                </Button>              </div>            }          />        </div>      </TabsContent>       {/* 5. Bare + relabeled — fundraising goal */}      <TabsContent value="bare" className="mt-6">        <div className="max-w-sm mx-auto rounded-2xl border border-border/50 bg-card p-6 space-y-2">          <p className="text-sm text-muted-foreground">            <code>framed=&#123;false&#125;</code> embedded inside an outer            card + relabeled for a fundraising-goal context.          </p>          <RegistrationCard            heading="Fundraising Goal"            capacity={50000}            registered={32000}            framed={false}            onRegister={noop}            onShare={noop}            labels={fundraisingLabels}          />        </div>      </TabsContent>    </Tabs>  );} 

Usage

When to use

Reach for RegistrationCard for any signup / RSVP / ticket / fundraising widget — event registration sidebars, course / webinar enrollment, training signups, conference tickets, beta / waitlist, fundraising goal cards. Capacity progress + spots-left counter + status-aware primary CTA + optional share / actions slot.

Minimal example

import { RegistrationCard } from "@/components/registration-card";

<RegistrationCard
  heading="Registration"
  capacity={500}
  registered={142}
  onRegister={() => openRegisterDialog()}
  onShare={() => share(eventUrl)}
/>;

4-state state machine

Status auto-derives from (capacity, registered, closed):

  • open — capacity available, less than lastSpotsRatiofull → primary "Register" CTA
  • lastSpots — capacity available, ≥ lastSpotsRatiofull (default 80%) → primary "Register" CTA + spots-left counter flips to text-destructive when ≤ urgentSpotsCount (default 10)
  • full — registered >= capacity→ disabled secondary "Sold out" CTA
  • closed — host-driven via closed: true→ disabled secondary "Closed" CTA. Wins over capacity (closed always closes regardless of remaining spots).

Public helper kernel — derive state without rendering

import {
  RegistrationCard,
  deriveRegistrationStatus,
  type RegistrationStatus,
} from "@/components/registration-card";

// Header counter: how many events are still open?
const stillOpen = events.filter(
  (e) =>
    deriveRegistrationStatus({
      capacity: e.capacity,
      registered: e.registered,
      closed: e.regClosed,
    }) !== "full" &&
    deriveRegistrationStatus({
      capacity: e.capacity,
      registered: e.registered,
      closed: e.regClosed,
    }) !== "closed",
).length;

Composing with event-card's state

registration-card is architecturally independent of event timing — the host derives event status (using getEventStatus from event-card) and passes closed: true when registration should close. Two common composition patterns:

import { getEventStatus } from "@/components/event-card";

// Pattern 1 — close registration only when event has expired:
<RegistrationCard
  capacity={event.capacity}
  registered={event.registered}
  closed={getEventStatus(event) === "expired"}
  onRegister={handleRegister}
/>

// Pattern 2 — close registration when event is expired OR ongoing
// (i.e., no walk-ins after the event starts):
<RegistrationCard
  capacity={event.capacity}
  registered={event.registered}
  closed={["expired", "ongoing"].includes(getEventStatus(event))}
  onRegister={handleRegister}
/>

No-quota mode

For unlimited / drop-in events, omit capacity and registered — the bar + counter rows hide; status stays open; CTA + share remain interactive.

<RegistrationCard
  heading="Sign up"
  onRegister={handleRegister}
  onShare={handleShare}
/>

Custom actions slot

The actions slot fully replaces the default share button. Drop in calendar export + save event + share clusters, or compose with share-bar:

<RegistrationCard
  capacity={200}
  registered={172}
  onRegister={handleRegister}
  actions={
    <div className="grid grid-cols-3 gap-2">
      <Button variant="outline" onClick={addToCalendar}>Cal</Button>
      <Button variant="outline" onClick={save}>Save</Button>
      <ShareBar url={eventUrl} variant="compact" />
    </div>
  }
/>

Relabeled for fundraising

<RegistrationCard
  heading="Fundraising Goal"
  capacity={50000}
  registered={32000}
  onRegister={openDonateDialog}
  labels={{
    capacityLabel: "Goal",
    spotsLeftSuffix: "remaining",
    spotsLeftFull: "Goal reached!",
    registeredSuffix: "raised",
    capacitySuffix: "goal",
    ctaRegister: "Donate now",
    ctaSoldOut: "Goal reached",
  }}
/>

Configurable thresholds

  • lastSpotsRatio (default 0.8) — percent of capacity (0–1) at which status flips to lastSpots. Drives the state machine.
  • urgentSpotsCount (default 10) — absolute spots-left count at which the counter color flips to text-destructive. Drives the visual color, not the state.

These are semantically distinct: lastSpotsRatio is percent (good for state), urgentSpotsCount is absolute (good for color urgency). For a 100-capacity event, 80% means 20 spots left — not yet urgent. For a 50-capacity event, 80% means 10 spots left — urgent.

Notes

  • The primary CTA is a REAL interactive button — fires onRegister() when clicked. (Differs from event-card's grid variant which has a decorative CTA.)
  • When onRegister is absent, the CTA renders disabled with labels.ctaUnavailable — useful for preview / admin contexts.
  • The bar uses Radix Progress primitive — emits role="progressbar" + aria-valuenow automatically.
  • Heading defaults to h3(this is a sidebar card, typically nested under the page's main h2).
  • statusOverride wins over derived status — useful for preview / what-if states in admin UIs.

Features

  • 4-state state machine — open / lastSpots / full / closed
  • Public helper kernel — deriveRegistrationStatus pure function
  • Capacity progress bar + spots-left counter with destructive-color threshold
  • Status-aware primary CTA (Register / Sold out / Closed / Unavailable)
  • Default share button when onShare provided
  • actions slot fully replaces the default share for richer clusters
  • No-quota mode (capacity-less events) hides bar + counter rows
  • Configurable thresholds — lastSpotsRatio (state machine) + urgentSpotsCount (counter color)
  • statusOverride for preview / what-if states
  • Optional heading with configurable level (h2/h3/h4, default h3)
  • Frame toggle — framed=true card chrome with shadow-lg / framed=false bare
  • i18n via 11-key labels object
  • WCAG — Radix Progress role=progressbar + aria-valuenow + section aria-labelledby
  • Architecturally INDEPENDENT of event-card's date-driven state machine (host composes)

Tags

registration-cardregistrationsignuprsvpevents

Dependencies

shadcn primitives: progress
npm peer deps: lucide-react@^1.11.0