Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/dashboard-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Globe,
LayoutDashboard,
type LucideIcon,
Mail,
Settings,
ShieldCheck,
Tags,
Expand Down Expand Up @@ -61,6 +62,7 @@ export const dashboardNavigation = [
items: [
{ title: "Groups", url: "/dashboard/azure/groups", icon: Database },
{ title: "Members", url: "/dashboard/azure/members", icon: UsersRound },
{ title: "Email templates", url: "/dashboard/azure/email-templates", icon: Mail },
],
},
{
Expand Down
179 changes: 179 additions & 0 deletions src/features/email-templates/email-template-dialogs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { useServerFn } from "@tanstack/react-start"
import { LoaderCircle, OctagonX } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"

import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import type { EmailTemplate } from "@/lib/api/types"
import { errorMessage } from "@/lib/errors"

import { addEmailTemplate, deleteEmailTemplate, editEmailTemplate } from "./email-templates.functions"

export function EmailTemplateDialog({
template,
onClose,
onSaved,
}: {
template: EmailTemplate | null
onClose: () => void
onSaved: (template: EmailTemplate) => void
}) {
const [subject, setSubject] = useState(template?.subject ?? "")
const [body, setBody] = useState(template?.body ?? "")
const [pending, setPending] = useState(false)
const [error, setError] = useState("")
const addEmailTemplateFn = useServerFn(addEmailTemplate)
const editEmailTemplateFn = useServerFn(editEmailTemplate)

const trimmedSubject = subject.trim()
const trimmedBody = body.trim()

async function submit(event: React.FormEvent) {
event.preventDefault()
if (!trimmedSubject || !trimmedBody) return

setPending(true)
setError("")
try {
const saved = template
? await editEmailTemplateFn({ data: { id: template.id, subject: trimmedSubject, body: trimmedBody } })
: await addEmailTemplateFn({ data: { subject: trimmedSubject, body: trimmedBody } })
onSaved(saved)
} catch (cause) {
console.error(cause)
setError(errorMessage(cause, "The template could not be saved. Check your permissions and try again."))
} finally {
setPending(false)
}
}

return (
<Dialog open onOpenChange={(open) => !open && !pending && onClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] max-w-[calc(100%-2rem)] overflow-y-auto border-border p-0 sm:max-w-2xl">
<DialogHeader className="border-b border-border px-6 py-5">
<p className="font-mono text-[10px] font-medium tracking-[0.13em] text-muted-foreground">
AZURE · EMAIL TEMPLATES
</p>
<DialogTitle className="text-xl font-semibold tracking-[-0.03em]">
{template ? "Edit email template" : "New email template"}
</DialogTitle>
<DialogDescription>
{template
? "Update the predefined subject and body for this template."
: "Save a predefined subject and body that can be selected when emailing members."}
</DialogDescription>
</DialogHeader>
<form className="px-6 py-5" onSubmit={(event) => void submit(event)}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="template-subject">Subject</FieldLabel>
<Input
id="template-subject"
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="e.g. Membership renewal reminder"
required
autoFocus
/>
</Field>
<Field>
<FieldLabel htmlFor="template-body">Body</FieldLabel>
<Textarea
id="template-body"
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder="Write the email body…"
className="h-56 field-sizing-fixed resize-none overflow-y-auto"
required
/>
</Field>
{error && <FieldError>{error}</FieldError>}
</FieldGroup>
<DialogFooter className="-mx-6 -mb-5 mt-5 flex-row justify-end border-t border-border bg-muted/50 px-6 py-4">
<Button type="button" variant="outline" onClick={onClose} disabled={pending}>
Cancel
</Button>
<Button type="submit" disabled={pending || !trimmedSubject || !trimmedBody}>
{pending && <LoaderCircle data-icon="inline-start" className="animate-spin-slow" />}{" "}
{template ? "Save changes" : "Create template"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

export function DeleteEmailTemplateDialog({
template,
onClose,
onDeleted,
}: {
template: EmailTemplate
onClose: () => void
onDeleted: (id: number) => void
}) {
const [pending, setPending] = useState(false)
const deleteEmailTemplateFn = useServerFn(deleteEmailTemplate)

async function remove() {
setPending(true)
try {
await deleteEmailTemplateFn({ data: { id: template.id } })
onDeleted(template.id)
} catch (error) {
console.error(error)
toast.error("The template could not be deleted. Check your permissions and try again.")
} finally {
setPending(false)
}
}

return (
<AlertDialog open onOpenChange={(open) => !open && !pending && onClose()}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
<OctagonX />
</AlertDialogMedia>
<AlertDialogTitle>Delete email template</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{template.subject}</strong>? <br />
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>

<AlertDialogFooter>
<AlertDialogCancel disabled={pending} variant="outline" onClick={onClose}>
Cancel
</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={pending} onClick={() => void remove()}>
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin-slow" /> : "Confirm"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
162 changes: 162 additions & 0 deletions src/features/email-templates/email-templates-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { useRouter } from "@tanstack/react-router"
import { Mail, Pencil, Plus, Trash2 } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { toast } from "sonner"

import { DataToolbar } from "@/components/data-toolbar"
import { EmptyState } from "@/components/empty-state"
import { Button } from "@/components/ui/button"
import { DataTableHead, Table, TableBody, TableCell, TableHeader, TableRow, TableSurface } from "@/components/ui/table"
import type { EmailTemplate } from "@/lib/api/types"

import { DeleteEmailTemplateDialog, EmailTemplateDialog } from "./email-template-dialogs"

function truncate(value: string, length: number) {
return value.length > length ? `${value.slice(0, length).trimEnd()}…` : value
}

export function EmailTemplatesPage({
initialTemplates,
canWrite,
}: {
initialTemplates: EmailTemplate[]
canWrite: boolean
}) {
const router = useRouter()
const [templates, setTemplates] = useState(initialTemplates)
const [query, setQuery] = useState("")
const [editing, setEditing] = useState<EmailTemplate | null | "new">(null)
const [deleting, setDeleting] = useState<EmailTemplate | null>(null)

useEffect(() => setTemplates(initialTemplates), [initialTemplates])

const filteredTemplates = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase()
return normalized
? templates.filter((template) => template.subject.toLocaleLowerCase().includes(normalized))
: templates
}, [templates, query])

async function refresh() {
try {
await router.invalidate({ sync: true })
} catch (error) {
console.error(error)
toast.warning("Your change was saved, but the latest template list could not be refreshed.")
}
}

return (
<div className="animate-appear">
<DataToolbar
eyebrow="Azure"
title="Email templates"
description="Save predefined subject and body text that can be selected when emailing members."
count={filteredTemplates.length}
total={templates.length}
searchPlaceholder="Search by subject…"
onSearch={setQuery}
action={
canWrite ? (
<Button onClick={() => setEditing("new")}>
<Plus data-icon="inline-start" /> Add template
</Button>
) : undefined
}
/>
{filteredTemplates.length ? (
<TableSurface>
<Table className="min-w-[640px] text-left">
<TableHeader>
<TableRow className="border-0 hover:bg-transparent">
<DataTableHead>Subject</DataTableHead>
<DataTableHead>Body</DataTableHead>
{canWrite && <DataTableHead className="text-right">Actions</DataTableHead>}
</TableRow>
</TableHeader>
<TableBody>
{filteredTemplates.map((template) => (
<TableRow key={template.id}>
<TableCell className="px-4 py-3.5">
<div className="flex items-center gap-3">
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-accent text-primary">
<Mail className="size-4" />
</span>
<span className="font-medium">{template.subject}</span>
</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-sm text-muted-foreground">
{truncate(template.body, 120)}
</TableCell>
{canWrite && (
<TableCell className="px-4 py-3.5 text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="icon-sm"
aria-label={`Edit ${template.subject}`}
onClick={() => setEditing(template)}
>
<Pencil />
</Button>
<Button
variant="destructive"
size="icon-sm"
aria-label={`Delete ${template.subject}`}
onClick={() => setDeleting(template)}
>
<Trash2 />
</Button>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableSurface>
) : (
<EmptyState
icon={Mail}
title={templates.length ? "No template matches this search" : "No email templates yet"}
text={
templates.length
? "Try a different subject."
: "Save a predefined subject and body so admins can reuse it when emailing members."
}
action={
canWrite && !templates.length ? (
<Button onClick={() => setEditing("new")}>Add first template</Button>
) : undefined
}
/>
)}
{editing && (
<EmailTemplateDialog
template={editing === "new" ? null : editing}
onClose={() => setEditing(null)}
onSaved={(saved) => {
setTemplates((current) =>
editing === "new" ? [...current, saved] : current.map((t) => (t.id === saved.id ? saved : t))
)
setEditing(null)
toast.success(editing === "new" ? "Template created successfully" : "Template updated successfully")
void refresh()
}}
/>
)}
{deleting && (
<DeleteEmailTemplateDialog
template={deleting}
onClose={() => setDeleting(null)}
onDeleted={(id) => {
setTemplates((current) => current.filter((template) => template.id !== id))
setDeleting(null)
toast.success("Template deleted")
void refresh()
}}
/>
)}
</div>
)
}
40 changes: 40 additions & 0 deletions src/features/email-templates/email-templates.functions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createServerFn } from "@tanstack/react-start"

import { adminMiddleware, writeAdminMiddleware } from "@/server/auth.middleware"

import { addEmailTemplateInput, deleteEmailTemplateInput, editEmailTemplateInput } from "./email-templates.validation"

export const listEmailTemplates = createServerFn()
.middleware([adminMiddleware])
.handler(({ context }) => context.backend.email.templates.getAll.query())

Check failure on line 9 in src/features/email-templates/email-templates.functions.ts

View workflow job for this annotation

GitHub Actions / Typecheck and Lint

Property 'email' does not exist on type 'TRPCClient<BuiltRouter<{ ctx: { userId?: string | undefined; }; meta: object; errorShape: { data: { zodError: { errors: string[]; } | null; code: "PARSE_ERROR" | "BAD_REQUEST" | "INTERNAL_SERVER_ERROR" | "NOT_IMPLEMENTED" | ... 15 more ... | "CLIENT_CLOSED_REQUEST"; httpStatus: number; path?: string | undefined; sta...'.

export const addEmailTemplate = createServerFn({ method: "POST" })
.middleware([writeAdminMiddleware])
.validator(addEmailTemplateInput)
.handler(({ data, context }) =>
context.backend.email.templates.add.mutate({

Check failure on line 15 in src/features/email-templates/email-templates.functions.ts

View workflow job for this annotation

GitHub Actions / Typecheck and Lint

Property 'email' does not exist on type 'TRPCClient<BuiltRouter<{ ctx: { userId?: string | undefined; }; meta: object; errorShape: { data: { zodError: { errors: string[]; } | null; code: "PARSE_ERROR" | "BAD_REQUEST" | "INTERNAL_SERVER_ERROR" | "NOT_IMPLEMENTED" | ... 15 more ... | "CLIENT_CLOSED_REQUEST"; httpStatus: number; path?: string | undefined; sta...'.
...data,
createdBy: context.telegramId,
})
)

export const editEmailTemplate = createServerFn({ method: "POST" })
.middleware([writeAdminMiddleware])
.validator(editEmailTemplateInput)
.handler(async ({ data, context }) => {
const result = await context.backend.email.templates.edit.mutate({

Check failure on line 25 in src/features/email-templates/email-templates.functions.ts

View workflow job for this annotation

GitHub Actions / Typecheck and Lint

Property 'email' does not exist on type 'TRPCClient<BuiltRouter<{ ctx: { userId?: string | undefined; }; meta: object; errorShape: { data: { zodError: { errors: string[]; } | null; code: "PARSE_ERROR" | "BAD_REQUEST" | "INTERNAL_SERVER_ERROR" | "NOT_IMPLEMENTED" | ... 15 more ... | "CLIENT_CLOSED_REQUEST"; httpStatus: number; path?: string | undefined; sta...'.
...data,
modifiedBy: context.telegramId,
})
if ("error" in result) throw new Error(result.error)
return result
})

export const deleteEmailTemplate = createServerFn({ method: "POST" })
.middleware([writeAdminMiddleware])
.validator(deleteEmailTemplateInput)
.handler(async ({ data, context }) => {
const result = await context.backend.email.templates.delete.mutate(data)

Check failure on line 37 in src/features/email-templates/email-templates.functions.ts

View workflow job for this annotation

GitHub Actions / Typecheck and Lint

Property 'email' does not exist on type 'TRPCClient<BuiltRouter<{ ctx: { userId?: string | undefined; }; meta: object; errorShape: { data: { zodError: { errors: string[]; } | null; code: "PARSE_ERROR" | "BAD_REQUEST" | "INTERNAL_SERVER_ERROR" | "NOT_IMPLEMENTED" | ... 15 more ... | "CLIENT_CLOSED_REQUEST"; httpStatus: number; path?: string | undefined; sta...'.
if (result.error) throw new Error(result.error)
return result
})
Loading
Loading