Skip to content
Closed
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
86 changes: 86 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/settings/ReplyZeroSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";

import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/Badge";
import { Button } from "@/components/ui/button";
import { FormSection, FormSectionLeft } from "@/components/Form";
import { useAccount } from "@/providers/EmailAccountProvider";
import { toggleRuleAction } from "@/utils/actions/rule";
import { CONVERSATION_STATUS_TYPES } from "@/utils/reply-tracker/conversation-status-config";
import { useSetupProgress } from "@/hooks/useSetupProgress";
import { LoadingContent } from "@/components/LoadingContent";

export function ReplyZeroSection() {
const { emailAccountId } = useAccount();
const { data, isLoading: isLoadingStatus, mutate } = useSetupProgress();
const [isToggling, setIsToggling] = useState(false);

const isEnabled = data?.steps.replyZero ?? false;

const handleToggle = async () => {
setIsToggling(true);
const newEnabled = !isEnabled;

try {
const results = await Promise.all(
CONVERSATION_STATUS_TYPES.map((systemType) =>
toggleRuleAction(emailAccountId, {
enabled: newEnabled,
systemType,
}),
),
);

const hasError = results.some((result) => result?.serverError);

if (hasError) {
toast.error(
`Error ${newEnabled ? "enabling" : "disabling"} Reply Zero`,
);
} else {
mutate();
toast.success(
`Reply Zero ${newEnabled ? "enabled" : "disabled"} successfully`,
);
}
} catch {
toast.error(`Error ${newEnabled ? "enabling" : "disabling"} Reply Zero`);
} finally {
setIsToggling(false);
}
};

return (
<FormSection>
<FormSectionLeft
title="Reply Zero"
description="Track emails that need your reply and get AI-drafted responses."
/>

<LoadingContent loading={isLoadingStatus}>
<div className="flex items-center gap-3">
{isEnabled ? (
<Badge color="green">Enabled</Badge>
) : (
<Badge color="gray">Disabled</Badge>
)}

<Button
variant="outline"
onClick={handleToggle}
disabled={isToggling}
>
{isToggling
? isEnabled
? "Disabling..."
: "Enabling..."
: isEnabled
? "Disable"
: "Enable"}
</Button>
</div>
</LoadingContent>
</FormSection>
);
}
4 changes: 4 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { BillingSection } from "@/app/(app)/[emailAccountId]/settings/BillingSec
import { DeleteSection } from "@/app/(app)/[emailAccountId]/settings/DeleteSection";
import { ModelSection } from "@/app/(app)/[emailAccountId]/settings/ModelSection";
import { MultiAccountSection } from "@/app/(app)/[emailAccountId]/settings/MultiAccountSection";
import { ReplyZeroSection } from "@/app/(app)/[emailAccountId]/settings/ReplyZeroSection";
import { ResetAnalyticsSection } from "@/app/(app)/[emailAccountId]/settings/ResetAnalyticsSection";
import { WebhookSection } from "@/app/(app)/[emailAccountId]/settings/WebhookSection";
import { isGoogleProvider } from "@/utils/email/provider-types";
import { FormSection, FormWrapper } from "@/components/Form";
import { PageHeader } from "@/components/PageHeader";
import { TabsToolbar } from "@/components/TabsToolbar";
Expand Down Expand Up @@ -58,6 +60,8 @@ export default function SettingsPage() {
</SectionDescription>
</FormSection>

{isGoogleProvider(emailAccount.provider) && <ReplyZeroSection />}

<ResetAnalyticsSection />

{/* this is only used in Gmail when sending a new message. disabling for now. */}
Expand Down
20 changes: 20 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/setup/SetupContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type LucideIcon,
ChromeIcon,
CalendarIcon,
MessageCircleReplyIcon,
} from "lucide-react";
import { useLocalStorage } from "usehooks-ts";
import { PageHeading, SectionDescription } from "@/components/Typography";
Expand Down Expand Up @@ -217,6 +218,7 @@ function Checklist({
progressPercentage,
isBulkUnsubscribeConfigured,
isAiAssistantConfigured,
isReplyZeroConfigured,
isCalendarConnected,
}: {
emailAccountId: string;
Expand All @@ -226,6 +228,7 @@ function Checklist({
progressPercentage: number;
isBulkUnsubscribeConfigured: boolean;
isAiAssistantConfigured: boolean;
isReplyZeroConfigured: boolean;
isCalendarConnected: boolean;
}) {
const [isExtensionInstalled, setIsExtensionInstalled] = useLocalStorage(
Expand Down Expand Up @@ -270,6 +273,19 @@ function Checklist({
actionText="Set up"
/>

{isGoogleProvider(provider) && (
<StepItem
href={prefixPath(emailAccountId, "/reply-zero")}
icon={<MessageCircleReplyIcon size={20} />}
iconBg="bg-blue-100 dark:bg-blue-900/50"
iconColor="text-blue-500 dark:text-blue-400"
title="Enable Reply Zero"
timeEstimate="1 minute"
completed={isReplyZeroConfigured}
actionText="Enable"
/>
)}

<StepItem
href={prefixPath(emailAccountId, "/bulk-unsubscribe")}
icon={<ArchiveIcon size={20} />}
Expand Down Expand Up @@ -322,6 +338,7 @@ export function SetupContent() {
emailAccountId={emailAccountId}
provider={provider}
isAiAssistantConfigured={data.steps.aiAssistant}
isReplyZeroConfigured={data.steps.replyZero}
isBulkUnsubscribeConfigured={data.steps.bulkUnsubscribe}
isCalendarConnected={data.steps.calendarConnected}
completedCount={data.completed}
Expand All @@ -338,6 +355,7 @@ function SetupPageContent({
provider,
isBulkUnsubscribeConfigured,
isAiAssistantConfigured,
isReplyZeroConfigured,
isCalendarConnected,
completedCount,
totalSteps,
Expand All @@ -347,6 +365,7 @@ function SetupPageContent({
provider: string;
isBulkUnsubscribeConfigured: boolean;
isAiAssistantConfigured: boolean;
isReplyZeroConfigured: boolean;
isCalendarConnected: boolean;
completedCount: number;
totalSteps: number;
Expand Down Expand Up @@ -375,6 +394,7 @@ function SetupPageContent({
provider={provider}
isBulkUnsubscribeConfigured={isBulkUnsubscribeConfigured}
isAiAssistantConfigured={isAiAssistantConfigured}
isReplyZeroConfigured={isReplyZeroConfigured}
isCalendarConnected={isCalendarConnected}
completedCount={completedCount}
totalSteps={totalSteps}
Expand Down
35 changes: 25 additions & 10 deletions apps/web/app/api/user/setup-progress/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import prisma from "@/utils/prisma";
import { withEmailAccount } from "@/utils/middleware";
import { SystemType } from "@/generated/prisma/enums";

export type GetSetupProgressResponse = Awaited<
ReturnType<typeof getSetupProgress>
Expand All @@ -18,24 +19,38 @@ async function getSetupProgress({
}: {
emailAccountId: string;
}) {
const emailAccount = await prisma.emailAccount.findUnique({
where: { id: emailAccountId },
select: {
rules: { select: { id: true }, take: 1 },
newsletters: {
where: { status: { not: null } },
take: 1,
const [emailAccount, replyZeroRule] = await Promise.all([
prisma.emailAccount.findUnique({
where: { id: emailAccountId },
select: {
rules: { select: { id: true }, take: 1 },
newsletters: {
where: { status: { not: null } },
take: 1,
},
calendarConnections: { select: { id: true }, take: 1 },
},
calendarConnections: { select: { id: true }, take: 1 },
},
});
}),
// TO_REPLY is the canonical indicator for Reply Zero status.
// ReplyZeroSection toggles all 4 CONVERSATION_STATUS_TYPES together,
// but TO_REPLY represents the core "needs reply" tracking functionality.
prisma.rule.findFirst({
where: {
emailAccountId,
systemType: SystemType.TO_REPLY,
enabled: true,
},
select: { id: true },
}),
]);

if (!emailAccount) {
throw new Error("Email account not found");
}

const steps = {
aiAssistant: emailAccount.rules.length > 0,
replyZero: !!replyZeroRule,
bulkUnsubscribe: emailAccount.newsletters.length > 0,
calendarConnected: emailAccount.calendarConnections.length > 0,
};
Expand Down
Loading