Zum Inhalt springen
UI Kit
Accent

Webhooks

Webhook-Screens. Endpunkte mit Zustand der letzten 24 Stunden, Ereignisauswahl, Zustellprotokoll mit Anfrage und Antwort, Ereigniskatalog und Testversand.

Eine von 3 Components in Developer Settings

5 Variants

Variants

Endpunkte

Jeder Endpunkt mit Ereignissen, letzter Zustellung, Schalter zum Pausieren und Stundenbalken des letzten Tages, die bei Fehlern rot werden.

Im Studio öffnen

Endpunkt hinzufügen

URL mit https-Prüfung, Ereignisse in Gruppen mit Suche und Gruppenhaken, danach das Signaturgeheimnis einmal.

Im Studio öffnen

Zustellprotokoll

Zustellungen nach Ergebnis gefiltert, Detail mit Status, Dauer, Versuch, Anfrage und Antwort, dazu erneutes Senden mit neuem Versuch.

Im Studio öffnen

Ereigniskatalog

Jeder Ereignistyp in Gruppen mit Suche, Beschreibung, wer es erhält, und ein Beispiel zum Kopieren.

Im Studio öffnen

Testereignis

Endpunkt und Ereignis wählen, Inhalt mit JSON-Prüfung bearbeiten und sehen, wie die Anfrage signiert, gesendet und beantwortet wird.

Im Studio öffnen

Details

Was Webhooks mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.1 · seit 1.1.0
Datei
components/ui/webhooks.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 82 eigene Texte

Props

Die Schnittstelle WebhooksProps aus components/ui/webhooks.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    WebhooksVariant

    endpointsadd-endpointdeliveriesevent-catalogtest-event

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlePflicht
    string
  • subtitleoptional
    string
  • nowoptional
    string

    ISO date and time that counts as now

  • endpointsoptional
    WebhookEndpoint[]
  • eventsoptional
    WebhookEvent[]
  • deliveriesoptional
    WebhookDelivery[]
  • onActionoptional
    (action: 'add' | 'toggle' | 'resend' | 'test' | 'delete', data: Record<string, unknown>) => void | Promise<void>

    Called on save, toggle, resend and test. Without it the block simulates the request.

Eingebaute Texte

Diese Texte schreibt Webhooks selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 82 Texte anzeigen
endpointsEnglisch EndpointsDeutsch Endpunkte
endpointsTextEnglisch We send a POST request with a signed JSON body for every event you pick.Deutsch Für jedes gewählte Ereignis senden wir eine POST-Anfrage mit signiertem JSON.
addEnglisch Add endpointDeutsch Endpunkt hinzufügen
urlEnglisch Endpoint URLDeutsch URL des Endpunkts
urlHintEnglisch Must start with https:// and answer within 10 seconds.Deutsch Muss mit https:// beginnen und innerhalb von 10 Sekunden antworten.
urlErrorEnglisch Enter a full https:// address.Deutsch Gib eine vollständige https://-Adresse ein.
descriptionEnglisch DescriptionDeutsch Beschreibung
descPlaceholderEnglisch New orders to the warehouseDeutsch Neue Bestellungen ans Lager
eventsEnglisch EventsDeutsch Ereignisse
allEventsEnglisch All eventsDeutsch Alle Ereignisse
eventsNEnglisch {n} eventsDeutsch {n} Ereignisse
eventsPickedEnglisch {n} of {total} pickedDeutsch {n} von {total} gewählt
pickEventsEnglisch Pick at least one event.Deutsch Wähle mindestens ein Ereignis.
searchEventsEnglisch Search eventsDeutsch Ereignisse suchen
selectGroupEnglisch Pick all in {group}Deutsch Alle in {group} wählen
clearAllEnglisch ClearDeutsch Leeren
noEventsEnglisch No event matches “{q}”.Deutsch Kein Ereignis passt zu „{q}“.
enabledEnglisch ActiveDeutsch Aktiv
disabledEnglisch PausedDeutsch Pausiert
failingEnglisch FailingDeutsch Fehlerhaft
toggleEnglisch Send events to {url}Deutsch Ereignisse an {url} senden
success24Englisch Last 24 hoursDeutsch Letzte 24 Stunden
successRateEnglisch {n} deliveredDeutsch {n} zugestellt
lastDeliveryEnglisch Last delivery {when}Deutsch Zuletzt zugestellt {when}
neverEnglisch No deliveries yetDeutsch Noch keine Zustellung
saveEnglisch Add endpointDeutsch Endpunkt hinzufügen
savingEnglisch Adding…Deutsch Wird hinzugefügt…
addedEnglisch Endpoint addedDeutsch Endpunkt hinzugefügt
addedTextEnglisch Use this secret to check that requests come from us. It is shown in full only now.Deutsch Mit diesem Geheimnis prüfst du, dass Anfragen von uns kommen. Vollständig zeigen wir es nur jetzt.
secretEnglisch Signing secretDeutsch Signaturgeheimnis
copyEnglisch CopyDeutsch Kopieren
copiedEnglisch CopiedDeutsch Kopiert
doneEnglisch DoneDeutsch Fertig
cancelEnglisch CancelDeutsch Abbrechen
removeEnglisch Delete endpointDeutsch Endpunkt löschen
removeTitleEnglisch Delete this endpoint?Deutsch Endpunkt löschen?
removeBodyEnglisch {url} stops receiving events right away. Past deliveries stay in the log for 30 days.Deutsch {url} erhält sofort keine Ereignisse mehr. Frühere Zustellungen bleiben 30 Tage im Protokoll.
removedEnglisch Endpoint deleted.Deutsch Endpunkt gelöscht.
deliveriesEnglisch DeliveriesDeutsch Zustellungen
allStatusEnglisch AllDeutsch Alle
succeededEnglisch SucceededDeutsch Erfolgreich
failedEnglisch FailedDeutsch Fehlgeschlagen
noAnswerEnglisch No answerDeutsch Keine Antwort
attemptEnglisch Attempt {n}Deutsch Versuch {n}
requestEnglisch Request bodyDeutsch Anfrage
responseEnglisch ResponseDeutsch Antwort
durationEnglisch DurationDeutsch Dauer
statusEnglisch StatusDeutsch Status
timeEnglisch TimeDeutsch Zeit
resendEnglisch ResendDeutsch Erneut senden
resendingEnglisch Sending…Deutsch Wird gesendet…
resentEnglisch Sent again, attempt {n} answered {status}.Deutsch Erneut gesendet, Versuch {n} antwortete {status}.
backEnglisch Back to deliveriesDeutsch Zurück zu den Zustellungen
pickEnglisch Pick a delivery to see its request and response.Deutsch Wähle eine Zustellung, um Anfrage und Antwort zu sehen.
nextRetryEnglisch Next automatic retry {when}Deutsch Nächster automatischer Versuch {when}
catalogEnglisch Event typesDeutsch Ereignistypen
catalogTextEnglisch Every event we can send, with an example body.Deutsch Jedes Ereignis, das wir senden können, mit Beispiel.
exampleEnglisch Example bodyDeutsch Beispiel
copyExampleEnglisch Copy exampleDeutsch Beispiel kopieren
subscribedByEnglisch Sent to {n} endpointsDeutsch Geht an {n} Endpunkte
subscribedOneEnglisch Sent to 1 endpointDeutsch Geht an 1 Endpunkt
notSubscribedEnglisch No endpoint gets this event yetDeutsch Noch kein Endpunkt erhält dieses Ereignis
testEnglisch Send a test eventDeutsch Testereignis senden
testTextEnglisch Pick an endpoint and an event. We send the example body, marked as a test.Deutsch Wähle Endpunkt und Ereignis. Wir senden das Beispiel, als Test markiert.
endpointEnglisch EndpointDeutsch Endpunkt
eventEnglisch EventDeutsch Ereignis
bodyEnglisch BodyDeutsch Inhalt
bodyErrorEnglisch This is not valid JSON ({msg}).Deutsch Das ist kein gültiges JSON ({msg}).
resetEnglisch Reset to exampleDeutsch Beispiel wiederherstellen
sendEnglisch Send test eventDeutsch Testereignis senden
sendingEnglisch Sending…Deutsch Wird gesendet…
stepSignedEnglisch SignedDeutsch Signiert
stepSentEnglisch SentDeutsch Gesendet
stepAnswerEnglisch AnswerDeutsch Antwort
testOkEnglisch Delivered in {ms} msDeutsch Zugestellt in {ms} ms
testFailEnglisch The endpoint answered {status}Deutsch Der Endpunkt antwortete {status}
testNoneEnglisch The endpoint did not answer within 10 secondsDeutsch Der Endpunkt hat nicht innerhalb von 10 Sekunden geantwortet
retryPlanEnglisch Real events are retried 5 times over 24 hours.Deutsch Echte Ereignisse versuchen wir 5-mal über 24 Stunden.
againEnglisch Send againDeutsch Erneut senden
logEnglisch Test deliveriesDeutsch Testzustellungen
noTestsEnglisch Your test deliveries show up here.Deutsch Deine Testzustellungen erscheinen hier.
pausedEnglisch This endpoint is paused. Test events still go out.Deutsch Dieser Endpunkt ist pausiert. Testereignisse gehen trotzdem raus.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Webhooks reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
  • Setzt aria-checked, aria-controls, aria-current, aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, img, listbox, option, radio, radiogroup und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.295 Zeilen
components/ui/webhooks.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Dialog } from '@/components/ui/dialog';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';

export type WebhooksVariant = 'endpoints' | 'add-endpoint' | 'deliveries' | 'event-catalog' | 'test-event';

export interface WebhookEvent {
  /** e.g. order.created */
  type: string;
  /** Group label, e.g. Orders */
  group: string;
  description: string;
  /** Example payload, any JSON */
  example?: unknown;
}

export interface WebhookEndpoint {
  id: string;
  url: string;
  description?: string;
  /** Event types, or ['*'] for all */
  events: string[];
  enabled: boolean;
  /** Successful share per hour, the last 24 hours, oldest first, 0 to 1 */
  health?: number[];
  /** ISO date and time */
  lastDelivery?: string;
}

export interface WebhookDelivery {
  id: string;
  event: string;
  endpoint: string;
  /** HTTP status of the answer, 0 when the endpoint did not answer */
  status: number;
  /** ISO date and time */
  time: string;
  ms: number;
  attempt: number;
  payload: unknown;
  response?: string;
}

export interface WebhooksProps {
  variant?: WebhooksVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title: string;
  subtitle?: string;
  /** ISO date and time that counts as now */
  now?: string;
  endpoints?: WebhookEndpoint[];
  events?: WebhookEvent[];
  deliveries?: WebhookDelivery[];
  /** Called on save, toggle, resend and test. Without it the block simulates the request. */
  onAction?: (action: 'add' | 'toggle' | 'resend' | 'test' | 'delete', data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    endpoints: 'Endpoints',
    endpointsText: 'We send a POST request with a signed JSON body for every event you pick.',
    add: 'Add endpoint',
    url: 'Endpoint URL',
    urlHint: 'Must start with https:// and answer within 10 seconds.',
    urlError: 'Enter a full https:// address.',
    description: 'Description',
    descPlaceholder: 'New orders to the warehouse',
    events: 'Events',
    allEvents: 'All events',
    eventsN: '{n} events',
    eventsPicked: '{n} of {total} picked',
    pickEvents: 'Pick at least one event.',
    searchEvents: 'Search events',
    selectGroup: 'Pick all in {group}',
    clearAll: 'Clear',
    noEvents: 'No event matches “{q}”.',
    enabled: 'Active',
    disabled: 'Paused',
    failing: 'Failing',
    toggle: 'Send events to {url}',
    success24: 'Last 24 hours',
    successRate: '{n} delivered',
    lastDelivery: 'Last delivery {when}',
    never: 'No deliveries yet',
    save: 'Add endpoint',
    saving: 'Adding…',
    added: 'Endpoint added',
    addedText: 'Use this secret to check that requests come from us. It is shown in full only now.',
    secret: 'Signing secret',
    copy: 'Copy',
    copied: 'Copied',
    done: 'Done',
    cancel: 'Cancel',
    remove: 'Delete endpoint',
    removeTitle: 'Delete this endpoint?',
    removeBody: '{url} stops receiving events right away. Past deliveries stay in the log for 30 days.',
    removed: 'Endpoint deleted.',
    deliveries: 'Deliveries',
    allStatus: 'All',
    succeeded: 'Succeeded',
    failed: 'Failed',
    noAnswer: 'No answer',
    attempt: 'Attempt {n}',
    request: 'Request body',
    response: 'Response',
    duration: 'Duration',
    status: 'Status',
    time: 'Time',
    resend: 'Resend',
    resending: 'Sending…',
    resent: 'Sent again, attempt {n} answered {status}.',
    back: 'Back to deliveries',
    pick: 'Pick a delivery to see its request and response.',
    nextRetry: 'Next automatic retry {when}',
    catalog: 'Event types',
    catalogText: 'Every event we can send, with an example body.',
    example: 'Example body',
    copyExample: 'Copy example',
    subscribedBy: 'Sent to {n} endpoints',
    subscribedOne: 'Sent to 1 endpoint',
    notSubscribed: 'No endpoint gets this event yet',
    test: 'Send a test event',
    testText: 'Pick an endpoint and an event. We send the example body, marked as a test.',
    endpoint: 'Endpoint',
    event: 'Event',
    body: 'Body',
    bodyError: 'This is not valid JSON ({msg}).',
    reset: 'Reset to example',
    send: 'Send test event',
    sending: 'Sending…',
    stepSigned: 'Signed',
    stepSent: 'Sent',
    stepAnswer: 'Answer',
    testOk: 'Delivered in {ms} ms',
    testFail: 'The endpoint answered {status}',
    testNone: 'The endpoint did not answer within 10 seconds',
    retryPlan: 'Real events are retried 5 times over 24 hours.',
    again: 'Send again',
    log: 'Test deliveries',
    noTests: 'Your test deliveries show up here.',
    paused: 'This endpoint is paused. Test events still go out.',
  },
  de: {
    endpoints: 'Endpunkte',
    endpointsText: 'Für jedes gewählte Ereignis senden wir eine POST-Anfrage mit signiertem JSON.',
    add: 'Endpunkt hinzufügen',
    url: 'URL des Endpunkts',
    urlHint: 'Muss mit https:// beginnen und innerhalb von 10 Sekunden antworten.',
    urlError: 'Gib eine vollständige https://-Adresse ein.',
    description: 'Beschreibung',
    descPlaceholder: 'Neue Bestellungen ans Lager',
    events: 'Ereignisse',
    allEvents: 'Alle Ereignisse',
    eventsN: '{n} Ereignisse',
    eventsPicked: '{n} von {total} gewählt',
    pickEvents: 'Wähle mindestens ein Ereignis.',
    searchEvents: 'Ereignisse suchen',
    selectGroup: 'Alle in {group} wählen',
    clearAll: 'Leeren',
    noEvents: 'Kein Ereignis passt zu „{q}“.',
    enabled: 'Aktiv',
    disabled: 'Pausiert',
    failing: 'Fehlerhaft',
    toggle: 'Ereignisse an {url} senden',
    success24: 'Letzte 24 Stunden',
    successRate: '{n} zugestellt',
    lastDelivery: 'Zuletzt zugestellt {when}',
    never: 'Noch keine Zustellung',
    save: 'Endpunkt hinzufügen',
    saving: 'Wird hinzugefügt…',
    added: 'Endpunkt hinzugefügt',
    addedText: 'Mit diesem Geheimnis prüfst du, dass Anfragen von uns kommen. Vollständig zeigen wir es nur jetzt.',
    secret: 'Signaturgeheimnis',
    copy: 'Kopieren',
    copied: 'Kopiert',
    done: 'Fertig',
    cancel: 'Abbrechen',
    remove: 'Endpunkt löschen',
    removeTitle: 'Endpunkt löschen?',
    removeBody: '{url} erhält sofort keine Ereignisse mehr. Frühere Zustellungen bleiben 30 Tage im Protokoll.',
    removed: 'Endpunkt gelöscht.',
    deliveries: 'Zustellungen',
    allStatus: 'Alle',
    succeeded: 'Erfolgreich',
    failed: 'Fehlgeschlagen',
    noAnswer: 'Keine Antwort',
    attempt: 'Versuch {n}',
    request: 'Anfrage',
    response: 'Antwort',
    duration: 'Dauer',
    status: 'Status',
    time: 'Zeit',
    resend: 'Erneut senden',
    resending: 'Wird gesendet…',
    resent: 'Erneut gesendet, Versuch {n} antwortete {status}.',
    back: 'Zurück zu den Zustellungen',
    pick: 'Wähle eine Zustellung, um Anfrage und Antwort zu sehen.',
    nextRetry: 'Nächster automatischer Versuch {when}',
    catalog: 'Ereignistypen',
    catalogText: 'Jedes Ereignis, das wir senden können, mit Beispiel.',
    example: 'Beispiel',
    copyExample: 'Beispiel kopieren',
    subscribedBy: 'Geht an {n} Endpunkte',
    subscribedOne: 'Geht an 1 Endpunkt',
    notSubscribed: 'Noch kein Endpunkt erhält dieses Ereignis',
    test: 'Testereignis senden',
    testText: 'Wähle Endpunkt und Ereignis. Wir senden das Beispiel, als Test markiert.',
    endpoint: 'Endpunkt',
    event: 'Ereignis',
    body: 'Inhalt',
    bodyError: 'Das ist kein gültiges JSON ({msg}).',
    reset: 'Beispiel wiederherstellen',
    send: 'Testereignis senden',
    sending: 'Wird gesendet…',
    stepSigned: 'Signiert',
    stepSent: 'Gesendet',
    stepAnswer: 'Antwort',
    testOk: 'Zugestellt in {ms} ms',
    testFail: 'Der Endpunkt antwortete {status}',
    testNone: 'Der Endpunkt hat nicht innerhalb von 10 Sekunden geantwortet',
    retryPlan: 'Echte Ereignisse versuchen wir 5-mal über 24 Stunden.',
    again: 'Erneut senden',
    log: 'Testzustellungen',
    noTests: 'Deine Testzustellungen erscheinen hier.',
    paused: 'Dieser Endpunkt ist pausiert. Testereignisse gehen trotzdem raus.',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/* ------------------------------------------------------------------ */
/* Motion of the app screens                                           */
/* ------------------------------------------------------------------ */
/*
 * The same small set in every app block (auth, dashboard, files, inbox …),
 * kept in the block so it ships on its own:
 *   useEnter   entrance that waits until its area scrolls in. The area gets
 *              data-kitm-scope and data-kitm={state}, children take kitm-wipe
 *              (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
 *              kitm-up and kitm-pop, with --kitm-d as delay.
 *   useGlide   a marker inside the active item ([data-glide]) glides over
 *              from where the previous one stood: tabs, segments, steps.
 *   kitm-in, kitm-fly, kitm-tick for things that appear after an action.
 * The server renders the end state, without JavaScript nothing waits longer
 * than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
 * clip-path change, so no layout shifts.
 */
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
  ['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
  ['grow-x', 'transform:scaleX(0)', 900],
  ['grow-y', 'transform:scaleY(0)', 800],
  ['up', 'opacity:0;transform:translateY(10px)', 600],
  ['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
  KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
  '@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
  '.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
  '@media (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
  '[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
  '}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
  '}';

function KitmStyle() {
  // React hoists it once into the head, however many app blocks render
  return (
    <style href="kitm" precedence="default">
      {KITM_CSS}
    </style>
  );
}

/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
    setState('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        setState('play');
        io.disconnect();
      },
      { rootMargin: '0px 0px -8% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}

type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
  const r = el.getBoundingClientRect();
  const b = root.getBoundingClientRect();
  return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};

/**
 * The marker ([data-glide]) is rendered inside the active item, so the server
 * and a page without JavaScript show it in place. After a change it starts
 * where the old one stood and glides into the new item.
 */
function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<Box | null>(null);
  // Read during render, while the DOM still shows the old marker
  if (typeof window !== 'undefined' && ref.current) {
    const m = ref.current.querySelector('[data-glide]');
    before.current = m ? boxOf(m, ref.current) : null;
  }
  useIso(() => {
    const root = ref.current;
    const m = root?.querySelector<HTMLElement>('[data-glide]');
    const a = before.current;
    if (!root || !m || !a || prefersReducedMotion()) return;
    const b = boxOf(m, root);
    if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
    m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
  }, [key]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* Time and numbers                                                    */
/* ------------------------------------------------------------------ */

const MIN = 60000;
const toMin = (iso: string) => Math.round(Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z')) / MIN);

type Formatter = ReturnType<typeof formatter>;

function formatter(locale: Locale) {
  const tag = locale === 'de' ? 'de-DE' : 'en-US';
  const rtf = new Intl.RelativeTimeFormat(tag, { numeric: 'auto' });
  return {
    num: (n: number) => new Intl.NumberFormat(tag).format(n),
    pct: (n: number) => new Intl.NumberFormat(tag, { style: 'percent', maximumFractionDigits: 1 }).format(n),
    stamp: (iso: string) => new Intl.DateTimeFormat(tag, { day: 'numeric', month: 'short', hour: tag === 'de-DE' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(toMin(iso) * MIN)),
    ago: (iso: string, now: string) => {
      const d = toMin(iso) - toMin(now);
      const a = Math.abs(d);
      if (a < 60) return rtf.format(Math.round(d), 'minute');
      if (a < 60 * 24) return rtf.format(Math.round(d / 60), 'hour');
      return rtf.format(Math.round(d / 1440), 'day');
    },
  };
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

type P = Omit<WebhooksProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; f: Formatter; now: string };

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: WebhooksProps['onAction'], action: Parameters<NonNullable<WebhooksProps['onAction']>>[0], data: Record<string, unknown>) {
  if (onAction) await onAction(action, data);
  else await wait(700);
}

function Card({ title, description, action, children, className, bodyClassName, id }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string; id?: string }) {
  return (
    <section aria-labelledby={title && id ? id : undefined} className={cn('flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
      {(title || action) && (
        <div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
          <div className="min-w-0">
            {title && (
              <h2 id={id} className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">
                {title}
              </h2>
            )}
            {description && <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{description}</p>}
          </div>
          {action}
        </div>
      )}
      <div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
    </section>
  );
}

function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
        <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
      </div>
      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
    </div>
  );
}

function Main({ children, className }: { children: React.ReactNode; className?: string }) {
  return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}

function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
  const ref = useGlide<HTMLDivElement>(value);
  const onKey = (e: React.KeyboardEvent, i: number) => {
    const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = (i + dir + options.length) % options.length;
    onChange(options[next].value);
    ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
  };
  return (
    <div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
      {options.map((o, i) => (
        <button
          key={o.value}
          type="button"
          role="radio"
          aria-checked={value === o.value}
          tabIndex={value === o.value ? 0 : -1}
          onClick={() => onChange(o.value)}
          onKeyDown={(e) => onKey(e, i)}
          className={cn(
            'relative inline-flex h-11 min-w-11 sm:h-10 sm:min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:h-8',
            value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
          )}
        >
          {value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
          <span className="relative inline-flex items-center gap-1.5">{o.label}</span>
        </button>
      ))}
    </div>
  );
}

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

function CopyButton({ text, t, label }: { text: string; t: Texts; label?: string }) {
  const [done, setDone] = useState(false);
  useEffect(() => {
    if (!done) return;
    const id = window.setTimeout(() => setDone(false), 1600);
    return () => window.clearTimeout(id);
  }, [done]);
  return (
    <button
      type="button"
      onClick={async () => {
        try {
          await navigator.clipboard.writeText(text);
        } catch {
          /* The check still shows */
        }
        setDone(true);
      }}
      aria-label={label ?? t.copy}
      className="inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-md px-2.5 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:h-8"
    >
      <Icon key={String(done)} name={done ? 'check' : 'copy'} size={14} className={done ? 'kitm-tick text-success-foreground' : 'kitm-in [--kitm-t:.25s]'} />
      <span key={`l${done}`} className="kitm-in [--kitm-t:.25s]">
        {done ? t.copied : t.copy}
      </span>
    </button>
  );
}

/** JSON with keys, strings and numbers set apart. Same typeface as the rest, lines wrap instead of scrolling sideways. */
function Json({ value, className }: { value: unknown; className?: string }) {
  const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
  const parts = text.split(/("(?:[^"\\]|\\.)*"(?=\s*:)|"(?:[^"\\]|\\.)*"|-?\b\d+(?:\.\d+)?\b|\btrue\b|\bfalse\b|\bnull\b)/g);
  return (
    <pre className={cn('whitespace-pre-wrap break-words font-[inherit] text-[12.5px] leading-[1.7] tabular-nums text-muted-foreground', className)}>
      {parts.map((p, i) => {
        if (i % 2 === 0) return p;
        const key = /^"/.test(p) && /^\s*:/.test(parts[i + 1] ?? '');
        const tone = key ? 'text-foreground' : p.startsWith('"') ? 'text-success-foreground' : /^(true|false|null)$/.test(p) ? 'text-warning-foreground' : 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]';
        return (
          <span key={i} className={tone}>
            {p}
          </span>
        );
      })}
    </pre>
  );
}

function StatusChip({ status, t }: { status: number; t: Texts }) {
  const ok = status >= 200 && status < 300;
  return (
    <span className={cn('inline-flex h-6 shrink-0 items-center gap-1.5 rounded-md px-2 text-[12px] font-semibold tabular-nums', ok ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--destructive)_11%,transparent)] text-destructive')}>
      <Icon name={ok ? 'check' : 'x'} size={12} strokeWidth={2.2} />
      {status || t.noAnswer}
    </span>
  );
}

/** 24 hourly bars, height by delivered share, red when below 90 % */
function Health({ values, label }: { values: number[]; label: string }) {
  return (
    <span role="img" aria-label={label} className="flex h-6 items-end gap-[2px]">
      {values.map((v, i) => (
        <span key={i} className={cn('kitm-grow-y w-[3px] rounded-[1px]', v < 0.9 ? 'bg-destructive' : 'bg-[color-mix(in_oklab,var(--success)_75%,var(--muted))]')} style={{ height: `${round(Math.max(0.15, v) * 100)}%`, '--kitm-d': `${i * 18}ms` } as React.CSSProperties} />
      ))}
    </span>
  );
}

const average = (xs: number[] = []) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 1);
const host = (url: string) => url.replace(/^https?:\/\//, '');

/* ------------------------------------------------------------------ */
/* The endpoint form, used by add-endpoint and in the endpoints dialog */
/* ------------------------------------------------------------------ */

function EndpointForm({ t, events = [], busy, onSubmit }: { t: Texts; events?: WebhookEvent[]; busy: boolean; onSubmit: (v: { url: string; description: string; events: string[] }) => void }) {
  const id = useId();
  const [url, setUrl] = useState('');
  const [desc, setDesc] = useState('');
  const [picked, setPicked] = useState<Set<string>>(new Set());
  const [q, setQ] = useState('');
  const [tried, setTried] = useState(false);
  const groups = useMemo(() => {
    const map = new Map<string, WebhookEvent[]>();
    for (const e of events) {
      if (q && !`${e.type} ${e.description}`.toLowerCase().includes(q.toLowerCase())) continue;
      map.set(e.group, [...(map.get(e.group) ?? []), e]);
    }
    return [...map];
  }, [events, q]);
  const urlOk = /^https:\/\/[^\s/$.?#].[^\s]*\.[^\s]{2,}/.test(url.trim());
  const toggle = (type: string) =>
    setPicked((s) => {
      const n = new Set(s);
      n.has(type) ? n.delete(type) : n.add(type);
      return n;
    });
  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    setTried(true);
    if (!urlOk || !picked.size) return;
    onSubmit({ url: url.trim(), description: desc.trim(), events: picked.size === events.length ? ['*'] : [...picked] });
  };
  return (
    <form noValidate onSubmit={submit} className="grid gap-5">
      <Input label={t.url} type="url" inputMode="url" placeholder="https://" value={url} error={tried && !urlOk ? t.urlError : undefined} hint={t.urlHint} success={urlOk} onChange={(e) => setUrl(e.target.value)} />
      <Input label={t.description} optional placeholder={t.descPlaceholder} value={desc} onChange={(e) => setDesc(e.target.value)} />
      <fieldset className="min-w-0">
        <legend className="flex w-full items-baseline justify-between gap-3 text-sm font-medium text-foreground">
          {t.events}
          <span key={picked.size} className="kitm-in text-[12.5px] font-normal tabular-nums text-muted-foreground">
            {fill(t.eventsPicked, { n: picked.size, total: events.length })}
          </span>
        </legend>
        <div className={cn('mt-2 overflow-hidden rounded-lg border', tried && !picked.size ? 'border-[color-mix(in_oklab,var(--destructive)_60%,var(--border))]' : 'border-border')}>
          <div className="flex items-center gap-2 border-b border-border bg-surface px-3">
            <Icon name="search" size={15} className="shrink-0 text-subtle-foreground" />
            <label htmlFor={`${id}-q`} className="sr-only">
              {t.searchEvents}
            </label>
            <input id={`${id}-q`} type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.searchEvents} className="h-11 w-0 min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-subtle-foreground sm:h-10 sm:text-[13.5px]" />
            <button type="button" onClick={() => setPicked(picked.size === events.length ? new Set() : new Set(events.map((e) => e.type)))} className="h-11 shrink-0 rounded-md px-2.5 text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] hover:bg-accent sm:h-8 sm:px-2">
              {picked.size === events.length ? t.clearAll : t.allEvents}
            </button>
          </div>
          <div className="max-h-[22rem] overflow-y-auto overscroll-contain">
            {groups.map(([group, list]) => {
              const on = list.filter((e) => picked.has(e.type)).length;
              return (
                <div key={group} className="border-b border-border last:border-b-0">
                  <label className="sticky top-0 z-[1] flex min-h-11 cursor-pointer items-center gap-3 bg-card px-3 py-2 text-[12.5px] font-semibold text-foreground">
                    <input
                      type="checkbox"
                      aria-label={fill(t.selectGroup, { group })}
                      checked={on === list.length}
                      ref={(el) => {
                        if (el) el.indeterminate = on > 0 && on < list.length;
                      }}
                      onChange={() =>
                        setPicked((s) => {
                          const n = new Set(s);
                          list.forEach((e) => (on === list.length ? n.delete(e.type) : n.add(e.type)));
                          return n;
                        })
                      }
                      className="size-4 shrink-0 accent-[var(--primary)]"
                    />
                    {group}
                    <span className="ms-auto text-[12px] font-normal tabular-nums text-subtle-foreground">
                      {on}/{list.length}
                    </span>
                  </label>
                  <ul>
                    {list.map((e) => (
                      <li key={e.type}>
                        <label className={cn('flex min-h-11 cursor-pointer items-start gap-3 px-3 py-2.5 ps-10 transition-colors', picked.has(e.type) ? 'bg-[color-mix(in_oklab,var(--primary)_5%,transparent)]' : 'hover:bg-[color-mix(in_oklab,var(--foreground)_3%,transparent)]')}>
                          <input type="checkbox" checked={picked.has(e.type)} onChange={() => toggle(e.type)} className="mt-0.5 size-4 shrink-0 accent-[var(--primary)]" />
                          <span className="min-w-0">
                            <span className="block text-[13px] font-medium text-foreground">{e.type}</span>
                            <span className="block text-pretty text-[12.5px] leading-snug text-muted-foreground">{e.description}</span>
                          </span>
                        </label>
                      </li>
                    ))}
                  </ul>
                </div>
              );
            })}
            {!groups.length && <p className="px-4 py-8 text-center text-[13px] text-muted-foreground">{fill(t.noEvents, { q })}</p>}
          </div>
        </div>
        {tried && !picked.size && (
          <p role="alert" className="kitm-in mt-2 flex items-center gap-1.5 text-[13px] font-medium text-destructive">
            <Icon name="alert-circle" size={15} />
            {t.pickEvents}
          </p>
        )}
      </fieldset>
      <Button type="submit" disabled={busy} iconLeft={busy ? 'loader' : 'plus'} className="max-sm:w-full sm:justify-self-end">
        {busy ? t.saving : t.save}
      </Button>
    </form>
  );
}

function SecretReveal({ t, onDone }: { t: Texts; onDone: () => void }) {
  const [secret] = useState(() => {
    const pool = 'abcdefghijkmnopqrstuvwxyz0123456789';
    const b = new Uint8Array(28);
    crypto.getRandomValues(b);
    return `whsec_${Array.from(b, (x) => pool[x % pool.length]).join('')}`;
  });
  return (
    <div className="kitm-in grid gap-4">
      <p className="text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{t.addedText}</p>
      <div className="flex min-w-0 items-center gap-1 rounded-lg border border-border bg-surface p-1.5 ps-3.5">
        <code className="min-w-0 flex-1 break-all py-2 font-[inherit] text-[13.5px] font-medium tabular-nums text-foreground">{secret}</code>
        <CopyButton text={secret} t={t} />
      </div>
      <Button onClick={onDone} className="max-sm:w-full sm:justify-self-end">
        {t.done}
      </Button>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* endpoints                                                           */
/* ------------------------------------------------------------------ */

function Endpoints({ t, f, title, subtitle, endpoints = [], events = [], now, onAction }: P) {
  const [items, setItems] = useState(endpoints);
  const [adding, setAdding] = useState(false);
  const [busy, setBusy] = useState(false);
  const [secretFor, setSecretFor] = useState('');
  const [removing, setRemoving] = useState<WebhookEndpoint | null>(null);
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const add = async (v: { url: string; description: string; events: string[] }) => {
    setBusy(true);
    await run(onAction, 'add', v);
    const e: WebhookEndpoint = { id: `we${Date.now()}`, url: v.url, description: v.description, events: v.events, enabled: true };
    setItems((l) => [e, ...l]);
    setSecretFor(e.id);
    setBusy(false);
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Button size="sm" iconLeft="plus" onClick={() => setAdding(true)} magnetic>
          {t.add}
        </Button>
      </Header>
      <p className="mt-2 max-w-[44rem] text-pretty text-[13.5px] text-muted-foreground">{t.endpointsText}</p>
      <div ref={scope} {...enter} className="mt-6">
        <ul className="grid gap-3">
          {items.map((e, i) => {
            const rate = average(e.health);
            const failing = e.enabled && rate < 0.9;
            return (
              <li key={e.id} className={cn('kitm-up rounded-xl border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,opacity] duration-300', failing ? 'border-[color-mix(in_oklab,var(--destructive)_40%,var(--border))]' : 'border-border', !e.enabled && 'opacity-80')} style={{ '--kitm-d': `${i * 70}ms` } as React.CSSProperties}>
                <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-3 p-4 sm:p-5 lg:grid-cols-[minmax(0,1fr)_auto_9rem_auto]">
                  <div className="min-w-0">
                    <p className="flex min-w-0 items-center gap-2">
                      <span className={cn('size-2 shrink-0 rounded-full', !e.enabled ? 'bg-subtle-foreground' : failing ? 'bg-destructive motion-safe:animate-pulse' : 'bg-success')} aria-hidden />
                      <span className="truncate text-[14px] font-medium text-foreground">{host(e.url)}</span>
                    </p>
                    {e.description && <p className="mt-1 line-clamp-2 text-pretty text-[13px] text-muted-foreground">{e.description}</p>}
                    <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-subtle-foreground">
                      <span className="inline-flex h-5 items-center rounded bg-muted px-1.5 font-medium text-muted-foreground">{e.events[0] === '*' ? t.allEvents : fill(t.eventsN, { n: e.events.length })}</span>
                      <span>{e.lastDelivery ? fill(t.lastDelivery, { when: f.ago(e.lastDelivery, now) }) : t.never}</span>
                      <span className={cn('font-medium', !e.enabled ? 'text-muted-foreground' : failing ? 'text-destructive' : 'text-success-foreground')}>{!e.enabled ? t.disabled : failing ? t.failing : t.enabled}</span>
                    </p>
                  </div>
                  <div className="col-start-2 row-start-1 flex items-center gap-1 lg:col-start-4">
                    <Switch
                      checked={e.enabled}
                      aria-label={fill(t.toggle, { url: host(e.url) })}
                      onCheckedChange={(v) => {
                        setItems((l) => l.map((x) => (x.id === e.id ? { ...x, enabled: v } : x)));
                        void run(onAction, 'toggle', { id: e.id, enabled: v });
                      }}
                    />
                    <button type="button" aria-label={`${t.remove}: ${host(e.url)}`} onClick={() => setRemoving(e)} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive sm:size-9">
                      <Icon name="trash" size={15} />
                    </button>
                  </div>
                  {e.health && (
                    <div className={cn('col-span-2 flex items-center justify-between gap-4 border-t border-border pt-3 transition-opacity lg:col-span-1 lg:col-start-2 lg:row-start-1 lg:block lg:border-0 lg:pt-0', !e.enabled && 'opacity-50')}>
                      <Health values={e.health} label={`${t.success24}: ${fill(t.successRate, { n: f.pct(rate) })}`} />
                      <p className="text-right text-[11.5px] text-subtle-foreground lg:mt-1 lg:text-left">
                        <span className={cn('me-1.5 text-[13px] font-semibold tabular-nums lg:hidden', failing ? 'text-destructive' : 'text-foreground')}>{f.pct(rate)}</span>
                        {t.success24}
                      </p>
                    </div>
                  )}
                  {e.health && (
                    <p className="hidden text-right lg:col-start-3 lg:row-start-1 lg:block">
                      <span className={cn('block text-[18px] font-semibold tabular-nums', failing ? 'text-destructive' : 'text-foreground')}>{f.pct(rate)}</span>
                      <span className="text-[11.5px] text-subtle-foreground">{fill(t.successRate, { n: '' }).trim()}</span>
                    </p>
                  )}
                </div>
              </li>
            );
          })}
        </ul>
      </div>
      <Dialog
        open={adding}
        onOpenChange={(v) => {
          setAdding(v);
          if (!v) setSecretFor('');
        }}
        size="lg"
        title={secretFor ? t.added : t.add}
        description={secretFor ? undefined : t.endpointsText}
      >
        <div className="pb-6">
          {secretFor ? (
            <SecretReveal
              t={t}
              onDone={() => {
                setAdding(false);
                setSecretFor('');
              }}
            />
          ) : (
            <EndpointForm t={t} events={events} busy={busy} onSubmit={add} />
          )}
        </div>
      </Dialog>
      <Dialog
        open={Boolean(removing)}
        onOpenChange={(v) => !v && setRemoving(null)}
        icon="trash"
        tone="destructive"
        size="sm"
        title={t.removeTitle}
        description={removing ? fill(t.removeBody, { url: host(removing.url) }) : ''}
        footer={(close) => (
          <>
            <Button variant="outline" size="sm" onClick={close}>
              {t.cancel}
            </Button>
            <Button
              variant="destructive"
              size="sm"
              onClick={() => {
                const e = removing;
                close();
                if (!e) return;
                setItems((l) => l.filter((x) => x.id !== e.id));
                setMsg(t.removed);
                void run(onAction, 'delete', { id: e.id });
              }}
            >
              {t.remove}
            </Button>
          </>
        )}
      />
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* add-endpoint                                                        */
/* ------------------------------------------------------------------ */

function AddEndpoint({ t, title, subtitle, events = [], onAction }: P) {
  const [busy, setBusy] = useState(false);
  const [done, setDone] = useState(false);
  const [round_, setRound] = useState(0);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const steps: { icon: IconName; title: string; text: string }[] = [
    { icon: 'link', title: t.url, text: t.urlHint },
    { icon: 'list-checks', title: t.events, text: t.endpointsText },
    { icon: 'key', title: t.secret, text: t.addedText },
  ];
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start">
        <Card title={done ? t.added : t.add} className="kitm-up">
          {done ? (
            <SecretReveal
              t={t}
              onDone={() => {
                setDone(false);
                setRound((n) => n + 1);
              }}
            />
          ) : (
            <EndpointForm
              key={round_}
              t={t}
              events={events}
              busy={busy}
              onSubmit={async (v) => {
                setBusy(true);
                await run(onAction, 'add', v);
                setBusy(false);
                setDone(true);
              }}
            />
          )}
        </Card>
        {/* Desktop: what happens, as a quiet column. Tablets and phones: a row of three under the form. */}
        <ol className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
          {steps.map((s, i) => (
            <li key={s.title} className="kitm-up flex gap-3 rounded-xl border border-border bg-surface p-4" style={{ '--kitm-d': `${120 + i * 80}ms` } as React.CSSProperties}>
              <span className="grid size-8 shrink-0 place-items-center rounded-lg bg-card text-foreground shadow-[var(--highlight)]">
                <Icon name={s.icon} size={15} />
              </span>
              <span className="min-w-0">
                <span className="block text-[13px] font-medium text-foreground">{s.title}</span>
                <span className="mt-0.5 block text-pretty text-[12.5px] leading-snug text-muted-foreground">{s.text}</span>
              </span>
            </li>
          ))}
        </ol>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* deliveries                                                          */
/* ------------------------------------------------------------------ */

function Deliveries({ t, f, title, subtitle, deliveries = [], now, onAction }: P) {
  const id = useId();
  const [items, setItems] = useState(deliveries);
  const [filter, setFilter] = useState<'all' | 'ok' | 'failed'>('all');
  const [sel, setSel] = useState(deliveries.find((d) => d.status < 200 || d.status >= 300)?.id ?? deliveries[0]?.id ?? '');
  const [open, setOpen] = useState(false);
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const listRef = useGlide<HTMLUListElement>(sel);
  const ok = (d: WebhookDelivery) => d.status >= 200 && d.status < 300;
  const shown = items.filter((d) => filter === 'all' || (filter === 'ok' ? ok(d) : !ok(d)));
  const d = items.find((x) => x.id === sel);
  const resend = async () => {
    if (!d) return;
    setBusy(true);
    await run(onAction, 'resend', { id: d.id });
    const attempt = Math.max(...items.filter((x) => x.event === d.event && x.endpoint === d.endpoint).map((x) => x.attempt)) + 1;
    const n: WebhookDelivery = { ...d, id: `${d.id}-r${attempt}`, status: 200, ms: 164, attempt, time: now, response: '{"received": true}' };
    setItems((l) => [n, ...l]);
    setFilter('all');
    setSel(n.id);
    setBusy(false);
    setMsg(fill(t.resent, { n: attempt, status: 200 }));
  };
  const pick = (x: string) => {
    setSel(x);
    setOpen(true);
    setMsg('');
  };
  const detail = d && (
    <div key={d.id} className="kitm-in @container grid gap-5">
      <div className="flex flex-wrap items-start justify-between gap-3">
        <div className="min-w-0">
          <p className="truncate text-[15px] font-semibold text-foreground">{d.event}</p>
          <p className="mt-0.5 truncate text-[12.5px] text-muted-foreground">{host(d.endpoint)}</p>
        </div>
        <Button size="sm" variant="outline" iconLeft={busy ? 'loader' : 'rotate-cw'} onClick={resend} disabled={busy}>
          {busy ? t.resending : t.resend}
        </Button>
      </div>
      {/* Four figures in a row only where each one has room, otherwise two by two */}
      <dl className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border text-[13px] @lg:grid-cols-4">
        {[
          { k: t.status, v: <StatusChip status={d.status} t={t} /> },
          { k: t.duration, v: <span className="tabular-nums">{f.num(d.ms)} ms</span> },
          { k: t.time, v: <span className="tabular-nums">{f.stamp(d.time)}</span> },
          { k: fill(t.attempt, { n: '' }).trim(), v: <span className="tabular-nums">{d.attempt}</span> },
        ].map((x) => (
          <div key={x.k} className="min-w-0 bg-card p-3">
            <dt className="text-[12px] text-muted-foreground">{x.k}</dt>
            <dd className="mt-1 truncate font-medium text-foreground">{x.v}</dd>
          </div>
        ))}
      </dl>
      {!ok(d) && (
        <p className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
          <Icon name="clock" size={14} className="shrink-0" />
          {fill(t.nextRetry, { when: f.ago(new Date((toMin(d.time) + 30 * d.attempt) * MIN).toISOString().slice(0, 16), now) })}
        </p>
      )}
      <section>
        <div className="flex items-center justify-between gap-3">
          <h3 className="text-[12.5px] font-medium text-muted-foreground">{t.request}</h3>
          <CopyButton text={JSON.stringify(d.payload, null, 2)} t={t} />
        </div>
        <div className="mt-1.5 max-h-80 overflow-y-auto rounded-lg border border-border bg-surface p-3.5">
          <Json value={d.payload} />
        </div>
      </section>
      <section>
        <h3 className="text-[12.5px] font-medium text-muted-foreground">{t.response}</h3>
        <div className="mt-1.5 rounded-lg border border-border bg-surface p-3.5">
          <Json value={d.response ?? '·'} className={ok(d) ? undefined : 'text-destructive'} />
        </div>
      </section>
    </div>
  );
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Segmented
          label={t.status}
          value={filter}
          onChange={setFilter}
          className="w-full sm:w-auto"
          options={[
            { value: 'all', label: t.allStatus },
            { value: 'ok', label: t.succeeded },
            { value: 'failed', label: t.failed },
          ]}
        />
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[22rem_minmax(0,1fr)] lg:items-start">
        <section aria-label={t.deliveries} className={cn('kitm-up min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', open && 'max-lg:hidden')}>
          <ul ref={listRef} className="divide-y divide-border">
            {shown.map((x) => (
              <li key={x.id} className={cn(x.id.includes('-r') && 'kitm-fly')}>
                <button type="button" aria-current={x.id === sel ? 'true' : undefined} aria-controls={`${id}-d`} onClick={() => pick(x.id)} className={cn('relative grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 px-4 py-3 text-left [outline-offset:-2px] transition-colors', x.id === sel ? 'bg-[color-mix(in_oklab,var(--primary)_6%,transparent)]' : 'hover:bg-[color-mix(in_oklab,var(--foreground)_3%,transparent)]')}>
                  {x.id === sel && <span data-glide aria-hidden className="absolute inset-y-0 left-0 w-0.5 bg-primary max-lg:hidden" />}
                  <span className="truncate text-[13.5px] font-medium text-foreground">{x.event}</span>
                  <StatusChip status={x.status} t={t} />
                  <span className="truncate text-[12px] text-muted-foreground">{host(x.endpoint)}</span>
                  <span className="text-right text-[12px] tabular-nums text-subtle-foreground">{f.ago(x.time, now)}</span>
                </button>
              </li>
            ))}
          </ul>
        </section>
        {/* Desktop: the detail next to the list. Tablets and phones: a view of its own with a way back. */}
        <section id={`${id}-d`} aria-label={t.request} className={cn('kitm-up [--kitm-d:100ms] min-w-0 rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5 lg:sticky lg:top-6', !open && 'max-lg:hidden')}>
          <button type="button" onClick={() => setOpen(false)} className="-ms-1 mb-4 inline-flex h-11 items-center gap-1.5 rounded-md px-1 text-[13px] font-medium text-muted-foreground hover:text-foreground lg:hidden">
            <Icon name="arrow-left" size={15} />
            {t.back}
          </button>
          {detail ?? <p className="py-10 text-center text-[13px] text-muted-foreground">{t.pick}</p>}
        </section>
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* event-catalog                                                       */
/* ------------------------------------------------------------------ */

function Catalog({ t, title, subtitle, events = [], endpoints = [] }: P) {
  const id = useId();
  const [q, setQ] = useState('');
  const [sel, setSel] = useState(events[0]?.type ?? '');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const listRef = useGlide<HTMLDivElement>(sel);
  const shown = events.filter((e) => !q || `${e.type} ${e.description} ${e.group}`.toLowerCase().includes(q.toLowerCase()));
  const groups = [...new Set(shown.map((e) => e.group))];
  const e = events.find((x) => x.type === sel);
  const subs = (type: string) => endpoints.filter((x) => x.events.includes('*') || x.events.includes(type)).length;
  const body = (x: WebhookEvent) => (
    <div className="grid gap-4">
      <p className="text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{x.description}</p>
      <p className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
        <Icon name="plug" size={14} />
        {subs(x.type) === 1 ? t.subscribedOne : subs(x.type) ? fill(t.subscribedBy, { n: subs(x.type) }) : t.notSubscribed}
      </p>
      {x.example !== undefined && (
        <section>
          <div className="flex items-center justify-between gap-3">
            <h3 className="text-[12.5px] font-medium text-muted-foreground">{t.example}</h3>
            <CopyButton text={JSON.stringify(x.example, null, 2)} t={t} label={t.copyExample} />
          </div>
          <div className="mt-1.5 rounded-lg border border-border bg-surface p-3.5">
            <Json value={x.example} />
          </div>
        </section>
      )}
    </div>
  );
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Input aria-label={t.searchEvents} placeholder={t.searchEvents} icon="search" size="sm" type="search" value={q} onChange={(ev) => setQ(ev.target.value)} className="w-full sm:w-64" />
      </Header>
      <p className="mt-2 text-[13.5px] text-muted-foreground">{t.catalogText}</p>
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 md:grid-cols-[17rem_minmax(0,1fr)] md:items-start lg:grid-cols-[20rem_minmax(0,1fr)]">
        {/* Tablets and desktop: a list and the event next to it. Phones: an accordion. */}
        <div
          ref={listRef}
          className="kitm-up hidden overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] md:block"
          role="listbox"
          aria-label={t.catalog}
          onKeyDown={(k) => {
            const order = groups.flatMap((g) => shown.filter((x) => x.group === g).map((x) => x.type));
            const at = order.indexOf(sel);
            const next = k.key === 'ArrowDown' ? order[Math.min(order.length - 1, at + 1)] : k.key === 'ArrowUp' ? order[Math.max(0, at - 1)] : k.key === 'Home' ? order[0] : k.key === 'End' ? order[order.length - 1] : undefined;
            if (!next) return;
            k.preventDefault();
            setSel(next);
            document.getElementById(`${id}-${next}`)?.focus();
          }}
        >
          {groups.map((g) => (
            <div key={g} className="border-b border-border last:border-b-0">
              <p className="px-4 pb-1 pt-3 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{g}</p>
              {shown
                .filter((x) => x.group === g)
                .map((x) => (
                  <button key={x.type} id={`${id}-${x.type}`} type="button" role="option" aria-selected={x.type === sel} tabIndex={x.type === sel || (!shown.some((y) => y.type === sel) && x === shown[0]) ? 0 : -1} onClick={() => setSel(x.type)} className={cn('relative flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left [outline-offset:-2px] text-[13.5px] transition-colors', x.type === sel ? 'font-medium text-foreground' : 'text-muted-foreground hover:text-foreground')}>
                    {x.type === sel && <span data-glide aria-hidden className="absolute inset-y-1 inset-x-2 rounded-md bg-[color-mix(in_oklab,var(--primary)_9%,transparent)]" />}
                    <span className="relative truncate">{x.type}</span>
                    {subs(x.type) > 0 && <span className="relative size-1.5 shrink-0 rounded-full bg-success" aria-hidden />}
                  </button>
                ))}
            </div>
          ))}
          {!groups.length && <p className="px-4 py-8 text-center text-[13px] text-muted-foreground">{fill(t.noEvents, { q })}</p>}
        </div>
        <section aria-live="polite" className="kitm-up [--kitm-d:100ms] hidden min-w-0 rounded-xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] md:block md:sticky md:top-6">
          {e && (
            <div key={e.type} className="kitm-in">
              <p className="text-[12px] font-medium text-subtle-foreground">{e.group}</p>
              <h2 className="mt-0.5 text-[18px] font-semibold tracking-[-0.015em] text-foreground">{e.type}</h2>
              <div className="mt-4">{body(e)}</div>
            </div>
          )}
        </section>
        <div className="grid gap-2 md:hidden">
          {shown.map((x, i) => (
            <details key={x.type} className="kitm-up group rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] open:shadow-[var(--highlight),var(--shadow-md)]" style={{ '--kitm-d': `${Math.min(i, 8) * 40}ms` } as React.CSSProperties}>
              <summary className="flex min-h-12 cursor-pointer list-none items-center gap-3 px-4 py-3 [&::-webkit-details-marker]:hidden">
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-[14px] font-medium text-foreground">{x.type}</span>
                  <span className="block text-[12px] text-subtle-foreground">{x.group}</span>
                </span>
                <Icon name="chevron-down" size={16} className="shrink-0 text-muted-foreground transition-transform duration-300 group-open:rotate-180" />
              </summary>
              <div className="border-t border-border p-4">{body(x)}</div>
            </details>
          ))}
          {!shown.length && <p className="py-8 text-center text-[13px] text-muted-foreground">{fill(t.noEvents, { q })}</p>}
        </div>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* test-event                                                          */
/* ------------------------------------------------------------------ */

type TestResult = { id: string; endpoint: string; event: string; status: number; ms: number };

function TestEvent({ t, f, title, subtitle, endpoints = [], events = [], onAction }: P) {
  const id = useId();
  const [ep, setEp] = useState(endpoints[0]?.id ?? '');
  const [ev, setEv] = useState(events[0]?.type ?? '');
  const example = (type: string) => JSON.stringify({ test: true, ...((events.find((x) => x.type === type)?.example as object) ?? {}) }, null, 2);
  const [body, setBody] = useState(() => example(events[0]?.type ?? ''));
  const [phase, setPhase] = useState(-1);
  const [result, setResult] = useState<TestResult | null>(null);
  const [log, setLog] = useState<TestResult[]>([]);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const endpoint = endpoints.find((x) => x.id === ep);
  let jsonError = '';
  try {
    JSON.parse(body);
  } catch (e) {
    jsonError = (e as Error).message.replace(/^JSON\.parse: /, '');
  }
  // A failing endpoint (below 90 % in the last day) answers 500 in the simulation, the others 200
  const send = async () => {
    if (jsonError || phase >= 0) return;
    setResult(null);
    const reduce = prefersReducedMotion();
    for (let i = 0; i < 3; i++) {
      setPhase(i);
      await wait(reduce ? 0 : 420);
    }
    await run(onAction, 'test', { endpoint: ep, event: ev, body });
    const failing = average(endpoint?.health) < 0.9;
    const r: TestResult = { id: `t${Date.now()}`, endpoint: endpoint?.url ?? '', event: ev, status: failing ? 500 : 200, ms: failing ? 2140 : 182 };
    setResult(r);
    setLog((l) => [r, ...l].slice(0, 5));
    setPhase(-1);
  };
  const steps = [t.stepSigned, t.stepSent, t.stepAnswer];
  const reached = phase >= 0 ? phase : result ? 3 : -1;
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start">
        <Card title={t.test} description={t.testText} className="kitm-up">
          <div className="grid gap-5">
            <div className="grid gap-4 sm:grid-cols-2">
              <NativeSelect label={t.endpoint} value={ep} onChange={(e) => setEp(e.target.value)} options={endpoints.map((x) => ({ value: x.id, label: host(x.url) }))} />
              <NativeSelect
                label={t.event}
                value={ev}
                onChange={(e) => {
                  setEv(e.target.value);
                  setBody(example(e.target.value));
                }}
                options={events.map((x) => ({ value: x.type, label: x.type }))}
              />
            </div>
            {endpoint && !endpoint.enabled && (
              <p className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
                <Icon name="info" size={14} className="shrink-0" />
                {t.paused}
              </p>
            )}
            <div className="grid gap-2">
              <div className="flex items-baseline justify-between gap-3">
                <label htmlFor={`${id}-b`} className="text-sm font-medium text-foreground">
                  {t.body}
                </label>
                <button type="button" onClick={() => setBody(example(ev))} className="-my-2 h-11 rounded-md px-1.5 text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] hover:underline sm:my-0 sm:h-auto">
                  {t.reset}
                </button>
              </div>
              <textarea
                id={`${id}-b`}
                rows={10}
                spellCheck={false}
                value={body}
                aria-invalid={Boolean(jsonError)}
                aria-describedby={`${id}-be`}
                onChange={(e) => setBody(e.target.value)}
                className={cn('w-full resize-y rounded-lg border bg-surface px-3.5 py-3 text-[13px] leading-[1.7] tabular-nums text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]', jsonError ? 'border-[color-mix(in_oklab,var(--destructive)_70%,var(--border))]' : 'border-input focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))]')}
              />
              <p id={`${id}-be`} aria-live="polite" className={cn('text-[13px]', jsonError ? 'font-medium text-destructive' : 'text-muted-foreground')}>
                {jsonError ? fill(t.bodyError, { msg: jsonError }) : t.retryPlan}
              </p>
            </div>
            <Button onClick={send} disabled={Boolean(jsonError) || phase >= 0} iconLeft={phase >= 0 ? 'loader' : 'send'} className="max-sm:w-full sm:justify-self-end">
              {phase >= 0 ? t.sending : result ? t.again : t.send}
            </Button>
          </div>
        </Card>

        <div className="grid gap-6 lg:sticky lg:top-6">
          {/* The way of the request: signed, sent, answered */}
          <Card className="kitm-up [--kitm-d:120ms]">
            <ol className="relative grid grid-cols-3 gap-2">
              <span aria-hidden className="absolute left-[16.6%] right-[16.6%] top-4 h-px bg-border" />
              <span aria-hidden className="absolute left-[16.6%] top-4 h-px origin-left bg-primary transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: '66.8%', transform: `scaleX(${reached <= 0 ? 0 : reached >= 2 ? 1 : 0.5})` }} />
              {steps.map((s, i) => {
                const done = reached > i;
                const now_ = reached === i && phase >= 0;
                const bad = i === 2 && result && result.status !== 200;
                return (
                  <li key={s} className="relative flex flex-col items-center gap-2 text-center">
                    <span className={cn('grid size-8 place-items-center rounded-full border transition-colors duration-300', bad ? 'border-destructive bg-destructive text-white' : done ? 'border-primary bg-primary text-primary-foreground' : now_ ? 'border-primary bg-card text-primary' : 'border-border bg-card text-subtle-foreground')}>
                      {now_ ? <Icon name="loader" size={14} className="motion-safe:animate-spin" /> : bad ? <Icon name="x" size={14} strokeWidth={2.2} /> : done ? <Icon name="check" size={14} strokeWidth={2.2} className="kitm-tick" /> : <span className="text-[12px] font-semibold tabular-nums">{i + 1}</span>}
                    </span>
                    <span className={cn('text-[12.5px] font-medium', done || now_ ? 'text-foreground' : 'text-muted-foreground')}>{s}</span>
                  </li>
                );
              })}
            </ol>
            <div className="mt-5 min-h-16 rounded-lg bg-surface p-3.5" role="status" aria-live="polite">
              {result ? (
                <div key={result.id} className="kitm-in flex items-start gap-3">
                  <StatusChip status={result.status} t={t} />
                  <p className="min-w-0 text-[13px] leading-snug text-foreground">
                    {result.status === 200 ? fill(t.testOk, { ms: f.num(result.ms) }) : result.status ? fill(t.testFail, { status: result.status }) : t.testNone}
                    {result.status !== 200 && <span className="mt-1 block text-[12.5px] text-muted-foreground">{t.retryPlan}</span>}
                  </p>
                </div>
              ) : (
                <p className="text-[13px] text-muted-foreground">{t.testText}</p>
              )}
            </div>
          </Card>
          <Card title={t.log} className="kitm-up [--kitm-d:200ms]" bodyClassName="p-0 sm:p-0">
            {log.length ? (
              <ul className="divide-y divide-border">
                {log.map((r) => (
                  <li key={r.id} className="kitm-fly flex items-center gap-3 px-4 py-3 sm:px-5">
                    <StatusChip status={r.status} t={t} />
                    <span className="min-w-0 flex-1">
                      <span className="block truncate text-[13px] font-medium text-foreground">{r.event}</span>
                      <span className="block truncate text-[12px] text-muted-foreground">{host(r.endpoint)}</span>
                    </span>
                    <span className="shrink-0 text-[12px] tabular-nums text-subtle-foreground">{f.num(r.ms)} ms</span>
                  </li>
                ))}
              </ul>
            ) : (
              <p className="px-4 py-6 text-center text-[13px] text-muted-foreground sm:px-5">{t.noTests}</p>
            )}
          </Card>
        </div>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* Webhooks                                                            */
/* ------------------------------------------------------------------ */

/**
 * Webhook screens for developer settings: endpoints with 24 hour health,
 * a form with a searchable event picker, a delivery log with request and
 * response, an event catalog with example bodies and a test sender that
 * shows the way of the request. Every action is simulated without onAction.
 *   <Webhooks variant="endpoints" title="Webhooks" endpoints={…} events={…} />
 */
export function Webhooks({ variant = 'endpoints', sidebar, labels, className, now = '2026-10-19T14:30', ...props }: WebhooksProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const f = useMemo(() => formatter(locale), [locale]);
  const p: P = { ...props, t, f, now };
  const body = (() => {
    switch (variant) {
      case 'add-endpoint':
        return <AddEndpoint {...p} />;
      case 'deliveries':
        return <Deliveries {...p} />;
      case 'event-catalog':
        return <Catalog {...p} />;
      case 'test-event':
        return <TestEvent {...p} />;
      default:
        return <Endpoints {...p} />;
    }
  })();
  if (!sidebar)
    return (
      <div className={cn('min-h-dvh bg-background', className)}>
        <KitmStyle />
        {body}
      </div>
    );
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      <KitmStyle />
      {body}
    </Sidebar>
  );
}

Häufige Fragen

Wie installiere ich Webhooks?

Mit aiw add @ai-website/webhooks. Der Befehl legt die Datei components/ui/webhooks.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, input, select, switch, dialog und sidebar. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Webhooks?

5 Varianten, Endpunkte, Endpunkt hinzufügen, Zustellprotokoll, Ereigniskatalog und Testereignis. Du wählst sie mit der Prop variant, zum Beispiel variant="add-endpoint".

Kann ich die Texte von Webhooks übersetzen?

Ja. Webhooks bringt 82 eigene Texte auf Deutsch und Englisch mit, etwa „Endpunkte“, „Für jedes gewählte Ereignis senden wir eine POST-Anfrage mit signiertem JSON.“ und „Endpunkt hinzufügen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Webhooks einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Webhooks mit aiw_components und installieren sie mit aiw_add @ai-website/webhooks. Nenn im Prompt die Variante, etwa variant="endpoints", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Webhooks ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Webhooks-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert