-
-
Notifications
You must be signed in to change notification settings - Fork 212
feat: add double opt-in #237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
-- Add double opt-in supporting columns | ||
ALTER TABLE "Domain" ADD COLUMN "defaultFrom" TEXT; | ||
|
||
ALTER TABLE "ContactBook" | ||
ADD COLUMN "defaultDomainId" INTEGER, | ||
ADD COLUMN "doubleOptInEnabled" BOOLEAN NOT NULL DEFAULT false, | ||
ADD COLUMN "doubleOptInTemplateId" TEXT; | ||
|
||
-- Indexes for new foreign keys | ||
CREATE INDEX "ContactBook_defaultDomainId_idx" ON "ContactBook"("defaultDomainId"); | ||
CREATE INDEX "ContactBook_doubleOptInTemplateId_idx" ON "ContactBook"("doubleOptInTemplateId"); | ||
|
||
-- Foreign key constraints | ||
ALTER TABLE "ContactBook" | ||
ADD CONSTRAINT "ContactBook_defaultDomainId_fkey" FOREIGN KEY ("defaultDomainId") REFERENCES "Domain"("id") ON DELETE SET NULL ON UPDATE CASCADE; | ||
ALTER TABLE "ContactBook" | ||
ADD CONSTRAINT "ContactBook_doubleOptInTemplateId_fkey" FOREIGN KEY ("doubleOptInTemplateId") REFERENCES "Template"("id") ON DELETE SET NULL ON UPDATE CASCADE; |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,243 @@ | ||||||||||||||||||||||||||||||||||
"use client"; | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
import { use, useEffect } from "react"; | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not use React’s
-import { use, useEffect } from "react";
+import { useEffect } from "react"; 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||||||||||||||||||||||
import { | ||||||||||||||||||||||||||||||||||
Form, | ||||||||||||||||||||||||||||||||||
FormControl, | ||||||||||||||||||||||||||||||||||
FormField, | ||||||||||||||||||||||||||||||||||
FormItem, | ||||||||||||||||||||||||||||||||||
FormLabel, | ||||||||||||||||||||||||||||||||||
FormMessage, | ||||||||||||||||||||||||||||||||||
} from "@usesend/ui/src/form"; | ||||||||||||||||||||||||||||||||||
import { useForm } from "react-hook-form"; | ||||||||||||||||||||||||||||||||||
import { z } from "zod"; | ||||||||||||||||||||||||||||||||||
import { zodResolver } from "@hookform/resolvers/zod"; | ||||||||||||||||||||||||||||||||||
import { Switch } from "@usesend/ui/src/switch"; | ||||||||||||||||||||||||||||||||||
import { Button } from "@usesend/ui/src/button"; | ||||||||||||||||||||||||||||||||||
import { | ||||||||||||||||||||||||||||||||||
Select, | ||||||||||||||||||||||||||||||||||
SelectContent, | ||||||||||||||||||||||||||||||||||
SelectItem, | ||||||||||||||||||||||||||||||||||
SelectTrigger, | ||||||||||||||||||||||||||||||||||
SelectValue, | ||||||||||||||||||||||||||||||||||
} from "@usesend/ui/src/select"; | ||||||||||||||||||||||||||||||||||
import { api } from "~/trpc/react"; | ||||||||||||||||||||||||||||||||||
import { toast } from "@usesend/ui/src/toaster"; | ||||||||||||||||||||||||||||||||||
import { Skeleton } from "@usesend/ui/src/skeleton"; | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
const schema = z | ||||||||||||||||||||||||||||||||||
.object({ | ||||||||||||||||||||||||||||||||||
doubleOptInEnabled: z.boolean(), | ||||||||||||||||||||||||||||||||||
defaultDomainId: z.string().nullable(), | ||||||||||||||||||||||||||||||||||
doubleOptInTemplateId: z.string().nullable(), | ||||||||||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||||||||||
.superRefine((value, ctx) => { | ||||||||||||||||||||||||||||||||||
if (!value.doubleOptInEnabled) { | ||||||||||||||||||||||||||||||||||
return; | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
if (!value.defaultDomainId) { | ||||||||||||||||||||||||||||||||||
ctx.addIssue({ | ||||||||||||||||||||||||||||||||||
path: ["defaultDomainId"], | ||||||||||||||||||||||||||||||||||
code: z.ZodIssueCode.custom, | ||||||||||||||||||||||||||||||||||
message: "Choose a verified domain", | ||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
if (!value.doubleOptInTemplateId) { | ||||||||||||||||||||||||||||||||||
ctx.addIssue({ | ||||||||||||||||||||||||||||||||||
path: ["doubleOptInTemplateId"], | ||||||||||||||||||||||||||||||||||
code: z.ZodIssueCode.custom, | ||||||||||||||||||||||||||||||||||
message: "Select a confirmation template", | ||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
export default function ContactBookSettingsPage({ | ||||||||||||||||||||||||||||||||||
params, | ||||||||||||||||||||||||||||||||||
}: { | ||||||||||||||||||||||||||||||||||
params: Promise<{ contactBookId: string }>; | ||||||||||||||||||||||||||||||||||
}) { | ||||||||||||||||||||||||||||||||||
const { contactBookId } = use(params); | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
const utils = api.useUtils(); | ||||||||||||||||||||||||||||||||||
Comment on lines
+56
to
+63
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix component props: accept params directly and drop Align with Next.js App Router conventions for client components. -export default function ContactBookSettingsPage({
- params,
-}: {
- params: Promise<{ contactBookId: string }>;
-}) {
- const { contactBookId } = use(params);
+export default function ContactBookSettingsPage({
+ params,
+}: {
+ params: { contactBookId: string };
+}) {
+ const { contactBookId } = params; 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
const settingsQuery = api.contacts.getContactBookSettings.useQuery({ | ||||||||||||||||||||||||||||||||||
contactBookId, | ||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
const updateMutation = api.contacts.updateContactBook.useMutation({ | ||||||||||||||||||||||||||||||||||
onSuccess: async () => { | ||||||||||||||||||||||||||||||||||
await Promise.all([ | ||||||||||||||||||||||||||||||||||
utils.contacts.getContactBookSettings.invalidate({ contactBookId }), | ||||||||||||||||||||||||||||||||||
utils.contacts.getContactBookDetails.invalidate({ contactBookId }), | ||||||||||||||||||||||||||||||||||
]); | ||||||||||||||||||||||||||||||||||
toast.success("Settings updated"); | ||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||
onError: (error) => { | ||||||||||||||||||||||||||||||||||
toast.error(error.message); | ||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
const form = useForm<z.infer<typeof schema>>({ | ||||||||||||||||||||||||||||||||||
resolver: zodResolver(schema), | ||||||||||||||||||||||||||||||||||
defaultValues: { | ||||||||||||||||||||||||||||||||||
doubleOptInEnabled: false, | ||||||||||||||||||||||||||||||||||
defaultDomainId: null, | ||||||||||||||||||||||||||||||||||
doubleOptInTemplateId: null, | ||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
useEffect(() => { | ||||||||||||||||||||||||||||||||||
if (!settingsQuery.data) return; | ||||||||||||||||||||||||||||||||||
const { contactBook } = settingsQuery.data; | ||||||||||||||||||||||||||||||||||
form.reset({ | ||||||||||||||||||||||||||||||||||
doubleOptInEnabled: contactBook.doubleOptInEnabled, | ||||||||||||||||||||||||||||||||||
defaultDomainId: contactBook.defaultDomainId | ||||||||||||||||||||||||||||||||||
? String(contactBook.defaultDomainId) | ||||||||||||||||||||||||||||||||||
: null, | ||||||||||||||||||||||||||||||||||
doubleOptInTemplateId: contactBook.doubleOptInTemplateId, | ||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||
}, [settingsQuery.data, form]); | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
const onSubmit = form.handleSubmit((values) => { | ||||||||||||||||||||||||||||||||||
updateMutation.mutate({ | ||||||||||||||||||||||||||||||||||
contactBookId, | ||||||||||||||||||||||||||||||||||
doubleOptInEnabled: values.doubleOptInEnabled, | ||||||||||||||||||||||||||||||||||
defaultDomainId: values.defaultDomainId | ||||||||||||||||||||||||||||||||||
? Number(values.defaultDomainId) | ||||||||||||||||||||||||||||||||||
: null, | ||||||||||||||||||||||||||||||||||
doubleOptInTemplateId: values.doubleOptInTemplateId, | ||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
if (settingsQuery.isLoading || !settingsQuery.data) { | ||||||||||||||||||||||||||||||||||
return ( | ||||||||||||||||||||||||||||||||||
<div className="space-y-6"> | ||||||||||||||||||||||||||||||||||
<Skeleton className="h-6 w-40" /> | ||||||||||||||||||||||||||||||||||
<Skeleton className="h-12 w-full" /> | ||||||||||||||||||||||||||||||||||
<Skeleton className="h-12 w-full" /> | ||||||||||||||||||||||||||||||||||
</div> | ||||||||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
const { domains, templates } = settingsQuery.data; | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
const disableSelectorsBase = | ||||||||||||||||||||||||||||||||||
!form.watch("doubleOptInEnabled") || domains.length === 0; | ||||||||||||||||||||||||||||||||||
const disableDomainSelect = disableSelectorsBase; | ||||||||||||||||||||||||||||||||||
const disableTemplateSelect = disableSelectorsBase || templates.length === 0; | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
return ( | ||||||||||||||||||||||||||||||||||
<div className="max-w-2xl space-y-10"> | ||||||||||||||||||||||||||||||||||
<div> | ||||||||||||||||||||||||||||||||||
<h1 className="text-2xl font-semibold">Double opt-in</h1> | ||||||||||||||||||||||||||||||||||
<p className="text-sm text-muted-foreground"> | ||||||||||||||||||||||||||||||||||
Require new contacts to confirm their email address before they are | ||||||||||||||||||||||||||||||||||
subscribed. | ||||||||||||||||||||||||||||||||||
</p> | ||||||||||||||||||||||||||||||||||
</div> | ||||||||||||||||||||||||||||||||||
<Form {...form}> | ||||||||||||||||||||||||||||||||||
<form onSubmit={onSubmit} className="space-y-8"> | ||||||||||||||||||||||||||||||||||
<FormField | ||||||||||||||||||||||||||||||||||
control={form.control} | ||||||||||||||||||||||||||||||||||
name="doubleOptInEnabled" | ||||||||||||||||||||||||||||||||||
render={({ field }) => ( | ||||||||||||||||||||||||||||||||||
<FormItem className="flex items-center justify-between rounded-lg border p-4"> | ||||||||||||||||||||||||||||||||||
<div> | ||||||||||||||||||||||||||||||||||
<FormLabel>Require confirmation</FormLabel> | ||||||||||||||||||||||||||||||||||
<p className="text-sm text-muted-foreground"> | ||||||||||||||||||||||||||||||||||
Send a confirmation email when contacts are added via the | ||||||||||||||||||||||||||||||||||
API. | ||||||||||||||||||||||||||||||||||
</p> | ||||||||||||||||||||||||||||||||||
</div> | ||||||||||||||||||||||||||||||||||
<FormControl> | ||||||||||||||||||||||||||||||||||
<Switch | ||||||||||||||||||||||||||||||||||
checked={field.value} | ||||||||||||||||||||||||||||||||||
onCheckedChange={field.onChange} | ||||||||||||||||||||||||||||||||||
/> | ||||||||||||||||||||||||||||||||||
</FormControl> | ||||||||||||||||||||||||||||||||||
</FormItem> | ||||||||||||||||||||||||||||||||||
)} | ||||||||||||||||||||||||||||||||||
/> | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
<FormField | ||||||||||||||||||||||||||||||||||
control={form.control} | ||||||||||||||||||||||||||||||||||
name="defaultDomainId" | ||||||||||||||||||||||||||||||||||
render={({ field }) => ( | ||||||||||||||||||||||||||||||||||
<FormItem> | ||||||||||||||||||||||||||||||||||
<FormLabel>From domain</FormLabel> | ||||||||||||||||||||||||||||||||||
<FormControl> | ||||||||||||||||||||||||||||||||||
<Select | ||||||||||||||||||||||||||||||||||
value={field.value ?? undefined} | ||||||||||||||||||||||||||||||||||
onValueChange={(value) => field.onChange(value)} | ||||||||||||||||||||||||||||||||||
disabled={disableDomainSelect} | ||||||||||||||||||||||||||||||||||
> | ||||||||||||||||||||||||||||||||||
<SelectTrigger> | ||||||||||||||||||||||||||||||||||
<SelectValue placeholder="Select a verified domain" /> | ||||||||||||||||||||||||||||||||||
</SelectTrigger> | ||||||||||||||||||||||||||||||||||
<SelectContent> | ||||||||||||||||||||||||||||||||||
{domains.map((domain) => ( | ||||||||||||||||||||||||||||||||||
<SelectItem key={domain.id} value={String(domain.id)}> | ||||||||||||||||||||||||||||||||||
{domain.name} | ||||||||||||||||||||||||||||||||||
</SelectItem> | ||||||||||||||||||||||||||||||||||
))} | ||||||||||||||||||||||||||||||||||
</SelectContent> | ||||||||||||||||||||||||||||||||||
</Select> | ||||||||||||||||||||||||||||||||||
</FormControl> | ||||||||||||||||||||||||||||||||||
{domains.length === 0 ? ( | ||||||||||||||||||||||||||||||||||
<p className="text-sm text-muted-foreground"> | ||||||||||||||||||||||||||||||||||
Add a verified domain before enabling double opt-in. | ||||||||||||||||||||||||||||||||||
</p> | ||||||||||||||||||||||||||||||||||
) : ( | ||||||||||||||||||||||||||||||||||
<FormMessage /> | ||||||||||||||||||||||||||||||||||
)} | ||||||||||||||||||||||||||||||||||
</FormItem> | ||||||||||||||||||||||||||||||||||
)} | ||||||||||||||||||||||||||||||||||
/> | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
<FormField | ||||||||||||||||||||||||||||||||||
control={form.control} | ||||||||||||||||||||||||||||||||||
name="doubleOptInTemplateId" | ||||||||||||||||||||||||||||||||||
render={({ field }) => ( | ||||||||||||||||||||||||||||||||||
<FormItem> | ||||||||||||||||||||||||||||||||||
<FormLabel>Confirmation email</FormLabel> | ||||||||||||||||||||||||||||||||||
<FormControl> | ||||||||||||||||||||||||||||||||||
<Select | ||||||||||||||||||||||||||||||||||
value={field.value ?? undefined} | ||||||||||||||||||||||||||||||||||
onValueChange={(value) => field.onChange(value)} | ||||||||||||||||||||||||||||||||||
disabled={disableTemplateSelect} | ||||||||||||||||||||||||||||||||||
> | ||||||||||||||||||||||||||||||||||
<SelectTrigger> | ||||||||||||||||||||||||||||||||||
<SelectValue placeholder="Select a template" /> | ||||||||||||||||||||||||||||||||||
</SelectTrigger> | ||||||||||||||||||||||||||||||||||
<SelectContent> | ||||||||||||||||||||||||||||||||||
{templates.map((template) => ( | ||||||||||||||||||||||||||||||||||
<SelectItem key={template.id} value={template.id}> | ||||||||||||||||||||||||||||||||||
{template.name} | ||||||||||||||||||||||||||||||||||
</SelectItem> | ||||||||||||||||||||||||||||||||||
))} | ||||||||||||||||||||||||||||||||||
</SelectContent> | ||||||||||||||||||||||||||||||||||
</Select> | ||||||||||||||||||||||||||||||||||
</FormControl> | ||||||||||||||||||||||||||||||||||
<p className="text-sm text-muted-foreground"> | ||||||||||||||||||||||||||||||||||
Templates must include the {"{{verificationUrl}}"} placeholder. | ||||||||||||||||||||||||||||||||||
{templates.length === 0 | ||||||||||||||||||||||||||||||||||
? " Create or publish a template before enabling double opt-in." | ||||||||||||||||||||||||||||||||||
: ""} | ||||||||||||||||||||||||||||||||||
</p> | ||||||||||||||||||||||||||||||||||
<FormMessage /> | ||||||||||||||||||||||||||||||||||
</FormItem> | ||||||||||||||||||||||||||||||||||
)} | ||||||||||||||||||||||||||||||||||
/> | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
<div className="flex justify-end"> | ||||||||||||||||||||||||||||||||||
<Button type="submit" disabled={updateMutation.isPending}> | ||||||||||||||||||||||||||||||||||
{updateMutation.isPending ? "Saving..." : "Save changes"} | ||||||||||||||||||||||||||||||||||
</Button> | ||||||||||||||||||||||||||||||||||
</div> | ||||||||||||||||||||||||||||||||||
</form> | ||||||||||||||||||||||||||||||||||
</Form> | ||||||||||||||||||||||||||||||||||
</div> | ||||||||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix invalid interactive nesting: inside .
A Button inside a Link renders a button inside an anchor, which is invalid and harms a11y. Use the Button’s
asChild
to render the anchor as the button.📝 Committable suggestion
🤖 Prompt for AI Agents