Compare commits

...
1 Commits
Author SHA1 Message Date
Iuliia Shnai 4f95f0b54d feat: two emails for abandoned checkout 2026-01-29 14:15:14 +11:00
10 changed files with 570 additions and 4 deletions
+12 -1
View File
@@ -106,8 +106,19 @@ export function UpgradePlanModal({
trigger: trigger,
teamId: teamInfo?.currentTeam?.id,
});
// Track upgrade click for email scheduling (with trigger info for personalization)
if (teamInfo?.currentTeam?.id) {
fetch(`/api/teams/${teamInfo.currentTeam.id}/billing/track-upgrade-click`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trigger }),
}).catch(() => {
// Silently fail - this is just for tracking
});
}
}
}, [open, trigger]);
}, [open, trigger, teamInfo?.currentTeam?.id]);
// Track analytics event when child button is present
const handleUpgradeClick = () => {
@@ -217,10 +217,21 @@ export function UpgradePlanModalWithDiscount({
trigger: trigger,
teamId,
});
// Track upgrade click for email scheduling (with trigger info for personalization)
if (teamId) {
fetch(`/api/teams/${teamId}/billing/track-upgrade-click`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trigger }),
}).catch(() => {
// Silently fail - this is just for tracking
});
}
} else {
setDataRoomsPlanSelection("base");
}
}, [open, trigger]);
}, [open, trigger, teamId]);
const handleUpgradeClick = () => {
analytics.capture("Upgrade Button Clicked", {
+12 -1
View File
@@ -193,10 +193,21 @@ export function UpgradePlanModal({
trigger: trigger,
teamId,
});
// Track upgrade click for email scheduling (with trigger info for personalization)
if (teamId) {
fetch(`/api/teams/${teamId}/billing/track-upgrade-click`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trigger }),
}).catch(() => {
// Silently fail - this is just for tracking
});
}
} else {
setDataRoomsPlanSelection("base");
}
}, [open, trigger]);
}, [open, trigger, teamId]);
const handleUpgradeClick = () => {
analytics.capture("Upgrade Button Clicked", {
+49
View File
@@ -0,0 +1,49 @@
import {
Body,
Head,
Html,
Link,
Tailwind,
Text,
} from "@react-email/components";
interface AbandonedCheckoutEmailProps {
name: string | null | undefined;
}
const AbandonedCheckoutEmail = ({ name }: AbandonedCheckoutEmailProps) => {
return (
<Html>
<Head />
<Tailwind>
<Body className="font-sans text-sm">
<Text>Hi{name && ` ${name}`},</Text>
<Text>
I noticed you started the checkout process but didn&apos;t complete
it. Did something go wrong?
</Text>
<Text>
If you ran into any issues or have questions about our plans,
I&apos;d be happy to help. Just reply to this email.
</Text>
<Text>
<Link
href="https://app.papermark.com/settings/upgrade"
target="_blank"
className="text-blue-500 underline"
>
Complete your upgrade
</Link>
</Text>
<Text>
Best,
<br />
Marc
</Text>
</Body>
</Tailwind>
</Html>
);
};
export default AbandonedCheckoutEmail;
+118
View File
@@ -0,0 +1,118 @@
import {
Body,
Head,
Html,
Link,
Tailwind,
Text,
} from "@react-email/components";
// Map trigger names to feature titles
const FEATURE_NAMES: Record<string, string> = {
// Custom domains
add_domain_overview: "Custom Domains",
add_domain_link_sheet: "Custom Domains",
// Data rooms
datarooms: "Secure Data Rooms",
add_dataroom_overview: "Secure Data Rooms",
datarooms_generate_index_button: "Data Room Index",
datarooms_rebuild_index_button: "Data Room Index",
// Team features
invite_team_members: "Team Collaboration",
add_new_team: "Multiple Teams",
// Visitor analytics
"visitor-table-user-agent": "Visitor Analytics",
// Tags
create_tag: "Document Tags",
// Folders
add_folder_button: "Folders",
// Document limits
limit_upload_documents: "Unlimited Documents",
limit_upload_document_version: "Document Versions",
// Link limits
limit_add_link: "Unlimited Links",
// Analytics exports
dashboard_visitors_export: "Analytics Export",
dashboard_views_export: "Analytics Export",
dashboard_links_export: "Analytics Export",
dashboard_documents_export: "Analytics Export",
dashboard_time_range_custom_select: "Custom Date Ranges",
// Branding
pro_banner: "Custom Branding",
// Web links
add_web_link_document: "Web Links",
// Groups
add_group_link: "Link Groups",
};
// Get unique feature names from triggers (deduplicated, lowercase)
function getUniqueFeatureNames(triggers: string[]): string[] {
const seenTitles = new Set<string>();
const featureNames: string[] = [];
for (const trigger of triggers) {
const title = FEATURE_NAMES[trigger];
if (title && !seenTitles.has(title)) {
seenTitles.add(title);
featureNames.push(title.toLowerCase());
}
}
return featureNames.slice(0, 3); // Max 3 features
}
// Format feature names into a sentence (e.g., "secure data rooms, custom domains and team collaboration")
function formatFeatureList(names: string[]): string {
if (names.length === 0) return "";
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]} and ${names[1]}`;
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
}
interface UpgradeIntentEmailProps {
name: string | null | undefined;
triggers?: string[];
}
const UpgradeIntentEmail = ({ name, triggers = [] }: UpgradeIntentEmailProps) => {
const featureNames = getUniqueFeatureNames(triggers);
const hasFeatures = featureNames.length > 0;
const featureList = formatFeatureList(featureNames);
return (
<Html>
<Head />
<Tailwind>
<Body className="font-sans text-sm">
<Text>Hi{name && ` ${name}`},</Text>
<Text>
I noticed you&apos;ve been exploring our upgrade options.
{hasFeatures &&
` I am happy to share more about Papermark ${featureList}.`}
</Text>
<Text>
Is there anything holding you back or any questions I can help
answer?
</Text>
<Text>
<Link
href="https://app.papermark.com/settings/upgrade"
target="_blank"
className="text-blue-500 underline"
>
View upgrade options
</Link>
</Text>
<Text>Just reply to this email and I&apos;ll get back to you.</Text>
<Text>
Best,
<br />
Marc
</Text>
</Body>
</Tailwind>
</Html>
);
};
export default UpgradeIntentEmail;
+30
View File
@@ -0,0 +1,30 @@
import { sendEmail } from "@/lib/resend";
import AbandonedCheckoutEmail from "@/components/emails/abandoned-checkout";
import { CreateUserEmailProps } from "../types";
export const sendAbandonedCheckoutEmail = async (
params: CreateUserEmailProps,
) => {
const { name, email } = params.user;
// Get the first name from the full name
const firstName = name ? name.split(" ")[0] : null;
const emailTemplate = AbandonedCheckoutEmail({
name: firstName,
});
try {
await sendEmail({
to: email as string,
subject: "Did something block checkout?",
from: "Marc Seitz <marc@papermark.com>",
react: emailTemplate,
test: process.env.NODE_ENV === "development",
});
} catch (e) {
console.error(e);
}
};
+38
View File
@@ -0,0 +1,38 @@
import { sendEmail } from "@/lib/resend";
import UpgradeIntentEmail from "@/components/emails/upgrade-intent";
interface SendUpgradeIntentEmailProps {
user: {
name: string | null | undefined;
email: string | null | undefined;
};
triggers?: string[];
}
export const sendUpgradeIntentEmail = async (
params: SendUpgradeIntentEmailProps,
) => {
const { name, email } = params.user;
const triggers = params.triggers || [];
// Get the first name from the full name
const firstName = name ? name.split(" ")[0] : null;
const emailTemplate = UpgradeIntentEmail({
name: firstName,
triggers,
});
try {
await sendEmail({
to: email as string,
subject: "Need help with your upgrade?",
from: "Marc Seitz <marc@papermark.com>",
react: emailTemplate,
test: process.env.NODE_ENV === "development",
});
} catch (e) {
console.error(e);
}
};
+121
View File
@@ -1,10 +1,13 @@
import { logger, task } from "@trigger.dev/sdk/v3";
import { sendAbandonedCheckoutEmail } from "@/lib/emails/send-abandoned-checkout";
import { sendDataroomInfoEmail } from "@/lib/emails/send-dataroom-info";
import { sendDataroomTrial24hReminderEmail } from "@/lib/emails/send-dataroom-trial-24h";
import { sendDataroomTrialEndEmail } from "@/lib/emails/send-dataroom-trial-end";
import { sendUpgradeIntentEmail } from "@/lib/emails/send-upgrade-intent";
import { sendUpgradeOneMonthCheckinEmail } from "@/lib/emails/send-upgrade-month-checkin";
import prisma from "@/lib/prisma";
import { redis } from "@/lib/redis";
export const sendDataroomTrialInfoEmailTask = task({
id: "send-dataroom-trial-info-email",
@@ -171,3 +174,121 @@ export const sendUpgradeOneMonthCheckinEmailTask = task({
}
},
});
export const sendAbandonedCheckoutEmailTask = task({
id: "send-abandoned-checkout-email",
retry: { maxAttempts: 3 },
run: async (payload: { to: string; name: string; teamId: string }) => {
try {
const team = await prisma.team.findUnique({
where: { id: payload.teamId },
select: {
plan: true,
},
});
if (!team) {
logger.error("Team not found", { teamId: payload.teamId });
return;
}
// If team already upgraded to a paid plan, don't send abandoned checkout email
const paidPlans = [
"pro",
"business",
"datarooms",
"datarooms-plus",
"datarooms-premium",
];
const isPaidPlan = paidPlans.some((plan) => team.plan.includes(plan));
if (isPaidPlan) {
logger.info("Team already on paid plan - no abandoned checkout email needed", {
teamId: payload.teamId,
plan: team.plan,
});
return;
}
await sendAbandonedCheckoutEmail({
user: { email: payload.to, name: payload.name },
});
logger.info("Abandoned checkout email sent", { to: payload.to });
} catch (error) {
logger.error("Error sending abandoned checkout email", { error });
return;
}
},
});
export const sendUpgradeIntentEmailTask = task({
id: "send-upgrade-intent-email",
retry: { maxAttempts: 3 },
run: async (payload: {
to: string;
name: string;
teamId: string;
triggers?: string[];
}) => {
try {
const team = await prisma.team.findUnique({
where: { id: payload.teamId },
select: {
plan: true,
},
});
if (!team) {
logger.error("Team not found", { teamId: payload.teamId });
return;
}
// If team already upgraded to a paid plan or proceeded to checkout, don't send email
const paidPlans = [
"pro",
"business",
"datarooms",
"datarooms-plus",
"datarooms-premium",
];
const isPaidPlan = paidPlans.some((plan) => team.plan.includes(plan));
if (isPaidPlan) {
logger.info("Team already on paid plan - no upgrade intent email needed", {
teamId: payload.teamId,
plan: team.plan,
});
return;
}
// Check if user proceeded to checkout (by checking if they have checkout session started)
const checkoutKey = `checkout:started:${payload.teamId}`;
const checkoutStarted = await redis.get(checkoutKey);
if (checkoutStarted) {
logger.info("User already proceeded to checkout - no upgrade intent email needed", {
teamId: payload.teamId,
});
return;
}
await sendUpgradeIntentEmail({
user: { email: payload.to, name: payload.name },
triggers: payload.triggers,
});
logger.info("Upgrade intent email sent", {
to: payload.to,
triggers: payload.triggers,
});
// Clear the upgrade clicks and triggers after sending email
const upgradeClicksKey = `upgrade:clicks:${payload.teamId}`;
const upgradeTriggersKey = `upgrade:triggers:${payload.teamId}`;
await redis.del(upgradeClicksKey);
await redis.del(upgradeTriggersKey);
} catch (error) {
logger.error("Error sending upgrade intent email", { error });
return;
}
},
});
@@ -0,0 +1,156 @@
import { NextApiRequest, NextApiResponse } from "next";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { redis } from "@/lib/redis";
import { sendUpgradeIntentEmailTask } from "@/lib/trigger/send-scheduled-email";
import { CustomUser } from "@/lib/types";
import { authOptions } from "../../../auth/[...nextauth]";
export const config = {
supportsResponseStreaming: true,
};
const THREE_DAYS_IN_SECONDS = 3 * 24 * 60 * 60;
const REQUIRED_CLICKS = 3;
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method === "POST") {
const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).end("Unauthorized");
}
const { teamId } = req.query as { teamId: string };
const { trigger } = req.body as { trigger?: string };
const {
id: userId,
email: userEmail,
name: userName,
} = session.user as CustomUser;
// Verify user belongs to team
const userTeam = await prisma.userTeam.findFirst({
where: {
teamId,
userId,
},
});
if (!userTeam) {
return res.status(404).end("Team not found");
}
// Check if team is already on a paid plan
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { plan: true },
});
if (!team) {
return res.status(404).end("Team not found");
}
const paidPlans = [
"pro",
"business",
"datarooms",
"datarooms-plus",
"datarooms-premium",
];
const isPaidPlan = paidPlans.some((plan) => team.plan.includes(plan));
if (isPaidPlan) {
// Team already on paid plan, no need to track
return res.status(200).json({ tracked: false, reason: "already_paid" });
}
const upgradeClicksKey = `upgrade:clicks:${teamId}`;
const upgradeTriggersKey = `upgrade:triggers:${teamId}`;
const now = Date.now();
const threeDaysAgo = now - THREE_DAYS_IN_SECONDS * 1000;
// Get existing clicks
const existingClicks = await redis.zrange(upgradeClicksKey, 0, -1, {
withScores: true,
});
// Filter clicks within the last 3 days
const recentClicks: number[] = [];
for (let i = 0; i < existingClicks.length; i += 2) {
const timestamp = existingClicks[i + 1] as number;
if (timestamp > threeDaysAgo) {
recentClicks.push(timestamp);
}
}
// Add new click
await redis.zadd(upgradeClicksKey, { score: now, member: now.toString() });
// Store the trigger if provided
if (trigger) {
await redis.sadd(upgradeTriggersKey, trigger);
await redis.expire(upgradeTriggersKey, THREE_DAYS_IN_SECONDS);
}
// Remove old clicks (older than 3 days)
await redis.zremrangebyscore(upgradeClicksKey, 0, threeDaysAgo);
// Set expiry on the key (3 days from now)
await redis.expire(upgradeClicksKey, THREE_DAYS_IN_SECONDS);
const totalRecentClicks = recentClicks.length + 1;
// If this is the 3rd click (or more) within 3 days, schedule the email
if (totalRecentClicks >= REQUIRED_CLICKS) {
// Check if we already scheduled an email for this session
const emailScheduledKey = `upgrade:email-scheduled:${teamId}`;
const alreadyScheduled = await redis.get(emailScheduledKey);
if (!alreadyScheduled) {
// Get all triggers the user clicked on
const triggers = (await redis.smembers(upgradeTriggersKey)) as string[];
// Mark as scheduled
await redis.set(emailScheduledKey, "1", { ex: THREE_DAYS_IN_SECONDS });
// Schedule email for 1 day after the last click
waitUntil(
sendUpgradeIntentEmailTask.trigger(
{
to: userEmail as string,
name: userName as string,
teamId,
triggers,
},
{
delay: "1d",
},
),
);
return res.status(200).json({
tracked: true,
clicks: totalRecentClicks,
triggers,
emailScheduled: true,
});
}
}
return res.status(200).json({
tracked: true,
clicks: totalRecentClicks,
emailScheduled: false,
});
} else {
res.setHeader("Allow", ["POST"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
+22 -1
View File
@@ -10,6 +10,8 @@ import { getServerSession } from "next-auth/next";
import { identifyUser, trackAnalytics } from "@/lib/analytics";
import { getDubDiscountForExternalUserId } from "@/lib/dub";
import prisma from "@/lib/prisma";
import { redis } from "@/lib/redis";
import { sendAbandonedCheckoutEmailTask } from "@/lib/trigger/send-scheduled-email";
import { CustomUser } from "@/lib/types";
import { getIpAddress } from "@/lib/utils/ip";
@@ -52,7 +54,7 @@ export default async function handle(
applyYearlyDiscount?: string;
};
const { id: userId, email: userEmail } = session.user as CustomUser;
const { id: userId, email: userEmail, name: userName } = session.user as CustomUser;
const team = await prisma.team.findUnique({
where: {
@@ -164,6 +166,10 @@ export default async function handle(
});
}
// Mark that user proceeded to checkout (for upgrade intent email check)
const checkoutStartedKey = `checkout:started:${teamId}`;
await redis.set(checkoutStartedKey, "1", { ex: 3 * 24 * 60 * 60 }); // Expires in 3 days
waitUntil(
Promise.all([
identifyUser(userEmail ?? userId),
@@ -175,6 +181,21 @@ export default async function handle(
]),
);
// Schedule abandoned checkout email to be sent in 1 hour
// The task will check if the team has already upgraded before sending
waitUntil(
sendAbandonedCheckoutEmailTask.trigger(
{
to: userEmail as string,
name: userName as string,
teamId,
},
{
delay: "1h",
},
),
);
return res.status(200).json(stripeSession);
} else {
// We only allow POST requests