mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-28 17:56:37 +02:00
✨ Self-Hosting Update (#842)
This commit is contained in:
parent
3e616d1e41
commit
7a5f9ae474
51 changed files with 945 additions and 781 deletions
1
.github/workflows/docker-image-manual.yml
vendored
1
.github/workflows/docker-image-manual.yml
vendored
|
@ -37,3 +37,4 @@ jobs:
|
|||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ github.ref_name }}
|
||||
SELF_HOSTED=true
|
||||
|
|
|
@ -39,3 +39,4 @@ jobs:
|
|||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ github.ref_name }}
|
||||
SELF_HOSTED=true
|
||||
|
|
|
@ -6,18 +6,15 @@ description: Help fund this project and keep it ad-free
|
|||
|
||||
## Why should I donate?
|
||||
|
||||
> Rallly is available free and without ads.
|
||||
> But free software doesn't mean it's without cost.
|
||||
> Someone has to develop, maintain, and support it.
|
||||
> 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
|
||||
Most users of Rallly use it for free, and that's great!
|
||||
However, running Rallly costs money.
|
||||
By donating, you can help keep Rallly running and ad-free.
|
||||
|
||||
## How can I donate?
|
||||
|
||||
You can donate using any of the following methods:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<CardGroup cols={3}>
|
||||
<Card
|
||||
title="Donate with PayPal"
|
||||
icon="paypal"
|
||||
|
|
|
@ -12,13 +12,13 @@
|
|||
"anchors": { "from": "#878AFA", "to": "#4347F1" }
|
||||
},
|
||||
"topbarCtaButton": {
|
||||
"name": "Get Started",
|
||||
"url": "https://rallly.co/new"
|
||||
"name": "Go to App",
|
||||
"url": "https://app.rallly.co"
|
||||
},
|
||||
"topbarLinks": [
|
||||
{
|
||||
"name": "Contact",
|
||||
"url": "/contact/support"
|
||||
"name": "Home",
|
||||
"url": "https://rallly.co"
|
||||
}
|
||||
],
|
||||
"anchors": [
|
||||
|
@ -63,6 +63,7 @@
|
|||
"group": "Self-Hosting",
|
||||
"pages": [
|
||||
"self-hosting/introduction",
|
||||
"self-hosting/pricing",
|
||||
"self-hosting/managed-hosting",
|
||||
"self-hosting/configuration-options"
|
||||
]
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
---
|
||||
icon: gear
|
||||
title: Configuration Options
|
||||
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.
|
||||
|
||||
<ParamField path="DATABASE_URL" default="">
|
||||
<ParamField path="DATABASE_URL" required>
|
||||
Postgres database connection string
|
||||
</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.
|
||||
`http://` or `https://`), the domain name, and optionally a port.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="SECRET_PASSWORD">
|
||||
<ParamField path="SECRET_PASSWORD" required>
|
||||
A random 32-character secret key used to encrypt user sessions
|
||||
</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
|
||||
|
||||
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,
|
||||
`SUPPORT_EMAIL` will be used instead.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="SUPPORT_EMAIL" default="">
|
||||
<ParamField path="SUPPORT_EMAIL" required>
|
||||
This email will be shown as the contact email for support queries.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="SMTP_HOST" default="localhost">
|
||||
<ParamField path="SMTP_HOST" required>
|
||||
The host address of your SMTP server
|
||||
</ParamField>
|
||||
|
||||
|
@ -46,7 +53,7 @@ These variables need to be configured to let Rallly send out transactional email
|
|||
The port of your SMTP server
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="SMTP_SECURE" default={"false"}>
|
||||
<ParamField path="SMTP_SECURE" default="false">
|
||||
Set to "true" if SSL is enabled for your SMTP connection
|
||||
</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"}>
|
||||
Enable TLS for your SMTP connection
|
||||
</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>
|
||||
|
|
|
@ -1,13 +1,29 @@
|
|||
---
|
||||
icon: server
|
||||
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
|
||||
|
||||
|
@ -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.
|
||||
</Card>
|
||||
<Card
|
||||
icon="server"
|
||||
icon="cloud"
|
||||
title="Using a hosting provider"
|
||||
href="/self-hosting/managed-hosting"
|
||||
>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
---
|
||||
icon: cloud
|
||||
title: Managed Hosting
|
||||
description: Using a managed hosting service to self-host Rallly.
|
||||
---
|
||||
|
|
55
apps/docs/self-hosting/pricing.mdx
Normal file
55
apps/docs/self-hosting/pricing.mdx
Normal 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 doesn’t mean it’s 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>
|
4
apps/landing/declarations/environment.d.ts
vendored
4
apps/landing/declarations/environment.d.ts
vendored
|
@ -64,10 +64,6 @@ declare global {
|
|||
* Example: "user@example.com, *@example.com, *@*.example.com"
|
||||
*/
|
||||
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"
|
||||
*/
|
||||
|
|
|
@ -36,8 +36,7 @@
|
|||
"react-i18next": "^12.1.4",
|
||||
"react-use": "^17.4.0",
|
||||
"remark": "^14.0.3",
|
||||
"remark-html": "^15.0.2",
|
||||
"typescript": "^4.9.4"
|
||||
"remark-html": "^15.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^12.3.4",
|
||||
|
@ -50,11 +49,8 @@
|
|||
"@types/react-dom": "^18.0.11",
|
||||
"@types/react-linkify": "^1.0.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",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^7.26.0",
|
||||
"eslint-config-next": "^13.0.1",
|
||||
"eslint-config-turbo": "^0.0.9",
|
||||
"eslint-import-resolver-typescript": "^2.7.0",
|
||||
|
|
|
@ -21,6 +21,9 @@ RUN yarn db:generate
|
|||
ARG APP_VERSION
|
||||
ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION
|
||||
|
||||
ARG SELF_HOSTED
|
||||
ENV NEXT_PUBLIC_SELF_HOSTED=$SELF_HOSTED
|
||||
|
||||
RUN yarn build
|
||||
|
||||
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/public ./apps/web/public
|
||||
|
||||
ARG SELF_HOSTED
|
||||
ENV NEXT_PUBLIC_SELF_HOSTED=$SELF_HOSTED
|
||||
|
||||
CMD ["./docker-start.sh"]
|
||||
|
|
8
apps/web/declarations/environment.d.ts
vendored
8
apps/web/declarations/environment.d.ts
vendored
|
@ -33,10 +33,6 @@ declare global {
|
|||
* Crisp website ID
|
||||
*/
|
||||
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
|
||||
*/
|
||||
|
@ -68,10 +64,6 @@ declare global {
|
|||
* Example: "user@example.com, *@example.com, *@*.example.com"
|
||||
*/
|
||||
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"
|
||||
*/
|
||||
|
|
|
@ -33,6 +33,7 @@
|
|||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tanstack/react-table": "^8.9.1",
|
||||
"@vercel/analytics": "^0.1.8",
|
||||
"@vercel/og": "^0.5.13",
|
||||
"accept-language-parser": "^1.5.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"class-variance-authority": "^0.6.0",
|
||||
|
@ -67,7 +68,6 @@
|
|||
"spacetime": "^7.4.7",
|
||||
"superjson": "^1.12.2",
|
||||
"timezone-soft": "^1.4.1",
|
||||
"typescript": "^4.9.4",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -81,11 +81,8 @@
|
|||
"@types/react-dom": "^18.0.11",
|
||||
"@types/react-linkify": "^1.0.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",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^7.26.0",
|
||||
"eslint-config-next": "^13.0.1",
|
||||
"eslint-config-turbo": "^0.0.9",
|
||||
"eslint-import-resolver-typescript": "^2.7.0",
|
||||
|
|
|
@ -103,7 +103,6 @@
|
|||
"addComment": "Add Comment",
|
||||
"profile": "Profile",
|
||||
"polls": "Polls",
|
||||
"showLess": "Show less…",
|
||||
"showMore": "Show more…",
|
||||
"timeZoneSelect__defaultValue": "Select time zone…",
|
||||
"timeZoneSelect__noOption": "No option found",
|
||||
|
@ -205,14 +204,8 @@
|
|||
"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 :)",
|
||||
"savePercent": "Save {percent}%",
|
||||
"priceIncreaseSoon": "Price increase soon.",
|
||||
"lockPrice": "Upgrade today to keep this price forever.",
|
||||
"noAds": "No ads",
|
||||
"upgrade": "Upgrade",
|
||||
"notToday": "Not Today",
|
||||
"supportProject": "Support this project",
|
||||
"features": "Get access to all current and future Pro features!",
|
||||
"continueAsGuest": "Continue as Guest",
|
||||
"scrollLeft": "Scroll Left",
|
||||
"scrollRight": "Scroll Right",
|
||||
|
@ -231,5 +224,12 @@
|
|||
"accessAllFeatures": "Access all 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.",
|
||||
"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"
|
||||
}
|
||||
|
|
|
@ -1,25 +1,21 @@
|
|||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Trans } from "next-i18next";
|
||||
import React from "react";
|
||||
|
||||
import { Logo } from "@/components/logo";
|
||||
import { IfCloudHosted } from "@/contexts/environment";
|
||||
|
||||
export const AuthLayout = ({ children }: { children?: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="h-full p-3 sm:p-8">
|
||||
<div className="mx-auto max-w-lg">
|
||||
<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">
|
||||
<Image
|
||||
src="/static/logo.svg"
|
||||
height={30}
|
||||
width={150}
|
||||
alt="Rallly"
|
||||
className="text-primary-600 inline-block h-7"
|
||||
/>
|
||||
<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">
|
||||
<Logo />
|
||||
</div>
|
||||
<div className="p-4 sm:p-6">{children}</div>
|
||||
</div>
|
||||
{process.env.AUTH_REQUIRED === "true" ? null : (
|
||||
<IfCloudHosted>
|
||||
<p className="mt-8 text-center">
|
||||
<Link
|
||||
href="/polls"
|
||||
|
@ -28,7 +24,7 @@ export const AuthLayout = ({ children }: { children?: React.ReactNode }) => {
|
|||
<Trans i18nKey="continueAsGuest" defaults="Continue as Guest" />
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</IfCloudHosted>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -141,7 +141,7 @@ export const BillingPlans = () => {
|
|||
</div>
|
||||
</Tabs>
|
||||
<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" />
|
||||
</div>
|
||||
<div className="mb-2 flex items-center gap-x-2">
|
||||
|
|
|
@ -13,7 +13,7 @@ const FeaturebaseScript = () => (
|
|||
<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(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const win = window as any;
|
||||
|
@ -37,7 +37,7 @@ export const Changelog = ({ className }: { className?: string }) => {
|
|||
<>
|
||||
<FeaturebaseScript />
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
"hidden sm:inline-flex [&>*]:pointer-events-none",
|
||||
|
|
|
@ -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 clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { useToggle } from "react-use";
|
||||
|
||||
import { Container } from "@/components/container";
|
||||
import { StandardLayout } from "@/components/layouts/standard-layout";
|
||||
import { IfCloudHosted } from "@/contexts/environment";
|
||||
import { Plan } from "@/contexts/plan";
|
||||
|
||||
import { IconComponent, NextPageWithLayout } from "../../types";
|
||||
import { IfAuthenticated } from "../user-provider";
|
||||
import { useUser } from "../user-provider";
|
||||
|
||||
const MenuItem = (props: {
|
||||
icon: IconComponent;
|
||||
|
@ -21,40 +31,69 @@ const MenuItem = (props: {
|
|||
return (
|
||||
<Link
|
||||
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
|
||||
? "text-foreground"
|
||||
: "text-gray-500 hover:text-gray-800",
|
||||
? "bg-gray-200"
|
||||
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800",
|
||||
)}
|
||||
href={props.href}
|
||||
>
|
||||
<props.icon className="h-5 w-5" />
|
||||
{props.children}
|
||||
<props.icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{props.children}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<Container className="p-2 sm:py-8">
|
||||
<Card className="mx-auto max-w-4xl overflow-hidden">
|
||||
<div className="flex gap-4 gap-x-6 border-b bg-gray-50 px-3 py-4 md:px-4">
|
||||
<IfAuthenticated>
|
||||
<MenuItem href="/settings/profile" icon={UserIcon}>
|
||||
<Trans i18nKey="profile" defaults="Profile" />
|
||||
</MenuItem>
|
||||
</IfAuthenticated>
|
||||
<MenuItem href="/settings/preferences" icon={Settings2Icon}>
|
||||
<Trans i18nKey="preferences" defaults="Preferences" />
|
||||
</MenuItem>
|
||||
<IfAuthenticated>
|
||||
<MenuItem href="/settings/billing" icon={CreditCardIcon}>
|
||||
<Trans i18nKey="billing" defaults="Billing" />
|
||||
</MenuItem>
|
||||
</IfAuthenticated>
|
||||
<Card className="mx-auto flex flex-col overflow-hidden md:min-h-[600px]">
|
||||
<div className="border-b bg-gray-50 p-3 md:hidden">
|
||||
<Button onClick={toggle} icon={MenuIcon} />
|
||||
</div>
|
||||
<div className="relative flex grow md:divide-x">
|
||||
<div
|
||||
className={cn(
|
||||
"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",
|
||||
{
|
||||
hidden: !isMenuOpen,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1">
|
||||
<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>
|
||||
{children}
|
||||
</Card>
|
||||
</Container>
|
||||
</div>
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { ListIcon, LogInIcon, SparklesIcon } from "@rallly/icons";
|
||||
import { ListIcon, SparklesIcon } from "@rallly/icons";
|
||||
import { cn } from "@rallly/ui";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import clsx from "clsx";
|
||||
import { AnimatePresence, m } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
@ -11,13 +10,18 @@ import { Toaster } from "react-hot-toast";
|
|||
|
||||
import { Clock, ClockPreferences } from "@/components/clock";
|
||||
import { Container } from "@/components/container";
|
||||
import { Changelog, FeaturebaseIdentify } from "@/components/featurebase";
|
||||
import {
|
||||
FeaturebaseChangelog,
|
||||
FeaturebaseIdentify,
|
||||
} from "@/components/featurebase";
|
||||
import FeedbackButton from "@/components/feedback";
|
||||
import { Logo } from "@/components/logo";
|
||||
import { Spinner } from "@/components/spinner";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { UserDropdown } from "@/components/user-dropdown";
|
||||
import { IfCloudHosted } from "@/contexts/environment";
|
||||
import { IfFreeUser } from "@/contexts/plan";
|
||||
import { isFeedbackEnabled } from "@/utils/constants";
|
||||
import { appVersion, isFeedbackEnabled } from "@/utils/constants";
|
||||
import { DayjsProvider } from "@/utils/dayjs";
|
||||
|
||||
import { IconComponent, NextPageWithLayout } from "../../types";
|
||||
|
@ -73,7 +77,7 @@ const Upgrade = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const Logo = () => {
|
||||
const LogoArea = () => {
|
||||
const router = useRouter();
|
||||
const [isBusy, setIsBusy] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
|
@ -87,20 +91,14 @@ const Logo = () => {
|
|||
};
|
||||
}, [router.events]);
|
||||
return (
|
||||
<div className="relative flex items-center justify-center gap-4">
|
||||
<div className="relative flex items-center justify-center gap-x-4">
|
||||
<Link
|
||||
href="/polls"
|
||||
className={clsx(
|
||||
"inline-block transition-transform active:translate-y-1",
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
priority={true}
|
||||
src="/static/logo.svg"
|
||||
width={120}
|
||||
height={22}
|
||||
alt="Rallly"
|
||||
/>
|
||||
<Logo size="sm" />
|
||||
</Link>
|
||||
<div
|
||||
className={cn(
|
||||
|
@ -114,6 +112,10 @@ const Logo = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const Changelog = () => {
|
||||
return <FeaturebaseChangelog />;
|
||||
};
|
||||
|
||||
const MainNav = () => {
|
||||
return (
|
||||
<m.div
|
||||
|
@ -127,8 +129,8 @@ const MainNav = () => {
|
|||
className="border-b bg-gray-50/50"
|
||||
>
|
||||
<Container className="flex h-14 items-center justify-between gap-x-2.5">
|
||||
<div className="flex shrink-0">
|
||||
<Logo />
|
||||
<div className="flex shrink-0 gap-x-4">
|
||||
<LogoArea />
|
||||
<nav className="hidden gap-x-2 sm:flex">
|
||||
<NavMenuItem
|
||||
icon={ListIcon}
|
||||
|
@ -137,11 +139,13 @@ const MainNav = () => {
|
|||
/>
|
||||
</nav>
|
||||
</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">
|
||||
<IfFreeUser>
|
||||
<Upgrade />
|
||||
</IfFreeUser>
|
||||
<IfCloudHosted>
|
||||
<IfFreeUser>
|
||||
<Upgrade />
|
||||
</IfFreeUser>
|
||||
</IfCloudHosted>
|
||||
<IfGuest>
|
||||
<Button
|
||||
size="sm"
|
||||
|
@ -150,12 +154,13 @@ const MainNav = () => {
|
|||
className="hidden sm:flex"
|
||||
>
|
||||
<Link href="/login">
|
||||
<LogInIcon className="h-4 w-4" />
|
||||
<Trans i18nKey="login" defaults="Login" />
|
||||
</Link>
|
||||
</Button>
|
||||
</IfGuest>
|
||||
<Changelog />
|
||||
<IfCloudHosted>
|
||||
<Changelog />
|
||||
</IfCloudHosted>
|
||||
<ClockPreferences>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Clock />
|
||||
|
@ -198,6 +203,17 @@ export const StandardLayout: React.FunctionComponent<{
|
|||
{children}
|
||||
</m.div>
|
||||
</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>
|
||||
{isFeedbackEnabled ? (
|
||||
<>
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
import clsx from "clsx";
|
||||
import Image from "next/image";
|
||||
|
||||
export const Logo = (props: { className?: string; color?: boolean }) => {
|
||||
const { color = true } = props;
|
||||
const sizes = {
|
||||
sm: {
|
||||
width: 120,
|
||||
height: 22,
|
||||
},
|
||||
md: {
|
||||
width: 150,
|
||||
height: 30,
|
||||
},
|
||||
};
|
||||
|
||||
export const Logo = ({ size = "md" }: { size?: keyof typeof sizes }) => {
|
||||
return (
|
||||
<span className="inline-flex select-none items-center">
|
||||
<span
|
||||
className={clsx(
|
||||
"font-semibold uppercase tracking-widest",
|
||||
{
|
||||
"text-primary-600": color,
|
||||
},
|
||||
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>
|
||||
<Image
|
||||
priority={true}
|
||||
className="mx"
|
||||
src="/static/logo.svg"
|
||||
width={sizes[size].width}
|
||||
height={sizes[size].height}
|
||||
alt="Rallly"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
70
apps/web/src/components/settings/settings.tsx
Normal file
70
apps/web/src/components/settings/settings.tsx
Normal 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>;
|
||||
};
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -12,20 +12,36 @@ export const UpgradeButton = ({
|
|||
const posthog = usePostHog();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="primary"
|
||||
asChild
|
||||
onClick={() => {
|
||||
posthog?.capture("click upgrade button");
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`/api/stripe/checkout?period=${
|
||||
annual ? "yearly" : "monthly"
|
||||
}&return_path=${encodeURIComponent(window.location.pathname)}`}
|
||||
<form method="POST" action="/api/stripe/checkout">
|
||||
<input
|
||||
type="hidden"
|
||||
name="period"
|
||||
value={annual ? "yearly" : "monthly"}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="return_path"
|
||||
value={encodeURIComponent(window.location.pathname)}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
posthog?.capture("click upgrade button");
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
</Button>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
ChevronDown,
|
||||
CreditCardIcon,
|
||||
GemIcon,
|
||||
LifeBuoyIcon,
|
||||
ListIcon,
|
||||
LogInIcon,
|
||||
|
@ -24,6 +25,7 @@ import Link from "next/link";
|
|||
|
||||
import { Trans } from "@/components/trans";
|
||||
import { CurrentUserAvatar } from "@/components/user";
|
||||
import { IfCloudHosted, IfSelfHosted } from "@/contexts/environment";
|
||||
import { Plan } from "@/contexts/plan";
|
||||
import { isFeedbackEnabled } from "@/utils/constants";
|
||||
|
||||
|
@ -34,7 +36,7 @@ export const UserDropdown = () => {
|
|||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild className="group">
|
||||
<Button className="rounded-full">
|
||||
<Button variant="ghost" className="rounded-full">
|
||||
<CurrentUserAvatar size="sm" className="-ml-1" />
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
|
@ -52,17 +54,18 @@ export const UserDropdown = () => {
|
|||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<IfAuthenticated>
|
||||
<DropdownMenuItem asChild={true}>
|
||||
<Link
|
||||
href="/settings/profile"
|
||||
className="flex items-center gap-x-2"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<Trans i18nKey="profile" defaults="Profile" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</IfAuthenticated>
|
||||
<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>
|
||||
<DropdownMenuItem asChild={true}>
|
||||
<Link href="/settings/profile" className="flex items-center gap-x-2">
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<Trans i18nKey="profile" defaults="Profile" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild={true}>
|
||||
<Link
|
||||
href="/settings/preferences"
|
||||
|
@ -72,7 +75,7 @@ export const UserDropdown = () => {
|
|||
<Trans i18nKey="preferences" defaults="Preferences" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<IfAuthenticated>
|
||||
<IfCloudHosted>
|
||||
<DropdownMenuItem asChild={true}>
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
|
@ -82,13 +85,7 @@ export const UserDropdown = () => {
|
|||
<Trans i18nKey="Billing" defaults="Billing" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</IfAuthenticated>
|
||||
<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>
|
||||
</IfCloudHosted>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild={true}>
|
||||
<Link
|
||||
|
@ -100,6 +97,18 @@ export const UserDropdown = () => {
|
|||
<Trans i18nKey="support" defaults="Support" />
|
||||
</Link>
|
||||
</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 ? (
|
||||
<DropdownMenuItem asChild={true}>
|
||||
<Link
|
||||
|
|
|
@ -8,7 +8,7 @@ import { useWhoAmI } from "@/contexts/whoami";
|
|||
import { useRequiredContext } from "./use-required-context";
|
||||
|
||||
export const UserContext = React.createContext<{
|
||||
user: UserSession & { shortName: string };
|
||||
user: UserSession & { name: string };
|
||||
refresh: () => void;
|
||||
ownsObject: (obj: { userId: string | null }) => boolean;
|
||||
} | null>(null);
|
||||
|
@ -50,23 +50,22 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
|
|||
const queryClient = trpc.useContext();
|
||||
|
||||
const user = useWhoAmI();
|
||||
const subscriptionQuery = trpc.user.subscription.useQuery();
|
||||
const { data: userPreferences } = trpc.userPreferences.get.useQuery();
|
||||
|
||||
const shortName = user
|
||||
const name = user
|
||||
? user.isGuest === false
|
||||
? user.name.split(" ")[0]
|
||||
? user.name
|
||||
: user.id.substring(0, 10)
|
||||
: t("guest");
|
||||
|
||||
if (!user || userPreferences === undefined || !subscriptionQuery.isFetched) {
|
||||
if (!user || userPreferences === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UserContext.Provider
|
||||
value={{
|
||||
user: { ...user, shortName },
|
||||
user: { ...user, name },
|
||||
refresh: () => {
|
||||
return queryClient.whoami.invalidate();
|
||||
},
|
||||
|
|
9
apps/web/src/contexts/environment.tsx
Normal file
9
apps/web/src/contexts/environment.tsx
Normal 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}</>;
|
||||
};
|
|
@ -3,9 +3,33 @@ import { Badge } from "@rallly/ui/badge";
|
|||
import React from "react";
|
||||
|
||||
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 = () => {
|
||||
const { data } = trpc.user.subscription.useQuery();
|
||||
const data = useSubscription();
|
||||
|
||||
const isPaid = data?.active === true;
|
||||
|
||||
|
@ -34,6 +58,7 @@ export const Plan = () => {
|
|||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<Trans i18nKey="planFree" defaults="Free" />
|
||||
|
|
|
@ -10,26 +10,37 @@ const publicPaths = ["/login", "/register", "/invite", "/auth"];
|
|||
// these paths always require authentication
|
||||
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) {
|
||||
const { headers, cookies, nextUrl } = req;
|
||||
const newUrl = nextUrl.clone();
|
||||
const res = NextResponse.next();
|
||||
const session = await getSession(req, res);
|
||||
|
||||
// a protected path is one that requires to be logged in
|
||||
const isProtectedPath = protectedPaths.some((protectedPath) =>
|
||||
req.nextUrl.pathname.includes(protectedPath),
|
||||
);
|
||||
const isLoginRequired = await checkLoginRequirements(req, res);
|
||||
|
||||
const isProtectedPathDueToRequiredAuth =
|
||||
process.env.AUTH_REQUIRED === "true" &&
|
||||
!publicPaths.some((publicPath) =>
|
||||
req.nextUrl.pathname.startsWith(publicPath),
|
||||
);
|
||||
|
||||
const isGuest = session.user?.isGuest !== false;
|
||||
|
||||
if (isGuest && (isProtectedPathDueToRequiredAuth || isProtectedPath)) {
|
||||
if (isLoginRequired) {
|
||||
newUrl.pathname = "/login";
|
||||
newUrl.searchParams.set("redirect", req.nextUrl.pathname);
|
||||
return NextResponse.redirect(newUrl);
|
||||
|
|
|
@ -59,7 +59,7 @@ const Page: NextPageWithLayout<{ userId: string; pollId: string }> = ({
|
|||
<ArrowRightIcon className="text-muted-foreground h-4 w-4" />
|
||||
<div className="flex items-center gap-x-2.5">
|
||||
<CurrentUserAvatar />
|
||||
<div>{user.shortName}</div>
|
||||
<div>{user.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageDialogContent>
|
||||
|
|
|
@ -20,17 +20,19 @@ export default async function handler(
|
|||
res: NextApiResponse,
|
||||
) {
|
||||
const userSession = await getSession(req, res);
|
||||
const { period = "monthly", return_path } = inputSchema.parse(req.body);
|
||||
|
||||
if (userSession.user?.isGuest !== false) {
|
||||
// You need to be logged in to subscribe
|
||||
return res
|
||||
.status(403)
|
||||
.redirect(
|
||||
`/login${req.url ? `?redirect=${encodeURIComponent(req.url)}` : ""}`,
|
||||
);
|
||||
}
|
||||
res.redirect(
|
||||
303,
|
||||
`/login${
|
||||
return_path ? `?redirect=${encodeURIComponent(return_path)}` : ""
|
||||
}`,
|
||||
);
|
||||
|
||||
const { period = "monthly", return_path } = inputSchema.parse(req.query);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
|
@ -48,12 +50,14 @@ export default async function handler(
|
|||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(403).redirect("/logout");
|
||||
res.redirect(303, "/logout");
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.subscription?.active === true) {
|
||||
// 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({
|
||||
|
@ -99,10 +103,11 @@ export default async function handler(
|
|||
|
||||
if (session.url) {
|
||||
// redirect to checkout session
|
||||
return res.status(303).redirect(session.url);
|
||||
res.redirect(303, session.url);
|
||||
return;
|
||||
}
|
||||
|
||||
return res
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Something went wrong while creating a checkout session" });
|
||||
}
|
||||
|
|
|
@ -18,11 +18,12 @@ export default async function handler(
|
|||
|
||||
if (userSession.user?.isGuest !== false) {
|
||||
// You need to be logged in to subscribe
|
||||
return res
|
||||
res
|
||||
.status(403)
|
||||
.redirect(
|
||||
`/login${req.url ? `?redirect=${encodeURIComponent(req.url)}` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
|
@ -36,7 +37,8 @@ export default async function handler(
|
|||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(403).redirect("/logout");
|
||||
res.status(403).redirect("/logout");
|
||||
return;
|
||||
}
|
||||
|
||||
const { session_id: sessionId, return_path } = inputSchema.parse(req.query);
|
||||
|
|
|
@ -35,15 +35,18 @@ export default async function handler(
|
|||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).end();
|
||||
res.status(405).end();
|
||||
return;
|
||||
}
|
||||
if (!endpointSecret) {
|
||||
return res.status(400).send("No endpoint secret");
|
||||
res.status(400).send("No endpoint secret");
|
||||
return;
|
||||
}
|
||||
const event = await validatedWebhook(req);
|
||||
|
||||
if (!event) {
|
||||
return res.status(400).send("Invalid signature");
|
||||
res.status(400).send("Invalid signature");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
|
@ -52,7 +55,8 @@ export default async function handler(
|
|||
const { userId } = metadataSchema.parse(checkoutSession.metadata);
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).send("Missing client reference ID");
|
||||
res.status(400).send("Missing client reference ID");
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
|
|
|
@ -1,19 +1,28 @@
|
|||
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 { Card } from "@rallly/ui/card";
|
||||
import { Label } from "@rallly/ui/label";
|
||||
import dayjs from "dayjs";
|
||||
import { GetStaticProps } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
import { BillingPlans } from "@/components/billing/billing-plans";
|
||||
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 { useUser } from "@/components/user-provider";
|
||||
import { Plan } from "@/contexts/plan";
|
||||
import { useSubscription } from "@/contexts/plan";
|
||||
import { isSelfHosted } from "@/utils/constants";
|
||||
|
||||
import { NextPageWithLayout } from "../../types";
|
||||
import { getStaticTranslations } from "../../utils/with-page-translations";
|
||||
|
@ -28,9 +37,13 @@ declare global {
|
|||
const BillingPortal = () => {
|
||||
return (
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
<Trans i18nKey="billingPortal" />
|
||||
</Label>
|
||||
<SettingsGroup>
|
||||
<SettingsItem>
|
||||
<SettingsItemTitle>
|
||||
<Trans i18nKey="billingStatus" />
|
||||
</SettingsItemTitle>
|
||||
</SettingsItem>
|
||||
</SettingsGroup>
|
||||
<p className="text-sm">
|
||||
<Trans
|
||||
i18nKey="activeSubscription"
|
||||
|
@ -40,10 +53,12 @@ const BillingPortal = () => {
|
|||
<div className="mt-6">
|
||||
<Button asChild>
|
||||
<Link
|
||||
target="_blank"
|
||||
href={`/api/stripe/portal?return_path=${encodeURIComponent(
|
||||
window.location.pathname,
|
||||
)}`}
|
||||
>
|
||||
<CreditCardIcon className="h-4 w-4" />
|
||||
<span>
|
||||
<Trans i18nKey="billingPortal" defaults="Billing Portal" />
|
||||
</span>
|
||||
|
@ -62,28 +77,14 @@ export const proPlanIdYearly = process.env
|
|||
.NEXT_PUBLIC_PRO_PLAN_ID_YEARLY as string;
|
||||
|
||||
const SubscriptionStatus = () => {
|
||||
const { user } = useUser();
|
||||
|
||||
const { data } = trpc.user.subscription.useQuery();
|
||||
const data = useSubscription();
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (user.isGuest) {
|
||||
return <>You need to be logged in.</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
<Trans i18nKey="currentPlan" />
|
||||
</Label>
|
||||
<div>
|
||||
<Plan />
|
||||
</div>
|
||||
</div>
|
||||
{!data.active ? (
|
||||
<div>
|
||||
<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 { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="divide-y">
|
||||
<Settings>
|
||||
<SettingsHeader>
|
||||
<Trans i18nKey="billing" />
|
||||
</SettingsHeader>
|
||||
<Head>
|
||||
<title>{t("billing")}</title>
|
||||
</Head>
|
||||
<SettingsSection
|
||||
title={<Trans i18nKey="billingStatus" defaults="Billing Status" />}
|
||||
description={
|
||||
<Trans
|
||||
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">
|
||||
<SettingsContent>
|
||||
<SettingsSection
|
||||
title={<Trans i18nKey="billingStatus" defaults="Billing Status" />}
|
||||
description={
|
||||
<Trans
|
||||
i18nKey="supportBilling"
|
||||
defaults="Please reach out if you need any assistance."
|
||||
i18nKey="billingStatusDescription"
|
||||
defaults="Manage your subscription and billing details."
|
||||
/>
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="mailto:support@rallly.co">
|
||||
<Trans i18nKey="contactSupport" defaults="Contact Support" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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
|
||||
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;
|
||||
|
||||
export const getStaticProps = getStaticTranslations;
|
||||
export const getStaticProps: GetStaticProps = async (ctx) => {
|
||||
if (isSelfHosted) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
return await getStaticTranslations(ctx);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
|
|
@ -4,7 +4,12 @@ import { useTranslation } from "next-i18next";
|
|||
import { getProfileLayout } from "@/components/layouts/profile-layout";
|
||||
import { DateTimePreferences } from "@/components/settings/date-time-preferences";
|
||||
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 { NextPageWithLayout } from "../../types";
|
||||
|
@ -14,33 +19,38 @@ const Page: NextPageWithLayout = () => {
|
|||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="divide-y">
|
||||
<Head>
|
||||
<title>{t("settings")}</title>
|
||||
</Head>
|
||||
<SettingsSection
|
||||
title={<Trans i18nKey="language" defaults="Language" />}
|
||||
description={
|
||||
<Trans
|
||||
i18nKey="languageDescription"
|
||||
defaults="Change your preferred language"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LanguagePreference />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
title={<Trans i18nKey="dateAndTime" defaults="Date & Time" />}
|
||||
description={
|
||||
<Trans
|
||||
i18nKey="dateAndTimeDescription"
|
||||
defaults="Change your preferred date and time settings"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DateTimePreferences />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
<Settings>
|
||||
<SettingsHeader>
|
||||
<Trans i18nKey="preferences" />
|
||||
</SettingsHeader>
|
||||
<SettingsContent>
|
||||
<Head>
|
||||
<title>{t("settings")}</title>
|
||||
</Head>
|
||||
<SettingsSection
|
||||
title={<Trans i18nKey="language" defaults="Language" />}
|
||||
description={
|
||||
<Trans
|
||||
i18nKey="languageDescription"
|
||||
defaults="Change your preferred language"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LanguagePreference />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
title={<Trans i18nKey="dateAndTime" defaults="Date & Time" />}
|
||||
description={
|
||||
<Trans
|
||||
i18nKey="dateAndTimeDescription"
|
||||
defaults="Change your preferred date and time settings"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DateTimePreferences />
|
||||
</SettingsSection>
|
||||
</SettingsContent>
|
||||
</Settings>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -3,7 +3,11 @@ import { useTranslation } from "next-i18next";
|
|||
|
||||
import { getProfileLayout } from "@/components/layouts/profile-layout";
|
||||
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 { useUser } from "@/components/user-provider";
|
||||
|
||||
|
@ -18,10 +22,13 @@ const Page: NextPageWithLayout = () => {
|
|||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="divide-y">
|
||||
<Settings>
|
||||
<Head>
|
||||
<title>{t("settings")}</title>
|
||||
<title>{t("profile")}</title>
|
||||
</Head>
|
||||
<SettingsHeader>
|
||||
<Trans i18nKey="profile" />
|
||||
</SettingsHeader>
|
||||
<SettingsSection
|
||||
title={<Trans i18nKey="profile" defaults="Profile" />}
|
||||
description={
|
||||
|
@ -59,7 +66,7 @@ const Page: NextPageWithLayout = () => {
|
|||
<Trans i18nKey="deleteMyAccount" defaults="Yes, delete my account" />
|
||||
</Button>
|
||||
</SettingsSection> */}
|
||||
</div>
|
||||
</Settings>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -4,8 +4,11 @@ export const planIdMonthly = process.env
|
|||
export const planIdYearly = process.env
|
||||
.NEXT_PUBLIC_PRO_PLAN_ID_YEARLY as string;
|
||||
|
||||
export const isFeedbackEnabled =
|
||||
process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === "true";
|
||||
export const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === "true";
|
||||
|
||||
export const isFeedbackEnabled = !isSelfHosted;
|
||||
|
||||
export const monthlyPriceUsd = 7;
|
||||
|
||||
export const annualPriceUsd = 42;
|
||||
export const appVersion = process.env.NEXT_PUBLIC_APP_VERSION;
|
||||
|
|
|
@ -15,6 +15,8 @@ services:
|
|||
|
||||
rallly:
|
||||
build:
|
||||
args:
|
||||
- SELF_HOSTED=true
|
||||
context: .
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
restart: always
|
||||
|
|
|
@ -28,10 +28,14 @@
|
|||
"dependencies": {
|
||||
"next": "^13.2.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^6.6.0",
|
||||
"@typescript-eslint/parser": "^6.6.0",
|
||||
"dotenv-cli": "^7.1.0",
|
||||
"eslint": "^8.48.0",
|
||||
"prettier": "^2.8.4",
|
||||
"turbo": "^1.10.7"
|
||||
},
|
||||
|
|
|
@ -2,7 +2,7 @@ export const sessionConfig = {
|
|||
password: process.env.SECRET_PASSWORD ?? "",
|
||||
cookieName: "rallly-session",
|
||||
cookieOptions: {
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: process.env.NEXT_PUBLIC_BASE_URL?.startsWith("https://") ?? false,
|
||||
},
|
||||
ttl: 60 * 60 * 24 * 30, // 30 days
|
||||
};
|
||||
|
|
|
@ -19,7 +19,13 @@ export async function createContext(
|
|||
opts.req.session.user = user;
|
||||
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>;
|
||||
|
|
|
@ -9,7 +9,6 @@ import utc from "dayjs/plugin/utc";
|
|||
import * as ics from "ics";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getSubscriptionStatus } from "../../utils/auth";
|
||||
import { getTimeZoneAbbreviation } from "../../utils/date";
|
||||
import { nanoid } from "../../utils/nanoid";
|
||||
import {
|
||||
|
@ -74,8 +73,6 @@ export const polls = router({
|
|||
const participantUrlId = nanoid();
|
||||
const pollId = nanoid();
|
||||
|
||||
const { active: isPro } = await getSubscriptionStatus(ctx.user.id);
|
||||
|
||||
const poll = await prisma.poll.create({
|
||||
select: {
|
||||
adminUrlId: true,
|
||||
|
@ -117,13 +114,9 @@ export const polls = router({
|
|||
})),
|
||||
},
|
||||
},
|
||||
...(isPro
|
||||
? {
|
||||
hideParticipants: input.hideParticipants,
|
||||
disableComments: input.disableComments,
|
||||
hideScores: input.hideScores,
|
||||
}
|
||||
: undefined),
|
||||
hideParticipants: input.hideParticipants,
|
||||
disableComments: input.disableComments,
|
||||
hideScores: input.hideScores,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -171,11 +164,9 @@ export const polls = router({
|
|||
hideScores: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
.mutation(async ({ input }) => {
|
||||
const pollId = await getPollIdFromAdminUrlId(input.urlId);
|
||||
|
||||
const { active: isPro } = await getSubscriptionStatus(ctx.user.id);
|
||||
|
||||
if (input.optionsToDelete && input.optionsToDelete.length > 0) {
|
||||
await prisma.option.deleteMany({
|
||||
where: {
|
||||
|
@ -218,13 +209,9 @@ export const polls = router({
|
|||
description: input.description,
|
||||
timeZone: input.timeZone,
|
||||
closed: input.closed,
|
||||
...(isPro
|
||||
? {
|
||||
hideScores: input.hideScores,
|
||||
hideParticipants: input.hideParticipants,
|
||||
disableComments: input.disableComments,
|
||||
}
|
||||
: undefined),
|
||||
hideScores: input.hideScores,
|
||||
hideParticipants: input.hideParticipants,
|
||||
disableComments: input.disableComments,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
|
|
@ -19,7 +19,8 @@ export const middleware = t.middleware;
|
|||
|
||||
export const possiblyPublicProcedure = t.procedure.use(
|
||||
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({
|
||||
code: "UNAUTHORIZED",
|
||||
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);
|
||||
|
||||
if (!isPro) {
|
||||
|
|
|
@ -18,8 +18,8 @@ export const getSubscriptionStatus = async (userId: string) => {
|
|||
if (user?.subscription?.active === true) {
|
||||
return {
|
||||
active: true,
|
||||
expiresAt: user.subscription.periodEnd,
|
||||
};
|
||||
legacy: false,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const userPaymentData = await prisma.userPaymentData.findFirst({
|
||||
|
@ -34,18 +34,14 @@ export const getSubscriptionStatus = async (userId: string) => {
|
|||
},
|
||||
});
|
||||
|
||||
if (
|
||||
userPaymentData?.endDate &&
|
||||
userPaymentData.endDate.getTime() > Date.now()
|
||||
) {
|
||||
if (userPaymentData) {
|
||||
return {
|
||||
active: true,
|
||||
legacy: true,
|
||||
expiresAt: userPaymentData.endDate,
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
active: false,
|
||||
};
|
||||
} as const;
|
||||
};
|
||||
|
|
|
@ -19,7 +19,6 @@
|
|||
"@rallly/tsconfig": "*",
|
||||
"@types/node": "^18.15.10",
|
||||
"prisma": "^5.0.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.0.2"
|
||||
"ts-node": "^10.9.1"
|
||||
}
|
||||
}
|
||||
|
|
4
packages/tsconfig/environment.d.ts
vendored
4
packages/tsconfig/environment.d.ts
vendored
|
@ -68,10 +68,6 @@ declare global {
|
|||
* Example: "user@example.com, *@example.com, *@*.example.com"
|
||||
*/
|
||||
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"
|
||||
*/
|
||||
|
|
|
@ -26,12 +26,8 @@ SMTP_PWD=
|
|||
|
||||
# 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.
|
||||
# You can use wildcard syntax to match a range of email addresses.
|
||||
# Example: "john@example.com,jane@example.com" or "*@example.com"
|
||||
ALLOWED_EMAILS=
|
||||
# Whether or not to disable the landing page
|
||||
DISABLE_LANDING_PAGE=false
|
||||
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
#!/bin/sh
|
||||
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 build
|
||||
# Deploy migration using direct database connection (no connection pool)
|
||||
|
|
|
@ -51,7 +51,6 @@
|
|||
"ALLOWED_EMAILS",
|
||||
"ANALYZE",
|
||||
"API_SECRET",
|
||||
"AUTH_REQUIRED",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_REGION",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
|
@ -65,12 +64,12 @@
|
|||
"NEXT_PUBLIC_CRISP_WEBSITE_ID",
|
||||
"NEXT_PUBLIC_ENABLE_ANALYTICS",
|
||||
"NEXT_PUBLIC_ENABLE_FINALIZATION",
|
||||
"NEXT_PUBLIC_FEEDBACK_ENABLED",
|
||||
"NEXT_PUBLIC_LANDING_PAGE_URL",
|
||||
"NEXT_PUBLIC_MAINTENANCE_MODE",
|
||||
"NEXT_PUBLIC_PADDLE_SANDBOX",
|
||||
"NEXT_PUBLIC_PADDLE_VENDOR_ID",
|
||||
"NEXT_PUBLIC_POSTHOG_API_HOST",
|
||||
"NEXT_PUBLIC_SELF_HOSTED",
|
||||
"NEXT_PUBLIC_POSTHOG_API_KEY",
|
||||
"NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY",
|
||||
"NEXT_PUBLIC_PRO_PLAN_ID_YEARLY",
|
||||
|
|
Loading…
Add table
Reference in a new issue