TypeScript 97.5%
SQL 1.4%
Python 0.8%
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'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