Web InnoventixFreeCode

Status Badges Table

Original · free

table with status badges

byWeb InnoventixReact + Tailwind
tablestatusbadgestables
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/table-status-badges.json
table-status-badges.tsx
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type Status = "live" | "building" | "queued" | "failed" | "rolledback";
type Environment = "Production" | "Staging" | "Preview";
type SortKey = "service" | "environment" | "status" | "duration" | "updated";
type SortDir = "asc" | "desc";
type StatusFilter = Status | "all";

interface Deployment {
  id: string;
  service: string;
  branch: string;
  environment: Environment;
  status: Status;
  durationSec: number;
  updatedMinsAgo: number;
  author: string;
}

const INITIAL_ROWS: Deployment[] = [
  { id: "dpl_8f21", service: "checkout-api", branch: "main", environment: "Production", status: "live", durationSec: 134, updatedMinsAgo: 4, author: "Priya Nair" },
  { id: "dpl_7a05", service: "web-storefront", branch: "main", environment: "Production", status: "building", durationSec: 92, updatedMinsAgo: 1, author: "Diego Alvarez" },
  { id: "dpl_6c93", service: "auth-gateway", branch: "release/2.4", environment: "Staging", status: "failed", durationSec: 47, updatedMinsAgo: 12, author: "Mara Kessler" },
  { id: "dpl_5b77", service: "search-indexer", branch: "feat/ranking", environment: "Preview", status: "queued", durationSec: 0, updatedMinsAgo: 0, author: "Tomas Fuchs" },
  { id: "dpl_4e12", service: "billing-worker", branch: "main", environment: "Production", status: "live", durationSec: 210, updatedMinsAgo: 22, author: "Aiko Tanaka" },
  { id: "dpl_3d48", service: "notifications-svc", branch: "hotfix/smtp", environment: "Staging", status: "rolledback", durationSec: 168, updatedMinsAgo: 38, author: "Liam O'Brien" },
  { id: "dpl_2a66", service: "media-cdn", branch: "feat/webp", environment: "Preview", status: "live", durationSec: 76, updatedMinsAgo: 9, author: "Sofia Rossi" },
  { id: "dpl_1f39", service: "analytics-pipeline", branch: "main", environment: "Production", status: "building", durationSec: 305, updatedMinsAgo: 2, author: "Noah Weber" },
  { id: "dpl_0c84", service: "graphql-edge", branch: "chore/deps", environment: "Staging", status: "failed", durationSec: 58, updatedMinsAgo: 51, author: "Yara Haddad" },
];

const STATUS_META: Record<Status, { label: string; badge: string; animated?: boolean; rank: number }> = {
  failed: {
    label: "Failed",
    rank: 0,
    badge:
      "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25",
  },
  building: {
    label: "Building",
    rank: 1,
    animated: true,
    badge:
      "bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-400/25",
  },
  queued: {
    label: "Queued",
    rank: 2,
    badge:
      "bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/25",
  },
  rolledback: {
    label: "Rolled back",
    rank: 3,
    badge:
      "bg-zinc-100 text-zinc-700 ring-zinc-500/20 dark:bg-zinc-500/10 dark:text-zinc-300 dark:ring-zinc-400/25",
  },
  live: {
    label: "Live",
    rank: 4,
    badge:
      "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
  },
};

const ENV_META: Record<Environment, string> = {
  Production:
    "bg-indigo-50 text-indigo-700 ring-indigo-600/15 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20",
  Staging:
    "bg-violet-50 text-violet-700 ring-violet-600/15 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-400/20",
  Preview:
    "bg-slate-100 text-slate-600 ring-slate-500/15 dark:bg-slate-800/60 dark:text-slate-300 dark:ring-slate-500/25",
};

const ENV_RANK: Record<Environment, number> = { Production: 0, Staging: 1, Preview: 2 };

const COLUMNS: { key: SortKey; label: string; align: "left" | "right" }[] = [
  { key: "service", label: "Service", align: "left" },
  { key: "environment", label: "Environment", align: "left" },
  { key: "status", label: "Status", align: "left" },
  { key: "duration", label: "Build time", align: "right" },
  { key: "updated", label: "Last updated", align: "right" },
];

const FILTERS: { key: StatusFilter; label: string }[] = [
  { key: "all", label: "All" },
  { key: "live", label: "Live" },
  { key: "building", label: "Building" },
  { key: "queued", label: "Queued" },
  { key: "failed", label: "Failed" },
  { key: "rolledback", label: "Rolled back" },
];

const RING =
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950";

function formatDuration(sec: number): string {
  if (sec <= 0) return "—";
  if (sec < 60) return `${sec}s`;
  return `${Math.floor(sec / 60)}m ${sec % 60}s`;
}

function formatUpdated(mins: number): string {
  if (mins <= 0) return "just now";
  if (mins < 60) return `${mins}m ago`;
  const h = Math.floor(mins / 60);
  return `${h}h ago`;
}

function StatusIcon({ status }: { status: Status }) {
  const common = { width: 13, height: 13, viewBox: "0 0 24 24", fill: "none", "aria-hidden": true } as const;
  if (status === "live") {
    return (
      <svg {...common}>
        <path d="M20 6 9 17l-5-5" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" />
      </svg>
    );
  }
  if (status === "building") {
    return (
      <svg {...common} className="tsbadge-spin">
        <path d="M12 3a9 9 0 1 0 9 9" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" />
      </svg>
    );
  }
  if (status === "queued") {
    return (
      <svg {...common}>
        <circle cx={12} cy={12} r={9} stroke="currentColor" strokeWidth={2} />
        <path d="M12 7.5V12l3 2" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
      </svg>
    );
  }
  if (status === "failed") {
    return (
      <svg {...common}>
        <circle cx={12} cy={12} r={9} stroke="currentColor" strokeWidth={2} />
        <path d="m15 9-6 6M9 9l6 6" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" />
      </svg>
    );
  }
  return (
    <svg {...common}>
      <path d="M9 14 4 9l5-5" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
      <path d="M4 9h11a5 5 0 0 1 0 10h-3" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function SortGlyph({ state }: { state: "asc" | "desc" | "none" }) {
  return (
    <svg width={12} height={12} viewBox="0 0 24 24" fill="none" aria-hidden="true" className="shrink-0">
      <path
        d="M8 10l4-4 4 4"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
        strokeLinejoin="round"
        className={state === "asc" ? "opacity-100" : "opacity-30"}
      />
      <path
        d="M8 14l4 4 4-4"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
        strokeLinejoin="round"
        className={state === "desc" ? "opacity-100" : "opacity-30"}
      />
    </svg>
  );
}

export default function TableStatusBadges() {
  const reduce = useReducedMotion();
  const searchId = useId();
  const selectAllRef = useRef<HTMLInputElement>(null);

  const [rows, setRows] = useState<Deployment[]>(INITIAL_ROWS);
  const [query, setQuery] = useState<string>("");
  const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
  const [sortKey, setSortKey] = useState<SortKey>("updated");
  const [sortDir, setSortDir] = useState<SortDir>("asc");
  const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
  const [action, setAction] = useState<string>("");

  const searchFiltered = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return rows;
    return rows.filter(
      (r) =>
        r.service.toLowerCase().includes(q) ||
        r.branch.toLowerCase().includes(q) ||
        r.author.toLowerCase().includes(q),
    );
  }, [rows, query]);

  const counts = useMemo(() => {
    const c: Record<StatusFilter, number> = { all: 0, live: 0, building: 0, queued: 0, failed: 0, rolledback: 0 };
    for (const r of searchFiltered) {
      c[r.status] += 1;
      c.all += 1;
    }
    return c;
  }, [searchFiltered]);

  const visible = useMemo(() => {
    const base = statusFilter === "all" ? searchFiltered : searchFiltered.filter((r) => r.status === statusFilter);
    const dir = sortDir === "asc" ? 1 : -1;
    return [...base].sort((a, b) => {
      let cmp = 0;
      switch (sortKey) {
        case "service":
          cmp = a.service.localeCompare(b.service);
          break;
        case "environment":
          cmp = ENV_RANK[a.environment] - ENV_RANK[b.environment];
          break;
        case "status":
          cmp = STATUS_META[a.status].rank - STATUS_META[b.status].rank;
          break;
        case "duration":
          cmp = a.durationSec - b.durationSec;
          break;
        case "updated":
          cmp = a.updatedMinsAgo - b.updatedMinsAgo;
          break;
      }
      if (cmp === 0) cmp = a.service.localeCompare(b.service);
      return cmp * dir;
    });
  }, [searchFiltered, statusFilter, sortKey, sortDir]);

  const visibleIds = useMemo(() => visible.map((r) => r.id), [visible]);
  const selectedVisible = visibleIds.filter((id) => selected.has(id)).length;
  const allVisibleSelected = visible.length > 0 && selectedVisible === visible.length;

  useEffect(() => {
    if (selectAllRef.current) {
      selectAllRef.current.indeterminate = selectedVisible > 0 && !allVisibleSelected;
    }
  }, [selectedVisible, allVisibleSelected]);

  function toggleSort(key: SortKey) {
    if (key === sortKey) {
      setSortDir((d) => (d === "asc" ? "desc" : "asc"));
    } else {
      setSortKey(key);
      setSortDir(key === "service" || key === "environment" ? "asc" : "asc");
    }
  }

  function toggleRow(id: string) {
    setSelected((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }

  function toggleAll() {
    setSelected((prev) => {
      const next = new Set(prev);
      if (allVisibleSelected) {
        for (const id of visibleIds) next.delete(id);
      } else {
        for (const id of visibleIds) next.add(id);
      }
      return next;
    });
  }

  function redeploySelected() {
    const ids = selected;
    if (ids.size === 0) return;
    const n = ids.size;
    setRows((prev) =>
      prev.map((r) => (ids.has(r.id) ? { ...r, status: "building", durationSec: 0, updatedMinsAgo: 0 } : r)),
    );
    setSelected(new Set());
    setAction(`${n} deployment${n === 1 ? "" : "s"} queued for redeploy.`);
  }

  function clearSelection() {
    setSelected(new Set());
    setAction("Selection cleared.");
  }

  const summary = `Showing ${visible.length} of ${rows.length} deployments${
    statusFilter === "all" ? "" : ` with status ${STATUS_META[statusFilter].label.toLowerCase()}`
  }.`;

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-6 lg:py-24">
      <style>{`
        @keyframes tsbadge-spin { to { transform: rotate(360deg); } }
        @keyframes tsbadge-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
        .tsbadge-spin { animation: tsbadge-spin 0.8s linear infinite; transform-origin: center; }
        @media (prefers-reduced-motion: reduce) {
          .tsbadge-spin { animation: none !important; }
        }
      `}</style>

      <motion.div
        initial={reduce ? false : { opacity: 0, y: 14 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.45, ease: "easeOut" }}
        className="mx-auto w-full max-w-5xl"
      >
        <header className="mb-6 flex flex-col gap-1.5">
          <div className="flex items-center gap-2">
            <span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-600 text-white dark:bg-indigo-500">
              <svg width={14} height={14} viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path d="M4 7h16M4 12h16M4 17h10" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
              </svg>
            </span>
            <h2 className="text-lg font-semibold tracking-tight sm:text-xl">Deployment activity</h2>
          </div>
          <p className="text-sm text-slate-500 dark:text-slate-400">
            Live pipeline status across every service and environment. Sort, filter, and requeue builds.
          </p>
        </header>

        <div className="rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900/60">
          {/* Toolbar */}
          <div className="flex flex-col gap-4 border-b border-slate-200 p-4 dark:border-slate-800 sm:p-5">
            <div className="relative">
              <label htmlFor={searchId} className="sr-only">
                Search deployments by service, branch, or author
              </label>
              <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500">
                <svg width={16} height={16} viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <circle cx={11} cy={11} r={7} stroke="currentColor" strokeWidth={2} />
                  <path d="m20 20-3.5-3.5" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
                </svg>
              </span>
              <input
                id={searchId}
                type="text"
                inputMode="search"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Search service, branch, or author…"
                className={`w-full rounded-lg border border-slate-300 bg-white py-2 pl-9 pr-9 text-sm text-slate-900 placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500 ${RING}`}
              />
              {query !== "" && (
                <button
                  type="button"
                  onClick={() => setQuery("")}
                  aria-label="Clear search"
                  className={`absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-300 ${RING}`}
                >
                  <svg width={15} height={15} viewBox="0 0 24 24" fill="none" aria-hidden="true">
                    <path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
                  </svg>
                </button>
              )}
            </div>

            <div role="group" aria-label="Filter by status" className="flex flex-wrap gap-2">
              {FILTERS.map((f) => {
                const isActive = statusFilter === f.key;
                const count = counts[f.key];
                return (
                  <button
                    key={f.key}
                    type="button"
                    aria-pressed={isActive}
                    onClick={() => setStatusFilter(f.key)}
                    className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${RING} ${
                      isActive
                        ? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500"
                        : "border-slate-300 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-slate-100"
                    }`}
                  >
                    {f.label}
                    <span
                      className={`inline-flex min-w-[1.25rem] justify-center rounded-full px-1 text-[0.65rem] font-semibold tabular-nums ${
                        isActive
                          ? "bg-white/25 text-white"
                          : "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400"
                      }`}
                    >
                      {count}
                    </span>
                  </button>
                );
              })}
            </div>
          </div>

          {/* Bulk action bar */}
          {selected.size > 0 && (
            <div className="flex flex-wrap items-center justify-between gap-3 border-b border-indigo-200 bg-indigo-50 px-4 py-2.5 dark:border-indigo-500/25 dark:bg-indigo-500/10 sm:px-5">
              <p className="text-sm font-medium text-indigo-800 dark:text-indigo-200">
                <span className="tabular-nums">{selected.size}</span> selected
              </p>
              <div className="flex items-center gap-2">
                <button
                  type="button"
                  onClick={redeploySelected}
                  className={`inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400 ${RING}`}
                >
                  <svg width={14} height={14} viewBox="0 0 24 24" fill="none" aria-hidden="true">
                    <path
                      d="M20 12a8 8 0 1 1-2.3-5.6M20 4v4h-4"
                      stroke="currentColor"
                      strokeWidth={2}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                  </svg>
                  Redeploy
                </button>
                <button
                  type="button"
                  onClick={clearSelection}
                  className={`rounded-lg border border-indigo-300 bg-white px-3 py-1.5 text-xs font-semibold text-indigo-700 hover:bg-indigo-100 dark:border-indigo-500/30 dark:bg-transparent dark:text-indigo-200 dark:hover:bg-indigo-500/15 ${RING}`}
                >
                  Clear
                </button>
              </div>
            </div>
          )}

          {/* Table */}
          <div className="overflow-x-auto">
            <table className="w-full min-w-[46rem] border-collapse text-left text-sm">
              <caption className="sr-only">
                Deployments. Use the column header buttons to sort and the checkboxes to select rows.
              </caption>
              <thead>
                <tr className="border-b border-slate-200 dark:border-slate-800">
                  <th scope="col" className="w-12 px-4 py-3">
                    <span className="sr-only">Select</span>
                    <input
                      ref={selectAllRef}
                      type="checkbox"
                      checked={allVisibleSelected}
                      onChange={toggleAll}
                      disabled={visible.length === 0}
                      aria-label="Select all visible deployments"
                      className={`h-4 w-4 cursor-pointer rounded border-slate-300 accent-indigo-600 dark:border-slate-600 ${RING}`}
                    />
                  </th>
                  {COLUMNS.map((col) => {
                    const isActive = sortKey === col.key;
                    const ariaSort: "ascending" | "descending" | "none" = isActive
                      ? sortDir === "asc"
                        ? "ascending"
                        : "descending"
                      : "none";
                    return (
                      <th
                        key={col.key}
                        scope="col"
                        aria-sort={ariaSort}
                        className="px-4 py-3 font-medium text-slate-500 dark:text-slate-400"
                      >
                        <button
                          type="button"
                          onClick={() => toggleSort(col.key)}
                          className={`group -mx-1.5 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-semibold uppercase tracking-wide hover:text-slate-900 dark:hover:text-slate-100 ${RING} ${
                            col.align === "right" ? "flex-row-reverse" : ""
                          } ${isActive ? "text-slate-900 dark:text-slate-100" : ""}`}
                        >
                          <span>{col.label}</span>
                          <SortGlyph state={isActive ? sortDir : "none"} />
                        </button>
                      </th>
                    );
                  })}
                </tr>
              </thead>
              <tbody>
                {visible.length === 0 ? (
                  <tr>
                    <td colSpan={COLUMNS.length + 1} className="px-4 py-16 text-center">
                      <p className="text-sm font-medium text-slate-700 dark:text-slate-200">No deployments match</p>
                      <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
                        Try a different search or clear the status filter.
                      </p>
                    </td>
                  </tr>
                ) : (
                  visible.map((r) => {
                    const isSelected = selected.has(r.id);
                    const meta = STATUS_META[r.status];
                    return (
                      <motion.tr
                        key={r.id}
                        layout={reduce ? false : "position"}
                        transition={{ duration: 0.25, ease: "easeOut" }}
                        className={`border-b border-slate-100 transition-colors last:border-0 dark:border-slate-800/70 ${
                          isSelected
                            ? "bg-indigo-50/70 dark:bg-indigo-500/10"
                            : "hover:bg-slate-50 dark:hover:bg-slate-800/40"
                        }`}
                      >
                        <td className="px-4 py-3 align-middle">
                          <input
                            type="checkbox"
                            checked={isSelected}
                            onChange={() => toggleRow(r.id)}
                            aria-label={`Select ${r.service}`}
                            className={`h-4 w-4 cursor-pointer rounded border-slate-300 accent-indigo-600 dark:border-slate-600 ${RING}`}
                          />
                        </td>
                        <td className="px-4 py-3 align-middle">
                          <div className="font-medium text-slate-900 dark:text-slate-100">{r.service}</div>
                          <div className="mt-0.5 flex items-center gap-1 font-mono text-xs text-slate-500 dark:text-slate-400">
                            <svg width={11} height={11} viewBox="0 0 24 24" fill="none" aria-hidden="true">
                              <circle cx={6} cy={6} r={3} stroke="currentColor" strokeWidth={2} />
                              <circle cx={6} cy={18} r={3} stroke="currentColor" strokeWidth={2} />
                              <circle cx={18} cy={9} r={3} stroke="currentColor" strokeWidth={2} />
                              <path d="M18 12v.5a3 3 0 0 1-3 3H9M6 9v6" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
                            </svg>
                            {r.branch}
                          </div>
                        </td>
                        <td className="px-4 py-3 align-middle">
                          <span
                            className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${ENV_META[r.environment]}`}
                          >
                            {r.environment}
                          </span>
                        </td>
                        <td className="px-4 py-3 align-middle">
                          <span
                            className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset ${meta.badge}`}
                          >
                            <StatusIcon status={r.status} />
                            {meta.label}
                          </span>
                        </td>
                        <td className="px-4 py-3 text-right align-middle tabular-nums text-slate-600 dark:text-slate-300">
                          {formatDuration(r.durationSec)}
                        </td>
                        <td className="px-4 py-3 text-right align-middle">
                          <div className="tabular-nums text-slate-600 dark:text-slate-300">
                            {formatUpdated(r.updatedMinsAgo)}
                          </div>
                          <div className="mt-0.5 text-xs text-slate-400 dark:text-slate-500">{r.author}</div>
                        </td>
                      </motion.tr>
                    );
                  })
                )}
              </tbody>
            </table>
          </div>

          {/* Footer summary */}
          <div className="border-t border-slate-200 px-4 py-3 dark:border-slate-800 sm:px-5">
            <p aria-live="polite" className="text-xs text-slate-500 dark:text-slate-400">
              {summary}
            </p>
          </div>
        </div>

        <p aria-live="assertive" className="sr-only">
          {action}
        </p>
      </motion.div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →