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%
6.5 KB · 150 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { useRouter } from "next/navigation";4import { MailPlus, Send, Trash2, UserPlus } from "lucide-react";5import { allowEmails, resendInvite, revokeAllow } from "@/actions/access";6import type { AdminActionResult } from "@/actions/admin";7import { Button } from "@/components/ui/button";8import { Input, Textarea } from "@/components/ui/input";9import { Field, Hint, Label } from "@/components/ui/label";10import { Switch } from "@/components/ui/switch";11import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";12import { ActionButton, ResultMessage } from "./action-button";1314export function AddEmailsDialog({ variant = "primary" }: { variant?: "primary" | "outline" }) {15  const router = useRouter();16  const [open, setOpen] = React.useState(false);17  const [pending, start] = React.useTransition();18  const [emails, setEmails] = React.useState("");19  const [note, setNote] = React.useState("");20  const [sendInvite, setSendInvite] = React.useState(true);21  const [result, setResult] = React.useState<AdminActionResult<{ added: number; existing: number; invited: number }> | null>(null);2223  const count = emails.split(/[\s,;]+/).filter((s) => s.includes("@")).length;2425  const reset = () => {26    setEmails("");27    setNote("");28    setSendInvite(true);29    setResult(null);30  };3132  return (33    <>34      <Button variant={variant} size="sm" onClick={() => setOpen(true)}>35        <UserPlus className="size-3.5" /> Add emails36      </Button>37      <Dialog38        open={open}39        onOpenChange={(v) => {40          setOpen(v);41          if (!v) reset();42        }}43      >44        <DialogContent size="md">45          <DialogHeader>46            <DialogTitle>Add emails to the access list</DialogTitle>47            <DialogDescription>Only listed addresses can create an account. Paste one or many emails separated by commas, spaces or new lines.</DialogDescription>48          </DialogHeader>49          <form50            className="grid gap-3"51            onSubmit={(e) => {52              e.preventDefault();53              start(async () => {54                const r = await allowEmails({ emails, note, sendInvite });55                setResult(r);56                if (r.ok) {57                  router.refresh();58                  if (!r.warning) {59                    setOpen(false);60                    reset();61                  } else {62                    setEmails("");63                  }64                }65              });66            }}67          >68            <Field>69              <Label htmlFor="access-emails">Emails</Label>70              <Textarea id="access-emails" value={emails} onChange={(e) => setEmails(e.target.value)} placeholder={"ada@example.com\ngrace@example.com, linus@example.com"} className="min-h-[120px] font-mono text-[12.5px]" required autoFocus />71              <Hint>{count === 0 ? "Addresses are lower-cased and de-duplicated." : `${count} address${count === 1 ? "" : "es"} detected. Existing entries are kept (the note is updated when provided).`}</Hint>72            </Field>73            <Field>74              <Label htmlFor="access-note">75                Note <span className="font-normal text-fg-subtle">(optional)</span>76              </Label>77              <Input id="access-note" value={note} onChange={(e) => setNote(e.target.value)} placeholder="e.g. Acme data team, pilot Q4" maxLength={280} />78              <Hint>Internal only — never shown to the invitee.</Hint>79            </Field>80            <div className="flex items-start gap-3 rounded-md border border-border bg-bg-subtle/60 px-3 py-2.5">81              <Switch id="access-send" checked={sendInvite} onCheckedChange={setSendInvite} aria-label="Send invitation email" />82              <div className="grid gap-0.5">83                <Label htmlFor="access-send">Send invitation email</Label>84                <Hint>Each address receives “You&apos;re invited to Fetcha” with a link to the signup page, pre-filled with their email. You can resend it later.</Hint>85              </div>86            </div>87            {result && result.ok ? (88              <p role="status" className="text-[12.5px] text-success">89                {result.data ? `${result.data.added} added, ${result.data.existing} already listed${sendInvite ? `, ${result.data.invited} invitation${result.data.invited === 1 ? "" : "s"} sent` : ""}.` : "Saved."}90              </p>91            ) : null}92            <ResultMessage result={result && (!result.ok || result.warning) ? result : null} />93            <DialogFooter>94              <Button type="button" variant="outline" onClick={() => setOpen(false)}>95                {result?.ok ? "Close" : "Cancel"}96              </Button>97              <Button type="submit" variant="primary" loading={pending} disabled={count === 0}>98                {sendInvite ? (99                  <>100                    <Send className="size-3.5" /> Add and invite101                  </>102                ) : (103                  <>104                    <UserPlus className="size-3.5" /> Add to list105                  </>106                )}107              </Button>108            </DialogFooter>109          </form>110        </DialogContent>111      </Dialog>112    </>113  );114}115116export function ResendInviteButton({ email, invitedBefore }: { email: string; invitedBefore: boolean }) {117  return (118    <ActionButton119      variant="outline"120      size="xs"121      aria-label={`${invitedBefore ? "Resend" : "Send"} invitation to ${email}`}122      action={() => resendInvite(email)}123      confirm={{124        title: `${invitedBefore ? "Resend" : "Send"} the invitation to ${email}?`,125        description: "An email with a link to the signup page (pre-filled with this address) is sent from hello@fetcha.co.",126        confirmLabel: invitedBefore ? "Resend invite" : "Send invite",127        variant: "primary",128      }}129    >130      <MailPlus className="size-3.5" /> {invitedBefore ? "Resend invite" : "Send invite"}131    </ActionButton>132  );133}134135export function RemoveAllowButton({ email, hasAccount }: { email: string; hasAccount: boolean }) {136  return (137    <ActionButton138      variant="ghost"139      size="icon-sm"140      aria-label={`Remove ${email} from the access list`}141      disabled={hasAccount}142      title={hasAccount ? "This address already has an account; ban the user instead." : undefined}143      action={() => revokeAllow(email)}144      confirm={{ title: `Remove ${email}?`, description: "The address will no longer be able to create an account. Existing accounts are not affected.", confirmLabel: "Remove" }}145    >146      <Trash2 className="size-3.5 text-fg-subtle" />147    </ActionButton>148  );149}150