SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
9.4 KB · 199 lines tsx
Raw Blame History
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