mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-12 22:17:28 +02:00
✨ Send recovery url to users with expired checkout sessions (#1555)
This commit is contained in:
parent
5437b91c10
commit
9fdd5f3ea3
13 changed files with 341 additions and 23 deletions
|
@ -103,6 +103,13 @@ export async function POST(request: NextRequest) {
|
|||
automatic_tax: {
|
||||
enabled: true,
|
||||
},
|
||||
expires_at: Math.floor(Date.now() / 1000) + 30 * 60, // 30 minutes
|
||||
after_expiration: {
|
||||
recovery: {
|
||||
enabled: true,
|
||||
allow_promotion_codes: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (session.url) {
|
||||
|
|
|
@ -4,10 +4,13 @@ import { prisma } from "@rallly/database";
|
|||
import { posthog } from "@rallly/posthog/server";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
import { kv } from "@vercel/kv";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getEmailClient } from "@/utils/emails";
|
||||
|
||||
const checkoutMetadataSchema = z.object({
|
||||
userId: z.string(),
|
||||
});
|
||||
|
@ -206,6 +209,71 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
break;
|
||||
}
|
||||
case "checkout.session.expired": {
|
||||
console.info("Checkout session expired");
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
// When a Checkout Session expires, the customer's email isn't returned in
|
||||
// the webhook payload unless they give consent for promotional content
|
||||
const email = session.customer_details?.email;
|
||||
const recoveryUrl = session.after_expiration?.recovery?.url;
|
||||
const userId = session.metadata?.userId;
|
||||
if (!userId) {
|
||||
console.info("No user ID found in Checkout Session metadata");
|
||||
Sentry.captureMessage("No user ID found in Checkout Session metadata");
|
||||
break;
|
||||
}
|
||||
// Do nothing if the Checkout Session has no email or recovery URL
|
||||
if (!email || !recoveryUrl) {
|
||||
console.info("No email or recovery URL found in Checkout Session");
|
||||
Sentry.captureMessage(
|
||||
"No email or recovery URL found in Checkout Session",
|
||||
);
|
||||
break;
|
||||
}
|
||||
const promoEmailKey = `promo_email_sent:${email}`;
|
||||
// Track that a promotional email opportunity has been shown to this user
|
||||
const hasReceivedPromo = await kv.get(promoEmailKey);
|
||||
console.info("Has received promo", hasReceivedPromo);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
locale: true,
|
||||
subscription: {
|
||||
select: {
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isPro = !!user?.subscription?.active;
|
||||
|
||||
// Avoid spamming people who abandon Checkout multiple times
|
||||
if (user && !hasReceivedPromo && !isPro) {
|
||||
console.info("Sending abandoned checkout email");
|
||||
// Set the flag with a 30-day expiration (in seconds)
|
||||
await kv.set(promoEmailKey, 1, { ex: 30 * 24 * 60 * 60, nx: true });
|
||||
getEmailClient(user.locale ?? undefined).sendTemplate(
|
||||
"AbandonedCheckoutEmail",
|
||||
{
|
||||
to: email,
|
||||
from: {
|
||||
name: "Luke from Rallly",
|
||||
address: "luke@rallly.co",
|
||||
},
|
||||
props: {
|
||||
name: session.customer_details?.name ?? undefined,
|
||||
recoveryUrl,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
Sentry.captureException(new Error(`Unhandled event type: ${event.type}`));
|
||||
// Unexpected event type
|
||||
|
|
|
@ -20,7 +20,7 @@ export const getEmailClient = (locale?: string) => {
|
|||
config: {
|
||||
logoUrl: isSelfHosted
|
||||
? absoluteUrl("/images/rallly-logo-mark.png")
|
||||
: "https://rallly-public.s3.amazonaws.com/images/rallly-logo-mark.png",
|
||||
: "https://d39ixtfgglw55o.cloudfront.net/images/rallly-logo-mark.png",
|
||||
baseUrl: absoluteUrl(),
|
||||
domain: absoluteUrl().replace(/(^\w+:|^)\/\//, ""),
|
||||
supportEmail: env.SUPPORT_EMAIL,
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
},
|
||||
"scripts": {
|
||||
"normalize-subscription-metadata": "dotenv -e ../../.env -- tsx ./src/scripts/normalize-metadata.ts",
|
||||
"checkout-expiry": "dotenv -e ../../.env -- tsx ./src/scripts/checkout-expiry.ts",
|
||||
"type-check": "tsc --pretty --noEmit",
|
||||
"lint": "eslint ./src"
|
||||
},
|
||||
|
|
39
packages/billing/src/scripts/checkout-expiry.ts
Normal file
39
packages/billing/src/scripts/checkout-expiry.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { getProPricing, stripe } from "../lib/stripe";
|
||||
|
||||
async function createAndExpireCheckout() {
|
||||
const pricingData = await getProPricing();
|
||||
console.info("📝 Creating checkout session...");
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
success_url: "http://localhost:3000/success",
|
||||
cancel_url: "http://localhost:3000/cancel",
|
||||
mode: "subscription",
|
||||
customer_email: "dev@rallly.co",
|
||||
line_items: [
|
||||
{
|
||||
price: pricingData.monthly.id,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
userId: "free-user",
|
||||
},
|
||||
expires_at: Math.floor(Date.now() / 1000) + 30 * 60,
|
||||
after_expiration: {
|
||||
recovery: {
|
||||
enabled: true,
|
||||
allow_promotion_codes: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.info("💳 Checkout session created:", session.id);
|
||||
console.info("🔗 Checkout URL:", session.url);
|
||||
|
||||
console.info("⏳ Expiring checkout session...");
|
||||
await stripe.checkout.sessions.expire(session.id);
|
||||
|
||||
console.info("✨ Done! Check Stripe Dashboard for events");
|
||||
console.info("🔍 Dashboard URL: https://dashboard.stripe.com/test/events");
|
||||
}
|
||||
|
||||
createAndExpireCheckout();
|
|
@ -53,5 +53,14 @@
|
|||
"changeEmailRequest_button": "Verify Email Address",
|
||||
"changeEmailRequest_subject": "Verify your new email address",
|
||||
"changeEmailRequest_text3": "This link will expire in 10 minutes. If you did not request this change, please ignore this email.",
|
||||
"changeEmailRequest_text1": "We've received a request to change the email address for your account from <b>{{fromEmail}}</b> to <b>{{toEmail}}</b>."
|
||||
"changeEmailRequest_text1": "We've received a request to change the email address for your account from <b>{{fromEmail}}</b> to <b>{{toEmail}}</b>.",
|
||||
"abandoned_checkout_name": "Hey {{name}},",
|
||||
"abandoned_checkout_noname": "Hey there,",
|
||||
"abandoned_checkout_content": "I noticed you were checking out <b>Rallly Pro</b> earlier. I wanted to reach out to see if you had any questions or needed help with anything.",
|
||||
"abandoned_checkout_offer": "To help you get started, you can get <b>{{discount}}% off</b> your first year. Just use the code below when you check out:",
|
||||
"abandoned_checkout_button": "Upgrade to Rallly Pro",
|
||||
"abandoned_checkout_support": "If you have any questions about Rallly Pro or need help with anything at all, just reply to this email. I'm here to help!",
|
||||
"abandoned_checkout_preview": "Exclusive offer: Get 20% off your first year of Rallly Pro!",
|
||||
"abandoned_checkout_subject": "Get 20% off your first year of Rallly Pro",
|
||||
"abandoned_checkout_signoff": "Best regards,"
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ i18nInstance.init({
|
|||
});
|
||||
|
||||
export const previewEmailContext: EmailContext = {
|
||||
logoUrl: "https://rallly-public.s3.amazonaws.com/images/rallly-logo-mark.png",
|
||||
logoUrl: "https://d39ixtfgglw55o.cloudfront.net/images/rallly-logo-mark.png",
|
||||
baseUrl: "https://rallly.co",
|
||||
domain: "rallly.co",
|
||||
supportEmail: "support@rallly.co",
|
||||
|
|
|
@ -15,6 +15,7 @@ import { darkTextColor, fontFamily, Link, Text } from "./styled-components";
|
|||
export interface EmailLayoutProps {
|
||||
preview: string;
|
||||
ctx: EmailContext;
|
||||
poweredBy?: boolean;
|
||||
}
|
||||
|
||||
const containerStyles = {
|
||||
|
@ -30,6 +31,7 @@ export const EmailLayout = ({
|
|||
preview,
|
||||
children,
|
||||
ctx,
|
||||
poweredBy = true,
|
||||
}: React.PropsWithChildren<EmailLayoutProps>) => {
|
||||
const { logoUrl } = ctx;
|
||||
return (
|
||||
|
@ -48,7 +50,8 @@ export const EmailLayout = ({
|
|||
alt="Rallly Logo"
|
||||
/>
|
||||
{children}
|
||||
<Section style={{ marginTop: 32 }}>
|
||||
{poweredBy ? (
|
||||
<Section>
|
||||
<Text light={true}>
|
||||
<Trans
|
||||
i18n={ctx.i18n}
|
||||
|
@ -65,6 +68,7 @@ export const EmailLayout = ({
|
|||
/>
|
||||
</Text>
|
||||
</Section>
|
||||
) : null}
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
|
|
|
@ -16,6 +16,7 @@ import type { EmailContext } from "../types";
|
|||
export const lightTextColor = "#4B5563";
|
||||
export const darkTextColor = "#1F2937";
|
||||
export const borderColor = "#E2E8F0";
|
||||
|
||||
export const Text = (
|
||||
props: TextProps & { light?: boolean; small?: boolean },
|
||||
) => {
|
||||
|
@ -48,14 +49,15 @@ export const Button = (props: React.ComponentProps<typeof UnstyledButton>) => {
|
|||
style={{
|
||||
backgroundColor: "#4F46E5",
|
||||
borderRadius: "4px",
|
||||
padding: "12px 14px",
|
||||
padding: "14px",
|
||||
fontFamily,
|
||||
boxSizing: "border-box",
|
||||
display: "block",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
textAlign: "center",
|
||||
fontSize: "16px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
color: "white",
|
||||
}}
|
||||
/>
|
||||
|
@ -150,6 +152,36 @@ export const Card = (props: SectionProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const Signature = () => {
|
||||
return (
|
||||
<Section>
|
||||
<UnstyledText
|
||||
style={{
|
||||
fontSize: 16,
|
||||
margin: 0,
|
||||
fontWeight: "bold",
|
||||
color: darkTextColor,
|
||||
fontFamily,
|
||||
}}
|
||||
>
|
||||
Luke Vella
|
||||
</UnstyledText>
|
||||
<UnstyledText
|
||||
style={{ fontSize: 16, margin: 0, color: lightTextColor, fontFamily }}
|
||||
>
|
||||
Founder
|
||||
</UnstyledText>
|
||||
<img
|
||||
src="https://d39ixtfgglw55o.cloudfront.net/images/luke.jpg"
|
||||
alt="Luke Vella"
|
||||
style={{ borderRadius: "50%", marginTop: 16 }}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export const trackingWide = {
|
||||
letterSpacing: 2,
|
||||
};
|
||||
|
|
12
packages/emails/src/previews/abandoned-checkout.tsx
Normal file
12
packages/emails/src/previews/abandoned-checkout.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { previewEmailContext } from "../components/email-context";
|
||||
import { AbandonedCheckoutEmail } from "../templates/abandoned-checkout";
|
||||
|
||||
export default function AbandonedCheckoutEmailPreview() {
|
||||
return (
|
||||
<AbandonedCheckoutEmail
|
||||
ctx={previewEmailContext}
|
||||
recoveryUrl="https://example.com"
|
||||
name="John Doe"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -12,6 +12,10 @@ import type { TemplateComponent, TemplateName, TemplateProps } from "./types";
|
|||
|
||||
type SendEmailOptions<T extends TemplateName> = {
|
||||
to: string;
|
||||
from?: {
|
||||
name: string;
|
||||
address: string;
|
||||
};
|
||||
props: TemplateProps<T>;
|
||||
attachments?: Mail.Options["attachments"];
|
||||
};
|
||||
|
@ -106,7 +110,7 @@ export class EmailClient {
|
|||
|
||||
try {
|
||||
await this.sendEmail({
|
||||
from: this.config.mail.from,
|
||||
from: options.from || this.config.mail.from,
|
||||
to: options.to,
|
||||
subject,
|
||||
html,
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { AbandonedCheckoutEmail } from "./templates/abandoned-checkout";
|
||||
import { ChangeEmailRequest } from "./templates/change-email-request";
|
||||
import { FinalizeHostEmail } from "./templates/finalized-host";
|
||||
import { FinalizeParticipantEmail } from "./templates/finalized-participant";
|
||||
|
@ -19,6 +20,7 @@ const templates = {
|
|||
NewPollEmail,
|
||||
RegisterEmail,
|
||||
ChangeEmailRequest,
|
||||
AbandonedCheckoutEmail,
|
||||
};
|
||||
|
||||
export const emailTemplates = Object.keys(templates) as TemplateName[];
|
||||
|
|
140
packages/emails/src/templates/abandoned-checkout.tsx
Normal file
140
packages/emails/src/templates/abandoned-checkout.tsx
Normal file
|
@ -0,0 +1,140 @@
|
|||
import { Section } from "@react-email/components";
|
||||
import { Trans } from "react-i18next/TransWithoutContext";
|
||||
|
||||
import { EmailLayout } from "../components/email-layout";
|
||||
import { Button, Card, Signature, Text } from "../components/styled-components";
|
||||
import type { EmailContext } from "../types";
|
||||
|
||||
interface AbandonedCheckoutEmailProps {
|
||||
recoveryUrl: string;
|
||||
name?: string;
|
||||
ctx: EmailContext;
|
||||
}
|
||||
|
||||
export const AbandonedCheckoutEmail = ({
|
||||
recoveryUrl,
|
||||
name,
|
||||
ctx,
|
||||
}: AbandonedCheckoutEmailProps) => {
|
||||
return (
|
||||
<EmailLayout
|
||||
ctx={ctx}
|
||||
poweredBy={false}
|
||||
preview={ctx.t("abandoned_checkout_preview", {
|
||||
defaultValue:
|
||||
"Exclusive offer: Get 20% off your first year of Rallly Pro!",
|
||||
ns: "emails",
|
||||
})}
|
||||
>
|
||||
{name ? (
|
||||
<Text>
|
||||
<Trans
|
||||
t={ctx.t}
|
||||
i18n={ctx.i18n}
|
||||
i18nKey="abandoned_checkout_name"
|
||||
defaults="Hey {{name}},"
|
||||
values={{ name }}
|
||||
ns="emails"
|
||||
/>
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
<Trans
|
||||
t={ctx.t}
|
||||
i18n={ctx.i18n}
|
||||
i18nKey="abandoned_checkout_noname"
|
||||
defaults="Hey there,"
|
||||
ns="emails"
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
<Text>
|
||||
<Trans
|
||||
t={ctx.t}
|
||||
i18n={ctx.i18n}
|
||||
i18nKey="abandoned_checkout_content"
|
||||
defaults="I noticed you were exploring <b>Rallly Pro</b> and wanted to personally reach out. I'd love to hear what features caught your interest and answer any questions you might have."
|
||||
ns="emails"
|
||||
components={{
|
||||
b: <b />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<Text>
|
||||
<Trans
|
||||
t={ctx.t}
|
||||
i18n={ctx.i18n}
|
||||
i18nKey="abandoned_checkout_offer"
|
||||
defaults="To help you get started, I'd like to offer you <b>{{discount}}% off your first year</b> with Rallly Pro. Simply use this code during checkout:"
|
||||
ns="emails"
|
||||
values={{
|
||||
discount: 20,
|
||||
}}
|
||||
components={{
|
||||
b: <b />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<Section style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
<Card>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontFamily: "monospace",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
GETPRO1Y20
|
||||
</Text>
|
||||
</Card>
|
||||
<Button href={recoveryUrl} id="recoveryUrl">
|
||||
<Trans
|
||||
i18n={ctx.i18n}
|
||||
t={ctx.t}
|
||||
i18nKey="abandoned_checkout_button"
|
||||
defaults="Upgrade to Rallly Pro"
|
||||
ns="emails"
|
||||
/>
|
||||
</Button>
|
||||
</Section>
|
||||
<Section>
|
||||
<Text>
|
||||
<Trans
|
||||
i18n={ctx.i18n}
|
||||
t={ctx.t}
|
||||
i18nKey="abandoned_checkout_support"
|
||||
defaults="Have questions or need assistance? Just reply to this email."
|
||||
ns="emails"
|
||||
/>
|
||||
</Text>
|
||||
</Section>
|
||||
<Section>
|
||||
<Text>
|
||||
<Trans
|
||||
i18n={ctx.i18n}
|
||||
t={ctx.t}
|
||||
i18nKey="abandoned_checkout_signoff"
|
||||
defaults="Best regards,"
|
||||
ns="emails"
|
||||
/>
|
||||
</Text>
|
||||
</Section>
|
||||
<Signature />
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
AbandonedCheckoutEmail.getSubject = (
|
||||
props: AbandonedCheckoutEmailProps,
|
||||
ctx: EmailContext,
|
||||
) => {
|
||||
return (
|
||||
"🎉 " +
|
||||
ctx.t("abandoned_checkout_subject", {
|
||||
defaultValue: "Get 20% off your first year of Rallly Pro",
|
||||
ns: "emails",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export default AbandonedCheckoutEmail;
|
Loading…
Add table
Add a link
Reference in a new issue