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: {
|
automatic_tax: {
|
||||||
enabled: true,
|
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) {
|
if (session.url) {
|
||||||
|
|
|
@ -4,10 +4,13 @@ import { prisma } from "@rallly/database";
|
||||||
import { posthog } from "@rallly/posthog/server";
|
import { posthog } from "@rallly/posthog/server";
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { waitUntil } from "@vercel/functions";
|
import { waitUntil } from "@vercel/functions";
|
||||||
|
import { kv } from "@vercel/kv";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getEmailClient } from "@/utils/emails";
|
||||||
|
|
||||||
const checkoutMetadataSchema = z.object({
|
const checkoutMetadataSchema = z.object({
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
});
|
});
|
||||||
|
@ -206,6 +209,71 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
break;
|
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:
|
default:
|
||||||
Sentry.captureException(new Error(`Unhandled event type: ${event.type}`));
|
Sentry.captureException(new Error(`Unhandled event type: ${event.type}`));
|
||||||
// Unexpected event type
|
// Unexpected event type
|
||||||
|
|
|
@ -20,7 +20,7 @@ export const getEmailClient = (locale?: string) => {
|
||||||
config: {
|
config: {
|
||||||
logoUrl: isSelfHosted
|
logoUrl: isSelfHosted
|
||||||
? absoluteUrl("/images/rallly-logo-mark.png")
|
? 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(),
|
baseUrl: absoluteUrl(),
|
||||||
domain: absoluteUrl().replace(/(^\w+:|^)\/\//, ""),
|
domain: absoluteUrl().replace(/(^\w+:|^)\/\//, ""),
|
||||||
supportEmail: env.SUPPORT_EMAIL,
|
supportEmail: env.SUPPORT_EMAIL,
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"normalize-subscription-metadata": "dotenv -e ../../.env -- tsx ./src/scripts/normalize-metadata.ts",
|
"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",
|
"type-check": "tsc --pretty --noEmit",
|
||||||
"lint": "eslint ./src"
|
"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_button": "Verify Email Address",
|
||||||
"changeEmailRequest_subject": "Verify your new 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_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 = {
|
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",
|
baseUrl: "https://rallly.co",
|
||||||
domain: "rallly.co",
|
domain: "rallly.co",
|
||||||
supportEmail: "support@rallly.co",
|
supportEmail: "support@rallly.co",
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { darkTextColor, fontFamily, Link, Text } from "./styled-components";
|
||||||
export interface EmailLayoutProps {
|
export interface EmailLayoutProps {
|
||||||
preview: string;
|
preview: string;
|
||||||
ctx: EmailContext;
|
ctx: EmailContext;
|
||||||
|
poweredBy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerStyles = {
|
const containerStyles = {
|
||||||
|
@ -30,6 +31,7 @@ export const EmailLayout = ({
|
||||||
preview,
|
preview,
|
||||||
children,
|
children,
|
||||||
ctx,
|
ctx,
|
||||||
|
poweredBy = true,
|
||||||
}: React.PropsWithChildren<EmailLayoutProps>) => {
|
}: React.PropsWithChildren<EmailLayoutProps>) => {
|
||||||
const { logoUrl } = ctx;
|
const { logoUrl } = ctx;
|
||||||
return (
|
return (
|
||||||
|
@ -48,23 +50,25 @@ export const EmailLayout = ({
|
||||||
alt="Rallly Logo"
|
alt="Rallly Logo"
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
<Section style={{ marginTop: 32 }}>
|
{poweredBy ? (
|
||||||
<Text light={true}>
|
<Section>
|
||||||
<Trans
|
<Text light={true}>
|
||||||
i18n={ctx.i18n}
|
<Trans
|
||||||
t={ctx.t}
|
i18n={ctx.i18n}
|
||||||
i18nKey="common_poweredBy"
|
t={ctx.t}
|
||||||
ns="emails"
|
i18nKey="common_poweredBy"
|
||||||
defaults="Powered by <a>{{domain}}</a>"
|
ns="emails"
|
||||||
values={{ domain: "rallly.co" }}
|
defaults="Powered by <a>{{domain}}</a>"
|
||||||
components={{
|
values={{ domain: "rallly.co" }}
|
||||||
a: (
|
components={{
|
||||||
<Link href="https://rallly.co?utm_source=email&utm_medium=transactional" />
|
a: (
|
||||||
),
|
<Link href="https://rallly.co?utm_source=email&utm_medium=transactional" />
|
||||||
}}
|
),
|
||||||
/>
|
}}
|
||||||
</Text>
|
/>
|
||||||
</Section>
|
</Text>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
</Container>
|
</Container>
|
||||||
</Body>
|
</Body>
|
||||||
</Html>
|
</Html>
|
||||||
|
|
|
@ -16,6 +16,7 @@ import type { EmailContext } from "../types";
|
||||||
export const lightTextColor = "#4B5563";
|
export const lightTextColor = "#4B5563";
|
||||||
export const darkTextColor = "#1F2937";
|
export const darkTextColor = "#1F2937";
|
||||||
export const borderColor = "#E2E8F0";
|
export const borderColor = "#E2E8F0";
|
||||||
|
|
||||||
export const Text = (
|
export const Text = (
|
||||||
props: TextProps & { light?: boolean; small?: boolean },
|
props: TextProps & { light?: boolean; small?: boolean },
|
||||||
) => {
|
) => {
|
||||||
|
@ -48,14 +49,15 @@ export const Button = (props: React.ComponentProps<typeof UnstyledButton>) => {
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#4F46E5",
|
backgroundColor: "#4F46E5",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
padding: "12px 14px",
|
padding: "14px",
|
||||||
fontFamily,
|
fontFamily,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
display: "block",
|
display: "block",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
fontSize: "16px",
|
fontSize: "14px",
|
||||||
|
fontWeight: "bold",
|
||||||
color: "white",
|
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 = {
|
export const trackingWide = {
|
||||||
letterSpacing: 2,
|
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> = {
|
type SendEmailOptions<T extends TemplateName> = {
|
||||||
to: string;
|
to: string;
|
||||||
|
from?: {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
};
|
||||||
props: TemplateProps<T>;
|
props: TemplateProps<T>;
|
||||||
attachments?: Mail.Options["attachments"];
|
attachments?: Mail.Options["attachments"];
|
||||||
};
|
};
|
||||||
|
@ -106,7 +110,7 @@ export class EmailClient {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.sendEmail({
|
await this.sendEmail({
|
||||||
from: this.config.mail.from,
|
from: options.from || this.config.mail.from,
|
||||||
to: options.to,
|
to: options.to,
|
||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { AbandonedCheckoutEmail } from "./templates/abandoned-checkout";
|
||||||
import { ChangeEmailRequest } from "./templates/change-email-request";
|
import { ChangeEmailRequest } from "./templates/change-email-request";
|
||||||
import { FinalizeHostEmail } from "./templates/finalized-host";
|
import { FinalizeHostEmail } from "./templates/finalized-host";
|
||||||
import { FinalizeParticipantEmail } from "./templates/finalized-participant";
|
import { FinalizeParticipantEmail } from "./templates/finalized-participant";
|
||||||
|
@ -19,6 +20,7 @@ const templates = {
|
||||||
NewPollEmail,
|
NewPollEmail,
|
||||||
RegisterEmail,
|
RegisterEmail,
|
||||||
ChangeEmailRequest,
|
ChangeEmailRequest,
|
||||||
|
AbandonedCheckoutEmail,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const emailTemplates = Object.keys(templates) as TemplateName[];
|
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