💸 Update webhook and checkout route (#1729)

This commit is contained in:
Luke Vella 2025-05-26 15:28:47 +01:00 committed by GitHub
parent 061989d241
commit 0512e07539
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 621 additions and 440 deletions

View file

@ -86,6 +86,11 @@ const nextConfig = {
destination: createAppUrl("/register"), destination: createAppUrl("/register"),
permanent: true, permanent: true,
}, },
{
source: "/buy-license/:product",
destination: createAppUrl("/api/stripe/buy-license?product=:product"),
permanent: false,
},
{ {
source: "/S17JJrRWc", source: "/S17JJrRWc",
destination: "/", destination: "/",

View file

@ -25,5 +25,13 @@
"status": "Status", "status": "Status",
"when2MeetAlternative": "When2Meet Alternative", "when2MeetAlternative": "When2Meet Alternative",
"meetingPoll": "Meeting Poll", "meetingPoll": "Meeting Poll",
"signUp": "Sign Up" "signUp": "Sign Up",
"licensingThankYouTitle": "Thank You for Your Purchase!",
"licensingThankYouSubtitle": "Your Rallly self-hosted license is confirmed. We're excited to have you on board!",
"licensingThankYouLicense": "Next Steps",
"licensingThankYouLicenseEmailed": "Your license key has been sent to the email address you provided during checkout. Please check your inbox.",
"licensingThankYouNextStepsInstallation": "Installation Guide",
"licensingThankYouNextStepsApplyLicense": "How to Apply Your License",
"licensingThankYouSupportPrompt": "Need help or have questions? Visit our <0>Support Center</0> or <1>contact us</1>.",
"licensingThankYouGoHomeLink": "Return to Home"
} }

View file

@ -14,31 +14,9 @@
"statsPollsCreated": "{count, number, ::compact-short}+ polls created", "statsPollsCreated": "{count, number, ::compact-short}+ polls created",
"statsLanguagesSupported": "10+ languages supported", "statsLanguagesSupported": "10+ languages supported",
"hint": "It's free! No login required.", "hint": "It's free! No login required.",
"doodleAlternative": "The Best Free Doodle Alternative",
"doodleAlternativeDescription": "Rallly is the Doodle alternative that everyone is looking for. Thousands of users have already made the switch and are now enjoying professional ad-free meeting polls in an intuitive and easy-to-use interface. ",
"availabilityPollCta": "Create an Availability Poll",
"availabilityPollMetaTitle": "Availability Poll | Streamline Scheduling with Rallly",
"availabilityPollMetaDescription": "Schedule meetings and events seamlessly with Rallly's Availability Poll. Ensure everyone's availability is considered for a smooth and efficient planning experience.",
"availabilityPollTitle": "Availability Polls",
"availabilityPollDescription": "Tired of struggling to find a meeting time that works for everyone? Streamline your scheduling with an availability poll - a powerful tool designed to simplify and optimize your event and meeting planning.",
"createAPoll": "Create a Meeting Poll", "createAPoll": "Create a Meeting Poll",
"doodleAlternativeMetaTitle": "Best Free Doodle Alternative | Rallly",
"doodleAlternativeMetaDescription": "Looking for a Doodle alternative? Try Rallly! It's free, easy to use, and doesn't require an account.",
"createASchedulingPoll": "Create a Scheduling Poll",
"freeSchedulingPollMetaTitle": "Create a Free Scheduling Poll Instantly | No Account Required",
"freeSchedulingPollMetaDescription": "Create a free scheduling poll in seconds. Ideal for organizing meetings, events, conferences, sports teams and more.",
"freeSchedulingPollTitle": "Find a date for your next event",
"freeSchedulingPollDescription": "Rallly let's you create beautiful and easy to use scheduling polls so you can find the best time for your next event.",
"new": "New", "new": "New",
"metaTitle": "Rallly: Group Scheduling Tool", "metaTitle": "Rallly: Group Scheduling Tool",
"metaDescription": "Rallly is the fastest and easiest scheduling and collaboration tool. Create a meeting poll in seconds, no login required.", "metaDescription": "Rallly is the fastest and easiest scheduling and collaboration tool. Create a meeting poll in seconds, no login required.",
"when2meetAlternativeMetaTitle": "Best When2Meet Alternative: Rallly",
"when2meetAlternativeMetaDescription": "Find a better way to schedule meetings with Rallly, the top free alternative to When2Meet. Easy to use and free.",
"when2meetAlternative": "Still using When2Meet?",
"when2meetAlternativeDescription": "Create professional, ad-free meetings polls for free with Rallly.",
"meetingPoll": "Create professional meetings polls with Rallly",
"meetingPollDescription": "Meeting polls are a great way to get people's availability. Rallly lets you create beautiful meeting polls with ease.",
"meetingPollMetaTitle": "Meeting Poll",
"meetingPollMetaDescription": "Easily schedule meetings with our poll feature, ensuring everyone's availability.",
"quickCreateBlog": "Introducing Quick Create" "quickCreateBlog": "Introducing Quick Create"
} }

View file

@ -1,12 +1,13 @@
import { Trans } from "react-i18next/TransWithoutContext"; import { Trans } from "react-i18next/TransWithoutContext";
import type { URLParams } from "@/app/[locale]/types";
import { getTranslation } from "@/i18n/server"; import { getTranslation } from "@/i18n/server";
import { getAllPosts } from "@/lib/api"; import { getAllPosts } from "@/lib/api";
import { PostPreview } from "./post-preview"; import { PostPreview } from "./post-preview";
export default async function Page(props: { params: Promise<URLParams> }) { export default async function Page(props: {
params: Promise<{ locale: string }>;
}) {
const params = await props.params; const params = await props.params;
const { t } = await getTranslation(params.locale, "blog"); const { t } = await getTranslation(params.locale, "blog");
const allPosts = getAllPosts([ const allPosts = getAllPosts([

View file

@ -0,0 +1,134 @@
import languages from "@rallly/languages";
import { Button } from "@rallly/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import { Icon } from "@rallly/ui/icon";
import { MenuIcon } from "lucide-react";
import type { Viewport } from "next";
import Image from "next/image";
import Link from "next/link";
import { Trans } from "react-i18next/TransWithoutContext";
import { getTranslation } from "@/i18n/server";
import { linkToApp } from "@/lib/linkToApp";
import { Footer } from "./footer";
import { NavLink } from "./nav-link";
export async function generateStaticParams() {
return Object.keys(languages).map((locale) => ({ locale }));
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
};
export default async function Root(props: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const params = await props.params;
const { locale } = params;
const { children } = props;
const { t } = await getTranslation(locale, "common");
return (
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col space-y-8 p-4 sm:p-8">
<header className="flex w-full items-center">
<div className="flex grow items-center gap-x-12">
<Link className="inline-block rounded" href="/">
<Image src="/logo.svg" width={130} height={30} alt="rallly.co" />
</Link>
<nav className="hidden items-center gap-2 lg:flex">
<NavLink href="https://support.rallly.co/workflow/create">
<Trans t={t} i18nKey="howItWorks" defaults="How it Works" />
</NavLink>
<NavLink href="/pricing">
<Trans t={t} i18nKey="pricing" />
</NavLink>
<NavLink href="/blog">
<Trans t={t} i18nKey="blog" />
</NavLink>
<NavLink href="https://support.rallly.co">
<Trans t={t} i18nKey="support" />
</NavLink>
</nav>
</div>
<div className="flex items-center gap-4 sm:gap-8">
<div className="hidden items-center gap-2 sm:flex">
<Button variant="ghost" asChild>
<Link href={linkToApp("/login")}>
<Trans t={t} i18nKey="login" defaults="Login" />
</Link>
</Button>
<Button asChild variant="primary">
<Link href={linkToApp("/register")}>
<Trans t={t} i18nKey="signUp" defaults="Sign up" />
</Link>
</Button>
</div>
<div className="flex items-center justify-center lg:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost">
<Icon>
<MenuIcon />
</Icon>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" align="end" sideOffset={16}>
<DropdownMenuItem asChild>
<Link href="https://support.rallly.co/workflow/create">
<Trans t={t} i18nKey="howItWorks" defaults="How it Works" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/pricing">
<Trans t={t} i18nKey="pricing" defaults="Pricing" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/blog">
<Trans t={t} i18nKey="blog" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="https://support.rallly.co">
<Trans t={t} i18nKey="support" />
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="space-y-2">
<Button variant="secondary" className="w-full" asChild>
<Link href={linkToApp("/login")}>
<Trans t={t} i18nKey="login" defaults="Login" />
</Link>
</Button>
<Button variant="primary" className="w-full" asChild>
<Link href={linkToApp("/register")}>
<Trans t={t} i18nKey="signUp" defaults="Sign up" />
</Link>
</Button>
</DropdownMenuLabel>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
<section className="relative grow">{children}</section>
<hr className="border-transparent" />
<footer>
<Footer />
</footer>
</div>
);
}

View file

@ -1,54 +0,0 @@
import { Trans } from "react-i18next/TransWithoutContext";
import Bonus from "@/components/home/bonus";
import { MarketingHero } from "@/components/home/hero";
import { BigTestimonial, Marketing, MentionedBy } from "@/components/marketing";
import { getTranslation } from "@/i18n/server";
export default async function Page(props: {
params: Promise<{ locale: string }>;
}) {
const params = await props.params;
const { t } = await getTranslation(params.locale, "home");
return (
<Marketing>
<MarketingHero
title={t("availabilityPollTitle", {
ns: "home",
defaultValue: "Availability Polls",
})}
description={t("availabilityPollDescription", {
ns: "home",
defaultValue:
"Tired of struggling to find a meeting time that works for everyone? Streamline your scheduling with an availability poll - a powerful tool designed to simplify and optimize your event and meeting planning.",
})}
callToAction={
<Trans
t={t}
ns="home"
i18nKey="availabilityPollCta"
defaults="Create an Availability Poll"
/>
}
/>
<Bonus t={t} />
<BigTestimonial />
<MentionedBy />
</Marketing>
);
}
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}) {
const params = await props.params;
const { t } = await getTranslation(params.locale, "home");
return {
title: t("availabilityPollMetaTitle", {
ns: "home",
}),
description: t("availabilityPollMetaDescription", {
ns: "home",
}),
};
}

View file

@ -1,44 +0,0 @@
import { Trans } from "react-i18next/TransWithoutContext";
import Bonus from "@/components/home/bonus";
import { MarketingHero } from "@/components/home/hero";
import { BigTestimonial, Marketing, MentionedBy } from "@/components/marketing";
import { getTranslation } from "@/i18n/server";
export default async function Page(props: {
params: Promise<{ locale: string }>;
}) {
const params = await props.params;
const { t } = await getTranslation(params.locale, ["home"]);
return (
<Marketing>
<MarketingHero
title={t("doodleAlternative", {
ns: "home",
})}
description={t("doodleAlternativeDescription", {
ns: "home",
})}
callToAction={<Trans t={t} ns="home" i18nKey="createAPoll" />}
/>
<Bonus t={t} />
<BigTestimonial />
<MentionedBy />
</Marketing>
);
}
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}) {
const params = await props.params;
const { t } = await getTranslation(params.locale, "home");
return {
title: t("doodleAlternativeMetaTitle", {
ns: "home",
}),
description: t("doodleAlternativeMetaDescription", {
ns: "home",
}),
};
}

View file

@ -1,44 +0,0 @@
import { Trans } from "react-i18next/TransWithoutContext";
import Bonus from "@/components/home/bonus";
import { MarketingHero } from "@/components/home/hero";
import { BigTestimonial, Marketing, MentionedBy } from "@/components/marketing";
import { getTranslation } from "@/i18n/server";
export default async function Page(props: {
params: Promise<{ locale: string }>;
}) {
const params = await props.params;
const { t } = await getTranslation(params.locale, ["home"]);
return (
<Marketing>
<MarketingHero
title={t("freeSchedulingPollTitle", {
ns: "home",
})}
description={t("freeSchedulingPollDescription", {
ns: "home",
})}
callToAction={<Trans t={t} ns="home" i18nKey="createASchedulingPoll" />}
/>
<Bonus t={t} />
<BigTestimonial />
<MentionedBy />
</Marketing>
);
}
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}) {
const params = await props.params;
const { t } = await getTranslation(params.locale, "home");
return {
title: t("freeSchedulingPollMetaTitle", {
ns: "home",
}),
description: t("freeSchedulingPollMetaDescription", {
ns: "home",
}),
};
}

View file

@ -1,50 +0,0 @@
import { Trans } from "react-i18next/TransWithoutContext";
import Bonus from "@/components/home/bonus";
import { MarketingHero } from "@/components/home/hero";
import { BigTestimonial, Marketing, MentionedBy } from "@/components/marketing";
import { getTranslation } from "@/i18n/server";
export default async function Page(props: {
params: Promise<{ locale: string }>;
}) {
const params = await props.params;
const { t } = await getTranslation(params.locale, ["home"]);
return (
<Marketing>
<MarketingHero
title={t("meetingPoll", {
defaultValue: "Create professional meetings polls with Rallly",
ns: "home",
})}
description={t("meetingPollDescription", {
defaultValue:
"Meeting polls are a great way to get people's availability. Rallly lets you create beautiful meeting polls with ease.",
ns: "home",
})}
callToAction={<Trans t={t} ns="home" i18nKey="createAPoll" />}
/>
<Bonus t={t} />
<BigTestimonial />
<MentionedBy />
</Marketing>
);
}
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}) {
const params = await props.params;
const { t } = await getTranslation(params.locale, "home");
return {
title: t("meetingPollMetaTitle", {
ns: "home",
defaultValue: "Meeting Poll",
}),
description: t("meetingPollMetaDescription", {
ns: "home",
defaultValue:
"Easily schedule meetings with our poll feature, ensuring everyone's availability.",
}),
};
}

View file

@ -1,44 +0,0 @@
import { Trans } from "react-i18next/TransWithoutContext";
import Bonus from "@/components/home/bonus";
import { MarketingHero } from "@/components/home/hero";
import { BigTestimonial, Marketing, MentionedBy } from "@/components/marketing";
import { getTranslation } from "@/i18n/server";
export default async function Page(props: {
params: Promise<{ locale: string }>;
}) {
const params = await props.params;
const { t } = await getTranslation(params.locale, ["home"]);
return (
<Marketing>
<MarketingHero
title={t("when2meetAlternative", {
ns: "home",
})}
description={t("when2meetAlternativeDescription", {
ns: "home",
})}
callToAction={<Trans t={t} ns="home" i18nKey="createASchedulingPoll" />}
/>
<Bonus t={t} />
<BigTestimonial />
<MentionedBy />
</Marketing>
);
}
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}) {
const params = await props.params;
const { t } = await getTranslation(params.locale, "home");
return {
title: t("when2meetAlternativeMetaTitle", {
ns: "home",
}),
description: t("when2meetAlternativeMetaDescription", {
ns: "home",
}),
};
}

View file

@ -1,31 +1,12 @@
import "../../style.css"; import "../../style.css";
import languages from "@rallly/languages"; import languages from "@rallly/languages";
import { Button } from "@rallly/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import { Icon } from "@rallly/ui/icon";
import { Analytics } from "@vercel/analytics/react"; import { Analytics } from "@vercel/analytics/react";
import { MenuIcon } from "lucide-react";
import { LazyMotion, domAnimation } from "motion/react"; import { LazyMotion, domAnimation } from "motion/react";
import type { Viewport } from "next"; import type { Viewport } from "next";
import Image from "next/image";
import Link from "next/link";
import { Trans } from "react-i18next/TransWithoutContext";
import { sans } from "@/fonts/sans"; import { sans } from "@/fonts/sans";
import { I18nProvider } from "@/i18n/client/i18n-provider"; import { I18nProvider } from "@/i18n/client/i18n-provider";
import { getTranslation } from "@/i18n/server";
import { linkToApp } from "@/lib/linkToApp";
import { Footer } from "./footer";
import { NavLink } from "./nav-link";
export async function generateStaticParams() { export async function generateStaticParams() {
return Object.keys(languages).map((locale) => ({ locale })); return Object.keys(languages).map((locale) => ({ locale }));
@ -46,126 +27,11 @@ export default async function Root(props: {
const { children } = props; const { children } = props;
const { t } = await getTranslation(locale, "common");
return ( return (
<html lang={locale} className={sans.className}> <html lang={locale} className={sans.className}>
<body> <body>
<LazyMotion features={domAnimation}> <LazyMotion features={domAnimation}>
<I18nProvider locale={locale}> <I18nProvider locale={locale}>{children}</I18nProvider>
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col space-y-12 p-4 sm:p-8">
<header className="flex w-full items-center">
<div className="flex grow items-center gap-x-12">
<Link className="inline-block rounded" href="/">
<Image
src="/logo.svg"
width={130}
height={30}
alt="rallly.co"
/>
</Link>
<nav className="hidden items-center gap-2 lg:flex">
<NavLink href="https://support.rallly.co/workflow/create">
<Trans
t={t}
i18nKey="howItWorks"
defaults="How it Works"
/>
</NavLink>
<NavLink href="/pricing">
<Trans t={t} i18nKey="pricing" />
</NavLink>
<NavLink href="/blog">
<Trans t={t} i18nKey="blog" />
</NavLink>
<NavLink href="https://support.rallly.co">
<Trans t={t} i18nKey="support" />
</NavLink>
</nav>
</div>
<div className="flex items-center gap-4 sm:gap-8">
<div className="hidden items-center gap-2 sm:flex">
<Button variant="ghost" asChild>
<Link href={linkToApp("/login")}>
<Trans t={t} i18nKey="login" defaults="Login" />
</Link>
</Button>
<Button asChild variant="primary">
<Link href={linkToApp("/register")}>
<Trans t={t} i18nKey="signUp" defaults="Sign up" />
</Link>
</Button>
</div>
<div className="flex items-center justify-center lg:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost">
<Icon>
<MenuIcon />
</Icon>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48"
align="end"
sideOffset={16}
>
<DropdownMenuItem asChild>
<Link href="https://support.rallly.co/workflow/create">
<Trans
t={t}
i18nKey="howItWorks"
defaults="How it Works"
/>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/pricing">
<Trans t={t} i18nKey="pricing" defaults="Pricing" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/blog">
<Trans t={t} i18nKey="blog" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="https://support.rallly.co">
<Trans t={t} i18nKey="support" />
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="space-y-2">
<Button
variant="secondary"
className="w-full"
asChild
>
<Link href={linkToApp("/login")}>
<Trans t={t} i18nKey="login" defaults="Login" />
</Link>
</Button>
<Button variant="primary" className="w-full" asChild>
<Link href={linkToApp("/register")}>
<Trans
t={t}
i18nKey="signUp"
defaults="Sign up"
/>
</Link>
</Button>
</DropdownMenuLabel>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
<section className="relative grow">{children}</section>
<hr className="border-transparent" />
<footer>
<Footer />
</footer>
</div>
</I18nProvider>
</LazyMotion> </LazyMotion>
<Analytics /> <Analytics />
</body> </body>

View file

@ -0,0 +1,97 @@
import { Trans } from "@/i18n/client/trans";
import Image from "next/image";
import Link from "next/link";
export default function LicensingThankYouPage() {
return (
<main className="p-4 sm:p-16 h-dvh flex flex-col sm:justify-center relative">
<div className="w-full relative z-10 max-w-2xl mx-auto space-y-6">
<div className="py-4">
<Link className="inline-block rounded" href="/">
<Image src="/logo.svg" width={150} height={30} alt="rallly.co" />
</Link>
</div>
<div className="space-y-4">
<h1 className="text-2xl font-bold tracking-tight">
<Trans
i18nKey="licensingThankYouTitle"
defaults="Thank You for Your Purchase!"
/>
</h1>
<p>
<Trans
i18nKey="licensingThankYouSubtitle"
defaults="Your Rallly self-hosted license is confirmed. We're excited to have you on board!"
/>
</p>
</div>
<div className="space-y-4">
<h2 className="text-xl font-semibold">
<Trans i18nKey="licensingThankYouLicense" defaults="Next Steps" />
</h2>
<p className="text-gray-700 dark:text-gray-300">
<Trans
i18nKey="licensingThankYouLicenseEmailed"
defaults="Your license key has been sent to the email address you provided during checkout. Please check your inbox."
/>
</p>
<ul className="space-y-1 list-disc list-inside">
<li>
<Link
className="text-link"
href="https://support.rallly.co/self-hosted/installation"
>
<Trans
i18nKey="licensingThankYouNextStepsInstallation"
defaults="Installation Guide"
/>
</Link>
</li>
<li>
<Link
className="text-link"
href="https://support.rallly.co/self-hosted/licensing/apply-license"
>
<Trans
i18nKey="licensingThankYouNextStepsApplyLicense"
defaults="How to Apply Your License"
/>
</Link>
</li>
</ul>
</div>
<div className="text-muted-foreground">
<p className="mb-6">
<Trans
i18nKey="licensingThankYouSupportPrompt"
defaults="Need help or have questions? Visit our <0>Support Center</0> or <1>contact us</1>."
components={{
0: (
<Link
href="https://support.rallly.co"
className="font-medium text-brand hover:underline"
/>
),
1: (
<Link
href="/contact"
className="font-medium text-brand hover:underline"
/>
),
}}
/>
</p>
<Link href="/" className="font-medium text-brand hover:underline">
<Trans
i18nKey="licensingThankYouGoHomeLink"
defaults="Return to Home"
/>
</Link>
</div>
</div>
</main>
);
}

View file

@ -1,3 +0,0 @@
export type URLParams = {
locale: string;
};

View file

@ -0,0 +1,89 @@
import type { LicenseCheckoutMetadata } from "@/features/licensing/schema";
import type { LicenseType } from "@prisma/client";
import { stripe } from "@rallly/billing";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const productSchema = z.enum(["plus", "organization"]);
type Product = z.infer<typeof productSchema>;
const mapProductToLicenseType: Record<
Product,
{ type: LicenseType; seats: number }
> = {
plus: { type: "PLUS", seats: 5 },
organization: { type: "ORGANIZATION", seats: 50 },
};
export async function GET(request: NextRequest) {
const product = productSchema.parse(
request.nextUrl.searchParams.get("product"),
);
const prices = await stripe.prices.list({
lookup_keys: [product],
});
if (!prices.data || prices.data.length === 0) {
// Log this error on your server, as it might indicate a misconfiguration
console.error(`No price found for lookup_key: ${product}`);
return NextResponse.json(
{ error: "Pricing information not found for this product." },
{ status: 500 },
); // Or 404 if you prefer
}
if (prices.data.length > 1) {
// This might indicate a configuration issue (e.g., duplicate lookup keys)
console.warn(
`Multiple prices found for lookup_key: ${product}. Using the first one.`,
);
}
const price = prices.data[0];
if (!price.id) {
console.error(`Price object for ${product} is missing an ID.`);
return NextResponse.json(
{ error: "Price configuration error." },
{ status: 500 },
);
}
const { type, seats } = mapProductToLicenseType[product];
try {
const session = await stripe.checkout.sessions.create({
line_items: [
{
price: price.id,
quantity: 1,
},
],
mode: "payment",
success_url: "https://rallly.co/licensing/thank-you",
metadata: {
licenseType: type,
seats,
} satisfies LicenseCheckoutMetadata,
});
if (session.url) {
return NextResponse.redirect(session.url, 303);
}
return NextResponse.json(
{ error: "Something went wrong" },
{ status: 500 },
);
} catch (error) {
console.error("Stripe API error:", error);
let errorMessage =
"An unexpected error occurred with our payment processor.";
if (error instanceof stripe.errors.StripeError) {
errorMessage = error.message;
}
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}

View file

@ -1,44 +1,17 @@
import { type Stripe, stripe } from "@rallly/billing"; import { licensingClient } from "@/features/licensing/client";
import { prisma } from "@rallly/database"; import { licenseCheckoutMetadataSchema } from "@/features/licensing/schema";
import { subscriptionCheckoutMetadataSchema } from "@/features/subscription/schema";
import { getEmailClient } from "@/utils/emails";
import type { Stripe } from "@rallly/billing";
import { stripe } from "@rallly/billing";
import { posthog } from "@rallly/posthog/server"; import { posthog } from "@rallly/posthog/server";
import { z } from "zod";
import { createOrUpdatePaymentMethod } from "../utils"; async function handleSubscriptionCheckoutSessionCompleted(
checkoutSession: Stripe.Checkout.Session,
const checkoutMetadataSchema = z.object({ ) {
userId: z.string(), const { userId } = subscriptionCheckoutMetadataSchema.parse(
}); checkoutSession.metadata,
);
export async function onCheckoutSessionCompleted(event: Stripe.Event) {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
if (checkoutSession.subscription === null) {
// This is a one-time payment (probably for Rallly Self-Hosted)
return;
}
const { userId } = checkoutMetadataSchema.parse(checkoutSession.metadata);
if (!userId) {
return;
}
const customerId = checkoutSession.customer as string;
await prisma.user.update({
where: {
id: userId,
},
data: {
customerId,
},
});
const paymentMethods = await stripe.customers.listPaymentMethods(customerId);
const [paymentMethod] = paymentMethods.data;
await createOrUpdatePaymentMethod(userId, paymentMethod);
const subscription = await stripe.subscriptions.retrieve( const subscription = await stripe.subscriptions.retrieve(
checkoutSession.subscription as string, checkoutSession.subscription as string,
@ -55,3 +28,73 @@ export async function onCheckoutSessionCompleted(event: Stripe.Event) {
}, },
}); });
} }
async function handleSelfHostedCheckoutSessionCompleted(
checkoutSession: Stripe.Checkout.Session,
) {
const { success, data } = licenseCheckoutMetadataSchema.safeParse(
checkoutSession.metadata,
);
if (!success) {
// If there is no metadata than this is likely a donation from a payment link
return;
}
const { licenseType, seats } = data;
const customerDetails = checkoutSession.customer_details;
if (!customerDetails) {
throw new Error(
`No customer details found for session: ${checkoutSession.id}`,
);
}
const { email } = customerDetails;
if (!email) {
throw new Error(
`No email found for customer details in session: ${checkoutSession.id}`,
);
}
const license = await licensingClient.createLicense({
type: licenseType,
licenseeEmail: email,
licenseeName: customerDetails.name ?? undefined,
seats,
stripeCustomerId: checkoutSession.customer as string,
});
if (!license || !license.data) {
throw new Error(
`Failed to create team license for session: ${checkoutSession.id} - ${license?.error}`,
);
}
const emailClient = getEmailClient();
emailClient.sendTemplate("LicenseKeyEmail", {
to: email,
from: {
name: "Luke from Rallly",
address: process.env.SUPPORT_EMAIL,
},
props: {
licenseKey: license.data.key,
seats,
tier: licenseType,
},
});
}
export async function onCheckoutSessionCompleted(event: Stripe.Event) {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
if (checkoutSession.subscription === null) {
await handleSelfHostedCheckoutSessionCompleted(checkoutSession);
} else {
await handleSubscriptionCheckoutSessionCompleted(checkoutSession);
}
}

View file

@ -83,6 +83,6 @@ export const licenseCheckoutMetadataSchema = z.object({
seats: z.number(), seats: z.number(),
}); });
export type LicenseCheckoutMetada = z.infer< export type LicenseCheckoutMetadata = z.infer<
typeof licenseCheckoutMetadataSchema typeof licenseCheckoutMetadataSchema
>; >;

View file

@ -0,0 +1,9 @@
import { z } from "zod";
export const subscriptionCheckoutMetadataSchema = z.object({
userId: z.string(),
});
export type SubscriptionCheckoutMetadata = z.infer<
typeof subscriptionCheckoutMetadataSchema
>;

View file

@ -62,5 +62,17 @@
"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_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 {discount}% off your first year of Rallly Pro!", "abandoned_checkout_preview": "Exclusive offer: Get {discount}% off your first year of Rallly Pro!",
"abandoned_checkout_subject": "Get {discount}% off your first year of Rallly Pro", "abandoned_checkout_subject": "Get {discount}% off your first year of Rallly Pro",
"abandoned_checkout_signoff": "Best regards," "abandoned_checkout_signoff": "Best regards,",
"license_key_content": "Your purchase has been confirmed and your license key has been generated.",
"license_key_yourKey": "License Details",
"license_key_plan": "Plan",
"license_key_seats": "Seats",
"license_key_licenseKey": "License Key",
"license_key_nextStepsHeading": "Next Steps",
"license_key_activationSteps": "Follow these <a>instructions</a> to activate your license on your Rallly Self-Hosted instance.",
"license_key_questionsHeading": "Questions?",
"license_key_support": "Reply to this email or contact us at <a>support@rallly.co</a> if you need help.",
"license_key_signoff": "Thank you for choosing Rallly!",
"license_key_preview": "Your license key has been generated.",
"license_key_subject": "Your Rallly Self-Hosted {tier} License"
} }

View file

@ -0,0 +1,13 @@
import { previewEmailContext } from "../components/email-context";
import { LicenseKeyEmail } from "../templates/license-key";
export default function LicenseKeyPreview() {
return (
<LicenseKeyEmail
licenseKey="RLYV4-ABCD-1234-ABCD-1234-XXXX"
tier="PLUS"
seats={5}
ctx={previewEmailContext}
/>
);
}

View file

@ -2,6 +2,7 @@ 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";
import { LicenseKeyEmail } from "./templates/license-key";
import { LoginEmail } from "./templates/login"; import { LoginEmail } from "./templates/login";
import { NewCommentEmail } from "./templates/new-comment"; import { NewCommentEmail } from "./templates/new-comment";
import { NewParticipantEmail } from "./templates/new-participant"; import { NewParticipantEmail } from "./templates/new-participant";
@ -21,6 +22,7 @@ const templates = {
RegisterEmail, RegisterEmail,
ChangeEmailRequest, ChangeEmailRequest,
AbandonedCheckoutEmail, AbandonedCheckoutEmail,
LicenseKeyEmail,
}; };
export const emailTemplates = Object.keys(templates) as TemplateName[]; export const emailTemplates = Object.keys(templates) as TemplateName[];

View file

@ -0,0 +1,163 @@
import { Trans } from "react-i18next/TransWithoutContext";
import { EmailLayout } from "../components/email-layout";
import { Heading, Link, Text } from "../components/styled-components";
import type { EmailContext } from "../types";
interface LicenseKeyEmailProps {
licenseKey: string;
tier: string;
seats: number;
ctx: EmailContext;
}
export const LicenseKeyEmail = ({
licenseKey,
tier,
seats,
ctx,
}: LicenseKeyEmailProps) => {
return (
<EmailLayout
poweredBy={false}
ctx={ctx}
preview={ctx.t("license_key_preview", {
defaultValue: "Your license key has been generated.",
ns: "emails",
})}
>
<Text>
<Trans
t={ctx.t}
i18n={ctx.i18n}
ns="emails"
i18nKey="license_key_content"
defaults="Your purchase has been confirmed and your license key has been generated."
/>
</Text>
<Heading as="h2">
<Trans
t={ctx.t}
i18n={ctx.i18n}
ns="emails"
i18nKey="license_key_yourKey"
defaults="License Details"
/>
</Heading>
<table>
<tr>
<td style={{ paddingRight: "16px" }}>
<Trans
t={ctx.t}
i18n={ctx.i18n}
ns="emails"
i18nKey="license_key_plan"
defaults="Plan"
/>
</td>
<td>{tier}</td>
</tr>
<tr>
<td style={{ paddingRight: "16px" }}>
<Trans
t={ctx.t}
i18n={ctx.i18n}
ns="emails"
i18nKey="license_key_seats"
defaults="Seats"
/>
</td>
<td>{seats}</td>
</tr>
<tr>
<td style={{ paddingRight: "16px" }}>
<Trans
t={ctx.t}
i18n={ctx.i18n}
ns="emails"
i18nKey="license_key_licenseKey"
defaults="License Key"
/>
</td>
<td
style={{
fontFamily: "monospace",
fontSize: "16px",
letterSpacing: "0.1em",
}}
>
{licenseKey}
</td>
</tr>
</table>
<Heading as="h2">
<Trans
t={ctx.t}
ns="emails"
i18nKey="license_key_nextStepsHeading"
defaults="Next Steps"
/>
</Heading>
<Text>
<Trans
t={ctx.t}
ns="emails"
i18nKey="license_key_activationSteps"
defaults={
"Follow these <a>instructions</a> to activate your license on your Rallly Self-Hosted instance."
}
components={{
a: (
<Link
className="text-link"
href="https://docs.rallly.co/self-hosted"
/>
),
}}
/>
</Text>
<Heading as="h2">
<Trans
t={ctx.t}
ns="emails"
i18nKey="license_key_questionsHeading"
defaults="Questions?"
/>
</Heading>
<Text>
<Trans
t={ctx.t}
ns="emails"
i18nKey="license_key_support"
defaults={
"Reply to this email or contact us at <a>support@rallly.co</a> if you need help."
}
components={{
a: <Link className="text-link" href="mailto:support@rallly.co" />,
}}
/>
</Text>
<Text>
<Trans
t={ctx.t}
ns="emails"
i18nKey="license_key_signoff"
defaults="Thank you for choosing Rallly!"
/>
</Text>
</EmailLayout>
);
};
LicenseKeyEmail.getSubject = (
props: LicenseKeyEmailProps,
ctx: EmailContext,
) => {
return ctx.t("license_key_subject", {
defaultValue: "Your Rallly Self-Hosted {tier} License",
ns: "emails",
tier: props.tier,
});
};
export default LicenseKeyEmail;