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
Variants
Details
Was Webhooks mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Developer Settings
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle WebhooksProps aus components/ui/webhooks.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalWebhooksVariantendpointsadd-endpointdeliveriesevent-catalogtest-event
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlePflichtstringsubtitleoptionalstringnowoptionalstringISO date and time that counts as now
endpointsoptionalWebhookEndpoint[]eventsoptionalWebhookEvent[]deliveriesoptionalWebhookDelivery[]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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| endpoints | Englisch Endpoints | Deutsch Endpunkte |
| endpointsText | Englisch 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. |
| add | Englisch Add endpoint | Deutsch Endpunkt hinzufügen |
| url | Englisch Endpoint URL | Deutsch URL des Endpunkts |
| urlHint | Englisch Must start with https:// and answer within 10 seconds. | Deutsch Muss mit https:// beginnen und innerhalb von 10 Sekunden antworten. |
| urlError | Englisch Enter a full https:// address. | Deutsch Gib eine vollständige https://-Adresse ein. |
| description | Englisch Description | Deutsch Beschreibung |
| descPlaceholder | Englisch New orders to the warehouse | Deutsch Neue Bestellungen ans Lager |
| events | Englisch Events | Deutsch Ereignisse |
| allEvents | Englisch All events | Deutsch Alle Ereignisse |
| eventsN | Englisch {n} events | Deutsch {n} Ereignisse |
| eventsPicked | Englisch {n} of {total} picked | Deutsch {n} von {total} gewählt |
| pickEvents | Englisch Pick at least one event. | Deutsch Wähle mindestens ein Ereignis. |
| searchEvents | Englisch Search events | Deutsch Ereignisse suchen |
| selectGroup | Englisch Pick all in {group} | Deutsch Alle in {group} wählen |
| clearAll | Englisch Clear | Deutsch Leeren |
| noEvents | Englisch No event matches “{q}”. | Deutsch Kein Ereignis passt zu „{q}“. |
| enabled | Englisch Active | Deutsch Aktiv |
| disabled | Englisch Paused | Deutsch Pausiert |
| failing | Englisch Failing | Deutsch Fehlerhaft |
| toggle | Englisch Send events to {url} | Deutsch Ereignisse an {url} senden |
| success24 | Englisch Last 24 hours | Deutsch Letzte 24 Stunden |
| successRate | Englisch {n} delivered | Deutsch {n} zugestellt |
| lastDelivery | Englisch Last delivery {when} | Deutsch Zuletzt zugestellt {when} |
| never | Englisch No deliveries yet | Deutsch Noch keine Zustellung |
| save | Englisch Add endpoint | Deutsch Endpunkt hinzufügen |
| saving | Englisch Adding… | Deutsch Wird hinzugefügt… |
| added | Englisch Endpoint added | Deutsch Endpunkt hinzugefügt |
| addedText | Englisch 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. |
| secret | Englisch Signing secret | Deutsch Signaturgeheimnis |
| copy | Englisch Copy | Deutsch Kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| done | Englisch Done | Deutsch Fertig |
| cancel | Englisch Cancel | Deutsch Abbrechen |
| remove | Englisch Delete endpoint | Deutsch Endpunkt löschen |
| removeTitle | Englisch Delete this endpoint? | Deutsch Endpunkt löschen? |
| removeBody | Englisch {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. |
| removed | Englisch Endpoint deleted. | Deutsch Endpunkt gelöscht. |
| deliveries | Englisch Deliveries | Deutsch Zustellungen |
| allStatus | Englisch All | Deutsch Alle |
| succeeded | Englisch Succeeded | Deutsch Erfolgreich |
| failed | Englisch Failed | Deutsch Fehlgeschlagen |
| noAnswer | Englisch No answer | Deutsch Keine Antwort |
| attempt | Englisch Attempt {n} | Deutsch Versuch {n} |
| request | Englisch Request body | Deutsch Anfrage |
| response | Englisch Response | Deutsch Antwort |
| duration | Englisch Duration | Deutsch Dauer |
| status | Englisch Status | Deutsch Status |
| time | Englisch Time | Deutsch Zeit |
| resend | Englisch Resend | Deutsch Erneut senden |
| resending | Englisch Sending… | Deutsch Wird gesendet… |
| resent | Englisch Sent again, attempt {n} answered {status}. | Deutsch Erneut gesendet, Versuch {n} antwortete {status}. |
| back | Englisch Back to deliveries | Deutsch Zurück zu den Zustellungen |
| pick | Englisch Pick a delivery to see its request and response. | Deutsch Wähle eine Zustellung, um Anfrage und Antwort zu sehen. |
| nextRetry | Englisch Next automatic retry {when} | Deutsch Nächster automatischer Versuch {when} |
| catalog | Englisch Event types | Deutsch Ereignistypen |
| catalogText | Englisch Every event we can send, with an example body. | Deutsch Jedes Ereignis, das wir senden können, mit Beispiel. |
| example | Englisch Example body | Deutsch Beispiel |
| copyExample | Englisch Copy example | Deutsch Beispiel kopieren |
| subscribedBy | Englisch Sent to {n} endpoints | Deutsch Geht an {n} Endpunkte |
| subscribedOne | Englisch Sent to 1 endpoint | Deutsch Geht an 1 Endpunkt |
| notSubscribed | Englisch No endpoint gets this event yet | Deutsch Noch kein Endpunkt erhält dieses Ereignis |
| test | Englisch Send a test event | Deutsch Testereignis senden |
| testText | Englisch 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. |
| endpoint | Englisch Endpoint | Deutsch Endpunkt |
| event | Englisch Event | Deutsch Ereignis |
| body | Englisch Body | Deutsch Inhalt |
| bodyError | Englisch This is not valid JSON ({msg}). | Deutsch Das ist kein gültiges JSON ({msg}). |
| reset | Englisch Reset to example | Deutsch Beispiel wiederherstellen |
| send | Englisch Send test event | Deutsch Testereignis senden |
| sending | Englisch Sending… | Deutsch Wird gesendet… |
| stepSigned | Englisch Signed | Deutsch Signiert |
| stepSent | Englisch Sent | Deutsch Gesendet |
| stepAnswer | Englisch Answer | Deutsch Antwort |
| testOk | Englisch Delivered in {ms} ms | Deutsch Zugestellt in {ms} ms |
| testFail | Englisch The endpoint answered {status} | Deutsch Der Endpunkt antwortete {status} |
| testNone | Englisch The endpoint did not answer within 10 seconds | Deutsch Der Endpunkt hat nicht innerhalb von 10 Sekunden geantwortet |
| retryPlan | Englisch Real events are retried 5 times over 24 hours. | Deutsch Echte Ereignisse versuchen wir 5-mal über 24 Stunden. |
| again | Englisch Send again | Deutsch Erneut senden |
| log | Englisch Test deliveries | Deutsch Testzustellungen |
| noTests | Englisch Your test deliveries show up here. | Deutsch Deine Testzustellungen erscheinen hier. |
| paused | Englisch 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
'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.