Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "ContactSegment" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"contactBookId" TEXT NOT NULL,
"filters" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "ContactSegment_pkey" PRIMARY KEY ("id")
);

-- AlterTable
ALTER TABLE "Campaign" ADD COLUMN "contactSegmentId" TEXT;

-- CreateIndex
CREATE UNIQUE INDEX "ContactSegment_contactBookId_name_key" ON "ContactSegment"("contactBookId", "name");

-- CreateIndex
CREATE INDEX "ContactSegment_contactBookId_createdAt_idx" ON "ContactSegment"("contactBookId", "createdAt" DESC);

-- CreateIndex
CREATE INDEX "Campaign_contactSegmentId_idx" ON "Campaign"("contactSegmentId");

-- AddForeignKey
ALTER TABLE "ContactSegment" ADD CONSTRAINT "ContactSegment_contactBookId_fkey" FOREIGN KEY ("contactBookId") REFERENCES "ContactBook"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Campaign" ADD CONSTRAINT "Campaign_contactSegmentId_fkey" FOREIGN KEY ("contactSegmentId") REFERENCES "ContactSegment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
21 changes: 20 additions & 1 deletion apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ model ContactBook {
emoji String @default("📙")
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contacts Contact[]
segments ContactSegment[]

@@index([teamId])
}
Expand Down Expand Up @@ -363,6 +364,7 @@ model Campaign {
html String?
content String?
contactBookId String?
contactSegmentId String?
scheduledAt DateTime?
total Int @default(0)
sent Int @default(0)
Expand All @@ -382,12 +384,29 @@ model Campaign {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contactSegment ContactSegment? @relation(fields: [contactSegmentId], references: [id], onDelete: SetNull)

@@index([createdAt(sort: Desc)])
@@index([contactSegmentId])
@@index([status, scheduledAt])
}

model ContactSegment {
id String @id @default(cuid())
name String
contactBookId String
filters Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

contactBook ContactBook @relation(fields: [contactBookId], references: [id], onDelete: Cascade)
campaigns Campaign[]

@@unique([contactBookId, name])
@@index([contactBookId, createdAt(sort: Desc)])
}

model Template {
id String @id @default(cuid())
name String
Expand Down
84 changes: 65 additions & 19 deletions apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { api } from "~/trpc/react";
import { Spinner } from "@usesend/ui/src/spinner";
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Editor } from "@usesend/email-editor";
import { use, useMemo, useState } from "react";
Expand All @@ -13,25 +12,7 @@ import {
SelectItem,
SelectTrigger,
} from "@usesend/ui/src/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { toast } from "@usesend/ui/src/toaster";
import { useDebouncedCallback } from "use-debounce";
import { formatDistanceToNow } from "date-fns";
Expand All @@ -41,6 +22,7 @@ import {
AccordionItem,
AccordionTrigger,
} from "@usesend/ui/src/accordion";
import { Badge } from "@usesend/ui/src/badge";
import ScheduleCampaign from "../../schedule-campaign";
import { useRouter } from "next/navigation";

Expand Down Expand Up @@ -109,6 +91,9 @@ function CampaignEditor({
const [subject, setSubject] = useState(campaign.subject);
const [from, setFrom] = useState(campaign.from);
const [contactBookId, setContactBookId] = useState(campaign.contactBookId);
const [contactSegmentId, setContactSegmentId] = useState(
campaign.contactSegmentId,
);
const [replyTo, setReplyTo] = useState<string | undefined>(
campaign.replyTo[0],
);
Expand All @@ -119,6 +104,7 @@ function CampaignEditor({
const updateCampaignMutation = api.campaign.updateCampaign.useMutation({
onSuccess: () => {
utils.campaign.getCampaign.invalidate();
utils.campaign.getCampaignAudience.invalidate({ campaignId: campaign.id });
setIsSaving(false);
},
});
Expand Down Expand Up @@ -167,6 +153,13 @@ function CampaignEditor({
const contactBook = contactBooksQuery.data?.find(
(book) => book.id === contactBookId,
);
const segmentsQuery = api.contacts.listSegments.useQuery(
{ contactBookId: contactBookId ?? "" },
{ enabled: !!contactBookId },
);
const audienceQuery = api.campaign.getCampaignAudience.useQuery({
campaignId: campaign.id,
});
const editorVariables = useMemo(() => {
const baseVariables = ["email", "firstName", "lastName"];
const registryVariables = contactBook?.variables ?? [];
Expand Down Expand Up @@ -401,14 +394,17 @@ function CampaignEditor({
{
campaignId: campaign.id,
contactBookId: val,
contactSegmentId: null,
},
{
onError: () => {
setContactBookId(campaign.contactBookId);
setContactSegmentId(campaign.contactSegmentId);
},
},
);
setContactBookId(val);
setContactSegmentId(null);
}}
>
<SelectTrigger className="w-[300px]">
Expand All @@ -430,6 +426,56 @@ function CampaignEditor({
</Select>
)}
</div>
<div className=" flex items-center gap-2">
<label className="block text-sm w-[80px] text-muted-foreground">
Segment
</label>
<Select
value={contactSegmentId ?? "all"}
disabled={isApiCampaign || !contactBookId}
onValueChange={(val) => {
if (isApiCampaign) {
return;
}

const nextSegmentId = val === "all" ? null : val;

updateCampaignMutation.mutate(
{
campaignId: campaign.id,
contactSegmentId: nextSegmentId,
},
{
onError: () => {
setContactSegmentId(campaign.contactSegmentId);
},
},
);
setContactSegmentId(nextSegmentId);
}}
>
<SelectTrigger className="w-[300px]">
{contactSegmentId
? segmentsQuery.data?.find(
(segment) => segment.id === contactSegmentId,
)?.name ?? "Select a segment"
: "All contacts"}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All contacts</SelectItem>
{segmentsQuery.data?.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.name}
</SelectItem>
))}
</SelectContent>
</Select>
{audienceQuery.data ? (
<Badge variant="outline">
{audienceQuery.data.count.toLocaleString()} recipients
</Badge>
) : null}
</div>
</AccordionContent>
</div>
</AccordionItem>
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export default function CampaignDetailsPage({
refetchInterval: 5000,
}
);
const { data: audience } = api.campaign.getCampaignAudience.useQuery({
campaignId,
});

if (isLoading) {
return (
Expand Down Expand Up @@ -260,6 +263,13 @@ export default function CampaignDetailsPage({
<div className="w-[70px] text-muted-foreground">From</div>
<div> {campaign.from}</div>
</div>
<div className="flex text-sm">
<div className="w-[70px] text-muted-foreground">Audience</div>
<div>
{campaign.contactSegment?.name ?? "All contacts"}
{audience ? ` (${audience.count.toLocaleString()})` : ""}
</div>
</div>
<div className="flex text-sm items-center">
<div className="w-[70px] text-muted-foreground">Contact</div>
<Link
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from "@usesend/ui/src/tooltip";
import { UnsubscribeReason } from "@prisma/client";
import { Download } from "lucide-react";
import { useEffect } from "react";

function sanitizeFilename(
name: string | undefined,
Expand Down Expand Up @@ -83,13 +84,30 @@ export default function ContactList({
const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status");
const [search, setSearch] = useUrlState("search");
const [segmentId, setSegmentId] = useUrlState("segment");

const pageNumber = Number(page);
const segmentsQuery = api.contacts.listSegments.useQuery({ contactBookId });

useEffect(() => {
if (!segmentId || !segmentsQuery.data) {
return;
}

const segmentExists = segmentsQuery.data.some(
(segment) => segment.id === segmentId,
);

if (!segmentExists) {
setSegmentId(null);
}
}, [segmentId, segmentsQuery.data, setSegmentId]);

const contactsQuery = api.contacts.contacts.useQuery({
contactBookId,
page: pageNumber,
search: search ?? undefined,
segmentId: segmentId ?? undefined,
subscribed:
status === "Subscribed"
? true
Expand All @@ -108,10 +126,16 @@ export default function ContactList({
setPage("1");
};

const handleSegmentChange = (value: string) => {
setSegmentId(value === "all" ? null : value);
setPage("1");
};

const exportQuery = api.contacts.exportContacts.useQuery(
{
contactBookId,
search: search ?? undefined,
segmentId: segmentId ?? undefined,
subscribed:
status === "Subscribed"
? true
Expand Down Expand Up @@ -201,6 +225,22 @@ export default function ContactList({
/>
</div>
<div className="flex gap-2">
<Select value={segmentId ?? "all"} onValueChange={handleSegmentChange}>
<SelectTrigger className="w-[220px]">
{segmentId
? segmentsQuery.data?.find((segment) => segment.id === segmentId)
?.name ?? "Segment"
: "All segments"}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All segments</SelectItem>
{segmentsQuery.data?.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={status ?? "All"} onValueChange={handleStatusChange}>
<SelectTrigger className="w-[180px] capitalize">
{status || "All statuses"}
Expand Down
Loading