♻️ Migrate landing pages to app router (#1362)

This commit is contained in:
Luke Vella 2024-09-27 09:47:42 +01:00 committed by GitHub
parent 6b1e2f9c49
commit f92c0075e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 2420 additions and 2166 deletions

View file

@ -1,6 +0,0 @@
const languages = require("@rallly/languages/languages.json");
module.exports = {
defaultLocale: "en",
locales: Object.keys(languages),
};

View file

@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View file

@ -1,12 +0,0 @@
const ICU = require("i18next-icu/i18nextICU.js");
const path = require("path");
const i18n = require("./i18n.config.js");
module.exports = {
i18n,
defaultNS: "common",
reloadOnPrerender: process.env.NODE_ENV === "development",
localePath: path.resolve("./public/locales"),
use: [new ICU()],
serializeConfig: false,
};

View file

@ -3,7 +3,6 @@
// https://nextjs.org/docs/api-reference/next.config.js/introduction
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
const i18n = require("./i18n.config.js");
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
@ -16,10 +15,14 @@ function createAppUrl(subpath) {
}
const nextConfig = {
i18n: i18n,
productionBrowserSourceMaps: true,
output: "standalone",
transpilePackages: ["@rallly/icons", "@rallly/ui", "@rallly/tailwind-config"],
transpilePackages: [
"@rallly/icons",
"@rallly/ui",
"@rallly/tailwind-config",
"next-mdx-remote",
],
webpack(config) {
config.module.rules.push({
test: /\.svg$/,

View file

@ -13,14 +13,14 @@
"prettier": "prettier --write ./src"
},
"dependencies": {
"@rallly/billing": "*",
"@rallly/icons": "*",
"@rallly/languages": "*",
"@rallly/tailwind-config": "*",
"@rallly/ui": "*",
"@rallly/billing": "*",
"@svgr/webpack": "^6.5.1",
"@vercel/analytics": "^0.1.8",
"class-variance-authority": "^0.7.0",
"accept-language-parser": "^1.5.0",
"dayjs": "^1.11.7",
"gray-matter": "^4.0.3",
"i18next": "^22.4.9",
@ -29,15 +29,15 @@
"lodash": "^4.17.21",
"nanoid": "^4.0.0",
"next-i18next": "^13.0.3",
"next-mdx-remote": "^5.0.0",
"next-seo": "^6.1.0",
"react-i18next": "^12.1.4",
"react-use": "^17.4.0",
"remark": "^14.0.3",
"remark-html": "^15.0.2"
"react-use": "^17.4.0"
},
"devDependencies": {
"@next/bundle-analyzer": "^12.3.4",
"@rallly/tsconfig": "*",
"@rallly/eslint-config": "*",
"@types/color-hash": "^1.0.2",
"@types/lodash": "^4.14.178",
"cross-env": "^7.0.3",

View file

@ -19,11 +19,11 @@
"pricing": "Pricing",
"bestDoodleAlternative": "Best Doodle Alternative",
"freeSchedulingPoll": "Free Scheduling Poll",
"findATime": "Find a Time",
"getStarted": "Get started",
"availabilityPoll": "Availability Poll",
"solutions": "Solutions",
"howItWorks": "How it Works",
"status": "Status",
"when2MeetAlternative": "When2Meet Alternative"
"when2MeetAlternative": "When2Meet Alternative",
"meetingPoll": "Meeting Poll"
}

View file

@ -10,8 +10,8 @@
"ericQuote": "“If your scheduling workflow lives in emails, I strongly encourage you to try and let Rallly simplify your scheduling tasks for a more organized and less stressful workday.”",
"viaTrustpilot": "via Trustpilot",
"ericJobTitle": "Executive Assistant at MIT",
"statsUsersRegistered": "45k+ registered users",
"statsPollsCreated": "100k+ polls created",
"statsUsersRegistered": "{count, number, ::compact-short}+ registered users",
"statsPollsCreated": "{count, number, ::compact-short}+ polls created",
"statsLanguagesSupported": "10+ languages supported",
"hint": "It's free! No login required.",
"doodleAlternative": "The Best Free Doodle Alternative",
@ -24,10 +24,6 @@
"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.",
"findATimeMetaTitle": "Find a Time to Meet | Rallly",
"findATimeMetaDescription": "Create a meeting poll in seconds, no login required.",
"findATimeTitle": "Find a Time to Meet",
"findATimeDescription": "Create a meeting poll and let your participants vote on the best time to meet.",
"createASchedulingPoll": "Create a Scheduling Poll",
"freeSchedulingPollMetaTitle": "Free Scheduling Poll | Rallly",
"freeSchedulingPollMetaDescription": "Create a free scheduling poll in seconds. Ideal for organizing meetings, events, conferences, sports teams and more.",
@ -40,5 +36,9 @@
"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."
"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."
}

View file

@ -1,5 +1,4 @@
{
"pricing": "Pricing",
"pricingDescription": "Get started for free. No login required.",
"freeForever": "free forever",
"planPro": "Pro",
@ -29,5 +28,7 @@
"whenPollInactive": "When does a poll become inactive?",
"whenPollInactiveAnswer": "Polls become inactive when all date options are in the past AND the poll has not been accessed for over 30 days. Inactive polls are automatically deleted if you do not have a paid subscription.",
"yearlyBillingDescription": "per year",
"annualBenefit": "{count} months free"
"annualBenefit": "{count} months free",
"pricingTitle": "Get started for free",
"pricingSubtitle": "Upgrade to a paid plan to get access to premium features"
}

View file

@ -0,0 +1,52 @@
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({ params }: { params: { locale: string } }) {
const { t } = await getTranslation(params.locale, ["common", "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({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale, "home");
return {
title: t("availabilityPollMetaTitle", {
ns: "home",
}),
description: t("availabilityPollMetaDescription", {
ns: "home",
}),
};
}

View file

@ -0,0 +1,42 @@
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({ params }: { params: { locale: string } }) {
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({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale, "home");
return {
title: t("doodleAlternativeMetaTitle", {
ns: "home",
}),
description: t("doodleAlternativeMetaDescription", {
ns: "home",
}),
};
}

View file

@ -0,0 +1,42 @@
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({ params }: { params: { locale: string } }) {
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({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale, "home");
return {
title: t("freeSchedulingPollMetaTitle", {
ns: "home",
}),
description: t("freeSchedulingPollMetaDescription", {
ns: "home",
}),
};
}

View file

@ -0,0 +1,48 @@
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({ params }: { params: { locale: string } }) {
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({
params,
}: {
params: { locale: string };
}) {
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

@ -0,0 +1,42 @@
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({ params }: { params: { locale: string } }) {
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({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale, "home");
return {
title: t("when2meetAlternativeMetaTitle", {
ns: "home",
}),
description: t("when2meetAlternativeMetaDescription", {
ns: "home",
}),
};
}

View file

@ -0,0 +1,5 @@
import { notFound } from "next/navigation";
export default function CatchAllPage() {
notFound();
}

View file

@ -0,0 +1,101 @@
import { ArrowLeftIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { MDXRemote } from "next-mdx-remote/rsc";
import PostHeader from "@/components/blog/post-header";
import { getAllPosts, getPostBySlug } from "@/lib/api";
import { absoluteUrl } from "@/utils/absolute-url";
export default async function Page({ params }: { params: { slug: string } }) {
const post = getPostBySlug(params.slug, [
"title",
"date",
"slug",
"author",
"excerpt",
"content",
]);
return (
<div>
<nav className="mb-2">
<Link
className="text-muted-foreground hover:text-primary inline-flex items-center gap-x-2 text-sm font-medium"
href="/blog"
>
<ArrowLeftIcon className="size-4" /> All Posts
</Link>
</nav>
<article className="space-y-8">
<PostHeader title={post.title} date={post.date} />
<div className="blog-content">
<MDXRemote source={post.content} />
</div>
<div className="mt-8 flex items-center gap-x-4">
<Image
src="/static/images/luke-vella.jpg"
width={48}
height={48}
className="rounded-full"
alt="Luke Vella"
/>
<div>
<div className="font-medium leading-none">Luke Vella</div>
<div>
<Link
className="text-muted-foreground hover:text-primary text-sm"
href="https://twitter.com/imlukevella"
>
@imlukevella
</Link>
</div>
</div>
</div>
</article>
</div>
);
}
export async function generateStaticParams() {
const posts = getAllPosts(["slug"]);
return posts.map((post) => ({
slug: post.slug,
}));
}
export async function generateMetadata({
params,
}: {
params: { slug: string };
}) {
const post = getPostBySlug(params.slug, [
"title",
"date",
"slug",
"author",
"excerpt",
]);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
url: absoluteUrl(`/blog/${post.slug}`),
images: [
{
url: absoluteUrl("/api/og-image", {
title: post.title,
excerpt: post.excerpt,
}),
width: 1200,
height: 630,
alt: post.title,
type: "image/png",
},
],
},
};
}

View file

@ -0,0 +1,39 @@
import { NewspaperIcon } from "lucide-react";
import Script from "next/script";
export default function BlogLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="space-y-6">
<div className="w-full">
<div className="mx-auto max-w-2xl space-y-12">
{children}
<div className="overflow-hidden rounded-md border bg-gray-200/50 backdrop-blur-sm">
<div className="flex flex-col gap-x-4 gap-y-2 p-6 pb-0 sm:flex-row">
<div>
<NewspaperIcon className="size-6" />
</div>
<div>
<div className="font-medium">Want to stay up to date?</div>
<div className="text-sm text-gray-500">
Subscribe to our newsletter to get updates on new features and
releases.
</div>
</div>
</div>
<div className="flex sm:ml-11">
<div
className="ml-embedded min-h-[88px] w-96 p-0"
data-form="h9YecB"
/>
</div>
</div>
</div>
<Script id="mailerlite" src="/static/scripts/mailerlite.js" />
</div>
</div>
);
}

View file

@ -0,0 +1,63 @@
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({ params }: { params: URLParams }) {
const { t } = await getTranslation(params.locale, "blog");
const allPosts = getAllPosts([
"title",
"date",
"slug",
"author",
"coverImage",
"excerpt",
]);
return (
<section className="space-y-12">
<header className="p-6">
<h1 className="text-4xl font-bold tracking-tight">
<Trans
t={t}
ns="blog"
i18nKey="recentPosts"
defaults="Recent Posts"
/>
</h1>
</header>
<div className="mb-16 grid grid-cols-1 gap-8">
{allPosts.map((post) => (
<PostPreview
key={post.slug}
title={post.title}
coverImage={post.coverImage}
date={post.date}
slug={post.slug}
excerpt={post.excerpt}
/>
))}
</div>
</section>
);
}
export async function generateMetadata({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale, "blog");
return {
title: t("blogTitle", {
ns: "blog",
defaultValue: "Rallly - Blog",
}),
description: t("blogDescription", {
ns: "blog",
defaultValue: "News, updates and announcement about Rallly.",
}),
};
}

View file

@ -1,6 +1,11 @@
"use client";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import Link from "next/link";
dayjs.extend(localizedFormat);
type Props = {
title: string;
coverImage?: string;
@ -9,9 +14,9 @@ type Props = {
slug: string;
};
const PostPreview = ({ title, date, excerpt, slug }: Props) => {
export const PostPreview = ({ title, date, excerpt, slug }: Props) => {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:gap-8">
<article className="flex flex-col gap-2 sm:flex-row sm:gap-8">
<div>
<div className="text-muted-foreground w-48 pt-1 sm:text-right">
<time dateTime={date}>{dayjs(date).format("LL")}</time>
@ -30,8 +35,6 @@ const PostPreview = ({ title, date, excerpt, slug }: Props) => {
</h3>
<p className="mb-4 text-lg leading-relaxed text-gray-600">{excerpt}</p>
</div>
</div>
</article>
);
};
export default PostPreview;

View file

@ -1,13 +1,6 @@
import { GetStaticProps } from "next";
import { NextSeo } from "next-seo";
import PageLayout from "@/components/layouts/page-layout";
import { getStaticTranslations } from "@/utils/page-translations";
const PrivacyPolicy = () => {
export default function CookiePolicy() {
return (
<PageLayout>
<NextSeo title="Cookie Policy" />
<>
<div className="prose mx-auto my-16 max-w-3xl rounded-lg bg-white p-8 shadow-md">
<h1>Cookie Policy</h1>
<p>Last updated: 19 April 2023</p>
@ -46,10 +39,13 @@ const PrivacyPolicy = () => {
website.
</p>
</div>
</PageLayout>
</>
);
};
}
export default PrivacyPolicy;
export const getStaticProps: GetStaticProps = getStaticTranslations();
export function generateMetadata() {
return {
title: "Rallly: Cookie Policy",
description: "The cookie policy for Rallly.",
};
}

View file

@ -1,5 +1,7 @@
"use client";
import { DiscordIcon } from "@rallly/icons";
import languages from "@rallly/languages";
import languages, { supportedLngs } from "@rallly/languages";
import { Button } from "@rallly/ui/button";
import {
Select,
@ -16,22 +18,29 @@ import {
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { usePathname, useRouter } from "next/navigation";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Trans } from "@/components/trans";
export const LanguageSelect = () => {
const LanguageSelect = () => {
const router = useRouter();
const pathname = usePathname() ?? "";
const { i18n } = useTranslation();
return (
<Select
value={router.locale}
value={i18n.language}
onValueChange={(newLocale) => {
router.replace(router.asPath, undefined, {
locale: newLocale,
scroll: false,
});
const isLocalizedPath = supportedLngs.some((lng) =>
pathname?.startsWith(`/${lng}`),
);
const newPath = isLocalizedPath
? pathname.replace(new RegExp(`^/${i18n.language}`), "")
: pathname;
router.replace(`/${newLocale}${newPath}`);
}}
>
<SelectTrigger asChild>
@ -50,7 +59,7 @@ export const LanguageSelect = () => {
);
};
const Footer: React.FunctionComponent = () => {
export const Footer: React.FunctionComponent = () => {
return (
<div className="mx-auto space-y-8">
<div className="space-y-16 lg:flex lg:space-x-8 lg:space-y-0">
@ -64,6 +73,7 @@ const Footer: React.FunctionComponent = () => {
<div className="my-8 text-sm text-gray-500">
<p className="mb-4 leading-relaxed">
<Trans
ns="common"
i18nKey="footerSponsor"
components={{
a: (
@ -77,6 +87,7 @@ const Footer: React.FunctionComponent = () => {
</p>
<div>
<Trans
ns="common"
i18nKey="footerCredit"
components={{
a: (
@ -122,7 +133,7 @@ const Footer: React.FunctionComponent = () => {
</div>
<div className="lg:w-1/6">
<div className="mb-8 font-medium">
<Trans i18nKey="links" defaults="Links" />
<Trans ns="common" i18nKey="links" defaults="Links" />
</div>
<ul className="grid gap-2 text-sm">
<li>
@ -139,7 +150,11 @@ const Footer: React.FunctionComponent = () => {
className="inline-block font-normal text-gray-500 hover:text-gray-800 hover:no-underline"
href="https://github.com/lukevella/rallly/discussions"
>
<Trans i18nKey="discussions" defaults="Discussions" />
<Trans
ns="common"
i18nKey="discussions"
defaults="Discussions"
/>
</Link>
</li>
<li>
@ -147,7 +162,7 @@ const Footer: React.FunctionComponent = () => {
href="https://rallly.co/blog"
className="inline-block font-normal text-gray-500 hover:text-gray-800 hover:no-underline"
>
<Trans i18nKey="blog" defaults="Blog" />
<Trans ns="common" i18nKey="blog" defaults="Blog" />
</Link>
</li>
<li>
@ -155,7 +170,7 @@ const Footer: React.FunctionComponent = () => {
href="https://support.rallly.co"
className="inline-block font-normal text-gray-500 hover:text-gray-800 hover:no-underline"
>
<Trans i18nKey="support" defaults="Support" />
<Trans ns="common" i18nKey="support" defaults="Support" />
</Link>
</li>
<li>
@ -163,7 +178,7 @@ const Footer: React.FunctionComponent = () => {
href="https://rallly.openstatus.dev"
className="inline-block font-normal text-gray-500 hover:text-gray-800 hover:no-underline"
>
<Trans i18nKey="status" defaults="Status" />
<Trans ns="common" i18nKey="status" defaults="Status" />
</Link>
</li>
</ul>
@ -179,6 +194,7 @@ const Footer: React.FunctionComponent = () => {
href="/best-doodle-alternative"
>
<Trans
ns="common"
i18nKey="bestDoodleAlternative"
defaults="Best Doodle Alternative"
/>
@ -190,6 +206,7 @@ const Footer: React.FunctionComponent = () => {
href="/when2meet-alternative"
>
<Trans
ns="common"
i18nKey="when2MeetAlternative"
defaults="When2Meet Alternative"
/>
@ -201,6 +218,7 @@ const Footer: React.FunctionComponent = () => {
href="/free-scheduling-poll"
>
<Trans
ns="common"
i18nKey="freeSchedulingPoll"
defaults="Free Scheduling Poll"
/>
@ -209,9 +227,13 @@ const Footer: React.FunctionComponent = () => {
<li>
<Link
className="inline-block font-normal text-gray-500 hover:text-gray-800 hover:no-underline"
href="/find-a-time"
href="/meeting-poll"
>
<Trans i18nKey="findATime" defaults="Find a Time" />
<Trans
ns="common"
i18nKey="meetingPoll"
defaults="Meeting Poll"
/>
</Link>
</li>
<li>
@ -220,6 +242,7 @@ const Footer: React.FunctionComponent = () => {
href="/availability-poll"
>
<Trans
ns="common"
i18nKey="availabilityPoll"
defaults="Availability Poll"
/>
@ -229,7 +252,7 @@ const Footer: React.FunctionComponent = () => {
</div>
<div className="lg:w-2/6">
<div className="mb-8 font-medium">
<Trans i18nKey="language" defaults="Language" />
<Trans ns="common" i18nKey="language" defaults="Language" />
</div>
<div className="mb-4">
<LanguageSelect />
@ -239,7 +262,7 @@ const Footer: React.FunctionComponent = () => {
className="hover:border-primary-600 hover:text-primary-600 inline-flex items-center rounded-md border px-3 py-2 text-xs text-gray-500"
>
<LanguagesIcon className="mr-2 size-5" />
<Trans i18nKey="volunteerTranslator" /> &rarr;
<Trans ns="common" i18nKey="volunteerTranslator" /> &rarr;
</Link>
</div>
</div>
@ -250,7 +273,7 @@ const Footer: React.FunctionComponent = () => {
href="/privacy-policy"
className="inline-block font-normal text-gray-500 hover:text-gray-800 hover:no-underline"
>
<Trans i18nKey="privacyPolicy" />
<Trans ns="common" i18nKey="privacyPolicy" />
</Link>
</li>
<li>
@ -258,7 +281,7 @@ const Footer: React.FunctionComponent = () => {
href="/cookie-policy"
className="inline-block font-normal text-gray-500 hover:text-gray-800 hover:no-underline"
>
<Trans i18nKey="cookiePolicy" />
<Trans ns="common" i18nKey="cookiePolicy" />
</Link>
</li>
<li>
@ -266,13 +289,13 @@ const Footer: React.FunctionComponent = () => {
href="/terms-of-use"
className="inline-block font-normal text-gray-500 hover:text-gray-800 hover:no-underline"
>
<Trans i18nKey="termsOfUse" />
<Trans ns="common" i18nKey="termsOfUse" />
</Link>
</li>
</ul>
<div className="grid gap-2.5">
<div className="text-sm tracking-tight sm:text-right">
<Trans i18nKey="poweredBy" defaults="Powered by" />
<Trans ns="common" i18nKey="poweredBy" defaults="Powered by" />
</div>
<div className="flex flex-wrap items-center gap-x-8 gap-y-2 md:justify-end">
<div>
@ -323,5 +346,3 @@ const Footer: React.FunctionComponent = () => {
</div>
);
};
export default Footer;

View file

@ -0,0 +1,157 @@
import "tailwindcss/tailwind.css";
import "../../style.css";
import languages from "@rallly/languages";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import { ChevronRightIcon, MenuIcon } from "lucide-react";
import { 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";
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,
maximumScale: 1,
userScalable: false,
};
export default async function Root({
children,
params: { locale },
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const { t } = await getTranslation(locale, "common");
return (
<html lang={locale} className={sans.className}>
<body>
<I18nProvider locale={locale}>
<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 space-x-8 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">
<Link
href={linkToApp("/login")}
className="hover:text-primary text-muted-foreground hidden rounded text-sm font-medium hover:no-underline hover:underline-offset-2 lg:inline-flex"
>
<Trans t={t} i18nKey="login" defaults="Login" />
</Link>
<Link
href={linkToApp()}
className="bg-primary hover:bg-primary-500 active:bg-primary-700 group inline-flex items-center gap-1 rounded-full py-1.5 pl-4 pr-3 text-sm font-medium text-white shadow-sm transition-transform"
>
<span>
<Trans t={t} i18nKey="goToApp" defaults="Go to app" />
</span>
<ChevronRightIcon className="inline-block size-4 transition-all group-active:translate-x-1" />
</Link>
<div className="flex items-center justify-center lg:hidden">
<DropdownMenu>
<DropdownMenuTrigger>
<MenuIcon className="size-6" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={16}>
<DropdownMenuItem asChild>
<Link
className="flex items-center gap-3 p-2 text-lg"
href="https://support.rallly.co/workflow/create"
>
<Trans
t={t}
i18nKey="howItWorks"
defaults="How it Works"
/>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className="flex items-center gap-3 p-2 text-lg"
href="/pricing"
>
<Trans t={t} i18nKey="pricing" defaults="Pricing" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className="flex items-center gap-3 p-2 text-lg"
href="/blog"
>
<Trans t={t} i18nKey="blog" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className="flex items-center gap-3 p-2 text-lg"
href="https://support.rallly.co"
>
<Trans t={t} i18nKey="support" />
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className="flex items-center gap-3 p-2 text-lg"
href={linkToApp("/login")}
>
<Trans t={t} i18nKey="login" defaults="Login" />
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
<section className="relative grow">{children}</section>
<hr className="border-transparent" />
<footer>
<Footer />
</footer>
</div>
</I18nProvider>
</body>
</html>
);
}

View file

@ -0,0 +1,23 @@
"use client";
import { cn } from "@rallly/ui";
import Link from "next/link";
import { usePathname } from "next/navigation";
export const NavLink = ({
className,
...props
}: React.ComponentProps<typeof Link>) => {
const pathname = usePathname();
const isActive = pathname === props.href;
return (
<Link
className={cn(
"inline-flex items-center gap-x-2.5 rounded text-sm font-medium",
isActive ? "" : "hover:text-primary text-muted-foreground ",
className,
)}
{...props}
/>
);
};

View file

@ -0,0 +1,15 @@
import ErrorPage from "@/components/error-page";
import { getTranslation } from "@/i18n/server";
export default async function Page() {
// TODO (Luke Vella) [2023-11-03]: not-found doesn't have access to params right now
// See: https://github.com/vercel/next.js/discussions/43179
const { t } = await getTranslation("en");
return (
<ErrorPage
title={t("notFoundTitle")}
description={t("notFoundDescription")}
/>
);
}

View file

@ -0,0 +1,41 @@
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({ params }: { params: { locale: string } }) {
const { t } = await getTranslation(params.locale, ["common", "home"]);
return (
<Marketing>
<MarketingHero
title={t("home:headline", {
defaultValue: "Ditch the back-and-forth emails",
})}
description={t("home:subheading", {
defaultValue: "Streamline your scheduling process and save time",
})}
callToAction={t("getStarted")}
/>
<Bonus t={t} />
<BigTestimonial />
<MentionedBy />
</Marketing>
);
}
export async function generateMetadata({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale, "home");
return {
title: t("home:metaTitle", {
defaultValue: "Rallly: Group Scheduling Tool",
}),
description: t("home:metaDescription", {
defaultValue:
"Create polls and vote to find the best day or time. A free alternative to Doodle.",
}),
};
}

View file

@ -0,0 +1,193 @@
import { TFunction } from "i18next";
import { TrendingUpIcon } from "lucide-react";
import Link from "next/link";
import { Trans } from "react-i18next/TransWithoutContext";
import { getTranslation } from "@/i18n/server";
import { linkToApp } from "@/lib/linkToApp";
import { PriceTables } from "./pricing-table";
const FAQ = async ({ t }: { t: TFunction }) => {
return (
<section>
<h2 className="text-2xl font-bold">
<Trans
t={t}
ns="pricing"
i18nKey="faq"
defaults="Frequently Asked Questions"
/>
</h2>
<h3 className="mb-2 mt-6 text-lg font-bold">
<Trans
t={t}
ns="pricing"
i18nKey="canUseFree"
defaults="Can I use Rallly for free?"
/>
</h3>
<p className="col-span-2 text-sm leading-relaxed text-slate-600">
<Trans
t={t}
ns="pricing"
i18nKey="canUseFreeAnswer2"
defaults="Yes, most of Rallly's features are free and many users will never need to pay for anything. However, there are some features that are only available to paying customers. These features are designed to help you get the most out of Rallly."
/>
</p>
<h3 className="mb-2 mt-6 text-lg font-bold">
<Trans
t={t}
ns="pricing"
i18nKey="whyUpgrade"
defaults="Why should I upgrade?"
/>
</h3>
<p className="col-span-2 text-sm leading-relaxed text-slate-600">
<Trans
t={t}
ns="pricing"
i18nKey="whyUpgradeAnswer2"
defaults="Upgrading to a paid plan makes sense if you use Rallly often or use it for work. The current subscription rate is a special early adopter rate and will increase in the future. By upgrading now, you will get early access to new, high-quality scheduling tools as they are released and lock in your subscription rate so you won't be affected by future price increases."
/>
</p>
<h3 className="mb-2 mt-6 text-lg font-bold">
<Trans
t={t}
ns="pricing"
i18nKey="whenPollInactive"
defaults="When does a poll become inactive?"
/>
</h3>
<p className="col-span-2 text-sm leading-relaxed text-slate-600">
<Trans
t={t}
ns="pricing"
i18nKey="whenPollInactiveAnswer"
defaults="Polls become inactive when all date options are in the past AND the poll has not been accessed for over 30 days. Inactive polls are automatically deleted if you do not have a paid subscription."
/>
</p>
<h3 className="mb-2 mt-6 text-lg font-bold">
<Trans
t={t}
ns="pricing"
i18nKey="howToUpgrade"
defaults="How do I upgrade to a paid plan?"
/>
</h3>
<p className="col-span-2 text-sm leading-relaxed text-slate-600">
<Trans
t={t}
ns="pricing"
i18nKey="howToUpgradeAnswer"
components={{
a: (
<Link
className="text-link"
href={linkToApp("/settings/billing")}
/>
),
b: <strong />,
}}
defaults="To upgrade, you can go to your <a>billing settings</a> and click on <b>Upgrade</b>."
/>
</p>
<h3 className="mb-2 mt-6 text-lg font-bold">
<Trans
t={t}
ns="pricing"
i18nKey="cancelSubscription"
defaults="How do I cancel my subscription?"
/>
</h3>
<p className="col-span-2 text-sm leading-relaxed text-slate-600">
<Trans
t={t}
ns="pricing"
i18nKey="cancelSubscriptionAnswer"
components={{
a: (
<Link
className="text-link"
href={linkToApp("/settings/billing")}
/>
),
b: <strong />,
}}
defaults="You can cancel your subscription at any time by going to your <a>billing settings</a>. Once you cancel your subscription, you will still have access to your paid plan until the end of your billing period. After that, you will be downgraded to a free plan."
/>
</p>
</section>
);
};
export default async function Page({ params }: { params: { locale: string } }) {
const { t } = await getTranslation(params.locale, ["common", "pricing"]);
return (
<article className="mx-auto max-w-3xl space-y-6">
<header className="space-y-2 p-6 text-center ">
<h1 className="text-4xl font-bold tracking-tight">
<Trans
t={t}
ns="pricing"
i18nKey="pricingTitle"
defaults="Get started for free"
/>
</h1>
<p className="text-muted-foreground text-lg">
<Trans
t={t}
ns="pricing"
i18nKey="pricingSubtitle"
defaults="Upgrade to a paid plan to get access to premium features"
/>
</p>
</header>
<section>
<PriceTables />
</section>
<section>
<div className="flex flex-col gap-4 rounded-md border border-cyan-800/10 bg-gradient-to-b from-cyan-50 to-cyan-50/60 p-4 text-cyan-800 shadow-sm sm:flex-row sm:gap-6 sm:p-5">
<div>
<TrendingUpIcon className="size-6" />
</div>
<div>
<h3 className="mb-2 text-sm font-bold">
<Trans
t={t}
ns="pricing"
i18nKey="upgradeNowSaveLater"
defaults="Upgrade now, save later"
/>
</h3>
<p className="text-sm">
<Trans
t={t}
ns="pricing"
i18nKey="earlyAdopterDescription"
defaults="As an early adopter, you'll lock in your subscription rate and won't be affected by future price increases."
/>
</p>
</div>
</div>
</section>
<hr className="border-transparent" />
<FAQ t={t} />
</article>
);
}
export async function generateMetadata({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale, ["common", "pricing"]);
return {
title: t("pricing", { ns: "common", defaultValue: "Pricing" }),
description: t("pricingDescription", {
ns: "pricing",
}),
};
}

View file

@ -0,0 +1,199 @@
"use client";
import { pricingData } from "@rallly/billing/pricing";
import { Badge } from "@rallly/ui/badge";
import {
BillingPlan,
BillingPlanDescription,
BillingPlanHeader,
BillingPlanPeriod,
BillingPlanPerk,
BillingPlanPerks,
BillingPlanPrice,
BillingPlanTitle,
} from "@rallly/ui/billing-plan";
import { Button } from "@rallly/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@rallly/ui/tabs";
import Link from "next/link";
import React from "react";
import { Trans } from "react-i18next/TransWithoutContext";
import { useTranslation } from "@/i18n/client";
import { linkToApp } from "@/lib/linkToApp";
export function PriceTables() {
const { t } = useTranslation("pricing");
const [tab, setTab] = React.useState("yearly");
return (
<Tabs value={tab} onValueChange={setTab}>
<div className="flex justify-center">
<TabsList className="mb-4 sm:mb-6">
<TabsTrigger value="monthly">
<Trans
t={t}
ns="pricing"
i18nKey="billingPeriodMonthly"
defaults="Monthly"
/>
</TabsTrigger>
<TabsTrigger value="yearly" className="inline-flex gap-x-2.5">
<Trans
t={t}
ns="pricing"
i18nKey="billingPeriodYearly"
defaults="Yearly"
/>
</TabsTrigger>
</TabsList>
</div>
<div className="mx-auto grid gap-4 sm:gap-6 md:grid-cols-2">
<BillingPlan>
<BillingPlanHeader>
<BillingPlanTitle>
<Trans t={t} ns="pricing" i18nKey="planFree" defaults="Free" />
</BillingPlanTitle>
<BillingPlanDescription>
<Trans
t={t}
ns="pricing"
i18nKey="planFreeDescription"
defaults="For casual users"
/>
</BillingPlanDescription>
</BillingPlanHeader>
<div>
<BillingPlanPrice>$0</BillingPlanPrice>
<BillingPlanPeriod>
<Trans
t={t}
ns="pricing"
i18nKey="freeForever"
defaults="free forever"
/>
</BillingPlanPeriod>
</div>
<hr />
<Button asChild className="w-full">
<Link href={linkToApp("/")}>
<Trans
t={t}
ns="common"
i18nKey="getStarted"
defaults="Get started"
/>
</Link>
</Button>
<BillingPlanPerks>
<BillingPlanPerk>
<Trans
t={t}
ns="pricing"
i18nKey="limitedAccess"
defaults="Access to core features"
/>
</BillingPlanPerk>
<BillingPlanPerk>
<Trans
t={t}
ns="pricing"
i18nKey="pollsDeleted"
defaults="Polls are automatically deleted once they become inactive"
/>
</BillingPlanPerk>
</BillingPlanPerks>
</BillingPlan>
<BillingPlan className="relative">
<BillingPlanHeader>
<BillingPlanTitle>
<Trans t={t} ns="pricing" i18nKey="planPro" defaults="Pro" />
</BillingPlanTitle>
<BillingPlanDescription>
<Trans
t={t}
ns="pricing"
i18nKey="planProDescription"
defaults="For power users and professionals"
/>
</BillingPlanDescription>
</BillingPlanHeader>
<TabsContent value="yearly">
<div className="flex items-center gap-x-2">
<BillingPlanPrice>
${pricingData.yearly.amount / 100}
</BillingPlanPrice>
<Badge variant="green" className="inline-flex gap-2">
<Trans
t={t}
ns="pricing"
i18nKey="annualBenefit"
defaults="{count} months free!"
values={{
count: 4,
}}
/>
</Badge>
</div>
<BillingPlanPeriod>
<Trans
t={t}
ns="pricing"
i18nKey="yearlyBillingDescription"
defaults="per year"
/>
</BillingPlanPeriod>
</TabsContent>
<TabsContent value="monthly">
<BillingPlanPrice>
${pricingData.monthly.amount / 100}
</BillingPlanPrice>
<BillingPlanPeriod>
<Trans
t={t}
ns="pricing"
i18nKey="monthlyBillingDescription"
defaults="per month"
/>
</BillingPlanPeriod>
</TabsContent>
<hr />
<Button asChild variant="primary" className="w-full">
<Link href={linkToApp("/settings/billing")}>
<Trans
t={t}
ns="pricing"
i18nKey="upgrade"
defaults="Go to billing"
/>
</Link>
</Button>
<BillingPlanPerks>
<BillingPlanPerk pro={true}>
<Trans
t={t}
ns="pricing"
i18nKey="accessAllFeatures"
defaults="Access all features"
/>
</BillingPlanPerk>
<BillingPlanPerk pro={true}>
<Trans
t={t}
ns="pricing"
i18nKey="keepPollsIndefinitely"
defaults="Keep polls indefinitely"
/>
</BillingPlanPerk>
<BillingPlanPerk pro={true}>
<Trans
t={t}
ns="pricing"
i18nKey="getEarlyAccess"
defaults="Get early access to new features"
/>
</BillingPlanPerk>
</BillingPlanPerks>
</BillingPlan>
</div>
</Tabs>
);
}

View file

@ -1,12 +1,6 @@
import { NextSeo } from "next-seo";
import PageLayout from "@/components/layouts/page-layout";
import { getStaticTranslations } from "@/utils/page-translations";
const PrivacyPolicy = () => {
export default function PrivacyPolicy() {
return (
<PageLayout>
<NextSeo title="Privacy Policy" />
<>
<div className="prose mx-auto my-16 max-w-3xl rounded-lg bg-white p-8 shadow-md">
<h1>Privacy Policy</h1>
<p>Last updated: 1 August 2023</p>
@ -125,10 +119,13 @@ const PrivacyPolicy = () => {
<a href="mailto:support@rallly.co">support@rallly.co</a>.
</p>
</div>
</PageLayout>
</>
);
};
}
export default PrivacyPolicy;
export const getStaticProps = getStaticTranslations();
export function generateMetadata() {
return {
title: "Rallly: Privacy Policy",
description: "The privacy policy for Rallly.",
};
}

View file

@ -0,0 +1,83 @@
export default function TermsOfUse() {
return (
<div className="prose mx-auto my-16 max-w-3xl rounded-lg bg-white p-8 shadow-md">
<h1>Terms of Use</h1>
<p>Last updated: 4 July 2023</p>
<p>
{`This website is operated by Stack Snap Ltd. References made to "we",
"us" or "our" pertain directly and exclusively to Stack Snap Ltd. We
provide you, as the user, with this website, which includes all the
information, tools, and services accessible on it, under the
stipulation that you agree to all the terms, conditions, policies, and
notices laid out herein.`}
</p>
<h2>1. Use of Website</h2>
<p>
You may use this website only for lawful purposes and in accordance with
these terms of use. You must not use this website in any way that causes
or may cause damage to the website or impairment of the availability or
accessibility of the website. You must not use this website in any way
that is unlawful, fraudulent, or harmful.
</p>
<h2>2. Limitation of Liability</h2>
<p>
We will not be liable for any damages arising from the use or inability
to use this website, including but not limited to direct, indirect,
incidental, consequential, or punitive damages.
</p>
<h2>3. No Refund Policy</h2>
<p>
All purchases and transactions made through this website are final and
non-refundable. We do not provide refunds for any reason. By making a
purchase or engaging in a transaction on this website, you acknowledge
and agree to our no refund policy.
</p>
<h2>4. Links to Third-Party Websites</h2>
<p>
This website may contain links to third-party websites that are not
owned or controlled by rallly.co. We have no control over, and assume no
responsibility for, the content, privacy policies, or practices of any
third-party websites.
</p>
<h2>5. Modifications to Terms of Use</h2>
<p>
We reserve the right to modify these terms of use at any time, without
prior notice to you. Your continued use of this website after any
modifications to these terms of use will constitute your acceptance of
such modifications.
</p>
<h2>6. Contact</h2>
<p>
If you have any questions about these terms of use, please contact us at{" "}
<a href="mailto:support@rallly.co">support@rallly.co</a>.
</p>
<p className="text-sm font-semibold">
Stack Snap Ltd.
<br />
The Gallery 14
<br />
Upland Road
<br />
London
<br />
SE22 9EE
<br />
United Kingdom
</p>
</div>
);
}
export function generateMetadata() {
return {
title: "Rallly: Terms of Use",
description: "The terms of use for Rallly.",
};
}

View file

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

View file

@ -0,0 +1,23 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import NextError from "next/error";
import { useEffect } from "react";
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html>
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</body>
</html>
);
}

View file

@ -1,4 +1,7 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
dayjs.extend(localizedFormat);
type Props = {
dateString: string;

View file

@ -1,30 +0,0 @@
.markdown {
@apply text-lg leading-relaxed;
}
.markdown p,
.markdown ul,
.markdown ol,
.markdown blockquote {
@apply my-6 text-gray-700;
}
.markdown h2 {
@apply mb-4 mt-12 text-2xl font-semibold leading-snug;
}
.markdown h3 {
@apply mb-4 mt-8 text-xl font-semibold leading-snug;
}
.markdown a {
@apply hover:text-primary underline;
}
.markdown ul {
@apply list-inside list-disc;
}
.markdown img {
@apply mx-auto my-8 rounded-md;
}

View file

@ -1,4 +1,4 @@
import DateFormatter from "@/components/blog/date-formatter";
import DateFormatter from "./date-formatter";
type Props = {
title: string;
@ -7,14 +7,14 @@ type Props = {
const PostHeader = ({ title, date }: Props) => {
return (
<>
<header>
<h1 className="mb-2 text-center text-4xl font-bold tracking-tighter md:text-left md:leading-tight">
{title}
</h1>
<div className="mb-2 text-center text-lg text-gray-400 sm:text-left">
<DateFormatter dateString={date} />
</div>
</>
</header>
);
};

View file

@ -1,32 +0,0 @@
import { Trans } from "@/components/trans";
import { Post } from "@/types";
import PostPreview from "./post-preview";
type Props = {
posts: Post[];
};
const Posts = ({ posts }: Props) => {
return (
<section>
<h1 className="mb-16 text-4xl font-bold tracking-tight">
<Trans i18nKey="blog:recentPosts" defaults="Recent Posts" />
</h1>
<div className="mb-16 grid grid-cols-1 gap-8">
{posts.map((post) => (
<PostPreview
key={post.slug}
title={post.title}
coverImage={post.coverImage}
date={post.date}
slug={post.slug}
excerpt={post.excerpt}
/>
))}
</div>
</section>
);
};
export default Posts;

View file

@ -1,45 +1,44 @@
import { FrownIcon } from "lucide-react";
import Head from "next/head";
"use client";
import { Button } from "@rallly/ui/button";
import { FileSearchIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "next-i18next";
import * as React from "react";
export interface ComponentProps {
icon?: React.ComponentType<{ className?: string }>;
icon?: React.ReactNode;
title: string;
description: string;
}
const ErrorPage: React.FunctionComponent<ComponentProps> = ({
icon: Icon = FrownIcon,
icon,
title,
description,
}) => {
const { t } = useTranslation();
return (
<div className="flex h-[calc(100vh-100px)] w-full items-center justify-center">
<Head>
<title>{title}</title>
</Head>
<div className="inset-0 flex h-full w-full items-center justify-center lg:absolute">
<div className="space-y-8">
<div className="space-y-4 text-center">
<Icon className="mb-4 inline-block size-24 text-gray-400" />
{icon || (
<FileSearchIcon className="mb-4 inline-block size-24 text-gray-400" />
)}
<div className="text-primary-600 mb-2 text-3xl font-bold ">
{title}
</div>
<p className="text-gray-600">{description}</p>
</div>
<div className="flex justify-center space-x-3">
<Link href="/" className="btn-primary">
{t("goToHome")}
</Link>
<Link
href="https://support.rallly.co"
passHref={true}
className="btn-default"
>
<Button variant="primary" asChild>
<Link href="/">{t("goToHome")}</Link>
</Button>
<Button asChild>
<Link href="https://support.rallly.co" passHref={true}>
{t("support")}
</Link>
</Button>
</div>
</div>
</div>

View file

@ -0,0 +1,37 @@
"use client";
import { cn } from "@rallly/ui";
import { motion } from "framer-motion";
export const BonusItem = ({
className,
children,
delay = 0,
icon,
}: React.PropsWithChildren<{
className?: string;
delay?: number;
icon: React.ReactNode;
}>) => {
return (
<motion.div
transition={{
delay,
type: "spring",
bounce: 0.3,
}}
initial={{ opacity: 0, y: -20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: "all" }}
className="flex justify-center"
>
<div className="flex items-center justify-center gap-x-2.5 rounded-full border bg-gray-50 p-1 pr-6 shadow-sm">
<span
className={cn("bg-primary rounded-full p-2 text-gray-50", className)}
>
{icon}
</span>
<div className="text-sm font-semibold">{children}</div>
</div>
</motion.div>
);
};

View file

@ -1,75 +1,70 @@
import { cn } from "@rallly/ui";
import { m } from "framer-motion";
import { prisma } from "@rallly/database";
import { TFunction } from "i18next";
import {
CalendarCheck2Icon,
LanguagesIcon,
Users2Icon,
ZapIcon,
} from "lucide-react";
import { Trans } from "react-i18next/TransWithoutContext";
import { Trans } from "@/components/trans";
import { IconComponent } from "@/types";
import { BonusItem } from "@/components/home/bonus-item";
const Item = ({
icon: Icon,
className,
children,
delay = 0,
}: React.PropsWithChildren<{
icon: IconComponent;
className?: string;
delay?: number;
}>) => {
return (
<m.div
transition={{
delay,
type: "spring",
bounce: 0.3,
}}
initial={{ opacity: 0, y: -20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: "all" }}
className="flex justify-center"
>
<div className="flex items-center justify-center gap-x-2.5 rounded-full border bg-gray-50 p-1 pr-6 shadow-sm">
<span
className={cn("bg-primary rounded-full p-2 text-gray-50", className)}
>
<Icon className="size-4" />
</span>
<div className="text-sm font-semibold">{children}</div>
</div>
</m.div>
);
};
const Bonus: React.FunctionComponent = () => {
export async function Bonus({ t }: { t: TFunction }) {
const userCount = await prisma.user.count();
return (
<div className="mx-auto flex flex-wrap justify-center gap-2 whitespace-nowrap text-center sm:grid-cols-4 sm:gap-4 sm:gap-x-8">
<Item className="bg-indigo-600" icon={Users2Icon}>
<BonusItem
className="bg-indigo-600"
icon={<Users2Icon className="size-4" />}
>
<Trans
i18nKey="home:statsUsersRegistered"
defaults="45k+ registered users"
t={t}
i18nKey="statsUsersRegistered"
ns="home"
defaults="{count, number, ::compact-short} registered users"
values={{ count: userCount }}
/>
</Item>
<Item delay={0.25} className="bg-pink-600" icon={CalendarCheck2Icon}>
</BonusItem>
<BonusItem
delay={0.25}
className="bg-pink-600"
icon={<CalendarCheck2Icon className="size-4" />}
>
<Trans
i18nKey="home:statsPollsCreated"
defaults="100k+ polls created"
t={t}
ns="home"
i18nKey="statsPollsCreated"
values={{ count: 300 * 1000 }}
defaults="{count, number, ::compact-short}+ polls created"
/>
</Item>
<Item delay={0.5} className="bg-gray-800" icon={LanguagesIcon}>
</BonusItem>
<BonusItem
delay={0.5}
className="bg-gray-800"
icon={<LanguagesIcon className="size-4" />}
>
<Trans
i18nKey="home:statsLanguagesSupported"
t={t}
ns="home"
i18nKey="statsLanguagesSupported"
defaults="10+ languages supported"
/>
</Item>
<Item delay={0.75} className="bg-amber-500" icon={ZapIcon}>
<Trans i18nKey="home:noLoginRequired" defaults="No login required" />
</Item>
</BonusItem>
<BonusItem
delay={0.75}
className="bg-teal-500"
icon={<ZapIcon className="size-4" />}
>
<Trans
t={t}
ns="home"
i18nKey="noLoginRequired"
defaults="No login required"
/>
</BonusItem>
</div>
);
};
}
export default Bonus;

View file

@ -1,13 +1,16 @@
"use client";
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { preventWidows } from "@rallly/utils";
import { m } from "framer-motion";
import { motion } from "framer-motion";
import { ChevronRightIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import * as React from "react";
import { Trans } from "@/components/trans";
import { handwritten } from "@/fonts/handwritten";
import { linkToApp } from "@/lib/linkToApp";
const Screenshot = () => {
@ -15,7 +18,7 @@ const Screenshot = () => {
return (
<>
<m.div
<motion.div
transition={{
delay: 0.5,
type: "spring",
@ -40,8 +43,8 @@ const Screenshot = () => {
<span className="absolute left-1/2 top-full z-10 h-8 w-px -translate-x-1/2 bg-gray-800" />
<span className="absolute -bottom-12 left-1/2 z-10 inline-block size-3 origin-right -translate-x-1/2 rounded-full bg-gray-800 ring-1 ring-gray-800 ring-offset-2" />
<span className="absolute -bottom-12 left-1/2 z-10 inline-block size-3 origin-right -translate-x-1/2 animate-ping rounded-full bg-gray-800 ring-1 ring-gray-800 ring-offset-2" />
</m.div>
<m.div
</motion.div>
<motion.div
transition={{
type: "spring",
duration: 1,
@ -65,7 +68,7 @@ const Screenshot = () => {
setIsLoaded(true);
}}
/>
</m.div>
</motion.div>
</>
);
};
@ -80,15 +83,16 @@ export const MarketingHero = ({
callToAction: React.ReactNode;
}) => {
return (
<div className="mt-8 max-w-full text-center sm:mt-16">
<div className="mb-8">
<article className="max-w-full space-y-12 text-center">
<header className="sm:p-6">
<div>
<Link
locale="en"
href="/blog/rallly-3-0-self-hosting"
className="hover:ring-primary relative inline-flex items-center gap-x-3 rounded-full border bg-gray-100 py-1 pl-1 pr-4 text-sm leading-6 text-gray-600 hover:bg-gray-50 focus:ring-2 focus:ring-gray-300 focus:ring-offset-1"
>
<Badge variant="green">
<Trans i18nKey="home:new" defaults="New" />
<Trans ns="home" i18nKey="new" defaults="New" />
</Badge>
<span className="flex items-center gap-x-1">
<Trans
@ -99,13 +103,13 @@ export const MarketingHero = ({
</span>
</Link>
</div>
<h1 className="mb-4 text-4xl font-bold tracking-tight sm:text-5xl">
<h1 className="mb-2 mt-6 text-pretty text-2xl font-bold tracking-tight sm:mb-4 sm:text-5xl">
{preventWidows(title)}
</h1>
<p className="mx-auto max-w-3xl text-lg text-gray-500 sm:text-xl sm:leading-relaxed">
<h2 className="mx-auto max-w-3xl text-pretty text-lg text-gray-500 sm:text-xl sm:leading-relaxed">
{preventWidows(description)}
</p>
<div className="my-8 flex flex-col items-center justify-center gap-4">
</h2>
<div className="mt-8 flex flex-col items-center justify-center gap-4">
<Button
size="lg"
className="group rounded-full hover:shadow-md active:shadow-sm"
@ -117,13 +121,25 @@ export const MarketingHero = ({
<ChevronRightIcon className="-ml-1 size-5 transition-transform group-active:translate-x-1" />
</Link>
</Button>
<div className="whitespace-nowrap text-center text-sm font-medium text-gray-500">
<Trans i18nKey="home:hint" defaults="It's free! No login required." />
<p
className={cn(
"whitespace-nowrap text-center text-sm text-gray-600",
handwritten.className,
"decoration underline decoration-gray-300 decoration-2 underline-offset-8",
"skew-x-[-10deg]",
)}
>
<Trans
ns="home"
i18nKey="hint"
defaults="It's free! No login required."
/>
</p>
</div>
</div>
<div className="mt-16">
</header>
<section>
<Screenshot />
</div>
</div>
</section>
</article>
);
};

View file

@ -1,41 +0,0 @@
import { NewspaperIcon } from "lucide-react";
import Script from "next/script";
import PageLayout from "@/components/layouts/page-layout";
export const BlogLayout = ({ children }: React.PropsWithChildren) => {
return (
<div className="mx-auto w-full max-w-2xl">
<div>{children}</div>
<Script id="mailerlite" src="/static/scripts/mailerlite.js" />
<div className="mt-16 overflow-hidden rounded-md border bg-gray-200/50 backdrop-blur-sm">
<div className="flex flex-col gap-x-4 gap-y-2 p-6 pb-0 sm:flex-row">
<div>
<NewspaperIcon className="size-6" />
</div>
<div>
<div className="font-medium">Want to stay up to date?</div>
<div className="text-sm text-gray-500">
Subscribe to our newsletter to get updates on new features and
releases.
</div>
</div>
</div>
<div className="flex sm:ml-11">
<div
className="ml-embedded min-h-[88px] w-96 p-0"
data-form="h9YecB"
/>
</div>
</div>
</div>
);
};
export const getBlogLayout = (page: React.ReactElement) => {
return (
<PageLayout>
<BlogLayout>{page}</BlogLayout>
</PageLayout>
);
};

View file

@ -1,178 +0,0 @@
import { cn } from "@rallly/ui";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import { ChevronRightIcon, MenuIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { Trans } from "next-i18next";
import * as React from "react";
import { linkToApp } from "@/lib/linkToApp";
import Footer from "./page-layout/footer";
export interface PageLayoutProps {
children?: React.ReactNode;
}
const NavLink = ({
className,
...props
}: React.ComponentProps<typeof Link>) => {
const router = useRouter();
const isActive = router.pathname === props.href;
return (
<Link
className={cn(
"inline-flex items-center gap-x-2.5 rounded text-sm font-medium",
isActive ? "" : "hover:text-primary text-muted-foreground ",
className,
)}
{...props}
/>
);
};
const Menu: React.FunctionComponent<{ className: string }> = ({
className,
}) => {
return (
<nav className={className}>
<NavLink href="https://support.rallly.co/workflow/create">
<Trans i18nKey="howItWorks" defaults="How it Works" />
</NavLink>
<NavLink href="/pricing">
<Trans i18nKey="pricing" />
</NavLink>
<NavLink href="/blog">
<Trans i18nKey="blog" />
</NavLink>
<NavLink href="https://support.rallly.co">
<Trans i18nKey="support" />
</NavLink>
</nav>
);
};
const PageLayout: React.FunctionComponent<PageLayoutProps> = ({ children }) => {
return (
<div className="isolate flex min-h-[100vh] flex-col overflow-x-hidden bg-gray-100">
<svg
className="absolute inset-x-0 top-0 -z-10 h-[64rem] w-full stroke-gray-200 [mask-image:radial-gradient(800px_800px_at_center,white,transparent)]"
aria-hidden="true"
>
<defs>
<pattern
id="1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84"
width={220}
height={220}
x="50%"
y={-1}
patternUnits="userSpaceOnUse"
>
<path d="M.5 220V.5H220" fill="none" />
</pattern>
</defs>
<rect
width="100%"
height="100%"
strokeWidth={0}
fill="url(#1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84)"
/>
</svg>
<div className="mx-auto w-full max-w-full grow p-4 sm:max-w-7xl sm:px-8 sm:py-6">
<div className="mb-16 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>
<Menu className="hidden items-center space-x-8 lg:flex" />
</div>
<div className="flex items-center gap-4 sm:gap-8">
<Link
href={linkToApp("/login")}
className="hover:text-primary text-muted-foreground hidden rounded text-sm font-medium hover:no-underline hover:underline-offset-2 lg:inline-flex"
>
<Trans i18nKey="login" defaults="Login" />
</Link>
<Link
href={linkToApp()}
className="bg-primary hover:bg-primary-500 active:bg-primary-700 group inline-flex items-center gap-1 rounded-full py-1.5 pl-4 pr-3 text-sm font-medium text-white shadow-sm transition-transform"
>
<span>
<Trans i18nKey="goToApp" defaults="Go to app" />
</span>
<ChevronRightIcon className="inline-block size-4 transition-all group-active:translate-x-1" />
</Link>
<div className="flex items-center justify-center lg:hidden">
<DropdownMenu>
<DropdownMenuTrigger>
<MenuIcon className="size-6" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={16}>
<DropdownMenuItem asChild>
<Link
className="flex items-center gap-3 p-2 text-lg"
href="https://support.rallly.co/workflow/create"
>
<Trans i18nKey="howItWorks" defaults="How it Works" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className="flex items-center gap-3 p-2 text-lg"
href="/pricing"
>
<Trans i18nKey="pricing" defaults="Pricing" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className="flex items-center gap-3 p-2 text-lg"
href="/blog"
>
<Trans i18nKey="blog" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className="flex items-center gap-3 p-2 text-lg"
href="https://support.rallly.co"
>
<Trans i18nKey="support" />
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className="flex items-center gap-3 p-2 text-lg"
href={linkToApp("/login")}
>
<Trans i18nKey="login" defaults="Login" />
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<div className="grow">{children}</div>
<div className="pt-16 sm:pt-36">
<Footer />
</div>
</div>
</div>
);
};
export const getPageLayout = (page: React.ReactElement) => (
<PageLayout>{page}</PageLayout>
);
export default PageLayout;

View file

@ -1,24 +1,23 @@
import { m } from "framer-motion";
"use client";
import { motion } from "framer-motion";
import { ArrowUpRight } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { NextSeo } from "next-seo";
import React from "react";
import Bonus from "@/components/home/bonus";
import { Trans } from "@/components/trans";
// const UsedBy = () => {
// export const UsedBy = () => {
// return (
// <div>
// <h2 className="mx-auto mb-8 max-w-2xl text-center leading-relaxed">
// Used by employees of some of the world's most influential companies and
// Trusted by some of the world's most influential companies and
// organizations
// </h2>
// <div className="flex flex-wrap justify-center gap-8">
// <div className="relative h-12 w-24 grayscale hover:grayscale-0">
// <Image
// src="/icrc-logo.svg"
// src="/static/images/icrc-logo.svg"
// fill
// style={{ objectFit: "contain" }}
// alt="ICRC"
@ -77,7 +76,7 @@ import { Trans } from "@/components/trans";
// );
// };
// const Testimonials = () => {
// export const Testimonials = () => {
// return (
// <div>
// <h2 className="mb-12 text-center">Testimonials</h2>
@ -112,7 +111,7 @@ import { Trans } from "@/components/trans";
// );
// };
// const Testimonial = ({
// export const Testimonial = ({
// author,
// children,
// logo,
@ -158,7 +157,7 @@ const Mention = ({
delay?: number;
}>) => {
return (
<m.div
<motion.div
transition={{
delay,
type: "spring",
@ -171,11 +170,11 @@ const Mention = ({
>
<div className="flex items-start justify-between">{logo}</div>
<p className="grow text-center text-base">{children}</p>
</m.div>
</motion.div>
);
};
const MentionedBy = () => {
export const MentionedBy = () => {
return (
<div>
<div className="grid gap-8 md:grid-cols-4">
@ -256,9 +255,9 @@ const MentionedBy = () => {
);
};
const BigTestimonial = () => {
export const BigTestimonial = () => {
return (
<m.div
<motion.div
transition={{
duration: 1,
type: "spring",
@ -311,21 +310,10 @@ const BigTestimonial = () => {
</div>
</div>
</div>
</m.div>
</motion.div>
);
};
export const Marketing = ({
children,
...props
}: React.PropsWithChildren<{ title: string; description: string }>) => {
return (
<div className="space-y-12 sm:space-y-24">
<NextSeo {...props} />
{children}
<Bonus />
<BigTestimonial />
<MentionedBy />
</div>
);
export const Marketing = ({ children }: React.PropsWithChildren) => {
return <div className="space-y-12 sm:space-y-24">{children}</div>;
};

View file

@ -3,6 +3,6 @@ import { Trans as BaseTrans, useTranslation } from "next-i18next";
type TransWithContextProps = Omit<React.ComponentProps<typeof BaseTrans>, "t">;
export const Trans = (props: TransWithContextProps) => {
const { t } = useTranslation();
const { t } = useTranslation(props.ns);
return <BaseTrans t={t} {...props} />;
};

View file

@ -0,0 +1,6 @@
import { Playpen_Sans } from "next/font/google";
export const handwritten = Playpen_Sans({
subsets: ["latin"],
display: "swap",
});

View file

@ -0,0 +1,6 @@
import { Inter } from "next/font/google";
export const sans = Inter({
subsets: ["latin"],
display: "swap",
});

View file

@ -0,0 +1,54 @@
"use client";
import i18next, { Namespace } from "i18next";
import ICU from "i18next-icu";
import resourcesToBackend from "i18next-resources-to-backend";
import React from "react";
import {
I18nextProvider,
initReactI18next,
useTranslation as useTranslationOrg,
} from "react-i18next";
import { useAsync } from "react-use";
import { defaultNS, getOptions } from "./settings";
async function initTranslations(lng: string) {
const i18n = i18next
.use(initReactI18next)
.use(ICU)
.use(
resourcesToBackend(
(language: string, namespace: string) =>
import(`../../public/locales/${language}/${namespace}.json`),
),
);
await i18n.init(getOptions(lng));
return i18n;
}
export function useTranslation(ns?: Namespace) {
return useTranslationOrg(ns);
}
export function I18nProvider({
locale,
children,
}: {
locale: string;
children: React.ReactNode;
}) {
const res = useAsync(async () => {
return await initTranslations(locale);
});
if (!res.value) {
return null;
}
return (
<I18nextProvider i18n={res.value} defaultNS={defaultNS}>
{children}
</I18nextProvider>
);
}

View file

@ -0,0 +1,32 @@
import { createInstance, Namespace } from "i18next";
import ICU from "i18next-icu";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next/initReactI18next";
import { defaultNS, getOptions } from "./settings";
const initI18next = async (lng: string, ns: Namespace) => {
const i18nInstance = createInstance();
await i18nInstance
.use(initReactI18next)
.use(ICU)
.use(
resourcesToBackend(
(language: string, namespace: string) =>
import(`../../public/locales/${language}/${namespace}.json`),
),
)
.init(getOptions(lng, ns));
return i18nInstance;
};
export async function getTranslation(
locale: string,
ns: Namespace = defaultNS,
) {
const i18nextInstance = await initI18next(locale, ns);
return {
t: i18nextInstance.getFixedT(locale, Array.isArray(ns) ? ns[0] : ns),
i18n: i18nextInstance,
};
}

View file

@ -0,0 +1,21 @@
import allLanguages from "@rallly/languages";
import { InitOptions } from "i18next";
export const fallbackLng = "en";
export const languages = Object.keys(allLanguages);
export const defaultNS = "common";
export function getOptions(
lng = fallbackLng,
ns: string | string[] = defaultNS,
): InitOptions {
return {
// debug: true,
supportedLngs: languages,
fallbackLng,
lng,
fallbackNS: defaultNS,
defaultNS,
ns,
};
}

View file

@ -1,7 +0,0 @@
import { remark } from "remark";
import html from "remark-html";
export default async function markdownToHtml(markdown: string) {
const result = await remark().use(html).process(markdown);
return result.toString();
}

View file

@ -0,0 +1,42 @@
import { supportedLngs } from "@rallly/languages";
import languageParser from "accept-language-parser";
import { NextRequest, NextResponse } from "next/server";
export async function getLocaleFromHeader(req: NextRequest) {
const headers = req.headers;
const acceptLanguageHeader = headers.get("accept-language");
const localeFromHeader = acceptLanguageHeader
? languageParser.pick(supportedLngs, acceptLanguageHeader)
: null;
return localeFromHeader ?? "en";
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const localeInPath = supportedLngs.find(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
);
if (localeInPath) {
if (localeInPath === "en") {
// redirect to the same path without the locale
const newUrl = request.nextUrl.clone();
newUrl.pathname = pathname.replace(`/${localeInPath}`, "");
return NextResponse.redirect(newUrl);
}
return;
}
const locale = await getLocaleFromHeader(request);
request.nextUrl.pathname = `/${locale}${pathname}`;
if (locale === "en") {
return NextResponse.rewrite(request.nextUrl);
}
return NextResponse.redirect(request.nextUrl);
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|static|.*\\.).*)"],
};

View file

@ -1,29 +0,0 @@
import { FileSearchIcon } from "lucide-react";
import { GetStaticProps } from "next";
import { useTranslation } from "next-i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import React from "react";
import ErrorPage from "@/components/error-page";
import { NextPageWithLayout } from "@/types";
const Custom404: NextPageWithLayout = () => {
const { t } = useTranslation();
return (
<ErrorPage
icon={FileSearchIcon}
title={t("notFoundTitle")}
description={t("notFoundDescription")}
/>
);
};
export const getStaticProps: GetStaticProps = async ({ locale = "en" }) => {
return {
props: {
...(await serverSideTranslations(locale)),
},
};
};
export default Custom404;

View file

@ -1,110 +0,0 @@
import "tailwindcss/tailwind.css";
import "../style.css";
import { inject } from "@vercel/analytics";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { domMax, LazyMotion } from "framer-motion";
import { NextPage } from "next";
import { AppProps } from "next/app";
import { Inter } from "next/font/google";
import Head from "next/head";
import { useRouter } from "next/router";
import { appWithTranslation } from "next-i18next";
import { DefaultSeo, SoftwareAppJsonLd } from "next-seo";
import React from "react";
import { absoluteUrl } from "@/utils/absolute-url";
import * as nextI18nNextConfig from "../../next-i18next.config.js";
import { NextPageWithLayout } from "../types";
dayjs.extend(localizedFormat);
const inter = Inter({
subsets: ["latin"],
display: "swap",
});
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
const MyApp: NextPage<AppPropsWithLayout> = ({ Component, pageProps }) => {
const router = useRouter();
React.useEffect(() => {
if (process.env.NEXT_PUBLIC_ENABLE_ANALYTICS) {
// calling inject directly to avoid having this run for self-hosted instances
inject({ debug: false });
}
}, []);
const canonicalUrl = React.useMemo(() => {
const path = router.asPath === "/" ? "" : router.asPath;
if (router.locale === router.defaultLocale) {
return absoluteUrl(path);
} else {
return absoluteUrl(`/${router.locale}${path}`);
}
}, [router.defaultLocale, router.locale, router.asPath]);
const getLayout = Component.getLayout ?? ((page) => page);
return (
<LazyMotion features={domMax}>
<DefaultSeo
canonical={canonicalUrl}
openGraph={{
siteName: "Rallly",
type: "website",
url: canonicalUrl,
images: [
{
url: absoluteUrl("/og-image-1200.png"),
width: 1200,
height: 630,
alt: "Rallly | Schedule group meetings",
type: "image/png",
},
],
}}
facebook={{
appId: "920386682263077",
}}
twitter={{
handle: "@imlukevella",
site: "@ralllyco",
cardType: "summary_large_image",
}}
/>
<SoftwareAppJsonLd
name="Rallly"
aggregateRating={{
ratingValue: "4.4",
bestRating: "5",
worstRating: "0",
ratingCount: "11",
}}
price="0"
priceCurrency="USD"
operatingSystem="All"
applicationCategory="Scheduling"
description="Group scheduling made easy. Create polls, send links, and get feedback from your participants in seconds."
/>
<Head>
<meta
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5, user-scalable=yes"
/>
</Head>
<style jsx global>{`
html {
--font-inter: ${inter.style.fontFamily};
}
`}</style>
{getLayout(<Component {...pageProps} />)}
</LazyMotion>
);
};
export default appWithTranslation(MyApp, nextI18nNextConfig);

View file

@ -1,107 +0,0 @@
import { Head, Html, Main, NextScript } from "next/document";
import React from "react";
export default function Document() {
return (
<Html>
<Head>
<link
rel="apple-touch-icon-precomposed"
sizes="57x57"
href="/apple-touch-icon-57x57.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="114x114"
href="/apple-touch-icon-114x114.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="72x72"
href="/apple-touch-icon-72x72.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="144x144"
href="/apple-touch-icon-144x144.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="60x60"
href="/apple-touch-icon-60x60.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="120x120"
href="/apple-touch-icon-120x120.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="76x76"
href="/apple-touch-icon-76x76.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="152x152"
href="/apple-touch-icon-152x152.png"
/>
<link
rel="icon"
type="image/png"
href="/favicon-196x196.png"
sizes="196x196"
/>
<link
rel="icon"
type="image/png"
href="/favicon-96x96.png"
sizes="96x96"
/>
<link
rel="icon"
type="image/png"
href="/favicon-32x32.png"
sizes="32x32"
/>
<link
rel="icon"
type="image/png"
href="/favicon-16x16.png"
sizes="16x16"
/>
<link
rel="icon"
type="image/png"
href="/favicon-128x128.png"
sizes="128x128"
/>
<meta name="application-name" content="Rallly" />
<meta name="msapplication-TileColor" content="#FFFFFF" />
<meta name="msapplication-TileImage" content="/mstile-144x144.png" />
<meta
name="msapplication-square70x70logo"
content="/mstile-70x70.png"
/>
<meta
name="msapplication-square150x150logo"
content="/mstile-150x150.png"
/>
<meta
name="msapplication-wide310x150logo"
content="/mstile-310x150.png"
/>
<meta
name="msapplication-square310x310logo"
content="/mstile-310x310.png"
/>
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<meta name="theme-color" content="#F3F4F6" />
</Head>
<body>
<Main />
<NextScript />
<div id="portal"></div>
</body>
</Html>
);
}

View file

@ -1,38 +0,0 @@
/**
* NOTE: This requires `@sentry/nextjs` version 7.3.0 or higher.
*
* This page is loaded by Nextjs:
* - on the server, when data-fetching methods throw or reject
* - on the client, when `getInitialProps` throws or rejects
* - on the client, when a React lifecycle method throws or rejects, and it's
* caught by the built-in Nextjs error boundary
*
* See:
* - https://nextjs.org/docs/basic-features/data-fetching/overview
* - https://nextjs.org/docs/api-reference/data-fetching/get-initial-props
* - https://reactjs.org/docs/error-boundaries.html
*/
import * as Sentry from "@sentry/nextjs";
import type { NextPage } from "next";
import type { ErrorProps } from "next/error";
import NextErrorComponent from "next/error";
const CustomErrorComponent: NextPage<ErrorProps> = (props) => {
// If you're using a Nextjs version prior to 12.2.1, uncomment this to
// compensate for https://github.com/vercel/next.js/issues/8592
// Sentry.captureUnderscoreErrorException(props);
return <NextErrorComponent statusCode={props.statusCode} />;
};
CustomErrorComponent.getInitialProps = async (contextData) => {
// In case this is running in a serverless function, await this in order to give Sentry
// time to send the error before the lambda exits
await Sentry.captureUnderscoreErrorException(contextData);
// This will contain the status code of the response
return NextErrorComponent.getInitialProps(contextData);
};
export default CustomErrorComponent;

View file

@ -1,45 +0,0 @@
import { useTranslation } from "next-i18next";
import { MarketingHero } from "@/components/home/hero";
import { getPageLayout } from "@/components/layouts/page-layout";
import { Marketing } from "@/components/marketing";
import { Trans } from "@/components/trans";
import { NextPageWithLayout } from "@/types";
import { getStaticTranslations } from "@/utils/page-translations";
const Page: NextPageWithLayout = () => {
const { t } = useTranslation(["home"]);
return (
<Marketing
title={t("home:availabilityPollMetaTitle", {
defaultValue: "Availability Poll | Streamline Scheduling with Rallly",
})}
description={t("home:availabilityPollMetaDescription", {
defaultValue:
"Schedule meetings and events seamlessly with Rallly's Availability Poll. Ensure everyone's availability is considered for a smooth and efficient planning experience.",
})}
>
<MarketingHero
title={t("home:availabilityPollTitle", {
defaultValue: "Availability Polls",
})}
description={t("home:availabilityPollDescription", {
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
i18nKey="home:availabilityPollCta"
defaults="Create an Availability Poll"
/>
}
/>
</Marketing>
);
};
Page.getLayout = getPageLayout;
export default Page;
export const getStaticProps = getStaticTranslations(["home"]);

View file

@ -1,42 +0,0 @@
import { useTranslation } from "next-i18next";
import { MarketingHero } from "@/components/home/hero";
import { getPageLayout } from "@/components/layouts/page-layout";
import { Marketing } from "@/components/marketing";
import { Trans } from "@/components/trans";
import { NextPageWithLayout } from "@/types";
import { getStaticTranslations } from "@/utils/page-translations";
const Page: NextPageWithLayout = () => {
const { t } = useTranslation(["home"]);
return (
<Marketing
title={t("home:doodleAlternativeMetaTitle", {
defaultValue: "Best Free Doodle Alternative | Rallly",
})}
description={t("home:doodleAlternativeMetaDescription", {
defaultValue:
"Looking for a Doodle alternative? Try Rallly! It's free, easy to use, and doesn't require an account.",
})}
>
<MarketingHero
title={t("home:doodleAlternative", {
defaultValue: "The Best Free Doodle Alternative",
})}
description={t("home:doodleAlternativeDescription", {
defaultValue:
"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.",
})}
callToAction={
<Trans i18nKey="home:createAPoll" defaults="Create a Meeting Poll" />
}
/>
</Marketing>
);
};
Page.getLayout = getPageLayout;
export default Page;
export const getStaticProps = getStaticTranslations(["home"]);

View file

@ -1,57 +0,0 @@
import { GetStaticProps } from "next";
import { useTranslation } from "next-i18next";
import { NextSeo } from "next-seo";
import Posts from "@/components/blog/posts";
import { getBlogLayout } from "@/components/layouts/blog-layout";
import { getAllPosts } from "@/lib/api";
import { NextPageWithLayout, Post } from "@/types";
import { getStaticTranslations } from "@/utils/page-translations";
type Props = {
allPosts: Post[];
};
const Page: NextPageWithLayout<Props> = ({ allPosts }) => {
const { t } = useTranslation();
return (
<div>
<NextSeo
title={t("blog:blogTitle", {
defaultValue: "Rallly - Blog",
})}
description={t("blog:blogDescription", {
defaultValue: "News, updates and announcement about Rallly.",
})}
/>
<div>
<Posts posts={allPosts} />
</div>
</div>
);
};
Page.getLayout = getBlogLayout;
export default Page;
export const getStaticProps: GetStaticProps = async (ctx) => {
const allPosts = getAllPosts([
"title",
"date",
"slug",
"author",
"coverImage",
"excerpt",
]);
const res = await getStaticTranslations(["blog"])(ctx);
if ("props" in res) {
return {
props: { allPosts, ...res.props },
};
} else {
return res;
}
};

View file

@ -1,137 +0,0 @@
import { ArrowLeftIcon } from "lucide-react";
import { GetStaticPropsContext } from "next";
import ErrorPage from "next/error";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { NextSeo } from "next-seo";
import PostBody from "@/components/blog/post-body";
import PostHeader from "@/components/blog/post-header";
import { getBlogLayout } from "@/components/layouts/blog-layout";
import { getAllPosts, getPostBySlug } from "@/lib/api";
import markdownToHtml from "@/lib/markdownToHtml";
import { NextPageWithLayout, Post } from "@/types";
import { absoluteUrl } from "@/utils/absolute-url";
import { getStaticTranslations } from "@/utils/page-translations";
type Props = {
post: Post;
morePosts: Post[];
};
const Page: NextPageWithLayout<Props> = ({ post }) => {
const router = useRouter();
if (!router.isFallback && !post?.slug) {
return <ErrorPage statusCode={404} />;
}
return (
<div>
<NextSeo
title={post.title}
description={post.excerpt}
openGraph={{
title: post.title,
description: post.excerpt,
url: absoluteUrl(`/blog/${post.slug}`),
images: [
{
url: absoluteUrl("/api/og-image", {
title: post.title,
excerpt: post.excerpt,
}),
width: 1200,
height: 630,
alt: post.title,
type: "image/png",
},
],
}}
/>
<nav className="mb-2">
<Link
className="text-muted-foreground hover:text-primary inline-flex items-center gap-x-2 text-sm font-medium"
href="/blog"
>
<ArrowLeftIcon className="size-4" /> All Posts
</Link>
</nav>
<article>
<Head>
<title>{post.title}</title>
</Head>
<PostHeader title={post.title} date={post.date} />
<PostBody content={post.content} />
<div className="mt-8 flex items-center gap-x-4">
<Image
src="/static/images/luke-vella.jpg"
width={48}
height={48}
className="rounded-full"
alt="Luke Vella"
/>
<div>
<div className="font-medium leading-none">Luke Vella</div>
<div>
<Link
className="text-muted-foreground hover:text-primary text-sm"
href="https://twitter.com/imlukevella"
>
@imlukevella
</Link>
</div>
</div>
</div>
</article>
</div>
);
};
Page.getLayout = getBlogLayout;
export default Page;
export async function getStaticProps(ctx: GetStaticPropsContext) {
const res = await getStaticTranslations(["blog"])(ctx);
const post = getPostBySlug(ctx.params?.slug as string, [
"title",
"date",
"slug",
"author",
"excerpt",
"content",
]);
const content = await markdownToHtml(post.content || "");
if ("props" in res) {
return {
props: {
post: {
...post,
content,
},
...res.props,
},
};
}
return res;
}
export async function getStaticPaths() {
const posts = getAllPosts(["slug"]);
return {
paths: posts.map((post) => {
return {
params: {
slug: post.slug,
},
};
}),
fallback: false,
};
}

View file

@ -1,41 +0,0 @@
import { useTranslation } from "next-i18next";
import { MarketingHero } from "@/components/home/hero";
import { getPageLayout } from "@/components/layouts/page-layout";
import { Marketing } from "@/components/marketing";
import { Trans } from "@/components/trans";
import { NextPageWithLayout } from "@/types";
import { getStaticTranslations } from "@/utils/page-translations";
const Page: NextPageWithLayout = () => {
const { t } = useTranslation(["home"]);
return (
<Marketing
title={t("home:findATimeMetaTitle", {
defaultValue: "Find a Time to Meet | Rallly",
})}
description={t("home:findATimeMetaDescription", {
defaultValue: "Create a meeting poll in seconds, no login required.",
})}
>
<MarketingHero
title={t("home:findATimeTitle", {
defaultValue: "Find a Time to Meet",
})}
description={t("home:findATimeDescription", {
defaultValue:
"Create a meeting poll and let your participants vote on the best time to meet.",
})}
callToAction={
<Trans i18nKey="home:createAPoll" defaults="Create a Meeting Poll" />
}
/>
</Marketing>
);
};
Page.getLayout = getPageLayout;
export default Page;
export const getStaticProps = getStaticTranslations(["home"]);

View file

@ -1,45 +0,0 @@
import { useTranslation } from "next-i18next";
import { MarketingHero } from "@/components/home/hero";
import { getPageLayout } from "@/components/layouts/page-layout";
import { Marketing } from "@/components/marketing";
import { Trans } from "@/components/trans";
import { NextPageWithLayout } from "@/types";
import { getStaticTranslations } from "@/utils/page-translations";
const Page: NextPageWithLayout = () => {
const { t } = useTranslation(["home"]);
return (
<Marketing
title={t("home:freeSchedulingPollMetaTitle", {
defaultValue: "Free Scheduling Poll | Rallly",
})}
description={t("home:freeSchedulingPollMetaDescription", {
defaultValue:
"Create a free scheduling poll in seconds. Ideal for organizing meetings, events, conferences, sports teams and more.",
})}
>
<MarketingHero
title={t("home:freeSchedulingPollTitle", {
defaultValue: "Looking for a free scheduling poll?",
})}
description={t("home:freeSchedulingPollDescription", {
defaultValue:
"Rallly let's you create beautiful and easy to use scheduling polls so you can find the best time for your next event.",
})}
callToAction={
<Trans
i18nKey="home:createASchedulingPoll"
defaults="Create a Scheduling Poll"
/>
}
/>
</Marketing>
);
};
Page.getLayout = getPageLayout;
export default Page;
export const getStaticProps = getStaticTranslations(["home"]);

View file

@ -1,37 +0,0 @@
import { useTranslation } from "next-i18next";
import { MarketingHero } from "@/components/home/hero";
import { getPageLayout } from "@/components/layouts/page-layout";
import { Marketing } from "@/components/marketing";
import { Trans } from "@/components/trans";
import { NextPageWithLayout } from "@/types";
import { getStaticTranslations } from "@/utils/page-translations";
const Page: NextPageWithLayout = () => {
const { t } = useTranslation(["home"]);
return (
<Marketing
title={t("home:metaTitle", {
defaultValue: "Rallly - Schedule Group Meetings",
})}
description={t("home:metaDescription", {
defaultValue:
"Create polls and vote to find the best day or time. A free alternative to Doodle.",
})}
>
<MarketingHero
title={t("home:headline")}
description={t("home:subheading", {
defaultValue: "Streamline your scheduling process and save time",
})}
callToAction={<Trans i18nKey="getStarted" defaults="Get started" />}
/>
</Marketing>
);
};
Page.getLayout = getPageLayout;
export default Page;
export const getStaticProps = getStaticTranslations(["home"]);

View file

@ -1,356 +0,0 @@
import { pricingData } from "@rallly/billing/pricing";
import { Badge } from "@rallly/ui/badge";
import {
BillingPlan,
BillingPlanDescription,
BillingPlanHeader,
BillingPlanPeriod,
BillingPlanPerk,
BillingPlanPerks,
BillingPlanPrice,
BillingPlanTitle,
} from "@rallly/ui/billing-plan";
import { Button } from "@rallly/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@rallly/ui/tabs";
import { TrendingUpIcon } from "lucide-react";
import { GetStaticProps } from "next";
import Link from "next/link";
import { useTranslation } from "next-i18next";
import { NextSeo } from "next-seo";
import React from "react";
import { getPageLayout } from "@/components/layouts/page-layout";
import { Trans } from "@/components/trans";
import { linkToApp } from "@/lib/linkToApp";
import { NextPageWithLayout } from "@/types";
import { getStaticTranslations } from "@/utils/page-translations";
export const UpgradeButton = ({
children,
annual,
}: React.PropsWithChildren<{ annual?: boolean }>) => {
return (
<form method="POST" action={linkToApp("/api/stripe/checkout")}>
<input
type="hidden"
name="period"
value={annual ? "yearly" : "monthly"}
/>
<input
type="hidden"
name="return_path"
value={window.location.pathname}
/>
<Button className="w-full" type="submit" variant="primary">
{children || <Trans i18nKey="pricing:upgrade" defaults="Upgrade" />}
</Button>
</form>
);
};
const PriceTables = ({
pricingData,
}: {
pricingData: {
monthly: {
amount: number;
currency: string;
};
yearly: {
amount: number;
currency: string;
};
};
}) => {
const [tab, setTab] = React.useState("yearly");
return (
<Tabs value={tab} onValueChange={setTab}>
<div className="flex justify-center">
<TabsList className="mb-4 sm:mb-6">
<TabsTrigger value="monthly">
<Trans i18nKey="pricing:billingPeriodMonthly" defaults="Monthly" />
</TabsTrigger>
<TabsTrigger value="yearly" className="inline-flex gap-x-2.5">
<Trans i18nKey="pricing:billingPeriodYearly" defaults="Yearly" />
</TabsTrigger>
</TabsList>
</div>
<div className="mx-auto grid gap-4 sm:gap-6 md:grid-cols-2">
<BillingPlan>
<BillingPlanHeader>
<BillingPlanTitle>
<Trans i18nKey="pricing:planFree" defaults="Free" />
</BillingPlanTitle>
<BillingPlanDescription>
<Trans
i18nKey="pricing:planFreeDescription"
defaults="For casual users"
/>
</BillingPlanDescription>
</BillingPlanHeader>
<div>
<BillingPlanPrice>$0</BillingPlanPrice>
<BillingPlanPeriod>
<Trans i18nKey="pricing:freeForever" defaults="free forever" />
</BillingPlanPeriod>
</div>
<hr />
<Button asChild className="w-full">
<Link href={linkToApp("/")}>
<Trans i18nKey="common:getStarted" defaults="Get started" />
</Link>
</Button>
<BillingPlanPerks>
<BillingPlanPerk>
<Trans
i18nKey="pricing:limitedAccess"
defaults="Access to core features"
/>
</BillingPlanPerk>
<BillingPlanPerk>
<Trans
i18nKey="pricing:pollsDeleted"
defaults="Polls are automatically deleted once they become inactive"
/>
</BillingPlanPerk>
</BillingPlanPerks>
</BillingPlan>
<BillingPlan className="relative">
<BillingPlanHeader>
<BillingPlanTitle>
<Trans i18nKey="pricing:planPro" defaults="Pro" />
</BillingPlanTitle>
<BillingPlanDescription>
<Trans
i18nKey="pricing:planProDescription"
defaults="For power users and professionals"
/>
</BillingPlanDescription>
</BillingPlanHeader>
<TabsContent value="yearly">
<div className="flex items-center gap-x-2">
<BillingPlanPrice>
${pricingData.yearly.amount / 100}
</BillingPlanPrice>
<Badge variant="green" className="inline-flex gap-2">
<Trans
i18nKey="pricing:annualBenefit"
defaults="{count} months free!"
values={{
count: 4,
}}
/>
</Badge>
</div>
<BillingPlanPeriod>
<Trans
i18nKey="pricing:yearlyBillingDescription"
defaults="per year"
/>
</BillingPlanPeriod>
</TabsContent>
<TabsContent value="monthly">
<BillingPlanPrice>
${pricingData.monthly.amount / 100}
</BillingPlanPrice>
<BillingPlanPeriod>
<Trans
i18nKey="pricing:monthlyBillingDescription"
defaults="per month"
/>
</BillingPlanPeriod>
</TabsContent>
<hr />
<Button asChild variant="primary" className="w-full">
<Link href={linkToApp("/settings/billing")}>
<Trans i18nKey="pricing:upgrade" defaults="Go to billing" />
</Link>
</Button>
<BillingPlanPerks>
<BillingPlanPerk pro={true}>
<Trans
i18nKey="pricing:accessAllFeatures"
defaults="Access all features"
/>
</BillingPlanPerk>
<BillingPlanPerk pro={true}>
<Trans
i18nKey="pricing:keepPollsIndefinitely"
defaults="Keep polls indefinitely"
/>
</BillingPlanPerk>
<BillingPlanPerk pro={true}>
<Trans
i18nKey="pricing:getEarlyAccess"
defaults="Get early access to new features"
/>
</BillingPlanPerk>
</BillingPlanPerks>
</BillingPlan>
</div>
</Tabs>
);
};
const FAQ = () => {
return (
<div className="rounded-md p-6">
<h2 className="mb-4 sm:mb-6">
<Trans i18nKey="pricing:faq" defaults="Frequently Asked Questions" />
</h2>
<div className="space-y-4 sm:space-y-6">
<div className="grid grid-cols-1 gap-x-8 gap-y-2">
<h3 className="col-span-1">
<Trans
i18nKey="pricing:canUseFree"
defaults="Can I use Rallly for free?"
/>
</h3>
<p className="col-span-2 text-sm leading-relaxed text-slate-600">
<Trans
i18nKey="pricing:canUseFreeAnswer2"
defaults="Yes, most of Rallly's features are free and many users will never need to pay for anything. However, there are some features that are only available to paying customers. These features are designed to help you get the most out of Rallly."
/>
</p>
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-2">
<h3 className="col-span-1">
<Trans
i18nKey="pricing:whyUpgrade"
defaults="Why should I upgrade?"
/>
</h3>
<p className="col-span-2 text-sm leading-relaxed text-slate-600">
<Trans
i18nKey="pricing:whyUpgradeAnswer2"
defaults="Upgrading to a paid plan makes sense if you use Rallly often or use it for work. The current subscription rate is a special early adopter rate and will increase in the future. By upgrading now, you will get early access to new, high-quality scheduling tools as they are released and lock in your subscription rate so you won't be affected by future price increases."
/>
</p>
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-2">
<h3 className="col-span-1">
<Trans
i18nKey="pricing:whenPollInactive"
defaults="When does a poll become inactive?"
/>
</h3>
<p className="col-span-2 text-sm leading-relaxed text-slate-600">
<Trans
i18nKey="pricing:whenPollInactiveAnswer"
defaults="Polls become inactive when all date options are in the past AND the poll has not been accessed for over 30 days. Inactive polls are automatically deleted if you do not have a paid subscription."
/>
</p>
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-2">
<h3 className="col-span-1">
<Trans
i18nKey="pricing:howToUpgrade"
defaults="How do I upgrade to a paid plan?"
/>
</h3>
<p className="col-span-2 text-sm leading-relaxed text-slate-600">
<Trans
i18nKey="pricing:howToUpgradeAnswer"
components={{
a: (
<Link
className="text-link"
href={linkToApp("/settings/billing")}
/>
),
b: <strong />,
}}
defaults="To upgrade, you can go to your <a>billing settings</a> and click on <b>Upgrade</b>."
/>
</p>
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-2">
<h3 className="col-span-1">
<Trans
i18nKey="pricing:cancelSubscription"
defaults="How do I cancel my subscription?"
/>
</h3>
<p className="col-span-2 text-sm leading-relaxed text-slate-600">
<Trans
i18nKey="pricing:cancelSubscriptionAnswer"
components={{
a: (
<Link
className="text-link"
href={linkToApp("/settings/billing")}
/>
),
b: <strong />,
}}
defaults="You can cancel your subscription at any time by going to your <a>billing settings</a>. Once you cancel your subscription, you will still have access to your paid plan until the end of your billing period. After that, you will be downgraded to a free plan."
/>
</p>
</div>
</div>
</div>
);
};
const Page: NextPageWithLayout = () => {
const { t } = useTranslation(["pricing"]);
return (
<div className="mx-auto max-w-3xl">
<NextSeo
title={t("common:pricing", { defaultValue: "Pricing" })}
description={t("pricing:pricingDescription")}
/>
<div className="mb-4 text-center sm:mb-6">
<h1 className="mb-4 text-4xl font-bold tracking-tight">
<Trans i18nKey="pricing:pricing">Pricing</Trans>
</h1>
<p className="text-muted-foreground text-lg">
<Trans
i18nKey="pricing:pricingDescription"
defaults="Get started for free. No login required."
/>
</p>
</div>
<div className="space-y-4 sm:space-y-6">
<PriceTables pricingData={pricingData} />
<div className="rounded-md border bg-gradient-to-b from-cyan-50 to-cyan-50/60 px-5 py-4 text-cyan-800">
<div className="mb-2">
<TrendingUpIcon className="text-indigo mr-2 mt-0.5 size-6 shrink-0" />
</div>
<div className="mb-1 flex items-center gap-x-2">
<h3 className="text-sm">
<Trans
i18nKey="pricing:upgradeNowSaveLater"
defaults="Upgrade now, save later"
/>
</h3>
</div>
<p className="text-sm">
<Trans
i18nKey="pricing:earlyAdopterDescription"
defaults="As an early adopter, you'll lock in your subscription rate and won't be affected by future price increases."
/>
</p>
</div>
<hr />
<FAQ />
</div>
</div>
);
};
Page.getLayout = getPageLayout;
export default Page;
export const getStaticProps: GetStaticProps = async (ctx) => {
const res = await getStaticTranslations(["pricing"])(ctx);
if ("props" in res) {
return {
props: {
...res.props,
},
};
}
return res;
};

View file

@ -1,88 +0,0 @@
import { NextSeo } from "next-seo";
import PageLayout from "@/components/layouts/page-layout";
import { getStaticTranslations } from "@/utils/page-translations";
const PrivacyPolicy = () => {
return (
<PageLayout>
<NextSeo title="Terms of Use" />
<div className="prose mx-auto my-16 max-w-3xl rounded-lg bg-white p-8 shadow-md">
<h1>Terms of Use</h1>
<p>Last updated: 4 July 2023</p>
<p>
{`This website is operated by Stack Snap Ltd. References made to "we",
"us" or "our" pertain directly and exclusively to Stack Snap Ltd. We
provide you, as the user, with this website, which includes all the
information, tools, and services accessible on it, under the
stipulation that you agree to all the terms, conditions, policies, and
notices laid out herein.`}
</p>
<h2>1. Use of Website</h2>
<p>
You may use this website only for lawful purposes and in accordance
with these terms of use. You must not use this website in any way that
causes or may cause damage to the website or impairment of the
availability or accessibility of the website. You must not use this
website in any way that is unlawful, fraudulent, or harmful.
</p>
<h2>2. Limitation of Liability</h2>
<p>
We will not be liable for any damages arising from the use or
inability to use this website, including but not limited to direct,
indirect, incidental, consequential, or punitive damages.
</p>
<h2>3. No Refund Policy</h2>
<p>
All purchases and transactions made through this website are final and
non-refundable. We do not provide refunds for any reason. By making a
purchase or engaging in a transaction on this website, you acknowledge
and agree to our no refund policy.
</p>
<h2>4. Links to Third-Party Websites</h2>
<p>
This website may contain links to third-party websites that are not
owned or controlled by rallly.co. We have no control over, and assume
no responsibility for, the content, privacy policies, or practices of
any third-party websites.
</p>
<h2>5. Modifications to Terms of Use</h2>
<p>
We reserve the right to modify these terms of use at any time, without
prior notice to you. Your continued use of this website after any
modifications to these terms of use will constitute your acceptance of
such modifications.
</p>
<h2>6. Contact</h2>
<p>
If you have any questions about these terms of use, please contact us
at <a href="mailto:support@rallly.co">support@rallly.co</a>.
</p>
<p className="text-sm font-semibold">
Stack Snap Ltd.
<br />
The Gallery 14
<br />
Upland Road
<br />
London
<br />
SE22 9EE
<br />
United Kingdom
</p>
</div>
</PageLayout>
);
};
export default PrivacyPolicy;
export const getStaticProps = getStaticTranslations();

View file

@ -1,42 +0,0 @@
import { useTranslation } from "next-i18next";
import { MarketingHero } from "@/components/home/hero";
import { getPageLayout } from "@/components/layouts/page-layout";
import { Marketing } from "@/components/marketing";
import { Trans } from "@/components/trans";
import { NextPageWithLayout } from "@/types";
import { getStaticTranslations } from "@/utils/page-translations";
const Page: NextPageWithLayout = () => {
const { t } = useTranslation(["home"]);
return (
<Marketing
title={t("home:when2meetAlternativeMetaTitle", {
defaultValue: "Best When2Meet Alternative: Rallly",
})}
description={t("home:when2meetAlternativeMetaDescription", {
defaultValue:
"Find a better way to schedule meetings with Rallly, the top free alternative to When2Meet. Easy to use and free.",
})}
>
<MarketingHero
title={t("home:when2meetAlternative", {
defaultValue: "Still using When2Meet?",
})}
description={t("home:when2meetAlternativeDescription", {
defaultValue:
"Create professional, ad-free meetings polls for free with Rallly.",
})}
callToAction={
<Trans i18nKey="home:createAPoll" defaults="Create a Meeting Poll" />
}
/>
</Marketing>
);
};
Page.getLayout = getPageLayout;
export default Page;
export const getStaticProps = getStaticTranslations(["home"]);

View file

@ -38,6 +38,8 @@ You can expect to see more features being added soon such as:
I've also published a post outlining [the future of Rallly](/blog/the-future-of-rallly) which involves adding more solutions on top of the much loved polling feature. With your help we can turn this vision into a reality.
---
Thank you for your support and if you'd like to be notified when Rallly Pro is available you can subscribe below.
Happy scheduling :)

View file

@ -9,7 +9,7 @@ It's been over a month since the [Rallly Pro launch](/blog/rallly-pro-launch).
A heartfelt thank you to each of you who subscribed your support means a lot.
Knowing that there is value in what I've built is a huge morale boost and drives me to push the boundaries even further!
## 📝 What's new?
## What's new?
I've been burning the midnight oil to roll out features based on the [feedback](https://feedback.rallly.co) I've received. Here's some highlights:
@ -22,7 +22,7 @@ You can keep your participant details private by hiding the participant list or
**Table View Revamp** - The table view has been overhauled to improve it's readability. There's a lot less repetition and clutter making it easier to work with long lists of options.
## 🎉 Grab Rallly Pro at Launch Pricing!
## Grab Rallly Pro at Launch Pricing
If you haven't jumped aboard yet, the launch price for Rallly Pro is still up for grabs.
**As an early adopter, you will not be impacted by future price increases**, so you will be benefitting from significantly reduced rates once more features are added.
@ -36,7 +36,7 @@ The current price is about 3 to 4 times cheaper than competitors in the same spa
My aim is to eventually price Rallly Pro to be in line with similar products but I'll be adjusting the price in stages as more features are released. The next stage will be **$7/month** or **$42/year**.
The updated prices will kick in on **1st September 2023** (so you have until then to lock in the current rate).
## 👍 Thank you
## Thank you
To all the subscribers and sponsors who make it possible for me to continue my work on Rallly.

View file

@ -25,15 +25,11 @@ The update includes some sweet animations that are not only delightful but also
to the right path.
The name field and save button were often missed so the animations should help draw attention to them.
<p style="text-align:center">![Device data](/static/images/animations.gif)</p>
![Device data](/static/images/animations.gif)
## Increased touch area
<div className="text-center">
<div className="inline-block">
![Device data](/static/images/touchable-area.png)
</div>
</div>
![Device data](/static/images/touchable-area.png)
Previously, you would need to touch the tiny checkbox on the right to toggle a vote. Quite annoying if
you're on a tiny screen. Well that's not the case anymore. The entire row is now touchable so you don't

View file

@ -8,32 +8,32 @@ Rallly has always been about simplifying group scheduling, but my aim is to evol
## The Workflow
![funnel](/static/images/the-future-of-rallly/funnel.svg)
Envisioned as a three-component funnel, this new workflow is designed to simplify and streamline your scheduling process.
![funnel](/static/images/the-future-of-rallly/funnel.svg)
## Breaking it Down
Here's how these components will work together:
### Availability Grids
![Availability Grids](/static/images/the-future-of-rallly/availability-grid.svg)
When you're scheduling an event, getting a general idea of participants' availability can be incredibly helpful. The Availability Grid will let users select a broad range, and participants can then highlight areas on the grid to indicate when they are available. This will give hosts a quick snapshot of potential suitable time slots.
![Availability Grids](/static/images/the-future-of-rallly/availability-grid.svg)
### Polls
![Polls](/static/images/the-future-of-rallly/poll.svg)
Polling is at the core of what Rallly does, and it fits seamlessly into this new workflow. Once you've gathered general availability, hosts will be able to narrow down a few options and let participants vote for their preferred dates.
![Polls](/static/images/the-future-of-rallly/poll.svg)
### RSVP
![RSVP](/static/images/the-future-of-rallly/rsvp.svg)
After you've narrowed down to a single date through polling, the RSVP component will come into play. This page will let participants confirm their attendance and access detailed information about your event.
![RSVP](/static/images/the-future-of-rallly/rsvp.svg)
## Tying it Together
The above components will follow this order: Availability Grid -> Poll -> RSVP. However, the beauty of this workflow is its flexibility. You can start from any part of the workflow that fits your needs. For example, you might start with an Availability Grid, select a few options to create a poll, and finish with an RSVP. Alternatively, you could select a single option from an Availability Grid to create an RSVP, or you might prefer to start directly with a poll or RSVP.

View file

@ -7,10 +7,7 @@
@apply border-border;
}
body {
@apply text-foreground overflow-y-auto bg-gray-200/50;
font-feature-settings:
"rlig" 1,
"calt" 1;
@apply text-foreground h-full overflow-y-auto bg-gray-100 font-sans;
}
html {
@apply h-full font-sans text-base;
@ -46,6 +43,60 @@
}
@layer components {
.blog-content {
h2,
h3,
h4 {
@apply mb-4 mt-12;
}
h2 {
@apply text-2xl font-bold;
}
h3 {
@apply text-xl font-semibold;
}
h4 {
@apply text-lg font-semibold;
}
p {
@apply mb-6 leading-relaxed;
}
p:has(> img) {
@apply flex items-center justify-center overflow-hidden rounded-xl border bg-gray-50 p-2;
}
img {
@apply mx-auto rounded-lg border;
}
ul,
ol {
@apply mb-6 ml-6;
}
ul {
@apply list-disc;
}
ol {
@apply list-decimal;
}
li {
@apply mb-2;
}
hr {
@apply my-12;
}
a {
@apply text-primary-600 hover:underline;
}
blockquote {
@apply my-6 border-l-4 border-gray-300 pl-4 italic text-gray-700;
}
pre {
@apply my-6 overflow-x-auto rounded-lg bg-gray-100 p-4;
}
code {
@apply rounded bg-gray-100 px-1 py-0.5 text-sm;
}
}
.text-link {
@apply text-primary-600 hover:text-primary-600 focus-visible:ring-primary-600 rounded-md font-medium outline-none hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1;
}

View file

@ -173,10 +173,7 @@
"duplicateDescription": "Create a new poll based on this one",
"duplicateTitleLabel": "Title",
"duplicateTitleDescription": "Hint: Give your new poll a unique title",
"proFeature": "Pro Feature",
"upgradeOverlaySubtitle2": "Please upgrade to a paid plan to use this feature. This is how we keep the lights on :)",
"upgrade": "Upgrade",
"notToday": "Not Today",
"continueAsGuest": "Continue as Guest",
"scrollLeft": "Scroll Left",
"scrollRight": "Scroll Right",

View file

@ -10,7 +10,7 @@ export default async function Page() {
const { t } = await getTranslation("en");
return (
<div className="flex h-[calc(100vh-100px)] w-full items-center justify-center">
<div className="flex h-full w-full items-center justify-center">
<div className="space-y-8">
<div className="space-y-4 text-center">
<FileSearchIcon className="mb-4 inline-block size-24 text-gray-400" />

View file

@ -1,3 +1,5 @@
import languages from "./languages.json";
export const supportedLngs = Object.keys(languages);
export default languages;

View file

@ -26,7 +26,7 @@ export const BillingPlanHeader = ({
};
export const BillingPlanTitle = ({ children }: React.PropsWithChildren) => {
return <h3 className="font-semibold">{children}</h3>;
return <h3 className="font-bold">{children}</h3>;
};
export const BillingPlanDescription = ({

1158
yarn.lock

File diff suppressed because it is too large Load diff