mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-18 00:37:27 +02:00
💸 Update webhook and checkout route (#1729)
This commit is contained in:
parent
061989d241
commit
0512e07539
33 changed files with 621 additions and 440 deletions
|
@ -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: "/",
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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([
|
134
apps/landing/src/app/[locale]/(main)/layout.tsx
Normal file
134
apps/landing/src/app/[locale]/(main)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -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",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -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",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -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.",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -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",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -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>
|
||||||
|
|
97
apps/landing/src/app/[locale]/licensing/thank-you/page.tsx
Normal file
97
apps/landing/src/app/[locale]/licensing/thank-you/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,3 +0,0 @@
|
||||||
export type URLParams = {
|
|
||||||
locale: string;
|
|
||||||
};
|
|
89
apps/web/src/app/api/stripe/buy-license/route.ts
Normal file
89
apps/web/src/app/api/stripe/buy-license/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
>;
|
>;
|
||||||
|
|
9
apps/web/src/features/subscription/schema.ts
Normal file
9
apps/web/src/features/subscription/schema.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const subscriptionCheckoutMetadataSchema = z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SubscriptionCheckoutMetadata = z.infer<
|
||||||
|
typeof subscriptionCheckoutMetadataSchema
|
||||||
|
>;
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
13
packages/emails/src/previews/license-key.tsx
Normal file
13
packages/emails/src/previews/license-key.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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[];
|
||||||
|
|
163
packages/emails/src/templates/license-key.tsx
Normal file
163
packages/emails/src/templates/license-key.tsx
Normal 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;
|
Loading…
Add table
Add a link
Reference in a new issue