Self-Hosting Update (#842)

This commit is contained in:
Luke Vella 2023-09-11 15:34:55 +01:00 committed by GitHub
parent 3e616d1e41
commit 7a5f9ae474
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 945 additions and 781 deletions

View file

@ -37,3 +37,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: | build-args: |
APP_VERSION=${{ github.ref_name }} APP_VERSION=${{ github.ref_name }}
SELF_HOSTED=true

View file

@ -39,3 +39,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: | build-args: |
APP_VERSION=${{ github.ref_name }} APP_VERSION=${{ github.ref_name }}
SELF_HOSTED=true

View file

@ -6,18 +6,15 @@ description: Help fund this project and keep it ad-free
## Why should I donate? ## Why should I donate?
> Rallly is available free and without ads. Most users of Rallly use it for free, and that's great!
> But free software doesn't mean it's without cost. However, running Rallly costs money.
> Someone has to develop, maintain, and support it. By donating, you can help keep Rallly running and ad-free.
> With the help of generous people like you, we can keep Rallly ad-free for everyone and continue to make it even better.
>
> -- [Luke Vella](https://twitter.com/imlukevella), Creator of Rallly
## How can I donate? ## How can I donate?
You can donate using any of the following methods: You can donate using any of the following methods:
<CardGroup cols={2}> <CardGroup cols={3}>
<Card <Card
title="Donate with PayPal" title="Donate with PayPal"
icon="paypal" icon="paypal"

View file

@ -12,13 +12,13 @@
"anchors": { "from": "#878AFA", "to": "#4347F1" } "anchors": { "from": "#878AFA", "to": "#4347F1" }
}, },
"topbarCtaButton": { "topbarCtaButton": {
"name": "Get Started", "name": "Go to App",
"url": "https://rallly.co/new" "url": "https://app.rallly.co"
}, },
"topbarLinks": [ "topbarLinks": [
{ {
"name": "Contact", "name": "Home",
"url": "/contact/support" "url": "https://rallly.co"
} }
], ],
"anchors": [ "anchors": [
@ -63,6 +63,7 @@
"group": "Self-Hosting", "group": "Self-Hosting",
"pages": [ "pages": [
"self-hosting/introduction", "self-hosting/introduction",
"self-hosting/pricing",
"self-hosting/managed-hosting", "self-hosting/managed-hosting",
"self-hosting/configuration-options" "self-hosting/configuration-options"
] ]

View file

@ -1,4 +1,5 @@
--- ---
icon: gear
title: Configuration Options title: Configuration Options
description: Using environment variable to configure a self-hosted instance of Rallly. description: Using environment variable to configure a self-hosted instance of Rallly.
--- ---
@ -12,33 +13,39 @@ This page lists all environment variables supported by Rallly.
These variables need to be configured for Rallly to run and function properly. These variables need to be configured for Rallly to run and function properly.
<ParamField path="DATABASE_URL" default=""> <ParamField path="DATABASE_URL" required>
Postgres database connection string Postgres database connection string
</ParamField> </ParamField>
<ParamField path="NEXT_PUBLIC_BASE_URL" default="http://localhost:3000"> <ParamField path="NEXT_PUBLIC_BASE_URL" required>
The base url where this instance is accessible, including the scheme (eg. The base url where this instance is accessible, including the scheme (eg.
`http://` or `https://`), the domain name, and optionally a port. `http://` or `https://`), the domain name, and optionally a port.
</ParamField> </ParamField>
<ParamField path="SECRET_PASSWORD"> <ParamField path="SECRET_PASSWORD" required>
A random 32-character secret key used to encrypt user sessions A random 32-character secret key used to encrypt user sessions
</ParamField> </ParamField>
<ParamField path="ALLOWED_EMAILS">
Comma separated list of email addresses that are allowed to register and
login. Wildcard characters are supported. Example: Setting it to
`*@example.com` to allow anyone with a `@example.com` email address.
</ParamField>
### Email Configuration ### Email Configuration
These variables need to be configured to let Rallly send out transactional emails. These variables need to be configured to let Rallly send out transactional emails.
<ParamField path="NOREPLY_EMAIL" default=""> <ParamField path="NOREPLY_EMAIL">
This email is used as the sender for all transactional emails. If not set, This email is used as the sender for all transactional emails. If not set,
`SUPPORT_EMAIL` will be used instead. `SUPPORT_EMAIL` will be used instead.
</ParamField> </ParamField>
<ParamField path="SUPPORT_EMAIL" default=""> <ParamField path="SUPPORT_EMAIL" required>
This email will be shown as the contact email for support queries. This email will be shown as the contact email for support queries.
</ParamField> </ParamField>
<ParamField path="SMTP_HOST" default="localhost"> <ParamField path="SMTP_HOST" required>
The host address of your SMTP server The host address of your SMTP server
</ParamField> </ParamField>
@ -46,7 +53,7 @@ These variables need to be configured to let Rallly send out transactional email
The port of your SMTP server The port of your SMTP server
</ParamField> </ParamField>
<ParamField path="SMTP_SECURE" default={"false"}> <ParamField path="SMTP_SECURE" default="false">
Set to "true" if SSL is enabled for your SMTP connection Set to "true" if SSL is enabled for your SMTP connection
</ParamField> </ParamField>
@ -61,21 +68,3 @@ These variables need to be configured to let Rallly send out transactional email
<ParamField path="SMTP_TLS_ENABLED" default={"false"}> <ParamField path="SMTP_TLS_ENABLED" default={"false"}>
Enable TLS for your SMTP connection Enable TLS for your SMTP connection
</ParamField> </ParamField>
### Custom Configuration
These variables allow you to change Rallly's default behavior.
<ParamField path="ALLOWED_EMAILS" default="">
Comma separated list of email addresses that are allowed to register and
login. Wildcard characters are supported. Example: `*@yourcompany.com`
</ParamField>
<ParamField path="AUTH_REQUIRED" default={"false"}>
Set to `true` to require authentication for creating new polls and accessing
admin pages
</ParamField>
<ParamField path="DISABLE_LANDING_PAGE" default={"false"}>
Whether or not to disable the landing page
</ParamField>

View file

@ -1,13 +1,29 @@
--- ---
icon: server
title: Introduction title: Introduction
description: How to run your own instance of Rallly. description: How to Self-Host Rallly
--- ---
## What is Self-Hosting? Rallly is 100% open-source and available under the [GNU Affero General Public License v3.0 (AGPL-3.0)](https://github.com/lukevella/rallly/blob/main/LICENSE)
which allows you to run your own instance of Rallly for free for both personal and commercial use.
Self-hosting refers to the practice of running your own instance of Rallly on your own server. ## Official Docker Image
The main advantage of self-hosting is the autonomy it affords, but it also requires more technical knowledge and maintenance, as well as the potential security risks of managing your own infrastructure. The best way to self-host Rallly is using the [official Docker image](https://hub.docker.com/r/lukevella/rallly).
This image contains a build that is specifically intended for self-hosting.
It is updated regularly but it is _not_ guaranteed to be up-to-date with the latest version of Rallly.
If you want to have access to the latest features and bug fixes, you should consider using the [official managed service](https://rallly.co).
<Note>
Though it is technically possible to build and run Rallly from its
[source-code](https://github.com/lukevella/rallly), it is not recommended and
we do not provide support for this.
</Note>
## Pricing
Rallly is **completely free** to self-host but for users who wish to contribute to the project,
please check out the [pricing](/self-hosting/pricing) page.
## Get Started ## Get Started
@ -22,7 +38,7 @@ Depending on how comfortable you are with technical things, you can either run R
Host your own instance of Rallly on your own server using Docker. Host your own instance of Rallly on your own server using Docker.
</Card> </Card>
<Card <Card
icon="server" icon="cloud"
title="Using a hosting provider" title="Using a hosting provider"
href="/self-hosting/managed-hosting" href="/self-hosting/managed-hosting"
> >

View file

@ -1,4 +1,5 @@
--- ---
icon: cloud
title: Managed Hosting title: Managed Hosting
description: Using a managed hosting service to self-host Rallly. description: Using a managed hosting service to self-host Rallly.
--- ---

View file

@ -0,0 +1,55 @@
---
icon: coins
title: Pricing
description: How much does it cost to self-host Rallly?
---
Rallly is **completely free** to self-host.
But free software doesnt mean its without cost.
Someone has to develop, maintain, and support it.
If you find Rallly useful you can help support further development by paying a one-time fee.
## Suggested Price
We believe $42 USD is a very fair price for the time and effort that's gone into making this product.
It's also the cost of a yearly subscription to the [official managed service](https://rallly.co).
If you find Rallly useful and can afford to pay, please consider paying this amount.
<CardGroup cols={1}>
<Card
title="Pay $42 USD"
icon="cart-shopping"
href="https://buy.stripe.com/9AQ0411zW9BZ5nqaEK"
>
One-time payment for Rallly Self-Hosted
</Card>
</CardGroup>
## Pay What You Want
Alternatively, you can choose to pay **any amount** you want. Your support is appreciated regardless of how much you are able to pay.
<CardGroup cols={3}>
<Card
icon="dollar-sign"
title="Pay in USD"
href="https://buy.stripe.com/5kAdURdiEeWj5nqeV4"
>
One-time payment
</Card>
<Card
icon="euro-sign"
title="Pay in EUR"
href="https://buy.stripe.com/aEU2c92E0aG33fi7sA"
>
One-time payment
</Card>
<Card
icon="sterling-sign"
title="Pay in GBP"
href="https://buy.stripe.com/fZeeYV2E0bK7g246ox"
>
One-time payment
</Card>
</CardGroup>

View file

@ -64,10 +64,6 @@ declare global {
* Example: "user@example.com, *@example.com, *@*.example.com" * Example: "user@example.com, *@example.com, *@*.example.com"
*/ */
ALLOWED_EMAILS?: string; ALLOWED_EMAILS?: string;
/**
* "true" to require authentication for creating new polls and accessing admin pages
*/
AUTH_REQUIRED?: string;
/** /**
* Determines what email provider to use. "smtp" or "ses" * Determines what email provider to use. "smtp" or "ses"
*/ */

View file

@ -36,8 +36,7 @@
"react-i18next": "^12.1.4", "react-i18next": "^12.1.4",
"react-use": "^17.4.0", "react-use": "^17.4.0",
"remark": "^14.0.3", "remark": "^14.0.3",
"remark-html": "^15.0.2", "remark-html": "^15.0.2"
"typescript": "^4.9.4"
}, },
"devDependencies": { "devDependencies": {
"@next/bundle-analyzer": "^12.3.4", "@next/bundle-analyzer": "^12.3.4",
@ -50,11 +49,8 @@
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@types/react-linkify": "^1.0.1", "@types/react-linkify": "^1.0.1",
"@types/smoothscroll-polyfill": "^0.3.1", "@types/smoothscroll-polyfill": "^0.3.1",
"@typescript-eslint/eslint-plugin": "^5.21.0",
"@typescript-eslint/parser": "^5.50.0",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^7.26.0",
"eslint-config-next": "^13.0.1", "eslint-config-next": "^13.0.1",
"eslint-config-turbo": "^0.0.9", "eslint-config-turbo": "^0.0.9",
"eslint-import-resolver-typescript": "^2.7.0", "eslint-import-resolver-typescript": "^2.7.0",

View file

@ -21,6 +21,9 @@ RUN yarn db:generate
ARG APP_VERSION ARG APP_VERSION
ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION
ARG SELF_HOSTED
ENV NEXT_PUBLIC_SELF_HOSTED=$SELF_HOSTED
RUN yarn build RUN yarn build
FROM node:18 AS runner FROM node:18 AS runner
@ -48,4 +51,7 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
ARG SELF_HOSTED
ENV NEXT_PUBLIC_SELF_HOSTED=$SELF_HOSTED
CMD ["./docker-start.sh"] CMD ["./docker-start.sh"]

View file

@ -33,10 +33,6 @@ declare global {
* Crisp website ID * Crisp website ID
*/ */
NEXT_PUBLIC_CRISP_WEBSITE_ID?: string; NEXT_PUBLIC_CRISP_WEBSITE_ID?: string;
/**
* When `true` it will show the feedback button and pull the changelog from featurebase
*/
NEXT_PUBLIC_FEEDBACK_ENABLED?: string;
/** /**
* Users of your instance will see this as their support email * Users of your instance will see this as their support email
*/ */
@ -68,10 +64,6 @@ declare global {
* Example: "user@example.com, *@example.com, *@*.example.com" * Example: "user@example.com, *@example.com, *@*.example.com"
*/ */
ALLOWED_EMAILS?: string; ALLOWED_EMAILS?: string;
/**
* "true" to require authentication for creating new polls and accessing admin pages
*/
AUTH_REQUIRED?: string;
/** /**
* Determines what email provider to use. "smtp" or "ses" * Determines what email provider to use. "smtp" or "ses"
*/ */

View file

@ -33,6 +33,7 @@
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@tanstack/react-table": "^8.9.1", "@tanstack/react-table": "^8.9.1",
"@vercel/analytics": "^0.1.8", "@vercel/analytics": "^0.1.8",
"@vercel/og": "^0.5.13",
"accept-language-parser": "^1.5.0", "accept-language-parser": "^1.5.0",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"class-variance-authority": "^0.6.0", "class-variance-authority": "^0.6.0",
@ -67,7 +68,6 @@
"spacetime": "^7.4.7", "spacetime": "^7.4.7",
"superjson": "^1.12.2", "superjson": "^1.12.2",
"timezone-soft": "^1.4.1", "timezone-soft": "^1.4.1",
"typescript": "^4.9.4",
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
@ -81,11 +81,8 @@
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@types/react-linkify": "^1.0.1", "@types/react-linkify": "^1.0.1",
"@types/smoothscroll-polyfill": "^0.3.1", "@types/smoothscroll-polyfill": "^0.3.1",
"@typescript-eslint/eslint-plugin": "^5.21.0",
"@typescript-eslint/parser": "^5.50.0",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^7.26.0",
"eslint-config-next": "^13.0.1", "eslint-config-next": "^13.0.1",
"eslint-config-turbo": "^0.0.9", "eslint-config-turbo": "^0.0.9",
"eslint-import-resolver-typescript": "^2.7.0", "eslint-import-resolver-typescript": "^2.7.0",

View file

@ -103,7 +103,6 @@
"addComment": "Add Comment", "addComment": "Add Comment",
"profile": "Profile", "profile": "Profile",
"polls": "Polls", "polls": "Polls",
"showLess": "Show less…",
"showMore": "Show more…", "showMore": "Show more…",
"timeZoneSelect__defaultValue": "Select time zone…", "timeZoneSelect__defaultValue": "Select time zone…",
"timeZoneSelect__noOption": "No option found", "timeZoneSelect__noOption": "No option found",
@ -205,14 +204,8 @@
"duplicateTitleDescription": "Hint: Give your new poll a unique title", "duplicateTitleDescription": "Hint: Give your new poll a unique title",
"proFeature": "Pro Feature", "proFeature": "Pro Feature",
"upgradeOverlaySubtitle2": "Please upgrade to a paid plan to use this feature. This is how we keep the lights on :)", "upgradeOverlaySubtitle2": "Please upgrade to a paid plan to use this feature. This is how we keep the lights on :)",
"savePercent": "Save {percent}%",
"priceIncreaseSoon": "Price increase soon.",
"lockPrice": "Upgrade today to keep this price forever.",
"noAds": "No ads",
"upgrade": "Upgrade", "upgrade": "Upgrade",
"notToday": "Not Today", "notToday": "Not Today",
"supportProject": "Support this project",
"features": "Get access to all current and future Pro features!",
"continueAsGuest": "Continue as Guest", "continueAsGuest": "Continue as Guest",
"scrollLeft": "Scroll Left", "scrollLeft": "Scroll Left",
"scrollRight": "Scroll Right", "scrollRight": "Scroll Right",
@ -231,5 +224,12 @@
"accessAllFeatures": "Access all features", "accessAllFeatures": "Access all features",
"earlyAccess": "Get early access to new features", "earlyAccess": "Get early access to new features",
"earlyAdopterDescription": "As an early adopter, you'll lock in your subscription rate and won't be affected by future price increases.", "earlyAdopterDescription": "As an early adopter, you'll lock in your subscription rate and won't be affected by future price increases.",
"upgradeNowSaveLater": "Upgrade now, save later" "upgradeNowSaveLater": "Upgrade now, save later",
"savePercent": "Save {percent}%",
"priceIncreaseSoon": "Price increase soon.",
"lockPrice": "Upgrade today to keep this price forever.",
"features": "Get access to all current and future Pro features!",
"noAds": "No ads",
"supportProject": "Support this project",
"pricing": "Pricing"
} }

View file

@ -1,25 +1,21 @@
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Trans } from "next-i18next"; import { Trans } from "next-i18next";
import React from "react"; import React from "react";
import { Logo } from "@/components/logo";
import { IfCloudHosted } from "@/contexts/environment";
export const AuthLayout = ({ children }: { children?: React.ReactNode }) => { export const AuthLayout = ({ children }: { children?: React.ReactNode }) => {
return ( return (
<div className="h-full p-3 sm:p-8"> <div className="h-full p-3 sm:p-8">
<div className="mx-auto max-w-lg"> <div className="mx-auto max-w-lg">
<div className="overflow-hidden rounded-lg border bg-white shadow-sm"> <div className="overflow-hidden rounded-lg border bg-white shadow-sm">
<div className="bg-pattern border-t-primary-600 border-b border-t-4 bg-gray-500/5 p-4 text-center sm:p-8"> <div className="bg-pattern border-t-primary-600 flex justify-center border-b border-t-4 bg-gray-500/5 p-4 text-center sm:p-8">
<Image <Logo />
src="/static/logo.svg"
height={30}
width={150}
alt="Rallly"
className="text-primary-600 inline-block h-7"
/>
</div> </div>
<div className="p-4 sm:p-6">{children}</div> <div className="p-4 sm:p-6">{children}</div>
</div> </div>
{process.env.AUTH_REQUIRED === "true" ? null : ( <IfCloudHosted>
<p className="mt-8 text-center"> <p className="mt-8 text-center">
<Link <Link
href="/polls" href="/polls"
@ -28,7 +24,7 @@ export const AuthLayout = ({ children }: { children?: React.ReactNode }) => {
<Trans i18nKey="continueAsGuest" defaults="Continue as Guest" /> <Trans i18nKey="continueAsGuest" defaults="Continue as Guest" />
</Link> </Link>
</p> </p>
)} </IfCloudHosted>
</div> </div>
</div> </div>
); );

View file

@ -141,7 +141,7 @@ export const BillingPlans = () => {
</div> </div>
</Tabs> </Tabs>
<div className="rounded-md border border-cyan-200 bg-cyan-50 px-4 py-3 text-cyan-800"> <div className="rounded-md border border-cyan-200 bg-cyan-50 px-4 py-3 text-cyan-800">
<div className="mb-2 flex items-start justify-between"> <div className="mb-2">
<TrendingUpIcon className="text-indigo mr-2 mt-0.5 h-6 w-6 shrink-0" /> <TrendingUpIcon className="text-indigo mr-2 mt-0.5 h-6 w-6 shrink-0" />
</div> </div>
<div className="mb-2 flex items-center gap-x-2"> <div className="mb-2 flex items-center gap-x-2">

View file

@ -13,7 +13,7 @@ const FeaturebaseScript = () => (
<Script src="https://do.featurebase.app/js/sdk.js" id="featurebase-sdk" /> <Script src="https://do.featurebase.app/js/sdk.js" id="featurebase-sdk" />
); );
export const Changelog = ({ className }: { className?: string }) => { export const FeaturebaseChangelog = ({ className }: { className?: string }) => {
React.useEffect(() => { React.useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = window as any; const win = window as any;
@ -37,7 +37,7 @@ export const Changelog = ({ className }: { className?: string }) => {
<> <>
<FeaturebaseScript /> <FeaturebaseScript />
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger asChild>
<Button <Button
className={cn( className={cn(
"hidden sm:inline-flex [&>*]:pointer-events-none", "hidden sm:inline-flex [&>*]:pointer-events-none",

View file

@ -1,16 +1,26 @@
import { CreditCardIcon, Settings2Icon, UserIcon } from "@rallly/icons"; import {
CreditCardIcon,
MenuIcon,
Settings2Icon,
UserIcon,
} from "@rallly/icons";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import { Card } from "@rallly/ui/card"; import { Card } from "@rallly/ui/card";
import clsx from "clsx"; import clsx from "clsx";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React from "react"; import React from "react";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { useToggle } from "react-use";
import { Container } from "@/components/container"; import { Container } from "@/components/container";
import { StandardLayout } from "@/components/layouts/standard-layout"; import { StandardLayout } from "@/components/layouts/standard-layout";
import { IfCloudHosted } from "@/contexts/environment";
import { Plan } from "@/contexts/plan";
import { IconComponent, NextPageWithLayout } from "../../types"; import { IconComponent, NextPageWithLayout } from "../../types";
import { IfAuthenticated } from "../user-provider"; import { useUser } from "../user-provider";
const MenuItem = (props: { const MenuItem = (props: {
icon: IconComponent; icon: IconComponent;
@ -21,40 +31,69 @@ const MenuItem = (props: {
return ( return (
<Link <Link
className={clsx( className={clsx(
"flex items-center gap-x-2.5 text-sm font-medium", "flex min-w-0 items-center gap-x-2.5 px-2.5 py-1.5 text-sm font-medium",
router.asPath === props.href router.asPath === props.href
? "text-foreground" ? "bg-gray-200"
: "text-gray-500 hover:text-gray-800", : "text-gray-500 hover:bg-gray-100 hover:text-gray-800",
)} )}
href={props.href} href={props.href}
> >
<props.icon className="h-5 w-5" /> <props.icon className="h-4 w-4 shrink-0" />
{props.children} <span className="truncate">{props.children}</span>
</Link> </Link>
); );
}; };
export const ProfileLayout = ({ children }: React.PropsWithChildren) => { export const ProfileLayout = ({ children }: React.PropsWithChildren) => {
const { user } = useUser();
// reset toggle whenever route changes
const router = useRouter();
const [isMenuOpen, toggle] = useToggle(false);
React.useEffect(() => {
toggle(false);
}, [router.asPath, toggle]);
return ( return (
<div> <div>
<Container className="p-2 sm:py-8"> <Container className="p-2 sm:py-8">
<Card className="mx-auto max-w-4xl overflow-hidden"> <Card className="mx-auto flex flex-col overflow-hidden md:min-h-[600px]">
<div className="flex gap-4 gap-x-6 border-b bg-gray-50 px-3 py-4 md:px-4"> <div className="border-b bg-gray-50 p-3 md:hidden">
<IfAuthenticated> <Button onClick={toggle} icon={MenuIcon} />
<MenuItem href="/settings/profile" icon={UserIcon}> </div>
<Trans i18nKey="profile" defaults="Profile" /> <div className="relative flex grow md:divide-x">
</MenuItem> <div
</IfAuthenticated> className={cn(
<MenuItem href="/settings/preferences" icon={Settings2Icon}> "absolute inset-0 z-10 grow bg-gray-50 md:static md:block md:shrink-0 md:grow-0 md:basis-56 md:px-5 md:py-4",
<Trans i18nKey="preferences" defaults="Preferences" /> {
</MenuItem> hidden: !isMenuOpen,
<IfAuthenticated> },
<MenuItem href="/settings/billing" icon={CreditCardIcon}> )}
<Trans i18nKey="billing" defaults="Billing" /> >
</MenuItem> <div className="grid gap-1">
</IfAuthenticated> <div className="flex items-center justify-between gap-x-2.5 gap-y-2 p-3">
<div className="truncate text-sm font-semibold">
{user.name}
</div>
<Plan />
</div>
<MenuItem href="/settings/profile" icon={UserIcon}>
<Trans i18nKey="profile" defaults="Profile" />
</MenuItem>
<MenuItem href="/settings/preferences" icon={Settings2Icon}>
<Trans i18nKey="preferences" defaults="Preferences" />
</MenuItem>
<IfCloudHosted>
<MenuItem href="/settings/billing" icon={CreditCardIcon}>
<Trans i18nKey="billing" defaults="Billing" />
</MenuItem>
</IfCloudHosted>
</div>
</div>
<div className="max-w-2xl grow">{children}</div>
</div> </div>
{children}
</Card> </Card>
</Container> </Container>
</div> </div>

View file

@ -1,9 +1,8 @@
import { ListIcon, LogInIcon, SparklesIcon } from "@rallly/icons"; import { ListIcon, SparklesIcon } from "@rallly/icons";
import { cn } from "@rallly/ui"; import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import clsx from "clsx"; import clsx from "clsx";
import { AnimatePresence, m } from "framer-motion"; import { AnimatePresence, m } from "framer-motion";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React from "react"; import React from "react";
@ -11,13 +10,18 @@ import { Toaster } from "react-hot-toast";
import { Clock, ClockPreferences } from "@/components/clock"; import { Clock, ClockPreferences } from "@/components/clock";
import { Container } from "@/components/container"; import { Container } from "@/components/container";
import { Changelog, FeaturebaseIdentify } from "@/components/featurebase"; import {
FeaturebaseChangelog,
FeaturebaseIdentify,
} from "@/components/featurebase";
import FeedbackButton from "@/components/feedback"; import FeedbackButton from "@/components/feedback";
import { Logo } from "@/components/logo";
import { Spinner } from "@/components/spinner"; import { Spinner } from "@/components/spinner";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { UserDropdown } from "@/components/user-dropdown"; import { UserDropdown } from "@/components/user-dropdown";
import { IfCloudHosted } from "@/contexts/environment";
import { IfFreeUser } from "@/contexts/plan"; import { IfFreeUser } from "@/contexts/plan";
import { isFeedbackEnabled } from "@/utils/constants"; import { appVersion, isFeedbackEnabled } from "@/utils/constants";
import { DayjsProvider } from "@/utils/dayjs"; import { DayjsProvider } from "@/utils/dayjs";
import { IconComponent, NextPageWithLayout } from "../../types"; import { IconComponent, NextPageWithLayout } from "../../types";
@ -73,7 +77,7 @@ const Upgrade = () => {
); );
}; };
const Logo = () => { const LogoArea = () => {
const router = useRouter(); const router = useRouter();
const [isBusy, setIsBusy] = React.useState(false); const [isBusy, setIsBusy] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
@ -87,20 +91,14 @@ const Logo = () => {
}; };
}, [router.events]); }, [router.events]);
return ( return (
<div className="relative flex items-center justify-center gap-4"> <div className="relative flex items-center justify-center gap-x-4">
<Link <Link
href="/polls" href="/polls"
className={clsx( className={clsx(
"inline-block transition-transform active:translate-y-1", "inline-block transition-transform active:translate-y-1",
)} )}
> >
<Image <Logo size="sm" />
priority={true}
src="/static/logo.svg"
width={120}
height={22}
alt="Rallly"
/>
</Link> </Link>
<div <div
className={cn( className={cn(
@ -114,6 +112,10 @@ const Logo = () => {
); );
}; };
const Changelog = () => {
return <FeaturebaseChangelog />;
};
const MainNav = () => { const MainNav = () => {
return ( return (
<m.div <m.div
@ -127,8 +129,8 @@ const MainNav = () => {
className="border-b bg-gray-50/50" className="border-b bg-gray-50/50"
> >
<Container className="flex h-14 items-center justify-between gap-x-2.5"> <Container className="flex h-14 items-center justify-between gap-x-2.5">
<div className="flex shrink-0"> <div className="flex shrink-0 gap-x-4">
<Logo /> <LogoArea />
<nav className="hidden gap-x-2 sm:flex"> <nav className="hidden gap-x-2 sm:flex">
<NavMenuItem <NavMenuItem
icon={ListIcon} icon={ListIcon}
@ -137,11 +139,13 @@ const MainNav = () => {
/> />
</nav> </nav>
</div> </div>
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-2.5">
<nav className="flex items-center gap-x-1 sm:gap-x-1.5"> <nav className="flex items-center gap-x-1 sm:gap-x-1.5">
<IfFreeUser> <IfCloudHosted>
<Upgrade /> <IfFreeUser>
</IfFreeUser> <Upgrade />
</IfFreeUser>
</IfCloudHosted>
<IfGuest> <IfGuest>
<Button <Button
size="sm" size="sm"
@ -150,12 +154,13 @@ const MainNav = () => {
className="hidden sm:flex" className="hidden sm:flex"
> >
<Link href="/login"> <Link href="/login">
<LogInIcon className="h-4 w-4" />
<Trans i18nKey="login" defaults="Login" /> <Trans i18nKey="login" defaults="Login" />
</Link> </Link>
</Button> </Button>
</IfGuest> </IfGuest>
<Changelog /> <IfCloudHosted>
<Changelog />
</IfCloudHosted>
<ClockPreferences> <ClockPreferences>
<Button size="sm" variant="ghost"> <Button size="sm" variant="ghost">
<Clock /> <Clock />
@ -198,6 +203,17 @@ export const StandardLayout: React.FunctionComponent<{
{children} {children}
</m.div> </m.div>
</AnimatePresence> </AnimatePresence>
{appVersion ? (
<div className="fixed bottom-0 right-0 z-50 rounded-tl-md bg-gray-200/90">
<Link
className="px-2 py-1 text-xs tabular-nums tracking-tight"
target="_blank"
href={`https://github.com/lukevella/rallly/releases/${appVersion}`}
>
{`${appVersion}`}
</Link>
</div>
) : null}
</div> </div>
{isFeedbackEnabled ? ( {isFeedbackEnabled ? (
<> <>

View file

@ -1,25 +1,25 @@
import clsx from "clsx"; import Image from "next/image";
export const Logo = (props: { className?: string; color?: boolean }) => { const sizes = {
const { color = true } = props; sm: {
width: 120,
height: 22,
},
md: {
width: 150,
height: 30,
},
};
export const Logo = ({ size = "md" }: { size?: keyof typeof sizes }) => {
return ( return (
<span className="inline-flex select-none items-center"> <Image
<span priority={true}
className={clsx( className="mx"
"font-semibold uppercase tracking-widest", src="/static/logo.svg"
{ width={sizes[size].width}
"text-primary-600": color, height={sizes[size].height}
}, alt="Rallly"
props.className, />
)}
>
Rallly
</span>
{process.env.NEXT_PUBLIC_BETA === "1" ? (
<span className="ml-2 inline-block rounded bg-rose-500 px-1 text-xs lowercase tracking-tight text-gray-50">
beta
</span>
) : null}
</span>
); );
}; };

View file

@ -1,70 +0,0 @@
import { Logo } from "./logo";
const OpenBeta = () => {
return (
<div>
<div className="bg-pattern border-b px-4 py-8 text-center text-2xl">
<Logo />
</div>
<div className="max-w-3xl p-3 sm:p-6">
<div>
<p className="mb-4">
The open beta allows you to test out new features before they are
officially released to the general public. By participating you,
will have the opportunity to provide feedback and help shape the
future of Rallly!
</p>
</div>
<div>
<h2 className="mb-4">Feedback</h2>
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2">
<a
href="https://github.com/lukevella/rallly/issues/new?assignees=&labels=bug&template=---bug-report.md&title="
className="hover:text-primary-600 rounded border p-3"
>
🐞 Submit a bug report
</a>
<a
href="https://github.com/lukevella/rallly/discussions/new/choose"
className="hover:text-primary-600 rounded border p-3"
>
📢 Open a discussion with the community
</a>
<a
href="https://discord.gg/uzg4ZcHbuM"
className="hover:text-primary-600 rounded border p-3"
>
💬 Chat on Discord
</a>
<a
href="mailto:feedback@rallly.co"
className="hover:text-primary-600 rounded border p-3"
>
Send an email
</a>
</ul>
<div className="mt-4 rounded border bg-gray-50 p-4">
<h2 className="mb-4 text-gray-800">Important</h2>
<p className="mb-4">
<strong>
You should not rely on the beta for any important data or
information.
</strong>
</p>
<p className="mb-4">
The beta should be used exclusively for testing purposes.
Features, polls, accounts, or data may be removed at any time
without prior notice.
</p>
<p className="m-0">
Any data or information saved on the beta website cannot be
accessed on the production website and vice versa.
</p>
</div>
</div>
</div>
</div>
);
};
export default OpenBeta;

View file

@ -1,15 +0,0 @@
export const SettingsSection = (props: {
title: React.ReactNode;
description: React.ReactNode;
children: React.ReactNode;
}) => {
return (
<div className="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-4 p-3 sm:p-6 md:grid-cols-3">
<div>
<h2 className="text-base font-semibold">{props.title}</h2>
<p className="mt-1 text-sm text-gray-500">{props.description}</p>
</div>
<div className="md:col-span-2">{props.children}</div>
</div>
);
};

View file

@ -0,0 +1,70 @@
import { InfoIcon } from "@rallly/icons";
import { cn } from "@rallly/ui";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
export const Settings = ({ children }: React.PropsWithChildren) => {
return <div className="px-4 py-3 md:p-6">{children}</div>;
};
export const SettingsHeader = ({ children }: React.PropsWithChildren) => {
return (
<div className="mb-4 md:mb-8">
<h2>{children}</h2>
</div>
);
};
export const SettingsContent = ({ children }: React.PropsWithChildren) => {
return <div className="space-y-8">{children}</div>;
};
export const SettingsSection = (props: {
title: React.ReactNode;
description: React.ReactNode;
children: React.ReactNode;
}) => {
return (
<div className="grid gap-3 md:gap-4">
<div>
<h2 className="mb-1 text-base font-semibold">{props.title}</h2>
<p className="text-muted-foreground text-sm">{props.description}</p>
</div>
<div>{props.children}</div>
</div>
);
};
export const SettingsGroup = ({ children }: React.PropsWithChildren) => {
return <dl className="grid gap-3 md:gap-6">{children}</dl>;
};
export const SettingsItem = ({ children }: React.PropsWithChildren) => {
return <div className="grid gap-1.5">{children}</div>;
};
export const SettingsItemTitle = ({
children,
hint,
}: React.PropsWithChildren<{ hint?: React.ReactNode }>) => {
return (
<div className="flex items-center gap-x-2">
<dt className="text-sm font-medium text-gray-500">{children}</dt>
{hint ? (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="inline-block h-4 w-4 text-gray-500" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-sm">
{hint}
</TooltipContent>
</Tooltip>
) : null}
</div>
);
};
export const SettingsItemValue = ({
children,
className,
}: React.PropsWithChildren<{ className?: string }>) => {
return <dd className={cn("text-sm text-gray-900", className)}>{children}</dd>;
};

View file

@ -1,39 +0,0 @@
import React from "react";
import { Trans } from "@/components/trans";
export const TextSummary = ({
max = Infinity,
text,
}: {
text: string;
max?: number;
}) => {
const [isExpanded, setExpanded] = React.useState(false);
if (text.length <= max) {
return <p>{text}</p>;
}
const summary = text.substring(0, text.lastIndexOf(" ", max)) + "…";
return (
<>
<p className="leading-relaxed text-gray-600">
{isExpanded ? <>{text}</> : <>{summary}</>}
</p>
{isExpanded ? (
<button
className="mt-2 border px-1 text-sm font-medium tracking-tight hover:text-gray-800 hover:underline active:text-gray-900"
onClick={() => setExpanded(false)}
>
<Trans defaults="Show less…" i18nKey="showLess" />
</button>
) : (
<button
className="mt-2 border px-1 text-sm font-medium tracking-tight hover:text-gray-800 hover:underline active:text-gray-900"
onClick={() => setExpanded(true)}
>
<Trans defaults="Show more…" i18nKey="showMore" />
</button>
)}
</>
);
};

View file

@ -12,20 +12,36 @@ export const UpgradeButton = ({
const posthog = usePostHog(); const posthog = usePostHog();
return ( return (
<Button <form method="POST" action="/api/stripe/checkout">
className="w-full" <input
variant="primary" type="hidden"
asChild name="period"
onClick={() => { value={annual ? "yearly" : "monthly"}
posthog?.capture("click upgrade button"); />
}} <input
> type="hidden"
<Link name="return_path"
href={`/api/stripe/checkout?period=${ value={encodeURIComponent(window.location.pathname)}
annual ? "yearly" : "monthly" />
}&return_path=${encodeURIComponent(window.location.pathname)}`} <Button
className="w-full"
type="submit"
variant="primary"
onClick={() => {
posthog?.capture("click upgrade button");
}}
> >
{children || <Trans i18nKey="upgrade" defaults="Upgrade" />} {children || <Trans i18nKey="upgrade" defaults="Upgrade" />}
</Button>
</form>
);
};
export const UpgradeLink = ({}) => {
return (
<Button variant="primary" asChild>
<Link href="/settings/billing">
<Trans i18nKey="upgrade" defaults="Upgrade" />
</Link> </Link>
</Button> </Button>
); );

View file

@ -1,6 +1,7 @@
import { import {
ChevronDown, ChevronDown,
CreditCardIcon, CreditCardIcon,
GemIcon,
LifeBuoyIcon, LifeBuoyIcon,
ListIcon, ListIcon,
LogInIcon, LogInIcon,
@ -24,6 +25,7 @@ import Link from "next/link";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { CurrentUserAvatar } from "@/components/user"; import { CurrentUserAvatar } from "@/components/user";
import { IfCloudHosted, IfSelfHosted } from "@/contexts/environment";
import { Plan } from "@/contexts/plan"; import { Plan } from "@/contexts/plan";
import { isFeedbackEnabled } from "@/utils/constants"; import { isFeedbackEnabled } from "@/utils/constants";
@ -34,7 +36,7 @@ export const UserDropdown = () => {
return ( return (
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild className="group"> <DropdownMenuTrigger asChild className="group">
<Button className="rounded-full"> <Button variant="ghost" className="rounded-full">
<CurrentUserAvatar size="sm" className="-ml-1" /> <CurrentUserAvatar size="sm" className="-ml-1" />
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</Button> </Button>
@ -52,17 +54,18 @@ export const UserDropdown = () => {
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<IfAuthenticated> <DropdownMenuItem asChild={true}>
<DropdownMenuItem asChild={true}> <Link href="/polls" className="flex items-center gap-x-2 sm:hidden">
<Link <ListIcon className="h-4 w-4" />
href="/settings/profile" <Trans i18nKey="polls" defaults="Polls" />
className="flex items-center gap-x-2" </Link>
> </DropdownMenuItem>
<UserIcon className="h-4 w-4" /> <DropdownMenuItem asChild={true}>
<Trans i18nKey="profile" defaults="Profile" /> <Link href="/settings/profile" className="flex items-center gap-x-2">
</Link> <UserIcon className="h-4 w-4" />
</DropdownMenuItem> <Trans i18nKey="profile" defaults="Profile" />
</IfAuthenticated> </Link>
</DropdownMenuItem>
<DropdownMenuItem asChild={true}> <DropdownMenuItem asChild={true}>
<Link <Link
href="/settings/preferences" href="/settings/preferences"
@ -72,7 +75,7 @@ export const UserDropdown = () => {
<Trans i18nKey="preferences" defaults="Preferences" /> <Trans i18nKey="preferences" defaults="Preferences" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<IfAuthenticated> <IfCloudHosted>
<DropdownMenuItem asChild={true}> <DropdownMenuItem asChild={true}>
<Link <Link
href="/settings/billing" href="/settings/billing"
@ -82,13 +85,7 @@ export const UserDropdown = () => {
<Trans i18nKey="Billing" defaults="Billing" /> <Trans i18nKey="Billing" defaults="Billing" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</IfAuthenticated> </IfCloudHosted>
<DropdownMenuItem asChild={true}>
<Link href="/polls" className="flex items-center gap-x-2 sm:hidden">
<ListIcon className="h-4 w-4" />
<Trans i18nKey="polls" defaults="Polls" />
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild={true}> <DropdownMenuItem asChild={true}>
<Link <Link
@ -100,6 +97,18 @@ export const UserDropdown = () => {
<Trans i18nKey="support" defaults="Support" /> <Trans i18nKey="support" defaults="Support" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<IfSelfHosted>
<DropdownMenuItem asChild={true}>
<Link
target="_blank"
href="https://support.rallly.co/self-hosting/pricing"
className="flex items-center gap-x-2"
>
<GemIcon className="h-4 w-4" />
<Trans i18nKey="pricing" defaults="Pricing" />
</Link>
</DropdownMenuItem>
</IfSelfHosted>
{isFeedbackEnabled ? ( {isFeedbackEnabled ? (
<DropdownMenuItem asChild={true}> <DropdownMenuItem asChild={true}>
<Link <Link

View file

@ -8,7 +8,7 @@ import { useWhoAmI } from "@/contexts/whoami";
import { useRequiredContext } from "./use-required-context"; import { useRequiredContext } from "./use-required-context";
export const UserContext = React.createContext<{ export const UserContext = React.createContext<{
user: UserSession & { shortName: string }; user: UserSession & { name: string };
refresh: () => void; refresh: () => void;
ownsObject: (obj: { userId: string | null }) => boolean; ownsObject: (obj: { userId: string | null }) => boolean;
} | null>(null); } | null>(null);
@ -50,23 +50,22 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
const queryClient = trpc.useContext(); const queryClient = trpc.useContext();
const user = useWhoAmI(); const user = useWhoAmI();
const subscriptionQuery = trpc.user.subscription.useQuery();
const { data: userPreferences } = trpc.userPreferences.get.useQuery(); const { data: userPreferences } = trpc.userPreferences.get.useQuery();
const shortName = user const name = user
? user.isGuest === false ? user.isGuest === false
? user.name.split(" ")[0] ? user.name
: user.id.substring(0, 10) : user.id.substring(0, 10)
: t("guest"); : t("guest");
if (!user || userPreferences === undefined || !subscriptionQuery.isFetched) { if (!user || userPreferences === undefined) {
return null; return null;
} }
return ( return (
<UserContext.Provider <UserContext.Provider
value={{ value={{
user: { ...user, shortName }, user: { ...user, name },
refresh: () => { refresh: () => {
return queryClient.whoami.invalidate(); return queryClient.whoami.invalidate();
}, },

View file

@ -0,0 +1,9 @@
export const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === "true";
export const IfSelfHosted = ({ children }: React.PropsWithChildren) => {
return isSelfHosted ? <>{children}</> : null;
};
export const IfCloudHosted = ({ children }: React.PropsWithChildren) => {
return isSelfHosted ? null : <>{children}</>;
};

View file

@ -3,9 +3,33 @@ import { Badge } from "@rallly/ui/badge";
import React from "react"; import React from "react";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { isSelfHosted } from "@/utils/constants";
export const useSubscription = () => {
const { user } = useUser();
const { data } = trpc.user.subscription.useQuery(undefined, {
enabled: !isSelfHosted && user.isGuest === false,
});
if (isSelfHosted) {
return {
active: true,
};
}
if (user.isGuest) {
return {
active: false,
};
}
return data;
};
export const usePlan = () => { export const usePlan = () => {
const { data } = trpc.user.subscription.useQuery(); const data = useSubscription();
const isPaid = data?.active === true; const isPaid = data?.active === true;
@ -34,6 +58,7 @@ export const Plan = () => {
</Badge> </Badge>
); );
} }
return ( return (
<Badge variant="secondary"> <Badge variant="secondary">
<Trans i18nKey="planFree" defaults="Free" /> <Trans i18nKey="planFree" defaults="Free" />

View file

@ -10,26 +10,37 @@ const publicPaths = ["/login", "/register", "/invite", "/auth"];
// these paths always require authentication // these paths always require authentication
const protectedPaths = ["/settings/billing", "/settings/profile"]; const protectedPaths = ["/settings/billing", "/settings/profile"];
const checkLoginRequirements = async (req: NextRequest, res: NextResponse) => {
const session = await getSession(req, res);
const isGuest = session.user?.isGuest !== false;
if (!isGuest) {
// already logged in
return false;
}
// TODO (Luke Vella) [2023-09-11]: We should handle this on the client-side
if (process.env.NEXT_PUBLIC_SELF_HOSTED === "true") {
// when self-hosting, only public paths don't require login
return !publicPaths.some((publicPath) =>
req.nextUrl.pathname.startsWith(publicPath),
);
} else {
// when using the hosted version, only protected paths require login
return protectedPaths.some((protectedPath) =>
req.nextUrl.pathname.includes(protectedPath),
);
}
};
export async function middleware(req: NextRequest) { export async function middleware(req: NextRequest) {
const { headers, cookies, nextUrl } = req; const { headers, cookies, nextUrl } = req;
const newUrl = nextUrl.clone(); const newUrl = nextUrl.clone();
const res = NextResponse.next(); const res = NextResponse.next();
const session = await getSession(req, res);
// a protected path is one that requires to be logged in const isLoginRequired = await checkLoginRequirements(req, res);
const isProtectedPath = protectedPaths.some((protectedPath) =>
req.nextUrl.pathname.includes(protectedPath),
);
const isProtectedPathDueToRequiredAuth = if (isLoginRequired) {
process.env.AUTH_REQUIRED === "true" &&
!publicPaths.some((publicPath) =>
req.nextUrl.pathname.startsWith(publicPath),
);
const isGuest = session.user?.isGuest !== false;
if (isGuest && (isProtectedPathDueToRequiredAuth || isProtectedPath)) {
newUrl.pathname = "/login"; newUrl.pathname = "/login";
newUrl.searchParams.set("redirect", req.nextUrl.pathname); newUrl.searchParams.set("redirect", req.nextUrl.pathname);
return NextResponse.redirect(newUrl); return NextResponse.redirect(newUrl);

View file

@ -59,7 +59,7 @@ const Page: NextPageWithLayout<{ userId: string; pollId: string }> = ({
<ArrowRightIcon className="text-muted-foreground h-4 w-4" /> <ArrowRightIcon className="text-muted-foreground h-4 w-4" />
<div className="flex items-center gap-x-2.5"> <div className="flex items-center gap-x-2.5">
<CurrentUserAvatar /> <CurrentUserAvatar />
<div>{user.shortName}</div> <div>{user.name}</div>
</div> </div>
</div> </div>
</PageDialogContent> </PageDialogContent>

View file

@ -20,17 +20,19 @@ export default async function handler(
res: NextApiResponse, res: NextApiResponse,
) { ) {
const userSession = await getSession(req, res); const userSession = await getSession(req, res);
const { period = "monthly", return_path } = inputSchema.parse(req.body);
if (userSession.user?.isGuest !== false) { if (userSession.user?.isGuest !== false) {
// You need to be logged in to subscribe // You need to be logged in to subscribe
return res res.redirect(
.status(403) 303,
.redirect( `/login${
`/login${req.url ? `?redirect=${encodeURIComponent(req.url)}` : ""}`, return_path ? `?redirect=${encodeURIComponent(return_path)}` : ""
); }`,
} );
const { period = "monthly", return_path } = inputSchema.parse(req.query); return;
}
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
@ -48,12 +50,14 @@ export default async function handler(
}); });
if (!user) { if (!user) {
return res.status(403).redirect("/logout"); res.redirect(303, "/logout");
return;
} }
if (user.subscription?.active === true) { if (user.subscription?.active === true) {
// User already has an active subscription. Take them to customer portal // User already has an active subscription. Take them to customer portal
return res.redirect("/api/stripe/portal"); res.redirect(303, "/api/stripe/portal");
return;
} }
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
@ -99,10 +103,11 @@ export default async function handler(
if (session.url) { if (session.url) {
// redirect to checkout session // redirect to checkout session
return res.status(303).redirect(session.url); res.redirect(303, session.url);
return;
} }
return res res
.status(500) .status(500)
.json({ error: "Something went wrong while creating a checkout session" }); .json({ error: "Something went wrong while creating a checkout session" });
} }

View file

@ -18,11 +18,12 @@ export default async function handler(
if (userSession.user?.isGuest !== false) { if (userSession.user?.isGuest !== false) {
// You need to be logged in to subscribe // You need to be logged in to subscribe
return res res
.status(403) .status(403)
.redirect( .redirect(
`/login${req.url ? `?redirect=${encodeURIComponent(req.url)}` : ""}`, `/login${req.url ? `?redirect=${encodeURIComponent(req.url)}` : ""}`,
); );
return;
} }
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@ -36,7 +37,8 @@ export default async function handler(
}); });
if (!user) { if (!user) {
return res.status(403).redirect("/logout"); res.status(403).redirect("/logout");
return;
} }
const { session_id: sessionId, return_path } = inputSchema.parse(req.query); const { session_id: sessionId, return_path } = inputSchema.parse(req.query);

View file

@ -35,15 +35,18 @@ export default async function handler(
res: NextApiResponse, res: NextApiResponse,
) { ) {
if (req.method !== "POST") { if (req.method !== "POST") {
return res.status(405).end(); res.status(405).end();
return;
} }
if (!endpointSecret) { if (!endpointSecret) {
return res.status(400).send("No endpoint secret"); res.status(400).send("No endpoint secret");
return;
} }
const event = await validatedWebhook(req); const event = await validatedWebhook(req);
if (!event) { if (!event) {
return res.status(400).send("Invalid signature"); res.status(400).send("Invalid signature");
return;
} }
switch (event.type) { switch (event.type) {
@ -52,7 +55,8 @@ export default async function handler(
const { userId } = metadataSchema.parse(checkoutSession.metadata); const { userId } = metadataSchema.parse(checkoutSession.metadata);
if (!userId) { if (!userId) {
return res.status(400).send("Missing client reference ID"); res.status(400).send("Missing client reference ID");
return;
} }
await prisma.user.update({ await prisma.user.update({

View file

@ -1,19 +1,28 @@
import { trpc } from "@rallly/backend"; import { trpc } from "@rallly/backend";
import { ArrowUpRight, CreditCardIcon } from "@rallly/icons"; import { ArrowUpRight, CreditCardIcon, SendIcon } from "@rallly/icons";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { Card } from "@rallly/ui/card"; import { Card } from "@rallly/ui/card";
import { Label } from "@rallly/ui/label"; import { Label } from "@rallly/ui/label";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { GetStaticProps } from "next";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { BillingPlans } from "@/components/billing/billing-plans"; import { BillingPlans } from "@/components/billing/billing-plans";
import { getProfileLayout } from "@/components/layouts/profile-layout"; import { getProfileLayout } from "@/components/layouts/profile-layout";
import { SettingsSection } from "@/components/settings/settings-section"; import {
Settings,
SettingsContent,
SettingsGroup,
SettingsHeader,
SettingsItem,
SettingsItemTitle,
SettingsSection,
} from "@/components/settings/settings";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider"; import { useSubscription } from "@/contexts/plan";
import { Plan } from "@/contexts/plan"; import { isSelfHosted } from "@/utils/constants";
import { NextPageWithLayout } from "../../types"; import { NextPageWithLayout } from "../../types";
import { getStaticTranslations } from "../../utils/with-page-translations"; import { getStaticTranslations } from "../../utils/with-page-translations";
@ -28,9 +37,13 @@ declare global {
const BillingPortal = () => { const BillingPortal = () => {
return ( return (
<div> <div>
<Label className="mb-2"> <SettingsGroup>
<Trans i18nKey="billingPortal" /> <SettingsItem>
</Label> <SettingsItemTitle>
<Trans i18nKey="billingStatus" />
</SettingsItemTitle>
</SettingsItem>
</SettingsGroup>
<p className="text-sm"> <p className="text-sm">
<Trans <Trans
i18nKey="activeSubscription" i18nKey="activeSubscription"
@ -40,10 +53,12 @@ const BillingPortal = () => {
<div className="mt-6"> <div className="mt-6">
<Button asChild> <Button asChild>
<Link <Link
target="_blank"
href={`/api/stripe/portal?return_path=${encodeURIComponent( href={`/api/stripe/portal?return_path=${encodeURIComponent(
window.location.pathname, window.location.pathname,
)}`} )}`}
> >
<CreditCardIcon className="h-4 w-4" />
<span> <span>
<Trans i18nKey="billingPortal" defaults="Billing Portal" /> <Trans i18nKey="billingPortal" defaults="Billing Portal" />
</span> </span>
@ -62,28 +77,14 @@ export const proPlanIdYearly = process.env
.NEXT_PUBLIC_PRO_PLAN_ID_YEARLY as string; .NEXT_PUBLIC_PRO_PLAN_ID_YEARLY as string;
const SubscriptionStatus = () => { const SubscriptionStatus = () => {
const { user } = useUser(); const data = useSubscription();
const { data } = trpc.user.subscription.useQuery();
if (!data) { if (!data) {
return null; return null;
} }
if (user.isGuest) {
return <>You need to be logged in.</>;
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div>
<Label className="mb-2">
<Trans i18nKey="currentPlan" />
</Label>
<div>
<Plan />
</div>
</div>
{!data.active ? ( {!data.active ? (
<div> <div>
<Label className="mb-4"> <Label className="mb-4">
@ -214,54 +215,89 @@ const LegacyBilling = () => {
); );
}; };
<SettingsSection
title={<Trans i18nKey="support" defaults="Support" />}
description={
<Trans i18nKey="supportDescription" defaults="Need help with anything?" />
}
>
<div className="space-y-6">
<p className="text-sm">
<Trans
i18nKey="supportBilling"
defaults="Please reach out if you need any assistance."
/>
</p>
<Button asChild>
<Link href="mailto:support@rallly.co">
<SendIcon className="h-4 w-4" />
<Trans i18nKey="contactSupport" defaults="Contact Support" />
</Link>
</Button>
</div>
</SettingsSection>;
const Page: NextPageWithLayout = () => { const Page: NextPageWithLayout = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="divide-y"> <Settings>
<SettingsHeader>
<Trans i18nKey="billing" />
</SettingsHeader>
<Head> <Head>
<title>{t("billing")}</title> <title>{t("billing")}</title>
</Head> </Head>
<SettingsSection <SettingsContent>
title={<Trans i18nKey="billingStatus" defaults="Billing Status" />} <SettingsSection
description={ title={<Trans i18nKey="billingStatus" defaults="Billing Status" />}
<Trans description={
i18nKey="billingStatusDescription"
defaults="Manage your subscription and billing details."
/>
}
>
<SubscriptionStatus />
</SettingsSection>
<SettingsSection
title={<Trans i18nKey="support" defaults="Support" />}
description={
<Trans
i18nKey="supportDescription"
defaults="Need help with anything?"
/>
}
>
<div className="space-y-6">
<p className="text-sm">
<Trans <Trans
i18nKey="supportBilling" i18nKey="billingStatusDescription"
defaults="Please reach out if you need any assistance." defaults="Manage your subscription and billing details."
/> />
</p> }
<Button asChild> >
<Link href="mailto:support@rallly.co"> <SubscriptionStatus />
<Trans i18nKey="contactSupport" defaults="Contact Support" /> </SettingsSection>
</Link> <SettingsSection
</Button> title={<Trans i18nKey="support" defaults="Support" />}
</div> description={
</SettingsSection> <Trans
</div> i18nKey="supportDescription"
defaults="Need help with anything?"
/>
}
>
<div className="space-y-6">
<p className="text-sm">
<Trans
i18nKey="supportBilling"
defaults="Please reach out if you need any assistance."
/>
</p>
<Button asChild>
<Link href="mailto:support@rallly.co">
<SendIcon className="h-4 w-4" />
<Trans i18nKey="contactSupport" defaults="Contact Support" />
</Link>
</Button>
</div>
</SettingsSection>
</SettingsContent>
</Settings>
); );
}; };
Page.getLayout = getProfileLayout; Page.getLayout = getProfileLayout;
export const getStaticProps = getStaticTranslations; export const getStaticProps: GetStaticProps = async (ctx) => {
if (isSelfHosted) {
return {
notFound: true,
};
}
return await getStaticTranslations(ctx);
};
export default Page; export default Page;

View file

@ -4,7 +4,12 @@ import { useTranslation } from "next-i18next";
import { getProfileLayout } from "@/components/layouts/profile-layout"; import { getProfileLayout } from "@/components/layouts/profile-layout";
import { DateTimePreferences } from "@/components/settings/date-time-preferences"; import { DateTimePreferences } from "@/components/settings/date-time-preferences";
import { LanguagePreference } from "@/components/settings/language-preference"; import { LanguagePreference } from "@/components/settings/language-preference";
import { SettingsSection } from "@/components/settings/settings-section"; import {
Settings,
SettingsContent,
SettingsHeader,
SettingsSection,
} from "@/components/settings/settings";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { NextPageWithLayout } from "../../types"; import { NextPageWithLayout } from "../../types";
@ -14,33 +19,38 @@ const Page: NextPageWithLayout = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="divide-y"> <Settings>
<Head> <SettingsHeader>
<title>{t("settings")}</title> <Trans i18nKey="preferences" />
</Head> </SettingsHeader>
<SettingsSection <SettingsContent>
title={<Trans i18nKey="language" defaults="Language" />} <Head>
description={ <title>{t("settings")}</title>
<Trans </Head>
i18nKey="languageDescription" <SettingsSection
defaults="Change your preferred language" title={<Trans i18nKey="language" defaults="Language" />}
/> description={
} <Trans
> i18nKey="languageDescription"
<LanguagePreference /> defaults="Change your preferred language"
</SettingsSection> />
<SettingsSection }
title={<Trans i18nKey="dateAndTime" defaults="Date & Time" />} >
description={ <LanguagePreference />
<Trans </SettingsSection>
i18nKey="dateAndTimeDescription" <SettingsSection
defaults="Change your preferred date and time settings" title={<Trans i18nKey="dateAndTime" defaults="Date & Time" />}
/> description={
} <Trans
> i18nKey="dateAndTimeDescription"
<DateTimePreferences /> defaults="Change your preferred date and time settings"
</SettingsSection> />
</div> }
>
<DateTimePreferences />
</SettingsSection>
</SettingsContent>
</Settings>
); );
}; };

View file

@ -3,7 +3,11 @@ import { useTranslation } from "next-i18next";
import { getProfileLayout } from "@/components/layouts/profile-layout"; import { getProfileLayout } from "@/components/layouts/profile-layout";
import { ProfileSettings } from "@/components/settings/profile-settings"; import { ProfileSettings } from "@/components/settings/profile-settings";
import { SettingsSection } from "@/components/settings/settings-section"; import {
Settings,
SettingsHeader,
SettingsSection,
} from "@/components/settings/settings";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider"; import { useUser } from "@/components/user-provider";
@ -18,10 +22,13 @@ const Page: NextPageWithLayout = () => {
return null; return null;
} }
return ( return (
<div className="divide-y"> <Settings>
<Head> <Head>
<title>{t("settings")}</title> <title>{t("profile")}</title>
</Head> </Head>
<SettingsHeader>
<Trans i18nKey="profile" />
</SettingsHeader>
<SettingsSection <SettingsSection
title={<Trans i18nKey="profile" defaults="Profile" />} title={<Trans i18nKey="profile" defaults="Profile" />}
description={ description={
@ -59,7 +66,7 @@ const Page: NextPageWithLayout = () => {
<Trans i18nKey="deleteMyAccount" defaults="Yes, delete my account" /> <Trans i18nKey="deleteMyAccount" defaults="Yes, delete my account" />
</Button> </Button>
</SettingsSection> */} </SettingsSection> */}
</div> </Settings>
); );
}; };

View file

@ -4,8 +4,11 @@ export const planIdMonthly = process.env
export const planIdYearly = process.env export const planIdYearly = process.env
.NEXT_PUBLIC_PRO_PLAN_ID_YEARLY as string; .NEXT_PUBLIC_PRO_PLAN_ID_YEARLY as string;
export const isFeedbackEnabled = export const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === "true";
process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === "true";
export const isFeedbackEnabled = !isSelfHosted;
export const monthlyPriceUsd = 7; export const monthlyPriceUsd = 7;
export const annualPriceUsd = 42; export const annualPriceUsd = 42;
export const appVersion = process.env.NEXT_PUBLIC_APP_VERSION;

View file

@ -15,6 +15,8 @@ services:
rallly: rallly:
build: build:
args:
- SELF_HOSTED=true
context: . context: .
dockerfile: ./apps/web/Dockerfile dockerfile: ./apps/web/Dockerfile
restart: always restart: always

View file

@ -28,10 +28,14 @@
"dependencies": { "dependencies": {
"next": "^13.2.4", "next": "^13.2.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0",
"typescript": "^5.2.2"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"dotenv-cli": "^7.1.0", "dotenv-cli": "^7.1.0",
"eslint": "^8.48.0",
"prettier": "^2.8.4", "prettier": "^2.8.4",
"turbo": "^1.10.7" "turbo": "^1.10.7"
}, },

View file

@ -2,7 +2,7 @@ export const sessionConfig = {
password: process.env.SECRET_PASSWORD ?? "", password: process.env.SECRET_PASSWORD ?? "",
cookieName: "rallly-session", cookieName: "rallly-session",
cookieOptions: { cookieOptions: {
secure: process.env.NODE_ENV === "production", secure: process.env.NEXT_PUBLIC_BASE_URL?.startsWith("https://") ?? false,
}, },
ttl: 60 * 60 * 24 * 30, // 30 days ttl: 60 * 60 * 24 * 30, // 30 days
}; };

View file

@ -19,7 +19,13 @@ export async function createContext(
opts.req.session.user = user; opts.req.session.user = user;
await opts.req.session.save(); await opts.req.session.save();
} }
return { user, session: opts.req.session, req: opts.req, res: opts.res }; return {
user,
session: opts.req.session,
req: opts.req,
res: opts.res,
isSelfHosted: process.env.NEXT_PUBLIC_SELF_HOSTED === "true",
};
} }
export type Context = trpc.inferAsyncReturnType<typeof createContext>; export type Context = trpc.inferAsyncReturnType<typeof createContext>;

View file

@ -9,7 +9,6 @@ import utc from "dayjs/plugin/utc";
import * as ics from "ics"; import * as ics from "ics";
import { z } from "zod"; import { z } from "zod";
import { getSubscriptionStatus } from "../../utils/auth";
import { getTimeZoneAbbreviation } from "../../utils/date"; import { getTimeZoneAbbreviation } from "../../utils/date";
import { nanoid } from "../../utils/nanoid"; import { nanoid } from "../../utils/nanoid";
import { import {
@ -74,8 +73,6 @@ export const polls = router({
const participantUrlId = nanoid(); const participantUrlId = nanoid();
const pollId = nanoid(); const pollId = nanoid();
const { active: isPro } = await getSubscriptionStatus(ctx.user.id);
const poll = await prisma.poll.create({ const poll = await prisma.poll.create({
select: { select: {
adminUrlId: true, adminUrlId: true,
@ -117,13 +114,9 @@ export const polls = router({
})), })),
}, },
}, },
...(isPro hideParticipants: input.hideParticipants,
? { disableComments: input.disableComments,
hideParticipants: input.hideParticipants, hideScores: input.hideScores,
disableComments: input.disableComments,
hideScores: input.hideScores,
}
: undefined),
}, },
}); });
@ -171,11 +164,9 @@ export const polls = router({
hideScores: z.boolean().optional(), hideScores: z.boolean().optional(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ input }) => {
const pollId = await getPollIdFromAdminUrlId(input.urlId); const pollId = await getPollIdFromAdminUrlId(input.urlId);
const { active: isPro } = await getSubscriptionStatus(ctx.user.id);
if (input.optionsToDelete && input.optionsToDelete.length > 0) { if (input.optionsToDelete && input.optionsToDelete.length > 0) {
await prisma.option.deleteMany({ await prisma.option.deleteMany({
where: { where: {
@ -218,13 +209,9 @@ export const polls = router({
description: input.description, description: input.description,
timeZone: input.timeZone, timeZone: input.timeZone,
closed: input.closed, closed: input.closed,
...(isPro hideScores: input.hideScores,
? { hideParticipants: input.hideParticipants,
hideScores: input.hideScores, disableComments: input.disableComments,
hideParticipants: input.hideParticipants,
disableComments: input.disableComments,
}
: undefined),
}, },
}); });
}), }),

View file

@ -19,7 +19,8 @@ export const middleware = t.middleware;
export const possiblyPublicProcedure = t.procedure.use( export const possiblyPublicProcedure = t.procedure.use(
middleware(async ({ ctx, next }) => { middleware(async ({ ctx, next }) => {
if (process.env.AUTH_REQUIRED === "true" && ctx.user.isGuest) { // On self-hosted instances, these procedures require login
if (ctx.isSelfHosted && ctx.user.isGuest) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Login is required", message: "Login is required",
@ -38,6 +39,11 @@ export const proProcedure = t.procedure.use(
}); });
} }
if (ctx.isSelfHosted) {
// Self-hosted instances don't have paid subscriptions
return next();
}
const { active: isPro } = await getSubscriptionStatus(ctx.user.id); const { active: isPro } = await getSubscriptionStatus(ctx.user.id);
if (!isPro) { if (!isPro) {

View file

@ -18,8 +18,8 @@ export const getSubscriptionStatus = async (userId: string) => {
if (user?.subscription?.active === true) { if (user?.subscription?.active === true) {
return { return {
active: true, active: true,
expiresAt: user.subscription.periodEnd, legacy: false,
}; } as const;
} }
const userPaymentData = await prisma.userPaymentData.findFirst({ const userPaymentData = await prisma.userPaymentData.findFirst({
@ -34,18 +34,14 @@ export const getSubscriptionStatus = async (userId: string) => {
}, },
}); });
if ( if (userPaymentData) {
userPaymentData?.endDate &&
userPaymentData.endDate.getTime() > Date.now()
) {
return { return {
active: true, active: true,
legacy: true, legacy: true,
expiresAt: userPaymentData.endDate, } as const;
};
} }
return { return {
active: false, active: false,
}; } as const;
}; };

View file

@ -19,7 +19,6 @@
"@rallly/tsconfig": "*", "@rallly/tsconfig": "*",
"@types/node": "^18.15.10", "@types/node": "^18.15.10",
"prisma": "^5.0.0", "prisma": "^5.0.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1"
"typescript": "^5.0.2"
} }
} }

View file

@ -68,10 +68,6 @@ declare global {
* Example: "user@example.com, *@example.com, *@*.example.com" * Example: "user@example.com, *@example.com, *@*.example.com"
*/ */
ALLOWED_EMAILS?: string; ALLOWED_EMAILS?: string;
/**
* "true" to require authentication for creating new polls and accessing admin pages
*/
AUTH_REQUIRED?: string;
/** /**
* Determines what email provider to use. "smtp" or "ses" * Determines what email provider to use. "smtp" or "ses"
*/ */

View file

@ -26,12 +26,8 @@ SMTP_PWD=
# OPTIONAL CONFIG # OPTIONAL CONFIG
# Set to `true` to require authentication for creating new polls and accessing admin pages
AUTH_REQUIRED=false
# Comma separated list of email addresses that are allowed to register and login. # Comma separated list of email addresses that are allowed to register and login.
# You can use wildcard syntax to match a range of email addresses. # You can use wildcard syntax to match a range of email addresses.
# Example: "john@example.com,jane@example.com" or "*@example.com" # Example: "john@example.com,jane@example.com" or "*@example.com"
ALLOWED_EMAILS= ALLOWED_EMAILS=
# Whether or not to disable the landing page
DISABLE_LANDING_PAGE=false

View file

@ -1,7 +1,5 @@
#!/bin/sh #!/bin/sh
set -e set -e
export NEXT_PUBLIC_APP_VERSION=v$(node -p "require('./package.json').version")
echo "Set NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION"
yarn prisma generate yarn prisma generate
yarn build yarn build
# Deploy migration using direct database connection (no connection pool) # Deploy migration using direct database connection (no connection pool)

View file

@ -51,7 +51,6 @@
"ALLOWED_EMAILS", "ALLOWED_EMAILS",
"ANALYZE", "ANALYZE",
"API_SECRET", "API_SECRET",
"AUTH_REQUIRED",
"AWS_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID",
"AWS_REGION", "AWS_REGION",
"AWS_SECRET_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY",
@ -65,12 +64,12 @@
"NEXT_PUBLIC_CRISP_WEBSITE_ID", "NEXT_PUBLIC_CRISP_WEBSITE_ID",
"NEXT_PUBLIC_ENABLE_ANALYTICS", "NEXT_PUBLIC_ENABLE_ANALYTICS",
"NEXT_PUBLIC_ENABLE_FINALIZATION", "NEXT_PUBLIC_ENABLE_FINALIZATION",
"NEXT_PUBLIC_FEEDBACK_ENABLED",
"NEXT_PUBLIC_LANDING_PAGE_URL", "NEXT_PUBLIC_LANDING_PAGE_URL",
"NEXT_PUBLIC_MAINTENANCE_MODE", "NEXT_PUBLIC_MAINTENANCE_MODE",
"NEXT_PUBLIC_PADDLE_SANDBOX", "NEXT_PUBLIC_PADDLE_SANDBOX",
"NEXT_PUBLIC_PADDLE_VENDOR_ID", "NEXT_PUBLIC_PADDLE_VENDOR_ID",
"NEXT_PUBLIC_POSTHOG_API_HOST", "NEXT_PUBLIC_POSTHOG_API_HOST",
"NEXT_PUBLIC_SELF_HOSTED",
"NEXT_PUBLIC_POSTHOG_API_KEY", "NEXT_PUBLIC_POSTHOG_API_KEY",
"NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY", "NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY",
"NEXT_PUBLIC_PRO_PLAN_ID_YEARLY", "NEXT_PUBLIC_PRO_PLAN_ID_YEARLY",

592
yarn.lock

File diff suppressed because it is too large Load diff