TypeScript 97.5%
SQL 1.4%
Python 0.8%
1"use client";2import * as React from "react";3import { COUNTRIES } from "@fetcha/core/client";4import { createProject, updateProject } from "@/actions/projects";5import { Button } from "@/components/ui/button";6import { Input, NativeSelect, Textarea } from "@/components/ui/input";7import { Field, FieldError, Hint, Label } from "@/components/ui/label";8import { Alert } from "@/components/ui/alert";9import { Badge } from "@/components/ui/badge";10import { cn } from "@/lib/utils";1112export interface ProjectFormValues {13 name: string;14 description: string | null;15 environment: string;16 defaultCountry: string | null;17 defaultNetwork: string;18 logLevel: string;19 softLimitUsd: number | null;20 hardLimitUsd: number | null;21 monthlyRequestLimit: number | null;22}2324const LOG_LEVELS = [25 { v: "none", title: "None", desc: "Only billing counters. No URL, headers or timing are kept — nothing to inspect in Requests." },26 { v: "metadata", title: "Metadata", desc: "URL, status, network, country, timing and size. Recommended for production.", recommended: true },27 { v: "headers", title: "Headers", desc: "Metadata plus request and response headers. Authorization and Cookie are always redacted." },28 { v: "full", title: "Full", desc: "Headers plus a response body preview, kept for your plan's retention window. Use for debugging only." },29] as const;3031const NETWORKS = [32 { v: "auto", label: "Auto (recommended)", live: true },33 { v: "residential", label: "Residential", live: true },34 { v: "datacenter", label: "Datacenter — coming soon", live: false },35 { v: "isp", label: "ISP — coming soon", live: false },36 { v: "mobile", label: "Mobile — coming soon", live: false },37];3839const COUNTRY_OPTIONS = Object.entries(COUNTRIES).sort((a, b) => a[1].localeCompare(b[1]));4041export function ProjectForm({42 mode,43 projectId,44 initial,45 onSuccess,46 onCancel,47 submitLabel,48 compact,49}: {50 mode: "create" | "edit";51 projectId?: string;52 initial?: Partial<ProjectFormValues>;53 onSuccess?: (id: string) => void;54 onCancel?: () => void;55 submitLabel?: string;56 compact?: boolean;57}) {58 const [pending, startTransition] = React.useTransition();59 const [error, setError] = React.useState<string | null>(null);60 const [fieldError, setFieldError] = React.useState<string | null>(null);61 const [saved, setSaved] = React.useState(false);62 const [logLevel, setLogLevel] = React.useState(initial?.logLevel ?? "metadata");63 const [environment, setEnvironment] = React.useState(initial?.environment ?? "production");6465 function submit(e: React.FormEvent<HTMLFormElement>) {66 e.preventDefault();67 setError(null);68 setFieldError(null);69 setSaved(false);70 const fd = new FormData(e.currentTarget);71 startTransition(async () => {72 const res = mode === "create" ? await createProject(fd) : await updateProject(projectId!, fd);73 if (!res.ok) {74 setError(res.error);75 setFieldError(res.field ?? null);76 return;77 }78 setSaved(true);79 const id = mode === "create" && "data" in res && res.data ? res.data.id : projectId!;80 onSuccess?.(id);81 });82 }8384 const err = (f: string) => (fieldError === f ? error : null);8586 return (87 <form onSubmit={submit} className="grid gap-5" noValidate>88 {error && !fieldError ? <Alert variant="danger">{error}</Alert> : null}89 {saved && mode === "edit" ? <Alert variant="success">Project settings saved.</Alert> : null}9091 <section className="grid gap-4">92 <div className={cn("grid gap-4", !compact && "sm:grid-cols-[1fr_200px]")}>93 <Field>94 <Label htmlFor="p-name">Name</Label>95 <Input id="p-name" name="name" defaultValue={initial?.name ?? ""} placeholder="e.g. Price monitoring" maxLength={64} required autoFocus={mode === "create"} aria-invalid={Boolean(err("name"))} />96 <FieldError>{err("name")}</FieldError>97 </Field>98 <Field>99 <Label>Environment</Label>100 <div className="flex h-9 items-center gap-0.5 rounded-md bg-bg-muted p-1 text-[13px] font-medium text-fg-muted">101 {(["production", "development"] as const).map((env) => (102 <label key={env} className={cn("flex h-7 flex-1 cursor-pointer items-center justify-center rounded-[5px] capitalize transition-colors", environment === env ? "bg-bg text-fg shadow-xs" : "hover:text-fg")}>103 <input type="radio" name="environment" value={env} checked={environment === env} onChange={() => setEnvironment(env)} className="sr-only" />104 {env}105 </label>106 ))}107 </div>108 </Field>109 </div>110 <Field>111 <Label htmlFor="p-description">Description</Label>112 <Textarea id="p-description" name="description" defaultValue={initial?.description ?? ""} placeholder="What this project fetches and for whom (optional)" maxLength={280} className="min-h-[64px]" />113 </Field>114 </section>115116 <section className="grid gap-4">117 <h4 className="text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">Request defaults</h4>118 <div className={cn("grid gap-4", !compact && "sm:grid-cols-2")}>119 <Field>120 <Label htmlFor="p-country">Default country</Label>121 <NativeSelect id="p-country" name="defaultCountry" defaultValue={initial?.defaultCountry ?? ""}>122 <option value="">No default — let routing decide</option>123 {COUNTRY_OPTIONS.map(([code, name]) => (124 <option key={code} value={code}>125 {name} ({code})126 </option>127 ))}128 </NativeSelect>129 <Hint>Used when a request omits `country`.</Hint>130 </Field>131 <Field>132 <Label htmlFor="p-network">Default network</Label>133 <NativeSelect id="p-network" name="defaultNetwork" defaultValue={initial?.defaultNetwork ?? "auto"}>134 {NETWORKS.map((n) => (135 <option key={n.v} value={n.v} disabled={!n.live}>136 {n.label}137 </option>138 ))}139 </NativeSelect>140 <Hint>Only residential is live today; auto resolves to residential.</Hint>141 </Field>142 </div>143 <fieldset className="grid gap-2">144 <legend className="mb-1.5 text-[13px] font-medium">Request logging</legend>145 <div className={cn("grid gap-2", !compact && "sm:grid-cols-2")}>146 {LOG_LEVELS.map((l) => (147 <label key={l.v} className={cn("flex cursor-pointer items-start gap-2.5 rounded-md border px-3 py-2.5 transition-colors", logLevel === l.v ? "border-accent bg-accent-soft/40" : "border-border hover:border-border-strong")}>148 <input type="radio" name="logLevel" value={l.v} checked={logLevel === l.v} onChange={() => setLogLevel(l.v)} className="mt-0.5 accent-[var(--accent)]" />149 <span className="grid gap-0.5">150 <span className="flex items-center gap-2 text-[13px] font-medium">151 {l.title}152 {"recommended" in l && l.recommended ? <Badge variant="accent">Recommended</Badge> : null}153 </span>154 <span className="text-[12px] leading-relaxed text-fg-muted">{l.desc}</span>155 </span>156 </label>157 ))}158 </div>159 </fieldset>160 </section>161162 <section className="grid gap-4">163 <h4 className="text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">Limits</h4>164 <div className={cn("grid gap-4", !compact && "sm:grid-cols-3")}>165 <Field>166 <Label htmlFor="p-soft">Soft limit (USD / month)</Label>167 <Input id="p-soft" name="softLimitUsd" type="number" min={0} step="0.01" inputMode="decimal" defaultValue={initial?.softLimitUsd ?? ""} placeholder="No limit" className="font-mono tabular" aria-invalid={Boolean(err("softLimitUsd"))} />168 <Hint>We email you when spend crosses it. Requests continue.</Hint>169 <FieldError>{err("softLimitUsd")}</FieldError>170 </Field>171 <Field>172 <Label htmlFor="p-hard">Hard limit (USD / month)</Label>173 <Input id="p-hard" name="hardLimitUsd" type="number" min={0} step="0.01" inputMode="decimal" defaultValue={initial?.hardLimitUsd ?? ""} placeholder="No limit" className="font-mono tabular" aria-invalid={Boolean(err("hardLimitUsd"))} />174 <Hint>Requests are rejected with USAGE_LIMIT_REACHED until next month.</Hint>175 <FieldError>{err("hardLimitUsd")}</FieldError>176 </Field>177 <Field>178 <Label htmlFor="p-reqs">Monthly request limit</Label>179 <Input id="p-reqs" name="monthlyRequestLimit" type="number" min={0} step="1" inputMode="numeric" defaultValue={initial?.monthlyRequestLimit ?? ""} placeholder="Plan limit" className="font-mono tabular" aria-invalid={Boolean(err("monthlyRequestLimit"))} />180 <Hint>Caps this project below your plan allowance.</Hint>181 <FieldError>{err("monthlyRequestLimit")}</FieldError>182 </Field>183 </div>184 </section>185186 <div className="flex items-center justify-end gap-2 border-t border-border pt-4">187 {onCancel ? (188 <Button type="button" variant="ghost" onClick={onCancel} disabled={pending}>189 Cancel190 </Button>191 ) : null}192 <Button type="submit" variant="primary" loading={pending}>193 {submitLabel ?? (mode === "create" ? "Create project" : "Save changes")}194 </Button>195 </div>196 </form>197 );198}199