diff --git a/apps/landing/next.config.js b/apps/landing/next.config.js index 2bd74d141..7ebb11a22 100644 --- a/apps/landing/next.config.js +++ b/apps/landing/next.config.js @@ -86,6 +86,11 @@ const nextConfig = { destination: createAppUrl("/register"), permanent: true, }, + { + source: "/buy-license/:product", + destination: createAppUrl("/api/stripe/buy-license?product=:product"), + permanent: false, + }, { source: "/S17JJrRWc", destination: "/", diff --git a/apps/landing/public/locales/en/common.json b/apps/landing/public/locales/en/common.json index cf076876f..6682663dc 100644 --- a/apps/landing/public/locales/en/common.json +++ b/apps/landing/public/locales/en/common.json @@ -25,5 +25,13 @@ "status": "Status", "when2MeetAlternative": "When2Meet Alternative", "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 or <1>contact us.", + "licensingThankYouGoHomeLink": "Return to Home" } diff --git a/apps/landing/public/locales/en/home.json b/apps/landing/public/locales/en/home.json index 0e4040948..b3d5819dc 100644 --- a/apps/landing/public/locales/en/home.json +++ b/apps/landing/public/locales/en/home.json @@ -14,31 +14,9 @@ "statsPollsCreated": "{count, number, ::compact-short}+ polls created", "statsLanguagesSupported": "10+ languages supported", "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", - "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", "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.", - "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" } diff --git a/apps/landing/src/app/[locale]/[...notFound]/page.tsx b/apps/landing/src/app/[locale]/(main)/[...notFound]/page.tsx similarity index 100% rename from apps/landing/src/app/[locale]/[...notFound]/page.tsx rename to apps/landing/src/app/[locale]/(main)/[...notFound]/page.tsx diff --git a/apps/landing/src/app/[locale]/blog/[slug]/page.tsx b/apps/landing/src/app/[locale]/(main)/blog/[slug]/page.tsx similarity index 100% rename from apps/landing/src/app/[locale]/blog/[slug]/page.tsx rename to apps/landing/src/app/[locale]/(main)/blog/[slug]/page.tsx diff --git a/apps/landing/src/app/[locale]/blog/layout.tsx b/apps/landing/src/app/[locale]/(main)/blog/layout.tsx similarity index 100% rename from apps/landing/src/app/[locale]/blog/layout.tsx rename to apps/landing/src/app/[locale]/(main)/blog/layout.tsx diff --git a/apps/landing/src/app/[locale]/blog/page.tsx b/apps/landing/src/app/[locale]/(main)/blog/page.tsx similarity index 92% rename from apps/landing/src/app/[locale]/blog/page.tsx rename to apps/landing/src/app/[locale]/(main)/blog/page.tsx index 2e18dc935..64da6e9cf 100644 --- a/apps/landing/src/app/[locale]/blog/page.tsx +++ b/apps/landing/src/app/[locale]/(main)/blog/page.tsx @@ -1,12 +1,13 @@ import { Trans } from "react-i18next/TransWithoutContext"; -import type { URLParams } from "@/app/[locale]/types"; import { getTranslation } from "@/i18n/server"; import { getAllPosts } from "@/lib/api"; import { PostPreview } from "./post-preview"; -export default async function Page(props: { params: Promise }) { +export default async function Page(props: { + params: Promise<{ locale: string }>; +}) { const params = await props.params; const { t } = await getTranslation(params.locale, "blog"); const allPosts = getAllPosts([ diff --git a/apps/landing/src/app/[locale]/blog/post-preview.tsx b/apps/landing/src/app/[locale]/(main)/blog/post-preview.tsx similarity index 100% rename from apps/landing/src/app/[locale]/blog/post-preview.tsx rename to apps/landing/src/app/[locale]/(main)/blog/post-preview.tsx diff --git a/apps/landing/src/app/[locale]/cookie-policy/page.tsx b/apps/landing/src/app/[locale]/(main)/cookie-policy/page.tsx similarity index 100% rename from apps/landing/src/app/[locale]/cookie-policy/page.tsx rename to apps/landing/src/app/[locale]/(main)/cookie-policy/page.tsx diff --git a/apps/landing/src/app/[locale]/footer.tsx b/apps/landing/src/app/[locale]/(main)/footer.tsx similarity index 100% rename from apps/landing/src/app/[locale]/footer.tsx rename to apps/landing/src/app/[locale]/(main)/footer.tsx diff --git a/apps/landing/src/app/[locale]/(main)/layout.tsx b/apps/landing/src/app/[locale]/(main)/layout.tsx new file mode 100644 index 000000000..306308c22 --- /dev/null +++ b/apps/landing/src/app/[locale]/(main)/layout.tsx @@ -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 ( +
+
+
+ + rallly.co + + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
{children}
+
+
+
+
+
+ ); +} diff --git a/apps/landing/src/app/[locale]/nav-link.tsx b/apps/landing/src/app/[locale]/(main)/nav-link.tsx similarity index 100% rename from apps/landing/src/app/[locale]/nav-link.tsx rename to apps/landing/src/app/[locale]/(main)/nav-link.tsx diff --git a/apps/landing/src/app/[locale]/page.tsx b/apps/landing/src/app/[locale]/(main)/page.tsx similarity index 100% rename from apps/landing/src/app/[locale]/page.tsx rename to apps/landing/src/app/[locale]/(main)/page.tsx diff --git a/apps/landing/src/app/[locale]/pricing/page.tsx b/apps/landing/src/app/[locale]/(main)/pricing/page.tsx similarity index 100% rename from apps/landing/src/app/[locale]/pricing/page.tsx rename to apps/landing/src/app/[locale]/(main)/pricing/page.tsx diff --git a/apps/landing/src/app/[locale]/pricing/pricing-table.tsx b/apps/landing/src/app/[locale]/(main)/pricing/pricing-table.tsx similarity index 100% rename from apps/landing/src/app/[locale]/pricing/pricing-table.tsx rename to apps/landing/src/app/[locale]/(main)/pricing/pricing-table.tsx diff --git a/apps/landing/src/app/[locale]/privacy-policy/page.tsx b/apps/landing/src/app/[locale]/(main)/privacy-policy/page.tsx similarity index 100% rename from apps/landing/src/app/[locale]/privacy-policy/page.tsx rename to apps/landing/src/app/[locale]/(main)/privacy-policy/page.tsx diff --git a/apps/landing/src/app/[locale]/terms-of-use/page.tsx b/apps/landing/src/app/[locale]/(main)/terms-of-use/page.tsx similarity index 100% rename from apps/landing/src/app/[locale]/terms-of-use/page.tsx rename to apps/landing/src/app/[locale]/(main)/terms-of-use/page.tsx diff --git a/apps/landing/src/app/[locale]/(seo)/availability-poll/page.tsx b/apps/landing/src/app/[locale]/(seo)/availability-poll/page.tsx deleted file mode 100644 index 4853d613b..000000000 --- a/apps/landing/src/app/[locale]/(seo)/availability-poll/page.tsx +++ /dev/null @@ -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 ( - - - } - /> - - - - - ); -} - -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", - }), - }; -} diff --git a/apps/landing/src/app/[locale]/(seo)/best-doodle-alternative/page.tsx b/apps/landing/src/app/[locale]/(seo)/best-doodle-alternative/page.tsx deleted file mode 100644 index e61d98c8a..000000000 --- a/apps/landing/src/app/[locale]/(seo)/best-doodle-alternative/page.tsx +++ /dev/null @@ -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 ( - - } - /> - - - - - ); -} - -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", - }), - }; -} diff --git a/apps/landing/src/app/[locale]/(seo)/free-scheduling-poll/page.tsx b/apps/landing/src/app/[locale]/(seo)/free-scheduling-poll/page.tsx deleted file mode 100644 index 8048a6ed2..000000000 --- a/apps/landing/src/app/[locale]/(seo)/free-scheduling-poll/page.tsx +++ /dev/null @@ -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 ( - - } - /> - - - - - ); -} - -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", - }), - }; -} diff --git a/apps/landing/src/app/[locale]/(seo)/meeting-poll/page.tsx b/apps/landing/src/app/[locale]/(seo)/meeting-poll/page.tsx deleted file mode 100644 index 6e917fef3..000000000 --- a/apps/landing/src/app/[locale]/(seo)/meeting-poll/page.tsx +++ /dev/null @@ -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 ( - - } - /> - - - - - ); -} - -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.", - }), - }; -} diff --git a/apps/landing/src/app/[locale]/(seo)/when2meet-alternative/page.tsx b/apps/landing/src/app/[locale]/(seo)/when2meet-alternative/page.tsx deleted file mode 100644 index 33a45bdf4..000000000 --- a/apps/landing/src/app/[locale]/(seo)/when2meet-alternative/page.tsx +++ /dev/null @@ -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 ( - - } - /> - - - - - ); -} - -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", - }), - }; -} diff --git a/apps/landing/src/app/[locale]/layout.tsx b/apps/landing/src/app/[locale]/layout.tsx index 203634107..9a35e8747 100644 --- a/apps/landing/src/app/[locale]/layout.tsx +++ b/apps/landing/src/app/[locale]/layout.tsx @@ -1,31 +1,12 @@ import "../../style.css"; 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 { MenuIcon } from "lucide-react"; import { LazyMotion, domAnimation } from "motion/react"; 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 { 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() { return Object.keys(languages).map((locale) => ({ locale })); @@ -46,126 +27,11 @@ export default async function Root(props: { const { children } = props; - const { t } = await getTranslation(locale, "common"); return ( - -
-
-
- - rallly.co - - -
-
-
- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
{children}
-
-
-
-
-
-
+ {children}
diff --git a/apps/landing/src/app/[locale]/licensing/thank-you/page.tsx b/apps/landing/src/app/[locale]/licensing/thank-you/page.tsx new file mode 100644 index 000000000..dddb643eb --- /dev/null +++ b/apps/landing/src/app/[locale]/licensing/thank-you/page.tsx @@ -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 ( +
+
+
+ + rallly.co + +
+
+

+ +

+

+ +

+
+ +
+

+ +

+

+ +

+ +
    +
  • + + + +
  • +
  • + + + +
  • +
+
+ +
+

+ + ), + 1: ( + + ), + }} + /> +

+ + + +
+
+
+ ); +} diff --git a/apps/landing/src/app/[locale]/types.ts b/apps/landing/src/app/[locale]/types.ts deleted file mode 100644 index d001a9d3c..000000000 --- a/apps/landing/src/app/[locale]/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type URLParams = { - locale: string; -}; diff --git a/apps/web/src/app/api/stripe/buy-license/route.ts b/apps/web/src/app/api/stripe/buy-license/route.ts new file mode 100644 index 000000000..9b96d2dbf --- /dev/null +++ b/apps/web/src/app/api/stripe/buy-license/route.ts @@ -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; + +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 }); + } +} diff --git a/apps/web/src/app/api/stripe/webhook/handlers/checkout/completed.ts b/apps/web/src/app/api/stripe/webhook/handlers/checkout/completed.ts index 4506e2671..035310fd3 100644 --- a/apps/web/src/app/api/stripe/webhook/handlers/checkout/completed.ts +++ b/apps/web/src/app/api/stripe/webhook/handlers/checkout/completed.ts @@ -1,44 +1,17 @@ -import { type Stripe, stripe } from "@rallly/billing"; -import { prisma } from "@rallly/database"; +import { licensingClient } from "@/features/licensing/client"; +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 { z } from "zod"; -import { createOrUpdatePaymentMethod } from "../utils"; - -const checkoutMetadataSchema = z.object({ - userId: z.string(), -}); - -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); +async function handleSubscriptionCheckoutSessionCompleted( + checkoutSession: Stripe.Checkout.Session, +) { + const { userId } = subscriptionCheckoutMetadataSchema.parse( + checkoutSession.metadata, + ); const subscription = await stripe.subscriptions.retrieve( 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); + } +} diff --git a/apps/web/src/features/licensing/schema.ts b/apps/web/src/features/licensing/schema.ts index 6cdebfe93..1cd638c46 100644 --- a/apps/web/src/features/licensing/schema.ts +++ b/apps/web/src/features/licensing/schema.ts @@ -83,6 +83,6 @@ export const licenseCheckoutMetadataSchema = z.object({ seats: z.number(), }); -export type LicenseCheckoutMetada = z.infer< +export type LicenseCheckoutMetadata = z.infer< typeof licenseCheckoutMetadataSchema >; diff --git a/apps/web/src/features/subscription/schema.ts b/apps/web/src/features/subscription/schema.ts new file mode 100644 index 000000000..2b2c61e6d --- /dev/null +++ b/apps/web/src/features/subscription/schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const subscriptionCheckoutMetadataSchema = z.object({ + userId: z.string(), +}); + +export type SubscriptionCheckoutMetadata = z.infer< + typeof subscriptionCheckoutMetadataSchema +>; diff --git a/packages/emails/locales/en/emails.json b/packages/emails/locales/en/emails.json index cfb82df29..549755b0b 100644 --- a/packages/emails/locales/en/emails.json +++ b/packages/emails/locales/en/emails.json @@ -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_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_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 instructions 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 support@rallly.co 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" } diff --git a/packages/emails/src/previews/license-key.tsx b/packages/emails/src/previews/license-key.tsx new file mode 100644 index 000000000..d865e0cd0 --- /dev/null +++ b/packages/emails/src/previews/license-key.tsx @@ -0,0 +1,13 @@ +import { previewEmailContext } from "../components/email-context"; +import { LicenseKeyEmail } from "../templates/license-key"; + +export default function LicenseKeyPreview() { + return ( + + ); +} diff --git a/packages/emails/src/templates.ts b/packages/emails/src/templates.ts index 741f004bc..cb5d86111 100644 --- a/packages/emails/src/templates.ts +++ b/packages/emails/src/templates.ts @@ -2,6 +2,7 @@ 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"; +import { LicenseKeyEmail } from "./templates/license-key"; import { LoginEmail } from "./templates/login"; import { NewCommentEmail } from "./templates/new-comment"; import { NewParticipantEmail } from "./templates/new-participant"; @@ -21,6 +22,7 @@ const templates = { RegisterEmail, ChangeEmailRequest, AbandonedCheckoutEmail, + LicenseKeyEmail, }; export const emailTemplates = Object.keys(templates) as TemplateName[]; diff --git a/packages/emails/src/templates/license-key.tsx b/packages/emails/src/templates/license-key.tsx new file mode 100644 index 000000000..4bf18655b --- /dev/null +++ b/packages/emails/src/templates/license-key.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + +
+ + {tier}
+ + {seats}
+ + + {licenseKey} +
+ + + + + instructions to activate your license on your Rallly Self-Hosted instance." + } + components={{ + a: ( + + ), + }} + /> + + + + + + support@rallly.co if you need help." + } + components={{ + a: , + }} + /> + + + + +
+ ); +}; + +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;