1"use client";2import * as React from "react";3import { ChevronRight, History, Play, RotateCcw, Trash2 } from "lucide-react";4import { Badge } from "@/components/ui/badge";5import { Button } from "@/components/ui/button";6import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";7import { Input, Textarea } from "@/components/ui/input";8import { Field, Hint, Label } from "@/components/ui/label";9import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";10import { Switch } from "@/components/ui/switch";11import { SimpleTooltip } from "@/components/ui/tooltip";12import type { UrlCheck } from "@/lib/playground-url";13import { cn } from "@/lib/utils";14import { CountrySelect } from "./country-select";15import { KvRows } from "./kv-rows";16import { DEVICES, FORMATS, HTTP_METHODS, NETWORKS, NETWORK_LABELS, inspectBody, methodHasBody, type BuilderState, type CountryOption, type HttpMethod, type NetworkClass, type OutputFormat, type PlanSummary } from "./types";1718export interface RequestBuilderProps {19 state: BuilderState;20 onChange: (patch: Partial<BuilderState>) => void;21 plan: PlanSummary;22 availableNetworks: string[];23 countries: CountryOption[];24 urlCheck: UrlCheck;25 urlTouched: boolean;26 onUrlBlur: () => void;27 onRun: () => void;28 onReset: () => void;29 pending: boolean;30 recent: string[];31 onPickRecent: (url: string) => void;32 onClearRecent: () => void;33 isMac: boolean;34}3536function Section({ title, count, open, onToggle, children, hint }: { title: string; count?: number; open: boolean; onToggle: () => void; children: React.ReactNode; hint?: string }) {37 const id = React.useId();38 return (39 <div className="border-t border-border">40 <button type="button" onClick={onToggle} aria-expanded={open} aria-controls={id} className="flex w-full items-center gap-2 py-2.5 text-left text-[13px] font-medium text-fg hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 rounded-sm">41 <ChevronRight className={cn("size-3.5 text-fg-subtle transition-transform", open && "rotate-90")} aria-hidden />42 <span>{title}</span>43 {count ? (44 <Badge variant="default" className="px-1.5 py-0 text-[10.5px] leading-4">45 {count}46 </Badge>47 ) : null}48 {hint ? <span className="ml-auto text-xs font-normal text-fg-subtle">{hint}</span> : null}49 </button>50 {open ? (51 <div id={id} className="pb-4 pl-5.5">52 {children}53 </div>54 ) : null}55 </div>56 );57}5859export function RequestBuilder(p: RequestBuilderProps) {60 const { state: s, onChange, plan, availableNetworks, countries, urlCheck, urlTouched, onUrlBlur, onRun, onReset, pending, recent, onPickRecent, onClearRecent, isMac } = p;61 const [open, setOpen] = React.useState({ headers: s.headers.length > 0, query: s.query.length > 0, body: s.body.length > 0, cookies: s.cookies.length > 0, options: true });62 const toggle = (k: keyof typeof open) => setOpen((o) => ({ ...o, [k]: !o[k] }));63 const bodyState = inspectBody(s.body);64 const showUrlMessage = urlTouched && urlCheck.level !== "ok";65 const maxTimeoutS = Math.round(plan.max_timeout_ms / 1000);66 const filledHeaders = s.headers.filter((r) => r.key.trim()).length;67 const filledQuery = s.query.filter((r) => r.key.trim()).length;68 const filledCookies = s.cookies.filter((r) => r.key.trim()).length;6970 return (71 <form72 className="grid gap-4"73 onSubmit={(e) => {74 e.preventDefault();75 onRun();76 }}77 >78 {/* URL + method */}79 <div className="grid gap-2">80 <div className="flex items-center justify-between">81 <Label htmlFor="pg-url">URL</Label>82 {recent.length ? (83 <DropdownMenu>84 <DropdownMenuTrigger asChild>85 <Button type="button" variant="ghost" size="xs" className="-mr-1 text-fg-muted">86 <History className="size-3.5" /> Recent87 </Button>88 </DropdownMenuTrigger>89 <DropdownMenuContent align="end" className="max-w-[min(90vw,28rem)]">90 <DropdownMenuLabel>Recent URLs</DropdownMenuLabel>91 {recent.map((u) => (92 <DropdownMenuItem key={u} onSelect={() => onPickRecent(u)} className="font-mono text-[12px]">93 <span className="truncate">{u}</span>94 </DropdownMenuItem>95 ))}96 <DropdownMenuSeparator />97 <DropdownMenuItem onSelect={onClearRecent} destructive>98 <Trash2 /> Clear recent99 </DropdownMenuItem>100 </DropdownMenuContent>101 </DropdownMenu>102 ) : null}103 </div>104 <div className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-2">105 <Select value={s.method} onValueChange={(v) => onChange({ method: v as HttpMethod })}>106 <SelectTrigger className="h-11 font-mono text-[13px]" aria-label="HTTP method">107 <SelectValue />108 </SelectTrigger>109 <SelectContent>110 {HTTP_METHODS.map((m) => (111 <SelectItem key={m} value={m} className="font-mono text-[13px]">112 {m}113 </SelectItem>114 ))}115 </SelectContent>116 </Select>117 <Input118 id="pg-url"119 type="url"120 inputMode="url"121 autoFocus122 autoComplete="off"123 autoCapitalize="off"124 autoCorrect="off"125 spellCheck={false}126 placeholder="https://example.com/page"127 value={s.url}128 onChange={(e) => onChange({ url: e.target.value })}129 onBlur={onUrlBlur}130 aria-invalid={showUrlMessage && urlCheck.level === "error" ? true : undefined}131 aria-describedby={showUrlMessage ? "pg-url-msg" : undefined}132 className="h-11 font-mono text-[14px]"133 />134 </div>135 {showUrlMessage ? (136 <p id="pg-url-msg" className={cn("text-xs leading-relaxed", urlCheck.level === "error" ? "text-danger" : "text-warning")} role={urlCheck.level === "error" ? "alert" : "status"}>137 {urlCheck.message}138 </p>139 ) : null}140 </div>141142 {/* Sections */}143 <div>144 <Section title="Headers" count={filledHeaders} open={open.headers} onToggle={() => toggle("headers")}>145 <KvRows rows={s.headers} onChange={(headers) => onChange({ headers })} label="Header" keyPlaceholder="Header-Name" valuePlaceholder="value" addLabel="Add header" />146 <Hint className="mt-2">Sent to the target as-is. A realistic User-Agent is applied automatically when you don't set one.</Hint>147 </Section>148 <Section title="Query params" count={filledQuery} open={open.query} onToggle={() => toggle("query")}>149 <KvRows rows={s.query} onChange={(query) => onChange({ query })} label="Query parameter" keyPlaceholder="param" valuePlaceholder="value" addLabel="Add parameter" />150 <Hint className="mt-2">Merged into the URL when the request runs.</Hint>151 </Section>152 {methodHasBody(s.method) ? (153 <Section title="Body" open={open.body} onToggle={() => toggle("body")} hint={bodyState === "json" ? "Valid JSON" : bodyState === "text" ? "Plain text" : undefined}>154 <Textarea155 value={s.body}156 onChange={(e) => onChange({ body: e.target.value })}157 placeholder='{"query": "…"}'158 aria-label="Request body"159 spellCheck={false}160 className="min-h-[120px] font-mono text-[12.5px]"161 />162 <div className="mt-1.5 flex items-center gap-2">163 {bodyState === "json" ? (164 <Badge variant="success" dot>165 Valid JSON166 </Badge>167 ) : bodyState === "text" ? (168 <Badge variant="outline">Not JSON — sent as text</Badge>169 ) : null}170 <Hint>Set a Content-Type header to match your body.</Hint>171 </div>172 </Section>173 ) : null}174 <Section title="Cookies" count={filledCookies} open={open.cookies} onToggle={() => toggle("cookies")}>175 <KvRows rows={s.cookies} onChange={(cookies) => onChange({ cookies })} label="Cookie" keyPlaceholder="name" valuePlaceholder="value" addLabel="Add cookie" />176 </Section>177178 <Section title="Options" open={open.options} onToggle={() => toggle("options")}>179 <div className="grid gap-4">180 <div className="grid gap-4 sm:grid-cols-2">181 <Field>182 <Label htmlFor="pg-country">Country</Label>183 <CountrySelect id="pg-country" value={s.country} onChange={(country) => onChange({ country })} countries={countries} />184 </Field>185 <Field>186 <Label htmlFor="pg-network">Network</Label>187 <Select value={s.network} onValueChange={(v) => onChange({ network: v as NetworkClass })}>188 <SelectTrigger id="pg-network" aria-label="Network class">189 <SelectValue />190 </SelectTrigger>191 <SelectContent>192 {NETWORKS.map((n) => {193 const live = n === "auto" || availableNetworks.includes(n);194 const onPlan = n === "auto" || plan.networks.includes(n);195 const disabled = !live || !onPlan;196 const reason = !live ? "Not yet available" : !onPlan ? `Not on the ${plan.label} plan` : null;197 return (198 <SelectItem key={n} value={n} disabled={disabled} title={reason ?? undefined}>199 {NETWORK_LABELS[n]}200 {reason ? <span className="ml-1.5 text-xs text-fg-subtle">— {reason}</span> : null}201 </SelectItem>202 );203 })}204 </SelectContent>205 </Select>206 </Field>207 </div>208 <div className="grid gap-4 sm:grid-cols-2">209 <Field>210 <Label htmlFor="pg-region">Region</Label>211 <Input id="pg-region" value={s.region} onChange={(e) => onChange({ region: e.target.value })} placeholder="e.g. quebec" maxLength={64} autoCapitalize="off" />212 </Field>213 <Field>214 <Label htmlFor="pg-city">City</Label>215 <Input id="pg-city" value={s.city} onChange={(e) => onChange({ city: e.target.value })} placeholder="e.g. montreal" maxLength={128} autoCapitalize="off" />216 </Field>217 </div>218 <div className="grid gap-4 sm:grid-cols-2">219 <Field>220 <Label htmlFor="pg-session">Session</Label>221 <Input id="pg-session" value={s.session} onChange={(e) => onChange({ session: e.target.value })} placeholder="Auto (fresh IP)" className="font-mono text-[12.5px]" maxLength={64} autoCapitalize="off" spellCheck={false} />222 <Hint>Paste a <span className="font-mono">sess_…</span> id to reuse a sticky IP.</Hint>223 </Field>224 <Field>225 <Label htmlFor="pg-timeout">Timeout (ms)</Label>226 <Input227 id="pg-timeout"228 type="number"229 inputMode="numeric"230 min={1000}231 max={plan.max_timeout_ms}232 step={1000}233 value={s.timeout}234 onChange={(e) => onChange({ timeout: Number(e.target.value) })}235 onBlur={() => onChange({ timeout: Math.min(Math.max(1000, Math.round(s.timeout || 30_000)), plan.max_timeout_ms) })}236 className="font-mono tabular text-[12.5px]"237 />238 <Hint>239 Up to {maxTimeoutS} s on the {plan.label} plan.240 </Hint>241 </Field>242 </div>243 <div className="grid gap-4 sm:grid-cols-3">244 <Field>245 <Label htmlFor="pg-format">Output format</Label>246 <Select value={s.format} onValueChange={(v) => onChange({ format: v as OutputFormat })}>247 <SelectTrigger id="pg-format" aria-label="Output format">248 <SelectValue />249 </SelectTrigger>250 <SelectContent>251 {FORMATS.map((f) => (252 <SelectItem key={f} value={f} className="font-mono text-[13px]">253 {f}254 </SelectItem>255 ))}256 </SelectContent>257 </Select>258 </Field>259 <Field>260 <Label htmlFor="pg-device">Device</Label>261 <Select value={s.device || "default"} onValueChange={(v) => onChange({ device: v === "default" ? "" : (v as BuilderState["device"]) })}>262 <SelectTrigger id="pg-device" aria-label="Device">263 <SelectValue />264 </SelectTrigger>265 <SelectContent>266 <SelectItem value="default">Default</SelectItem>267 {DEVICES.map((d) => (268 <SelectItem key={d} value={d}>269 {d.charAt(0).toUpperCase() + d.slice(1)}270 </SelectItem>271 ))}272 </SelectContent>273 </Select>274 </Field>275 <Field>276 <Label htmlFor="pg-locale">Locale</Label>277 <Input id="pg-locale" value={s.locale} onChange={(e) => onChange({ locale: e.target.value })} placeholder="en-CA" maxLength={16} autoCapitalize="off" spellCheck={false} className="font-mono text-[12.5px]" />278 </Field>279 </div>280 <div className="grid gap-3 rounded-md border border-border bg-bg-subtle/50 p-3">281 <ToggleRow id="pg-follow" label="Follow redirects" hint="Each hop is validated against the URL policy." checked={s.followRedirects} onChange={(v) => onChange({ followRedirects: v })} />282 <ToggleRow id="pg-debug" label="Debug" hint="Include per-attempt routing details in the response." checked={s.debug} onChange={(v) => onChange({ debug: v })} />283 <SimpleTooltip content="Managed browser rendering is not available yet.">284 <div>285 <ToggleRow id="pg-browser" label="Browser rendering" hint="Execute JavaScript in a managed browser." checked={false} onChange={() => {}} disabled badge="Coming soon" />286 </div>287 </SimpleTooltip>288 </div>289 </div>290 </Section>291 </div>292293 {/* Actions (desktop) */}294 <div className="hidden items-center gap-2 border-t border-border pt-4 lg:flex">295 <Button type="submit" variant="primary" loading={pending} disabled={urlCheck.level === "error"} className="min-w-[10rem]">296 {!pending ? <Play className="size-3.5" /> : null}297 {pending ? "Running…" : "Run request"}298 {!pending ? <kbd className="ml-1 rounded-[4px] border border-white/25 bg-white/10 px-1.5 font-mono text-[10.5px] font-normal">{isMac ? "⌘" : "Ctrl"}↵</kbd> : null}299 </Button>300 <Button type="button" variant="ghost" onClick={onReset} disabled={pending}>301 <RotateCcw className="size-3.5" /> Reset302 </Button>303 <p className="ml-auto text-right text-[11.5px] leading-tight text-fg-subtle">304 {plan.label} plan · {plan.concurrency} concurrent · {maxTimeoutS} s max305 </p>306 </div>307 </form>308 );309}310311function ToggleRow({ id, label, hint, checked, onChange, disabled, badge }: { id: string; label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; badge?: string }) {312 return (313 <div className="flex items-center justify-between gap-3">314 <div className="min-w-0">315 <Label htmlFor={id} className={cn("flex items-center gap-2", disabled && "opacity-70")}>316 {label}317 {badge ? <Badge variant="outline">{badge}</Badge> : null}318 </Label>319 {hint ? <Hint className="mt-1">{hint}</Hint> : null}320 </div>321 <Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} aria-label={label} />322 </div>323 );324}325