mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-15 17:11:49 +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 }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
build-args: |
|
build-args: |
|
||||||
APP_VERSION=${{ github.ref_name }}
|
APP_VERSION=${{ github.ref_name }}
|
||||||
|
SELF_HOSTED=true
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
]
|
]
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
@ -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.
|
||||||
---
|
---
|
||||||
|
|
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"
|
* 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"
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
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
|
* 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"
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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();
|
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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
},
|
},
|
||||||
|
|
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 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" />
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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" });
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
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"
|
* 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"
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue