mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-22 21:36:25 +02:00
♻️ Migrate landing pages to app router (#1362)
This commit is contained in:
parent
6b1e2f9c49
commit
f92c0075e3
73 changed files with 2420 additions and 2166 deletions
|
@ -1,6 +0,0 @@
|
|||
const languages = require("@rallly/languages/languages.json");
|
||||
|
||||
module.exports = {
|
||||
defaultLocale: "en",
|
||||
locales: Object.keys(languages),
|
||||
};
|
3
apps/landing/next-env.d.ts
vendored
3
apps/landing/next-env.d.ts
vendored
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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$/,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
}),
|
||||
};
|
||||
}
|
|
@ -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",
|
||||
}),
|
||||
};
|
||||
}
|
|
@ -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",
|
||||
}),
|
||||
};
|
||||
}
|
48
apps/landing/src/app/[locale]/(seo)/meeting-poll/page.tsx
Normal file
48
apps/landing/src/app/[locale]/(seo)/meeting-poll/page.tsx
Normal 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.",
|
||||
}),
|
||||
};
|
||||
}
|
|
@ -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",
|
||||
}),
|
||||
};
|
||||
}
|
5
apps/landing/src/app/[locale]/[...notFound]/page.tsx
Normal file
5
apps/landing/src/app/[locale]/[...notFound]/page.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { notFound } from "next/navigation";
|
||||
|
||||
export default function CatchAllPage() {
|
||||
notFound();
|
||||
}
|
101
apps/landing/src/app/[locale]/blog/[slug]/page.tsx
Normal file
101
apps/landing/src/app/[locale]/blog/[slug]/page.tsx
Normal 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
39
apps/landing/src/app/[locale]/blog/layout.tsx
Normal file
39
apps/landing/src/app/[locale]/blog/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
63
apps/landing/src/app/[locale]/blog/page.tsx
Normal file
63
apps/landing/src/app/[locale]/blog/page.tsx
Normal 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.",
|
||||
}),
|
||||
};
|
||||
}
|
|
@ -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;
|
|
@ -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.",
|
||||
};
|
||||
}
|
|
@ -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" /> →
|
||||
<Trans ns="common" i18nKey="volunteerTranslator" /> →
|
||||
</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;
|
157
apps/landing/src/app/[locale]/layout.tsx
Normal file
157
apps/landing/src/app/[locale]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
23
apps/landing/src/app/[locale]/nav-link.tsx
Normal file
23
apps/landing/src/app/[locale]/nav-link.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
15
apps/landing/src/app/[locale]/not-found.tsx
Normal file
15
apps/landing/src/app/[locale]/not-found.tsx
Normal 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")}
|
||||
/>
|
||||
);
|
||||
}
|
41
apps/landing/src/app/[locale]/page.tsx
Normal file
41
apps/landing/src/app/[locale]/page.tsx
Normal 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.",
|
||||
}),
|
||||
};
|
||||
}
|
193
apps/landing/src/app/[locale]/pricing/page.tsx
Normal file
193
apps/landing/src/app/[locale]/pricing/page.tsx
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
199
apps/landing/src/app/[locale]/pricing/pricing-table.tsx
Normal file
199
apps/landing/src/app/[locale]/pricing/pricing-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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.",
|
||||
};
|
||||
}
|
83
apps/landing/src/app/[locale]/terms-of-use/page.tsx
Normal file
83
apps/landing/src/app/[locale]/terms-of-use/page.tsx
Normal 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.",
|
||||
};
|
||||
}
|
3
apps/landing/src/app/[locale]/types.ts
Normal file
3
apps/landing/src/app/[locale]/types.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export type URLParams = {
|
||||
locale: string;
|
||||
};
|
23
apps/landing/src/app/global-error.tsx
Normal file
23
apps/landing/src/app/global-error.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,7 @@
|
|||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
type Props = {
|
||||
dateString: string;
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
|
|
37
apps/landing/src/components/home/bonus-item.tsx
Normal file
37
apps/landing/src/components/home/bonus-item.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
@ -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} />;
|
||||
};
|
||||
|
|
6
apps/landing/src/fonts/handwritten.ts
Normal file
6
apps/landing/src/fonts/handwritten.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { Playpen_Sans } from "next/font/google";
|
||||
|
||||
export const handwritten = Playpen_Sans({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
6
apps/landing/src/fonts/sans.ts
Normal file
6
apps/landing/src/fonts/sans.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { Inter } from "next/font/google";
|
||||
|
||||
export const sans = Inter({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
54
apps/landing/src/i18n/client.tsx
Normal file
54
apps/landing/src/i18n/client.tsx
Normal 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>
|
||||
);
|
||||
}
|
32
apps/landing/src/i18n/server.ts
Normal file
32
apps/landing/src/i18n/server.ts
Normal 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,
|
||||
};
|
||||
}
|
21
apps/landing/src/i18n/settings.ts
Normal file
21
apps/landing/src/i18n/settings.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
}
|
42
apps/landing/src/middleware.ts
Normal file
42
apps/landing/src/middleware.ts
Normal 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|.*\\.).*)"],
|
||||
};
|
|
@ -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;
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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"]);
|
|
@ -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"]);
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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"]);
|
|
@ -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"]);
|
|
@ -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"]);
|
|
@ -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;
|
||||
};
|
|
@ -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();
|
|
@ -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"]);
|
|
@ -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 :)
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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"></p>
|
||||

|
||||
|
||||
## Increased touch area
|
||||
|
||||
<div className="text-center">
|
||||
<div className="inline-block">
|
||||

|
||||
</div>
|
||||
</div>
|
||||

|
||||
|
||||
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
|
||||
|
|
|
@ -8,32 +8,32 @@ Rallly has always been about simplifying group scheduling, but my aim is to evol
|
|||
|
||||
## The Workflow
|
||||
|
||||

|
||||
|
||||
Envisioned as a three-component funnel, this new workflow is designed to simplify and streamline your scheduling process.
|
||||
|
||||

|
||||
|
||||
## Breaking it Down
|
||||
|
||||
Here's how these components will work together:
|
||||
|
||||
### Availability Grids
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
### Polls
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
### RSVP
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import languages from "./languages.json";
|
||||
|
||||
export const supportedLngs = Object.keys(languages);
|
||||
|
||||
export default languages;
|
||||
|
|
|
@ -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 = ({
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue