1"use client";
2
3
16import { useState } from "react";
17import { z } from "zod";
18import { Check, ChevronDown, ChevronRight, Copy } from "lucide-react";
19
20
21import {
22 WEBHOOK_EVENT_CATALOG,
23 type WebhookEventCatalogEntry,
24} from "@factory/shared/events/catalog";
25import { cn } from "@/lib/utils";
26
27
28function zodToLines(schema: z.ZodTypeAny, depth = 0): { name: string; type: string; optional: boolean }[] {
29 const out: { name: string; type: string; optional: boolean }[] = [];
30 const push = (name: string, type: string, optional: boolean) =>
31 out.push({ name: depth === 0 ? name : `${name}`, type, optional });
32
33 let s = schema as z.ZodTypeAny;
34 const isOptional = (t: z.ZodTypeAny): boolean =>
35 t instanceof z.ZodOptional || (typeof t._def !== "undefined" && (t as unknown as { _def?: { typeName?: string } })._def?.typeName === "ZodOptional");
36 if (isOptional(s)) {
37 const inner = (s as z.ZodOptional<z.ZodTypeAny>)._def.innerType;
38 return zodToLines(inner, depth).map((l) => ({ ...l, optional: true }));
39 }
40
41 if (s instanceof z.ZodObject) {
42 const entries = Object.entries(s.shape as Record<string, z.ZodTypeAny>);
43 for (const [key, value] of entries) {
44 if (value instanceof z.ZodObject) {
45
46
47 const nested = Object.entries(value.shape as Record<string, z.ZodTypeAny>)
48 .map(([k, v]) => `${k}: ${typeOf(v)}`)
49 .join(", ");
50 push(key, `object { ${nested} }`, isOptional(value));
51 } else {
52 push(key, typeOf(value), isOptional(value));
53 }
54 }
55 } else {
56 push("", typeOf(s), false);
57 }
58 return out;
59}
60
61function typeOf(t: z.ZodTypeAny): string {
62 if (t instanceof z.ZodString) return "string";
63 if (t instanceof z.ZodNumber) return "number";
64 if (t instanceof z.ZodBoolean) return "boolean";
65 if (t instanceof z.ZodArray) return `${typeOf(t.element)}[]`;
66 if (t instanceof z.ZodNullable) return `${typeOf(t._def.innerType)} | null`;
67 if (t instanceof z.ZodOptional) return `${typeOf(t._def.innerType)}?`;
68 if (t instanceof z.ZodEnum) return t.options.join(" | ");
69 if (t instanceof z.ZodUnion)
70 return (t.options as z.ZodTypeAny[]).map((o) => typeOf(o)).join(" | ");
71 if (t instanceof z.ZodLiteral) return JSON.stringify(t._def.value);
72 if (t instanceof z.ZodRecord) return "object";
73 if (t instanceof z.ZodAny || t instanceof z.ZodUnknown) return "any";
74 return "object";
75}
76
77async function copyText(text: string): Promise<boolean> {
78 try {
79 await navigator.clipboard.writeText(text);
80 return true;
81 } catch {
82 try {
83 const ta = document.createElement("textarea");
84 ta.value = text;
85 ta.setAttribute("readonly", "");
86 ta.style.position = "fixed";
87 ta.style.opacity = "0";
88 document.body.appendChild(ta);
89 ta.select();
90 const ok = document.execCommand("copy");
91 document.body.removeChild(ta);
92 return ok;
93 } catch {
94 return false;
95 }
96 }
97}
98
99function EventRow({ entry }: { entry: WebhookEventCatalogEntry }) {
100 const [open, setOpen] = useState(false);
101 const [copied, setCopied] = useState(false);
102 const lines = zodToLines(entry.payloadSchema);
103
104 const doCopy = async () => {
105 const ok = await copyText(JSON.stringify(entry.example, null, 2));
106 setCopied(ok);
107 setTimeout(() => setCopied(false), 2000);
108 };
109
110 return (
111 <li className="rounded-md border border-border">
112 <button
113 type="button"
114 aria-expanded={open}
115 onClick={() => setOpen((v) => !v)}
116 className="flex w-full items-center gap-2 px-3 py-2 text-left"
117 >
118 {open ? (
119 <ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
120 ) : (
121 <ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
122 )}
123 <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{entry.name}</code>
124 <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
125 {entry.description}
126 </span>
127 </button>
128 {open ? (
129 <div className="space-y-2 border-t border-border px-3 py-2">
130 <div>
131 <p className="mb-1 text-[11px] font-medium text-muted-foreground">
132 Payload schema (<code className="font-mono">data</code>)
133 </p>
134 <table className="w-full text-left text-xs">
135 <caption className="sr-only">Payload fields for {entry.name}</caption>
136 <tbody>
137 {lines.map((l, i) => (
138 <tr key={i} className="border-b border-border/50 last:border-0">
139 <th scope="row" className="py-0.5 pr-3 font-mono font-normal text-foreground">
140 {l.name}
141 </th>
142 <td className="py-0.5 font-mono text-muted-foreground">{l.type}</td>
143 <td className="py-0.5 text-right text-muted-foreground">
144 {l.optional ? "optional" : ""}
145 </td>
146 </tr>
147 ))}
148 </tbody>
149 </table>
150 </div>
151 <div>
152 <div className="mb-1 flex items-center justify-between">
153 <p className="text-[11px] font-medium text-muted-foreground">Example</p>
154 <button
155 type="button"
156 onClick={doCopy}
157 aria-label={`Copy example payload for ${entry.name}`}
158 className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
159 >
160 {copied ? (
161 <Check className="size-3 text-emerald-600" aria-hidden />
162 ) : (
163 <Copy className="size-3" aria-hidden />
164 )}
165 {copied ? "Copied" : "Copy"}
166 </button>
167 </div>
168 <span role="status" className="sr-only">
169 {copied ? `${entry.name} example copied` : ""}
170 </span>
171 <pre className="overflow-x-auto rounded-md bg-muted/50 p-2 font-mono text-[11px] leading-relaxed">
172 {JSON.stringify(entry.example, null, 2)}
173 </pre>
174 </div>
175 <p className="text-[11px] text-muted-foreground">
176 Fired by: <span className="font-mono">{entry.firedBy}</span>
177 </p>
178 </div>
179 ) : null}
180 </li>
181 );
182}
183
184export function EventExplorer() {
185
186 const groups = new Map<string, WebhookEventCatalogEntry[]>();
187 for (const e of WEBHOOK_EVENT_CATALOG) {
188 const g = e.name.split(".")[0] ?? "other";
189 const arr = groups.get(g) ?? [];
190 arr.push(e);
191 groups.set(g, arr);
192 }
193
194 return (
195 <div className="space-y-4">
196 {Array.from(groups.entries()).map(([group, events]) => (
197 <div key={group}>
198 <p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
199 {group}
200 </p>
201 <ul className="space-y-1.5">
202 {events.map((e) => (
203 <EventRow key={e.name} entry={e} />
204 ))}
205 </ul>
206 </div>
207 ))}
208 <p className={cn("text-xs text-muted-foreground")}>
209 Subscriptions accept exact event names or the <code className="font-mono">*</code> wildcard.
210 Payloads are JSON, signed with{" "}
211 <code className="font-mono">X-Factory-Signature: t=…,v1=HMAC-SHA256(secret, "<t>.<body>")</code>.
212 </p>
213 </div>
214 );
215}
216