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 · 215 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { useRouter } from "next/navigation";4import { Plus } from "lucide-react";5import { COUNTRIES } from "@fetcha/core/client";6import { createDashboardSession, type DashboardSession } from "@/actions/sessions";7import { Button } from "@/components/ui/button";8import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";9import { Field, FieldError, Hint, Label } from "@/components/ui/label";10import { Input } from "@/components/ui/input";11import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";12import { Alert } from "@/components/ui/alert";13import { CodeBlock } from "@/components/ui/code-block";14import { CopyButton } from "@/components/ui/copy-button";15import { Badge } from "@/components/ui/badge";1617const ANY = "__any__";1819function curlFor(sessionId: string, apiBase: string, country?: string): string {20  const body: Record<string, unknown> = { url: "https://example.com", session: sessionId };21  if (country) body.country = country;22  return `curl -X POST ${apiBase}/v1/fetch \\23  -H "Authorization: Bearer fch_live_YOUR_KEY" \\24  -H "Content-Type: application/json" \\25  -d '${JSON.stringify(body)}'`;26}2728export function CreateSessionDialog({ apiBase, defaultCountry }: { apiBase: string; defaultCountry?: string | null }) {29  const router = useRouter();30  const [open, setOpen] = React.useState(false);31  const [pending, startTransition] = React.useTransition();32  const [error, setError] = React.useState<string | null>(null);33  const [created, setCreated] = React.useState<DashboardSession | null>(null);3435  const [country, setCountry] = React.useState<string>(defaultCountry && defaultCountry in COUNTRIES ? defaultCountry : ANY);36  const [network, setNetwork] = React.useState<string>("auto");37  const [region, setRegion] = React.useState("");38  const [city, setCity] = React.useState("");39  const [ttl, setTtl] = React.useState(600);40  const [label, setLabel] = React.useState("");4142  function reset() {43    setError(null);44    setCreated(null);45    setRegion("");46    setCity("");47    setTtl(600);48    setLabel("");49    setNetwork("auto");50  }5152  function submit(e: React.FormEvent) {53    e.preventDefault();54    setError(null);55    startTransition(async () => {56      const res = await createDashboardSession({57        country: country === ANY ? undefined : country,58        region: region || undefined,59        city: city || undefined,60        network: network as "auto" | "residential",61        ttl,62        label: label || undefined,63      });64      if (!res.ok) {65        setError(res.error);66        return;67      }68      setCreated(res.data ?? null);69      router.refresh();70    });71  }7273  const ttlLabel = ttl >= 60 ? `${Math.floor(ttl / 60)} min${ttl % 60 ? ` ${ttl % 60} s` : ""}` : `${ttl} s`;7475  return (76    <Dialog77      open={open}78      onOpenChange={(o) => {79        setOpen(o);80        if (!o) reset();81      }}82    >83      <DialogTrigger asChild>84        <Button variant="primary" size="sm">85          <Plus /> Create session86        </Button>87      </DialogTrigger>88      <DialogContent size="lg">89        {created ? (90          <>91            <DialogHeader>92              <DialogTitle>Session created</DialogTitle>93              <DialogDescription>94                Pass this id as <code className="font-mono">session</code> in each request. It keeps the same exit IP and cookies until it expires or you close it.95              </DialogDescription>96            </DialogHeader>97            <div className="flex items-center gap-2 rounded-md border border-border bg-bg-subtle px-3 py-2">98              <code className="min-w-0 flex-1 truncate font-mono text-[13px]">{created.id}</code>99              <Badge variant="success" dot>100                {created.status}101              </Badge>102              <CopyButton value={created.id} label="Copy" />103            </div>104            <dl className="grid grid-cols-3 gap-3 text-[12.5px]">105              <div>106                <dt className="text-fg-subtle">Network</dt>107                <dd className="font-medium">{created.network}</dd>108              </div>109              <div>110                <dt className="text-fg-subtle">Country</dt>111                <dd className="font-mono">{created.country ?? "any"}</dd>112              </div>113              <div>114                <dt className="text-fg-subtle">Expires</dt>115                <dd className="font-mono">{new Date(created.expires_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}</dd>116              </div>117            </dl>118            <CodeBlock code={curlFor(created.id, apiBase, created.country ?? undefined)} lang="bash" title="Use it in a request" />119            <DialogFooter>120              <Button variant="outline" onClick={() => reset()}>121                Create another122              </Button>123              <DialogClose asChild>124                <Button>Done</Button>125              </DialogClose>126            </DialogFooter>127          </>128        ) : (129          <form onSubmit={submit} className="contents">130            <DialogHeader>131              <DialogTitle>Create a sticky session</DialogTitle>132              <DialogDescription>A session pins one exit IP and carries cookies across requests, for logins, carts and paginated crawls. Sessions live 1 to 30 minutes.</DialogDescription>133            </DialogHeader>134            {error ? <Alert variant="danger">{error}</Alert> : null}135            <div className="grid gap-4 sm:grid-cols-2">136              <Field>137                <Label htmlFor="s-country">Country</Label>138                <Select value={country} onValueChange={setCountry}>139                  <SelectTrigger id="s-country">140                    <SelectValue />141                  </SelectTrigger>142                  <SelectContent>143                    <SelectItem value={ANY}>Any country</SelectItem>144                    {Object.entries(COUNTRIES).map(([code, name]) => (145                      <SelectItem key={code} value={code}>146                        <span className="font-mono text-[12px] text-fg-subtle">{code}</span> {name}147                      </SelectItem>148                    ))}149                  </SelectContent>150                </Select>151              </Field>152              <Field>153                <Label htmlFor="s-network">Network</Label>154                <Select value={network} onValueChange={setNetwork}>155                  <SelectTrigger id="s-network">156                    <SelectValue />157                  </SelectTrigger>158                  <SelectContent>159                    <SelectItem value="auto">Auto (recommended)</SelectItem>160                    <SelectItem value="residential">Residential</SelectItem>161                    <SelectItem value="datacenter" disabled>162                      Datacenter — coming soon163                    </SelectItem>164                    <SelectItem value="isp" disabled>165                      ISP — coming soon166                    </SelectItem>167                    <SelectItem value="mobile" disabled>168                      Mobile — coming soon169                    </SelectItem>170                  </SelectContent>171                </Select>172                <Hint>Auto resolves to residential today.</Hint>173              </Field>174              <Field>175                <Label htmlFor="s-region">Region (optional)</Label>176                <Input id="s-region" value={region} onChange={(e) => setRegion(e.target.value)} placeholder="quebec" maxLength={64} autoComplete="off" />177              </Field>178              <Field>179                <Label htmlFor="s-city">City (optional)</Label>180                <Input id="s-city" value={city} onChange={(e) => setCity(e.target.value)} placeholder="montreal" maxLength={128} autoComplete="off" />181              </Field>182              <Field className="sm:col-span-2">183                <div className="flex items-center justify-between">184                  <Label htmlFor="s-ttl">Time to live</Label>185                  <span className="font-mono tabular text-[12.5px] text-fg-muted">{ttlLabel}</span>186                </div>187                <div className="flex items-center gap-3">188                  <input id="s-ttl" type="range" min={60} max={1800} step={30} value={ttl} onChange={(e) => setTtl(Number(e.target.value))} className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-bg-muted accent-[var(--accent)]" aria-valuetext={ttlLabel} />189                  <Input type="number" min={60} max={1800} step={1} value={ttl} onChange={(e) => setTtl(Math.min(1800, Math.max(60, Number(e.target.value) || 60)))} className="w-24 font-mono" aria-label="TTL in seconds" />190                </div>191                <Hint>Seconds, 60–1800. The clock does not reset on use.</Hint>192              </Field>193              <Field className="sm:col-span-2">194                <Label htmlFor="s-label">Label (optional)</Label>195                <Input id="s-label" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="checkout-flow-ca" maxLength={128} autoComplete="off" />196                <FieldError />197              </Field>198            </div>199            <DialogFooter>200              <DialogClose asChild>201                <Button type="button" variant="ghost">202                  Cancel203                </Button>204              </DialogClose>205              <Button type="submit" variant="primary" loading={pending}>206                Create session207              </Button>208            </DialogFooter>209          </form>210        )}211      </DialogContent>212    </Dialog>213  );214}215