mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-11-22 02:37:49 +01:00
format write
This commit is contained in:
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1 +1 @@
|
|||||||
*.wasp linguist-language=TypeScript
|
*.wasp linguist-language=TypeScript
|
||||||
|
|||||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,6 +1,6 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
github: [ wasp-lang ] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
github: [wasp-lang] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||||
patreon: # Replace with a single Patreon username
|
patreon: # Replace with a single Patreon username
|
||||||
open_collective: # Replace with a single Open Collective username
|
open_collective: # Replace with a single Open Collective username
|
||||||
ko_fi: # Replace with a single Ko-fi username
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
|||||||
12
.github/workflows/blog-deployment.yml
vendored
12
.github/workflows/blog-deployment.yml
vendored
@@ -5,12 +5,12 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- 'opensaas-sh/blog/**'
|
- "opensaas-sh/blog/**"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- 'opensaas-sh/blog/**'
|
- "opensaas-sh/blog/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
node-version: "lts/*"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: ./opensaas-sh/blog
|
working-directory: ./opensaas-sh/blog
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
node-version: "lts/*"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: ./opensaas-sh/blog
|
working-directory: ./opensaas-sh/blog
|
||||||
@@ -64,10 +64,10 @@ jobs:
|
|||||||
- name: Deploy to Netlify
|
- name: Deploy to Netlify
|
||||||
uses: nwtgck/actions-netlify@v2
|
uses: nwtgck/actions-netlify@v2
|
||||||
with:
|
with:
|
||||||
publish-dir: './opensaas-sh/blog/dist'
|
publish-dir: "./opensaas-sh/blog/dist"
|
||||||
production-branch: main
|
production-branch: main
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
deploy-message: 'Deploy from GitHub Actions'
|
deploy-message: "Deploy from GitHub Actions"
|
||||||
env:
|
env:
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
|||||||
12
.github/workflows/e2e-tests.yml
vendored
12
.github/workflows/e2e-tests.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
id: setup-node
|
id: setup-node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: "22"
|
||||||
|
|
||||||
- name: Docker setup
|
- name: Docker setup
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
./tools/patch.sh
|
./tools/patch.sh
|
||||||
|
|
||||||
- name: '[e2e-tests] Install Node.js dependencies for Playwright tests'
|
- name: "[e2e-tests] Install Node.js dependencies for Playwright tests"
|
||||||
if: steps.cache-e2e-tests.outputs.cache-hit != 'true'
|
if: steps.cache-e2e-tests.outputs.cache-hit != 'true'
|
||||||
working-directory: ./template
|
working-directory: ./template
|
||||||
run: |
|
run: |
|
||||||
@@ -78,14 +78,14 @@ jobs:
|
|||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}-${{ runner.os }}
|
key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}-${{ runner.os }}
|
||||||
|
|
||||||
- name: '[e2e-tests] Set up Playwright'
|
- name: "[e2e-tests] Set up Playwright"
|
||||||
if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
|
if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
|
||||||
working-directory: ./template
|
working-directory: ./template
|
||||||
run: |
|
run: |
|
||||||
cd e2e-tests
|
cd e2e-tests
|
||||||
npx playwright install --with-deps
|
npx playwright install --with-deps
|
||||||
|
|
||||||
- name: '[e2e-tests] Install Stripe CLI'
|
- name: "[e2e-tests] Install Stripe CLI"
|
||||||
run: |
|
run: |
|
||||||
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg
|
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg
|
||||||
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
|
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
|
||||||
@@ -95,13 +95,13 @@ jobs:
|
|||||||
# For Stripe webhooks to work in development, we need to run the Stripe CLI to listen for webhook events.
|
# For Stripe webhooks to work in development, we need to run the Stripe CLI to listen for webhook events.
|
||||||
# The Stripe CLI will receive the webhook events from Stripe test payments and
|
# The Stripe CLI will receive the webhook events from Stripe test payments and
|
||||||
# forward them to our local server so that we can test the payment flow in our e2e tests.
|
# forward them to our local server so that we can test the payment flow in our e2e tests.
|
||||||
- name: '[e2e-tests] Run Stripe CLI to listen for webhooks'
|
- name: "[e2e-tests] Run Stripe CLI to listen for webhooks"
|
||||||
env:
|
env:
|
||||||
STRIPE_DEVICE_NAME: ${{ secrets.STRIPE_DEVICE_NAME }}
|
STRIPE_DEVICE_NAME: ${{ secrets.STRIPE_DEVICE_NAME }}
|
||||||
run: |
|
run: |
|
||||||
stripe listen --api-key ${{ secrets.STRIPE_KEY }} --forward-to localhost:3001/payments-webhook &
|
stripe listen --api-key ${{ secrets.STRIPE_KEY }} --forward-to localhost:3001/payments-webhook &
|
||||||
|
|
||||||
- name: '[e2e-tests] Run Playwright tests'
|
- name: "[e2e-tests] Run Playwright tests"
|
||||||
env:
|
env:
|
||||||
# The e2e tests are testing parts of the app that need certain env vars, so we need to access them here.
|
# The e2e tests are testing parts of the app that need certain env vars, so we need to access them here.
|
||||||
# These secretes can be set in your GitHub repo settings, e.g. https://github.com/<account>/<repo>/settings/secrets/actions
|
# These secretes can be set in your GitHub repo settings, e.g. https://github.com/<account>/<repo>/settings/secrets/actions
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
Thanks so much for considering contributing to Open SaaS 🙏
|
Thanks so much for considering contributing to Open SaaS 🙏
|
||||||
|
|
||||||
## Considerations before Contributing
|
## Considerations before Contributing
|
||||||
|
|
||||||
Check if there is a GitHub issue already for the thing you would like to work on. If there is no issue yet, create a new one.
|
Check if there is a GitHub issue already for the thing you would like to work on. If there is no issue yet, create a new one.
|
||||||
|
|
||||||
Let us know, in the issue, that you would like to work on it and how you plan to approach it.
|
Let us know, in the issue, that you would like to work on it and how you plan to approach it.
|
||||||
@@ -15,6 +16,7 @@ Repo is divided into two main parts: [template](/template) dir and [opensaas-sh]
|
|||||||
`opensaas-sh` is the app deployed to https://opensaas.sh , and is actually made with open saas! It contains a demo app and open saas docs. We keep it updated as we work on the template.
|
`opensaas-sh` is the app deployed to https://opensaas.sh , and is actually made with open saas! It contains a demo app and open saas docs. We keep it updated as we work on the template.
|
||||||
|
|
||||||
## How to Contribute
|
## How to Contribute
|
||||||
|
|
||||||
1. Make sure you understand the basics of how open-saas works (check out [docs](https://docs.opensaas.sh)).
|
1. Make sure you understand the basics of how open-saas works (check out [docs](https://docs.opensaas.sh)).
|
||||||
2. Check out this repo (`main` branch) and make sure you are able to get the app in [template/app/](/template/app) running (to set it up, follow the same steps as for running a new open-saas app, as explained in the open-saas docs).
|
2. Check out this repo (`main` branch) and make sure you are able to get the app in [template/app/](/template/app) running (to set it up, follow the same steps as for running a new open-saas app, as explained in the open-saas docs).
|
||||||
3. Create a new git branch for your work (aka feature branch) and do your changes on it.
|
3. Create a new git branch for your work (aka feature branch) and do your changes on it.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
## Welcome to your new SaaS App! 🎉
|
## Welcome to your new SaaS App! 🎉
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/open-saas?embed=true&utm_source=badge-featured&utm_medium=badge&utm_source=badge-open-saas-2" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=991058&theme=neutral&t=1753776395137" alt="Open SaaS - The open-source SaaS boilerplate with superpowers! | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://www.producthunt.com/products/open-saas?embed=true&utm_source=badge-featured&utm_medium=badge&utm_source=badge-open-saas-2" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=991058&theme=neutral&t=1753776395137" alt="Open SaaS - The open-source SaaS boilerplate with superpowers! | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/3856276b-23e9-455e-a564-b5f26f4f0e98
|
https://github.com/user-attachments/assets/3856276b-23e9-455e-a564-b5f26f4f0e98
|
||||||
|
|
||||||
You've decided to build a SaaS app with the Open SaaS template. Great choice!
|
You've decided to build a SaaS app with the Open SaaS template. Great choice!
|
||||||
|
|
||||||
This template is:
|
This template is:
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ Because we're using Wasp as the full-stack framework, we can leverage a lot of i
|
|||||||
- 🚀 [One-command Deploy](https://wasp.sh/docs/advanced/deployment/overview) - Easily deploy your DB, Server, & Client with one commaned to [Railway](https://railway.app) or [Fly.io](https://fly.io) via the CLI. Or deploy manually to any other hosting serivce of your choice.
|
- 🚀 [One-command Deploy](https://wasp.sh/docs/advanced/deployment/overview) - Easily deploy your DB, Server, & Client with one commaned to [Railway](https://railway.app) or [Fly.io](https://fly.io) via the CLI. Or deploy manually to any other hosting serivce of your choice.
|
||||||
|
|
||||||
You also get access to Wasp's diverse, helpful community if you get stuck or need help.
|
You also get access to Wasp's diverse, helpful community if you get stuck or need help.
|
||||||
|
|
||||||
- 🤝 [Wasp Discord](https://discord.gg/aCamt5wCpS)
|
- 🤝 [Wasp Discord](https://discord.gg/aCamt5wCpS)
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -45,6 +47,7 @@ You also get access to Wasp's diverse, helpful community if you get stuck or nee
|
|||||||
### Simple Instructions
|
### Simple Instructions
|
||||||
|
|
||||||
First, to install the latest version of [Wasp](https://wasp.sh/) on macOS, Linux, or Windows with WSL, run the following command:
|
First, to install the latest version of [Wasp](https://wasp.sh/) on macOS, Linux, or Windows with WSL, run the following command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://get.wasp.sh/installer.sh | sh
|
curl -sSL https://get.wasp.sh/installer.sh | sh
|
||||||
```
|
```
|
||||||
@@ -66,6 +69,7 @@ We've documented everything in great detail, including installation instructions
|
|||||||
## Getting Help & Providing Feedback
|
## Getting Help & Providing Feedback
|
||||||
|
|
||||||
There are two ways to get help or provide feedback (and we try to always respond quickly!):
|
There are two ways to get help or provide feedback (and we try to always respond quickly!):
|
||||||
|
|
||||||
1. [Open an issue](https://github.com/wasp-lang/open-saas/issues)
|
1. [Open an issue](https://github.com/wasp-lang/open-saas/issues)
|
||||||
2. [Wasp Discord](https://discord.gg/aCamt5wCpS) -- please direct questions to the #🙋questions forum channel
|
2. [Wasp Discord](https://discord.gg/aCamt5wCpS) -- please direct questions to the #🙋questions forum channel
|
||||||
|
|
||||||
@@ -74,4 +78,3 @@ There are two ways to get help or provide feedback (and we try to always respond
|
|||||||
Note that we've tried to get as many of the core features of a SaaS app into this template as possible, but there still might be some missing features or functionality.
|
Note that we've tried to get as many of the core features of a SaaS app into this template as possible, but there still might be some missing features or functionality.
|
||||||
|
|
||||||
We could always use some help tying up loose ends: contributions are welcome! Check out [CONTRIBUTING.md](/CONTRIBUTING.md) for more details.
|
We could always use some help tying up loose ends: contributions are welcome! Check out [CONTRIBUTING.md](/CONTRIBUTING.md) for more details.
|
||||||
|
|
||||||
|
|||||||
1
opensaas-sh/blog/.gitignore
vendored
1
opensaas-sh/blog/.gitignore
vendored
@@ -12,7 +12,6 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
# environment variables
|
# environment variables
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
This is the docs and blog for the OpenSaaS.sh website, [](https://starlight.astro.build)
|
This is the docs and blog for the OpenSaaS.sh website, [](https://starlight.astro.build)
|
||||||
|
|
||||||
|
|
||||||
## 🚀 Project Structure
|
## 🚀 Project Structure
|
||||||
|
|
||||||
Inside of your Astro + Starlight project, you'll see the following folders and files:
|
Inside of your Astro + Starlight project, you'll see the following folders and files:
|
||||||
@@ -35,7 +34,7 @@ Static assets, like favicons and banner images, can be placed in the `public/` d
|
|||||||
|
|
||||||
We have a number of custom components in `src/components/` that you can use in your blog posts and docs.
|
We have a number of custom components in `src/components/` that you can use in your blog posts and docs.
|
||||||
|
|
||||||
## Custom Components
|
## Custom Components
|
||||||
|
|
||||||
Custom components in the `src/components/` that replace default Starlight components are imported into the `astro.config.mjs` file:
|
Custom components in the `src/components/` that replace default Starlight components are imported into the `astro.config.mjs` file:
|
||||||
|
|
||||||
@@ -56,12 +55,15 @@ title: "Open SaaS Tutorial"
|
|||||||
date: 2024-12-10
|
date: 2024-12-10
|
||||||
//...
|
//...
|
||||||
---
|
---
|
||||||
import VideoPlayer from '../../../components/VideoPlayer.astro';
|
|
||||||
|
import VideoPlayer from "../../../components/VideoPlayer.astro";
|
||||||
|
|
||||||
|
;
|
||||||
```
|
```
|
||||||
|
|
||||||
### HeadWithOGImage
|
### HeadWithOGImage
|
||||||
|
|
||||||
This component is used to generate the Open Graph (OG) meta tags for the social media preview images for each doc and blog post.
|
This component is used to generate the Open Graph (OG) meta tags for the social media preview images for each doc and blog post.
|
||||||
|
|
||||||
It checks if a banner image exists in `./public/banner-images` with the same name as the blog post but with a `.webp` extension, e.g. if the blog post is `2024-12-10-open-saas-tutorial.mdx`, it checks for `./public/banner-images/2024-12-10-open-saas-tutorial.webp`. If it does, it uses that image. If it doesn't, it uses the default banner image.
|
It checks if a banner image exists in `./public/banner-images` with the same name as the blog post but with a `.webp` extension, e.g. if the blog post is `2024-12-10-open-saas-tutorial.mdx`, it checks for `./public/banner-images/2024-12-10-open-saas-tutorial.webp`. If it does, it uses that image. If it doesn't, it uses the default banner image.
|
||||||
|
|
||||||
@@ -99,7 +101,8 @@ title: "Open SaaS Tutorial"
|
|||||||
date: 2024-12-10
|
date: 2024-12-10
|
||||||
//...
|
//...
|
||||||
---
|
---
|
||||||
import VideoPlayer from '../../../components/VideoPlayer.astro';
|
|
||||||
|
import VideoPlayer from "../../../components/VideoPlayer.astro";
|
||||||
|
|
||||||
<VideoPlayer src="/videos/open-saas-tutorial.mp4" lgWidth="75%" smWidth="80%" />
|
<VideoPlayer src="/videos/open-saas-tutorial.mp4" lgWidth="75%" smWidth="80%" />
|
||||||
```
|
```
|
||||||
@@ -110,7 +113,6 @@ This component is a wrapper around the `Header` component from the `@astrojs/sta
|
|||||||
|
|
||||||
It repositions the docs and blog links to the left, and adds a logo and a link to the home page, https://opensaas.sh.
|
It repositions the docs and blog links to the left, and adds a logo and a link to the home page, https://opensaas.sh.
|
||||||
|
|
||||||
|
|
||||||
## Authoring Content
|
## Authoring Content
|
||||||
|
|
||||||
The docs and blog are written in Markdown or MDX with some additional metadata:
|
The docs and blog are written in Markdown or MDX with some additional metadata:
|
||||||
@@ -119,18 +121,20 @@ The docs and blog are written in Markdown or MDX with some additional metadata:
|
|||||||
title: We Made the Most Annoying Cookie Banners Ever
|
title: We Made the Most Annoying Cookie Banners Ever
|
||||||
date: 2024-11-26
|
date: 2024-11-26
|
||||||
tags:
|
tags:
|
||||||
- cookie consent
|
|
||||||
- saas
|
- cookie consent
|
||||||
- sideproject
|
- saas
|
||||||
- hackathon
|
- sideproject
|
||||||
subtitle: and it was totally worth it
|
- hackathon
|
||||||
hideBannerImage: true
|
subtitle: and it was totally worth it
|
||||||
authors: vince
|
hideBannerImage: true
|
||||||
|
authors: vince
|
||||||
```
|
```
|
||||||
|
|
||||||
Most posts are written in MDX, which allows you to use jsx components in your blog posts. It's recommended to use the MDX extension for your editor, such as this one for [VSCode](https://marketplace.cursorapi.com/items?itemName=unifiedjs.vscode-mdx).
|
Most posts are written in MDX, which allows you to use jsx components in your blog posts. It's recommended to use the MDX extension for your editor, such as this one for [VSCode](https://marketplace.cursorapi.com/items?itemName=unifiedjs.vscode-mdx).
|
||||||
|
|
||||||
### Blog Post Metadata
|
### Blog Post Metadata
|
||||||
|
|
||||||
`authors` is required and will display the authors of the blog post. To configure a new author, add the proper metadata to `astro.config.mjs` under plugins > starlightBlog > authors:
|
`authors` is required and will display the authors of the blog post. To configure a new author, add the proper metadata to `astro.config.mjs` under plugins > starlightBlog > authors:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -159,8 +163,8 @@ See the [HeadWithOGImage](#headwithogimage) and [TitleWithBannerImage](#titlewit
|
|||||||
Always use astro's `Image` component to embed images in your blog posts and docs as Astro will automatically optimize the images for the web.
|
Always use astro's `Image` component to embed images in your blog posts and docs as Astro will automatically optimize the images for the web.
|
||||||
|
|
||||||
```mdx
|
```mdx
|
||||||
import { Image } from 'astro:assets';
|
import { Image } from "astro:assets";
|
||||||
import myImage from '../../../assets/my-image.jpg';
|
import myImage from "../../../assets/my-image.jpg";
|
||||||
|
|
||||||
<Image src={myImage} alt="My Image" />
|
<Image src={myImage} alt="My Image" />
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,100 +1,102 @@
|
|||||||
import { defineConfig } from 'astro/config';
|
import starlight from "@astrojs/starlight";
|
||||||
import starlight from '@astrojs/starlight';
|
import { defineConfig } from "astro/config";
|
||||||
import starlightBlog from 'starlight-blog';
|
import starlightBlog from "starlight-blog";
|
||||||
|
|
||||||
import tailwind from '@astrojs/tailwind';
|
import tailwind from "@astrojs/tailwind";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://docs.opensaas.sh',
|
site: "https://docs.opensaas.sh",
|
||||||
trailingSlash: 'always',
|
trailingSlash: "always",
|
||||||
integrations: [
|
integrations: [
|
||||||
starlight({
|
starlight({
|
||||||
title: 'OpenSaaS.sh',
|
title: "OpenSaaS.sh",
|
||||||
description: 'Open SaaS is a free, open-source, full-stack SaaS starter kit for React + NodeJS.',
|
description:
|
||||||
customCss: ['./src/styles/tailwind.css'],
|
"Open SaaS is a free, open-source, full-stack SaaS starter kit for React + NodeJS.",
|
||||||
|
customCss: ["./src/styles/tailwind.css"],
|
||||||
logo: {
|
logo: {
|
||||||
src: '/src/assets/logo.webp',
|
src: "/src/assets/logo.webp",
|
||||||
alt: 'Open SaaS',
|
alt: "Open SaaS",
|
||||||
},
|
},
|
||||||
head: [
|
head: [
|
||||||
{
|
{
|
||||||
tag: 'script',
|
tag: "script",
|
||||||
attrs: {
|
attrs: {
|
||||||
defer: true,
|
defer: true,
|
||||||
'data-domain': 'docs.opensaas.sh',
|
"data-domain": "docs.opensaas.sh",
|
||||||
'data-api': 'https://opensaas.sh/wasparadocs/wasp/event',
|
"data-api": "https://opensaas.sh/wasparadocs/wasp/event",
|
||||||
src: 'https://opensaas.sh/wasparadocs/wasp/script.js',
|
src: "https://opensaas.sh/wasparadocs/wasp/script.js",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'script',
|
tag: "script",
|
||||||
attrs: {
|
attrs: {
|
||||||
defer: true,
|
defer: true,
|
||||||
src: '/piggy.js',
|
src: "/piggy.js",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
editLink: {
|
editLink: {
|
||||||
baseUrl: 'https://github.com/wasp-lang/open-saas/edit/main/opensaas-sh/blog',
|
baseUrl:
|
||||||
|
"https://github.com/wasp-lang/open-saas/edit/main/opensaas-sh/blog",
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
SiteTitle: './src/components/MyHeader.astro',
|
SiteTitle: "./src/components/MyHeader.astro",
|
||||||
// We customized ThemeSelect to include a "Copy URL for LLMs" button
|
// We customized ThemeSelect to include a "Copy URL for LLMs" button
|
||||||
ThemeSelect: './src/components/MyRightNavBarItems.astro',
|
ThemeSelect: "./src/components/MyRightNavBarItems.astro",
|
||||||
Head: './src/components/HeadWithOGImage.astro',
|
Head: "./src/components/HeadWithOGImage.astro",
|
||||||
},
|
},
|
||||||
social: {
|
social: {
|
||||||
github: 'https://github.com/wasp-lang/open-saas',
|
github: "https://github.com/wasp-lang/open-saas",
|
||||||
twitter: 'https://twitter.com/wasplang',
|
twitter: "https://twitter.com/wasplang",
|
||||||
discord: 'https://discord.gg/aCamt5wCpS',
|
discord: "https://discord.gg/aCamt5wCpS",
|
||||||
},
|
},
|
||||||
sidebar: [
|
sidebar: [
|
||||||
{
|
{
|
||||||
label: 'Start Here',
|
label: "Start Here",
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Introduction', link: '/' },
|
{ label: "Introduction", link: "/" },
|
||||||
{ label: 'Getting Started', link: '/start/getting-started/' },
|
{ label: "Getting Started", link: "/start/getting-started/" },
|
||||||
{ label: 'Guided Tour', link: '/start/guided-tour/' },
|
{ label: "Guided Tour", link: "/start/guided-tour/" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Guides',
|
label: "Guides",
|
||||||
autogenerate: { directory: '/guides/' },
|
autogenerate: { directory: "/guides/" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'General',
|
label: "General",
|
||||||
autogenerate: { directory: '/general/' },
|
autogenerate: { directory: "/general/" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
starlightBlog({
|
starlightBlog({
|
||||||
title: 'Blog',
|
title: "Blog",
|
||||||
customCss: ['./src/styles/tailwind.css'],
|
customCss: ["./src/styles/tailwind.css"],
|
||||||
authors: {
|
authors: {
|
||||||
vince: {
|
vince: {
|
||||||
name: 'Vince',
|
name: "Vince",
|
||||||
title: 'Dev Rel @ Wasp',
|
title: "Dev Rel @ Wasp",
|
||||||
picture: '/CRAIG_ROCK.png', // Images in the `public` directory are supported.
|
picture: "/CRAIG_ROCK.png", // Images in the `public` directory are supported.
|
||||||
url: 'https://wasp.sh',
|
url: "https://wasp.sh",
|
||||||
},
|
},
|
||||||
matija: {
|
matija: {
|
||||||
name: 'Matija',
|
name: "Matija",
|
||||||
title: 'CEO @ Wasp',
|
title: "CEO @ Wasp",
|
||||||
picture: '/matija.jpeg', // Images in the `public` directory are supported.
|
picture: "/matija.jpeg", // Images in the `public` directory are supported.
|
||||||
url: 'https://wasp.sh',
|
url: "https://wasp.sh",
|
||||||
},
|
},
|
||||||
milica: {
|
milica: {
|
||||||
name: 'Milica',
|
name: "Milica",
|
||||||
title: 'Growth @ Wasp',
|
title: "Growth @ Wasp",
|
||||||
picture: '/milica.jpg', // Images in the `public` directory are supported.
|
picture: "/milica.jpg", // Images in the `public` directory are supported.
|
||||||
url: 'https://wasp.sh',
|
url: "https://wasp.sh",
|
||||||
},
|
},
|
||||||
martin: {
|
martin: {
|
||||||
name: 'Martin',
|
name: "Martin",
|
||||||
title: 'CTO @ Wasp',
|
title: "CTO @ Wasp",
|
||||||
picture: '/martin.jpg', // Images in the `public` directory are supported.
|
picture: "/martin.jpg", // Images in the `public` directory are supported.
|
||||||
url: 'https://wasp.sh',
|
url: "https://wasp.sh",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "docs",
|
"name": "docs",
|
||||||
"type": "module",
|
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
|
||||||
"start": "astro dev",
|
|
||||||
"build": "astro check && astro build",
|
|
||||||
"preview": "astro preview",
|
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"generate-llm-files": "node ./scripts/generate-llm-files.mjs"
|
"build": "astro check && astro build",
|
||||||
|
"dev": "astro dev",
|
||||||
|
"generate-llm-files": "node ./scripts/generate-llm-files.mjs",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"start": "astro dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.4",
|
"@astrojs/check": "^0.9.4",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ This is because OG Image URLs and Banner Images are automatically generated for
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const ogImageUrl = new URL(
|
const ogImageUrl = new URL(
|
||||||
`/banner-images/${Astro.props.id.replace(/blog\//, '').replace(/\.\w+$/, '.webp')}`,
|
`/banner-images/${Astro.props.id.replace(/blog\//, "").replace(/\.\w+$/, ".webp")}`,
|
||||||
Astro.site,
|
Astro.site,
|
||||||
)
|
);
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,57 +1,59 @@
|
|||||||
import fs from 'fs-extra';
|
import fm from "front-matter";
|
||||||
import path from 'path';
|
import fs from "fs-extra";
|
||||||
import { globSync } from 'glob';
|
import { globSync } from "glob";
|
||||||
import fm from 'front-matter';
|
import path from "path";
|
||||||
|
|
||||||
const BLOG_ROOT = process.cwd();
|
const BLOG_ROOT = process.cwd();
|
||||||
const PUBLIC_DIR = path.join(BLOG_ROOT, 'public');
|
const PUBLIC_DIR = path.join(BLOG_ROOT, "public");
|
||||||
const DOCS_BASE_DIR = path.join(BLOG_ROOT, 'src/content/docs');
|
const DOCS_BASE_DIR = path.join(BLOG_ROOT, "src/content/docs");
|
||||||
const ORDERED_SOURCES = ['index.mdx', 'start', 'guides', 'general']; // Relative to DOCS_BASE_DIR
|
const ORDERED_SOURCES = ["index.mdx", "start", "guides", "general"]; // Relative to DOCS_BASE_DIR
|
||||||
const GITHUB_RAW_BASE_URL = 'https://raw.githubusercontent.com/wasp-lang/open-saas/main/opensaas-sh/blog/';
|
const GITHUB_RAW_BASE_URL =
|
||||||
const OPEN_SAAS_DOCS_BASE_URL = 'https://docs.opensaas.sh/';
|
"https://raw.githubusercontent.com/wasp-lang/open-saas/main/opensaas-sh/blog/";
|
||||||
const LLM_FULL_FILENAME = 'llms-full.txt';
|
const OPEN_SAAS_DOCS_BASE_URL = "https://docs.opensaas.sh/";
|
||||||
const LLM_OVERVIEW_FILENAME = 'llms.txt';
|
const LLM_FULL_FILENAME = "llms-full.txt";
|
||||||
|
const LLM_OVERVIEW_FILENAME = "llms.txt";
|
||||||
const OVERVIEW_FILE = path.join(PUBLIC_DIR, LLM_OVERVIEW_FILENAME);
|
const OVERVIEW_FILE = path.join(PUBLIC_DIR, LLM_OVERVIEW_FILENAME);
|
||||||
const FULL_CONCAT_FILE = path.join(PUBLIC_DIR, LLM_FULL_FILENAME);
|
const FULL_CONCAT_FILE = path.join(PUBLIC_DIR, LLM_FULL_FILENAME);
|
||||||
|
|
||||||
function cleanContent(content) {
|
function cleanContent(content) {
|
||||||
if (!content) return '';
|
if (!content) return "";
|
||||||
let cleaned = content;
|
let cleaned = content;
|
||||||
// Remove lines starting with 'import ... from ...;' or just 'import ...'
|
// Remove lines starting with 'import ... from ...;' or just 'import ...'
|
||||||
cleaned = cleaned.replace(/^import\s+.*(?:from\s+['"].*['"])?;?\s*$/gm, '');
|
cleaned = cleaned.replace(/^import\s+.*(?:from\s+['"].*['"])?;?\s*$/gm, "");
|
||||||
// Remove lines like {/* TODO: ... */}
|
// Remove lines like {/* TODO: ... */}
|
||||||
cleaned = cleaned.replace(/^\{\/\*.*\*\/\}\s*$/gm, '');
|
cleaned = cleaned.replace(/^\{\/\*.*\*\/\}\s*$/gm, "");
|
||||||
// Remove lines like <!-- TODO: ... -->
|
// Remove lines like <!-- TODO: ... -->
|
||||||
cleaned = cleaned.replace(/^<!--.*-->\s*$/gm, '');
|
cleaned = cleaned.replace(/^<!--.*-->\s*$/gm, "");
|
||||||
// Remove <Image ... /> tags
|
// Remove <Image ... /> tags
|
||||||
cleaned = cleaned.replace(/<Image[^>]*?\/>/g, '');
|
cleaned = cleaned.replace(/<Image[^>]*?\/>/g, "");
|
||||||
// Remove <HiddenLLMHelper /> tag
|
// Remove <HiddenLLMHelper /> tag
|
||||||
cleaned = cleaned.replace(/<HiddenLLMHelper\s*\/>/g, '');
|
cleaned = cleaned.replace(/<HiddenLLMHelper\s*\/>/g, "");
|
||||||
// Remove Emojis using specific ranges (less likely to remove digits)
|
// Remove Emojis using specific ranges (less likely to remove digits)
|
||||||
cleaned = cleaned.replace(
|
cleaned = cleaned.replace(
|
||||||
/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA70}-\u{1FAFF}]/gu,
|
/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA70}-\u{1FAFF}]/gu,
|
||||||
''
|
"",
|
||||||
);
|
);
|
||||||
cleaned = cleaned.replace(/\u00A0/g, ' ');
|
cleaned = cleaned.replace(/\u00A0/g, " ");
|
||||||
// Remove Box Drawing Characters (single and double line)
|
// Remove Box Drawing Characters (single and double line)
|
||||||
cleaned = cleaned.replace(/[│├└─╔═╗║╚╝]/g, '');
|
cleaned = cleaned.replace(/[│├└─╔═╗║╚╝]/g, "");
|
||||||
// Remove more than two line breaks
|
// Remove more than two line breaks
|
||||||
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
|
cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
|
||||||
|
|
||||||
return cleaned.trim();
|
return cleaned.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateFiles() {
|
async function generateFiles() {
|
||||||
console.log('Starting LLM file generation...');
|
console.log("Starting LLM file generation...");
|
||||||
|
|
||||||
const introSummary = '> Open SaaS is a free, open-source, full-stack starter kit for building SaaS applications quickly. It leverages the [Wasp](https://wasp.sh/docs) framework (React, Node.js, Prisma) and integrates with various services like Stripe/Lemon Squeezy, OpenAI API, AWS S3, and different email providers.';
|
const introSummary =
|
||||||
|
"> Open SaaS is a free, open-source, full-stack starter kit for building SaaS applications quickly. It leverages the [Wasp](https://wasp.sh/docs) framework (React, Node.js, Prisma) and integrates with various services like Stripe/Lemon Squeezy, OpenAI API, AWS S3, and different email providers.";
|
||||||
|
|
||||||
let orderedSourceFiles = [];
|
let orderedSourceFiles = [];
|
||||||
const fullConcatenatedFileUrl = OPEN_SAAS_DOCS_BASE_URL + LLM_FULL_FILENAME;
|
const fullConcatenatedFileUrl = OPEN_SAAS_DOCS_BASE_URL + LLM_FULL_FILENAME;
|
||||||
let overviewContent = `# Open SaaS Documentation for LLMs\n\n${introSummary}\n\n## Full Documentation\n - [View Complete LLM-formatted Open SaaS Documentation](${fullConcatenatedFileUrl})\n\n## Individual documentation sections and guides:\n`;
|
let overviewContent = `# Open SaaS Documentation for LLMs\n\n${introSummary}\n\n## Full Documentation\n - [View Complete LLM-formatted Open SaaS Documentation](${fullConcatenatedFileUrl})\n\n## Individual documentation sections and guides:\n`;
|
||||||
let fullConcatContent = '';
|
let fullConcatContent = "";
|
||||||
|
|
||||||
console.log('Gathering source files...');
|
console.log("Gathering source files...");
|
||||||
for (const sourceItem of ORDERED_SOURCES) {
|
for (const sourceItem of ORDERED_SOURCES) {
|
||||||
const itemPath = path.join(DOCS_BASE_DIR, sourceItem);
|
const itemPath = path.join(DOCS_BASE_DIR, sourceItem);
|
||||||
try {
|
try {
|
||||||
@@ -60,56 +62,66 @@ async function generateFiles() {
|
|||||||
orderedSourceFiles.push(itemPath);
|
orderedSourceFiles.push(itemPath);
|
||||||
console.log(` Added file: ${sourceItem}`);
|
console.log(` Added file: ${sourceItem}`);
|
||||||
} else if (stats.isDirectory()) {
|
} else if (stats.isDirectory()) {
|
||||||
const files = globSync(path.join(itemPath, '**/*.{md,mdx}').replace(/\\/g, '/'), { nodir: true }).sort();
|
const files = globSync(
|
||||||
|
path.join(itemPath, "**/*.{md,mdx}").replace(/\\/g, "/"),
|
||||||
|
{ nodir: true },
|
||||||
|
).sort();
|
||||||
orderedSourceFiles.push(...files);
|
orderedSourceFiles.push(...files);
|
||||||
console.log(` Added ${files.length} files from directory: ${sourceItem}`);
|
console.log(
|
||||||
|
` Added ${files.length} files from directory: ${sourceItem}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'ENOENT') {
|
if (error.code === "ENOENT") {
|
||||||
console.warn(`Warning: Source item not found: ${itemPath}`);
|
console.warn(`Warning: Source item not found: ${itemPath}`);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Warning: Could not access source item: ${itemPath} - ${error.message}`);
|
console.warn(
|
||||||
|
`Warning: Could not access source item: ${itemPath} - ${error.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`Total source files found: ${orderedSourceFiles.length}`);
|
console.log(`Total source files found: ${orderedSourceFiles.length}`);
|
||||||
|
|
||||||
console.log('Processing files...');
|
console.log("Processing files...");
|
||||||
for (const sourceFilePath of orderedSourceFiles) {
|
for (const sourceFilePath of orderedSourceFiles) {
|
||||||
try {
|
try {
|
||||||
const rawContent = await fs.readFile(sourceFilePath, 'utf8');
|
const rawContent = await fs.readFile(sourceFilePath, "utf8");
|
||||||
const { attributes, body } = fm(rawContent);
|
const { attributes, body } = fm(rawContent);
|
||||||
|
|
||||||
const processedContent = cleanContent(body);
|
const processedContent = cleanContent(body);
|
||||||
|
|
||||||
const title = attributes.title || path.basename(sourceFilePath, path.extname(sourceFilePath));
|
const title =
|
||||||
|
attributes.title ||
|
||||||
|
path.basename(sourceFilePath, path.extname(sourceFilePath));
|
||||||
|
|
||||||
const relativeSourcePathForGithub = path.relative(BLOG_ROOT, sourceFilePath).replace(/\\/g, '/');
|
const relativeSourcePathForGithub = path
|
||||||
|
.relative(BLOG_ROOT, sourceFilePath)
|
||||||
|
.replace(/\\/g, "/");
|
||||||
const githubRawUrl = GITHUB_RAW_BASE_URL + relativeSourcePathForGithub;
|
const githubRawUrl = GITHUB_RAW_BASE_URL + relativeSourcePathForGithub;
|
||||||
overviewContent += `- [${title}](${githubRawUrl})\n`;
|
overviewContent += `- [${title}](${githubRawUrl})\n`;
|
||||||
|
|
||||||
fullConcatContent += `# ${title}\n\n${processedContent}\n\n---\n\n`;
|
fullConcatContent += `# ${title}\n\n${processedContent}\n\n---\n\n`;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error processing file ${sourceFilePath}:`, error);
|
console.error(`Error processing file ${sourceFilePath}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Writing output files to public/ ...');
|
console.log("Writing output files to public/ ...");
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(OVERVIEW_FILE, overviewContent.trim(), 'utf8');
|
await fs.writeFile(OVERVIEW_FILE, overviewContent.trim(), "utf8");
|
||||||
console.log(`Generated overview file: ${OVERVIEW_FILE}`);
|
console.log(`Generated overview file: ${OVERVIEW_FILE}`);
|
||||||
|
|
||||||
await fs.writeFile(FULL_CONCAT_FILE, fullConcatContent.trim(), 'utf8');
|
await fs.writeFile(FULL_CONCAT_FILE, fullConcatContent.trim(), "utf8");
|
||||||
console.log(`Generated full concatenated file: ${FULL_CONCAT_FILE}`);
|
console.log(`Generated full concatenated file: ${FULL_CONCAT_FILE}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error writing output files:', error);
|
console.error("Error writing output files:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('LLM file generation complete.');
|
console.log("LLM file generation complete.");
|
||||||
}
|
}
|
||||||
|
|
||||||
generateFiles().catch(error => {
|
generateFiles().catch((error) => {
|
||||||
console.error('Unhandled error during LLM file generation:', error);
|
console.error("Unhandled error during LLM file generation:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import path from 'path';
|
import { existsSync } from "fs";
|
||||||
import { existsSync } from 'fs';
|
import path from "path";
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
export const BANNER_PATH = '/banner-images';
|
export const BANNER_PATH = "/banner-images";
|
||||||
|
|
||||||
export const DEFAULT_BANNER_IMAGE = 'opensaas.webp';
|
export const DEFAULT_BANNER_IMAGE = "opensaas.webp";
|
||||||
|
|
||||||
export const getBannerImageFilename = ({ path }: { path: string }) =>
|
export const getBannerImageFilename = ({ path }: { path: string }) =>
|
||||||
path.replace(/.*\//, '').replace(/\.\w+$/, '.webp');
|
path.replace(/.*\//, "").replace(/\.\w+$/, ".webp");
|
||||||
|
|
||||||
export const checkBannerImageExists = ({ bannerImageFileName }: { bannerImageFileName: string }) => {
|
export const checkBannerImageExists = ({
|
||||||
|
bannerImageFileName,
|
||||||
|
}: {
|
||||||
|
bannerImageFileName: string;
|
||||||
|
}) => {
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const imagePath = path.join(__dirname, `../../public/${BANNER_PATH}`, bannerImageFileName);
|
const imagePath = path.join(
|
||||||
|
__dirname,
|
||||||
|
`../../public/${BANNER_PATH}`,
|
||||||
|
bannerImageFileName,
|
||||||
|
);
|
||||||
return existsSync(imagePath);
|
return existsSync(imagePath);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { defineCollection } from 'astro:content';
|
import { docsSchema, i18nSchema } from "@astrojs/starlight/schema";
|
||||||
import { i18nSchema, docsSchema } from '@astrojs/starlight/schema';
|
import { defineCollection, z } from "astro:content";
|
||||||
import { blogSchema } from 'starlight-blog/schema';
|
import { blogSchema } from "starlight-blog/schema";
|
||||||
import { z } from 'astro:content';
|
|
||||||
|
|
||||||
export const collections = {
|
export const collections = {
|
||||||
docs: defineCollection({
|
docs: defineCollection({
|
||||||
@@ -16,5 +15,5 @@ export const collections = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
i18n: defineCollection({ type: 'data', schema: i18nSchema() }),
|
i18n: defineCollection({ type: "data", schema: i18nSchema() }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,72 +4,97 @@ date: 2023-11-21
|
|||||||
tags: ["indiehacker", "saas", "sideproject"]
|
tags: ["indiehacker", "saas", "sideproject"]
|
||||||
authors: vince
|
authors: vince
|
||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
|
import { Image } from "astro:assets";
|
||||||
|
|
||||||
## Hey, I’m Vince…
|
## Hey, I’m Vince…
|
||||||
|
|
||||||
<Image src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/az8xf61b2qxx1msfo4t5.png" alt="Vince Headshot" loading="lazy" width={700} height={700} />
|
<Image
|
||||||
|
src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/az8xf61b2qxx1msfo4t5.png"
|
||||||
|
alt="Vince Headshot"
|
||||||
|
loading="lazy"
|
||||||
|
width={700}
|
||||||
|
height={700}
|
||||||
|
/>
|
||||||
|
|
||||||
I’m a self-taught developer that changed careers during the Covid Pandemic. I did it because I wanted a better career, enjoyed programming, and at the same time, had a keen interest in IndieHacking.
|
I’m a self-taught developer that changed careers during the Covid Pandemic. I did it because I wanted a better career, enjoyed programming, and at the same time, had a keen interest in IndieHacking.
|
||||||
|
|
||||||
If you’re not aware, IndieHacking is the movement of developers who build potentially profitable side-projects in their spare time. And there are some very successful examples of IndieHackers and “solopreneurs” out there inspiring others, such as [levels.io](http://levels.io) and [Marc Lou](https://twitter.com/marc_louvion).
|
If you’re not aware, IndieHacking is the movement of developers who build potentially profitable side-projects in their spare time. And there are some very successful examples of IndieHackers and “solopreneurs” out there inspiring others, such as [levels.io](http://levels.io) and [Marc Lou](https://twitter.com/marc_louvion).
|
||||||
|
|
||||||
This thought of being able to build my own side-project that could generate profit while I slept was always attractive to me.
|
This thought of being able to build my own side-project that could generate profit while I slept was always attractive to me.
|
||||||
<Image src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/e1r07ajn3gysdscjdkns.png" alt="CoverLetterGPT" loading="lazy" width={700} height={700} />
|
|
||||||
|
<Image
|
||||||
|
src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/e1r07ajn3gysdscjdkns.png"
|
||||||
|
alt="CoverLetterGPT"
|
||||||
|
loading="lazy"
|
||||||
|
width={700}
|
||||||
|
height={700}
|
||||||
|
/>
|
||||||
|
|
||||||
So I’m happy to report that I’ve finally done it with my first software-as-a-service (SaaS) app, [CoverLetterGPT.xyz](http://CoverLetterGPT.xyz), which I launched in March 2023!
|
So I’m happy to report that I’ve finally done it with my first software-as-a-service (SaaS) app, [CoverLetterGPT.xyz](http://CoverLetterGPT.xyz), which I launched in March 2023!
|
||||||
|
|
||||||
I’ll be the first to admit that the results aren’t spectacular, but they’re still something I’m very proud of:
|
I’ll be the first to admit that the results aren’t spectacular, but they’re still something I’m very proud of:
|
||||||
|
|
||||||
- over 5,000 registered users
|
- over 5,000 registered users
|
||||||
- $203 monthly recurring revenue (MRR)
|
- $203 monthly recurring revenue (MRR)
|
||||||
|
|
||||||
Below, I’m going to share with you how I built it (yes, it’s [open-source](https://github.com/vincanger/coverlettergpt)), how I marketed and monetized it, along with a bunch of helpful resources to help you build your own profitable side-project.
|
Below, I’m going to share with you how I built it (yes, it’s [open-source](https://github.com/vincanger/coverlettergpt)), how I marketed and monetized it, along with a bunch of helpful resources to help you build your own profitable side-project.
|
||||||
|
|
||||||
|
|
||||||
## What the heck is CoverLetterGPT?
|
## What the heck is CoverLetterGPT?
|
||||||
|
|
||||||
[CoverLetterGPT.xyz](http://CoverLetterGPT.xyz) was an idea I got after the OpenAI API was released. It’s an app that allows you to upload a PDF of your CV/resumé, along with the job description you’re applying to, and it will generate and edit unique cover letters for you based on this information.
|
[CoverLetterGPT.xyz](http://CoverLetterGPT.xyz) was an idea I got after the OpenAI API was released. It’s an app that allows you to upload a PDF of your CV/resumé, along with the job description you’re applying to, and it will generate and edit unique cover letters for you based on this information.
|
||||||
|
|
||||||
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
|
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
|
||||||
<iframe
|
<iframe
|
||||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
|
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
|
||||||
src="https://www.youtube.com/embed/ZhcFRD9cVrI"
|
src="https://www.youtube.com/embed/ZhcFRD9cVrI"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
It also lets you save and manage your cover letters per each job, making it easy to make and apply to multiple jobs without having to keep copy and pasting all your important info into ChatGPT!
|
It also lets you save and manage your cover letters per each job, making it easy to make and apply to multiple jobs without having to keep copy and pasting all your important info into ChatGPT!
|
||||||
|
|
||||||
## What’s the Tech Stack?
|
## What’s the Tech Stack?
|
||||||
|
|
||||||
<Image src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xpb97bgrx98bwxemrg0o.png" alt="Tech Stack" loading="lazy" width={700} height={700} />
|
<Image
|
||||||
|
src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xpb97bgrx98bwxemrg0o.png"
|
||||||
|
alt="Tech Stack"
|
||||||
|
loading="lazy"
|
||||||
|
width={700}
|
||||||
|
height={700}
|
||||||
|
/>
|
||||||
|
|
||||||
CoverLetterGPT is entirely open-source, so you can [check out the code](https://github.com/vincanger/coverlettergpt), fork it, learn from it, make your own, submit a PR (I’d love you forever if you did 🙂)… whatever!
|
CoverLetterGPT is entirely open-source, so you can [check out the code](https://github.com/vincanger/coverlettergpt), fork it, learn from it, make your own, submit a PR (I’d love you forever if you did 🙂)… whatever!
|
||||||
|
|
||||||
I built it using the [Wasp full-stack framework](https://wasp.sh) which allowed me to ship it about 10x faster.
|
I built it using the [Wasp full-stack framework](https://wasp.sh) which allowed me to ship it about 10x faster.
|
||||||
|
|
||||||
Why?
|
Why?
|
||||||
|
|
||||||
Because [Wasp](https://wasp.sh) as a framework allows you to describe your app’s core features in a `main.wasp` config file. Then it continually compiles and “glues” these features into a React-ExpressJS-Prisma full-stack app for you.
|
Because [Wasp](https://wasp.sh) as a framework allows you to describe your app’s core features in a `main.wasp` config file. Then it continually compiles and “glues” these features into a React-ExpressJS-Prisma full-stack app for you.
|
||||||
|
|
||||||
All you have to focus on is writing the client and server-side logic, and Wasp will do the boring stuff for you, like authentication & authorization, server config, email sending, and cron jobs.
|
All you have to focus on is writing the client and server-side logic, and Wasp will do the boring stuff for you, like authentication & authorization, server config, email sending, and cron jobs.
|
||||||
|
|
||||||
BTW, [Wasp](https://wasp.sh) is open-source and free and you can help the project out a ton by starring the repo on GitHub: [https://www.github.com/wasp-lang/wasp](https://www.github.com/wasp-lang/wasp) 🙏
|
BTW, [Wasp](https://wasp.sh) is open-source and free and you can help the project out a ton by starring the repo on GitHub: [https://www.github.com/wasp-lang/wasp](https://www.github.com/wasp-lang/wasp) 🙏
|
||||||
|
|
||||||
<Image src='https://media1.giphy.com/media/ZfK4cXKJTTay1Ava29/giphy.gif?cid=7941fdc6pmqo30ll0e4rzdiisbtagx97sx5t0znx4lk0auju&ep=v1_gifs_search&rid=giphy.gif&ct=g' loading="lazy" alt="star wasp" width={500} height={500} />
|
<Image
|
||||||
|
src="https://media1.giphy.com/media/ZfK4cXKJTTay1Ava29/giphy.gif?cid=7941fdc6pmqo30ll0e4rzdiisbtagx97sx5t0znx4lk0auju&ep=v1_gifs_search&rid=giphy.gif&ct=g"
|
||||||
|
loading="lazy"
|
||||||
|
alt="star wasp"
|
||||||
|
width={500}
|
||||||
|
height={500}
|
||||||
|
/>
|
||||||
|
|
||||||
[⭐️ Star Wasp on GitHub 🙏](https://www.github.com/wasp-lang/wasp)
|
[⭐️ Star Wasp on GitHub 🙏](https://www.github.com/wasp-lang/wasp)
|
||||||
|
|
||||||
For the UI, I used [Chakra UI](https://chakra-ui.com/), as I always do. I like that it’s a component-based UI library. This helps me build UI’s a lot faster than I would with Tailwind or vanilla CSS.
|
For the UI, I used [Chakra UI](https://chakra-ui.com/), as I always do. I like that it’s a component-based UI library. This helps me build UI’s a lot faster than I would with Tailwind or vanilla CSS.
|
||||||
|
|
||||||
For payments, I used [Stripe](https://www.notion.so/How-I-Built-and-Open-Sourced-CoverLetterGPT-5-000-users-200-MRR-0d32f13fa00a440fb8e08c8dbf2b8a27?pvs=21), (I’ll go into the details of monetization below).
|
For payments, I used [Stripe](https://www.notion.so/How-I-Built-and-Open-Sourced-CoverLetterGPT-5-000-users-200-MRR-0d32f13fa00a440fb8e08c8dbf2b8a27?pvs=21), (I’ll go into the details of monetization below).
|
||||||
|
|
||||||
The Server and Postgres Database are hosted on [https://railway.app](https://railway.app/), with the client on [Netlify.com](http://Netlify.com)’s free tier.
|
The Server and Postgres Database are hosted on [https://railway.app](https://railway.app/), with the client on [Netlify.com](http://Netlify.com)’s free tier.
|
||||||
|
|
||||||
By the way, If you’re interested in building your own SaaS with almost the same stack as above, I also built a [free SaaS template](https://github.com/wasp-lang/open-saas) you can use that will save you days of work!
|
By the way, If you’re interested in building your own SaaS with almost the same stack as above, I also built a [free SaaS template](https://github.com/wasp-lang/open-saas) you can use that will save you days of work!
|
||||||
|
|
||||||
## How I Marketed It
|
## How I Marketed It
|
||||||
|
|
||||||
@@ -81,15 +106,27 @@ Well, not quite.
|
|||||||
|
|
||||||
First of all, the number of people who will realistically spend the time and energy launching a direct competitor is low. Also, most people interested in your open-source code want to learn some aspect of it and apply it to their own ideas, not just copy yours directly.
|
First of all, the number of people who will realistically spend the time and energy launching a direct competitor is low. Also, most people interested in your open-source code want to learn some aspect of it and apply it to their own ideas, not just copy yours directly.
|
||||||
|
|
||||||
Secondly, and most importantly, the fact that it’s open-source makes people a lot more receptive to you talking about it.
|
Secondly, and most importantly, the fact that it’s open-source makes people a lot more receptive to you talking about it.
|
||||||
|
|
||||||
<Image src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q79djej6doj2yq10l2og.png" alt="reddit" loading="lazy" width={700} height={700} />
|
<Image
|
||||||
|
src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q79djej6doj2yq10l2og.png"
|
||||||
|
alt="reddit"
|
||||||
|
loading="lazy"
|
||||||
|
width={700}
|
||||||
|
height={700}
|
||||||
|
/>
|
||||||
|
|
||||||
When you present something you’ve built and give people the opportunity to learn from it, they’re much more welcoming! As a result, they’re more likely to upvote it, share it, use it, and recommend it to others.
|
When you present something you’ve built and give people the opportunity to learn from it, they’re much more welcoming! As a result, they’re more likely to upvote it, share it, use it, and recommend it to others.
|
||||||
|
|
||||||
This is exactly what happened with CoverLetterGPT! As a result of me sharing the open-source code, it get featured on the [IndieHackers.com](https://www.indiehackers.com/post/whats-new-don-t-build-things-no-one-wants-833ee752ba?utm_source=indie-hackers-emails&utm_campaign=ih-newsletter&utm_medium=email) newsletter (>100k subscribers), shared on blogs, and talked about on social media platforms.
|
This is exactly what happened with CoverLetterGPT! As a result of me sharing the open-source code, it get featured on the [IndieHackers.com](https://www.indiehackers.com/post/whats-new-don-t-build-things-no-one-wants-833ee752ba?utm_source=indie-hackers-emails&utm_campaign=ih-newsletter&utm_medium=email) newsletter (>100k subscribers), shared on blogs, and talked about on social media platforms.
|
||||||
|
|
||||||
<Image src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/44rlv65u97qhufbhqt0k.png" alt="product hunt" loading="lazy" width={700} height={700} />
|
<Image
|
||||||
|
src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/44rlv65u97qhufbhqt0k.png"
|
||||||
|
alt="product hunt"
|
||||||
|
loading="lazy"
|
||||||
|
width={700}
|
||||||
|
height={700}
|
||||||
|
/>
|
||||||
|
|
||||||
And even though it’s a small, simple product, I tried launching it on [Product Hunt](http://producthunt.com), where it also performed considerably well.
|
And even though it’s a small, simple product, I tried launching it on [Product Hunt](http://producthunt.com), where it also performed considerably well.
|
||||||
|
|
||||||
@@ -110,7 +147,13 @@ My initial payment options were:
|
|||||||
- $4.95 for a 3 months access
|
- $4.95 for a 3 months access
|
||||||
- $2.95 for 10 cover letter generations
|
- $2.95 for 10 cover letter generations
|
||||||
|
|
||||||
<Image src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/golo3tnh3o0sy5sujrer.png" alt="pricing page" loading="lazy" width={700} height={700} />
|
<Image
|
||||||
|
src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/golo3tnh3o0sy5sujrer.png"
|
||||||
|
alt="pricing page"
|
||||||
|
loading="lazy"
|
||||||
|
width={700}
|
||||||
|
height={700}
|
||||||
|
/>
|
||||||
|
|
||||||
That went reasonably well until I implemented the ability for users to use GPT to make finer edits to their generated cover letters. That’s when I changed my pricing and that’s when better profits started to come in:
|
That went reasonably well until I implemented the ability for users to use GPT to make finer edits to their generated cover letters. That’s when I changed my pricing and that’s when better profits started to come in:
|
||||||
|
|
||||||
@@ -153,8 +196,14 @@ Here are also the most important links from this article along with some further
|
|||||||
- 🪓 [IndieHackers Feature](https://www.indiehackers.com/post/whats-new-don-t-build-things-no-one-wants-833ee752ba?utm_source=indie-hackers-emails&utm_campaign=ih-newsletter&utm_medium=email)
|
- 🪓 [IndieHackers Feature](https://www.indiehackers.com/post/whats-new-don-t-build-things-no-one-wants-833ee752ba?utm_source=indie-hackers-emails&utm_campaign=ih-newsletter&utm_medium=email)
|
||||||
- 💸 [Great Video on how to use Stripe CLI & Webhooks](https://www.youtube.com/watch?v=Psq5N5C-FGo&t=1041s)
|
- 💸 [Great Video on how to use Stripe CLI & Webhooks](https://www.youtube.com/watch?v=Psq5N5C-FGo&t=1041s)
|
||||||
|
|
||||||
Oh, and if you found these resources useful, don't forget to support Wasp by [starring the repo on GitHub](https://github.com/wasp-lang/wasp)!
|
Oh, and if you found these resources useful, don't forget to support Wasp by [starring the repo on GitHub](https://github.com/wasp-lang/wasp)!
|
||||||
|
|
||||||
<Image src="https://res.cloudinary.com/practicaldev/image/fetch/s--OCpry2p9--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bky8z46ii7ayejprrqw3.gif" alt="star wasp" loading="lazy" width={500} height={500} />
|
<Image
|
||||||
|
src="https://res.cloudinary.com/practicaldev/image/fetch/s--OCpry2p9--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bky8z46ii7ayejprrqw3.gif"
|
||||||
|
alt="star wasp"
|
||||||
|
loading="lazy"
|
||||||
|
width={500}
|
||||||
|
height={500}
|
||||||
|
/>
|
||||||
|
|
||||||
[⭐️ Thanks For Your Support 🙏 ](https://www.github.com/wasp-lang/wasp)
|
[⭐️ Thanks For Your Support 🙏 ](https://www.github.com/wasp-lang/wasp)
|
||||||
|
|||||||
@@ -9,22 +9,23 @@ tags:
|
|||||||
hideBannerImage: true
|
hideBannerImage: true
|
||||||
authors: vince
|
authors: vince
|
||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
import wheel from '@assets/cookie-consent/wheel.gif';
|
import { Image } from "astro:assets";
|
||||||
import enter from '@assets/cookie-consent/enter.gif';
|
import wheel from "@assets/cookie-consent/wheel.gif";
|
||||||
import keyboard from '@assets/cookie-consent/keyboard.jpg';
|
import enter from "@assets/cookie-consent/enter.gif";
|
||||||
import share from '@assets/cookie-consent/image.png';
|
import keyboard from "@assets/cookie-consent/keyboard.jpg";
|
||||||
|
import share from "@assets/cookie-consent/image.png";
|
||||||
|
|
||||||
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
|
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
|
||||||
<iframe
|
<iframe
|
||||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
|
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
|
||||||
src="https://www.youtube.com/embed/tLEEk8Q5jo4?si=U-nROtawDHrjJ4k0"
|
src="https://www.youtube.com/embed/tLEEk8Q5jo4?si=U-nROtawDHrjJ4k0"
|
||||||
title="YouTube video player"
|
title="YouTube video player"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
referrerpolicy="strict-origin-when-cross-origin"
|
referrerpolicy="strict-origin-when-cross-origin"
|
||||||
allowfullscreen>
|
allowfullscreen
|
||||||
</iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## What kind of hackathon is this?
|
## What kind of hackathon is this?
|
||||||
@@ -33,20 +34,19 @@ The goal here is simple. Make **THE MOST ANNOYING COOKIE BANNER** you can think
|
|||||||
|
|
||||||
Cookie consent banners annoy us all. So we thought, why not have some fun with them? Here are a couple examples of what that might look like:
|
Cookie consent banners annoy us all. So we thought, why not have some fun with them? Here are a couple examples of what that might look like:
|
||||||
|
|
||||||
1. *The Cookie Consent Wheel of Fortune:*
|
1. _The Cookie Consent Wheel of Fortune:_
|
||||||
|
|
||||||
<Image src={wheel} alt='Consent wheel' loading='lazy' />
|
<Image src={wheel} alt="Consent wheel" loading="lazy" />
|
||||||
|
|
||||||
2. *The “Hit Enter When the Red Ball is Over the Accept Button to Consent” Banner:*
|
2. _The “Hit Enter When the Red Ball is Over the Accept Button to Consent” Banner:_
|
||||||
|
|
||||||
<Image src={enter} alt='Enter to win' loading='lazy' />
|
<Image src={enter} alt="Enter to win" loading="lazy" />
|
||||||
|
|
||||||
|
|
||||||
Now it’s time for you to get creative. Btw, if you’re looking for some inspiration, check out these [Ridiculous Volume Slider UI’s](https://uxdesign.cc/the-worst-volume-control-ui-in-the-world-60713dc86950).
|
Now it’s time for you to get creative. Btw, if you’re looking for some inspiration, check out these [Ridiculous Volume Slider UI’s](https://uxdesign.cc/the-worst-volume-control-ui-in-the-world-60713dc86950).
|
||||||
|
|
||||||
## Prizes
|
## Prizes
|
||||||
|
|
||||||
2 winners will receive a nice mechanical keyboard, an additional *annoying* gift, as well as a shoutout on our socials.
|
2 winners will receive a nice mechanical keyboard, an additional _annoying_ gift, as well as a shoutout on our socials.
|
||||||
|
|
||||||
The 2 winners will be selected by:
|
The 2 winners will be selected by:
|
||||||
|
|
||||||
@@ -55,22 +55,19 @@ The 2 winners will be selected by:
|
|||||||
|
|
||||||
The community will get a chance to vote in a battle royale style elimination tournament, where two banners will go head-to-head and the winner will advance to the next round.
|
The community will get a chance to vote in a battle royale style elimination tournament, where two banners will go head-to-head and the winner will advance to the next round.
|
||||||
|
|
||||||
<Image src={keyboard} alt='Keyboard' loading='lazy' />
|
<Image src={keyboard} alt="Keyboard" loading="lazy" />
|
||||||
|
|
||||||
(The brand/style will depend on the winner's location, but we'll do our best to find one with a Wasp look and feel 😃)
|
(The brand/style will depend on the winner's location, but we'll do our best to find one with a Wasp look and feel 😃)
|
||||||
|
|
||||||
## How to participate
|
## How to participate
|
||||||
|
|
||||||
- Fork the [Annoying Cookie Banner Stackblitz Template](https://stackblitz.com/edit/vitejs-vite-uiyjag?file=src%2Flanding-page%2Fcomponents%2FCookieConsentBanner.tsx)
|
- Fork the [Annoying Cookie Banner Stackblitz Template](https://stackblitz.com/edit/vitejs-vite-uiyjag?file=src%2Flanding-page%2Fcomponents%2FCookieConsentBanner.tsx)
|
||||||
- If you prefer to work in your own editor, just click on the `Create a repository` button after you fork the template
|
- If you prefer to work in your own editor, just click on the `Create a repository` button after you fork the template
|
||||||
- When finished with your banner, click on `Share` in the top left, and in the `Embed` tab, click `Copy URL` with the following settings:
|
- When finished with your banner, click on `Share` in the top left, and in the `Embed` tab, click `Copy URL` with the following settings:
|
||||||
|
<Image src={share} alt="Share" loading="lazy" />
|
||||||
<Image src={share} alt='Share' loading='lazy' />
|
|
||||||
|
|
||||||
- Next, [edit the `MOST-ANNOYING-COOKIE-BANNER.md` file](https://github.com/wasp-lang/open-saas/edit/main/MOST-ANNOYING-COOKIE-BANNER.md) on the Open SaaS repo.
|
- Next, [edit the `MOST-ANNOYING-COOKIE-BANNER.md` file](https://github.com/wasp-lang/open-saas/edit/main/MOST-ANNOYING-COOKIE-BANNER.md) on the Open SaaS repo.
|
||||||
- Enter your GitHub username followed by the embed link you copied from Stackblitz
|
- Enter your GitHub username followed by the embed link you copied from Stackblitz
|
||||||
- Note: after you create a PR, the Wasp team will add the `ANNOYING COOKIE BANNER` label to it.
|
- Note: after you create a PR, the Wasp team will add the `ANNOYING COOKIE BANNER` label to it.
|
||||||
|
|
||||||
- Make sure you also ⭐️ [star the Open Saas repository](https://github.com/wasp-lang/open-saas) to be eligible to win!
|
- Make sure you also ⭐️ [star the Open Saas repository](https://github.com/wasp-lang/open-saas) to be eligible to win!
|
||||||
|
|
||||||
## Deadline & Results
|
## Deadline & Results
|
||||||
@@ -81,8 +78,8 @@ Be sure to join our [Discord](https://discord.gg/rzdnErX) or follow us on [Twitt
|
|||||||
|
|
||||||
## Let's annoy our users! 🚀
|
## Let's annoy our users! 🚀
|
||||||
|
|
||||||
Let's create the most annoying cookie consent banner and have some fun! This hackathon is your chance to show off your creativity and tech skills. We're super excited to see what wild ideas you come up with.
|
Let's create the most annoying cookie consent banner and have some fun! This hackathon is your chance to show off your creativity and tech skills. We're super excited to see what wild ideas you come up with.
|
||||||
|
|
||||||
Remember, this is all about having fun and pushing the boundaries of user interface design!
|
Remember, this is all about having fun and pushing the boundaries of user interface design!
|
||||||
|
|
||||||
Let the annoyance games begin! 🎉
|
Let the annoyance games begin! 🎉
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ tags:
|
|||||||
subtitle: and it was totally worth it
|
subtitle: and it was totally worth it
|
||||||
authors: vince
|
authors: vince
|
||||||
---
|
---
|
||||||
import VideoPlayer from '../../../components/VideoPlayer.astro';
|
|
||||||
import { Image } from 'astro:assets';
|
import VideoPlayer from "../../../components/VideoPlayer.astro";
|
||||||
import camblackwood from '@assets/cookie-banner-hackathon/295-camblackwood.mp4';
|
import { Image } from "astro:assets";
|
||||||
import gangnam from '@assets/cookie-banner-hackathon/300-lezzz-sound.mp4';
|
import camblackwood from "@assets/cookie-banner-hackathon/295-camblackwood.mp4";
|
||||||
import wheredaway from '@assets/cookie-banner-hackathon/302-fecony-whereda.mp4';
|
import gangnam from "@assets/cookie-banner-hackathon/300-lezzz-sound.mp4";
|
||||||
import henryboyd from '@assets/cookie-banner-hackathon/296-henryboyd.mp4';
|
import wheredaway from "@assets/cookie-banner-hackathon/302-fecony-whereda.mp4";
|
||||||
import wardbox from '@assets/cookie-banner-hackathon/286-wardbox.mp4';
|
import henryboyd from "@assets/cookie-banner-hackathon/296-henryboyd.mp4";
|
||||||
import gangnamwinner from '@assets/cookie-banner-hackathon/285-3umaGH-gangnam.mp4';
|
import wardbox from "@assets/cookie-banner-hackathon/286-wardbox.mp4";
|
||||||
|
import gangnamwinner from "@assets/cookie-banner-hackathon/285-3umaGH-gangnam.mp4";
|
||||||
|
|
||||||
## The Most Annoying Cookie Consent Banner Ever Hackathon
|
## The Most Annoying Cookie Consent Banner Ever Hackathon
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ Now all we have to do is wait for the rejection email.
|
|||||||
|
|
||||||
## Grand Prize Winner: Gangnam Style Beat
|
## Grand Prize Winner: Gangnam Style Beat
|
||||||
|
|
||||||
Our community-chosen winner's submission pretty much speaks for itself.
|
Our community-chosen winner's submission pretty much speaks for itself.
|
||||||
|
|
||||||
Make sure you turn on the sound for this one!
|
Make sure you turn on the sound for this one!
|
||||||
|
|
||||||
@@ -89,10 +90,20 @@ At [Wasp](https://wasp.sh/) we're working hard to build a modern, open-source fu
|
|||||||
|
|
||||||
The easiest way to show your support is just to star the Wasp repo! 🐝 It helps us spread the word and motivates us to keep building.
|
The easiest way to show your support is just to star the Wasp repo! 🐝 It helps us spread the word and motivates us to keep building.
|
||||||
|
|
||||||
<Image src='https://dev-to-uploads.s3.amazonaws.com/uploads/articles/axqiv01tl1pha9ougp21.gif' alt='friendly handshake' width="500" height="500" loading='lazy' />
|
<Image
|
||||||
|
src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/axqiv01tl1pha9ougp21.gif"
|
||||||
|
alt="friendly handshake"
|
||||||
|
width="500"
|
||||||
|
height="500"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="cta">
|
<div className="cta">
|
||||||
<a href="https://github.com/wasp-lang/wasp" target="_blank" rel="noopener noreferrer">
|
<a
|
||||||
|
href="https://github.com/wasp-lang/wasp"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
⭐️ Star Wasp on GitHub 💪
|
⭐️ Star Wasp on GitHub 💪
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,16 +7,17 @@ tags:
|
|||||||
- sideproject
|
- sideproject
|
||||||
authors: matija
|
authors: matija
|
||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
import marc1 from '../../../assets/boilerplate-starters/marc1.png';
|
import { Image } from "astro:assets";
|
||||||
import opensaas from '../../../assets/boilerplate-starters/opensaas.png';
|
import marc1 from "../../../assets/boilerplate-starters/marc1.png";
|
||||||
import marc2 from '../../../assets/boilerplate-starters/marc2.png';
|
import opensaas from "../../../assets/boilerplate-starters/opensaas.png";
|
||||||
import daveShipfast from '../../../assets/boilerplate-starters/dave-shipfast-tweet.png';
|
import marc2 from "../../../assets/boilerplate-starters/marc2.png";
|
||||||
import osGhStats from '../../../assets/boilerplate-starters/os-gh-stats.png';
|
import daveShipfast from "../../../assets/boilerplate-starters/dave-shipfast-tweet.png";
|
||||||
import osCommits from '../../../assets/boilerplate-starters/os-commits.png';
|
import osGhStats from "../../../assets/boilerplate-starters/os-gh-stats.png";
|
||||||
import freeUpdates from '../../../assets/boilerplate-starters/free-updates-vs-not.png';
|
import osCommits from "../../../assets/boilerplate-starters/os-commits.png";
|
||||||
import communityContributions from '../../../assets/boilerplate-starters/community-contributions.png';
|
import freeUpdates from "../../../assets/boilerplate-starters/free-updates-vs-not.png";
|
||||||
import boilerplateLicenses from '../../../assets/boilerplate-starters/boilerplate-licenses.png';
|
import communityContributions from "../../../assets/boilerplate-starters/community-contributions.png";
|
||||||
|
import boilerplateLicenses from "../../../assets/boilerplate-starters/boilerplate-licenses.png";
|
||||||
|
|
||||||
SaaS boilerplate starters became a very popular thing in the web dev community, and also a pathway to a luxury lifestyle for those behind them, sometimes making north of five figure amounts per month.
|
SaaS boilerplate starters became a very popular thing in the web dev community, and also a pathway to a luxury lifestyle for those behind them, sometimes making north of five figure amounts per month.
|
||||||
|
|
||||||
@@ -102,9 +103,13 @@ An another direct benefit of the SaaS starter code being open-source is that you
|
|||||||
|
|
||||||
<Image src={osCommits} alt="Commits to open saas" loading="lazy" />
|
<Image src={osCommits} alt="Commits to open saas" loading="lazy" />
|
||||||
|
|
||||||
With closed source, it varies a lot from one starter to another. Some offer updates as an upsell (e.g. basic and pro tier), some offer a limited time updates (e.g. 1-year), and some promise a lifetime of updates.
|
With closed source, it varies a lot from one starter to another. Some offer updates as an upsell (e.g. basic and pro tier), some offer a limited time updates (e.g. 1-year), and some promise a lifetime of updates.
|
||||||
|
|
||||||
<Image src={freeUpdates} alt="Free updates vs pay for everything" loading="lazy" />
|
<Image
|
||||||
|
src={freeUpdates}
|
||||||
|
alt="Free updates vs pay for everything"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
## With a paid SaaS starter, you might need to buy a “license” for every new app
|
## With a paid SaaS starter, you might need to buy a “license” for every new app
|
||||||
|
|
||||||
@@ -120,19 +125,23 @@ With an open-source starter, there naturally isn't any such limit - the full sou
|
|||||||
|
|
||||||
One of the most exciting benefits of the open-source approach is that anybody can contribute! If there is a feature you're missing or want to improve, you can simply do it yourself it and create a pull request. Then, the core maintainers will review it, give advice and point you in the right direction if needed. Once it gets merged, it is available for everyone to use!
|
One of the most exciting benefits of the open-source approach is that anybody can contribute! If there is a feature you're missing or want to improve, you can simply do it yourself it and create a pull request. Then, the core maintainers will review it, give advice and point you in the right direction if needed. Once it gets merged, it is available for everyone to use!
|
||||||
|
|
||||||
<Image src={communityContributions} alt="Community contributions" loading="lazy" />
|
<Image
|
||||||
|
src={communityContributions}
|
||||||
|
alt="Community contributions"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Now that we have gone through the main differences between open-source and paid SaaS starters, let's give it a bird's-eye view:
|
Now that we have gone through the main differences between open-source and paid SaaS starters, let's give it a bird's-eye view:
|
||||||
|
|
||||||
| | Cost | Lifetime updates | Unlimited apps | Maintainers | Community | Air Jordans Effect | Easily contribute |
|
| | Cost | Lifetime updates | Unlimited apps | Maintainers | Community | Air Jordans Effect | Easily contribute |
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
| ------------------------ | ----- | ---------------- | -------------- | ------------- | ------------------ | ------------------ | ----------------- |
|
||||||
| Open-source SaaS starter | $0 | YES | YES | Many | Big, public | Rarely | YES |
|
| Open-source SaaS starter | $0 | YES | YES | Many | Big, public | Rarely | YES |
|
||||||
| Paid starter | $300+ | Depends | Depends | Typically one | Sometimes, private | Often | No |
|
| Paid starter | $300+ | Depends | Depends | Typically one | Sometimes, private | Often | No |
|
||||||
|
|
||||||
This is a useful list to be aware of when making a decision which route to go, but in the end there is no one answer that will fit all. Your decision will depend on what exactly you're looking to build and which tech stack you prefer using.
|
This is a useful list to be aware of when making a decision which route to go, but in the end there is no one answer that will fit all. Your decision will depend on what exactly you're looking to build and which tech stack you prefer using.
|
||||||
|
|
||||||
Also, the factors above will not be equally weighted by everyone - one person might be excited about being a part of a wider community and being able to easily contribute to the project, while other most appreciate the fact there is a strong online personality they can follow and get inspired.
|
Also, the factors above will not be equally weighted by everyone - one person might be excited about being a part of a wider community and being able to easily contribute to the project, while other most appreciate the fact there is a strong online personality they can follow and get inspired.
|
||||||
|
|
||||||
In the end, the only important thing is to take action and successfully ship that application you've been thinking about for so long. Good luck!
|
In the end, the only important thing is to take action and successfully ship that application you've been thinking about for so long. Good luck!
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ tags:
|
|||||||
- indiehackers
|
- indiehackers
|
||||||
authors: milica
|
authors: milica
|
||||||
---
|
---
|
||||||
import VideoPlayer from '../../../components/VideoPlayer.astro';
|
|
||||||
import { Image } from 'astro:assets';
|
import VideoPlayer from "../../../components/VideoPlayer.astro";
|
||||||
import landing from '../../../assets/turboreel/landing.webp';
|
import { Image } from "astro:assets";
|
||||||
import studioInterface from '../../../assets/turboreel/studio-interface.mp4';
|
import landing from "../../../assets/turboreel/landing.webp";
|
||||||
import opensaas from '../../../assets/turboreel/opensaas.mp4';
|
import studioInterface from "../../../assets/turboreel/studio-interface.mp4";
|
||||||
import reddit100Users from '../../../assets/turboreel/reddit-100-users.webp'
|
import opensaas from "../../../assets/turboreel/opensaas.mp4";
|
||||||
import reddit200Upvotes from '../../../assets/turboreel/reddit-200-upvotes.webp'
|
import reddit100Users from "../../../assets/turboreel/reddit-100-users.webp";
|
||||||
|
import reddit200Upvotes from "../../../assets/turboreel/reddit-200-upvotes.webp";
|
||||||
|
|
||||||
Peter is the creator of [**TurboReel**](https://turboreelgpt.tech/), an open-source platform with a paid SaaS layer, that transforms how creators generate short-form video content. With just a prompt, users can produce polished TikToks and YouTube Shorts in moments.
|
Peter is the creator of [**TurboReel**](https://turboreelgpt.tech/), an open-source platform with a paid SaaS layer, that transforms how creators generate short-form video content. With just a prompt, users can produce polished TikToks and YouTube Shorts in moments.
|
||||||
|
|
||||||
@@ -26,11 +27,11 @@ In this post, we'll cover three main things: what inspired Peter to kickstart th
|
|||||||
|
|
||||||
## The Starting Point: Open SaaS Boilerplate
|
## The Starting Point: Open SaaS Boilerplate
|
||||||
|
|
||||||
Peter's journey to Open SaaS began with a simple Google search for SaaS boilerplates.
|
Peter's journey to Open SaaS began with a simple Google search for SaaS boilerplates.
|
||||||
|
|
||||||
*"I was looking for something that could save me time," Peter recalls. "I came across a few options—some were free but basic, and [others were paid but didn't feel worth it](https://docs.opensaas.sh/blog/2024-12-04-open-source-saas-boilerplate-vs-paid/). Then I found Wasp's Open SaaS boilerplate."*
|
_"I was looking for something that could save me time," Peter recalls. "I came across a few options—some were free but basic, and [others were paid but didn't feel worth it](https://docs.opensaas.sh/blog/2024-12-04-open-source-saas-boilerplate-vs-paid/). Then I found Wasp's Open SaaS boilerplate."_
|
||||||
|
|
||||||
What stood out to Peter wasn't just that it was free, but that it was **open source**. *"I liked the idea of building on something maintained by a community, not locked behind a paywall"*, he says. Intrigued, Peter explored [Wasp](https://wasp.sh/) further and discovered an engaging community that offered exactly what he needed to start building TurboReel.
|
What stood out to Peter wasn't just that it was free, but that it was **open source**. _"I liked the idea of building on something maintained by a community, not locked behind a paywall"_, he says. Intrigued, Peter explored [Wasp](https://wasp.sh/) further and discovered an engaging community that offered exactly what he needed to start building TurboReel.
|
||||||
|
|
||||||
Here's a video presenting Open SaaS, generated with TurboReel 🐝
|
Here's a video presenting Open SaaS, generated with TurboReel 🐝
|
||||||
|
|
||||||
@@ -42,22 +43,22 @@ TurboReel lets users generate short explainer videos with minimal effort. Starti
|
|||||||
|
|
||||||
<VideoPlayer src={studioInterface} lgWidth="100%" />
|
<VideoPlayer src={studioInterface} lgWidth="100%" />
|
||||||
|
|
||||||
The platform's **open-source foundation** unlocks development potential, while the **paid SaaS layer** helps with funding.
|
The platform's **open-source foundation** unlocks development potential, while the **paid SaaS layer** helps with funding.
|
||||||
|
|
||||||
The tech behind TurboReel looks like this:
|
The tech behind TurboReel looks like this:
|
||||||
|
|
||||||
- **Open SaaS - a free, open-source React & Node.js SaaS starter**
|
- **Open SaaS - a free, open-source React & Node.js SaaS starter**
|
||||||
- powered by Wasp, a full stack web framework for JS.
|
- powered by Wasp, a full stack web framework for JS.
|
||||||
- Combines **React** for the frontend and **Node.js** for backend.
|
- Combines **React** for the frontend and **Node.js** for backend.
|
||||||
- [Prisma](https://www.prisma.io/) handles the database.
|
- [Prisma](https://www.prisma.io/) handles the database.
|
||||||
- **OpenAI**
|
- **OpenAI**
|
||||||
- Used for generating scripts and scenes in the videos.
|
- Used for generating scripts and scenes in the videos.
|
||||||
- [**Pollinations**](https://pollinations.ai/)
|
- [**Pollinations**](https://pollinations.ai/)
|
||||||
- Open-source platform for image and text generation.
|
- Open-source platform for image and text generation.
|
||||||
- Provides creative assets to enhance video quality.
|
- Provides creative assets to enhance video quality.
|
||||||
- [**Revideo**](https://re.video/)
|
- [**Revideo**](https://re.video/)
|
||||||
- Library for programmatic video creation.
|
- Library for programmatic video creation.
|
||||||
- Replaces the previously used **MoviePy**.
|
- Replaces the previously used **MoviePy**.
|
||||||
|
|
||||||
### Building faster with Open SaaS boilerplate
|
### Building faster with Open SaaS boilerplate
|
||||||
|
|
||||||
@@ -71,11 +72,10 @@ The boilerplate included everything he needed:
|
|||||||
- **Frontend-backend communication via a type-safe RPC layer**
|
- **Frontend-backend communication via a type-safe RPC layer**
|
||||||
- **Deployment of the app with a single CLI command**
|
- **Deployment of the app with a single CLI command**
|
||||||
|
|
||||||
One feature that particularly stood out was **Wasp's deployment commands**.
|
One feature that particularly stood out was **Wasp's deployment commands**.
|
||||||
|
|
||||||
> "Usually, deployment takes time to set up properly, but with Wasp, it was as simple as running `wasp deploy fly deploy`."
|
> "Usually, deployment takes time to set up properly, but with Wasp, it was as simple as running `wasp deploy fly deploy`."
|
||||||
|
|
||||||
|
|
||||||
Here's what Wasp's config file looks like, through which you can define full-stack auth in a Wasp app.
|
Here's what Wasp's config file looks like, through which you can define full-stack auth in a Wasp app.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -111,31 +111,40 @@ app myApp {
|
|||||||
```
|
```
|
||||||
|
|
||||||
<div style="background-color: #FFD700; padding: 1rem; text-align: center; font-size: 1.2rem; font-weight: bold; border-radius: 8px; color: black;">
|
<div style="background-color: #FFD700; padding: 1rem; text-align: center; font-size: 1.2rem; font-weight: bold; border-radius: 8px; color: black;">
|
||||||
⭐️ Star <a href="https://github.com/wasp-lang/open-saas" style="color: #0000FF; text-decoration: underline;">Open SaaS repo</a> and support tools that help you build fast!
|
⭐️ Star{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/wasp-lang/open-saas"
|
||||||
|
style="color: #0000FF; text-decoration: underline;"
|
||||||
|
>
|
||||||
|
Open SaaS repo
|
||||||
|
</a>{" "}
|
||||||
|
and support tools that help you build fast!
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### Out-of-the-box Stripe integration
|
### Out-of-the-box Stripe integration
|
||||||
|
|
||||||
Another significant advantage for Peter was how Open SaaS handled third-party integrations. Setting up services like [**Stripe for payments**](https://docs.opensaas.sh/guides/payments-integration/) often requires a lot of effort, but Wasp's OpenSaaS streamlined the process - you just need to add your API key and you're good to go.
|
Another significant advantage for Peter was how Open SaaS handled third-party integrations. Setting up services like [**Stripe for payments**](https://docs.opensaas.sh/guides/payments-integration/) often requires a lot of effort, but Wasp's OpenSaaS streamlined the process - you just need to add your API key and you're good to go.
|
||||||
|
|
||||||
> *"Payments are usually a huge headache, but Open SaaS made it so smooth. I didn't have to spend weeks integrating Stripe—it just worked. That gave me more time to focus on TurboReel's core functionality.*"
|
> _"Payments are usually a huge headache, but Open SaaS made it so smooth. I didn't have to spend weeks integrating Stripe—it just worked. That gave me more time to focus on TurboReel's core functionality._"
|
||||||
|
|
||||||
|
|
||||||
### The power of open source
|
### The power of open source
|
||||||
|
|
||||||
Both TurboReel and Wasp share a commitment to open source.
|
Both TurboReel and Wasp share a commitment to open source.
|
||||||
|
|
||||||
> *"The video generation space is complex. There aren't many established solutions for what I'm trying to do. [By making TurboReel open source](https://github.com/TurboReel), I'm inviting smart people to collaborate and help push the project forward."*
|
|
||||||
|
|
||||||
|
> _"The video generation space is complex. There aren't many established solutions for what I'm trying to do. [By making TurboReel open source](https://github.com/TurboReel), I'm inviting smart people to collaborate and help push the project forward."_
|
||||||
|
|
||||||
## Getting first users
|
## Getting first users
|
||||||
|
|
||||||
<Image src={reddit200Upvotes} alt="Reddit screenshot, 200 upvotes" loading="lazy" />
|
<Image
|
||||||
|
src={reddit200Upvotes}
|
||||||
|
alt="Reddit screenshot, 200 upvotes"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
Peter found interesting subreddits on Reddit and shared his product with users. He enabled everyone to sign up and create a few videos, to get feedback quite early. Lots of people in the creator community loved it, and based off of their feedback, he iterated furthermore improving the UI and the workflow.
|
Peter found interesting subreddits on Reddit and shared his product with users. He enabled everyone to sign up and create a few videos, to get feedback quite early. Lots of people in the creator community loved it, and based off of their feedback, he iterated furthermore improving the UI and the workflow.
|
||||||
|
|
||||||
Within a few days, he was able to get first paying customers, which proved that his MVP was going in the right direction. Plans for the future? The sky is the limit!
|
Within a few days, he was able to get first paying customers, which proved that his MVP was going in the right direction. Plans for the future? The sky is the limit!
|
||||||
|
|
||||||
### Ready to Build Your SaaS?
|
### Ready to Build Your SaaS?
|
||||||
|
|
||||||
Get started with [Wasp](https://wasp.sh/) today, or explore the [Open SaaS boilerplate](https://opensaas.sh/) to see how it can work for you.
|
Get started with [Wasp](https://wasp.sh/) today, or explore the [Open SaaS boilerplate](https://opensaas.sh/) to see how it can work for you.
|
||||||
|
|||||||
@@ -9,21 +9,22 @@ tags:
|
|||||||
authors: vince
|
authors: vince
|
||||||
---
|
---
|
||||||
|
|
||||||
import { Image } from 'astro:assets';
|
import { Image } from "astro:assets";
|
||||||
import Tweet from '../../../components/Tweet.astro';
|
import Tweet from "../../../components/Tweet.astro";
|
||||||
import landingPage from '../../../assets/cover-letter-gpt/coverlettergpt.webp';
|
import landingPage from "../../../assets/cover-letter-gpt/coverlettergpt.webp";
|
||||||
import mrrGraph from '../../../assets/cover-letter-gpt/mrr-graph.webp';
|
import mrrGraph from "../../../assets/cover-letter-gpt/mrr-graph.webp";
|
||||||
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
|
import StarOpenSaaSCTA from "../../../components/StarOpenSaaSCTA.astro";
|
||||||
import redditPost from '../../../assets/cover-letter-gpt/coverlettergpt-reddit.png';
|
import redditPost from "../../../assets/cover-letter-gpt/coverlettergpt-reddit.png";
|
||||||
|
|
||||||
Hey builders,
|
Hey builders,
|
||||||
|
|
||||||
I wanted to share my journey building a micro-SaaS, [CoverLetterGPT](https://coverlettergpt.xyz/), which now earns **$550/month in recurring revenue (MRR)**—all while requiring **minimal effort and maintenance**. Here's how I did it and why I believe small, simple SaaS apps are an underrated way to start as an indie maker.
|
I wanted to share my journey building a micro-SaaS, [CoverLetterGPT](https://coverlettergpt.xyz/), which now earns **$550/month in recurring revenue (MRR)**—all while requiring **minimal effort and maintenance**. Here's how I did it and why I believe small, simple SaaS apps are an underrated way to start as an indie maker.
|
||||||
|
|
||||||
<Tweet id="1863553258586820976" />
|
<Tweet id="1863553258586820976" />
|
||||||
|
|
||||||
### Quick Stats:
|
### Quick Stats:
|
||||||
|
|
||||||
- **Built in 1 week**
|
- **Built in 1 week**
|
||||||
- using [Wasp](https://wasp.sh/), a React, NodeJS, & Prisma framework
|
- using [Wasp](https://wasp.sh/), a React, NodeJS, & Prisma framework
|
||||||
- and [Chakra UI](https://chakra-ui.com/) for the design system.
|
- and [Chakra UI](https://chakra-ui.com/) for the design system.
|
||||||
- **Runs on autopilot**
|
- **Runs on autopilot**
|
||||||
@@ -34,7 +35,7 @@ I wanted to share my journey building a micro-SaaS, [CoverLetterGPT](https://cov
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
BTW, I built [Open SaaS](https://opensaas.sh), the free, open-source SaaS template based on what I learned from building and launching CoverLetterGPT.
|
BTW, I built [Open SaaS](https://opensaas.sh), the free, open-source SaaS template based on what I learned from building and launching CoverLetterGPT.
|
||||||
|
|
||||||
Because Open SaaS is a community-driven project, it also benefits from community feedback and contributions, so it boasts a clean codebase and a ton of useful features. Check it out and give us a star! We're always improving it.
|
Because Open SaaS is a community-driven project, it also benefits from community feedback and contributions, so it boasts a clean codebase and a ton of useful features. Check it out and give us a star! We're always improving it.
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ tags:
|
|||||||
- indiehackers
|
- indiehackers
|
||||||
authors: milica
|
authors: milica
|
||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
import articleGeneration from '../../../assets/ricardo-growth-hacks/article-generation.png';
|
import { Image } from "astro:assets";
|
||||||
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
|
import articleGeneration from "../../../assets/ricardo-growth-hacks/article-generation.png";
|
||||||
import meetingReminders from '../../../assets/ricardo-growth-hacks/meeting-reminders.png';
|
import StarOpenSaaSCTA from "../../../components/StarOpenSaaSCTA.astro";
|
||||||
import googleAddons from '../../../assets/ricardo-growth-hacks/google-addons.png';
|
import meetingReminders from "../../../assets/ricardo-growth-hacks/meeting-reminders.png";
|
||||||
|
import googleAddons from "../../../assets/ricardo-growth-hacks/google-addons.png";
|
||||||
|
|
||||||
[Meet Ricardo](https://x.com/rbatista19) - he has successfully launched multiple SaaS products, turning his ideas into revenue-generating apps. If you're looking to build and launch your own product efficiently, we're about to share some of Ricardo's key strategies.
|
[Meet Ricardo](https://x.com/rbatista19) - he has successfully launched multiple SaaS products, turning his ideas into revenue-generating apps. If you're looking to build and launch your own product efficiently, we're about to share some of Ricardo's key strategies.
|
||||||
|
|
||||||
@@ -24,22 +25,25 @@ By leveraging [Open SaaS](https://opensaas.sh/), Ricardo was able to ship multip
|
|||||||
|
|
||||||
When searching for frameworks to kickstart his projects, Ricardo stumbled upon Open SaaS, a 100% free, open-source starter for React & Node.js. and. He was drawn to Open SaaS because of its simplicity, community, and modern tech stack. He also liked the fact that the company had Y Combinator seal of approval.
|
When searching for frameworks to kickstart his projects, Ricardo stumbled upon Open SaaS, a 100% free, open-source starter for React & Node.js. and. He was drawn to Open SaaS because of its simplicity, community, and modern tech stack. He also liked the fact that the company had Y Combinator seal of approval.
|
||||||
|
|
||||||
> *"The fact that Wasp is low-friction and uses a great stack like Prisma, React, Node.js, and TypeScript—made it stand out. Plus, the community is super helpful. You can get started fast without spending hours on setup."*
|
> _"The fact that Wasp is low-friction and uses a great stack like Prisma, React, Node.js, and TypeScript—made it stand out. Plus, the community is super helpful. You can get started fast without spending hours on setup."_
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<img src="https://media0.giphy.com/media/13zeE9qQNC5IKk/giphy.gif?cid=7941fdc69ba2psk3jei2jwzvohsqp0912ugkwzm2jugyrw7x&ep=v1_gifs_search&rid=giphy.gif&ct=g" alt="Excited reaction gif" />
|
<img
|
||||||
|
src="https://media0.giphy.com/media/13zeE9qQNC5IKk/giphy.gif?cid=7941fdc69ba2psk3jei2jwzvohsqp0912ugkwzm2jugyrw7x&ep=v1_gifs_search&rid=giphy.gif&ct=g"
|
||||||
|
alt="Excited reaction gif"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
What Ricardo loves most:
|
What Ricardo loves most:
|
||||||
|
|
||||||
- **Pre-built Features**: Open SaaS relies on Wasp - a full stack framework for React, Node.js and Prisma. The way Wasp handles routes and authentication was a game-changer.
|
- **Pre-built Features**: Open SaaS relies on Wasp - a full stack framework for React, Node.js and Prisma. The way Wasp handles routes and authentication was a game-changer.
|
||||||
|
|
||||||
> *"Just putting routes in `main.wasp` makes everything super simple. Auth works seamlessly, too."*
|
> _"Just putting routes in `main.wasp` makes everything super simple. Auth works seamlessly, too."_
|
||||||
|
|
||||||
- **Focus on Building**: By handling repetitive setup tasks like setting up payment integrations or making admin dashboards, Open SaaS allowed Ricardo to focus on core features.
|
- **Focus on Building**: By handling repetitive setup tasks like setting up payment integrations or making admin dashboards, Open SaaS allowed Ricardo to focus on core features.
|
||||||
- **Adaptability**: regardless of the idea he had - a full-fledged SaaS, or a Google add-on which needed a robust-backend and a dashboard, he was able to build the app with Open SaaS boilerplate starter.
|
- **Adaptability**: regardless of the idea he had - a full-fledged SaaS, or a Google add-on which needed a robust-backend and a dashboard, he was able to build the app with Open SaaS boilerplate starter.
|
||||||
|
|
||||||
> *"I didn't feel limited by the boilerplate—it's flexible and gets out of the way."*
|
> _"I didn't feel limited by the boilerplate—it's flexible and gets out of the way."_
|
||||||
|
|
||||||
## Ricardo's Projects Built with Wasp
|
## Ricardo's Projects Built with Wasp
|
||||||
|
|
||||||
@@ -50,8 +54,7 @@ Ricardo started a few projects with Wasp, while working on the third one he star
|
|||||||
- Built in less than 7 days.
|
- Built in less than 7 days.
|
||||||
- 40+ paying customers.
|
- 40+ paying customers.
|
||||||
|
|
||||||
[This tool](https://article-generation.com/) simplifies content creation for businesses by generating SEO-friendly blog posts with AI. Article Generator is competing in a crowded market of AI writing tools, where each tool claims that it's the best one on the market.
|
[This tool](https://article-generation.com/) simplifies content creation for businesses by generating SEO-friendly blog posts with AI. Article Generator is competing in a crowded market of AI writing tools, where each tool claims that it's the best one on the market.
|
||||||
|
|
||||||
|
|
||||||
<Image src={articleGeneration} alt="Article Generation" loading="lazy" />
|
<Image src={articleGeneration} alt="Article Generation" loading="lazy" />
|
||||||
|
|
||||||
@@ -85,38 +88,96 @@ He also relies on SEO, and guess what, he pushed a couple of blog posts with his
|
|||||||
### Tips for Builders Launching Products
|
### Tips for Builders Launching Products
|
||||||
|
|
||||||
1. **Validate Before You Build**
|
1. **Validate Before You Build**
|
||||||
|
|
||||||
> *"Start by searching Reddit or similar platforms to find out if people are already solving the problem. If they are, ask yourself: can I do it better or faster?"*
|
> _"Start by searching Reddit or similar platforms to find out if people are already solving the problem. If they are, ask yourself: can I do it better or faster?"_
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<img src="https://media3.giphy.com/media/iK45mgOPCt5MsqcKhq/giphy.gif?cid=7941fdc6b5cs6fcmgw1ki92h5ic0v6xxweb2yn58h6nkx1c1&ep=v1_gifs_search&rid=giphy.gif&ct=g" alt="Excited reaction gif" />
|
<img
|
||||||
|
src="https://media3.giphy.com/media/iK45mgOPCt5MsqcKhq/giphy.gif?cid=7941fdc6b5cs6fcmgw1ki92h5ic0v6xxweb2yn58h6nkx1c1&ep=v1_gifs_search&rid=giphy.gif&ct=g"
|
||||||
|
alt="Excited reaction gif"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
2. **Diversify Launch Strategies**
|
2. **Diversify Launch Strategies**
|
||||||
|
|
||||||
- Avoid relying solely on Product Hunt
|
- Avoid relying solely on Product Hunt
|
||||||
|
|
||||||
> *"It's not as effective as it used to be."*
|
> _"It's not as effective as it used to be."_
|
||||||
|
|
||||||
- Explore short-form content like TikTok for quick validation. You can create a company account and post videos that showcase the problem and the solution.
|
- Explore short-form content like TikTok for quick validation. You can create a company account and post videos that showcase the problem and the solution.
|
||||||
|
|
||||||
<blockquote class="tiktok-embed" cite="https://www.tiktok.com/@meetingreminders/video/7462839913231306016" data-video-id="7462839913231306016" data-embed-from="embed_page" style="max-width:605px; min-width:325px;"> <section> <a target="_blank" title="@meetingreminders" href="https://www.tiktok.com/@meetingreminders?refer=embed">@meetingreminders</a> <p>BOOOM!! no more waiting in meetings - it's called Meeting Reminders <a title="workmeeting" target="_blank" href="https://www.tiktok.com/tag/workmeeting?refer=embed">#workmeeting</a> <a title="corporate" target="_blank" href="https://www.tiktok.com/tag/corporate?refer=embed">#corporate</a> <a title="workfromhome" target="_blank" href="https://www.tiktok.com/tag/workfromhome?refer=embed">#workfromhome</a> <a title="googlemeet" target="_blank" href="https://www.tiktok.com/tag/googlemeet?refer=embed">#googlemeet</a></p> <a target="_blank" title="♬ original sound - Meeting Reminders" href="https://www.tiktok.com/music/original-sound-7462839967913134880?refer=embed">♬ original sound - Meeting Reminders</a> </section> </blockquote> <script async src="https://www.tiktok.com/embed.js"></script>
|
|
||||||
|
<blockquote
|
||||||
> *"Their algorithm is great for targeting the right audience."*
|
class="tiktok-embed"
|
||||||
|
cite="https://www.tiktok.com/@meetingreminders/video/7462839913231306016"
|
||||||
|
data-video-id="7462839913231306016"
|
||||||
|
data-embed-from="embed_page"
|
||||||
|
style="max-width:605px; min-width:325px;"
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
<section>
|
||||||
|
{" "}
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
title="@meetingreminders"
|
||||||
|
href="https://www.tiktok.com/@meetingreminders?refer=embed"
|
||||||
|
>
|
||||||
|
@meetingreminders
|
||||||
|
</a>{" "}
|
||||||
|
<p>
|
||||||
|
BOOOM!! no more waiting in meetings - it's called Meeting Reminders{" "}
|
||||||
|
<a
|
||||||
|
title="workmeeting"
|
||||||
|
target="_blank"
|
||||||
|
href="https://www.tiktok.com/tag/workmeeting?refer=embed"
|
||||||
|
>
|
||||||
|
#workmeeting
|
||||||
|
</a>{" "}
|
||||||
|
<a
|
||||||
|
title="corporate"
|
||||||
|
target="_blank"
|
||||||
|
href="https://www.tiktok.com/tag/corporate?refer=embed"
|
||||||
|
>
|
||||||
|
#corporate
|
||||||
|
</a>{" "}
|
||||||
|
<a
|
||||||
|
title="workfromhome"
|
||||||
|
target="_blank"
|
||||||
|
href="https://www.tiktok.com/tag/workfromhome?refer=embed"
|
||||||
|
>
|
||||||
|
#workfromhome
|
||||||
|
</a>{" "}
|
||||||
|
<a
|
||||||
|
title="googlemeet"
|
||||||
|
target="_blank"
|
||||||
|
href="https://www.tiktok.com/tag/googlemeet?refer=embed"
|
||||||
|
>
|
||||||
|
#googlemeet
|
||||||
|
</a>
|
||||||
|
</p>{" "}
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
title="♬ original sound - Meeting Reminders"
|
||||||
|
href="https://www.tiktok.com/music/original-sound-7462839967913134880?refer=embed"
|
||||||
|
>
|
||||||
|
♬ original sound - Meeting Reminders
|
||||||
|
</a>{" "}
|
||||||
|
</section>{" "}
|
||||||
|
</blockquote>
|
||||||
|
<script async src="https://www.tiktok.com/embed.js"></script>> *"Their algorithm
|
||||||
|
is great for targeting the right audience."*
|
||||||
|
|
||||||
- Use targeted Reddit ads to reach niche communities.
|
- Use targeted Reddit ads to reach niche communities.
|
||||||
|
|
||||||
3. **Start small**
|
3. **Start small**
|
||||||
|
|
||||||
> *"If you're entering a competitive space, start small. Validate your product's unique edge by solving specific pain points and adjust based on user feedback."*
|
> _"If you're entering a competitive space, start small. Validate your product's unique edge by solving specific pain points and adjust based on user feedback."_
|
||||||
|
|
||||||
4. **Iterate Quickly**
|
4. **Iterate Quickly**
|
||||||
|
|
||||||
> *"Launch fast, gather feedback, and refine your product. You don't need to build the perfect app on day one—get it out there, see how people use it, and adjust."*
|
> _"Launch fast, gather feedback, and refine your product. You don't need to build the perfect app on day one—get it out there, see how people use it, and adjust."_
|
||||||
|
|
||||||
|
|
||||||
### **Ready to Build Your SaaS?**
|
### **Ready to Build Your SaaS?**
|
||||||
|
|
||||||
Explore the [Open SaaS boilerplate](https://opensaas.sh/) to see how you can kickstart your SaaS today.
|
Explore the [Open SaaS boilerplate](https://opensaas.sh/) to see how you can kickstart your SaaS today.
|
||||||
|
|
||||||
<StarOpenSaaSCTA trailingText="and support tools that help you build fast!" />
|
<StarOpenSaaSCTA trailingText="and support tools that help you build fast!" />
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
title: "Incident Report: Security Vulnerability in Open SaaS \"updateCurrentUser\" function"
|
title: 'Incident Report: Security Vulnerability in Open SaaS "updateCurrentUser" function'
|
||||||
date: 2025-02-26
|
date: 2025-02-26
|
||||||
tags:
|
tags:
|
||||||
- incident-report
|
- incident-report
|
||||||
authors: martin
|
authors: martin
|
||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
|
import { Image } from "astro:assets";
|
||||||
|
|
||||||
On Feb 12th, 2025, we learned about a security vulnerability in the `updateCurrentUser` function of our Open SaaS template. Users of apps built with Open SaaS can exploit this vulnerability to edit any field in their own `User` database record, including fields they weren't supposed to have write permissions for, like `subscriptionPlan` and `isAdmin`.
|
On Feb 12th, 2025, we learned about a security vulnerability in the `updateCurrentUser` function of our Open SaaS template. Users of apps built with Open SaaS can exploit this vulnerability to edit any field in their own `User` database record, including fields they weren't supposed to have write permissions for, like `subscriptionPlan` and `isAdmin`.
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ On Feb 12th, 2025, we learned about a security vulnerability in the `updateCurre
|
|||||||
|
|
||||||
The vulnerability does not affect the "vanilla" Wasp apps (Wasp apps not built with Open SaaS template) or those that modified the problematic part of the code enough to eliminate the problem.
|
The vulnerability does not affect the "vanilla" Wasp apps (Wasp apps not built with Open SaaS template) or those that modified the problematic part of the code enough to eliminate the problem.
|
||||||
|
|
||||||
Since then we fixed the vulnerability in all the versions of the Open SaaS template, did a [coordinated vulnerability disclosure](https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure) (culminating with this report) with the suggested fix, reviewed all the other templates and example apps of ours for similar security vulnerabilities, analyzed what at the first place enabled such omission to happen on our side, and prepared a plan on how to minimize the chance of similar mistakes happening in the future.
|
Since then we fixed the vulnerability in all the versions of the Open SaaS template, did a [coordinated vulnerability disclosure](https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure) (culminating with this report) with the suggested fix, reviewed all the other templates and example apps of ours for similar security vulnerabilities, analyzed what at the first place enabled such omission to happen on our side, and prepared a plan on how to minimize the chance of similar mistakes happening in the future.
|
||||||
|
|
||||||
We sincerely apologize for the impact and inconvenience caused by our mistake. Caring about code quality is at the center of our culture here at Wasp, but in this instance, we failed to follow up on our standards. We are deeply disappointed by it and will ensure we learn from it, improve, and regain your trust in the code we ship, especially as Wasp is heading from Beta toward 1.0.
|
We sincerely apologize for the impact and inconvenience caused by our mistake. Caring about code quality is at the center of our culture here at Wasp, but in this instance, we failed to follow up on our standards. We are deeply disappointed by it and will ensure we learn from it, improve, and regain your trust in the code we ship, especially as Wasp is heading from Beta toward 1.0.
|
||||||
|
|
||||||
@@ -22,7 +23,10 @@ We sincerely apologize for the impact and inconvenience caused by our mistake. C
|
|||||||
The vulnerability is caused by the `updateCurrentUser` function in `src/user/operations.ts` (or in `src/server/actions.ts` if you used an older version of Open SaaS):
|
The vulnerability is caused by the `updateCurrentUser` function in `src/user/operations.ts` (or in `src/server/actions.ts` if you used an older version of Open SaaS):
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
export const updateCurrentUser: UpdateCurrentUser<Partial<User>, User> = async (user, context) => {
|
export const updateCurrentUser: UpdateCurrentUser<Partial<User>, User> = async (
|
||||||
|
user,
|
||||||
|
context,
|
||||||
|
) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new HttpError(401);
|
throw new HttpError(401);
|
||||||
}
|
}
|
||||||
@@ -31,7 +35,7 @@ export const updateCurrentUser: UpdateCurrentUser<Partial<User>, User> = async (
|
|||||||
where: {
|
where: {
|
||||||
id: context.user.id,
|
id: context.user.id,
|
||||||
},
|
},
|
||||||
data: user, // <- This is the problem!
|
data: user, // <- This is the problem!
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@@ -44,7 +48,7 @@ An example of how a bad actor could exploit this is by creating a user account i
|
|||||||
|
|
||||||
The fix consists of three main steps:
|
The fix consists of three main steps:
|
||||||
|
|
||||||
1. Refactor `updateCurrentUser` function to `updateCurrentUserLastActiveTimestamp`
|
1. Refactor `updateCurrentUser` function to `updateCurrentUserLastActiveTimestamp`
|
||||||
2. Implement additional Wasp Action(s) for updating user data if needed
|
2. Implement additional Wasp Action(s) for updating user data if needed
|
||||||
3. Refactor `updateUserById` function to `updateUserIsAdminById`
|
3. Refactor `updateUserById` function to `updateUserIsAdminById`
|
||||||
|
|
||||||
@@ -54,34 +58,36 @@ In the Open SaaS template, as it comes when you create a new project with it, th
|
|||||||
|
|
||||||
1. Rename the operation `updateCurrentUser` to `updateCurrentUserLastActiveTimestamp`. Make sure to update its name in all the places: `main.wasp`, client code (i.e. `src/client/App.tsx`), server code (i.e. `src/user/operations.ts`).
|
1. Rename the operation `updateCurrentUser` to `updateCurrentUserLastActiveTimestamp`. Make sure to update its name in all the places: `main.wasp`, client code (i.e. `src/client/App.tsx`), server code (i.e. `src/user/operations.ts`).
|
||||||
2. Rewrite the operation `updateCurrentUserLastActiveTimestamp` in `src/user/operations.ts` so it receives no arguments and only updates the `lastActiveTimestamp` field on the `User`:
|
2. Rewrite the operation `updateCurrentUserLastActiveTimestamp` in `src/user/operations.ts` so it receives no arguments and only updates the `lastActiveTimestamp` field on the `User`:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
export const updateCurrentUserLastActiveTimestamp: UpdateCurrentUserLastActiveTimestamp<void, User> = async (_args, context) => {
|
export const updateCurrentUserLastActiveTimestamp: UpdateCurrentUserLastActiveTimestamp<
|
||||||
if (!context.user) {
|
void,
|
||||||
throw new HttpError(401);
|
User
|
||||||
}
|
> = async (_args, context) => {
|
||||||
|
if (!context.user) {
|
||||||
return context.entities.User.update({
|
throw new HttpError(401);
|
||||||
where: {
|
}
|
||||||
id: context.user.id,
|
|
||||||
},
|
return context.entities.User.update({
|
||||||
data: {
|
where: {
|
||||||
lastActiveTimestamp: new Date(),
|
id: context.user.id,
|
||||||
},
|
},
|
||||||
});
|
data: {
|
||||||
};
|
lastActiveTimestamp: new Date(),
|
||||||
```
|
},
|
||||||
|
});
|
||||||
Notice that also the name of the type of the operation changed, so you will want to update the type import, and we also changed the operation's Input type to `void`.
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Notice that also the name of the type of the operation changed, so you will want to update the type import, and we also changed the operation's Input type to `void`.
|
||||||
|
|
||||||
3. Remove all arguments from the call to `updateCurrentUserLastActiveTimestamp` in `src/client/App.tsx:`
|
3. Remove all arguments from the call to `updateCurrentUserLastActiveTimestamp` in `src/client/App.tsx:`
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
if (today.getTime() - lastSeenAt.getTime() > 5 * 60 * 1000) {
|
if (today.getTime() - lastSeenAt.getTime() > 5 * 60 * 1000) {
|
||||||
updateCurrentUserLastActiveTimestamp(); // <- no args anymore
|
updateCurrentUserLastActiveTimestamp(); // <- no args anymore
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Implement additional Wasp Action(s) for updating user data if needed
|
### Implement additional Wasp Action(s) for updating user data if needed
|
||||||
|
|
||||||
@@ -90,20 +96,23 @@ If you were using `updateCurrentUser` in your code beyond just updating `lastAct
|
|||||||
For example, let's say that in your app you additionally defined `fullName` and `address` fields on the `User` model, and you were using `updateCurrentUser` to allow the user to update those. In that case, we recommend defining an additional Wasp Action called `updateCurrentUserPersonalData`. It could look something like this:
|
For example, let's say that in your app you additionally defined `fullName` and `address` fields on the `User` model, and you were using `updateCurrentUser` to allow the user to update those. In that case, we recommend defining an additional Wasp Action called `updateCurrentUserPersonalData`. It could look something like this:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
export const updateCurrentUserPersonalData: UpdateCurrentUserPersonalData<Pick<User, "fullName" | "address">, User> = async (personalData, context) => {
|
export const updateCurrentUserPersonalData: UpdateCurrentUserPersonalData<
|
||||||
|
Pick<User, "fullName" | "address">,
|
||||||
|
User
|
||||||
|
> = async (personalData, context) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new HttpError(401);
|
throw new HttpError(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: This is also a good place to do data validation if you want to.
|
// NOTE: This is also a good place to do data validation if you want to.
|
||||||
const fullName = personalData.fullName
|
const fullName = personalData.fullName;
|
||||||
const address = personalData.address
|
const address = personalData.address;
|
||||||
|
|
||||||
return context.entities.User.update({
|
return context.entities.User.update({
|
||||||
where: {
|
where: {
|
||||||
id: context.user.id,
|
id: context.user.id,
|
||||||
},
|
},
|
||||||
data: { fullName, address }
|
data: { fullName, address },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@@ -116,7 +125,10 @@ Finally, while not a security vulnerability, we also recommend updating the rela
|
|||||||
2. Rewrite `updateUserIsAdminById` to only allow setting the `isAdmin` field:
|
2. Rewrite `updateUserIsAdminById` to only allow setting the `isAdmin` field:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
export const updateUserIsAdminById: UpdateUserIsAdminById<{ id: User['id'], isAdmin: User['isAdmin'] }, User> = async ({ id, isAdmin }, context) => {
|
export const updateUserIsAdminById: UpdateUserIsAdminById<
|
||||||
|
{ id: User["id"]; isAdmin: User["isAdmin"] },
|
||||||
|
User
|
||||||
|
> = async ({ id, isAdmin }, context) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new HttpError(401);
|
throw new HttpError(401);
|
||||||
}
|
}
|
||||||
@@ -142,7 +154,6 @@ Notice that we modified the shape of the operation input (now it is `{ id, isAdm
|
|||||||
|
|
||||||
This second part of the report is not required reading: all you need to know in order to fix the vulnerability is in the "[The vulnerability](#the-vulnerability)" and the "[The fix](#the-fix)" sections. But, if you want to learn more about what caused the vulnerability, how we handled it, and what are we doing to prevent similar mistakes from happening in the future, read on!
|
This second part of the report is not required reading: all you need to know in order to fix the vulnerability is in the "[The vulnerability](#the-vulnerability)" and the "[The fix](#the-fix)" sections. But, if you want to learn more about what caused the vulnerability, how we handled it, and what are we doing to prevent similar mistakes from happening in the future, read on!
|
||||||
|
|
||||||
|
|
||||||
## Coordinated vulnerability disclosure
|
## Coordinated vulnerability disclosure
|
||||||
|
|
||||||
The challenging part about handling a security vulnerability like this one is that we have to make the knowledge of it public so that all the people with affected apps learn about it and how to fix it, but then at the same time we are also making that knowledge easily available to any bad actors that might want to try to exploit it.
|
The challenging part about handling a security vulnerability like this one is that we have to make the knowledge of it public so that all the people with affected apps learn about it and how to fix it, but then at the same time we are also making that knowledge easily available to any bad actors that might want to try to exploit it.
|
||||||
@@ -174,12 +185,12 @@ The fact is, we never should have made Open SaaS publicly available without doin
|
|||||||
## What we are doing to prevent similar mistakes
|
## What we are doing to prevent similar mistakes
|
||||||
|
|
||||||
- **No code/documentation goes public without a thorough review.**
|
- **No code/documentation goes public without a thorough review.**
|
||||||
We have been doing this from the very start for the Wasp framework codebase, but we were more lenient with the templates and example apps. From now on, there will be no exceptions.
|
We have been doing this from the very start for the Wasp framework codebase, but we were more lenient with the templates and example apps. From now on, there will be no exceptions.
|
||||||
- **We checked all our existing templates and example apps for vulnerabilities.**
|
- **We checked all our existing templates and example apps for vulnerabilities.**
|
||||||
- **We have done a thorough review of the Open SaaS template codebase.**
|
- **We have done a thorough review of the Open SaaS template codebase.**
|
||||||
We have already merged a lot of code quality improvements based on it, and we are in the process of merging the rest.
|
We have already merged a lot of code quality improvements based on it, and we are in the process of merging the rest.
|
||||||
- **We will make it harder at the Wasp framework level to make a similar mistake.**
|
- **We will make it harder at the Wasp framework level to make a similar mistake.**
|
||||||
The mistake of passing unvalidated/unparsed data is too easy to make - we will, latest for Wasp 1.0, enforce [runtime data validation in Wasp](https://github.com/wasp-lang/wasp/issues/1241), for Operations, APIs, and other externally facing methods. We also have good ideas for advanced access control support in Wasp, which should further make it harder to make these kinds of mistakes.
|
The mistake of passing unvalidated/unparsed data is too easy to make - we will, latest for Wasp 1.0, enforce [runtime data validation in Wasp](https://github.com/wasp-lang/wasp/issues/1241), for Operations, APIs, and other externally facing methods. We also have good ideas for advanced access control support in Wasp, which should further make it harder to make these kinds of mistakes.
|
||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
@@ -187,24 +198,24 @@ What follows is the timeline of the actions we have taken since we learned about
|
|||||||
|
|
||||||
- Feb 12th, 2025 (Wed), 10 pm CET: we learned about the vulnerability (thanks [Ivan Vlahov](https://github.com/vlahovivan)!)
|
- Feb 12th, 2025 (Wed), 10 pm CET: we learned about the vulnerability (thanks [Ivan Vlahov](https://github.com/vlahovivan)!)
|
||||||
- Feb 13th (Thu):
|
- Feb 13th (Thu):
|
||||||
- Made an action plan on how to handle the incident, including how we will execute the coordinated disclosure.
|
- Made an action plan on how to handle the incident, including how we will execute the coordinated disclosure.
|
||||||
- Fixed all the versions of the Open SaaS template, to prevent new projects from being affected.
|
- Fixed all the versions of the Open SaaS template, to prevent new projects from being affected.
|
||||||
- Feb 14th (Fri):
|
- Feb 14th (Fri):
|
||||||
- Wrote the "Incident Notification" document with a detailed explanation of the problem and the suggested fix.
|
- Wrote the "Incident Notification" document with a detailed explanation of the problem and the suggested fix.
|
||||||
- Compiled a list of the people we know are deploying Open SaaS / Wasp apps and privately shared the "Incident Notification" document with them, giving them ~ a week of head start before we go more public with the incident.
|
- Compiled a list of the people we know are deploying Open SaaS / Wasp apps and privately shared the "Incident Notification" document with them, giving them ~ a week of head start before we go more public with the incident.
|
||||||
- Reviewed all the other Wasp templates and example apps for similar security issues.
|
- Reviewed all the other Wasp templates and example apps for similar security issues.
|
||||||
- Started a deep (re)review of all the Open SaaS code (that will continue into the next week).
|
- Started a deep (re)review of all the Open SaaS code (that will continue into the next week).
|
||||||
- Feb 17th (Mon):
|
- Feb 17th (Mon):
|
||||||
- Continued deep review of Open SaaS code.
|
- Continued deep review of Open SaaS code.
|
||||||
- Feb 18th (Tue):
|
- Feb 18th (Tue):
|
||||||
- Continued deep review of Open SaaS code.
|
- Continued deep review of Open SaaS code.
|
||||||
- Finalized first draft of this Incident Report document.
|
- Finalized first draft of this Incident Report document.
|
||||||
- Feb 19th (Wed):
|
- Feb 19th (Wed):
|
||||||
- Continued deep review of Open SaaS code.
|
- Continued deep review of Open SaaS code.
|
||||||
- Feb 20th (Thu):
|
- Feb 20th (Thu):
|
||||||
- Continued deep review of Open SaaS code.
|
- Continued deep review of Open SaaS code.
|
||||||
- Notified our Discord community about the incident by sharing the "Incident Notification" document with them, giving them a week of head start before we go fully public with the incident.
|
- Notified our Discord community about the incident by sharing the "Incident Notification" document with them, giving them a week of head start before we go fully public with the incident.
|
||||||
- Feb 21st (Fri):
|
- Feb 21st (Fri):
|
||||||
- Finalized the deep review of the Open SaaS code (while continuing with the code improvements).
|
- Finalized the deep review of the Open SaaS code (while continuing with the code improvements).
|
||||||
- Feb 26th (Wed):
|
- Feb 26th (Wed):
|
||||||
- Went public with the incident by publishing and sharing this Incident Report.
|
- Went public with the incident by publishing and sharing this Incident Report.
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ tags:
|
|||||||
- saas
|
- saas
|
||||||
authors: milica
|
authors: milica
|
||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
|
import { Image } from "astro:assets";
|
||||||
import plausibleCommunity from '../../../assets/plausible/plausible-community.png';
|
import StarOpenSaaSCTA from "../../../components/StarOpenSaaSCTA.astro";
|
||||||
|
import plausibleCommunity from "../../../assets/plausible/plausible-community.png";
|
||||||
|
|
||||||
In this interview, [Marko Saric](https://x.com/markosaric) shared his thoughts on privacy and running a bootstrapped SaaS business. [Plausible](https://plausible.io/) integration is already [available in Open SaaS](https://docs.opensaas.sh/guides/analytics/#plausible) as a privacy-friendly alternative to Google Analytics. We hope this interview helps you understand the value of such a product, and the nature of running an open source business.
|
In this interview, [Marko Saric](https://x.com/markosaric) shared his thoughts on privacy and running a bootstrapped SaaS business. [Plausible](https://plausible.io/) integration is already [available in Open SaaS](https://docs.opensaas.sh/guides/analytics/#plausible) as a privacy-friendly alternative to Google Analytics. We hope this interview helps you understand the value of such a product, and the nature of running an open source business.
|
||||||
|
|
||||||
@@ -36,14 +37,28 @@ We have been working on Plausible for more than 6 years now, have more than 14,0
|
|||||||
|
|
||||||
Here's an interactive demo of Plausible Analytics:
|
Here's an interactive demo of Plausible Analytics:
|
||||||
|
|
||||||
<div style={{ position: 'relative', paddingBottom: 'calc(53.11430527036276% + 41px)', height: 0, width: '100%' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
paddingBottom: "calc(53.11430527036276% + 41px)",
|
||||||
|
height: 0,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<iframe
|
<iframe
|
||||||
src="https://demo.arcade.software/4kph6Di7Pv5wlVhhsmEw?embed"
|
src="https://demo.arcade.software/4kph6Di7Pv5wlVhhsmEw?embed"
|
||||||
title="Plausible Analytics: Live Demo"
|
title="Plausible Analytics: Live Demo"
|
||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', colorScheme: 'light' }}
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
colorScheme: "light",
|
||||||
|
}}
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,7 +70,7 @@ Both of us have worked at venture funded startups in the past and neither of us
|
|||||||
|
|
||||||
We're in the privacy niche so open sourcing our product allows us to build trust as people can inspect our code to verify that our actions match our words. People cannot do that with Google Analytics and other competing products.
|
We're in the privacy niche so open sourcing our product allows us to build trust as people can inspect our code to verify that our actions match our words. People cannot do that with Google Analytics and other competing products.
|
||||||
|
|
||||||
>Just like Plausible, Wasp is an open-source project too! We'd appreciate it if you could [star Wasp on GitHub as a sign of support](https://github.com/wasp-lang/wasp)! ⭐️
|
> Just like Plausible, Wasp is an open-source project too! We'd appreciate it if you could [star Wasp on GitHub as a sign of support](https://github.com/wasp-lang/wasp)! ⭐️
|
||||||
|
|
||||||
## Do you have any advice for people who are considering bootstrapping their company? Do you have any books or podcasts to recommend?
|
## Do you have any advice for people who are considering bootstrapping their company? Do you have any books or podcasts to recommend?
|
||||||
|
|
||||||
@@ -71,15 +86,15 @@ Our first customers came through community engagement and the "build in public"
|
|||||||
|
|
||||||
## What were the biggest challenges you faced while building and growing Plausible?
|
## What were the biggest challenges you faced while building and growing Plausible?
|
||||||
|
|
||||||
The first year was pretty challenging in terms of growth. Uku was working alone on Plausible trying to do both development and marketing. This is pretty much an impossible task. The growth was very slow and we made it to about 100 subscribers and $400 MRR some 14 months into the existence of Plausible.
|
The first year was pretty challenging in terms of growth. Uku was working alone on Plausible trying to do both development and marketing. This is pretty much an impossible task. The growth was very slow and we made it to about 100 subscribers and $400 MRR some 14 months into the existence of Plausible.
|
||||||
|
|
||||||
That's when Uku decided to look for a marketing co-founder and that's how we found me. Being two co-founders helped us put more time and effort into marketing and communication. One of the first things we did when I joined was to change our positioning to make it crystal clear and easy to understand what we do, what we stand for and how we compare to Google Analytics (the biggest name in our market). And then we started publishing educational and informative content covering topics such as privacy, open source, bootstrapping and startup marketing .
|
That's when Uku decided to look for a marketing co-founder and that's how we found me. Being two co-founders helped us put more time and effort into marketing and communication. One of the first things we did when I joined was to change our positioning to make it crystal clear and easy to understand what we do, what we stand for and how we compare to Google Analytics (the biggest name in our market). And then we started publishing educational and informative content covering topics such as privacy, open source, bootstrapping and startup marketing .
|
||||||
|
|
||||||
I have written more about the changes we made in these early days [in this post](https://plausible.io/blog/blog-post-changed-my-startup).
|
I have written more about the changes we made in these early days [in this post](https://plausible.io/blog/blog-post-changed-my-startup).
|
||||||
|
|
||||||
## Which growth strategies have been the most effective?
|
## Which growth strategies have been the most effective?
|
||||||
|
|
||||||
We have a boring marketing strategy and we say no to all the growth hacks and other best marketing practices. Content marketing has been our most effective growth strategy. As an example, the first blog post that I published (Why You Should Remove Google Analytics from Your Site) went viral on Hacker News. It drove some good traffic to our site leading to an increase in brand awareness.
|
We have a boring marketing strategy and we say no to all the growth hacks and other best marketing practices. Content marketing has been our most effective growth strategy. As an example, the first blog post that I published (Why You Should Remove Google Analytics from Your Site) went viral on Hacker News. It drove some good traffic to our site leading to an increase in brand awareness.
|
||||||
|
|
||||||
What matters is doing quality work and staying consistent with it over a longer period of time so we continued to publish multiple blog posts per week for over a year. Thanks to that work, we've been fortunate enough to achieve the viral moments on Hacker News multiple times over those first 2-3 years.
|
What matters is doing quality work and staying consistent with it over a longer period of time so we continued to publish multiple blog posts per week for over a year. Thanks to that work, we've been fortunate enough to achieve the viral moments on Hacker News multiple times over those first 2-3 years.
|
||||||
|
|
||||||
@@ -89,9 +104,9 @@ I have shared more about our early years, marketing steps we've taken, lessons w
|
|||||||
|
|
||||||
The community has helped shape our product and spread the word about our mission.
|
The community has helped shape our product and spread the word about our mission.
|
||||||
|
|
||||||
We have an open roadmap and listen to the product feedback which determines our development prioritization. This is where feature requests and other feedback is very valuable to us. We pretty much pick the most upvoted feature and work on that.
|
We have an open roadmap and listen to the product feedback which determines our development prioritization. This is where feature requests and other feedback is very valuable to us. We pretty much pick the most upvoted feature and work on that.
|
||||||
|
|
||||||
As mentioned earlier, we don't do any traditional marketing as in we don't do any paid advertising nor pay anyone to recommend Plausible. This means that most of our growth comes from people who love using Plausible and who share their experiences with the world. Without people spreading the word about Plausible it would be difficult for us to do what we do. So that's why community contributions is vital for us.
|
As mentioned earlier, we don't do any traditional marketing as in we don't do any paid advertising nor pay anyone to recommend Plausible. This means that most of our growth comes from people who love using Plausible and who share their experiences with the world. Without people spreading the word about Plausible it would be difficult for us to do what we do. So that's why community contributions is vital for us.
|
||||||
|
|
||||||
<Image src={plausibleCommunity} alt="Plausible Community" loading="lazy" />
|
<Image src={plausibleCommunity} alt="Plausible Community" loading="lazy" />
|
||||||
|
|
||||||
@@ -103,4 +118,4 @@ Our developers are currently working on the top two most upvoted feature request
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Just like Plausible, Wasp is an open-source project too! We'd appreciate it if you could [star Wasp on GitHub as a sign of support](https://github.com/wasp-lang/wasp)! ⭐️
|
Just like Plausible, Wasp is an open-source project too! We'd appreciate it if you could [star Wasp on GitHub as a sign of support](https://github.com/wasp-lang/wasp)! ⭐️
|
||||||
|
|||||||
@@ -8,15 +8,16 @@ tags:
|
|||||||
- indiehackers
|
- indiehackers
|
||||||
authors: milica
|
authors: milica
|
||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
|
import { Image } from "astro:assets";
|
||||||
import plausibleCommunity from '../../../assets/plausible/plausible-community.png';
|
import StarOpenSaaSCTA from "../../../components/StarOpenSaaSCTA.astro";
|
||||||
import interfaceImg from '../../../assets/promptpanda/interface.png';
|
import plausibleCommunity from "../../../assets/plausible/plausible-community.png";
|
||||||
import meme1 from '../../../assets/promptpanda/meme1.jpg';
|
import interfaceImg from "../../../assets/promptpanda/interface.png";
|
||||||
import meme2 from '../../../assets/promptpanda/meme2.jpg';
|
import meme1 from "../../../assets/promptpanda/meme1.jpg";
|
||||||
import ph1 from '../../../assets/promptpanda/ph1.png';
|
import meme2 from "../../../assets/promptpanda/meme2.jpg";
|
||||||
import ph2 from '../../../assets/promptpanda/ph2.png';
|
import ph1 from "../../../assets/promptpanda/ph1.png";
|
||||||
import listImg from '../../../assets/promptpanda/list.png';
|
import ph2 from "../../../assets/promptpanda/ph2.png";
|
||||||
|
import listImg from "../../../assets/promptpanda/list.png";
|
||||||
|
|
||||||
Did you know that most co-founders meet each other through work? **[Lander Willem](https://x.com/WWWillems)** met his friend and co-founder **[Bram Billiet](https://x.com/brambilicious)** while they were working at the local venture fund. They both shared the love towards LLMs and got the idea to kickstart their SaaS after experiencing the same pain points with managing and versioning prompts.
|
Did you know that most co-founders meet each other through work? **[Lander Willem](https://x.com/WWWillems)** met his friend and co-founder **[Bram Billiet](https://x.com/brambilicious)** while they were working at the local venture fund. They both shared the love towards LLMs and got the idea to kickstart their SaaS after experiencing the same pain points with managing and versioning prompts.
|
||||||
|
|
||||||
@@ -28,21 +29,34 @@ In this post, you'll learn how they:
|
|||||||
|
|
||||||
## The problem: Managing prompts is messy
|
## The problem: Managing prompts is messy
|
||||||
|
|
||||||
Right after OpenAI released their first LLM models, Lander and Bram started exchanging tips on how to get optimal results from prompts. Soon, they learned that managing AI prompts is often chaotic.
|
Right after OpenAI released their first LLM models, Lander and Bram started exchanging tips on how to get optimal results from prompts. Soon, they learned that managing AI prompts is often chaotic.
|
||||||
|
|
||||||
People who share prompts usually do so through messaging apps such as Slack, Microsoft Teams or in better cases, shared Google Docs documents. Some of the people they talked to even confessed they were sharing their favorite prompts using screenshots 😅. Although a Google Doc might work initially, people quickly bump into issues regarding versioning and granular access management.
|
People who share prompts usually do so through messaging apps such as Slack, Microsoft Teams or in better cases, shared Google Docs documents. Some of the people they talked to even confessed they were sharing their favorite prompts using screenshots 😅. Although a Google Doc might work initially, people quickly bump into issues regarding versioning and granular access management.
|
||||||
|
|
||||||
This is how they got the idea to create [PromptPanda](https://www.promptpanda.io/) - a SaaS that allows people to exchange prompts in an easy way. Here's an interactive demo you can click through to see what they've built:
|
This is how they got the idea to create [PromptPanda](https://www.promptpanda.io/) - a SaaS that allows people to exchange prompts in an easy way. Here's an interactive demo you can click through to see what they've built:
|
||||||
|
|
||||||
|
<div
|
||||||
<div style={{ position: 'relative', paddingBottom: 'calc(53.11430527036276% + 41px)', height: 0, width: '100%' }}>
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
paddingBottom: "calc(53.11430527036276% + 41px)",
|
||||||
|
height: 0,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<iframe
|
<iframe
|
||||||
src="https://demo.arcade.software/JiVvKE3oDWzbar0DKUDX?embed"
|
src="https://demo.arcade.software/JiVvKE3oDWzbar0DKUDX?embed"
|
||||||
title="PromptPanda: Live Demo"
|
title="PromptPanda: Live Demo"
|
||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', colorScheme: 'light' }}
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
colorScheme: "light",
|
||||||
|
}}
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,8 +64,8 @@ This is how they got the idea to create [PromptPanda](https://www.promptpanda.io
|
|||||||
|
|
||||||
Other AI prompt tools are primarily designed with developers in mind, which leaves out non-technical teams. Those less technical users depend heavily on collaboration, efficiency, and consistency to complete their tasks. This is the market PromptPanda decided to go after.
|
Other AI prompt tools are primarily designed with developers in mind, which leaves out non-technical teams. Those less technical users depend heavily on collaboration, efficiency, and consistency to complete their tasks. This is the market PromptPanda decided to go after.
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={meme1} alt="Make apps for everyone" loading="lazy" />
|
<Image src={meme1} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
The tool is designed specifically to help teams centralize their prompts and ensures consistent output quality. Collaboration is painless because of an intuitive web app that also has [a Chrome extension](https://chromewebstore.google.com/detail/promptpanda/hpgfoodclhmbloolkenjjofklhalfblc).
|
The tool is designed specifically to help teams centralize their prompts and ensures consistent output quality. Collaboration is painless because of an intuitive web app that also has [a Chrome extension](https://chromewebstore.google.com/detail/promptpanda/hpgfoodclhmbloolkenjjofklhalfblc).
|
||||||
@@ -59,7 +73,7 @@ The tool is designed specifically to help teams centralize their prompts and ens
|
|||||||
PromptPanda integrates with major AI providers such as OpenAI, Anthropic, Google, Perplexity, and DeepSeek. Coupled with its built-in Prompt Improver, these integrations allow users to quickly test, iterate, and enhance their prompts, while not imposing any limitations for the end-users.
|
PromptPanda integrates with major AI providers such as OpenAI, Anthropic, Google, Perplexity, and DeepSeek. Coupled with its built-in Prompt Improver, these integrations allow users to quickly test, iterate, and enhance their prompts, while not imposing any limitations for the end-users.
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={interfaceImg} alt="PromptPanda interface" loading="lazy" />
|
<Image src={interfaceImg} alt="PromptPanda interface" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
With this approach they covered a market that other companies overlooked, non-technical users who rely on the biggest LLM providers for their daily tasks.
|
With this approach they covered a market that other companies overlooked, non-technical users who rely on the biggest LLM providers for their daily tasks.
|
||||||
@@ -71,27 +85,27 @@ With this approach they covered a market that other companies overlooked, non-te
|
|||||||
As soon as the app was somewhat stable and usable, Lander and Bram decided to launch on ProductHunt.
|
As soon as the app was somewhat stable and usable, Lander and Bram decided to launch on ProductHunt.
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph1} alt="PromptPanda on Product Hunt" loading="lazy" />
|
<Image src={ph1} alt="PromptPanda on Product Hunt" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
[Their first ProductHunt launch](https://www.producthunt.com/products/promptpanda#promptpanda) was great in terms of visibility. They were featured by the ProductHunt team which got them a bunch of upvotes and comments. **Although there was quite a lot of engagement with the launch, it didn't really end up in sticky, paying customers.**
|
[Their first ProductHunt launch](https://www.producthunt.com/products/promptpanda#promptpanda) was great in terms of visibility. They were featured by the ProductHunt team which got them a bunch of upvotes and comments. **Although there was quite a lot of engagement with the launch, it didn't really end up in sticky, paying customers.**
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph2} alt="PromptPanda on Product Hunt" loading="lazy" />
|
<Image src={ph2} alt="PromptPanda on Product Hunt" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
A short while later they relaunched on ProductHunt after processing the feedback from their first launch. Both their product and launch campaign were much better prepared. Weirdly enough, the launch mostly failed as they got almost no upvotes or conversions.
|
A short while later they relaunched on ProductHunt after processing the feedback from their first launch. Both their product and launch campaign were much better prepared. Weirdly enough, the launch mostly failed as they got almost no upvotes or conversions.
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={meme2} alt="Trying again" loading="lazy" />
|
<Image src={meme2} alt="Trying again" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Although their second launch was mostly a flop, it did manage to get them mentioned in a Superhuman (the email app) newsletter. Their user base doubled overnight.**
|
**Although their second launch was mostly a flop, it did manage to get them mentioned in a Superhuman (the email app) newsletter. Their user base doubled overnight.**
|
||||||
|
|
||||||
Ever since then they have an active stream of users and new signups coming in.
|
Ever since then they have an active stream of users and new signups coming in.
|
||||||
|
|
||||||
> ”My main takeaway is to never stop shipping, and always share your work!”
|
> ”My main takeaway is to never stop shipping, and always share your work!”
|
||||||
>
|
>
|
||||||
> Lander Willem
|
> Lander Willem
|
||||||
|
|
||||||
Most of their users today have found PromptPanda through organic SEO. They started writing articles about [AI Prompt Management](https://www.promptpanda.io/ai-prompt-management/) which have quickly found traction in search engine algorithms.
|
Most of their users today have found PromptPanda through organic SEO. They started writing articles about [AI Prompt Management](https://www.promptpanda.io/ai-prompt-management/) which have quickly found traction in search engine algorithms.
|
||||||
@@ -103,11 +117,11 @@ PromptPanda's team chose [Open SaaS](https://opensaas.sh/) because it significan
|
|||||||
Here's a full overview of their tech stack alongside all the tools they rely on to run their SaaS:
|
Here's a full overview of their tech stack alongside all the tools they rely on to run their SaaS:
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={listImg} alt="PromptPanda tech stack" loading="lazy" />
|
<Image src={listImg} alt="PromptPanda tech stack" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Are you ready to ship your SaaS now?
|
## Are you ready to ship your SaaS now?
|
||||||
|
|
||||||
PromptPanda's story proves the best SaaS ideas come from solving your own pain points. Lander and Bram also learned launching isn't predictable—success can come from unexpected places, even failed launches. The takeaway? Keep building, keep shipping, and always share your progress openly.
|
PromptPanda's story proves the best SaaS ideas come from solving your own pain points. Lander and Bram also learned launching isn't predictable—success can come from unexpected places, even failed launches. The takeaway? Keep building, keep shipping, and always share your progress openly.
|
||||||
|
|
||||||
If you enjoyed this post please make sure to [give Open SaaS a star on GitHub](https://github.com/wasp-lang/open-saas), this keeps us going forward and supports our work!
|
If you enjoyed this post please make sure to [give Open SaaS a star on GitHub](https://github.com/wasp-lang/open-saas), this keeps us going forward and supports our work!
|
||||||
|
|||||||
@@ -2,34 +2,35 @@
|
|||||||
title: "Product Hunt doesn't really work, but you should still use it to launch your product"
|
title: "Product Hunt doesn't really work, but you should still use it to launch your product"
|
||||||
date: 2025-05-07
|
date: 2025-05-07
|
||||||
tags:
|
tags:
|
||||||
- saas
|
- saas
|
||||||
- webdev
|
- webdev
|
||||||
- sideproject
|
- sideproject
|
||||||
- indiehackers
|
- indiehackers
|
||||||
authors: matija
|
authors: matija
|
||||||
---
|
---
|
||||||
import VideoPlayer from '../../../components/VideoPlayer.astro';
|
|
||||||
import { Image } from 'astro:assets';
|
import VideoPlayer from "../../../components/VideoPlayer.astro";
|
||||||
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
|
import { Image } from "astro:assets";
|
||||||
import ph1 from '../../../assets/ph/1.png';
|
import StarOpenSaaSCTA from "../../../components/StarOpenSaaSCTA.astro";
|
||||||
import ph2 from '../../../assets/ph/2.png';
|
import ph1 from "../../../assets/ph/1.png";
|
||||||
import ph3 from '../../../assets/ph/3.png';
|
import ph2 from "../../../assets/ph/2.png";
|
||||||
import ph4 from '../../../assets/ph/4.png';
|
import ph3 from "../../../assets/ph/3.png";
|
||||||
import ph5 from '../../../assets/ph/5.png';
|
import ph4 from "../../../assets/ph/4.png";
|
||||||
import ph6 from '../../../assets/ph/6.gif';
|
import ph5 from "../../../assets/ph/5.png";
|
||||||
import ph7 from '../../../assets/ph/7.png';
|
import ph6 from "../../../assets/ph/6.gif";
|
||||||
import ph8 from '../../../assets/ph/8.png';
|
import ph7 from "../../../assets/ph/7.png";
|
||||||
import ph9 from '../../../assets/ph/9.png';
|
import ph8 from "../../../assets/ph/8.png";
|
||||||
import ph10 from '../../../assets/ph/10.png';
|
import ph9 from "../../../assets/ph/9.png";
|
||||||
import ph11 from '../../../assets/ph/11.png';
|
import ph10 from "../../../assets/ph/10.png";
|
||||||
import ph12 from '../../../assets/ph/12.png';
|
import ph11 from "../../../assets/ph/11.png";
|
||||||
import ph13 from '../../../assets/ph/13.png';
|
import ph12 from "../../../assets/ph/12.png";
|
||||||
import ph14 from '../../../assets/ph/14.png';
|
import ph13 from "../../../assets/ph/13.png";
|
||||||
import ph15 from '../../../assets/ph/15.png';
|
import ph14 from "../../../assets/ph/14.png";
|
||||||
import ph16 from '../../../assets/ph/16.png';
|
import ph15 from "../../../assets/ph/15.png";
|
||||||
import ph17 from '../../../assets/ph/17.png';
|
import ph16 from "../../../assets/ph/16.png";
|
||||||
import ph18 from '../../../assets/ph/18.png';
|
import ph17 from "../../../assets/ph/17.png";
|
||||||
import ph19 from '../../../assets/ph/19.png';
|
import ph18 from "../../../assets/ph/18.png";
|
||||||
|
import ph19 from "../../../assets/ph/19.png";
|
||||||
|
|
||||||
Many of us have been launching on Product Hunt for a while, and **more and more folks have started questioning whether the audience there is genuine and whether it is still worth launching on their platform**.
|
Many of us have been launching on Product Hunt for a while, and **more and more folks have started questioning whether the audience there is genuine and whether it is still worth launching on their platform**.
|
||||||
|
|
||||||
@@ -43,8 +44,8 @@ Being fresh out of our latest launch from a week ago, I wanted to share here our
|
|||||||
|
|
||||||
We've launched 6 times on Product Hunt in the last 4 years, won "Top Product" awards (#1 and #5 of the day), and collected over 2,000 upvotes in total. Our last launch was with [Open SaaS - an open-source alternative to $300+ SaaS starters](https://github.com/wasp-lang/open-saas/).
|
We've launched 6 times on Product Hunt in the last 4 years, won "Top Product" awards (#1 and #5 of the day), and collected over 2,000 upvotes in total. Our last launch was with [Open SaaS - an open-source alternative to $300+ SaaS starters](https://github.com/wasp-lang/open-saas/).
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph1} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph1} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
You will find many articles with advice for launching on PH, and winning stories from those who got featured, but almost nobody shares behind-the-scenes knowledge and what it really took to get there. That is the purpose of this post.
|
You will find many articles with advice for launching on PH, and winning stories from those who got featured, but almost nobody shares behind-the-scenes knowledge and what it really took to get there. That is the purpose of this post.
|
||||||
@@ -53,16 +54,16 @@ I will guide you through the steps of the launch and comment and share our exper
|
|||||||
|
|
||||||
## Scheduling your launch and creating a "Coming soon" teaser - "let's exchange upvotes"
|
## Scheduling your launch and creating a "Coming soon" teaser - "let's exchange upvotes"
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph19} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph19} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Once you schedule your Product Hunt launch, you can create a banner to appear on their "Coming soon" page ([https://www.producthunt.com/coming-soon](https://www.producthunt.com/coming-soon)), and this is where your journey starts. This gives PH visitors an opportunity to see what's coming next and to subscribe to get notified once it launches, and it is also the first thing you can use for marketing your launch.
|
Once you schedule your Product Hunt launch, you can create a banner to appear on their "Coming soon" page ([https://www.producthunt.com/coming-soon](https://www.producthunt.com/coming-soon)), and this is where your journey starts. This gives PH visitors an opportunity to see what's coming next and to subscribe to get notified once it launches, and it is also the first thing you can use for marketing your launch.
|
||||||
|
|
||||||
**This is also when the PH economy starts - as soon as you publish your launch teaser, you will start receiving offers to exchange upvotes with other people launching their products soon**:
|
**This is also when the PH economy starts - as soon as you publish your launch teaser, you will start receiving offers to exchange upvotes with other people launching their products soon**:
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph18} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph18} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
This is actually a legitimate strategy (in the sense of shared incentives and not buying votes) that can probably be utilized pretty efficiently via automation. It won't bring any qualified leads (aka people genuinely interested in your product), but it might help with the upvotes, resulting in the increased visibility and reach of your launch.
|
This is actually a legitimate strategy (in the sense of shared incentives and not buying votes) that can probably be utilized pretty efficiently via automation. It won't bring any qualified leads (aka people genuinely interested in your product), but it might help with the upvotes, resulting in the increased visibility and reach of your launch.
|
||||||
@@ -81,19 +82,18 @@ Product Hunt recently introduced the feature of showing the products in the rand
|
|||||||
|
|
||||||
With our latest launch, Open SaaS, we had the best opening ever - 100 upvotes in 4 hours!
|
With our latest launch, Open SaaS, we had the best opening ever - 100 upvotes in 4 hours!
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph17} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph17} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
We, of course, engaged our network, but also noticed a lot of upvotes and comments from the people we don't personally know. With such a strong start, I was quite confident we secured our place in the top 5 products on the leaderboard.
|
We, of course, engaged our network, but also noticed a lot of upvotes and comments from the people we don't personally know. With such a strong start, I was quite confident we secured our place in the top 5 products on the leaderboard.
|
||||||
|
|
||||||
> Being in the top 5 products is an "above the fold" position on Product Hunt's home page, so getting there early is the best way to end up there.
|
> Being in the top 5 products is an "above the fold" position on Product Hunt's home page, so getting there early is the best way to end up there.
|
||||||
>
|
|
||||||
|
|
||||||
But when the leaderboard was finally revealed, Open SaaS was barely in the top 10 launches of the day!
|
But when the leaderboard was finally revealed, Open SaaS was barely in the top 10 launches of the day!
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph16} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph16} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
There was a quite noticeable cut-off between the first five places and the rest, and the product in the first place had almost double the upvotes than the second one. **That was fairly demotivating for us as it felt like we had literally zero chance of catching up.**
|
There was a quite noticeable cut-off between the first five places and the rest, and the product in the first place had almost double the upvotes than the second one. **That was fairly demotivating for us as it felt like we had literally zero chance of catching up.**
|
||||||
@@ -102,72 +102,72 @@ There was a quite noticeable cut-off between the first five places and the rest,
|
|||||||
|
|
||||||
After the leaderboard reveal, we started receiving another type of message - direct offers to buy upvotes. Being still relatively close to being in the top 5 products probably made us a highly qualified lead:
|
After the leaderboard reveal, we started receiving another type of message - direct offers to buy upvotes. Being still relatively close to being in the top 5 products probably made us a highly qualified lead:
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph15} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph15} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
A slight variation of this is having different social media influencers and community owners reach out and offer to market your launch to their followers, promising X upvotes:
|
A slight variation of this is having different social media influencers and community owners reach out and offer to market your launch to their followers, promising X upvotes:
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph14} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph14} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph13} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph13} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Even some of our direct contacts knew "a guy" that could get you to the top of Product Hunt and offered to intro us, so it kinda started feeling like a "public secret", and us being the rare ones who didn't know about it.
|
Even some of our direct contacts knew "a guy" that could get you to the top of Product Hunt and offered to intro us, so it kinda started feeling like a "public secret", and us being the rare ones who didn't know about it.
|
||||||
|
|
||||||
## What we got from the launch - #7, HN front page, trending on GitHub, ...
|
## What we got from the launch - #7, HN front page, trending on GitHub, ...
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph12} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph12} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**The main benefit of our PH launch wasn't the launch itself, but rather the fact we could combine it with other things, like** [launching Open SaaS on HackerNews](https://news.ycombinator.com/item?id=39192304)**,** where it ended up being featured for about half a day (and much longer on Show HN tab).
|
**The main benefit of our PH launch wasn't the launch itself, but rather the fact we could combine it with other things, like** [launching Open SaaS on HackerNews](https://news.ycombinator.com/item?id=39192304)**,** where it ended up being featured for about half a day (and much longer on Show HN tab).
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph11} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph11} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Finally, all that engagement combined allowed us to get trending globally on GitHub, which in turn brought in even more traffic to Open SaaS (today, a week after launching, it has [over 2.5k stars](https://github.com/wasp-lang/open-saas/)).
|
Finally, all that engagement combined allowed us to get trending globally on GitHub, which in turn brought in even more traffic to Open SaaS (today, a week after launching, it has [over 2.5k stars](https://github.com/wasp-lang/open-saas/)).
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph10} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph10} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### The resulting traffic
|
### The resulting traffic
|
||||||
|
|
||||||
Taking a look at the traffic that was brought to Open SaaS's repo in the last two weeks, here's what we can observe:
|
Taking a look at the traffic that was brought to Open SaaS's repo in the last two weeks, here's what we can observe:
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph9} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph9} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph8} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph8} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
HackerNews launch brought in more than 3 times more people than Product Hunt. GitHub brought even fewer people to the actual repo, but my gut feeling is that many more of them starred it without leaving the Trending page.
|
HackerNews launch brought in more than 3 times more people than Product Hunt. GitHub brought even fewer people to the actual repo, but my gut feeling is that many more of them starred it without leaving the Trending page.
|
||||||
|
|
||||||
## Getting featured in PH's daily newsletter - does it help?
|
## Getting featured in PH's daily newsletter - does it help?
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph7} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph7} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Open SaaS ended the launch as the #7 product of the day, with about ~400 upvotes. The top 10 products of the day end up in a daily newsletter that has over 500,000 subscribers, according to the Product Hunt.
|
Open SaaS ended the launch as the #7 product of the day, with about ~400 upvotes. The top 10 products of the day end up in a daily newsletter that has over 500,000 subscribers, according to the Product Hunt.
|
||||||
|
|
||||||
The newsletter starts with 3 big promotional blocks, so you must scroll quite a bit to reach the top products of the previous day.
|
The newsletter starts with 3 big promotional blocks, so you must scroll quite a bit to reach the top products of the previous day.
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph6} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph6} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
For us, it didn't make a huge dent, I think it got us about 20 upvotes. Maybe it was due to the fact we weren't number one, or simply because it's quite a deep funnel (open email → scroll all the way down → check all the products → like Open SaaS → decide to upvote it).
|
For us, it didn't make a huge dent, I think it got us about 20 upvotes. Maybe it was due to the fact we weren't number one, or simply because it's quite a deep funnel (open email → scroll all the way down → check all the products → like Open SaaS → decide to upvote it).
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph5} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph5} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Is it even possible to win #1 of the day without any boosting strategies?
|
## Is it even possible to win #1 of the day without any boosting strategies?
|
||||||
@@ -190,8 +190,8 @@ Why is that possible? Product Hunt is taking a lot of measures to detect and pre
|
|||||||
|
|
||||||
Product Hunt gives you a unique opportunity to declare an "official" launch of your product. You can decide on which day you want to do it, schedule it, and it's 100% going to be there for everybody to see, and for you to share and invite people to check it out. **You get 24 hours, during which it is fully justifiable to contact everyone you know (and beyond) and keep tooting your horn.**
|
Product Hunt gives you a unique opportunity to declare an "official" launch of your product. You can decide on which day you want to do it, schedule it, and it's 100% going to be there for everybody to see, and for you to share and invite people to check it out. **You get 24 hours, during which it is fully justifiable to contact everyone you know (and beyond) and keep tooting your horn.**
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph4} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph4} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**You can't do that with other high-reach platforms such as Reddit, and HackerNews.** You are, of course, free to share the news about your product at any time, but there is no guarantee that anybody will see it (quite the opposite, actually) unless the collective mind of the community decides so, which is all but deterministic. You could easily spend a week preparing your launch post just for it to get drowned by the algorithm in minutes.
|
**You can't do that with other high-reach platforms such as Reddit, and HackerNews.** You are, of course, free to share the news about your product at any time, but there is no guarantee that anybody will see it (quite the opposite, actually) unless the collective mind of the community decides so, which is all but deterministic. You could easily spend a week preparing your launch post just for it to get drowned by the algorithm in minutes.
|
||||||
@@ -202,8 +202,8 @@ Product Hunt gives you a unique opportunity to declare an "official" launch of y
|
|||||||
|
|
||||||
You will find a lot of articles (and paid courses) from "PH gurus", explaining how you should prepare your launch months in advance, warm up your audience, prepare comments they will share, etc. **We don't do any of that. We just prepare the content (video + a few screenshots, and an intro comment), and, on the day of the launch, invite everyone we know to support us. Then, during the day, we also post on Reddit, Hackernews, and [dev.to](http://dev.to/).**
|
You will find a lot of articles (and paid courses) from "PH gurus", explaining how you should prepare your launch months in advance, warm up your audience, prepare comments they will share, etc. **We don't do any of that. We just prepare the content (video + a few screenshots, and an intro comment), and, on the day of the launch, invite everyone we know to support us. Then, during the day, we also post on Reddit, Hackernews, and [dev.to](http://dev.to/).**
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph3} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph3} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Sometimes we end up in the top 5, sometimes we don't, **but every time, we get a solid uptick in user engagement, and usually something much better follows in the next days/weeks**. For example, [MAGE, our GPT-powered full-stack app starter](https://usemage.ai/), exploded after its PH launch and [has been used to create over 30,000 projects in a few months](https://dev.to/wasp/how-we-built-a-gpt-web-app-generator-for-react-nodejs-from-idea-to-25000-apps-in-4-months-1aol).
|
Sometimes we end up in the top 5, sometimes we don't, **but every time, we get a solid uptick in user engagement, and usually something much better follows in the next days/weeks**. For example, [MAGE, our GPT-powered full-stack app starter](https://usemage.ai/), exploded after its PH launch and [has been used to create over 30,000 projects in a few months](https://dev.to/wasp/how-we-built-a-gpt-web-app-generator-for-react-nodejs-from-idea-to-25000-apps-in-4-months-1aol).
|
||||||
@@ -213,10 +213,9 @@ Sometimes we end up in the top 5, sometimes we don't, **but every time, we get a
|
|||||||
Our goal is to launch on Product Hunt every 3 months as a part of our Launch Week, and that's what we've done so far. You cannot really launch the exact same product unless 6 months have passed or there's been a significant update, but you are free to launch other (sub)products and features connected to your main product.
|
Our goal is to launch on Product Hunt every 3 months as a part of our Launch Week, and that's what we've done so far. You cannot really launch the exact same product unless 6 months have passed or there's been a significant update, but you are free to launch other (sub)products and features connected to your main product.
|
||||||
|
|
||||||
> 💡 Hint: when you submit a launch, you can ask the PH team to "connect" it to your product so it will appear in a list of launches for that product. Often, they do it on their own. It will look like this:
|
> 💡 Hint: when you submit a launch, you can ask the PH team to "connect" it to your product so it will appear in a list of launches for that product. Often, they do it on their own. It will look like this:
|
||||||
>
|
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image src={ph2} alt="Make apps for everyone" loading="lazy" />
|
<Image src={ph2} alt="Make apps for everyone" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Although our main product is [Wasp, a full-stack framework on top of React & Node.js](https://github.com/wasp-lang/wasp), here's what we launched so far:
|
Although our main product is [Wasp, a full-stack framework on top of React & Node.js](https://github.com/wasp-lang/wasp), here's what we launched so far:
|
||||||
@@ -224,7 +223,7 @@ Although our main product is [Wasp, a full-stack framework on top of React & Nod
|
|||||||
- **Wasp Alpha** - pure product, v0 pretty much
|
- **Wasp Alpha** - pure product, v0 pretty much
|
||||||
- **Wasp Beta** - almost two years later, we haven't yet adopted the "launch often" approach
|
- **Wasp Beta** - almost two years later, we haven't yet adopted the "launch often" approach
|
||||||
- **Free SaaS template** - a predecessor of [Open SaaS](https://github.com/wasp-lang/open-saas), 4 months later
|
- **Free SaaS template** - a predecessor of [Open SaaS](https://github.com/wasp-lang/open-saas), 4 months later
|
||||||
- [*MAGE, GPT Web App Generator](https://usemage.ai/)* - a full-stack web app generator powered by Wasp
|
- [\*MAGE, GPT Web App Generator](https://usemage.ai/)\* - a full-stack web app generator powered by Wasp
|
||||||
- [**Full-stack auth for React/Node, no 3rd party services**](https://wasp-lang.dev/docs/auth/overview) - Wasp's auth feature
|
- [**Full-stack auth for React/Node, no 3rd party services**](https://wasp-lang.dev/docs/auth/overview) - Wasp's auth feature
|
||||||
- And finally, [**Open SaaS - A free, open-source alternative to $300+ React & Node.js SaaS starters**](https://github.com/wasp-lang/open-saas/)
|
- And finally, [**Open SaaS - A free, open-source alternative to $300+ React & Node.js SaaS starters**](https://github.com/wasp-lang/open-saas/)
|
||||||
|
|
||||||
@@ -240,4 +239,4 @@ Thanks for reading this far! This turned out to be quite a bit longer post than
|
|||||||
|
|
||||||
I would also love to get your feedback and hear about your experiences and strategies for launching on Product Hunt.
|
I would also love to get your feedback and hear about your experiences and strategies for launching on Product Hunt.
|
||||||
|
|
||||||
Happy launching!
|
Happy launching!
|
||||||
|
|||||||
@@ -13,25 +13,89 @@ tags:
|
|||||||
authors: vince
|
authors: vince
|
||||||
---
|
---
|
||||||
|
|
||||||
import { Image } from 'astro:assets';
|
import { Image } from "astro:assets";
|
||||||
import VideoPlayer from '../../../components/VideoPlayer.astro';
|
import VideoPlayer from "../../../components/VideoPlayer.astro";
|
||||||
import demoVideo from '../../../assets/cover-letter-gpt/demo-video.mp4';
|
import demoVideo from "../../../assets/cover-letter-gpt/demo-video.mp4";
|
||||||
import Tweet from '../../../components/Tweet.astro';
|
import Tweet from "../../../components/Tweet.astro";
|
||||||
import landingPage from '../../../assets/cover-letter-gpt/coverlettergpt-may2025.png';
|
import landingPage from "../../../assets/cover-letter-gpt/coverlettergpt-may2025.png";
|
||||||
import mrrRevenue from '../../../assets/cover-letter-gpt/mrr-revenue.png';
|
import mrrRevenue from "../../../assets/cover-letter-gpt/mrr-revenue.png";
|
||||||
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
|
import StarOpenSaaSCTA from "../../../components/StarOpenSaaSCTA.astro";
|
||||||
import redditPost from '../../../assets/cover-letter-gpt/coverlettergpt-reddit.png';
|
import redditPost from "../../../assets/cover-letter-gpt/coverlettergpt-reddit.png";
|
||||||
import coverlettergptPricing from '../../../assets/cover-letter-gpt/coverlettergpt-plans.png';
|
import coverlettergptPricing from "../../../assets/cover-letter-gpt/coverlettergpt-plans.png";
|
||||||
import openAiCost from '../../../assets/cover-letter-gpt/openai-cost.png';
|
import openAiCost from "../../../assets/cover-letter-gpt/openai-cost.png";
|
||||||
import openAiRequests from '../../../assets/cover-letter-gpt/openai-requests.png';
|
import openAiRequests from "../../../assets/cover-letter-gpt/openai-requests.png";
|
||||||
import jeep from '../../../assets/cover-letter-gpt/jeep.png';
|
import jeep from "../../../assets/cover-letter-gpt/jeep.png";
|
||||||
import coverlettergptPhLaunch from '../../../assets/cover-letter-gpt/coverlettergpt-ph-launch.png';
|
import coverlettergptPhLaunch from "../../../assets/cover-letter-gpt/coverlettergpt-ph-launch.png";
|
||||||
import coverlettergptIndieHackers from '../../../assets/cover-letter-gpt/coverlettergpt-indiehackers.png';
|
import coverlettergptIndieHackers from "../../../assets/cover-letter-gpt/coverlettergpt-indiehackers.png";
|
||||||
|
|
||||||
Hey builders,
|
Hey builders,
|
||||||
|
|
||||||
I wanted to share my journey building a micro SaaS, [CoverLetterGPT](https://coverlettergpt.xyz/), which earns **$550/month in recurring revenue (MRR)**, while requiring **minimal effort and maintenance**. Here's a breakdown of overall costs, profit, how I got customers, and why I believe small, simple SaaS apps are an underrated way to start as an indie maker.
|
I wanted to share my journey building a micro SaaS, [CoverLetterGPT](https://coverlettergpt.xyz/), which earns **$550/month in recurring revenue (MRR)**, while requiring **minimal effort and maintenance**. Here's a breakdown of overall costs, profit, how I got customers, and why I believe small, simple SaaS apps are an underrated way to start as an indie maker.
|
||||||
<blockquote class="tiktok-embed" cite="https://www.tiktok.com/@hot_town_/video/7504744778689449239" data-video-id="7504744778689449239" style="max-width: 605px;min-width: 325px;" > <section> <a target="_blank" title="@hot_town_" href="https://www.tiktok.com/@hot_town_?refer=embed">@hot_town_</a> Here’s a down of how much it cost me to run my SaaS app which is a simple GPT wrapper for generating cover letters. Overall, it’s been a decent little profit because the app doesn’t cost me much to run. <a title="webdevelopment" target="_blank" href="https://www.tiktok.com/tag/webdevelopment?refer=embed">#webdevelopment</a> <a title="sideproject" target="_blank" href="https://www.tiktok.com/tag/sideproject?refer=embed">#sideproject</a> <a title="indiehackers" target="_blank" href="https://www.tiktok.com/tag/indiehackers?refer=embed">#indiehackers</a> <a title="saas" target="_blank" href="https://www.tiktok.com/tag/saas?refer=embed">#saas</a> <a title="ai" target="_blank" href="https://www.tiktok.com/tag/ai?refer=embed">#ai</a> <a target="_blank" title="♬ original sound - Vinny" href="https://www.tiktok.com/music/original-sound-7504744764698856214?refer=embed">♬ original sound - Vinny</a> </section> </blockquote> <script async src="https://www.tiktok.com/embed.js"></script>
|
|
||||||
|
<blockquote
|
||||||
|
class="tiktok-embed"
|
||||||
|
cite="https://www.tiktok.com/@hot_town_/video/7504744778689449239"
|
||||||
|
data-video-id="7504744778689449239"
|
||||||
|
style="max-width: 605px;min-width: 325px;"
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
<section>
|
||||||
|
{" "}
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
title="@hot_town_"
|
||||||
|
href="https://www.tiktok.com/@hot_town_?refer=embed"
|
||||||
|
>
|
||||||
|
@hot_town_
|
||||||
|
</a>{" "}
|
||||||
|
Here’s a down of how much it cost me to run my SaaS app which is a simple
|
||||||
|
GPT wrapper for generating cover letters. Overall, it’s been a decent little
|
||||||
|
profit because the app doesn’t cost me much to run.{" "}
|
||||||
|
<a
|
||||||
|
title="webdevelopment"
|
||||||
|
target="_blank"
|
||||||
|
href="https://www.tiktok.com/tag/webdevelopment?refer=embed"
|
||||||
|
>
|
||||||
|
#webdevelopment
|
||||||
|
</a>{" "}
|
||||||
|
<a
|
||||||
|
title="sideproject"
|
||||||
|
target="_blank"
|
||||||
|
href="https://www.tiktok.com/tag/sideproject?refer=embed"
|
||||||
|
>
|
||||||
|
#sideproject
|
||||||
|
</a>{" "}
|
||||||
|
<a
|
||||||
|
title="indiehackers"
|
||||||
|
target="_blank"
|
||||||
|
href="https://www.tiktok.com/tag/indiehackers?refer=embed"
|
||||||
|
>
|
||||||
|
#indiehackers
|
||||||
|
</a>{" "}
|
||||||
|
<a
|
||||||
|
title="saas"
|
||||||
|
target="_blank"
|
||||||
|
href="https://www.tiktok.com/tag/saas?refer=embed"
|
||||||
|
>
|
||||||
|
#saas
|
||||||
|
</a>{" "}
|
||||||
|
<a
|
||||||
|
title="ai"
|
||||||
|
target="_blank"
|
||||||
|
href="https://www.tiktok.com/tag/ai?refer=embed"
|
||||||
|
>
|
||||||
|
#ai
|
||||||
|
</a>{" "}
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
title="♬ original sound - Vinny"
|
||||||
|
href="https://www.tiktok.com/music/original-sound-7504744764698856214?refer=embed"
|
||||||
|
>
|
||||||
|
♬ original sound - Vinny
|
||||||
|
</a>{" "}
|
||||||
|
</section>{" "}
|
||||||
|
</blockquote>
|
||||||
|
<script async src="https://www.tiktok.com/embed.js"></script>
|
||||||
|
|
||||||
### At a glance
|
### At a glance
|
||||||
|
|
||||||
@@ -40,7 +104,8 @@ I wanted to share my journey building a micro SaaS, [CoverLetterGPT](https://cov
|
|||||||
<VideoPlayer src={demoVideo} lgWidth="80%" />
|
<VideoPlayer src={demoVideo} lgWidth="80%" />
|
||||||
|
|
||||||
Here are some quick numbers:
|
Here are some quick numbers:
|
||||||
- **Built in 1 week**
|
|
||||||
|
- **Built in 1 week**
|
||||||
- using [Open SaaS](https://opensaas.sh/), a free, open-source React, NodeJS, SaaS boilerplate template with tons of features.
|
- using [Open SaaS](https://opensaas.sh/), a free, open-source React, NodeJS, SaaS boilerplate template with tons of features.
|
||||||
- **Runs on autopilot**
|
- **Runs on autopilot**
|
||||||
- ~1hr/month of maintenance
|
- ~1hr/month of maintenance
|
||||||
@@ -58,14 +123,14 @@ Here are some quick numbers:
|
|||||||
|
|
||||||
### Costs Breakdown
|
### Costs Breakdown
|
||||||
|
|
||||||
| Cost Type | Monthly Cost | Notes |
|
| Cost Type | Monthly Cost | Notes |
|
||||||
|----------------|--------------|----------------------------------------------------|
|
| ----------- | ------------ | ----------------------------------------------- |
|
||||||
| App Hosting | $12 | Railway.com |
|
| App Hosting | $12 | Railway.com |
|
||||||
| OpenAI API | $3 | ~1,500 requests / 1.5m tokens |
|
| OpenAI API | $3 | ~1,500 requests / 1.5m tokens |
|
||||||
| Domain | $0.98 | $11.82/year via Porkbun |
|
| Domain | $0.98 | $11.82/year via Porkbun |
|
||||||
| Stripe Fees | ~$45 | ~3% + 30¢/transaction + ~2% currency conversion|
|
| Stripe Fees | ~$45 | ~3% + 30¢/transaction + ~2% currency conversion |
|
||||||
|
|
||||||
As you can see, Stripe fees are the biggest "cost" in the end, becuase most of my customers are abroad, so I get hit with extra cross-border fees. But that's just the cost of doing business. Plus, I don't really see these fees, so they dont bother me much.
|
As you can see, Stripe fees are the biggest "cost" in the end, becuase most of my customers are abroad, so I get hit with extra cross-border fees. But that's just the cost of doing business. Plus, I don't really see these fees, so they dont bother me much.
|
||||||
|
|
||||||
Meanwhile, using the OpenAI API isn't as expensive as you might think, and the models keep getting cheaper and better. I'm currently using the `gpt-4o-mini` model for the standard plan, and `gpt-4.1` for the pro plan.
|
Meanwhile, using the OpenAI API isn't as expensive as you might think, and the models keep getting cheaper and better. I'm currently using the `gpt-4o-mini` model for the standard plan, and `gpt-4.1` for the pro plan.
|
||||||
|
|
||||||
@@ -81,27 +146,30 @@ Besides that, my hosting bill is about $12/month on [Railway](https://railway.co
|
|||||||
|
|
||||||
### Revenue & Profit Breakdown
|
### Revenue & Profit Breakdown
|
||||||
|
|
||||||
| Metric | Value | Notes |
|
| Metric | Value | Notes |
|
||||||
|---------------------------|----------------------|--------------------------------------------|
|
| --------------------------- | ---------- | ---------------------------------- |
|
||||||
| Avg. Monthly Revenue | $615 | Past 8 months, converted from €543 |
|
| Avg. Monthly Revenue | $615 | Past 8 months, converted from €543 |
|
||||||
| Total Net Revenue | $9,912 | Since launch, after Stripe fees |
|
| Total Net Revenue | $9,912 | Since launch, after Stripe fees |
|
||||||
| Total Costs to Date | $345 | $15/month × 23 months |
|
| Total Costs to Date | $345 | $15/month × 23 months |
|
||||||
| Avg. Monthly Profit | $416 | $9,567 ÷ 23 months |
|
| Avg. Monthly Profit | $416 | $9,567 ÷ 23 months |
|
||||||
| **Total Profit to Date 🎉** | **$9,567** | **Net revenue minus costs** |
|
| **Total Profit to Date 🎉** | **$9,567** | **Net revenue minus costs** |
|
||||||
|
|
||||||
|
|
||||||
At current exchange rates, my average monthly recurring revenue of €543 over the past 8 months equals
|
At current exchange rates, my average monthly recurring revenue of €543 over the past 8 months equals
|
||||||
|
|
||||||
- **an average of $615 MRR for the past 8 months**
|
- **an average of $615 MRR for the past 8 months**
|
||||||
|
|
||||||
My total revenue, minus ~$45/month Stripe fees, disputes, and refunds, since launch has been €8756. At current exchange rates, this equals
|
My total revenue, minus ~$45/month Stripe fees, disputes, and refunds, since launch has been €8756. At current exchange rates, this equals
|
||||||
|
|
||||||
- **$9,912 total net revenue**
|
- **$9,912 total net revenue**
|
||||||
|
|
||||||
My costs since launch 23 months ago have been ~$15/month. So 15 * 23 equals
|
My costs since launch 23 months ago have been ~$15/month. So 15 \* 23 equals
|
||||||
|
|
||||||
- **$345** monthly costs
|
- **$345** monthly costs
|
||||||
|
|
||||||
This brings my total profit since launch to
|
This brings my total profit since launch to
|
||||||
|
|
||||||
- **$9,567** total profit
|
- **$9,567** total profit
|
||||||
or
|
or
|
||||||
- **$416/month** average profit
|
- **$416/month** average profit
|
||||||
|
|
||||||
That's enough to afford a lease on a nice Jeep Wrangler.
|
That's enough to afford a lease on a nice Jeep Wrangler.
|
||||||
@@ -112,14 +180,14 @@ Not bad for a side project that I built in 1 week!
|
|||||||
|
|
||||||
### Marketing Breakdown
|
### Marketing Breakdown
|
||||||
|
|
||||||
| Channel | Effort Level | Return | Result / Notes |
|
| Channel | Effort Level | Return | Result / Notes |
|
||||||
|-------------------|---------------|-------------------------------|-----------------------------------------------------|
|
| ------------- | ---------------- | ------ | ---------------------------------------------------------------------- |
|
||||||
| Product Hunt | High | Medium | Initial launch, created marketing assets, got early traction |
|
| Product Hunt | High | Medium | Initial launch, created marketing assets, got early traction |
|
||||||
| Reddit | Medium-High | High | Posted in dev/entrepreneur/job subreddits, boosted SEO, some bans risk |
|
| Reddit | Medium-High | High | Posted in dev/entrepreneur/job subreddits, boosted SEO, some bans risk |
|
||||||
| Indie Hackers | Easy | Medium | Shared open-source story, featured in newsletter, good feedback |
|
| Indie Hackers | Easy | Medium | Shared open-source story, featured in newsletter, good feedback |
|
||||||
| Twitter | Hard | Low | Shared updates, videos, and journey. |
|
| Twitter | Hard | Low | Shared updates, videos, and journey. |
|
||||||
| TikTok | Medium (Ongoing) | High | Shared updates, videos, and journey, helps maintain MRR. |
|
| TikTok | Medium (Ongoing) | High | Shared updates, videos, and journey, helps maintain MRR. |
|
||||||
| Paid Ads | None | -- | Did not run any paid ads |
|
| Paid Ads | None | -- | Did not run any paid ads |
|
||||||
|
|
||||||
By far the question I get asked the most by other curious builders is how I got customers. Many ask if I paid for ads, and I didn't.
|
By far the question I get asked the most by other curious builders is how I got customers. Many ask if I paid for ads, and I didn't.
|
||||||
|
|
||||||
@@ -143,7 +211,7 @@ This all lead to giving me a jump start on spreading the word about the app. It
|
|||||||
|
|
||||||
<Image src={redditPost} alt="CoverLetterGPT Reddit Post" />
|
<Image src={redditPost} alt="CoverLetterGPT Reddit Post" />
|
||||||
|
|
||||||
Posting on Reddit can be tricky. If you're too forward, it gets seen as spam and you can get banned.
|
Posting on Reddit can be tricky. If you're too forward, it gets seen as spam and you can get banned.
|
||||||
|
|
||||||
Luckily, I left the app open-source because I wasn't anticipating much success from it, and a good side effect of this was that I was able to openly post about it on developer and entrepreneur subreddits. To this day, I think this had a good effect on SEO, but I'm not certain.
|
Luckily, I left the app open-source because I wasn't anticipating much success from it, and a good side effect of this was that I was able to openly post about it on developer and entrepreneur subreddits. To this day, I think this had a good effect on SEO, but I'm not certain.
|
||||||
|
|
||||||
@@ -159,7 +227,7 @@ This led to a getting featured in their newsletter--again, thanks to the app bei
|
|||||||
|
|
||||||
#### Twitter & tiktok
|
#### Twitter & tiktok
|
||||||
|
|
||||||
I also took to twitter and tiktok to share fun videos about the app, my journey building it, and my thoughts about it.
|
I also took to twitter and tiktok to share fun videos about the app, my journey building it, and my thoughts about it.
|
||||||
|
|
||||||
Doing this periodically and consistently throughout the past couple years has probably helped keep MRR consistent
|
Doing this periodically and consistently throughout the past couple years has probably helped keep MRR consistent
|
||||||
|
|
||||||
@@ -189,14 +257,14 @@ The most important lesson I've learned: **speed is everything.** The faster you
|
|||||||
|
|
||||||
1. **Avoid long, drawn-out failures:** Build small, execute early.
|
1. **Avoid long, drawn-out failures:** Build small, execute early.
|
||||||
2. **Use the fastest tools available:** I used [Open SaaS](https://opensaas.sh/) because it gives me all the building blocks already set up (auth, Stripe payments, OpenAI API examples, email sending, etc), letting me focus on the business logic of the app.
|
2. **Use the fastest tools available:** I used [Open SaaS](https://opensaas.sh/) because it gives me all the building blocks already set up (auth, Stripe payments, OpenAI API examples, email sending, etc), letting me focus on the business logic of the app.
|
||||||
3. **Forget perfection:** I didn't worry about making it pretty or perfect—-it just had to work.
|
3. **Forget perfection:** I didn't worry about making it pretty or perfect—-it just had to work.
|
||||||
|
|
||||||
#### Keep It Simple
|
#### Keep It Simple
|
||||||
|
|
||||||
The beauty of a simple, "micro SaaS" is in its simplicity. Here's why:
|
The beauty of a simple, "micro SaaS" is in its simplicity. Here's why:
|
||||||
|
|
||||||
- My app does **one thing well**: generating cover letters based on résumés and job descriptions and allows users to edit them inline with AI assistance.
|
- My app does **one thing well**: generating cover letters based on résumés and job descriptions and allows users to edit them inline with AI assistance.
|
||||||
- There's no need for a fancy landing page or marketing gimmicks. This is my 🌶 hot take. I mean, my landing page *is* my app! Users land on it and can instantly try it out.
|
- There's no need for a fancy landing page or marketing gimmicks. This is my 🌶 hot take. I mean, my landing page _is_ my app! Users land on it and can instantly try it out.
|
||||||
- Users get **3 trial credits**-—enough to try the app and see value before paying.
|
- Users get **3 trial credits**-—enough to try the app and see value before paying.
|
||||||
|
|
||||||
<Image src={landingPage} alt="CoverLetterGPT landing page" />
|
<Image src={landingPage} alt="CoverLetterGPT landing page" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: 'Open SaaS v2.0 -- ShadCN UI, LLM-friendly, MoRs, and more.'
|
title: "Open SaaS v2.0 -- ShadCN UI, LLM-friendly, MoRs, and more."
|
||||||
subtitle: 'A summary of the changes and improvements in the latest version of Open SaaS.'
|
subtitle: "A summary of the changes and improvements in the latest version of Open SaaS."
|
||||||
date: 2025-07-29
|
date: 2025-07-29
|
||||||
tags:
|
tags:
|
||||||
- gpt
|
- gpt
|
||||||
@@ -13,18 +13,18 @@ tags:
|
|||||||
authors: vince
|
authors: vince
|
||||||
---
|
---
|
||||||
|
|
||||||
import { Image } from 'astro:assets';
|
import { Image } from "astro:assets";
|
||||||
import VideoPlayer from '../../../components/VideoPlayer.astro';
|
import VideoPlayer from "../../../components/VideoPlayer.astro";
|
||||||
import openSaasV1 from '../../../assets/open-saas-v2/open-saas-v1.mp4';
|
import openSaasV1 from "../../../assets/open-saas-v2/open-saas-v1.mp4";
|
||||||
import openSaasV2 from '../../../assets/open-saas-v2/open-saas-v2.mp4';
|
import openSaasV2 from "../../../assets/open-saas-v2/open-saas-v2.mp4";
|
||||||
import Tweet from '../../../components/Tweet.astro';
|
import Tweet from "../../../components/Tweet.astro";
|
||||||
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
|
import StarOpenSaaSCTA from "../../../components/StarOpenSaaSCTA.astro";
|
||||||
import redditPost from '../../../assets/cover-letter-gpt/coverlettergpt-reddit.png';
|
import redditPost from "../../../assets/cover-letter-gpt/coverlettergpt-reddit.png";
|
||||||
import polar from '../../../assets/open-saas-v2/polar.webp';
|
import polar from "../../../assets/open-saas-v2/polar.webp";
|
||||||
import llmsFull from '../../../assets/ai/llm-txt-chat.webp';
|
import llmsFull from "../../../assets/ai/llm-txt-chat.webp";
|
||||||
import openSaasBanner from '../../../assets/open-saas-v2/open-saas-banner-light.png';
|
import openSaasBanner from "../../../assets/open-saas-v2/open-saas-banner-light.png";
|
||||||
import waitTheresMore from '../../../assets/open-saas-v2/wait-theres-more.jpg';
|
import waitTheresMore from "../../../assets/open-saas-v2/wait-theres-more.jpg";
|
||||||
import playwrightUi from '../../../assets/open-saas-v2/playwright-ui.png';
|
import playwrightUi from "../../../assets/open-saas-v2/playwright-ui.png";
|
||||||
|
|
||||||
{/* <Tweet id="" /> */}
|
{/* <Tweet id="" /> */}
|
||||||
|
|
||||||
@@ -34,23 +34,23 @@ Well, that's exactly what we've done with Open SaaS v2.0.
|
|||||||
|
|
||||||
Oh, and we're launching it on [Product Hunt](https://www.producthunt.com/products/open-saas), so go show your support for open-source SaaS dopeness! 🙏
|
Oh, and we're launching it on [Product Hunt](https://www.producthunt.com/products/open-saas), so go show your support for open-source SaaS dopeness! 🙏
|
||||||
|
|
||||||
<div style='border-radius: 8px; overflow: hidden;'>
|
<div style="border-radius: 8px; overflow: hidden;">
|
||||||
<a
|
<a
|
||||||
href='https://www.producthunt.com/products/open-saas?embed=true&utm_source=badge-featured&utm_medium=badge&utm_source=badge-open-saas-2'
|
href="https://www.producthunt.com/products/open-saas?embed=true&utm_source=badge-featured&utm_medium=badge&utm_source=badge-open-saas-2"
|
||||||
target='_blank'
|
target="_blank"
|
||||||
style='position: relative; display: flex; justify-content: center; align-items: center; height: 200px; width: 100%;'
|
style="position: relative; display: flex; justify-content: center; align-items: center; height: 200px; width: 100%;"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={openSaasBanner}
|
src={openSaasBanner}
|
||||||
alt='Open SaaS v2.0 Banner'
|
alt="Open SaaS v2.0 Banner"
|
||||||
style='position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; object-position: 50% 53%; filter: grayscale(100%); z-index: 1; opacity: 0.2;'
|
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; object-position: 50% 53%; filter: grayscale(100%); z-index: 1; opacity: 0.2;"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
src='https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=991058&theme=neutral&t=1753441449297'
|
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=991058&theme=neutral&t=1753441449297"
|
||||||
alt='Open SaaS - The open-source SaaS boilerplate with superpowers! | Product Hunt'
|
alt="Open SaaS - The open-source SaaS boilerplate with superpowers! | Product Hunt"
|
||||||
style='width: 250px; height: 54px; position: relative; z-index: 2'
|
style="width: 250px; height: 54px; position: relative; z-index: 2"
|
||||||
width='250'
|
width="250"
|
||||||
height='54'
|
height="54"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,11 +67,11 @@ So we've done just that.
|
|||||||
|
|
||||||
🧐 <b>Here's what it looked like before:</b>
|
🧐 <b>Here's what it looked like before:</b>
|
||||||
|
|
||||||
<VideoPlayer src={openSaasV1} lgWidth='500px' />
|
<VideoPlayer src={openSaasV1} lgWidth="500px" />
|
||||||
|
|
||||||
🔥 <b>And here's what it looks like now:</b>
|
🔥 <b>And here's what it looks like now:</b>
|
||||||
|
|
||||||
<VideoPlayer src={openSaasV2} lgWidth='500px' />
|
<VideoPlayer src={openSaasV2} lgWidth="500px" />
|
||||||
|
|
||||||
You probably noticed that the design is cleaner, more consistent, and offers more interactive elements. But it's also completely redesigned on top of [Shadcn UI](https://ui.shadcn.com/) (which is also open source!), so you get a modern, composable, and highly customizable experience out of the box.
|
You probably noticed that the design is cleaner, more consistent, and offers more interactive elements. But it's also completely redesigned on top of [Shadcn UI](https://ui.shadcn.com/) (which is also open source!), so you get a modern, composable, and highly customizable experience out of the box.
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ Now on to the new features!
|
|||||||
|
|
||||||
Merchants of Record (MoR) are great. They're a layer on top of payment processors like Stripe that take the pain out of the legalities and tax compliance of being a merchant.
|
Merchants of Record (MoR) are great. They're a layer on top of payment processors like Stripe that take the pain out of the legalities and tax compliance of being a merchant.
|
||||||
|
|
||||||
<Image src={polar} alt='Polar' style='border-radius: 8px;' />
|
<Image src={polar} alt="Polar" style="border-radius: 8px;" />
|
||||||
|
|
||||||
We've added support for [Lemon Squeezy](https://www.lemonsqueezy.com/) with [Polar](https://www.polar.sh/) integration [in the works](https://github.com/wasp-lang/open-saas/issues/441#issuecomment-3057897604) as we speak! Choose the payment processor that best fits your SaaS if Stripe is too granular for your needs.
|
We've added support for [Lemon Squeezy](https://www.lemonsqueezy.com/) with [Polar](https://www.polar.sh/) integration [in the works](https://github.com/wasp-lang/open-saas/issues/441#issuecomment-3057897604) as we speak! Choose the payment processor that best fits your SaaS if Stripe is too granular for your needs.
|
||||||
|
|
||||||
@@ -99,16 +99,18 @@ One of the best use cases for LLM-assisted coding is to get precise answers to y
|
|||||||
|
|
||||||
So we've added an LLM-friendly version of the Open SaaS docs, as well as the Wasp framework docs, to make it easier for you to use in your AI coding environments.
|
So we've added an LLM-friendly version of the Open SaaS docs, as well as the Wasp framework docs, to make it easier for you to use in your AI coding environments.
|
||||||
|
|
||||||
<Image src={llmsFull} alt='LLMs-full.txt' style='border-radius: 8px;' />
|
<Image src={llmsFull} alt="LLMs-full.txt" style="border-radius: 8px;" />
|
||||||
|
|
||||||
Just add the following urls to your AI coding environment such as Cursor, Copilot, etc, and mention it in your prompt to get precise answers to your Open SaaS and Wasp framework questions.
|
Just add the following urls to your AI coding environment such as Cursor, Copilot, etc, and mention it in your prompt to get precise answers to your Open SaaS and Wasp framework questions.
|
||||||
|
|
||||||
**Open SaaS AI-friendly docs:**
|
**Open SaaS AI-friendly docs:**
|
||||||
|
|
||||||
```
|
```
|
||||||
https://docs.opensaas.sh/llms-full.txt
|
https://docs.opensaas.sh/llms-full.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
**Wasp framework AI-friendly docs:**
|
**Wasp framework AI-friendly docs:**
|
||||||
|
|
||||||
```
|
```
|
||||||
https://wasp.sh/llms-full.txt
|
https://wasp.sh/llms-full.txt
|
||||||
```
|
```
|
||||||
@@ -119,16 +121,26 @@ In addition, we've also got a full set of project-specific Cursor rules files th
|
|||||||
|
|
||||||
One thing I hate about building a SaaS is that I'm constantly worried it might break after adding a new feature. To give you peace of mind, we've integrated a full suite of end-to-end tests using [Playwright](https://playwright.dev/). These tests act like a robot user, clicking through your app to verify that everything from user sign-ups to critical payment flows is working exactly as it should.
|
One thing I hate about building a SaaS is that I'm constantly worried it might break after adding a new feature. To give you peace of mind, we've integrated a full suite of end-to-end tests using [Playwright](https://playwright.dev/). These tests act like a robot user, clicking through your app to verify that everything from user sign-ups to critical payment flows is working exactly as it should.
|
||||||
|
|
||||||
<div style='display: flex; justify-content: center;'>
|
<div style="display: flex; justify-content: center;">
|
||||||
<Image src={playwrightUi} width={1050} alt='Playwright UI' style='border-radius: 8px;' />
|
<Image
|
||||||
|
src={playwrightUi}
|
||||||
|
width={1050}
|
||||||
|
alt="Playwright UI"
|
||||||
|
style="border-radius: 8px;"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
But we didn't just write tests for ourselves. We've provided the entire setup, [including a CI/CD workflow](https://docs.opensaas.sh/guides/tests/) file for GitHub Actions. This means you can automatically run these checks on every code change, ensuring you never accidentally break your production app. Ship with confidence, knowing your core features are easily protected, and use our test suite as a foundation for your own custom test cases.
|
But we didn't just write tests for ourselves. We've provided the entire setup, [including a CI/CD workflow](https://docs.opensaas.sh/guides/tests/) file for GitHub Actions. This means you can automatically run these checks on every code change, ensuring you never accidentally break your production app. Ship with confidence, knowing your core features are easily protected, and use our test suite as a foundation for your own custom test cases.
|
||||||
|
|
||||||
### More Refactors & Improvements
|
### More Refactors & Improvements
|
||||||
|
|
||||||
<div style='display: flex; justify-content: center;'>
|
<div style="display: flex; justify-content: center;">
|
||||||
<Image src={waitTheresMore} width={250} alt='But wait theres more!' style='border-radius: 8px;' />
|
<Image
|
||||||
|
src={waitTheresMore}
|
||||||
|
width={250}
|
||||||
|
alt="But wait theres more!"
|
||||||
|
style="border-radius: 8px;"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
On top of the headlining features, we've been busy shipping a ton of other improvements to make your life easier. We've squashed bugs, upgraded major dependencies like Node.js and the Wasp framework, and completely refactored the codebase for better organization. We also threw in some handy new integrations like enhanced file uploads with progress bars and a cookie consent banner. Here's a quick rundown of other key changes:
|
On top of the headlining features, we've been busy shipping a ton of other improvements to make your life easier. We've squashed bugs, upgraded major dependencies like Node.js and the Wasp framework, and completely refactored the codebase for better organization. We also threw in some handy new integrations like enhanced file uploads with progress bars and a cookie consent banner. Here's a quick rundown of other key changes:
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ banner:
|
|||||||
content: |
|
content: |
|
||||||
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
import dbStudio from '@assets/stripe/db-studio.png';
|
import { Image } from "astro:assets";
|
||||||
import adminDashboard from '@assets/admin/admin-dashboard.png';
|
import dbStudio from "@assets/stripe/db-studio.png";
|
||||||
|
import adminDashboard from "@assets/admin/admin-dashboard.png";
|
||||||
|
|
||||||
This is a reference on how the Admin dashboard, available at `/admin`, is set up.
|
This is a reference on how the Admin dashboard, available at `/admin`, is set up.
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ model User {
|
|||||||
```
|
```
|
||||||
|
|
||||||
To give yourself administrator priveledges, make sure you add your email addresses to the `ADMIN_EMAILS` environment variable in `.env.server` file before registering/logging in with that email address:
|
To give yourself administrator priveledges, make sure you add your email addresses to the `ADMIN_EMAILS` environment variable in `.env.server` file before registering/logging in with that email address:
|
||||||
|
|
||||||
```sh title=".env.server"
|
```sh title=".env.server"
|
||||||
ADMIN_EMAILS=me@example.com
|
ADMIN_EMAILS=me@example.com
|
||||||
|
|
||||||
@@ -53,18 +55,19 @@ If you're finding this template and its guides useful, consider giving us [a sta
|
|||||||
## Admin Dashboard Pages
|
## Admin Dashboard Pages
|
||||||
|
|
||||||
### Analytics Dashboard
|
### Analytics Dashboard
|
||||||
|
|
||||||
The Admin analytics dashboard is a single place for you to view your most important metrics and perform some admin tasks. At the moment, it pulls data from:
|
The Admin analytics dashboard is a single place for you to view your most important metrics and perform some admin tasks. At the moment, it pulls data from:
|
||||||
|
|
||||||
- [Payments Processor](/guides/payments-integration/):
|
- [Payments Processor](/guides/payments-integration/):
|
||||||
- total revenue
|
- total revenue
|
||||||
- revenue for each day of the past week
|
- revenue for each day of the past week
|
||||||
- [Google or Plausible](/guides/analytics/):
|
- [Google or Plausible](/guides/analytics/):
|
||||||
- total number of page views (non-unique)
|
- total number of page views (non-unique)
|
||||||
- percentage change in page views from the previous day
|
- percentage change in page views from the previous day
|
||||||
- top sources/referrers with unique visitor count (i.e. how many people came from that source to your app)
|
- top sources/referrers with unique visitor count (i.e. how many people came from that source to your app)
|
||||||
- Database:
|
- Database:
|
||||||
- total number of registered users
|
- total number of registered users
|
||||||
- daily change in number of registered users
|
- daily change in number of registered users
|
||||||
- total number of paying users
|
- total number of paying users
|
||||||
- daily change in number of paying users
|
- daily change in number of paying users
|
||||||
|
|
||||||
@@ -83,6 +86,7 @@ job dailyStatsJob {
|
|||||||
entities: [User, DailyStats, Logs, PageViewSource]
|
entities: [User, DailyStats, Logs, PageViewSource]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For more info on Wasp's recurring background jobs, check out the [Wasp Jobs docs](https://wasp.sh/docs/advanced/jobs).
|
For more info on Wasp's recurring background jobs, check out the [Wasp Jobs docs](https://wasp.sh/docs/advanced/jobs).
|
||||||
|
|
||||||
For a guide on how to integrate these services so that you can view your analytics via the dashboard, check out the [Payments Integration](/guides/payments-integration/) and [Analytics guide](/guides/analytics/) of the docs.
|
For a guide on how to integrate these services so that you can view your analytics via the dashboard, check out the [Payments Integration](/guides/payments-integration/) and [Analytics guide](/guides/analytics/) of the docs.
|
||||||
@@ -92,7 +96,9 @@ We're always looking to improve the Admin dashboard. If you feel something is mi
|
|||||||
:::
|
:::
|
||||||
|
|
||||||
### Users
|
### Users
|
||||||
|
|
||||||
The Users page is where you can view all your users and their most important details. You can also search and filter users by:
|
The Users page is where you can view all your users and their most important details. You can also search and filter users by:
|
||||||
|
|
||||||
- email address
|
- email address
|
||||||
- subscription/payment status
|
- subscription/payment status
|
||||||
- admin status
|
- admin status
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ The `User` entity within your app is defined in the `schema.prisma` file:
|
|||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
email String? @unique
|
email String? @unique
|
||||||
username String?
|
username String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
isAdmin Boolean @default(false)
|
isAdmin Boolean @default(false)
|
||||||
paymentProcessorUserId String? @unique
|
paymentProcessorUserId String? @unique
|
||||||
@@ -29,7 +29,7 @@ model User {
|
|||||||
gptResponses GptResponse[]
|
gptResponses GptResponse[]
|
||||||
contactFormMessages ContactFormMessage[]
|
contactFormMessages ContactFormMessage[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
files File[]
|
files File[]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -61,33 +61,34 @@ model User {
|
|||||||
|
|
||||||
In general, we determine if a user has paid for an initial subscription by checking if the `subscriptionStatus` field is set. This field is set by Stripe within your webhook handler and is used to signify more detailed information on the user's current status. By default, the template handles four statuses: `active`, `past_due`, `cancel_at_period_end`, and `deleted`.
|
In general, we determine if a user has paid for an initial subscription by checking if the `subscriptionStatus` field is set. This field is set by Stripe within your webhook handler and is used to signify more detailed information on the user's current status. By default, the template handles four statuses: `active`, `past_due`, `cancel_at_period_end`, and `deleted`.
|
||||||
|
|
||||||
- When `active` the user has paid for a subscription and has full access to the app.
|
- When `active` the user has paid for a subscription and has full access to the app.
|
||||||
|
|
||||||
- When `cancel_at_period_end`, the user has canceled their subscription and has access to the app until the end of their billing period.
|
- When `cancel_at_period_end`, the user has canceled their subscription and has access to the app until the end of their billing period.
|
||||||
|
|
||||||
- When `deleted`, the user has reached the end of their subscription period after canceling and no longer has access to the app.
|
- When `deleted`, the user has reached the end of their subscription period after canceling and no longer has access to the app.
|
||||||
|
|
||||||
- When `past_due`, the user's automatic subscription renewal payment was declined (e.g. their credit card expired). You can choose how to handle this status within your app. For example, you can send the user an email to update their payment information:
|
- When `past_due`, the user's automatic subscription renewal payment was declined (e.g. their credit card expired). You can choose how to handle this status within your app. For example, you can send the user an email to update their payment information:
|
||||||
```tsx title="src/payment/stripe/webhook.ts"
|
|
||||||
|
```tsx title="src/payment/stripe/webhook.ts"
|
||||||
import { emailSender } from "wasp/server/email";
|
import { emailSender } from "wasp/server/email";
|
||||||
//...
|
//...
|
||||||
|
|
||||||
if (subscription.status === 'past_due') {
|
if (subscription.status === "past_due") {
|
||||||
const updatedCustomer = await context.entities.User.update({
|
const updatedCustomer = await context.entities.User.update({
|
||||||
where: {
|
where: {
|
||||||
id: customer.id,
|
id: customer.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
subscriptionStatus: 'past_due',
|
subscriptionStatus: "past_due",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (updatedCustomer.email) {
|
if (updatedCustomer.email) {
|
||||||
await emailSender.send({
|
await emailSender.send({
|
||||||
to: updatedCustomer.email,
|
to: updatedCustomer.email,
|
||||||
subject: 'Your Payment is Past Due',
|
subject: "Your Payment is Past Due",
|
||||||
text: 'Please update your payment information to continue using our service.',
|
text: "Please update your payment information to continue using our service.",
|
||||||
html: '...',
|
html: "...",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,9 +98,9 @@ See the client-side [authorization section](/guides/authorization/) below for mo
|
|||||||
|
|
||||||
### Subscription Plans
|
### Subscription Plans
|
||||||
|
|
||||||
The `subscriptionPlan` field is used to determine what features the user has access to.
|
The `subscriptionPlan` field is used to determine what features the user has access to.
|
||||||
|
|
||||||
By default, we have three plans: `hobby` and `pro` subscription plans, as well as a `credits10` one-time purchase plan.
|
By default, we have three plans: `hobby` and `pro` subscription plans, as well as a `credits10` one-time purchase plan.
|
||||||
|
|
||||||
You can add more plans by adding more products and price IDs to your Stripe product and updating environment variables in your `.env.server` file as well as the relevant code in your app.
|
You can add more plans by adding more products and price IDs to your Stripe product and updating environment variables in your `.env.server` file as well as the relevant code in your app.
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ banner:
|
|||||||
content: |
|
content: |
|
||||||
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
||||||
---
|
---
|
||||||
|
|
||||||
This guide will show you how to integrate analytics for your app. You can choose between [Google Analytics](#google-analytics) and [Plausible](#plausible).
|
This guide will show you how to integrate analytics for your app. You can choose between [Google Analytics](#google-analytics) and [Plausible](#plausible).
|
||||||
|
|
||||||
Google Analytics is free, but uses cookies, so you'll probably want/need to implement the [Cookie Consent Modal](/guides/cookie-consent/) when using it.
|
Google Analytics is free, but uses cookies, so you'll probably want/need to implement the [Cookie Consent Modal](/guides/cookie-consent/) when using it.
|
||||||
@@ -15,6 +16,7 @@ If you're looking to add analytics to your blog, you can follow the [Adding Anal
|
|||||||
## Plausible
|
## Plausible
|
||||||
|
|
||||||
### Hosted Plausible
|
### Hosted Plausible
|
||||||
|
|
||||||
Sign up for a hosted Plausible account [here](https://plausible.io/).
|
Sign up for a hosted Plausible account [here](https://plausible.io/).
|
||||||
|
|
||||||
Once you've signed up, you'll be taken to your dashboard. Create your site by adding your domain. Your domain is also your `PLAUSIBLE_SITE_ID` in your `.env.server` file. Make sure to add it.
|
Once you've signed up, you'll be taken to your dashboard. Create your site by adding your domain. Your domain is also your `PLAUSIBLE_SITE_ID` in your `.env.server` file. Make sure to add it.
|
||||||
@@ -23,7 +25,7 @@ Once you've signed up, you'll be taken to your dashboard. Create your site by ad
|
|||||||
PLAUSIBLE_SITE_ID=<your domain without www>
|
PLAUSIBLE_SITE_ID=<your domain without www>
|
||||||
```
|
```
|
||||||
|
|
||||||
After adding your domain, you'll be taken to a page with your Plausible script tag. Copy and paste this script tag into the `main.wasp` file's head section.
|
After adding your domain, you'll be taken to a page with your Plausible script tag. Copy and paste this script tag into the `main.wasp` file's head section.
|
||||||
|
|
||||||
```js {7}
|
```js {7}
|
||||||
app OpenSaaS {
|
app OpenSaaS {
|
||||||
@@ -47,11 +49,11 @@ Plausible does not use cookies, so you don't need to add it to your [Cookie Cons
|
|||||||
|
|
||||||
Plausible, being an open-source project, allows you to self-host your analytics. This is a great option if you want to keep your data private and not pay for the hosted service.
|
Plausible, being an open-source project, allows you to self-host your analytics. This is a great option if you want to keep your data private and not pay for the hosted service.
|
||||||
|
|
||||||
*coming soon...*
|
_coming soon..._
|
||||||
*until then, check out the [official documentation](https://plausible.io/docs)*
|
_until then, check out the [official documentation](https://plausible.io/docs)_
|
||||||
|
|
||||||
:::tip[Contribute!]
|
:::tip[Contribute!]
|
||||||
If you'd like to help us write this guide, click the "Edit page" button at the bottom of this page
|
If you'd like to help us write this guide, click the "Edit page" button at the bottom of this page
|
||||||
|
|
||||||
As a completely free, open-source project, we appreciate any help 🙏
|
As a completely free, open-source project, we appreciate any help 🙏
|
||||||
:::
|
:::
|
||||||
@@ -62,12 +64,21 @@ First off, head over to `src/analytics/stats.ts` and switch out the Plausible Pr
|
|||||||
|
|
||||||
```ts ins={3} del={2} title="stats.ts"
|
```ts ins={3} del={2} title="stats.ts"
|
||||||
//...
|
//...
|
||||||
import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils';
|
import {
|
||||||
import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
|
getDailyPageViews,
|
||||||
|
getSources,
|
||||||
|
} from "./providers/plausibleAnalyticsUtils";
|
||||||
|
import {
|
||||||
|
getDailyPageViews,
|
||||||
|
getSources,
|
||||||
|
} from "./providers/googleAnalyticsUtils";
|
||||||
|
|
||||||
export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, context) => {
|
export const calculateDailyStats: DailyStatsJob<never, void> = async (
|
||||||
|
_args,
|
||||||
|
context,
|
||||||
|
) => {
|
||||||
//...
|
//...
|
||||||
}
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Next, make sure you sign up for [Google analytics](https://analytics.google.com/), then go to your `Admin` panel in the bottom of the left sidebar and then create a "Property" for your app.
|
Next, make sure you sign up for [Google analytics](https://analytics.google.com/), then go to your `Admin` panel in the bottom of the left sidebar and then create a "Property" for your app.
|
||||||
@@ -77,6 +88,7 @@ Once you've created a new Property, some Installation Instructions will pop up.
|
|||||||
```sh title="<your-google-analytics-id>"
|
```sh title="<your-google-analytics-id>"
|
||||||
https://www.googletagmanager.com/gtag/js?id=<your-google-analytics-id>
|
https://www.googletagmanager.com/gtag/js?id=<your-google-analytics-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
and copy and paste the Google Analytics ID into your `.env.client` file to get it working with the [Cookie Consent Modal](/guides/cookie-consent/) provided with this template:
|
and copy and paste the Google Analytics ID into your `.env.client` file to get it working with the [Cookie Consent Modal](/guides/cookie-consent/) provided with this template:
|
||||||
|
|
||||||
```sh title=".env.client"
|
```sh title=".env.client"
|
||||||
@@ -96,23 +108,22 @@ Then, set up the Google Analytics API access by following these steps:
|
|||||||
|
|
||||||
3. **Create credentials:** Now go to the "Credentials" tab within your Google Cloud project, click on `+ credentials`, and create a new service account key. First, give it a name. Then, under "Grant this service account access to project", choose `viewer`.
|
3. **Create credentials:** Now go to the "Credentials" tab within your Google Cloud project, click on `+ credentials`, and create a new service account key. First, give it a name. Then, under "Grant this service account access to project", choose `viewer`.
|
||||||
|
|
||||||
4. **Create Credentials:** When you go back to `Credentials` page, you should see a new service account listed under "Service Accounts". It will be a long email address to ends with `@your-project-id.iam.gserviceaccount.com`. Click on the service account name to go to the service account details page.
|
4. **Create Credentials:** When you go back to `Credentials` page, you should see a new service account listed under "Service Accounts". It will be a long email address to ends with `@your-project-id.iam.gserviceaccount.com`. Click on the service account name to go to the service account details page.
|
||||||
|
|
||||||
|
- Under "Keys" in the service account details page, click "Add Key" and choose `Create new key`.
|
||||||
|
- Select "JSON", then click "Create" to download your new service account's JSON key file. Keep this file secure and don't add it to your git repo as it grants access to your Google Analytics data.
|
||||||
|
|
||||||
- Under "Keys" in the service account details page, click "Add Key" and choose `Create new key`.
|
|
||||||
|
|
||||||
- Select "JSON", then click "Create" to download your new service account's JSON key file. Keep this file secure and don't add it to your git repo as it grants access to your Google Analytics data.
|
|
||||||
5. **Update your Google Anayltics Settings:** Go back to your Google Analytics dashboard, and click on the `Admin` section in the left sidebar. Under `Property Settings > Property > Property Access Management` Add the service account email address (the one that ends with `@your-project-id.iam.gserviceaccount.com`) and give it `Viewer` permissions.
|
5. **Update your Google Anayltics Settings:** Go back to your Google Analytics dashboard, and click on the `Admin` section in the left sidebar. Under `Property Settings > Property > Property Access Management` Add the service account email address (the one that ends with `@your-project-id.iam.gserviceaccount.com`) and give it `Viewer` permissions.
|
||||||
|
|
||||||
6. **Encode and add the Credentials:** Add the `client_email` and the `private_key` from your JSON Key file into your `.env.server` file. But be careful! Because Google uses a special PEM private key, you need to first convert the key to base64, otherwise you will run into errors parsing the key. To do this, in a terminal window, run the command below and paste the output into your `.env.server` file under the `GOOGLE_ANALYTICS_PRIVATE_KEY` variable:
|
6. **Encode and add the Credentials:** Add the `client_email` and the `private_key` from your JSON Key file into your `.env.server` file. But be careful! Because Google uses a special PEM private key, you need to first convert the key to base64, otherwise you will run into errors parsing the key. To do this, in a terminal window, run the command below and paste the output into your `.env.server` file under the `GOOGLE_ANALYTICS_PRIVATE_KEY` variable:
|
||||||
```sh
|
```sh
|
||||||
echo -n "-----BEGIN PRIVATE KEY-----\nMI...A++eK\n-----END PRIVATE KEY-----\n" | base64
|
echo -n "-----BEGIN PRIVATE KEY-----\nMI...A++eK\n-----END PRIVATE KEY-----\n" | base64
|
||||||
```
|
```
|
||||||
|
|
||||||
7. **Add your Google Analytics Property ID:** You will find the Property ID in your Google Analytics dashboard in the `Admin > Property > Property Settings > Property Details` section of your Google Analytics property (**not** your Google Cloud console). Add this 9-digit number to your `.env.server` file under the `GOOGLE_ANALYTICS_PROPERTY_ID` variable.
|
7. **Add your Google Analytics Property ID:** You will find the Property ID in your Google Analytics dashboard in the `Admin > Property > Property Settings > Property Details` section of your Google Analytics property (**not** your Google Cloud console). Add this 9-digit number to your `.env.server` file under the `GOOGLE_ANALYTICS_PROPERTY_ID` variable.
|
||||||
|
|
||||||
## Adding Analytics to your Blog
|
## Adding Analytics to your Blog
|
||||||
|
|
||||||
To add your analytics script to your Astro Starlight blog, all you need to do is modify the `head` property in your `blog/astro.config.mjs` file.
|
To add your analytics script to your Astro Starlight blog, all you need to do is modify the `head` property in your `blog/astro.config.mjs` file.
|
||||||
|
|
||||||
Below is an example of how to add Google Analytics to your blog:
|
Below is an example of how to add Google Analytics to your blog:
|
||||||
|
|
||||||
@@ -120,7 +131,7 @@ Below is an example of how to add Google Analytics to your blog:
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://opensaas.sh',
|
site: 'https://opensaas.sh',
|
||||||
integrations: [
|
integrations: [
|
||||||
starlightBlog({
|
starlightBlog({
|
||||||
// ...
|
// ...
|
||||||
}),
|
}),
|
||||||
starlight({
|
starlight({
|
||||||
@@ -138,7 +149,7 @@ export default defineConfig({
|
|||||||
window.dataLayer = window.dataLayer || [];
|
window.dataLayer = window.dataLayer || [];
|
||||||
function gtag(){dataLayer.push(arguments);}
|
function gtag(){dataLayer.push(arguments);}
|
||||||
gtag('js', new Date());
|
gtag('js', new Date());
|
||||||
|
|
||||||
gtag('config', '<YOUR-GOOGLE-ANALYTICS-ID>');
|
gtag('config', '<YOUR-GOOGLE-ANALYTICS-ID>');
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ banner:
|
|||||||
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
||||||
---
|
---
|
||||||
|
|
||||||
Setting up your app's authentication is easy with Wasp. In fact, it's already set up for you in the `main.wasp` file:
|
Setting up your app's authentication is easy with Wasp. In fact, it's already set up for you in the `main.wasp` file:
|
||||||
|
|
||||||
```tsx title="main.wasp"
|
```tsx title="main.wasp"
|
||||||
auth: {
|
auth: {
|
||||||
userEntity: User,
|
userEntity: User,
|
||||||
methods: {
|
methods: {
|
||||||
email: {},
|
email: {},
|
||||||
google: {},
|
google: {},
|
||||||
gitHub: {},
|
gitHub: {},
|
||||||
discord: {}
|
discord: {}
|
||||||
@@ -38,19 +38,20 @@ You can then follow these links to verify the user and continue with the sign-up
|
|||||||
provider: Dummy, // logs all email verification links/tokens to the server's console
|
provider: Dummy, // logs all email verification links/tokens to the server's console
|
||||||
defaultFrom: {
|
defaultFrom: {
|
||||||
name: "Open SaaS App",
|
name: "Open SaaS App",
|
||||||
email: "me@example.com"
|
email: "me@example.com"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
You **can not use the Dummy provider in production** and your app **will not build** until you move to a production-ready provider, such as SendGrid. We outline the process of migrating to SendGrid below.
|
You **can not use the Dummy provider in production** and your app **will not build** until you move to a production-ready provider, such as SendGrid. We outline the process of migrating to SendGrid below.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
In order to use the `email` auth method in production, you'll need to switch from the `Dummy` "email sender" provider to a production-ready provider like SendGrid:
|
In order to use the `email` auth method in production, you'll need to switch from the `Dummy` "email sender" provider to a production-ready provider like SendGrid:
|
||||||
|
|
||||||
1. First, set up your app's `emailSender` in the `main.wasp` file by following [this guide](/guides/email-sending/#integrate-your-email-sender).
|
1. First, set up your app's `emailSender` in the `main.wasp` file by following [this guide](/guides/email-sending/#integrate-your-email-sender).
|
||||||
2. Add your `SENDGRID_API_KEY` to the `.env.server` file.
|
2. Add your `SENDGRID_API_KEY` to the `.env.server` file.
|
||||||
3. Make sure the email address you use in the `fromField` object is the same email address that you configured your SendGrid account to send out emails with. In the end, your `main.wasp` file should look something like this:
|
3. Make sure the email address you use in the `fromField` object is the same email address that you configured your SendGrid account to send out emails with. In the end, your `main.wasp` file should look something like this:
|
||||||
|
|
||||||
```ts title="main.wasp" {6,7} del={15} ins={16}
|
```ts title="main.wasp" {6,7} del={15} ins={16}
|
||||||
auth: {
|
auth: {
|
||||||
methods: {
|
methods: {
|
||||||
@@ -58,10 +59,10 @@ In order to use the `email` auth method in production, you'll need to switch fro
|
|||||||
fromField: {
|
fromField: {
|
||||||
name: "Open SaaS App",
|
name: "Open SaaS App",
|
||||||
// When using SendGrid, you must use the same email address that you configured your account to send out emails with!
|
// When using SendGrid, you must use the same email address that you configured your account to send out emails with!
|
||||||
email: "me@example.com"
|
email: "me@example.com"
|
||||||
},
|
},
|
||||||
//...
|
//...
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
//...
|
//...
|
||||||
@@ -71,19 +72,18 @@ In order to use the `email` auth method in production, you'll need to switch fro
|
|||||||
defaultFrom: {
|
defaultFrom: {
|
||||||
name: "Open SaaS App",
|
name: "Open SaaS App",
|
||||||
// When using SendGrid, you must use the same email address that you configured your account to send out emails with!
|
// When using SendGrid, you must use the same email address that you configured your account to send out emails with!
|
||||||
email: "me@example.com"
|
email: "me@example.com"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
And that's it. Wasp will take care of the rest and update your AuthUI components accordingly.
|
And that's it. Wasp will take care of the rest and update your AuthUI components accordingly.
|
||||||
|
|
||||||
Check out the [Wasp Auth docs](https://wasp.sh/docs/auth/overview) for more info.
|
Check out the [Wasp Auth docs](https://wasp.sh/docs/auth/overview) for more info.
|
||||||
|
|
||||||
## Google, GitHub, & Discord Auth
|
## Google, GitHub, & Discord Auth
|
||||||
|
|
||||||
We've also customized and pre-built the Google and GitHub auth flow for you. To start using them, you just need to uncomment out the methods you want in your `main.wasp` file and obtain the proper API keys to add to your `.env.server` file.
|
We've also customized and pre-built the Google and GitHub auth flow for you. To start using them, you just need to uncomment out the methods you want in your `main.wasp` file and obtain the proper API keys to add to your `.env.server` file.
|
||||||
|
|
||||||
To create a Google OAuth app and get your Google API keys, follow the instructions in [Wasp's Google Auth docs](https://wasp.sh/docs/auth/social-auth/google#3-creating-a-google-oauth-app).
|
To create a Google OAuth app and get your Google API keys, follow the instructions in [Wasp's Google Auth docs](https://wasp.sh/docs/auth/social-auth/google#3-creating-a-google-oauth-app).
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ banner:
|
|||||||
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
||||||
---
|
---
|
||||||
|
|
||||||
This guide will help you get started with authorization in your SaaS app.
|
This guide will help you get started with authorization in your SaaS app.
|
||||||
|
|
||||||
Authorization refers to what users can access in your app. This is useful for differentiating between users who have paid for different subscription tiers (e.g. "hobby" vs "pro"), or between users who have admin privileges and those who do not.
|
Authorization refers to what users can access in your app. This is useful for differentiating between users who have paid for different subscription tiers (e.g. "hobby" vs "pro"), or between users who have admin privileges and those who do not.
|
||||||
|
|
||||||
@@ -38,23 +38,26 @@ Actually ensuring they don't have access to the data, that is on the server to e
|
|||||||
:::
|
:::
|
||||||
|
|
||||||
If you want more fine-grained control over what users can access, there are two Wasp-specific options:
|
If you want more fine-grained control over what users can access, there are two Wasp-specific options:
|
||||||
|
|
||||||
1. When you define the `authRequired: true` property on the `page` definition, Wasp automatically passes the User object to the page component. Here you can check for certain user properties before authorizing access:
|
1. When you define the `authRequired: true` property on the `page` definition, Wasp automatically passes the User object to the page component. Here you can check for certain user properties before authorizing access:
|
||||||
|
|
||||||
```tsx title="ExamplePage.tsx" "{ user }: { user: User }"
|
```tsx title="ExamplePage.tsx" "{ user }: { user: User }"
|
||||||
import { type User } from "wasp/entities";
|
import { type User } from "wasp/entities";
|
||||||
|
|
||||||
export default function Example({ user }: { user: User }) {
|
export default function Example({ user }: { user: User }) {
|
||||||
|
if (user.subscriptionStatus === "past_due") {
|
||||||
if (user.subscriptionStatus === 'past_due') {
|
return (
|
||||||
return (<span>Your subscription is past due. Please update your payment information.</span>)
|
<span>
|
||||||
|
Your subscription is past due. Please update your payment information.
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (user.subscriptionStatus === 'cancel_at_period_end') {
|
if (user.subscriptionStatus === "cancel_at_period_end") {
|
||||||
return (<span>Your susbscription will end on 01.01.2024</span>)
|
return <span>Your susbscription will end on 01.01.2024</span>;
|
||||||
}
|
}
|
||||||
if (user.subscriptionStatus === 'active') {
|
if (user.subscriptionStatus === "active") {
|
||||||
return (<span>Thanks so much for your support!</span>)
|
return <span>Thanks so much for your support!</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -66,9 +69,7 @@ import { useAuth } from "wasp/client/auth";
|
|||||||
export default function ExampleHomePage() {
|
export default function ExampleHomePage() {
|
||||||
const { data: user } = useAuth();
|
const { data: user } = useAuth();
|
||||||
|
|
||||||
return (
|
return <h1> Hi {user.email || "there"} 👋 </h1>;
|
||||||
<h1> Hi {user.email || 'there'} 👋 </h1>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ Authorization on the server-side is the core of your access control logic, and d
|
|||||||
|
|
||||||
You can authorize access to server-side operations by adding a check for a logged-in user on the `context.user` object which is passed to all operations in Wasp:
|
You can authorize access to server-side operations by adding a check for a logged-in user on the `context.user` object which is passed to all operations in Wasp:
|
||||||
|
|
||||||
```tsx title="src/server/actions.ts"
|
```tsx title="src/server/actions.ts"
|
||||||
export const someServerAction: SomeServerAction<...> = async (args, context) => {
|
export const someServerAction: SomeServerAction<...> = async (args, context) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new HttpError(401); // throw an error if user is not logged in
|
throw new HttpError(401); // throw an error if user is not logged in
|
||||||
@@ -90,5 +91,3 @@ export const someServerAction: SomeServerAction<...> = async (args, context) =>
|
|||||||
//...
|
//...
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,19 +4,21 @@ banner:
|
|||||||
content: |
|
content: |
|
||||||
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
import cookieBanner from '@assets/cookie-consent/cookiebanner.png';
|
import { Image } from "astro:assets";
|
||||||
import preferences from '@assets/cookie-consent/preferences.png';
|
import cookieBanner from "@assets/cookie-consent/cookiebanner.png";
|
||||||
|
import preferences from "@assets/cookie-consent/preferences.png";
|
||||||
|
|
||||||
<Image src={cookieBanner} alt="cookie banner" width="400px" />
|
<Image src={cookieBanner} alt="cookie banner" width="400px" />
|
||||||
|
|
||||||
Cookie consent banners are annoying, we know. But they are legally required in many countries, so we have to deal with them.
|
Cookie consent banners are annoying, we know. But they are legally required in many countries, so we have to deal with them.
|
||||||
|
|
||||||
This guide will help you dynamically add or remove cookies from your app via the Cookie Consent modal that comes with this template.
|
This guide will help you dynamically add or remove cookies from your app via the Cookie Consent modal that comes with this template.
|
||||||
|
|
||||||
This is needed for *non-essential cookies* that are not necessary for the basic functionality of your app, such as analytics cookies or marketing cookies.
|
This is needed for _non-essential cookies_ that are not necessary for the basic functionality of your app, such as analytics cookies or marketing cookies.
|
||||||
|
|
||||||
The Modal can be found at `app/src/client/components/cookie-consent/` and contains two main files:
|
The Modal can be found at `app/src/client/components/cookie-consent/` and contains two main files:
|
||||||
|
|
||||||
1. `Banner.tsx` - the component that displays the banner at the bottom of the page.
|
1. `Banner.tsx` - the component that displays the banner at the bottom of the page.
|
||||||
2. `Config.ts` - the configuration file that contains the cookies/scripts that will be dynamically added.
|
2. `Config.ts` - the configuration file that contains the cookies/scripts that will be dynamically added.
|
||||||
|
|
||||||
@@ -33,7 +35,7 @@ Below, we will guide you through the necessary steps to get the cookie consent m
|
|||||||
What's impotant to note for this template is that we are simply using the `onAccept` callbacks to dynamically add or remove our [Google Analytics](/guides/analytics/#google-analytics) cookies from the page. In order for it to work correctly with your app, you need to add your [Google Analytics ID](/guides/analytics/#google-analytics) to your `.env.client` file.
|
What's impotant to note for this template is that we are simply using the `onAccept` callbacks to dynamically add or remove our [Google Analytics](/guides/analytics/#google-analytics) cookies from the page. In order for it to work correctly with your app, you need to add your [Google Analytics ID](/guides/analytics/#google-analytics) to your `.env.client` file.
|
||||||
|
|
||||||
```sh title=".env.client"
|
```sh title=".env.client"
|
||||||
REACT_APP_GOOGLE_ANALYTICS_ID=G-1234567890
|
REACT_APP_GOOGLE_ANALYTICS_ID=G-1234567890
|
||||||
```
|
```
|
||||||
|
|
||||||
And that's it! The cookie consent modal will now dynamically add or remove the Google Analytics cookies based on the user's choice.
|
And that's it! The cookie consent modal will now dynamically add or remove the Google Analytics cookies based on the user's choice.
|
||||||
@@ -42,7 +44,7 @@ To check if it's working correctly, you can open the browser's developer tools a
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
_ga
|
_ga
|
||||||
_ga... # Google Analytics cookies.
|
_ga... # Google Analytics cookies.
|
||||||
cc_cookie # Cookie Consent cookie. The name of this cookie can be changed in the config file.
|
cc_cookie # Cookie Consent cookie. The name of this cookie can be changed in the config file.
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -81,7 +83,7 @@ If you've added more than just Google Analytics cookies to your app, you can all
|
|||||||
|
|
||||||
<Image src={preferences} alt="fine-grained cookie control" loading="lazy" />
|
<Image src={preferences} alt="fine-grained cookie control" loading="lazy" />
|
||||||
|
|
||||||
To do that, you can change the `preferencesModal.sections` property in `config.language`. Any section that you add to `preferencesModal.sections` must match a `linkedCategory` in the `config.categories` property. Make sure you also add a `showPreferencesBtn` property to `consentModal` (highlighted below).
|
To do that, you can change the `preferencesModal.sections` property in `config.language`. Any section that you add to `preferencesModal.sections` must match a `linkedCategory` in the `config.categories` property. Make sure you also add a `showPreferencesBtn` property to `consentModal` (highlighted below).
|
||||||
|
|
||||||
Below is an example of what your config might look like if you want to give users the option to control over multiple cookie preferences:
|
Below is an example of what your config might look like if you want to give users the option to control over multiple cookie preferences:
|
||||||
|
|
||||||
@@ -142,7 +144,7 @@ Below is an example of what your config might look like if you want to give user
|
|||||||
title: 'YouTube',
|
title: 'YouTube',
|
||||||
description: 'This service is used to display video content on the website.',
|
description: 'This service is used to display video content on the website.',
|
||||||
linkedCategory: 'youtube',
|
linkedCategory: 'youtube',
|
||||||
cookieTable: {
|
cookieTable: {
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ banner:
|
|||||||
content: |
|
content: |
|
||||||
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
import npmVersion from '@assets/stripe/npm-version.png';
|
import { Image } from "astro:assets";
|
||||||
import stripeListenEvents from '@assets/stripe/listen-to-stripe-events.png';
|
import npmVersion from "@assets/stripe/npm-version.png";
|
||||||
import stripeSigningSecret from '@assets/stripe/stripe-webhook-signing-secret.png';
|
import stripeListenEvents from "@assets/stripe/listen-to-stripe-events.png";
|
||||||
|
import stripeSigningSecret from "@assets/stripe/stripe-webhook-signing-secret.png";
|
||||||
|
|
||||||
Because this SaaS app is a React/NodeJS/Postgres app built on top of [Wasp](https://wasp.sh), Open SaaS can take advantage of Wasp's easy, one-command deploy to Fly.io or manual deploy to any provider of your choice.
|
Because this SaaS app is a React/NodeJS/Postgres app built on top of [Wasp](https://wasp.sh), Open SaaS can take advantage of Wasp's easy, one-command deploy to Fly.io or manual deploy to any provider of your choice.
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ The simplest and quickest option is to take advantage of Wasp's one-command depl
|
|||||||
Or if you prefer to deploy to a different provider, or your frontend and backend separately, you can follow the Deploying Manually section below.
|
Or if you prefer to deploy to a different provider, or your frontend and backend separately, you can follow the Deploying Manually section below.
|
||||||
|
|
||||||
## Deploying your App
|
## Deploying your App
|
||||||
|
|
||||||
### Steps for Deploying
|
### Steps for Deploying
|
||||||
|
|
||||||
These are the steps necessary for you to deploy your app. We recommend you follow these steps in order.
|
These are the steps necessary for you to deploy your app. We recommend you follow these steps in order.
|
||||||
@@ -30,50 +32,63 @@ These are the steps necessary for you to deploy your app. We recommend you follo
|
|||||||
Each of these steps is covered in more detail below.
|
Each of these steps is covered in more detail below.
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
#### AWS S3 CORS configuration
|
#### AWS S3 CORS configuration
|
||||||
|
|
||||||
If you're storing files in AWS S3, ensure you've listed your production domain
|
If you're storing files in AWS S3, ensure you've listed your production domain
|
||||||
in the bucket's CORS configuration under `AllowedOrigins`. Check the [File
|
in the bucket's CORS configuration under `AllowedOrigins`. Check the [File
|
||||||
uploading guide](/guides/file-uploading/#change-the-cors-settings) for details.
|
uploading guide](/guides/file-uploading/#change-the-cors-settings) for details.
|
||||||
|
|
||||||
#### Env Vars
|
#### Env Vars
|
||||||
|
|
||||||
Make sure you've got all your API keys and environment variables set up before you deploy.
|
Make sure you've got all your API keys and environment variables set up before you deploy.
|
||||||
|
|
||||||
##### Payment Processor Vars
|
##### Payment Processor Vars
|
||||||
|
|
||||||
In the [Payments Processor integration guide](/guides/payments-integration/), you set up your API keys using test keys and test product ids. You'll need to get the live/production versions of those keys. To get these, repeat the instructions in the [Integration Guide](/guides/payments-integration/) without being in test mode. Add the new keys to your deployed environment secrets.
|
In the [Payments Processor integration guide](/guides/payments-integration/), you set up your API keys using test keys and test product ids. You'll need to get the live/production versions of those keys. To get these, repeat the instructions in the [Integration Guide](/guides/payments-integration/) without being in test mode. Add the new keys to your deployed environment secrets.
|
||||||
|
|
||||||
##### Other Vars
|
##### Other Vars
|
||||||
|
|
||||||
Many of your other environment variables will probably be the same as in development, but you should double-check that they are set correctly for production.
|
Many of your other environment variables will probably be the same as in development, but you should double-check that they are set correctly for production.
|
||||||
|
|
||||||
Here are a list of all of them (some of which you may not be using, e.g. Analytics, Social Auth) in case you need to check:
|
Here are a list of all of them (some of which you may not be using, e.g. Analytics, Social Auth) in case you need to check:
|
||||||
|
|
||||||
###### General Vars
|
###### General Vars
|
||||||
|
|
||||||
- [ ] `DATABASE_URL`
|
- [ ] `DATABASE_URL`
|
||||||
- [ ] `JWT_SECRET`
|
- [ ] `JWT_SECRET`
|
||||||
- [ ] `WASP_WEB_CLIENT_URL`
|
- [ ] `WASP_WEB_CLIENT_URL`
|
||||||
- [ ] `WASP_SERVER_URL`
|
- [ ] `WASP_SERVER_URL`
|
||||||
|
|
||||||
###### Open AI API Key
|
###### Open AI API Key
|
||||||
|
|
||||||
- [ ] `OPENAI_API_KEY`
|
- [ ] `OPENAI_API_KEY`
|
||||||
|
|
||||||
###### Sendgrid API Key
|
###### Sendgrid API Key
|
||||||
|
|
||||||
- [ ] `SENDGRID_API_KEY`
|
- [ ] `SENDGRID_API_KEY`
|
||||||
|
|
||||||
###### Social Auth Vars
|
###### Social Auth Vars
|
||||||
|
|
||||||
- [ ] `GOOGLE_CLIENT_ID`
|
- [ ] `GOOGLE_CLIENT_ID`
|
||||||
- [ ] `GOOGLE_CLIENT_SECRET`
|
- [ ] `GOOGLE_CLIENT_SECRET`
|
||||||
- [ ] `GITHUB_CLIENT_ID`
|
- [ ] `GITHUB_CLIENT_ID`
|
||||||
- [ ] `GITHUB_CLIENT_SECRET`
|
- [ ] `GITHUB_CLIENT_SECRET`
|
||||||
|
|
||||||
###### Analytics Vars
|
###### Analytics Vars
|
||||||
|
|
||||||
- [ ] `REACT_APP_PLAUSIBLE_ANALYTICS_ID` (for client-side)
|
- [ ] `REACT_APP_PLAUSIBLE_ANALYTICS_ID` (for client-side)
|
||||||
- [ ] `PLAUSIBLE_API_KEY`
|
- [ ] `PLAUSIBLE_API_KEY`
|
||||||
- [ ] `PLAUSIBLE_SITE_ID`
|
- [ ] `PLAUSIBLE_SITE_ID`
|
||||||
- [ ] `PLAUSIBLE_BASE_URL`
|
- [ ] `PLAUSIBLE_BASE_URL`
|
||||||
- [ ] `REACT_APP_GOOGLE_ANALYTICS_ID` (for client-side)
|
- [ ] `REACT_APP_GOOGLE_ANALYTICS_ID` (for client-side)
|
||||||
- [ ] `GOOGLE_ANALYTICS_CLIENT_EMAIL`
|
- [ ] `GOOGLE_ANALYTICS_CLIENT_EMAIL`
|
||||||
- [ ] `GOOGLE_ANALYTICS_PROPERTY_ID`
|
- [ ] `GOOGLE_ANALYTICS_PROPERTY_ID`
|
||||||
- [ ] `GOOGLE_ANALYTICS_PRIVATE_KEY`
|
- [ ] `GOOGLE_ANALYTICS_PRIVATE_KEY`
|
||||||
(Make sure you convert the private key within the JSON file to base64 first with `echo -n "PRIVATE_KEY" | base64`. See the [Analytics docs](/guides/analytics/#google-analytics) for more info)
|
(Make sure you convert the private key within the JSON file to base64 first with `echo -n "PRIVATE_KEY" | base64`. See the [Analytics docs](/guides/analytics/#google-analytics) for more info)
|
||||||
|
|
||||||
###### AWS S3 Vars
|
###### AWS S3 Vars
|
||||||
|
|
||||||
- [ ] `AWS_S3_IAM_ACCESS_KEY`
|
- [ ] `AWS_S3_IAM_ACCESS_KEY`
|
||||||
- [ ] `AWS_S3_IAM_SECRET_KEY`
|
- [ ] `AWS_S3_IAM_SECRET_KEY`
|
||||||
- [ ] `AWS_S3_FILES_BUCKET`
|
- [ ] `AWS_S3_FILES_BUCKET`
|
||||||
@@ -94,12 +109,14 @@ Do you have an Open SaaS app running in production? If yes, we'd love to send so
|
|||||||
To learn how, please follow the detailed guide for [deploying to Fly via the Wasp CLI](https://wasp.sh/docs/deployment/deployment-methods/cli) from the Wasp documentation. We suggest you follow this guide carefully to get your app deployed.
|
To learn how, please follow the detailed guide for [deploying to Fly via the Wasp CLI](https://wasp.sh/docs/deployment/deployment-methods/cli) from the Wasp documentation. We suggest you follow this guide carefully to get your app deployed.
|
||||||
|
|
||||||
:::caution[Setting Environment Variables]
|
:::caution[Setting Environment Variables]
|
||||||
Remember, because we've set certain client-side env variables, make sure to pass them to the `wasp deploy` commands so that they can be included in the build:
|
Remember, because we've set certain client-side env variables, make sure to pass them to the `wasp deploy` commands so that they can be included in the build:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
REACT_APP_CLIENT_ENV_VAR_1=<...> REACT_APP_CLIENT_ENV_VAR_2=<...> wasp deploy
|
REACT_APP_CLIENT_ENV_VAR_1= < ... > REACT_APP_CLIENT_ENV_VAR_2= < ... > wasp deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
The `wasp deploy` command will also take care of setting the following server-side environment variables for you so you don't have to:
|
The `wasp deploy` command will also take care of setting the following server-side environment variables for you so you don't have to:
|
||||||
|
|
||||||
- `DATABASE_URL`
|
- `DATABASE_URL`
|
||||||
- `PORT`
|
- `PORT`
|
||||||
- `JWT_SECRET`
|
- `JWT_SECRET`
|
||||||
@@ -114,10 +131,12 @@ For setting the remaining server-side environment variables, please refer to the
|
|||||||
If you prefer to deploy manually, your frontend and backend separately, or just prefer using your favorite provider you can follow [Wasp's Manual Deployment Guide](https://wasp.sh/docs/deployment/deployment-methods/paas).
|
If you prefer to deploy manually, your frontend and backend separately, or just prefer using your favorite provider you can follow [Wasp's Manual Deployment Guide](https://wasp.sh/docs/deployment/deployment-methods/paas).
|
||||||
|
|
||||||
:::caution[Client-side Environment Variables]
|
:::caution[Client-side Environment Variables]
|
||||||
Remember to always set additional client-side environment variables, such as `REACT_APP_STRIPE_CUSTOMER_PORTAL` by appending them to the build command, e.g.
|
Remember to always set additional client-side environment variables, such as `REACT_APP_STRIPE_CUSTOMER_PORTAL` by appending them to the build command, e.g.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
REACT_APP_CLIENT_ENV_VAR_1=<...> npm run build
|
REACT_APP_CLIENT_ENV_VAR_1= < ... > npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Adding Server Redirect URL's to Social Auth
|
### Adding Server Redirect URL's to Social Auth
|
||||||
@@ -134,15 +153,17 @@ Now you need to set up your stripe webhook for production use. Below are some im
|
|||||||
#### Stripe API Versions
|
#### Stripe API Versions
|
||||||
|
|
||||||
When you create your Stripe account, Stripe will automatically assign you to their latest API version at that time. This API version is important because it determines the structure of the responses Stripe sends to your webhook, as well as the structure it expects of the requests you make toward the Stripe API.
|
When you create your Stripe account, Stripe will automatically assign you to their latest API version at that time. This API version is important because it determines the structure of the responses Stripe sends to your webhook, as well as the structure it expects of the requests you make toward the Stripe API.
|
||||||
|
|
||||||
Because this template was built with a specific version of the Stripe API in mind, it could be that your Stripe account is set to a different API version.
|
Because this template was built with a specific version of the Stripe API in mind, it could be that your Stripe account is set to a different API version.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
```ts title="stripeClient.ts"
|
|
||||||
|
```ts title="stripeClient.ts"
|
||||||
export const stripe = new Stripe(process.env.STRIPE_API_KEY!, {
|
export const stripe = new Stripe(process.env.STRIPE_API_KEY!, {
|
||||||
apiVersion: 'YYYY-MM-DD', // e.g. 2023-08-16
|
apiVersion: "YYYY-MM-DD", // e.g. 2023-08-16
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
When you specify a specific API version in your Stripe client, the requests you send to Stripe from your server, along with their responses, will match that API version. On the other hand, Stripe will send all other events to your webhook that didn't originate as a request sent from your server, like those made after a user completes a payment on checkout, using the default API version of the API.
|
When you specify a specific API version in your Stripe client, the requests you send to Stripe from your server, along with their responses, will match that API version. On the other hand, Stripe will send all other events to your webhook that didn't originate as a request sent from your server, like those made after a user completes a payment on checkout, using the default API version of the API.
|
||||||
|
|
||||||
This is why it's important to make sure your Stripe client version also matches the API version in your Stripe account, and to thoroughly test any changes you make to your Stripe client before deploying to production.
|
This is why it's important to make sure your Stripe client version also matches the API version in your Stripe account, and to thoroughly test any changes you make to your Stripe client before deploying to production.
|
||||||
@@ -152,42 +173,48 @@ To make sure your app is consistent with your Stripe account, here are some step
|
|||||||
|
|
||||||
1. You can find your `default` API version in the Stripe dashboard under the [Developers](https://dashboard.stripe.com/developers) section.
|
1. You can find your `default` API version in the Stripe dashboard under the [Developers](https://dashboard.stripe.com/developers) section.
|
||||||
2. Check that the API version in your `/src/payment/stripe/stripeClient.ts` file matches the default API version in your dashboard:
|
2. Check that the API version in your `/src/payment/stripe/stripeClient.ts` file matches the default API version in your dashboard:
|
||||||
|
|
||||||
```ts title="stripeClient.ts" {2}
|
```ts title="stripeClient.ts" {2}
|
||||||
export const stripe = new Stripe(process.env.STRIPE_KEY!, {
|
export const stripe = new Stripe(process.env.STRIPE_KEY!, {
|
||||||
apiVersion: 'YYYY-MM-DD', // e.g. 2023-08-16
|
apiVersion: "YYYY-MM-DD", // e.g. 2023-08-16
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
3. If they don't match, you can upgrade/downgrade your Stripe NPM package in `package.json` to match the API version in your dashboard:
|
3. If they don't match, you can upgrade/downgrade your Stripe NPM package in `package.json` to match the API version in your dashboard:
|
||||||
- If your default version on the Stripe dashboard is also the latest version of the API, you can simply upgrade your Stripe NPM package to the latest version.
|
|
||||||
- If your default version on the Stripe dashboard is not the latest version, and you don't want to [upgrade to the latest version](https://docs.stripe.com/upgrades#how-can-i-upgrade-my-api), because e.g. you have other projects that depend on the current version, you can find and install the Stripe NPM package version that matches your default API version by following these steps:
|
- If your default version on the Stripe dashboard is also the latest version of the API, you can simply upgrade your Stripe NPM package to the latest version.
|
||||||
- Find and note the date of your default API version in the [developer dashboard](https://dashboard.stripe.com/developers).
|
- If your default version on the Stripe dashboard is not the latest version, and you don't want to [upgrade to the latest version](https://docs.stripe.com/upgrades#how-can-i-upgrade-my-api), because e.g. you have other projects that depend on the current version, you can find and install the Stripe NPM package version that matches your default API version by following these steps:
|
||||||
- Go to the [Stripe NPM package](https://www.npmjs.com/package/stripe) page and hover over `Published` date column until you find the package release that matches your version. For example, here we find the NPM version that matches the default API version of `2023-08-16` in our dashboard, which is `13.x.x`.
|
- Find and note the date of your default API version in the [developer dashboard](https://dashboard.stripe.com/developers).
|
||||||
|
- Go to the [Stripe NPM package](https://www.npmjs.com/package/stripe) page and hover over `Published` date column until you find the package release that matches your version. For example, here we find the NPM version that matches the default API version of `2023-08-16` in our dashboard, which is `13.x.x`.
|
||||||
<Image src={npmVersion} alt="npm version" loading="lazy" />
|
<Image src={npmVersion} alt="npm version" loading="lazy" />
|
||||||
- Install the correct version of the Stripe NPM package by running, :
|
- Install the correct version of the Stripe NPM package by running, :
|
||||||
```sh
|
```sh
|
||||||
npm install stripe@x.x.x # e.g. npm install stripe@13.11.0
|
npm install stripe@x.x.x # e.g. npm install stripe@13.11.0
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Test your app thoroughly** to make sure that the changes you made to your Stripe client are working as expected before deploying to production.
|
4. **Test your app thoroughly** to make sure that the changes you made to your Stripe client are working as expected before deploying to production.
|
||||||
|
|
||||||
|
|
||||||
#### Creating Your Production Webhook
|
#### Creating Your Production Webhook
|
||||||
|
|
||||||
1. go to [https://dashboard.stripe.com/webhooks](https://dashboard.stripe.com/webhooks)
|
1. go to [https://dashboard.stripe.com/webhooks](https://dashboard.stripe.com/webhooks)
|
||||||
2. click on `+ add endpoint`
|
2. click on `+ add endpoint`
|
||||||
3. enter your endpoint url, which will be the url of your deployed server + `/payments-webhook`, e.g. `https://open-saas-wasp-sh-server.fly.dev/payments-webhook`
|
3. enter your endpoint url, which will be the url of your deployed server + `/payments-webhook`, e.g. `https://open-saas-wasp-sh-server.fly.dev/payments-webhook`
|
||||||
<Image src={stripeListenEvents} alt="listen events" loading="lazy" />
|
<Image src={stripeListenEvents} alt="listen events" loading="lazy" />
|
||||||
4. select the events you want to listen to. These should be the same events you're consuming in your webhook which you can find listed in [`src/payment/stripe/webhookPayload.ts`](https://github.com/wasp-lang/open-saas/blob/main/template/app/src/payment/stripe/webhookPayload.ts):
|
4. select the events you want to listen to. These should be the same events you're consuming in your webhook which you can find listed in [`src/payment/stripe/webhookPayload.ts`](https://github.com/wasp-lang/open-saas/blob/main/template/app/src/payment/stripe/webhookPayload.ts):
|
||||||
<Image src={stripeSigningSecret} alt="signing secret" loading="lazy" />
|
<Image src={stripeSigningSecret} alt="signing secret" loading="lazy" />
|
||||||
5. after that, go to the webhook you just created and `reveal` the new signing secret.
|
5. after that, go to the webhook you just created and `reveal` the new signing secret.
|
||||||
6. add this secret to your deployed server's `STRIPE_WEBHOOK_SECRET=` environment variable. <br/>If you've deployed to Fly.io, you can do that easily with the following command:
|
6. add this secret to your deployed server's `STRIPE_WEBHOOK_SECRET=` environment variable. <br/>If you've deployed to Fly.io, you can do that easily with the following command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
wasp deploy fly cmd --context server secrets set STRIPE_WEBHOOK_SECRET=whsec_...
|
wasp deploy fly cmd --context server secrets set STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setting up your Production Lemon Squeezy Webhook
|
### Setting up your Production Lemon Squeezy Webhook
|
||||||
|
|
||||||
To set up your Lemon Squeezy webhook, you'll need the URL of you newly deployed server + `/payments-webhook`, e.g. `https://open-saas-wasp-sh-server.fly.dev/payments-webhook`.
|
To set up your Lemon Squeezy webhook, you'll need the URL of you newly deployed server + `/payments-webhook`, e.g. `https://open-saas-wasp-sh-server.fly.dev/payments-webhook`.
|
||||||
|
|
||||||
With the webhook url ready, go to your [Lemon Squeezy Webhooks Dashboard](https://app.lemonsqueezy.com/settings/webhooks):
|
With the webhook url ready, go to your [Lemon Squeezy Webhooks Dashboard](https://app.lemonsqueezy.com/settings/webhooks):
|
||||||
|
|
||||||
- click the `+` button.
|
- click the `+` button.
|
||||||
- add the webhook forwarding url to the `Callback URL` section.
|
- add the webhook forwarding url to the `Callback URL` section.
|
||||||
- give your webhook a signing secret (a long, random string).
|
- give your webhook a signing secret (a long, random string).
|
||||||
@@ -199,13 +226,13 @@ With the webhook url ready, go to your [Lemon Squeezy Webhooks Dashboard](https:
|
|||||||
- subscription_cancelled
|
- subscription_cancelled
|
||||||
- click `save`
|
- click `save`
|
||||||
|
|
||||||
|
|
||||||
## Deploying your Blog
|
## Deploying your Blog
|
||||||
|
|
||||||
Deploying your Astro Starlight blog is a bit different than deploying your SaaS app. As an example, we will show you how to deploy your blog for free to Netlify. You will need a Netlify account and [Netlify CLI](https://docs.netlify.com/cli/get-started/) installed to follow these instructions.
|
Deploying your Astro Starlight blog is a bit different than deploying your SaaS app. As an example, we will show you how to deploy your blog for free to Netlify. You will need a Netlify account and [Netlify CLI](https://docs.netlify.com/cli/get-started/) installed to follow these instructions.
|
||||||
|
|
||||||
Make sure you are logged in with Netlify CLI.
|
Make sure you are logged in with Netlify CLI.
|
||||||
- You can check if you are logged in with `netlify status`,
|
|
||||||
|
- You can check if you are logged in with `netlify status`,
|
||||||
- you can log in with `netlify login`.
|
- you can log in with `netlify login`.
|
||||||
|
|
||||||
Position yourself in the `blog` directory and run the following command:
|
Position yourself in the `blog` directory and run the following command:
|
||||||
@@ -217,14 +244,13 @@ npm run build
|
|||||||
This will build your blog into the `blog/dist` directory. Now you can deploy your blog to Netlify with the following command:
|
This will build your blog into the `blog/dist` directory. Now you can deploy your blog to Netlify with the following command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
netlify deploy
|
netlify deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
Select the `dist` directory as the deploy path.
|
Select the `dist` directory as the deploy path.
|
||||||
|
|
||||||
Finally, if the deployment looks good, you can deploy your blog to production with the following command:
|
Finally, if the deployment looks good, you can deploy your blog to production with the following command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
netlify deploy --prod
|
netlify deploy --prod
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ banner:
|
|||||||
content: |
|
content: |
|
||||||
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
||||||
---
|
---
|
||||||
import { Tabs, TabItem } from '@astrojs/starlight/components';
|
|
||||||
|
import { Tabs, TabItem } from "@astrojs/starlight/components";
|
||||||
|
|
||||||
This guide explains how to use the integrated email sender and how you can integrate your own account in this template.
|
This guide explains how to use the integrated email sender and how you can integrate your own account in this template.
|
||||||
|
|
||||||
## Sending Emails
|
## Sending Emails
|
||||||
|
|
||||||
### The `Dummy` Email Provider (for Local Dev Only)
|
### The `Dummy` Email Provider (for Local Dev Only)
|
||||||
|
|
||||||
By default we've set up the email sender to use the `Dummy` provider. This is **for local development only** and no emails will actually be sent out!
|
By default we've set up the email sender to use the `Dummy` provider. This is **for local development only** and no emails will actually be sent out!
|
||||||
To obtain an email verification token/link, you must check the server logs on initial sign up. You can click this link to verify your email and continue with the sign up process.
|
To obtain an email verification token/link, you must check the server logs on initial sign up. You can click this link to verify your email and continue with the sign up process.
|
||||||
|
|
||||||
```tsx title="main.wasp"
|
```tsx title="main.wasp"
|
||||||
app SaaSTemplate {
|
app SaaSTemplate {
|
||||||
// ...
|
// ...
|
||||||
@@ -20,7 +23,7 @@ app SaaSTemplate {
|
|||||||
provider: Dummy,
|
provider: Dummy,
|
||||||
defaultFrom: {
|
defaultFrom: {
|
||||||
name: "Open SaaS App",
|
name: "Open SaaS App",
|
||||||
email: "me@example.com"
|
email: "me@example.com"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
@@ -28,6 +31,7 @@ app SaaSTemplate {
|
|||||||
Note that your app will not build if using the `Dummy` provider and you must switch to a production-ready provider in order to do so.
|
Note that your app will not build if using the `Dummy` provider and you must switch to a production-ready provider in order to do so.
|
||||||
|
|
||||||
### Using a Production-Ready Email Provider (e.g. SendGrid)
|
### Using a Production-Ready Email Provider (e.g. SendGrid)
|
||||||
|
|
||||||
To change your email provider to a production-ready one, such as SendGrid, you'll want to configure your `emailSender` like so:
|
To change your email provider to a production-ready one, such as SendGrid, you'll want to configure your `emailSender` like so:
|
||||||
|
|
||||||
```tsx title="main.wasp"
|
```tsx title="main.wasp"
|
||||||
@@ -38,7 +42,7 @@ app SaaSTemplate {
|
|||||||
defaultFrom: {
|
defaultFrom: {
|
||||||
name: "Open SaaS App",
|
name: "Open SaaS App",
|
||||||
// When using SendGrid, you must use the same email address that you configured your account to send out emails with!
|
// When using SendGrid, you must use the same email address that you configured your account to send out emails with!
|
||||||
email: "me@example.com"
|
email: "me@example.com"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
@@ -50,17 +54,17 @@ import { emailSender } from "wasp/server/email";
|
|||||||
|
|
||||||
//...
|
//...
|
||||||
|
|
||||||
if (subscription.cancel_at_period_end) {
|
if (subscription.cancel_at_period_end) {
|
||||||
await emailSender.send({
|
await emailSender.send({
|
||||||
to: customer.email,
|
to: customer.email,
|
||||||
subject: 'We hate to see you go :(',
|
subject: "We hate to see you go :(",
|
||||||
text: 'We hate to see you go. Here is a sweet offer...',
|
text: "We hate to see you go. Here is a sweet offer...",
|
||||||
html: 'We hate to see you go. Here is a sweet offer...',
|
html: "We hate to see you go. Here is a sweet offer...",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
In the example above, you can see that we're sending an email to the customer when we receive a cancel subscription event within the Stripe webhook.
|
In the example above, you can see that we're sending an email to the customer when we receive a cancel subscription event within the Stripe webhook.
|
||||||
|
|
||||||
This is a powerful feature and super simple to use.
|
This is a powerful feature and super simple to use.
|
||||||
|
|
||||||
@@ -71,7 +75,7 @@ To set up your email sender, you first need an account with one of the supported
|
|||||||
<Tabs>
|
<Tabs>
|
||||||
<TabItem label="SendGrid">
|
<TabItem label="SendGrid">
|
||||||
- Register at SendGrid.com and then get your [API KEYS](https://app.sendgrid.com/settings/api_keys).
|
- Register at SendGrid.com and then get your [API KEYS](https://app.sendgrid.com/settings/api_keys).
|
||||||
- Copy yours to the `.env.server` file under the `SENDGRID_API_KEY` variable.
|
- Copy yours to the `.env.server` file under the `SENDGRID_API_KEY` variable.
|
||||||
|
|
||||||
Make sure to change the `defaultFrom` email address in the `main.wasp` file to use the same email address that you configured your account to send out emails with!
|
Make sure to change the `defaultFrom` email address in the `main.wasp` file to use the same email address that you configured your account to send out emails with!
|
||||||
|
|
||||||
@@ -82,7 +86,8 @@ To set up your email sender, you first need an account with one of the supported
|
|||||||
name: "Open SaaS App",
|
name: "Open SaaS App",
|
||||||
email: "me@example.com" // <--- same email address you configured your SendGrid account to send emails with!
|
email: "me@example.com" // <--- same email address you configured your SendGrid account to send emails with!
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem label="Mailgun">
|
<TabItem label="Mailgun">
|
||||||
- Go to [Mailgun](https://mailgun.com) and create an account.
|
- Go to [Mailgun](https://mailgun.com) and create an account.
|
||||||
@@ -101,6 +106,7 @@ To set up your email sender, you first need an account with one of the supported
|
|||||||
email: "me@example.com" // <--- same email address you configured your Mailgun account to send emails with!
|
email: "me@example.com" // <--- same email address you configured your Mailgun account to send emails with!
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,25 @@ banner:
|
|||||||
content: |
|
content: |
|
||||||
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
import findS3 from '@assets/file-uploads/find-s3.png';
|
import { Image } from "astro:assets";
|
||||||
import createBucket from '@assets/file-uploads/create-bucket.png';
|
import findS3 from "@assets/file-uploads/find-s3.png";
|
||||||
import defaultSettings from '@assets/file-uploads/default-settings.png';
|
import createBucket from "@assets/file-uploads/create-bucket.png";
|
||||||
import newBucket from '@assets/file-uploads/new-bucket.png';
|
import defaultSettings from "@assets/file-uploads/default-settings.png";
|
||||||
import permissions from '@assets/file-uploads/permissions.png';
|
import newBucket from "@assets/file-uploads/new-bucket.png";
|
||||||
import cors from '@assets/file-uploads/cors.png';
|
import permissions from "@assets/file-uploads/permissions.png";
|
||||||
import username from '@assets/file-uploads/username.png';
|
import cors from "@assets/file-uploads/cors.png";
|
||||||
import keys from '@assets/file-uploads/keys.png';
|
import username from "@assets/file-uploads/username.png";
|
||||||
|
import keys from "@assets/file-uploads/keys.png";
|
||||||
|
|
||||||
This guide will show you how to set up file uploading in your SaaS app.
|
This guide will show you how to set up file uploading in your SaaS app.
|
||||||
|
|
||||||
There are two options we recommend:
|
There are two options we recommend:
|
||||||
|
|
||||||
1. Using [AWS S3](https://aws.amazon.com/s3/) with presigned URLS for secure file storage
|
1. Using [AWS S3](https://aws.amazon.com/s3/) with presigned URLS for secure file storage
|
||||||
2. Using Multer middleware to upload files to your own server
|
2. Using Multer middleware to upload files to your own server
|
||||||
|
|
||||||
**We recommend using AWS S3 as it's a scalable, secure option, that can handle a large amount of storage.**
|
**We recommend using AWS S3 as it's a scalable, secure option, that can handle a large amount of storage.**
|
||||||
|
|
||||||
If you're just looking to upload small files and don't expect your app to grow to a large scale, you can use Multer to upload files to your app's server.
|
If you're just looking to upload small files and don't expect your app to grow to a large scale, you can use Multer to upload files to your app's server.
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ If you're finding this template and its guides useful, consider giving us [a sta
|
|||||||
Presigned URLs are URLs that have been signed with your AWS credentials and can be used to upload files to your S3 bucket. They are time-limited and can be generated on the server and sent to the client to upload files directly to S3.
|
Presigned URLs are URLs that have been signed with your AWS credentials and can be used to upload files to your S3 bucket. They are time-limited and can be generated on the server and sent to the client to upload files directly to S3.
|
||||||
|
|
||||||
The process of generating a presigned URL is as follows:
|
The process of generating a presigned URL is as follows:
|
||||||
|
|
||||||
1. The client sends a request to the server to upload a file
|
1. The client sends a request to the server to upload a file
|
||||||
2. The server generates a presigned URL using its AWS credentials
|
2. The server generates a presigned URL using its AWS credentials
|
||||||
3. The server sends the presigned URL to the client
|
3. The server sends the presigned URL to the client
|
||||||
@@ -49,6 +52,7 @@ To use presigned URLs, we'll need to set up an S3 bucket and get our AWS credent
|
|||||||
### Create an AWS Account
|
### Create an AWS Account
|
||||||
|
|
||||||
Before you begin, you'll need to create an AWS account. AWS accounts are free to create and are split up into:
|
Before you begin, you'll need to create an AWS account. AWS accounts are free to create and are split up into:
|
||||||
|
|
||||||
1. Root account
|
1. Root account
|
||||||
2. IAM users
|
2. IAM users
|
||||||
|
|
||||||
@@ -61,24 +65,24 @@ To do so, follow the steps in this external guide: [Creating IAM users and S3 bu
|
|||||||
Once you are logged in with your IAM user, you'll need to create an S3 bucket to store your files.
|
Once you are logged in with your IAM user, you'll need to create an S3 bucket to store your files.
|
||||||
|
|
||||||
1. Navigate to the S3 service in the AWS console
|
1. Navigate to the S3 service in the AWS console
|
||||||
<Image src={findS3} alt="find s3" loading="lazy" />
|
<Image src={findS3} alt="find s3" loading="lazy" />
|
||||||
2. Click on the `Create bucket` button
|
2. Click on the `Create bucket` button
|
||||||
<Image src={createBucket} alt="create bucket" loading="lazy" />
|
<Image src={createBucket} alt="create bucket" loading="lazy" />
|
||||||
3. Fill in the bucket name and region
|
3. Fill in the bucket name and region
|
||||||
4. **Leave all the settings as default** and click `Create bucket`
|
4. **Leave all the settings as default** and click `Create bucket`
|
||||||
<Image src={defaultSettings} alt="bucket settings" loading="lazy" />
|
<Image src={defaultSettings} alt="bucket settings" loading="lazy" />
|
||||||
|
|
||||||
### Change the CORS settings
|
### Change the CORS settings
|
||||||
|
|
||||||
Now we need to change some permissions on the bucket to allow for file uploads from your app.
|
Now we need to change some permissions on the bucket to allow for file uploads from your app.
|
||||||
|
|
||||||
1. Click on the bucket you just created
|
1. Click on the bucket you just created
|
||||||
<Image src={newBucket} alt="new bucket" loading="lazy" />
|
<Image src={newBucket} alt="new bucket" loading="lazy" />
|
||||||
2. Click on the `Permissions` tab
|
2. Click on the `Permissions` tab
|
||||||
<Image src={permissions} alt="permissions" loading="lazy" />
|
<Image src={permissions} alt="permissions" loading="lazy" />
|
||||||
3. Scroll down to the `Cross-origin resource sharing (CORS)` section and click `Edit`
|
3. Scroll down to the `Cross-origin resource sharing (CORS)` section and click `Edit`
|
||||||
<Image src={cors} alt="cors" loading="lazy" />
|
<Image src={cors} alt="cors" loading="lazy" />
|
||||||
5. Insert the correct CORS configuration and click `Save changes`. You can
|
4. Insert the correct CORS configuration and click `Save changes`. You can
|
||||||
copy-paste most of the config below, but **you must edit the
|
copy-paste most of the config below, but **you must edit the
|
||||||
`AllowedOrigins` field** to fit your app. Include `http://localhost:3000` for
|
`AllowedOrigins` field** to fit your app. Include `http://localhost:3000` for
|
||||||
local development, and `https://<your domain>` for production.
|
local development, and `https://<your domain>` for production.
|
||||||
@@ -86,22 +90,15 @@ Now we need to change some permissions on the bucket to allow for file uploads f
|
|||||||
If you don't yet have a domain name, just list `http://localhost:3000` for
|
If you don't yet have a domain name, just list `http://localhost:3000` for
|
||||||
now. We'll remind you to add your domain before deploying to production in
|
now. We'll remind you to add your domain before deploying to production in
|
||||||
the [Deployment docs](/guides/deploying/#aws-s3-cors-configuration).
|
the [Deployment docs](/guides/deploying/#aws-s3-cors-configuration).
|
||||||
|
|
||||||
```json {11,12}
|
```json {11,12}
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"AllowedHeaders": [
|
"AllowedHeaders": ["*"],
|
||||||
"*"
|
"AllowedMethods": ["POST", "GET"],
|
||||||
],
|
"AllowedOrigins": ["http://localhost:3000", "https://<your-domain>"],
|
||||||
"AllowedMethods": [
|
"ExposeHeaders": []
|
||||||
"POST",
|
}
|
||||||
"GET"
|
|
||||||
],
|
|
||||||
"AllowedOrigins": [
|
|
||||||
"http://localhost:3000",
|
|
||||||
"https://<your-domain>"
|
|
||||||
],
|
|
||||||
"ExposeHeaders": []
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -110,12 +107,13 @@ Now we need to change some permissions on the bucket to allow for file uploads f
|
|||||||
Now that you have your S3 bucket set up, you'll need to get your S3 credentials to use in your app.
|
Now that you have your S3 bucket set up, you'll need to get your S3 credentials to use in your app.
|
||||||
|
|
||||||
1. Click on your username in the top right corner of the AWS console and select `Security Credentials`
|
1. Click on your username in the top right corner of the AWS console and select `Security Credentials`
|
||||||
<Image src={username} alt="username" loading="lazy" />
|
<Image src={username} alt="username" loading="lazy" />
|
||||||
2. Scroll down to the `Access keys` section
|
2. Scroll down to the `Access keys` section
|
||||||
3. Click on `Create Access Key`
|
3. Click on `Create Access Key`
|
||||||
4. Select the `Application running on an AWS service` option and create the access key
|
4. Select the `Application running on an AWS service` option and create the access key
|
||||||
<Image src={keys} alt="keys" loading="lazy" />
|
<Image src={keys} alt="keys" loading="lazy" />
|
||||||
5. Copy the `Access key ID` and `Secret access key` and paste them in your `src/app/.env.server` file:
|
5. Copy the `Access key ID` and `Secret access key` and paste them in your `src/app/.env.server` file:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
AWS_S3_IAM_ACCESS_KEY=ACK...
|
AWS_S3_IAM_ACCESS_KEY=ACK...
|
||||||
AWS_S3_IAM_SECRET_KEY=t+33a...
|
AWS_S3_IAM_SECRET_KEY=t+33a...
|
||||||
@@ -134,6 +132,7 @@ If you're finding this template and its guides useful, consider giving us [a sta
|
|||||||
With your S3 bucket set up and your AWS credentials in place, you can now start uploading files in your app using presigned URLs by navigating to `localhost:3000/file-upload` and uploading a file.
|
With your S3 bucket set up and your AWS credentials in place, you can now start uploading files in your app using presigned URLs by navigating to `localhost:3000/file-upload` and uploading a file.
|
||||||
|
|
||||||
To begin customizing file uploads, is important to know where everything lives in your app. Here's a quick overview:
|
To begin customizing file uploads, is important to know where everything lives in your app. Here's a quick overview:
|
||||||
|
|
||||||
- `main.wasp`:
|
- `main.wasp`:
|
||||||
- The `File entity` can be found here. Here you can modify the fields to suit your needs.
|
- The `File entity` can be found here. Here you can modify the fields to suit your needs.
|
||||||
- `src/file-upload/FileUploadPage.tsx`:
|
- `src/file-upload/FileUploadPage.tsx`:
|
||||||
|
|||||||
@@ -4,26 +4,28 @@ banner:
|
|||||||
content: |
|
content: |
|
||||||
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
import testApiKeys from '@assets/stripe/api-keys.png';
|
import { Image } from "astro:assets";
|
||||||
import testProduct from '@assets/stripe/test-product.png';
|
import testApiKeys from "@assets/stripe/api-keys.png";
|
||||||
import priceIds from '@assets/stripe/price-ids.png';
|
import testProduct from "@assets/stripe/test-product.png";
|
||||||
import switchPlans from '@assets/stripe/switch-plans.png';
|
import priceIds from "@assets/stripe/price-ids.png";
|
||||||
import dbStudio from '@assets/stripe/db-studio.png';
|
import switchPlans from "@assets/stripe/switch-plans.png";
|
||||||
import addProduct from '@assets/lemon-squeezy/add-product.png';
|
import dbStudio from "@assets/stripe/db-studio.png";
|
||||||
import addVariant from '@assets/lemon-squeezy/add-variant.png';
|
import addProduct from "@assets/lemon-squeezy/add-product.png";
|
||||||
import variantId from '@assets/lemon-squeezy/variant-id.png';
|
import addVariant from "@assets/lemon-squeezy/add-variant.png";
|
||||||
import subscriptionVariantIds from '@assets/lemon-squeezy/subscription-variant-ids.png';
|
import variantId from "@assets/lemon-squeezy/variant-id.png";
|
||||||
import ngrok from '@assets/lemon-squeezy/ngrok.png';
|
import subscriptionVariantIds from "@assets/lemon-squeezy/subscription-variant-ids.png";
|
||||||
import storeId from '@assets/lemon-squeezy/store-id.png';
|
import ngrok from "@assets/lemon-squeezy/ngrok.png";
|
||||||
|
import storeId from "@assets/lemon-squeezy/store-id.png";
|
||||||
|
|
||||||
This guide will show you how to set up Payments for testing and local development with the following payment processors:
|
This guide will show you how to set up Payments for testing and local development with the following payment processors:
|
||||||
|
|
||||||
- Stripe
|
- Stripe
|
||||||
- Lemon Squeezy
|
- Lemon Squeezy
|
||||||
|
|
||||||
:::note[Which should I choose?]
|
:::note[Which should I choose?]
|
||||||
Stripe is the industry standard, is more configurable, and has cheaper fees.
|
Stripe is the industry standard, is more configurable, and has cheaper fees.
|
||||||
Lemon Squeezy acts a [Merchant of Record](https://www.lemonsqueezy.com/reporting/merchant-of-record). This means they take care of paying taxes in multiple countries for you, but charge higher fees per transaction.
|
Lemon Squeezy acts a [Merchant of Record](https://www.lemonsqueezy.com/reporting/merchant-of-record). This means they take care of paying taxes in multiple countries for you, but charge higher fees per transaction.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Important First Steps
|
## Important First Steps
|
||||||
@@ -31,17 +33,18 @@ Lemon Squeezy acts a [Merchant of Record](https://www.lemonsqueezy.com/reporting
|
|||||||
First, go to `/src/payment/paymentProcessor.ts` and choose which payment processor you'd like to use, e.g. Stripe or Lemon Squeezy:
|
First, go to `/src/payment/paymentProcessor.ts` and choose which payment processor you'd like to use, e.g. Stripe or Lemon Squeezy:
|
||||||
|
|
||||||
```ts title="src/payment/paymentProcessor.ts" ins={5, 7}
|
```ts title="src/payment/paymentProcessor.ts" ins={5, 7}
|
||||||
import { stripePaymentProcessor } from './stripe/paymentProcessor';
|
import { stripePaymentProcessor } from "./stripe/paymentProcessor";
|
||||||
import { lemonSqueezyPaymentProcessor } from './lemonSqueezy/paymentProcessor';
|
import { lemonSqueezyPaymentProcessor } from "./lemonSqueezy/paymentProcessor";
|
||||||
//...
|
//...
|
||||||
|
|
||||||
export const paymentProcessor: PaymentProcessor = stripePaymentProcessor;
|
export const paymentProcessor: PaymentProcessor = stripePaymentProcessor;
|
||||||
// or...
|
// or...
|
||||||
export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor;
|
export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor;
|
||||||
```
|
```
|
||||||
|
|
||||||
At this point, you can delete:
|
At this point, you can delete:
|
||||||
- the unused payment processor code within the `/src/payment/<unused-provider>` directory,
|
|
||||||
|
- the unused payment processor code within the `/src/payment/<unused-provider>` directory,
|
||||||
- any unused environment variables from `.env.server` (they will be prefixed with the name of the provider your are not using):
|
- any unused environment variables from `.env.server` (they will be prefixed with the name of the provider your are not using):
|
||||||
- e.g. `STRIPE_API_KEY`, `STRIPE_CUSTOMER_PORTAL_URL`, `LEMONSQUEEZY_API_KEY`, `LEMONSQUEEZY_WEBHOOK_SECRET`
|
- e.g. `STRIPE_API_KEY`, `STRIPE_CUSTOMER_PORTAL_URL`, `LEMONSQUEEZY_API_KEY`, `LEMONSQUEEZY_WEBHOOK_SECRET`
|
||||||
- Make sure to also uninstall the unused dependencies:
|
- Make sure to also uninstall the unused dependencies:
|
||||||
@@ -78,16 +81,16 @@ To create a test product, go to the test products url [https://dashboard.stripe.
|
|||||||
|
|
||||||
<Image src={testProduct} alt="test product" loading="lazy" />
|
<Image src={testProduct} alt="test product" loading="lazy" />
|
||||||
|
|
||||||
- Click on the `Add a product` button and fill in the relevant information for your product.
|
- Click on the `Add a product` button and fill in the relevant information for your product.
|
||||||
- Make sure you select `Software as a service (SaaS)` as the product type.
|
- Make sure you select `Software as a service (SaaS)` as the product type.
|
||||||
- For Subscription products, make sure you select `Recurring` as the billing type.
|
- For Subscription products, make sure you select `Recurring` as the billing type.
|
||||||
- For One-time payment products, make sure you select `One-time` as the billing type.
|
- For One-time payment products, make sure you select `One-time` as the billing type.
|
||||||
- If you intend to let your users switch between two subscription plans, e.g. upgrade from hobby to pro, you'll need to create two separate products and with their own price IDs. The ability for users to swich plans can then be configured later in the [Customer Portal](#set-up-the-customer-portal).
|
- If you intend to let your users switch between two subscription plans, e.g. upgrade from hobby to pro, you'll need to create two separate products and with their own price IDs. The ability for users to swich plans can then be configured later in the [Customer Portal](#set-up-the-customer-portal).
|
||||||
- If you want to add different price tiers for the same product (e.g. monthly and yearly), click the `Add another price` button at the buttom.
|
- If you want to add different price tiers for the same product (e.g. monthly and yearly), click the `Add another price` button at the buttom.
|
||||||
|
|
||||||
<Image src={priceIds} alt="price ids" loading="lazy" />
|
<Image src={priceIds} alt="price ids" loading="lazy" />
|
||||||
|
|
||||||
- After you save the product, you'll be directed to the product page.
|
- After you save the product, you'll be directed to the product page.
|
||||||
- Copy the price IDs and paste them in the `.env.server` file
|
- Copy the price IDs and paste them in the `.env.server` file
|
||||||
- We've set you up with two example subscription product environment variables, `PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID=` and `PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID=`.
|
- We've set you up with two example subscription product environment variables, `PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID=` and `PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID=`.
|
||||||
- As well as a one-time payment product/credits-based environment variable, `PAYMENTS_CREDITS_10_PLAN_ID=`.
|
- As well as a one-time payment product/credits-based environment variable, `PAYMENTS_CREDITS_10_PLAN_ID=`.
|
||||||
@@ -98,9 +101,9 @@ To create a test product, go to the test products url [https://dashboard.stripe.
|
|||||||
To create a test customer, go to the test customers url [https://dashboard.stripe.com/test/customers](https://dashboard.stripe.com/test/customers).
|
To create a test customer, go to the test customers url [https://dashboard.stripe.com/test/customers](https://dashboard.stripe.com/test/customers).
|
||||||
|
|
||||||
- Click on the `Add a customer` button and fill in the relevant information for your test customer.
|
- Click on the `Add a customer` button and fill in the relevant information for your test customer.
|
||||||
:::note
|
:::note
|
||||||
When filling in the test customer email address, use an address you have access to and will use when logging into your SaaS app. This is important because the email address is used to identify the customer when creating a subscription and allows you to manage your test user's payments/subscriptions via the test customer portal
|
When filling in the test customer email address, use an address you have access to and will use when logging into your SaaS app. This is important because the email address is used to identify the customer when creating a subscription and allows you to manage your test user's payments/subscriptions via the test customer portal
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Set up the Customer Portal
|
### Set up the Customer Portal
|
||||||
|
|
||||||
@@ -110,7 +113,7 @@ Go to https://dashboard.stripe.com/test/settings/billing/portal in the Stripe Da
|
|||||||
STRIPE_CUSTOMER_PORTAL_URL=<your-test-customer-portal-link>
|
STRIPE_CUSTOMER_PORTAL_URL=<your-test-customer-portal-link>
|
||||||
```
|
```
|
||||||
|
|
||||||
If you'd like to give users the ability to switch between different plans, e.g. upgrade from a hobby to a pro subscription, go down to the `Subscriptions` dropdown and select `customers can switch plans`.
|
If you'd like to give users the ability to switch between different plans, e.g. upgrade from a hobby to a pro subscription, go down to the `Subscriptions` dropdown and select `customers can switch plans`.
|
||||||
|
|
||||||
<Image src={switchPlans} alt="switch plans" loading="lazy" />
|
<Image src={switchPlans} alt="switch plans" loading="lazy" />
|
||||||
|
|
||||||
@@ -131,6 +134,7 @@ or for other install scripts or OSes, follow the instructions [here](https://str
|
|||||||
Now, let's start the webhook server and get our webhook signing secret.
|
Now, let's start the webhook server and get our webhook signing secret.
|
||||||
|
|
||||||
First, login:
|
First, login:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
stripe login
|
stripe login
|
||||||
```
|
```
|
||||||
@@ -160,7 +164,7 @@ copy this secret to your `.env.server` file under `STRIPE_WEBHOOK_SECRET=`.
|
|||||||
stripe login
|
stripe login
|
||||||
```
|
```
|
||||||
|
|
||||||
- start the Stripe CLI webhook forwarding on port 3001 where your Node server is running.
|
- start the Stripe CLI webhook forwarding on port 3001 where your Node server is running.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
stripe listen --forward-to localhost:3001/payments-webhook
|
stripe listen --forward-to localhost:3001/payments-webhook
|
||||||
@@ -183,12 +187,12 @@ The results of the event firing will be visible in the initial terminal window.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
...
|
...
|
||||||
2023-11-21 09:31:09 --> invoice.paid [evt_1OEpMPILOQf67J5TjrUgRpk4]
|
2023-11-21 09:31:09 -- [evt_1OEpMPILOQf67J5TjrUgRpk4] > invoice.paid
|
||||||
2023-11-21 09:31:09 <-- [200] POST http://localhost:3001/payments-webhook [evt_1OEpMPILOQf67J5TjrUgRpk4]
|
2023-11-21 09:31:09 [200] POST http://localhost:3001/payments-webhook [evt_1OEpMPILOQf67J5TjrUgRpk4] < --
|
||||||
2023-11-21 09:31:10 --> invoice.payment_succeeded [evt_1OEpMPILOQf67J5T3MFBr1bq]
|
2023-11-21 09:31:10 -- [evt_1OEpMPILOQf67J5T3MFBr1bq] > invoice.payment_succeeded
|
||||||
2023-11-21 09:31:10 <-- [200] POST http://localhost:3001/payments-webhook [evt_1OEpMPILOQf67J5T3MFBr1bq]
|
2023-11-21 09:31:10 [200] POST http://localhost:3001/payments-webhook [evt_1OEpMPILOQf67J5T3MFBr1bq] < --
|
||||||
2023-11-21 09:31:10 --> checkout.session.completed [evt_1OEpMQILOQf67J5ThTZ0999r]
|
2023-11-21 09:31:10 -- [evt_1OEpMQILOQf67J5ThTZ0999r] > checkout.session.completed
|
||||||
2023-11-21 09:31:11 <-- [200] POST http://localhost:3001/payments-webhook [evt_1OEpMQILOQf67J5ThTZ0999r]
|
2023-11-21 09:31:11 [200] POST http://localhost:3001/payments-webhook [evt_1OEpMQILOQf67J5ThTZ0999r] < --
|
||||||
```
|
```
|
||||||
|
|
||||||
For more info on testing webhooks, check out https://stripe.com/docs/webhooks#test-webhook
|
For more info on testing webhooks, check out https://stripe.com/docs/webhooks#test-webhook
|
||||||
@@ -257,17 +261,21 @@ Copy and paste this number in your `.env.server` file under `LEMONSQUEEZY_STORE_
|
|||||||
|
|
||||||
To create a test product, go to the test products url [https://app.lemonsqueezy.com/products](https://app.lemonsqueezy.com/products).
|
To create a test product, go to the test products url [https://app.lemonsqueezy.com/products](https://app.lemonsqueezy.com/products).
|
||||||
|
|
||||||
- Click on the `+ New Product` button and fill in the relevant information for your product.
|
- Click on the `+ New Product` button and fill in the relevant information for your product.
|
||||||
- Fill in the general information.
|
- Fill in the general information.
|
||||||
- For pricing, select the type of product you'd like to create, e.g. `Subscription` for a recurring monthly payment product or `Single Payment` for credits-based product.
|
- For pricing, select the type of product you'd like to create, e.g. `Subscription` for a recurring monthly payment product or `Single Payment` for credits-based product.
|
||||||
<Image src={addProduct} alt="add product" loading="lazy" />
|
<Image src={addProduct} alt="add product" loading="lazy" />
|
||||||
- Make sure you select `Software as a service (SaaS)` as the Tax category type.
|
- Make sure you select `Software as a service (SaaS)` as the Tax category type.
|
||||||
- If you want to add different price tiers for `Subscription` products, click on `add variant` under the `variants` tab. Here you can input the name of the variant (e.g. "Hobby", "Pro"), and that variant's price.
|
- If you want to add different price tiers for `Subscription` products, click on `add variant` under the `variants` tab. Here you can input the name of the variant (e.g. "Hobby", "Pro"), and that variant's price.
|
||||||
<Image src={addVariant} alt="add variant" loading="lazy" />
|
<Image src={addVariant} alt="add variant" loading="lazy" />
|
||||||
- For a product with no variants, on the product page, click the `...` menu button and select `Copy variant ID`
|
- For a product with no variants, on the product page, click the `...` menu button and select `Copy variant ID`
|
||||||
<Image src={variantId} alt="variant id" loading="lazy" />
|
<Image src={variantId} alt="variant id" loading="lazy" />
|
||||||
- For a product with variants, on the product page, click on the product, go to the variants tab and select `Copy ID` for each variant.
|
- For a product with variants, on the product page, click on the product, go to the variants tab and select `Copy ID` for each variant.
|
||||||
<Image src={subscriptionVariantIds} alt="subscription variant ids" loading="lazy" />
|
<Image
|
||||||
|
src={subscriptionVariantIds}
|
||||||
|
alt="subscription variant ids"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
- Paste these IDs in the `.env.server` file:
|
- Paste these IDs in the `.env.server` file:
|
||||||
- We've set you up with two example subscription product environment variables, `PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID=` and `PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID=`.
|
- We've set you up with two example subscription product environment variables, `PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID=` and `PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID=`.
|
||||||
- As well as a one-time payment product/credits-based environment variable, `PAYMENTS_CREDITS__10_PLAN_ID=`.
|
- As well as a one-time payment product/credits-based environment variable, `PAYMENTS_CREDITS__10_PLAN_ID=`.
|
||||||
@@ -280,19 +288,21 @@ Lemon Squeezy sends messages/updates to your Wasp app via its webhook, e.g. when
|
|||||||
To do this, first make sure you have installed [ngrok](https://ngrok.com/docs/getting-started/).
|
To do this, first make sure you have installed [ngrok](https://ngrok.com/docs/getting-started/).
|
||||||
|
|
||||||
Once installed, and with your wasp app running, run:
|
Once installed, and with your wasp app running, run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
ngrok http 3001
|
ngrok http 3001
|
||||||
```
|
```
|
||||||
|
|
||||||
<Image src={ngrok} alt="ngrok" loading="lazy" />
|
<Image src={ngrok} alt="ngrok" loading="lazy" />
|
||||||
|
|
||||||
Ngrok will output a forwarding address for you. Copy and paste this address and add `/payments-webhook` to the end (this URL path has been configured for you already in `main.wasp` under the `api paymentsWebhook` definition). It should look something like this:
|
Ngrok will output a forwarding address for you. Copy and paste this address and add `/payments-webhook` to the end (this URL path has been configured for you already in `main.wasp` under the `api paymentsWebhook` definition). It should look something like this:
|
||||||
|
|
||||||
```sh title="Callback URL"
|
```sh title="Callback URL"
|
||||||
https://89e5-2003-c7-153c-72a5-f837.ngrok-free.app/payments-webhook
|
https://89e5-2003-c7-153c-72a5-f837.ngrok-free.app/payments-webhook
|
||||||
```
|
```
|
||||||
|
|
||||||
Now go to your [Lemon Squeezy Webhooks Dashboard](https://app.lemonsqueezy.com/settings/webhooks):
|
Now go to your [Lemon Squeezy Webhooks Dashboard](https://app.lemonsqueezy.com/settings/webhooks):
|
||||||
|
|
||||||
- click the `+` button.
|
- click the `+` button.
|
||||||
- add the newly created webhook forwarding url to the `Callback URL` section.
|
- add the newly created webhook forwarding url to the `Callback URL` section.
|
||||||
- give your webhook a signing secret (a long, random string).
|
- give your webhook a signing secret (a long, random string).
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ banner:
|
|||||||
content: |
|
content: |
|
||||||
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
import openSaaSGoogle from '@assets/seo/open-saas-google.png';
|
import { Image } from "astro:assets";
|
||||||
|
import openSaaSGoogle from "@assets/seo/open-saas-google.png";
|
||||||
|
|
||||||
This guides explains how to improve SEO for of your app
|
This guides explains how to improve SEO for of your app
|
||||||
|
|
||||||
@@ -46,13 +47,13 @@ Astro, being a static-site generator, will automatically inject relevant informa
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
title: 'My First Blog Post'
|
title: "My First Blog Post"
|
||||||
pubDate: 2022-07-01
|
pubDate: 2022-07-01
|
||||||
description: 'This is the first post of my new Astro blog.'
|
description: "This is the first post of my new Astro blog."
|
||||||
author: 'Astro Learner'
|
author: "Astro Learner"
|
||||||
image:
|
image:
|
||||||
url: 'https://docs.astro.build/assets/full-logo-light.png'
|
url: "https://docs.astro.build/assets/full-logo-light.png"
|
||||||
alt: 'The full Astro logo.'
|
alt: "The full Astro logo."
|
||||||
tags: ["astro", "blogging", "learning in public"]
|
tags: ["astro", "blogging", "learning in public"]
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
@@ -63,7 +64,8 @@ Improving your SEO is as simple as adding these properties to your docs and blog
|
|||||||
|
|
||||||
Open SaaS and Wasp do not currently have a SSR option (although it is coming soon!), but that does not mean that Open SaaS apps are at a disadvantage with regards to SEO.
|
Open SaaS and Wasp do not currently have a SSR option (although it is coming soon!), but that does not mean that Open SaaS apps are at a disadvantage with regards to SEO.
|
||||||
|
|
||||||
That's because the meta tags for the landing page (described above), plus the Astro docs/blog provided with Open SaaS are more than enough! Not to mention, Google is also able to crawl websites with JavaScript activated, making SSR unnecessary.
|
That's because the meta tags for the landing page (described above), plus the Astro docs/blog provided with Open SaaS are more than enough! Not to mention, Google is also able to crawl websites with JavaScript activated, making SSR unnecessary.
|
||||||
|
|
||||||
|
For example, try searching "Open SaaS" on Google and you'll see this App, which was built with this template, as the first result!
|
||||||
|
|
||||||
For example, try searching "Open SaaS" on Google and you'll see this App, which was built with this template, as the first result!
|
|
||||||
<Image src={openSaaSGoogle} alt="open-saas-google" loading="lazy" />
|
<Image src={openSaaSGoogle} alt="open-saas-google" loading="lazy" />
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ In the root of your project, you'll find an `e2e-tests` directory which contains
|
|||||||
To run the tests locally, or in a CI pipeline, follow the instructions in the `README.md` file in the `e2e-tests` directory.
|
To run the tests locally, or in a CI pipeline, follow the instructions in the `README.md` file in the `e2e-tests` directory.
|
||||||
|
|
||||||
## Using Tests in CI with GitHub Actions
|
## Using Tests in CI with GitHub Actions
|
||||||
|
|
||||||
Although the Open SaaS template does not come with an example workflow, you can find one at `.github/workflows/e2e-tests.yml` of the [remote repo](https://github.com/wasp-lang/open-saas).
|
Although the Open SaaS template does not come with an example workflow, you can find one at `.github/workflows/e2e-tests.yml` of the [remote repo](https://github.com/wasp-lang/open-saas).
|
||||||
|
|
||||||
You can copy and paste the `.github/` directory containing the `e2e-tests.yml` workflow into the root of your own repository to run the tests as part of your CI pipeline.
|
You can copy and paste the `.github/` directory containing the `e2e-tests.yml` workflow into the root of your own repository to run the tests as part of your CI pipeline.
|
||||||
|
|||||||
@@ -11,16 +11,15 @@ If you've already started building your app, we generally advise against merging
|
|||||||
Below we outline our reasoning why, and provide a basic guide to help you update your app if you decide to do so anyway.
|
Below we outline our reasoning why, and provide a basic guide to help you update your app if you decide to do so anyway.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|
||||||
## Why you probably shouldn't include the latest template changes in your app
|
## Why you probably shouldn't include the latest template changes in your app
|
||||||
|
|
||||||
We generally **advise against updating your Open SaaS-based applications** after initial setup.
|
We generally **advise against updating your Open SaaS-based applications** after initial setup.
|
||||||
|
|
||||||
Why?
|
Why?
|
||||||
|
|
||||||
Because your codebase will naturally diverge from the template as you build your unique application, and any updates we may make to the template may not be compatible with your modified codebase, or your version of Wasp.
|
Because your codebase will naturally diverge from the template as you build your unique application, and any updates we may make to the template may not be compatible with your modified codebase, or your version of Wasp.
|
||||||
|
|
||||||
Even if you *really* want to include a new feature from the template in your app, proceed with caution and thoroughly consider the following:
|
Even if you _really_ want to include a new feature from the template in your app, proceed with caution and thoroughly consider the following:
|
||||||
|
|
||||||
- Changes to the template may be tightly coupled. Implementing one change without related ones could cause unexpected issues.
|
- Changes to the template may be tightly coupled. Implementing one change without related ones could cause unexpected issues.
|
||||||
- Updates might not be compatible with your version of Wasp.
|
- Updates might not be compatible with your version of Wasp.
|
||||||
@@ -32,14 +31,16 @@ If you read above, considered the risks, and still need specific improvements, w
|
|||||||
|
|
||||||
To do this, you should can either 1) merge new Open SaaS template changes into your current project, or 2) merge project changes into a fresh Open SaaS template.
|
To do this, you should can either 1) merge new Open SaaS template changes into your current project, or 2) merge project changes into a fresh Open SaaS template.
|
||||||
|
|
||||||
1) Merge new Open SaaS template changes into your current project by:
|
1. Merge new Open SaaS template changes into your current project by:
|
||||||
- reviewing the latest commits,
|
|
||||||
- understanding what happened,
|
- reviewing the latest commits,
|
||||||
|
- understanding what happened,
|
||||||
- being mindful of the Wasp version you're using,
|
- being mindful of the Wasp version you're using,
|
||||||
- and then fitting those changes into your own codebase.
|
- and then fitting those changes into your own codebase.
|
||||||
|
|
||||||
2) Merge your project changes into a fresh Open SaaS template by:
|
2. Merge your project changes into a fresh Open SaaS template by:
|
||||||
|
|
||||||
- starting a new, fresh project with the latest Open SaaS template,
|
- starting a new, fresh project with the latest Open SaaS template,
|
||||||
- and then copying over the logic from your existing project that you want to keep.
|
- and then copying over the logic from your existing project that you want to keep.
|
||||||
|
|
||||||
The method you choose is up to you and will largely depend on the complexity of the changes you need to make.
|
The method you choose is up to you and will largely depend on the complexity of the changes you need to make.
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ banner:
|
|||||||
content: |
|
content: |
|
||||||
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
import llmsFullCursor from '@assets/ai/llm-full-cursor.webp';
|
import { Image } from "astro:assets";
|
||||||
import llmsTextChat from '@assets/ai/llm-txt-chat.webp';
|
import llmsFullCursor from "@assets/ai/llm-full-cursor.webp";
|
||||||
import vibeBoi from '@assets/ai/vibe-boi.png';
|
import llmsTextChat from "@assets/ai/llm-txt-chat.webp";
|
||||||
|
import vibeBoi from "@assets/ai/vibe-boi.png";
|
||||||
|
|
||||||
<Image src={vibeBoi} alt="vibe boi" width={300} />
|
<Image src={vibeBoi} alt="vibe boi" width={300} />
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ If you're looking to use AI to help build (or "vibe code") your SaaS app, this g
|
|||||||
|
|
||||||
## Coding with AI, Open SaaS, & Wasp
|
## Coding with AI, Open SaaS, & Wasp
|
||||||
|
|
||||||
Wasp is particularly well suited to coding with AI due to its central config file which gives LLMs context about the entire full-stack app, and its ability to manage boilerplate code so AI doesn't have to.
|
Wasp is particularly well suited to coding with AI due to its central config file which gives LLMs context about the entire full-stack app, and its ability to manage boilerplate code so AI doesn't have to.
|
||||||
|
|
||||||
Regardless, there are still some shortcomings to using AI to code with Wasp, as well as a learning curve to using it effectively.
|
Regardless, there are still some shortcomings to using AI to code with Wasp, as well as a learning curve to using it effectively.
|
||||||
|
|
||||||
@@ -24,12 +25,14 @@ Luckily, we did the work for you and put together a bunch of resources to help y
|
|||||||
### AI Resources in the Template
|
### AI Resources in the Template
|
||||||
|
|
||||||
The template comes with:
|
The template comes with:
|
||||||
|
|
||||||
- A full set of rules files, `app/.cursor/rules`, to be used with Cursor or adapted to your coding tool of choice (Windsurf, Claude Code, etc.).
|
- A full set of rules files, `app/.cursor/rules`, to be used with Cursor or adapted to your coding tool of choice (Windsurf, Claude Code, etc.).
|
||||||
- A set of example prompts, `app/.cursor/example-prompts.md`, to help you get started.
|
- A set of example prompts, `app/.cursor/example-prompts.md`, to help you get started.
|
||||||
|
|
||||||
### LLM-Friendly Documentation
|
### LLM-Friendly Documentation
|
||||||
|
|
||||||
We've also created a bunch of LLM-friendly documentation:
|
We've also created a bunch of LLM-friendly documentation:
|
||||||
|
|
||||||
- [Open SaaS Docs - LLMs.txt](https://docs.opensaas.sh/llms.txt) - Links to the raw text docs.
|
- [Open SaaS Docs - LLMs.txt](https://docs.opensaas.sh/llms.txt) - Links to the raw text docs.
|
||||||
- **[Open SaaS Docs - LLMs-full.txt](https://docs.opensaas.sh/llms-full.txt) - Complete docs as one text file.** ✅😎
|
- **[Open SaaS Docs - LLMs-full.txt](https://docs.opensaas.sh/llms-full.txt) - Complete docs as one text file.** ✅😎
|
||||||
- Coming Soon! ~~[Wasp Docs - LLMs.txt](https://wasp.sh/llms.txt)~~ - Links to the raw text docs.
|
- Coming Soon! ~~[Wasp Docs - LLMs.txt](https://wasp.sh/llms.txt)~~ - Links to the raw text docs.
|
||||||
@@ -45,5 +48,6 @@ Add these to your AI-assisted IDE settings so you can easily reference them in y
|
|||||||
### More AI-assisted Coding Learning Resources
|
### More AI-assisted Coding Learning Resources
|
||||||
|
|
||||||
Here's a list of articles and tutorials we've made:
|
Here's a list of articles and tutorials we've made:
|
||||||
|
|
||||||
- [3hr YouTube tutorial: Vibe Coding a Personal Finance App w/ Wasp & Cursor](https://www.youtube.com/watch?v=WYzEROo7reY)
|
- [3hr YouTube tutorial: Vibe Coding a Personal Finance App w/ Wasp & Cursor](https://www.youtube.com/watch?v=WYzEROo7reY)
|
||||||
- [Article: A Structured Workflow for "Vibe Coding" Full-Stack Apps](https://dev.to/wasp/a-structured-workflow-for-vibe-coding-full-stack-apps-352l)
|
- [Article: A Structured Workflow for "Vibe Coding" Full-Stack Apps](https://dev.to/wasp/a-structured-workflow-for-vibe-coding-full-stack-apps-352l)
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ banner:
|
|||||||
content: |
|
content: |
|
||||||
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
||||||
---
|
---
|
||||||
import HiddenLLMHelper from '../../components/HiddenLLMHelper.astro';
|
|
||||||
|
import HiddenLLMHelper from "../../components/HiddenLLMHelper.astro";
|
||||||
|
|
||||||
## Welcome to your new SaaS App!
|
## Welcome to your new SaaS App!
|
||||||
|
|
||||||
{/* TODO: add a screenshot of the app */}
|
{/* TODO: add a screenshot of the app */}
|
||||||
|
|
||||||
<HiddenLLMHelper />
|
<HiddenLLMHelper />
|
||||||
|
|
||||||
You've decided to build a SaaS app with this template. Great choice! 🎉
|
You've decided to build a SaaS app with this template. Great choice! 🎉
|
||||||
@@ -32,24 +34,27 @@ If you find this template useful, consider giving us [a star on GitHub](https://
|
|||||||
## What's inside?
|
## What's inside?
|
||||||
|
|
||||||
The template itself is built on top of some very powerful tools and frameworks, including:
|
The template itself is built on top of some very powerful tools and frameworks, including:
|
||||||
- 🐝 [Wasp](https://wasp.sh) - a full-stack React, NodeJS, Prisma framework with superpowers
|
|
||||||
- 🚀 [Astro](https://starlight.astro.build/) - Astro's lightweight "Starlight" template for documentation and blog
|
- 🐝 [Wasp](https://wasp.sh) - a full-stack React, NodeJS, Prisma framework with superpowers
|
||||||
- 💸 [Stripe](https://stripe.com) or [Lemon Squeezy](https://lemonsqueezy.com/) - for products and payments
|
- 🚀 [Astro](https://starlight.astro.build/) - Astro's lightweight "Starlight" template for documentation and blog
|
||||||
- 📈 [Plausible](https://plausible.io) or [Google](https://analytics.google.com/) Analytics
|
- 💸 [Stripe](https://stripe.com) or [Lemon Squeezy](https://lemonsqueezy.com/) - for products and payments
|
||||||
- 🤖 [OpenAI](https://openai.com) - OpenAI API integrated into the app or [Replicate](https://replicate.com/) (coming soon 👀)
|
- 📈 [Plausible](https://plausible.io) or [Google](https://analytics.google.com/) Analytics
|
||||||
- 📦 [AWS S3](https://aws.amazon.com/s3/) - for file uploads
|
- 🤖 [OpenAI](https://openai.com) - OpenAI API integrated into the app or [Replicate](https://replicate.com/) (coming soon 👀)
|
||||||
- 📧 [SendGrid](https://sendgrid.com), [MailGun](https://mailgun.com), or SMTP - for email sending
|
- 📦 [AWS S3](https://aws.amazon.com/s3/) - for file uploads
|
||||||
- 💅 [TailwindCSS](https://tailwindcss.com) - for styling
|
- 📧 [SendGrid](https://sendgrid.com), [MailGun](https://mailgun.com), or SMTP - for email sending
|
||||||
- 💼 [TailAdmin](https://tailadmin.com/) - admin dashboard & components for TailwindCSS
|
- 💅 [TailwindCSS](https://tailwindcss.com) - for styling
|
||||||
|
- 💼 [TailAdmin](https://tailadmin.com/) - admin dashboard & components for TailwindCSS
|
||||||
|
|
||||||
Because we're using Wasp as the full-stack framework, we can leverage a lot of its features to build our SaaS in record time, including:
|
Because we're using Wasp as the full-stack framework, we can leverage a lot of its features to build our SaaS in record time, including:
|
||||||
- 🔐 [Full-stack Authentication](https://wasp.sh/docs/auth/overview) - Email verified + social Auth in a few lines of code.
|
|
||||||
- ⛑ [End-to-end Type Safety](https://wasp.sh/docs/data-model/operations/overview) - Type your backend functions and get inferred types on the front-end automatically, without the need to install or configure any third-party libraries. Oh, and type-safe Links, too!
|
- 🔐 [Full-stack Authentication](https://wasp.sh/docs/auth/overview) - Email verified + social Auth in a few lines of code.
|
||||||
- 🤖 [Jobs](https://wasp.sh/docs/advanced/jobs) - Run cron jobs in the background or set up queues simply by defining a function in the config file.
|
- ⛑ [End-to-end Type Safety](https://wasp.sh/docs/data-model/operations/overview) - Type your backend functions and get inferred types on the front-end automatically, without the need to install or configure any third-party libraries. Oh, and type-safe Links, too!
|
||||||
- 🚀 [One-command Deploy](https://wasp.sh/docs/advanced/deployment/overview) - Easily deploy via the CLI to [Fly.io](https://fly.io), or to other providers like [Railway](https://railway.app) and [Netlify](https://netlify.com).
|
- 🤖 [Jobs](https://wasp.sh/docs/advanced/jobs) - Run cron jobs in the background or set up queues simply by defining a function in the config file.
|
||||||
|
- 🚀 [One-command Deploy](https://wasp.sh/docs/advanced/deployment/overview) - Easily deploy via the CLI to [Fly.io](https://fly.io), or to other providers like [Railway](https://railway.app) and [Netlify](https://netlify.com).
|
||||||
|
|
||||||
You also get access to Wasp's diverse, helpful community if you get stuck or need help.
|
You also get access to Wasp's diverse, helpful community if you get stuck or need help.
|
||||||
- 🤝 [Wasp Discord](https://discord.gg/rzdnErX)
|
|
||||||
|
- 🤝 [Wasp Discord](https://discord.gg/rzdnErX)
|
||||||
|
|
||||||
:::caution["Work In Progress"]
|
:::caution["Work In Progress"]
|
||||||
We've tried to get as many of the core features of a SaaS app into this template as possible, but there still might be some missing features or functionality.
|
We've tried to get as many of the core features of a SaaS app into this template as possible, but there still might be some missing features or functionality.
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ banner:
|
|||||||
content: |
|
content: |
|
||||||
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
Have an Open SaaS app in production? <a href="https://e44cy1h4s0q.typeform.com/to/EPJCwsMi">We'll send you some swag! 👕</a>
|
||||||
---
|
---
|
||||||
import VideoPlayer from '../../../components/VideoPlayer.astro';
|
|
||||||
|
import VideoPlayer from "../../../components/VideoPlayer.astro";
|
||||||
|
|
||||||
This guide will help you get your new SaaS app up and running.
|
This guide will help you get your new SaaS app up and running.
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ Your version of Node.js must be >= 22.12.
|
|||||||
To switch easily between Node.js versions, we recommend using [nvm](https://github.com/nvm-sh/nvm).
|
To switch easily between Node.js versions, we recommend using [nvm](https://github.com/nvm-sh/nvm).
|
||||||
|
|
||||||
:::note[Installing and using nvm]
|
:::note[Installing and using nvm]
|
||||||
|
|
||||||
<details aria-label="Installing and using nvm">
|
<details aria-label="Installing and using nvm">
|
||||||
<summary aria-label="Need help with nvm?">
|
<summary aria-label="Need help with nvm?">
|
||||||
Need help with nvm?
|
Need help with nvm?
|
||||||
@@ -52,6 +54,7 @@ To switch easily between Node.js versions, we recommend using [nvm](https://gith
|
|||||||
to check the version of Node.js currently being used in this shell session.
|
to check the version of Node.js currently being used in this shell session.
|
||||||
|
|
||||||
Check NVM repo for more details: [https://github.com/nvm-sh/nvm](https://github.com/nvm-sh/nvm).
|
Check NVM repo for more details: [https://github.com/nvm-sh/nvm](https://github.com/nvm-sh/nvm).
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
:::
|
:::
|
||||||
@@ -65,6 +68,7 @@ curl -sSL https://get.wasp.sh/installer.sh | sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
:::caution[Bad CPU type in executable]
|
:::caution[Bad CPU type in executable]
|
||||||
|
|
||||||
<details aria-label="Bad CPU type in executable">
|
<details aria-label="Bad CPU type in executable">
|
||||||
<summary aria-label="Are you getting this error on a Mac (Apple Silicon)?">
|
<summary aria-label="Are you getting this error on a Mac (Apple Silicon)?">
|
||||||
Are you getting this error on a Mac (Apple Silicon)?
|
Are you getting this error on a Mac (Apple Silicon)?
|
||||||
@@ -74,6 +78,7 @@ Given that the wasp binary is built for x86 and not for arm64 (Apple Silicon), y
|
|||||||
```bash
|
```bash
|
||||||
softwareupdate --install-rosetta
|
softwareupdate --install-rosetta
|
||||||
```
|
```
|
||||||
|
|
||||||
Once Rosetta is installed, you should be able to run Wasp without any issues.
|
Once Rosetta is installed, you should be able to run Wasp without any issues.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -114,18 +119,22 @@ su -s $USER
|
|||||||
</details>
|
</details>
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|
||||||
Once in WSL2, run the following command in your **WSL2 environment**:
|
Once in WSL2, run the following command in your **WSL2 environment**:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -sSL https://get.wasp.sh/installer.sh | sh
|
curl -sSL https://get.wasp.sh/installer.sh | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
:::caution[WSL2 and file system issues]
|
:::caution[WSL2 and file system issues]
|
||||||
|
|
||||||
<details aria-label="Are you getting file system issues using WSL2?">
|
<details aria-label="Are you getting file system issues using WSL2?">
|
||||||
<summary aria-label="Are you getting file system issues using WSL2?">
|
<summary aria-label="Are you getting file system issues using WSL2?">
|
||||||
Are you getting file system issues using WSL2?
|
Are you getting file system issues using WSL2?
|
||||||
</summary>
|
</summary>
|
||||||
If you are using WSL2, make sure that your Wasp project is not on the Windows file system, <b>but instead on the Linux file system</b>. Otherwise, Wasp won't be able to detect file changes, due to this <a href='https://github.com/microsoft/WSL/issues/4739'>issue in WSL2</a>.
|
If you are using WSL2, make sure that your Wasp project is not on the Windows
|
||||||
|
file system, <b>but instead on the Linux file system</b>. Otherwise, Wasp
|
||||||
|
won't be able to detect file changes, due to this{" "}
|
||||||
|
<a href="https://github.com/microsoft/WSL/issues/4739">issue in WSL2</a>.
|
||||||
</details>
|
</details>
|
||||||
:::
|
:::
|
||||||
|
|
||||||
@@ -148,6 +157,7 @@ You can install the Wasp VSCode extension by searching for "Wasp" in the Extensi
|
|||||||
### Cloning the OpenSaaS template
|
### Cloning the OpenSaaS template
|
||||||
|
|
||||||
From the directory where you'd like to create your new project run:
|
From the directory where you'd like to create your new project run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
wasp new
|
wasp new
|
||||||
```
|
```
|
||||||
@@ -218,8 +228,9 @@ This will install all the dependencies and start the app (client and server) for
|
|||||||
If the app doesn't open automatically in your browser, you can open it manually by visiting `http://localhost:3000` in your browser.
|
If the app doesn't open automatically in your browser, you can open it manually by visiting `http://localhost:3000` in your browser.
|
||||||
|
|
||||||
At this point, you should have:
|
At this point, you should have:
|
||||||
- your database running in one terminal session, likely on port `5432`.
|
|
||||||
- your app running in another terminal session, the client likely on port `3000`, and the server likely on port `3001`.
|
- your database running in one terminal session, likely on port `5432`.
|
||||||
|
- your app running in another terminal session, the client likely on port `3000`, and the server likely on port `3001`.
|
||||||
|
|
||||||
#### Run Blog and Docs
|
#### Run Blog and Docs
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ banner:
|
|||||||
|
|
||||||
Awesome, you now have your very own SaaS app up and running! But, first, here are some important things you need to know about your app in its current state:
|
Awesome, you now have your very own SaaS app up and running! But, first, here are some important things you need to know about your app in its current state:
|
||||||
|
|
||||||
1. When signing up with a new user, you will get a message to check your email for a verification link. But, in development, these emails are simply written to your terminal. **So, to continue with the registration process, check your server logs after sign up**!
|
1. When signing up with a new user, you will get a message to check your email for a verification link. But, in development, these emails are simply written to your terminal. **So, to continue with the registration process, check your server logs after sign up**!
|
||||||
|
|
||||||
```sh title="server logs"
|
```sh title="server logs"
|
||||||
[ Server ] ╔═══════════════════════╗
|
[ Server ] ╔═══════════════════════╗
|
||||||
[ Server ] ║ Dummy email sender ✉️ ║
|
[ Server ] ║ Dummy email sender ✉️ ║
|
||||||
@@ -19,20 +20,23 @@ Awesome, you now have your very own SaaS app up and running! But, first, here ar
|
|||||||
[ Server ] Click the link below to verify your email: http://localhost:3000/email-verification?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InZpbm55QHdhc3Auc2giLCJleHAiOjE3MTg5NjUyNTB9.PkRGrmuDPuYFXkTprf7QpAye0e_O9a70xbER6LfxGJw
|
[ Server ] Click the link below to verify your email: http://localhost:3000/email-verification?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InZpbm55QHdhc3Auc2giLCJleHAiOjE3MTg5NjUyNTB9.PkRGrmuDPuYFXkTprf7QpAye0e_O9a70xbER6LfxGJw
|
||||||
[ Server ] ═════════ HTML ═════════
|
[ Server ] ═════════ HTML ═════════
|
||||||
[ Server ] <p>Click the link below to verify your email</p>
|
[ Server ] <p>Click the link below to verify your email</p>
|
||||||
[ Server ] <a href="http://localhost:3000/email-verification?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InZpbm55QHdhc3Auc2giLCJleHAiOjE3MTg5NjUyNTB9.PkRGrmuDPuYFXkTprf7QpAye0e_O9a70xbER6LfxGJw">Verify email</a>
|
[ Server ] <a href="http://localhost:3000/email-verification?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InZpbm55QHdhc3Auc2giLCJleHAiOjE3MTg5NjUyNTB9.PkRGrmuDPuYFXkTprf7QpAye0e_O9a70xbER6LfxGJw">Verify email</a>
|
||||||
[ Server ] ════════════════════════
|
[ Server ] ════════════════════════
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Your app is still missing some key configurations (e.g. API keys for Payment Processors, OpenAI, AWS S3, Auth, Analytics). These services won't work at the moment, but don't fear, because **we've provided detailed guides in these docs to help you set up all the services in this template**.
|
2. Your app is still missing some key configurations (e.g. API keys for Payment Processors, OpenAI, AWS S3, Auth, Analytics). These services won't work at the moment, but don't fear, because **we've provided detailed guides in these docs to help you set up all the services in this template**.
|
||||||
3. If you want to get a feel for what your SaaS could look like when finished, **check out [OpenSaaS.sh](https://opensaas.sh) in your browser. It was built using this template!** So make sure to log in, play around with the demo app, make a test payment, and check out the admin dashboard.
|
3. If you want to get a feel for what your SaaS could look like when finished, **check out [OpenSaaS.sh](https://opensaas.sh) in your browser. It was built using this template!** So make sure to log in, play around with the demo app, make a test payment, and check out the admin dashboard.
|
||||||
|
|
||||||
In the sections below, we will take a short guide through the codebase and the app's main features. Then at the end of this tour, we also prepared a checklist of likely changes you will want to make to the app to make it your own.
|
In the sections below, we will take a short guide through the codebase and the app's main features. Then at the end of this tour, we also prepared a checklist of likely changes you will want to make to the app to make it your own.
|
||||||
|
|
||||||
We're looking forward to seeing what you build!
|
We're looking forward to seeing what you build!
|
||||||
|
|
||||||
## Getting acquainted with the codebase
|
## Getting acquainted with the codebase
|
||||||
|
|
||||||
Now that you've gotten a first look at the app, let's dive into the codebase.
|
Now that you've gotten a first look at the app, let's dive into the codebase.
|
||||||
|
|
||||||
At the root of our project, you will see three folders:
|
At the root of our project, you will see three folders:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
.
|
.
|
||||||
├── app
|
├── app
|
||||||
@@ -42,13 +46,13 @@ At the root of our project, you will see three folders:
|
|||||||
|
|
||||||
`app` contains the Wasp project files, which is your full-stack React + NodeJS + Prisma app along with a Wasp config file, `main.wasp`, which will be explained in more detail below.
|
`app` contains the Wasp project files, which is your full-stack React + NodeJS + Prisma app along with a Wasp config file, `main.wasp`, which will be explained in more detail below.
|
||||||
|
|
||||||
`blog` contains the [Astro Starlight template](https://starlight.astro.build/) for the blog and documentation section.
|
`blog` contains the [Astro Starlight template](https://starlight.astro.build/) for the blog and documentation section.
|
||||||
|
|
||||||
`e2e-tests` contains the end-to-end tests using Playwright, which you can run to test your app's functionality.
|
`e2e-tests` contains the end-to-end tests using Playwright, which you can run to test your app's functionality.
|
||||||
|
|
||||||
### App File Structure
|
### App File Structure
|
||||||
|
|
||||||
We've structured this full-stack app template vertically (by feature). That means that most directories within `app/src` contain both the React client code and NodeJS server code necessary for implementing its logic.
|
We've structured this full-stack app template vertically (by feature). That means that most directories within `app/src` contain both the React client code and NodeJS server code necessary for implementing its logic.
|
||||||
|
|
||||||
Let's check out what's in the `app` folder in more detail:
|
Let's check out what's in the `app` folder in more detail:
|
||||||
|
|
||||||
@@ -58,26 +62,26 @@ If you are using an older version of the OpenSaaS template with Wasp `v0.13.x` o
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
.
|
.
|
||||||
├── main.wasp # Wasp Config file. You define your app structure here.
|
├── main.wasp # Wasp Config file. You define your app structure here.
|
||||||
├── .wasp/ # Output dir for Wasp. DON'T MODIFY THESE FILES!
|
├── .wasp/ # Output dir for Wasp. DON'T MODIFY THESE FILES!
|
||||||
├── public/ # Public assets dir, e.g. www.yourdomain.com/public-banner.webp
|
├── public/ # Public assets dir, e.g. www.yourdomain.com/public-banner.webp
|
||||||
├── src/ # Your code goes here.
|
├── src/ # Your code goes here.
|
||||||
│ ├── admin/ # Admin dashboard related pages and components.
|
│ ├── admin/ # Admin dashboard related pages and components.
|
||||||
│ ├── analytics/ # Logic and background jobs for processing analytics.
|
│ ├── analytics/ # Logic and background jobs for processing analytics.
|
||||||
│ ├── auth/ # All auth-related pages/components and logic.
|
│ ├── auth/ # All auth-related pages/components and logic.
|
||||||
│ ├── client/ # Shared components, hooks, landing page, and other client code (React).
|
│ ├── client/ # Shared components, hooks, landing page, and other client code (React).
|
||||||
│ ├── demo-ai-app/ # Logic for the example AI-powered demo app.
|
│ ├── demo-ai-app/ # Logic for the example AI-powered demo app.
|
||||||
│ ├── file-upload/ # Logic for uploading files to S3.
|
│ ├── file-upload/ # Logic for uploading files to S3.
|
||||||
│ ├── landing-page # Landing page related code
|
│ ├── landing-page # Landing page related code
|
||||||
│ ├── messages # Logic for app user messages.
|
│ ├── messages # Logic for app user messages.
|
||||||
│ ├── payment/ # Logic for handling payments and webhooks.
|
│ ├── payment/ # Logic for handling payments and webhooks.
|
||||||
│ ├── server/ # Scripts, shared server utils, and other server-specific code (NodeJS).
|
│ ├── server/ # Scripts, shared server utils, and other server-specific code (NodeJS).
|
||||||
│ ├── shared/ # Shared constants and util functions.
|
│ ├── shared/ # Shared constants and util functions.
|
||||||
│ └── user/ # Logic related to users and their accounts.
|
│ └── user/ # Logic related to users and their accounts.
|
||||||
├── .env.server # Dev environment variables for your server code.
|
├── .env.server # Dev environment variables for your server code.
|
||||||
├── .env.client # Dev environment variables for your client code.
|
├── .env.client # Dev environment variables for your client code.
|
||||||
├── .prettierrc # Prettier configuration.
|
├── .prettierrc # Prettier configuration.
|
||||||
├── tailwind.config.js # TailwindCSS configuration.
|
├── tailwind.config.js # TailwindCSS configuration.
|
||||||
├── package.json
|
├── package.json
|
||||||
├── package-lock.json
|
├── package-lock.json
|
||||||
└── .wasproot
|
└── .wasproot
|
||||||
@@ -87,7 +91,7 @@ If you are using an older version of the OpenSaaS template with Wasp `v0.13.x` o
|
|||||||
|
|
||||||
This template at its core is a Wasp project, where [Wasp](https://wasp.sh) is a full-stack web app framework that let's you write your app in React, NodeJS, and Prisma and will manage the "boilerplatey" work for you, allowing you to just take care of the fun stuff!
|
This template at its core is a Wasp project, where [Wasp](https://wasp.sh) is a full-stack web app framework that let's you write your app in React, NodeJS, and Prisma and will manage the "boilerplatey" work for you, allowing you to just take care of the fun stuff!
|
||||||
|
|
||||||
[Wasp's secret sauce](https://wasp.sh/docs) is its use of a config file (`main.wasp`) and compiler which takes your code and outputs the client app, server app and deployment code for you.
|
[Wasp's secret sauce](https://wasp.sh/docs) is its use of a config file (`main.wasp`) and compiler which takes your code and outputs the client app, server app and deployment code for you.
|
||||||
|
|
||||||
In this template, we've already defined a number of things in the `main.wasp` config file, including:
|
In this template, we've already defined a number of things in the `main.wasp` config file, including:
|
||||||
|
|
||||||
@@ -113,25 +117,24 @@ The `src/client` folder contains any additional client-side code that doesn't be
|
|||||||
```sh
|
```sh
|
||||||
.
|
.
|
||||||
└── client
|
└── client
|
||||||
├── components # Your shared React components.
|
├── components # Your shared React components.
|
||||||
├── fonts # Extra fonts
|
├── fonts # Extra fonts
|
||||||
├── hooks # Your shared React hooks.
|
├── hooks # Your shared React hooks.
|
||||||
├── icons # Your shared SVG icons.
|
├── icons # Your shared SVG icons.
|
||||||
├── static # Assets that you need access to in your code, e.g. import logo from 'static/logo.png'
|
├── static # Assets that you need access to in your code, e.g. import logo from 'static/logo.png'
|
||||||
├── App.tsx # Main app component to wrap all child components. Useful for global state, navbars, etc.
|
├── App.tsx # Main app component to wrap all child components. Useful for global state, navbars, etc.
|
||||||
├── cn.ts # Helper function for dynamic and conditional Tailwind CSS classes.
|
├── cn.ts # Helper function for dynamic and conditional Tailwind CSS classes.
|
||||||
└── Main.css
|
└── Main.css
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
|
|
||||||
The `src/server` folder contains any additional server-side code that does not belong to a specific feature:
|
The `src/server` folder contains any additional server-side code that does not belong to a specific feature:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
└── server
|
└── server
|
||||||
├── scripts # Scripts to run via Wasp, e.g. database seeding.
|
├── scripts # Scripts to run via Wasp, e.g. database seeding.
|
||||||
└── utils.ts
|
└── utils.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Main Features
|
## Main Features
|
||||||
@@ -144,7 +147,7 @@ This template comes with a fully functional auth flow out of the box. It takes a
|
|||||||
auth: {
|
auth: {
|
||||||
userEntity: User,
|
userEntity: User,
|
||||||
methods: {
|
methods: {
|
||||||
email: {
|
email: {
|
||||||
//...
|
//...
|
||||||
},
|
},
|
||||||
google: {},
|
google: {},
|
||||||
@@ -156,39 +159,41 @@ This template comes with a fully functional auth flow out of the box. It takes a
|
|||||||
```
|
```
|
||||||
|
|
||||||
By defining the auth structure in your `main.wasp` file, Wasp manages all the necessary code for you, including:
|
By defining the auth structure in your `main.wasp` file, Wasp manages all the necessary code for you, including:
|
||||||
|
|
||||||
- Email verified login with reset password
|
- Email verified login with reset password
|
||||||
- Social login with Google and/or GitHub
|
- Social login with Google and/or GitHub
|
||||||
- Auth-related database entities for user credentials, sessions, and social logins
|
- Auth-related database entities for user credentials, sessions, and social logins
|
||||||
- Custom-generated AuthUI components for login, signup, and reset password
|
- Custom-generated AuthUI components for login, signup, and reset password
|
||||||
- Auth hooks for fetching user data
|
- Auth hooks for fetching user data
|
||||||
|
|
||||||
<!-- TODO: add pic of AuthUI components -->
|
<!-- TODO: add pic of AuthUI components -->
|
||||||
|
|
||||||
We've set the template up with Wasp's `email`, `google`, and `gitHub` methods, which are all battle-tested and suitable for production.
|
We've set the template up with Wasp's `email`, `google`, and `gitHub` methods, which are all battle-tested and suitable for production.
|
||||||
|
|
||||||
You can get started developing your app with the `email` method right away!
|
You can get started developing your app with the `email` method right away!
|
||||||
|
|
||||||
:::caution[Dummy Email Provider]
|
:::caution[Dummy Email Provider]
|
||||||
Note that the `email` method relies on an `emailSender` (configured at `app.emailSender` in the `main.wasp` file), a service which sends emails to verify users and reset passwords.
|
Note that the `email` method relies on an `emailSender` (configured at `app.emailSender` in the `main.wasp` file), a service which sends emails to verify users and reset passwords.
|
||||||
|
|
||||||
For development purposes, Wasp provides a `Dummy` email sender which Open SaaS comes with as the default. This provider *does not* actually send any confirmation emails to the specified email address, but instead logs all email verification links/tokens to the console! You can then follow these links to verify the user and continue with the sign-up process.
|
For development purposes, Wasp provides a `Dummy` email sender which Open SaaS comes with as the default. This provider _does not_ actually send any confirmation emails to the specified email address, but instead logs all email verification links/tokens to the console! You can then follow these links to verify the user and continue with the sign-up process.
|
||||||
|
|
||||||
```tsx title="main.wasp"
|
```tsx title="main.wasp"
|
||||||
emailSender: {
|
emailSender: {
|
||||||
provider: Dummy, // logs all email verification links/tokens to the server's console
|
provider: Dummy, // logs all email verification links/tokens to the server's console
|
||||||
defaultFrom: {
|
defaultFrom: {
|
||||||
name: "Open SaaS App",
|
name: "Open SaaS App",
|
||||||
email: "me@example.com"
|
email: "me@example.com"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
We will explain more about these auth methods, and how to properly integrate them into your app, in the [Authentication Guide](/guides/authentication/).
|
We will explain more about these auth methods, and how to properly integrate them into your app, in the [Authentication Guide](/guides/authentication/).
|
||||||
|
|
||||||
### Subscription Payments with Stripe or Lemon Squeezy
|
### Subscription Payments with Stripe or Lemon Squeezy
|
||||||
|
|
||||||
No SaaS is complete without payments, specifically subscription payments. That's why this template comes with a fully functional Stripe or Lemon Squeezy integration.
|
No SaaS is complete without payments, specifically subscription payments. That's why this template comes with a fully functional Stripe or Lemon Squeezy integration.
|
||||||
|
|
||||||
Let's take a quick look at how payments are handled in this template.
|
Let's take a quick look at how payments are handled in this template.
|
||||||
|
|
||||||
@@ -203,6 +208,7 @@ The payment processor you choose (Stripe or Lemon Squeezy) and its related funct
|
|||||||
The logic for creating the Checkout session is defined in the `src/payment/operation.ts` file. [Actions](https://wasp.sh/docs/data-model/operations/actions) are a type of Wasp Operation, specifically your server-side functions that are used to **write** or **update** data to the database. Once they're defined in the `main.wasp` file, you can easily call them on the client-side:
|
The logic for creating the Checkout session is defined in the `src/payment/operation.ts` file. [Actions](https://wasp.sh/docs/data-model/operations/actions) are a type of Wasp Operation, specifically your server-side functions that are used to **write** or **update** data to the database. Once they're defined in the `main.wasp` file, you can easily call them on the client-side:
|
||||||
|
|
||||||
a) define the action in the `main.wasp` file
|
a) define the action in the `main.wasp` file
|
||||||
|
|
||||||
```js title="main.wasp"
|
```js title="main.wasp"
|
||||||
action generateCheckoutSession {
|
action generateCheckoutSession {
|
||||||
fn: import { generateCheckoutSession } from "@src/payment/operations",
|
fn: import { generateCheckoutSession } from "@src/payment/operations",
|
||||||
@@ -211,13 +217,15 @@ action generateCheckoutSession {
|
|||||||
```
|
```
|
||||||
|
|
||||||
b) implement the action in the `src/payment/operations` file
|
b) implement the action in the `src/payment/operations` file
|
||||||
|
|
||||||
```js title="src/server/actions.ts"
|
```js title="src/server/actions.ts"
|
||||||
export const generateCheckoutSession = async (paymentPlanId, context) => {
|
export const generateCheckoutSession = async (paymentPlanId, context) => {
|
||||||
//...
|
//...
|
||||||
}
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
c) call the action on the client-side
|
c) call the action on the client-side
|
||||||
|
|
||||||
```js title="src/client/app/SubscriptionPage.tsx"
|
```js title="src/client/app/SubscriptionPage.tsx"
|
||||||
import { generateCheckoutSession } from "wasp/client/operations";
|
import { generateCheckoutSession } from "wasp/client/operations";
|
||||||
|
|
||||||
@@ -231,7 +239,7 @@ The webhook handler is defined in the `src/payment/webhook.ts` file. Unlike Acti
|
|||||||
```js title="main.wasp"
|
```js title="main.wasp"
|
||||||
api paymentsWebhook {
|
api paymentsWebhook {
|
||||||
fn: import { paymentsWebhook } from "@src/payment/webhook",
|
fn: import { paymentsWebhook } from "@src/payment/webhook",
|
||||||
httpRoute: (POST, "/payments-webhook")
|
httpRoute: (POST, "/payments-webhook")
|
||||||
entities: [User],
|
entities: [User],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -246,7 +254,6 @@ We've packed in a ton of features and love into this SaaS starter, and offer it
|
|||||||
If you're finding this template and its guides useful, consider giving us [a star on GitHub](https://github.com/wasp-lang/wasp)
|
If you're finding this template and its guides useful, consider giving us [a star on GitHub](https://github.com/wasp-lang/wasp)
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|
||||||
### Analytics and Admin Dashboard
|
### Analytics and Admin Dashboard
|
||||||
|
|
||||||
Keeping an eye on your metrics is crucial for any SaaS. That's why we've built an administrator's dashboard where you can view your app's stats, user data, and revenue all in one place.
|
Keeping an eye on your metrics is crucial for any SaaS. That's why we've built an administrator's dashboard where you can view your app's stats, user data, and revenue all in one place.
|
||||||
@@ -279,7 +286,7 @@ When you first start your Open SaaS app straight from the template, it will run,
|
|||||||
- Auth Methods (Google, GitHub)
|
- Auth Methods (Google, GitHub)
|
||||||
- Stripe or Lemon Squeezy
|
- Stripe or Lemon Squeezy
|
||||||
- OpenAI (Chat GPT API)
|
- OpenAI (Chat GPT API)
|
||||||
- Email Sending (Sendgrid) -- you must set this up if you're using the `email` Auth method
|
- Email Sending (Sendgrid) -- you must set this up if you're using the `email` Auth method
|
||||||
- Analytics (Plausible or Google Analytics)
|
- Analytics (Plausible or Google Analytics)
|
||||||
- File Uploading (AWS S3)
|
- File Uploading (AWS S3)
|
||||||
|
|
||||||
@@ -294,19 +301,24 @@ Remember, this template is built on the Wasp framework. If, at any time, these d
|
|||||||
But before you start setting up the main features, let's walk through the customizations you will likely want to make to the template to make it your own.
|
But before you start setting up the main features, let's walk through the customizations you will likely want to make to the template to make it your own.
|
||||||
|
|
||||||
### Customizations Checklist
|
### Customizations Checklist
|
||||||
|
|
||||||
#### `main.wasp` Config File
|
#### `main.wasp` Config File
|
||||||
|
|
||||||
- [ ] Change the app name and title:
|
- [ ] Change the app name and title:
|
||||||
|
|
||||||
```ts title="main.wasp" {1, 6}
|
```ts title="main.wasp" {1, 6}
|
||||||
app YourAppName {
|
app YourAppName {
|
||||||
wasp: {
|
wasp: {
|
||||||
version: "^0.13.2"
|
version: "^0.13.2"
|
||||||
},
|
},
|
||||||
|
|
||||||
title: "Your App Name",
|
title: "Your App Name",
|
||||||
```
|
```
|
||||||
|
|
||||||
:::caution[Restart Your App]
|
:::caution[Restart Your App]
|
||||||
Upon changing the app name, new, empty development database will be assigned to your app. This means you'll need to rerun `wasp db start`, `wasp db migrate-dev` and `wasp start`.
|
Upon changing the app name, new, empty development database will be assigned to your app. This means you'll need to rerun `wasp db start`, `wasp db migrate-dev` and `wasp start`.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
- [ ] Update meta tags in `app.head` (even if you don't have a custom domain yet, put one you would like to have, as this won't affect development).
|
- [ ] Update meta tags in `app.head` (even if you don't have a custom domain yet, put one you would like to have, as this won't affect development).
|
||||||
- [ ] Update `app.emailSender.defaultFrom.name` with the name of your app/company/whatever you want your users to see in their inbox, if you're using the `emailSender` feature and/or `email` Auth method.
|
- [ ] Update `app.emailSender.defaultFrom.name` with the name of your app/company/whatever you want your users to see in their inbox, if you're using the `emailSender` feature and/or `email` Auth method.
|
||||||
- [ ] Remove any features you might not use or need:
|
- [ ] Remove any features you might not use or need:
|
||||||
@@ -318,6 +330,7 @@ But before you start setting up the main features, let's walk through the custom
|
|||||||
- [ ] Rename Entites and their properties, Routes/Pages, & Operations, if you wish.
|
- [ ] Rename Entites and their properties, Routes/Pages, & Operations, if you wish.
|
||||||
|
|
||||||
#### Customizing the Look / Style of the App
|
#### Customizing the Look / Style of the App
|
||||||
|
|
||||||
- [ ] Update your favicon at `public/favicon.ico`.
|
- [ ] Update your favicon at `public/favicon.ico`.
|
||||||
- [ ] Update the banner image used when posting links to your site at `public/public-banner.webp`.
|
- [ ] Update the banner image used when posting links to your site at `public/public-banner.webp`.
|
||||||
- [ ] Update the URL for this banner at `og:image` and `twitter:image` in `app.head` of the `main.wasp` file.
|
- [ ] Update the URL for this banner at `og:image` and `twitter:image` in `app.head` of the `main.wasp` file.
|
||||||
@@ -327,16 +340,19 @@ But before you start setting up the main features, let's walk through the custom
|
|||||||
- [ ] If you want to make changes to the global styles of the app, you can do so in `tailwind.config.cjs`. **Be aware that the current custom global styles defined already are mostly used in the app's Admin Dashboard!**
|
- [ ] If you want to make changes to the global styles of the app, you can do so in `tailwind.config.cjs`. **Be aware that the current custom global styles defined already are mostly used in the app's Admin Dashboard!**
|
||||||
|
|
||||||
#### Customizing the Analytics & Admin Dashboard
|
#### Customizing the Analytics & Admin Dashboard
|
||||||
|
|
||||||
- [ ] If you're using Plausible, update the `app.head` with your Plausible domain.
|
- [ ] If you're using Plausible, update the `app.head` with your Plausible domain.
|
||||||
- [ ] Update the `calculateDailyStats` function in `src/server/workers/calculateDailyStats.ts` to pull the stats from the analytics provider you've chosen (Plausible or Google Analytics).
|
- [ ] Update the `calculateDailyStats` function in `src/server/workers/calculateDailyStats.ts` to pull the stats from the analytics provider you've chosen (Plausible or Google Analytics).
|
||||||
- [ ] Change the cron schedule in the `dailyStatsJob` in the `main.wasp` file to match how often you want your stats to be calculated.
|
- [ ] Change the cron schedule in the `dailyStatsJob` in the `main.wasp` file to match how often you want your stats to be calculated.
|
||||||
- [ ] Update the `AdminDashboard` components to display the stats you do/don't want to see.
|
- [ ] Update the `AdminDashboard` components to display the stats you do/don't want to see.
|
||||||
|
|
||||||
#### `.env.server` and `.env.client` Files
|
#### `.env.server` and `.env.client` Files
|
||||||
|
|
||||||
- [ ] After you've followed the `Guides` in the next section, you'll need to update the `.env.server` and `.env.client` files with your API keys and other environment variables for the services you've decided to use.
|
- [ ] After you've followed the `Guides` in the next section, you'll need to update the `.env.server` and `.env.client` files with your API keys and other environment variables for the services you've decided to use.
|
||||||
- [ ] Delete any redundant environment variables that you're not using, from the `.env.*` files as well as the `.env.*.example` files.
|
- [ ] Delete any redundant environment variables that you're not using, from the `.env.*` files as well as the `.env.*.example` files.
|
||||||
|
|
||||||
#### Other Customizations
|
#### Other Customizations
|
||||||
|
|
||||||
- [ ] Make a new GitHub Repo for your app.
|
- [ ] Make a new GitHub Repo for your app.
|
||||||
- [ ] Deploy your app to a hosting provider.
|
- [ ] Deploy your app to a hosting provider.
|
||||||
- [ ] Buy a domain name for your app and get it set up with your hosting provider.
|
- [ ] Buy a domain name for your app and get it set up with your hosting provider.
|
||||||
|
|||||||
18
opensaas-sh/blog/src/virtual.d.ts
vendored
18
opensaas-sh/blog/src/virtual.d.ts
vendored
@@ -1,22 +1,22 @@
|
|||||||
declare module 'virtual:starlight-blog-config' {
|
declare module "virtual:starlight-blog-config" {
|
||||||
const StarlightBlogConfig: import('./libs/config').StarlightBlogConfig;
|
const StarlightBlogConfig: import("./libs/config").StarlightBlogConfig;
|
||||||
|
|
||||||
export default StarlightBlogConfig;
|
export default StarlightBlogConfig;
|
||||||
}
|
}
|
||||||
declare module 'virtual:starlight/user-config' {
|
declare module "virtual:starlight/user-config" {
|
||||||
const Config: import('@astrojs/starlight/types').StarlightConfig;
|
const Config: import("@astrojs/starlight/types").StarlightConfig;
|
||||||
|
|
||||||
export default Config;
|
export default Config;
|
||||||
}
|
}
|
||||||
declare module 'virtual:starlight/user-images' {
|
declare module "virtual:starlight/user-images" {
|
||||||
type ImageMetadata = import('astro').ImageMetadata;
|
type ImageMetadata = import("astro").ImageMetadata;
|
||||||
export const logos: {
|
export const logos: {
|
||||||
dark?: ImageMetadata;
|
dark?: ImageMetadata;
|
||||||
light?: ImageMetadata;
|
light?: ImageMetadata;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
declare module 'virtual:astro-config' {
|
declare module "virtual:astro-config" {
|
||||||
const Config: import('@astrojs/types').Config;
|
const Config: import("@astrojs/types").Config;
|
||||||
|
|
||||||
export default Config;
|
export default Config;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import starlightPlugin from "@astrojs/starlight-tailwind";
|
import starlightPlugin from "@astrojs/starlight-tailwind";
|
||||||
import colors from "tailwindcss/colors";
|
import colors from "tailwindcss/colors";
|
||||||
|
|
||||||
const yellow = colors.yellow
|
const yellow = colors.yellow;
|
||||||
const gray = colors.gray
|
const gray = colors.gray;
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
@@ -10,7 +10,8 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
accent: yellow, gray
|
accent: yellow,
|
||||||
|
gray,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,4 +7,4 @@
|
|||||||
"@assets/*": ["src/assets/*"]
|
"@assets/*": ["src/assets/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
TOOLS_DIR=$(dirname "$(realpath "$0")") # Assumes this script is in `tools/`.
|
TOOLS_DIR=$(dirname "$(realpath "$0")") # Assumes this script is in `tools/`.
|
||||||
cd "${TOOLS_DIR}" && cd ../..
|
cd "${TOOLS_DIR}" && cd ../..
|
||||||
|
|
||||||
rm -rf opensaas-sh/app_diff
|
rm -rf opensaas-sh/app_diff
|
||||||
|
|||||||
@@ -8,28 +8,28 @@
|
|||||||
# Determine the patch command to use based on OS
|
# Determine the patch command to use based on OS
|
||||||
PATCH_CMD="patch"
|
PATCH_CMD="patch"
|
||||||
if [[ "$(uname)" == "Darwin" ]]; then
|
if [[ "$(uname)" == "Darwin" ]]; then
|
||||||
# On macOS, require gpatch to be installed
|
# On macOS, require gpatch to be installed
|
||||||
if command -v gpatch &> /dev/null; then
|
if command -v gpatch &> /dev/null; then
|
||||||
PATCH_CMD="gpatch"
|
PATCH_CMD="gpatch"
|
||||||
else
|
else
|
||||||
echo "Error: GNU patch (gpatch) not found. On MacOS, this script requires GNU patch."
|
echo "Error: GNU patch (gpatch) not found. On MacOS, this script requires GNU patch."
|
||||||
echo "Install it with: brew install gpatch"
|
echo "Install it with: brew install gpatch"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# List all the source files in the specified dir.
|
# List all the source files in the specified dir.
|
||||||
# "Source" files are any files that are not gitignored.
|
# "Source" files are any files that are not gitignored.
|
||||||
list_source_files() {
|
list_source_files() {
|
||||||
local dir=$1
|
local dir=$1
|
||||||
(cd "${dir}" && git ls-files --cached --others --exclude-standard | sort)
|
(cd "${dir}" && git ls-files --cached --others --exclude-standard | sort)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if the required arguments are provided.
|
# Check if the required arguments are provided.
|
||||||
if [ "$#" -ne 3 ]; then
|
if [ "$#" -ne 3 ]; then
|
||||||
echo "Usage: $0 <BASE_DIR> <DERIVED_DIR> <ACTION>"
|
echo "Usage: $0 <BASE_DIR> <DERIVED_DIR> <ACTION>"
|
||||||
echo "<ACTION> should be either 'diff' to get the diff between the specified dirs or 'patch' to apply such existing diff onto base dir."
|
echo "<ACTION> should be either 'diff' to get the diff between the specified dirs or 'patch' to apply such existing diff onto base dir."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
BASE_DIR=$1
|
BASE_DIR=$1
|
||||||
@@ -41,41 +41,41 @@ DIFF_DIR_DELETIONS="${DIFF_DIR}/deletions"
|
|||||||
|
|
||||||
# Based on base dir and derived dir, creates a diff dir that contains the diff between the two dirs.
|
# Based on base dir and derived dir, creates a diff dir that contains the diff between the two dirs.
|
||||||
recreate_diff_dir() {
|
recreate_diff_dir() {
|
||||||
mkdir -p "${DIFF_DIR}"
|
mkdir -p "${DIFF_DIR}"
|
||||||
|
|
||||||
# List all the source files in the base and derived dirs. We skip gitignored files.
|
# List all the source files in the base and derived dirs. We skip gitignored files.
|
||||||
local BASE_FILES
|
local BASE_FILES
|
||||||
BASE_FILES=$(list_source_files "${BASE_DIR}") # File paths relative to the base dir.
|
BASE_FILES=$(list_source_files "${BASE_DIR}") # File paths relative to the base dir.
|
||||||
local DERIVED_FILES
|
local DERIVED_FILES
|
||||||
DERIVED_FILES=$(list_source_files "${DERIVED_DIR}") # File paths relative to the derived dir.
|
DERIVED_FILES=$(list_source_files "${DERIVED_DIR}") # File paths relative to the derived dir.
|
||||||
|
|
||||||
# For each source file in the derived dir, generate a .diff file between it and the
|
# For each source file in the derived dir, generate a .diff file between it and the
|
||||||
# corresponding source file in the base dir.
|
# corresponding source file in the base dir.
|
||||||
while IFS= read -r filepath; do
|
while IFS= read -r filepath; do
|
||||||
local derivedFilepath="${DERIVED_DIR}/${filepath}"
|
local derivedFilepath="${DERIVED_DIR}/${filepath}"
|
||||||
local baseFilepath="${BASE_DIR}/${filepath}"
|
local baseFilepath="${BASE_DIR}/${filepath}"
|
||||||
|
|
||||||
local filepathToBeUsedAsBase="${baseFilepath}"
|
local filepathToBeUsedAsBase="${baseFilepath}"
|
||||||
# If the file is not one of the source files in base dir (e.g. is gitignored or doesn't exist),
|
# If the file is not one of the source files in base dir (e.g. is gitignored or doesn't exist),
|
||||||
# then we set it to /dev/null to indicate it doesn't exist for our purposes.
|
# then we set it to /dev/null to indicate it doesn't exist for our purposes.
|
||||||
if ! echo "${BASE_FILES}" | grep -q "^${filepath}$"; then
|
if ! echo "${BASE_FILES}" | grep -q "^${filepath}$"; then
|
||||||
filepathToBeUsedAsBase="/dev/null"
|
filepathToBeUsedAsBase="/dev/null"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local DIFF_OUTPUT
|
local DIFF_OUTPUT
|
||||||
DIFF_OUTPUT=$(diff -Nu --label "${baseFilepath}" --label "${derivedFilepath}" "${filepathToBeUsedAsBase}" "${derivedFilepath}")
|
DIFF_OUTPUT=$(diff -Nu --label "${baseFilepath}" --label "${derivedFilepath}" "${filepathToBeUsedAsBase}" "${derivedFilepath}")
|
||||||
if [ $? -eq 1 ]; then
|
if [ $? -eq 1 ]; then
|
||||||
mkdir -p "${DIFF_DIR}/$(dirname "${filepath}")"
|
mkdir -p "${DIFF_DIR}/$(dirname "${filepath}")"
|
||||||
echo "${DIFF_OUTPUT}" > "${DIFF_DIR}/${filepath}.diff"
|
echo "${DIFF_OUTPUT}" > "${DIFF_DIR}/${filepath}.diff"
|
||||||
echo "Generated ${DIFF_DIR}/${filepath}.diff"
|
echo "Generated ${DIFF_DIR}/${filepath}.diff"
|
||||||
fi
|
fi
|
||||||
done <<< "${DERIVED_FILES}"
|
done <<< "${DERIVED_FILES}"
|
||||||
|
|
||||||
local FILES_ONLY_IN_BASE
|
local FILES_ONLY_IN_BASE
|
||||||
FILES_ONLY_IN_BASE=$(comm -23 <(echo "${BASE_FILES}") <(echo "${DERIVED_FILES}"))
|
FILES_ONLY_IN_BASE=$(comm -23 <(echo "${BASE_FILES}") <(echo "${DERIVED_FILES}"))
|
||||||
echo "${FILES_ONLY_IN_BASE}" > "${DIFF_DIR_DELETIONS}"
|
echo "${FILES_ONLY_IN_BASE}" > "${DIFF_DIR_DELETIONS}"
|
||||||
|
|
||||||
echo "DONE: generated ${DIFF_DIR}/"
|
echo "DONE: generated ${DIFF_DIR}/"
|
||||||
}
|
}
|
||||||
|
|
||||||
RED_COLOR='\033[0;31m'
|
RED_COLOR='\033[0;31m'
|
||||||
@@ -84,76 +84,76 @@ RESET_COLOR='\033[0m'
|
|||||||
|
|
||||||
# Patches the diff dir onto the base dir to get the derived dir.
|
# Patches the diff dir onto the base dir to get the derived dir.
|
||||||
recreate_derived_dir() {
|
recreate_derived_dir() {
|
||||||
mkdir -p "${DERIVED_DIR}"
|
mkdir -p "${DERIVED_DIR}"
|
||||||
|
|
||||||
local BASE_FILES
|
local BASE_FILES
|
||||||
BASE_FILES=$(list_source_files "${BASE_DIR}") # File paths relative to the base dir.
|
BASE_FILES=$(list_source_files "${BASE_DIR}") # File paths relative to the base dir.
|
||||||
|
|
||||||
# Copy all the source files from the base dir over to the derived dir.
|
# Copy all the source files from the base dir over to the derived dir.
|
||||||
while IFS= read -r filepath; do
|
while IFS= read -r filepath; do
|
||||||
mkdir -p "${DERIVED_DIR}/$(dirname ${filepath})"
|
mkdir -p "${DERIVED_DIR}/$(dirname ${filepath})"
|
||||||
cp "${BASE_DIR}/${filepath}" "${DERIVED_DIR}/${filepath}"
|
cp "${BASE_DIR}/${filepath}" "${DERIVED_DIR}/${filepath}"
|
||||||
done <<< "${BASE_FILES}"
|
done <<< "${BASE_FILES}"
|
||||||
|
|
||||||
# For each .diff file in diff dir, apply the patch to the corresponding base file in the derived dir.
|
# For each .diff file in diff dir, apply the patch to the corresponding base file in the derived dir.
|
||||||
#local num_patches_failed
|
#local num_patches_failed
|
||||||
local num_patches_failed=0
|
local num_patches_failed=0
|
||||||
while IFS= read -r diff_filepath; do
|
while IFS= read -r diff_filepath; do
|
||||||
local derived_filepath
|
local derived_filepath
|
||||||
derived_filepath="${diff_filepath#${DIFF_DIR}/}"
|
derived_filepath="${diff_filepath#${DIFF_DIR}/}"
|
||||||
derived_filepath="${derived_filepath%.diff}"
|
derived_filepath="${derived_filepath%.diff}"
|
||||||
|
|
||||||
local patch_output
|
local patch_output
|
||||||
local patch_exit_code
|
local patch_exit_code
|
||||||
patch_output=$("${PATCH_CMD}" --no-backup-if-mismatch --merge "${DERIVED_DIR}/${derived_filepath}" < "${diff_filepath}")
|
patch_output=$("${PATCH_CMD}" --no-backup-if-mismatch --merge "${DERIVED_DIR}/${derived_filepath}" < "${diff_filepath}")
|
||||||
patch_exit_code=$?
|
patch_exit_code=$?
|
||||||
if [ ${patch_exit_code} -eq 0 ]; then
|
if [ ${patch_exit_code} -eq 0 ]; then
|
||||||
echo "${patch_output}"
|
echo "${patch_output}"
|
||||||
echo -e "${GREEN_COLOR}[OK]${RESET_COLOR}"
|
echo -e "${GREEN_COLOR}[OK]${RESET_COLOR}"
|
||||||
else
|
|
||||||
echo "${patch_output}"
|
|
||||||
echo -e "${RED_COLOR}[Failed with exit code ${patch_exit_code}]${RESET_COLOR}"
|
|
||||||
num_patches_failed=$((num_patches_failed + 1))
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
done < <(find "${DIFF_DIR}" -name "*.diff")
|
|
||||||
|
|
||||||
# Delete any files that exist in the base dir but shouldn't exist in the derived dir.
|
|
||||||
# TODO: also allow deletion of dirs.
|
|
||||||
if [ -f "${DIFF_DIR_DELETIONS}" ]; then
|
|
||||||
while IFS= read -r filepath; do
|
|
||||||
local derived_dir_filepath
|
|
||||||
local rm_exit_code
|
|
||||||
derived_dir_filepath="${DERIVED_DIR}/${filepath}"
|
|
||||||
rm "${derived_dir_filepath}"
|
|
||||||
rm_exit_code=$?
|
|
||||||
if [ ${rm_exit_code} -eq 0 ]; then
|
|
||||||
echo "Deleted ${derived_dir_filepath}"
|
|
||||||
echo -e "${GREEN_COLOR}[OK]${RESET_COLOR}"
|
|
||||||
else
|
|
||||||
echo "Failed to delete ${derived_dir_filepath}"
|
|
||||||
echo -e "${RED_COLOR}[Failed with exit code ${rm_exit_code}]${RESET_COLOR}"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
done < "${DIFF_DIR_DELETIONS}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "DONE: generated ${DERIVED_DIR}/"
|
|
||||||
|
|
||||||
if [ ${num_patches_failed} -gt 0 ]; then
|
|
||||||
echo -e "${RED_COLOR}${num_patches_failed} patches failed, look into generated files for merge conflicts.${RESET_COLOR}"
|
|
||||||
exit 1;
|
|
||||||
else
|
else
|
||||||
echo -e "${GREEN_COLOR}All patches successfully applied.${RESET_COLOR}"
|
echo "${patch_output}"
|
||||||
|
echo -e "${RED_COLOR}[Failed with exit code ${patch_exit_code}]${RESET_COLOR}"
|
||||||
|
num_patches_failed=$((num_patches_failed + 1))
|
||||||
fi
|
fi
|
||||||
|
echo ""
|
||||||
|
done < <(find "${DIFF_DIR}" -name "*.diff")
|
||||||
|
|
||||||
|
# Delete any files that exist in the base dir but shouldn't exist in the derived dir.
|
||||||
|
# TODO: also allow deletion of dirs.
|
||||||
|
if [ -f "${DIFF_DIR_DELETIONS}" ]; then
|
||||||
|
while IFS= read -r filepath; do
|
||||||
|
local derived_dir_filepath
|
||||||
|
local rm_exit_code
|
||||||
|
derived_dir_filepath="${DERIVED_DIR}/${filepath}"
|
||||||
|
rm "${derived_dir_filepath}"
|
||||||
|
rm_exit_code=$?
|
||||||
|
if [ ${rm_exit_code} -eq 0 ]; then
|
||||||
|
echo "Deleted ${derived_dir_filepath}"
|
||||||
|
echo -e "${GREEN_COLOR}[OK]${RESET_COLOR}"
|
||||||
|
else
|
||||||
|
echo "Failed to delete ${derived_dir_filepath}"
|
||||||
|
echo -e "${RED_COLOR}[Failed with exit code ${rm_exit_code}]${RESET_COLOR}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
done < "${DIFF_DIR_DELETIONS}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "DONE: generated ${DERIVED_DIR}/"
|
||||||
|
|
||||||
|
if [ ${num_patches_failed} -gt 0 ]; then
|
||||||
|
echo -e "${RED_COLOR}${num_patches_failed} patches failed, look into generated files for merge conflicts.${RESET_COLOR}"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo -e "${GREEN_COLOR}All patches successfully applied.${RESET_COLOR}"
|
||||||
|
fi
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if [ "$ACTION" == "diff" ]; then
|
if [ "$ACTION" == "diff" ]; then
|
||||||
recreate_diff_dir
|
recreate_diff_dir
|
||||||
elif [ "$ACTION" == "patch" ]; then
|
elif [ "$ACTION" == "patch" ]; then
|
||||||
recreate_derived_dir
|
recreate_derived_dir
|
||||||
else
|
else
|
||||||
echo "Invalid action specified. Use 'diff' to get a diff between specified dirs or 'patch' to patch the existing diff onto base dir."
|
echo "Invalid action specified. Use 'diff' to get a diff between specified dirs or 'patch' to patch the existing diff onto base dir."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
TOOLS_DIR=$(dirname "$(realpath "$0")") # Assumes this script is in `tools/`.
|
TOOLS_DIR=$(dirname "$(realpath "$0")") # Assumes this script is in `tools/`.
|
||||||
cd "${TOOLS_DIR}" && cd ../..
|
cd "${TOOLS_DIR}" && cd ../..
|
||||||
|
|
||||||
# Removes all files except for some gitignored files that we don't want to bother regenerating each time,
|
# Removes all files except for some gitignored files that we don't want to bother regenerating each time,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# <YOUR_APP_NAME>
|
# <YOUR_APP_NAME>
|
||||||
|
|
||||||
This project is based on [OpenSaas](https://opensaas.sh) template and consists of three main dirs:
|
This project is based on [OpenSaas](https://opensaas.sh) template and consists of three main dirs:
|
||||||
|
|
||||||
1. `app` - Your web app, built with [Wasp](https://wasp.sh).
|
1. `app` - Your web app, built with [Wasp](https://wasp.sh).
|
||||||
2. `e2e-tests` - [Playwright](https://playwright.dev/) tests for your Wasp web app.
|
2. `e2e-tests` - [Playwright](https://playwright.dev/) tests for your Wasp web app.
|
||||||
3. `blog` - Your blog / docs, built with [Astro](https://docs.astro.build) based on [Starlight](https://starlight.astro.build/) template.
|
3. `blog` - Your blog / docs, built with [Astro](https://docs.astro.build) based on [Starlight](https://starlight.astro.build/) template.
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
### PRD / initial prompt
|
### PRD / initial prompt
|
||||||
|
|
||||||
I want to create a `<insert-type-of-app-here>` app with the current SaaS boilerplate template project I'm in which uses Wasp and already has payment processing, AWS S3 file upload, a landing page, an admin dashboard, and authentication already setup. Leveraging Wasp's full-stack features (such as Auth), let's build the app based on the following spec:
|
I want to create a `<insert-type-of-app-here>` app with the current SaaS boilerplate template project I'm in which uses Wasp and already has payment processing, AWS S3 file upload, a landing page, an admin dashboard, and authentication already setup. Leveraging Wasp's full-stack features (such as Auth), let's build the app based on the following spec:
|
||||||
- `<insert-feature-spec-here>`
|
|
||||||
- `<insert-feature-spec-here>`
|
- `<insert-feature-spec-here>`
|
||||||
- `<insert-feature-spec-here>`
|
- `<insert-feature-spec-here>`
|
||||||
|
- `<insert-feature-spec-here>`
|
||||||
|
|
||||||
With this in mind, I want you to first evaluate the project template and think about a few possible PRD approaches before landing on the best one. Provide reasoning why this would be the best approach. Remember we're using Wasp, a full-stack framework with batteries included, that can do some of the heavy lifting for us, and we want to use a modified vertical slice implementation approach for LLM-assisted coding so we can start with basic implementations of features first, and add on complexity from there.
|
With this in mind, I want you to first evaluate the project template and think about a few possible PRD approaches before landing on the best one. Provide reasoning why this would be the best approach. Remember we're using Wasp, a full-stack framework with batteries included, that can do some of the heavy lifting for us, and we want to use a modified vertical slice implementation approach for LLM-assisted coding so we can start with basic implementations of features first, and add on complexity from there.
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ This template includes [ShadCN UI](https://ui.shadcn.com/) v2 for beautiful, acc
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Running locally
|
### Running locally
|
||||||
- Make sure you have the `.env.client` and `.env.server` files with correct dev values in the root of the project.
|
|
||||||
- Run the database with `wasp start db` and leave it running.
|
|
||||||
- Run `wasp start` and leave it running.
|
|
||||||
- [OPTIONAL]: If this is the first time starting the app, or you've just made changes to your entities/prisma schema, also run `wasp db migrate-dev`.
|
|
||||||
|
|
||||||
|
- Make sure you have the `.env.client` and `.env.server` files with correct dev values in the root of the project.
|
||||||
|
- Run the database with `wasp start db` and leave it running.
|
||||||
|
- Run `wasp start` and leave it running.
|
||||||
|
- [OPTIONAL]: If this is the first time starting the app, or you've just made changes to your entities/prisma schema, also run `wasp db migrate-dev`.
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { type AuthUser } from 'wasp/auth';
|
import { type AuthUser } from "wasp/auth";
|
||||||
import { getDailyStats, useQuery } from 'wasp/client/operations';
|
import { getDailyStats, useQuery } from "wasp/client/operations";
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from "../../../lib/utils";
|
||||||
import DefaultLayout from '../../layout/DefaultLayout';
|
import DefaultLayout from "../../layout/DefaultLayout";
|
||||||
import RevenueAndProfitChart from './RevenueAndProfitChart';
|
import RevenueAndProfitChart from "./RevenueAndProfitChart";
|
||||||
import SourcesTable from './SourcesTable';
|
import SourcesTable from "./SourcesTable";
|
||||||
import TotalPageViewsCard from './TotalPageViewsCard';
|
import TotalPageViewsCard from "./TotalPageViewsCard";
|
||||||
import TotalPayingUsersCard from './TotalPayingUsersCard';
|
import TotalPayingUsersCard from "./TotalPayingUsersCard";
|
||||||
import TotalRevenueCard from './TotalRevenueCard';
|
import TotalRevenueCard from "./TotalRevenueCard";
|
||||||
import TotalSignupsCard from './TotalSignupsCard';
|
import TotalSignupsCard from "./TotalSignupsCard";
|
||||||
|
|
||||||
const Dashboard = ({ user }: { user: AuthUser }) => {
|
const Dashboard = ({ user }: { user: AuthUser }) => {
|
||||||
const { data: stats, isLoading, error } = useQuery(getDailyStats);
|
const { data: stats, isLoading, error } = useQuery(getDailyStats);
|
||||||
@@ -15,11 +15,11 @@ const Dashboard = ({ user }: { user: AuthUser }) => {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<DefaultLayout user={user}>
|
<DefaultLayout user={user}>
|
||||||
<div className='flex h-full items-center justify-center'>
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className='rounded-lg bg-card p-8 shadow-lg'>
|
<div className="bg-card rounded-lg p-8 shadow-lg">
|
||||||
<p className='text-2xl font-bold text-red-500'>Error</p>
|
<p className="text-2xl font-bold text-red-500">Error</p>
|
||||||
<p className='mt-2 text-sm text-muted-foreground'>
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
{error.message || 'Something went wrong while fetching stats.'}
|
{error.message || "Something went wrong while fetching stats."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,40 +29,53 @@ const Dashboard = ({ user }: { user: AuthUser }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DefaultLayout user={user}>
|
<DefaultLayout user={user}>
|
||||||
<div className='relative'>
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className={cn({
|
className={cn({
|
||||||
'opacity-25': !stats,
|
"opacity-25": !stats,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 xl:grid-cols-4 2xl:gap-7.5'>
|
<div className="2xl:gap-7.5 grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 xl:grid-cols-4">
|
||||||
<TotalPageViewsCard
|
<TotalPageViewsCard
|
||||||
totalPageViews={stats?.dailyStats.totalViews}
|
totalPageViews={stats?.dailyStats.totalViews}
|
||||||
prevDayViewsChangePercent={stats?.dailyStats.prevDayViewsChangePercent}
|
prevDayViewsChangePercent={
|
||||||
|
stats?.dailyStats.prevDayViewsChangePercent
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<TotalRevenueCard
|
<TotalRevenueCard
|
||||||
dailyStats={stats?.dailyStats}
|
dailyStats={stats?.dailyStats}
|
||||||
weeklyStats={stats?.weeklyStats}
|
weeklyStats={stats?.weeklyStats}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
<TotalPayingUsersCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
|
<TotalPayingUsersCard
|
||||||
<TotalSignupsCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
|
dailyStats={stats?.dailyStats}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
<TotalSignupsCard
|
||||||
|
dailyStats={stats?.dailyStats}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-4 grid grid-cols-12 gap-4 md:mt-6 md:gap-6 2xl:mt-7.5 2xl:gap-7.5'>
|
<div className="2xl:mt-7.5 2xl:gap-7.5 mt-4 grid grid-cols-12 gap-4 md:mt-6 md:gap-6">
|
||||||
<RevenueAndProfitChart weeklyStats={stats?.weeklyStats} isLoading={isLoading} />
|
<RevenueAndProfitChart
|
||||||
|
weeklyStats={stats?.weeklyStats}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className='col-span-12 xl:col-span-8'>
|
<div className="col-span-12 xl:col-span-8">
|
||||||
<SourcesTable sources={stats?.dailyStats?.sources} />
|
<SourcesTable sources={stats?.dailyStats?.sources} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!stats && (
|
{!stats && (
|
||||||
<div className='absolute inset-0 flex items-start justify-center bg-background/50'>
|
<div className="bg-background/50 absolute inset-0 flex items-start justify-center">
|
||||||
<div className='rounded-lg bg-card p-8 shadow-lg'>
|
<div className="bg-card rounded-lg p-8 shadow-lg">
|
||||||
<p className='text-2xl font-bold text-foreground'>No daily stats generated yet</p>
|
<p className="text-foreground text-2xl font-bold">
|
||||||
<p className='mt-2 text-sm text-muted-foreground'>
|
No daily stats generated yet
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
Stats will appear here once the daily stats job has run
|
Stats will appear here once the daily stats job has run
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { ApexOptions } from 'apexcharts';
|
import { ApexOptions } from "apexcharts";
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import ReactApexChart from 'react-apexcharts';
|
import ReactApexChart from "react-apexcharts";
|
||||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
import { type DailyStatsProps } from "../../../analytics/stats";
|
||||||
|
|
||||||
const options: ApexOptions = {
|
const options: ApexOptions = {
|
||||||
legend: {
|
legend: {
|
||||||
show: false,
|
show: false,
|
||||||
position: 'top',
|
position: "top",
|
||||||
horizontalAlign: 'left',
|
horizontalAlign: "left",
|
||||||
},
|
},
|
||||||
colors: ['#3C50E0', '#80CAEE'],
|
colors: ["#3C50E0", "#80CAEE"],
|
||||||
chart: {
|
chart: {
|
||||||
fontFamily: 'Satoshi, sans-serif',
|
fontFamily: "Satoshi, sans-serif",
|
||||||
height: 335,
|
height: 335,
|
||||||
type: 'area',
|
type: "area",
|
||||||
dropShadow: {
|
dropShadow: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
color: '#623CEA14',
|
color: "#623CEA14",
|
||||||
top: 10,
|
top: 10,
|
||||||
blur: 4,
|
blur: 4,
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -47,7 +47,7 @@ const options: ApexOptions = {
|
|||||||
],
|
],
|
||||||
stroke: {
|
stroke: {
|
||||||
width: [2, 2],
|
width: [2, 2],
|
||||||
curve: 'straight',
|
curve: "straight",
|
||||||
},
|
},
|
||||||
// labels: {
|
// labels: {
|
||||||
// show: false,
|
// show: false,
|
||||||
@@ -70,8 +70,8 @@ const options: ApexOptions = {
|
|||||||
},
|
},
|
||||||
markers: {
|
markers: {
|
||||||
size: 4,
|
size: 4,
|
||||||
colors: '#fff',
|
colors: "#fff",
|
||||||
strokeColors: ['#3056D3', '#80CAEE'],
|
strokeColors: ["#3056D3", "#80CAEE"],
|
||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
strokeOpacity: 0.9,
|
strokeOpacity: 0.9,
|
||||||
strokeDashArray: 0,
|
strokeDashArray: 0,
|
||||||
@@ -83,7 +83,7 @@ const options: ApexOptions = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
xaxis: {
|
xaxis: {
|
||||||
type: 'category',
|
type: "category",
|
||||||
axisBorder: {
|
axisBorder: {
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
@@ -94,7 +94,7 @@ const options: ApexOptions = {
|
|||||||
yaxis: {
|
yaxis: {
|
||||||
title: {
|
title: {
|
||||||
style: {
|
style: {
|
||||||
fontSize: '0px',
|
fontSize: "0px",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
min: 0,
|
min: 0,
|
||||||
@@ -123,8 +123,8 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
|||||||
if (!!weeklyStats && weeklyStats?.length > 0) {
|
if (!!weeklyStats && weeklyStats?.length > 0) {
|
||||||
const datesArr = weeklyStats?.map((stat) => {
|
const datesArr = weeklyStats?.map((stat) => {
|
||||||
// get day of week, month, and day of month
|
// get day of week, month, and day of month
|
||||||
const dateArr = stat.date.toString().split(' ');
|
const dateArr = stat.date.toString().split(" ");
|
||||||
return dateArr.slice(0, 3).join(' ');
|
return dateArr.slice(0, 3).join(" ");
|
||||||
});
|
});
|
||||||
return datesArr;
|
return datesArr;
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
|||||||
const [state, setState] = useState<ChartOneState>({
|
const [state, setState] = useState<ChartOneState>({
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: 'Profit',
|
name: "Profit",
|
||||||
data: [4, 7, 10, 11, 13, 14, 17],
|
data: [4, 7, 10, 11, 13, 14, 17],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -144,7 +144,9 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
|||||||
if (dailyRevenueArray && dailyRevenueArray.length > 0) {
|
if (dailyRevenueArray && dailyRevenueArray.length > 0) {
|
||||||
setState((prevState) => {
|
setState((prevState) => {
|
||||||
// Check if a "Revenue" series already exists
|
// Check if a "Revenue" series already exists
|
||||||
const existingSeriesIndex = prevState.series.findIndex((series) => series.name === 'Revenue');
|
const existingSeriesIndex = prevState.series.findIndex(
|
||||||
|
(series) => series.name === "Revenue",
|
||||||
|
);
|
||||||
|
|
||||||
if (existingSeriesIndex >= 0) {
|
if (existingSeriesIndex >= 0) {
|
||||||
// Update existing "Revenue" series data
|
// Update existing "Revenue" series data
|
||||||
@@ -164,7 +166,7 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
|||||||
series: [
|
series: [
|
||||||
...prevState.series,
|
...prevState.series,
|
||||||
{
|
{
|
||||||
name: 'Revenue',
|
name: "Revenue",
|
||||||
data: dailyRevenueArray,
|
data: dailyRevenueArray,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -198,37 +200,41 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
|||||||
}, [daysOfWeekArr, dailyRevenueArray]);
|
}, [daysOfWeekArr, dailyRevenueArray]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='col-span-12 rounded-sm border border-border bg-card px-5 pt-7.5 pb-5 shadow-default sm:px-7.5 xl:col-span-8'>
|
<div className="border-border bg-card pt-7.5 shadow-default sm:px-7.5 col-span-12 rounded-sm border px-5 pb-5 xl:col-span-8">
|
||||||
<div className='flex flex-wrap items-start justify-between gap-3 sm:flex-nowrap'>
|
<div className="flex flex-wrap items-start justify-between gap-3 sm:flex-nowrap">
|
||||||
<div className='flex w-full flex-wrap gap-3 sm:gap-5'>
|
<div className="flex w-full flex-wrap gap-3 sm:gap-5">
|
||||||
<div className='flex min-w-47.5'>
|
<div className="min-w-47.5 flex">
|
||||||
<span className='mt-1 mr-2 flex h-4 w-full max-w-4 items-center justify-center rounded-full border border-primary'>
|
<span className="border-primary mr-2 mt-1 flex h-4 w-full max-w-4 items-center justify-center rounded-full border">
|
||||||
<span className='block h-2.5 w-full max-w-2.5 rounded-full bg-primary'></span>
|
<span className="bg-primary block h-2.5 w-full max-w-2.5 rounded-full"></span>
|
||||||
</span>
|
</span>
|
||||||
<div className='w-full'>
|
<div className="w-full">
|
||||||
<p className='font-semibold text-primary'>Total Profit</p>
|
<p className="text-primary font-semibold">Total Profit</p>
|
||||||
<p className='text-sm font-medium text-muted-foreground'>Last 7 Days</p>
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Last 7 Days
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex min-w-47.5'>
|
<div className="min-w-47.5 flex">
|
||||||
<span className='mt-1 mr-2 flex h-4 w-full max-w-4 items-center justify-center rounded-full border border-secondary'>
|
<span className="border-secondary mr-2 mt-1 flex h-4 w-full max-w-4 items-center justify-center rounded-full border">
|
||||||
<span className='block h-2.5 w-full max-w-2.5 rounded-full bg-secondary'></span>
|
<span className="bg-secondary block h-2.5 w-full max-w-2.5 rounded-full"></span>
|
||||||
</span>
|
</span>
|
||||||
<div className='w-full'>
|
<div className="w-full">
|
||||||
<p className='font-semibold text-secondary'>Total Revenue</p>
|
<p className="text-secondary font-semibold">Total Revenue</p>
|
||||||
<p className='text-sm font-medium text-muted-foreground'>Last 7 Days</p>
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Last 7 Days
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex w-full max-w-45 justify-end'>
|
<div className="max-w-45 flex w-full justify-end">
|
||||||
<div className='inline-flex items-center rounded-md bg-muted p-1.5'>
|
<div className="bg-muted inline-flex items-center rounded-md p-1.5">
|
||||||
<button className='rounded bg-background py-1 px-3 text-xs font-medium text-foreground shadow-card hover:bg-background hover:shadow-card'>
|
<button className="bg-background text-foreground shadow-card hover:bg-background hover:shadow-card rounded px-3 py-1 text-xs font-medium">
|
||||||
Day
|
Day
|
||||||
</button>
|
</button>
|
||||||
<button className='rounded py-1 px-3 text-xs font-medium text-muted-foreground hover:bg-background hover:shadow-card'>
|
<button className="text-muted-foreground hover:bg-background hover:shadow-card rounded px-3 py-1 text-xs font-medium">
|
||||||
Week
|
Week
|
||||||
</button>
|
</button>
|
||||||
<button className='rounded py-1 px-3 text-xs font-medium text-muted-foreground hover:bg-background hover:shadow-card'>
|
<button className="text-muted-foreground hover:bg-background hover:shadow-card rounded px-3 py-1 text-xs font-medium">
|
||||||
Month
|
Month
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,8 +242,13 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div id='chartOne' className='-ml-5'>
|
<div id="chartOne" className="-ml-5">
|
||||||
<ReactApexChart options={chartOptions} series={state.series} type='area' height={350} />
|
<ReactApexChart
|
||||||
|
options={chartOptions}
|
||||||
|
series={state.series}
|
||||||
|
type="area"
|
||||||
|
height={350}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,42 +1,54 @@
|
|||||||
import { type PageViewSource } from 'wasp/entities';
|
import { type PageViewSource } from "wasp/entities";
|
||||||
|
|
||||||
const SourcesTable = ({ sources }: { sources: PageViewSource[] | undefined }) => {
|
const SourcesTable = ({
|
||||||
|
sources,
|
||||||
|
}: {
|
||||||
|
sources: PageViewSource[] | undefined;
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className='rounded-sm border border-border bg-card px-5 pt-6 pb-2.5 shadow-default sm:px-7.5 xl:pb-1'>
|
<div className="border-border bg-card shadow-default sm:px-7.5 rounded-sm border px-5 pb-2.5 pt-6 xl:pb-1">
|
||||||
<h4 className='mb-6 text-xl font-semibold text-foreground'>Top Sources</h4>
|
<h4 className="text-foreground mb-6 text-xl font-semibold">
|
||||||
|
Top Sources
|
||||||
|
</h4>
|
||||||
|
|
||||||
<div className='flex flex-col'>
|
<div className="flex flex-col">
|
||||||
<div className='grid grid-cols-3 rounded-sm bg-gray-2 '>
|
<div className="bg-gray-2 grid grid-cols-3 rounded-sm">
|
||||||
<div className='p-2.5 xl:p-5'>
|
<div className="p-2.5 xl:p-5">
|
||||||
<h5 className='text-sm font-medium uppercase xsm:text-base'>Source</h5>
|
<h5 className="xsm:text-base text-sm font-medium uppercase">
|
||||||
|
Source
|
||||||
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div className='p-2.5 text-center xl:p-5'>
|
<div className="p-2.5 text-center xl:p-5">
|
||||||
<h5 className='text-sm font-medium uppercase xsm:text-base'>Visitors</h5>
|
<h5 className="xsm:text-base text-sm font-medium uppercase">
|
||||||
|
Visitors
|
||||||
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div className='hidden p-2.5 text-center sm:block xl:p-5'>
|
<div className="hidden p-2.5 text-center sm:block xl:p-5">
|
||||||
<h5 className='text-sm font-medium uppercase xsm:text-base'>Sales</h5>
|
<h5 className="xsm:text-base text-sm font-medium uppercase">
|
||||||
|
Sales
|
||||||
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sources && sources.length > 0 ? (
|
{sources && sources.length > 0 ? (
|
||||||
sources.map((source) => (
|
sources.map((source) => (
|
||||||
<div className='grid grid-cols-3 border-b border-border'>
|
<div className="border-border grid grid-cols-3 border-b">
|
||||||
<div className='flex items-center gap-3 p-2.5 xl:p-5'>
|
<div className="flex items-center gap-3 p-2.5 xl:p-5">
|
||||||
<p className='text-foreground'>{source.name}</p>
|
<p className="text-foreground">{source.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center justify-center p-2.5 xl:p-5'>
|
<div className="flex items-center justify-center p-2.5 xl:p-5">
|
||||||
<p className='text-foreground'>{source.visitors}</p>
|
<p className="text-foreground">{source.visitors}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='hidden items-center justify-center p-2.5 sm:flex xl:p-5'>
|
<div className="hidden items-center justify-center p-2.5 sm:flex xl:p-5">
|
||||||
<p className='text-foreground'>--</p>
|
<p className="text-foreground">--</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className='flex items-center justify-center p-2.5 xl:p-5'>
|
<div className="flex items-center justify-center p-2.5 xl:p-5">
|
||||||
<p className='text-foreground'>No data to display</p>
|
<p className="text-foreground">No data to display</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,42 +1,57 @@
|
|||||||
import { ArrowDown, ArrowUp, Eye } from 'lucide-react';
|
import { ArrowDown, ArrowUp, Eye } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
|
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from "../../../lib/utils";
|
||||||
|
|
||||||
type PageViewsStats = {
|
type PageViewsStats = {
|
||||||
totalPageViews: number | undefined;
|
totalPageViews: number | undefined;
|
||||||
prevDayViewsChangePercent: string | undefined;
|
prevDayViewsChangePercent: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TotalPageViewsCard = ({ totalPageViews, prevDayViewsChangePercent }: PageViewsStats) => {
|
const TotalPageViewsCard = ({
|
||||||
const prevDayViewsChangePercentValue = parseInt(prevDayViewsChangePercent || '');
|
totalPageViews,
|
||||||
|
prevDayViewsChangePercent,
|
||||||
|
}: PageViewsStats) => {
|
||||||
|
const prevDayViewsChangePercentValue = parseInt(
|
||||||
|
prevDayViewsChangePercent || "",
|
||||||
|
);
|
||||||
const isDeltaPositive = prevDayViewsChangePercentValue > 0;
|
const isDeltaPositive = prevDayViewsChangePercentValue > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
|
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
|
||||||
<Eye className='size-6' />
|
<Eye className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className='flex justify-between'>
|
<CardContent className="flex justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 className='text-title-md font-bold text-foreground'>{totalPageViews}</h4>
|
<h4 className="text-title-md text-foreground font-bold">
|
||||||
<span className='text-sm font-medium text-muted-foreground'>Total page views</span>
|
{totalPageViews}
|
||||||
|
</h4>
|
||||||
|
<span className="text-muted-foreground text-sm font-medium">
|
||||||
|
Total page views
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
className={cn("flex items-center gap-1 text-sm font-medium", {
|
||||||
'text-success':
|
"text-success":
|
||||||
isDeltaPositive && prevDayViewsChangePercent && prevDayViewsChangePercentValue !== 0,
|
isDeltaPositive &&
|
||||||
'text-destructive':
|
prevDayViewsChangePercent &&
|
||||||
!isDeltaPositive && prevDayViewsChangePercent && prevDayViewsChangePercentValue !== 0,
|
prevDayViewsChangePercentValue !== 0,
|
||||||
'text-muted-foreground': !prevDayViewsChangePercent || prevDayViewsChangePercentValue === 0,
|
"text-destructive":
|
||||||
|
!isDeltaPositive &&
|
||||||
|
prevDayViewsChangePercent &&
|
||||||
|
prevDayViewsChangePercentValue !== 0,
|
||||||
|
"text-muted-foreground":
|
||||||
|
!prevDayViewsChangePercent ||
|
||||||
|
prevDayViewsChangePercentValue === 0,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{prevDayViewsChangePercent && prevDayViewsChangePercentValue !== 0
|
{prevDayViewsChangePercent && prevDayViewsChangePercentValue !== 0
|
||||||
? `${prevDayViewsChangePercent}%`
|
? `${prevDayViewsChangePercent}%`
|
||||||
: '-'}
|
: "-"}
|
||||||
{prevDayViewsChangePercent &&
|
{prevDayViewsChangePercent &&
|
||||||
prevDayViewsChangePercentValue !== 0 &&
|
prevDayViewsChangePercentValue !== 0 &&
|
||||||
(isDeltaPositive ? <ArrowUp /> : <ArrowDown />)}
|
(isDeltaPositive ? <ArrowUp /> : <ArrowDown />)}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ArrowDown, ArrowUp, ShoppingBag } from 'lucide-react';
|
import { ArrowDown, ArrowUp, ShoppingBag } from "lucide-react";
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
import { type DailyStatsProps } from "../../../analytics/stats";
|
||||||
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
|
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from "../../../lib/utils";
|
||||||
|
|
||||||
const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
||||||
const isDeltaPositive = useMemo(() => {
|
const isDeltaPositive = useMemo(() => {
|
||||||
@@ -12,25 +12,30 @@ const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
|
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
|
||||||
<ShoppingBag className='size-6' />
|
<ShoppingBag className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className='flex justify-between'>
|
<CardContent className="flex justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 className='text-title-md font-bold text-foreground'>{dailyStats?.paidUserCount}</h4>
|
<h4 className="text-title-md text-foreground font-bold">
|
||||||
<span className='text-sm font-medium text-muted-foreground'>Total Paying Users</span>
|
{dailyStats?.paidUserCount}
|
||||||
|
</h4>
|
||||||
|
<span className="text-muted-foreground text-sm font-medium">
|
||||||
|
Total Paying Users
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
className={cn("flex items-center gap-1 text-sm font-medium", {
|
||||||
'text-success': isDeltaPositive && !isLoading,
|
"text-success": isDeltaPositive && !isLoading,
|
||||||
'text-destructive': !isDeltaPositive && !isLoading && dailyStats?.paidUserDelta !== 0,
|
"text-destructive":
|
||||||
'text-muted-foreground': isLoading || !dailyStats?.paidUserDelta,
|
!isDeltaPositive && !isLoading && dailyStats?.paidUserDelta !== 0,
|
||||||
|
"text-muted-foreground": isLoading || !dailyStats?.paidUserDelta,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isLoading ? '...' : dailyStats?.paidUserDelta ?? '-'}
|
{isLoading ? "..." : (dailyStats?.paidUserDelta ?? "-")}
|
||||||
{!isLoading &&
|
{!isLoading &&
|
||||||
(dailyStats?.paidUserDelta ?? 0) !== 0 &&
|
(dailyStats?.paidUserDelta ?? 0) !== 0 &&
|
||||||
(isDeltaPositive ? <ArrowUp /> : <ArrowDown />)}
|
(isDeltaPositive ? <ArrowUp /> : <ArrowDown />)}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { ArrowDown, ArrowUp, ShoppingCart } from 'lucide-react';
|
import { ArrowDown, ArrowUp, ShoppingCart } from "lucide-react";
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
import { type DailyStatsProps } from "../../../analytics/stats";
|
||||||
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
|
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from "../../../lib/utils";
|
||||||
|
|
||||||
const TotalRevenueCard = ({ dailyStats, weeklyStats, isLoading }: DailyStatsProps) => {
|
const TotalRevenueCard = ({
|
||||||
|
dailyStats,
|
||||||
|
weeklyStats,
|
||||||
|
isLoading,
|
||||||
|
}: DailyStatsProps) => {
|
||||||
const isDeltaPositive = useMemo(() => {
|
const isDeltaPositive = useMemo(() => {
|
||||||
if (!weeklyStats) return false;
|
if (!weeklyStats) return false;
|
||||||
return weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue > 0;
|
return weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue > 0;
|
||||||
@@ -12,37 +16,54 @@ const TotalRevenueCard = ({ dailyStats, weeklyStats, isLoading }: DailyStatsProp
|
|||||||
|
|
||||||
const deltaPercentage = useMemo(() => {
|
const deltaPercentage = useMemo(() => {
|
||||||
if (!weeklyStats || weeklyStats.length < 2 || isLoading) return;
|
if (!weeklyStats || weeklyStats.length < 2 || isLoading) return;
|
||||||
if (weeklyStats[1]?.totalRevenue === 0 || weeklyStats[0]?.totalRevenue === 0) return 0;
|
if (
|
||||||
|
weeklyStats[1]?.totalRevenue === 0 ||
|
||||||
|
weeklyStats[0]?.totalRevenue === 0
|
||||||
|
)
|
||||||
|
return 0;
|
||||||
|
|
||||||
weeklyStats.sort((a, b) => b.id - a.id);
|
weeklyStats.sort((a, b) => b.id - a.id);
|
||||||
|
|
||||||
const percentage =
|
const percentage =
|
||||||
((weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) / weeklyStats[1]?.totalRevenue) * 100;
|
((weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) /
|
||||||
|
weeklyStats[1]?.totalRevenue) *
|
||||||
|
100;
|
||||||
return Math.floor(percentage);
|
return Math.floor(percentage);
|
||||||
}, [weeklyStats]);
|
}, [weeklyStats]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
|
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
|
||||||
<ShoppingCart className='size-6' />
|
<ShoppingCart className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className='flex justify-between'>
|
<CardContent className="flex justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 className='text-title-md font-bold text-foreground'>${dailyStats?.totalRevenue}</h4>
|
<h4 className="text-title-md text-foreground font-bold">
|
||||||
<span className='text-sm font-medium text-muted-foreground'>Total Revenue</span>
|
${dailyStats?.totalRevenue}
|
||||||
|
</h4>
|
||||||
|
<span className="text-muted-foreground text-sm font-medium">
|
||||||
|
Total Revenue
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
className={cn("flex items-center gap-1 text-sm font-medium", {
|
||||||
'text-success': isDeltaPositive && !isLoading && deltaPercentage !== 0,
|
"text-success":
|
||||||
'text-destructive': !isDeltaPositive && !isLoading && deltaPercentage !== 0,
|
isDeltaPositive && !isLoading && deltaPercentage !== 0,
|
||||||
'text-muted-foreground': isLoading || !deltaPercentage || deltaPercentage === 0,
|
"text-destructive":
|
||||||
|
!isDeltaPositive && !isLoading && deltaPercentage !== 0,
|
||||||
|
"text-muted-foreground":
|
||||||
|
isLoading || !deltaPercentage || deltaPercentage === 0,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isLoading ? '...' : deltaPercentage && deltaPercentage !== 0 ? `${deltaPercentage}%` : '-'}
|
{isLoading
|
||||||
|
? "..."
|
||||||
|
: deltaPercentage && deltaPercentage !== 0
|
||||||
|
? `${deltaPercentage}%`
|
||||||
|
: "-"}
|
||||||
{!isLoading &&
|
{!isLoading &&
|
||||||
deltaPercentage &&
|
deltaPercentage &&
|
||||||
deltaPercentage !== 0 &&
|
deltaPercentage !== 0 &&
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ArrowUp, UsersRound } from 'lucide-react';
|
import { ArrowUp, UsersRound } from "lucide-react";
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
import { type DailyStatsProps } from "../../../analytics/stats";
|
||||||
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
|
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from "../../../lib/utils";
|
||||||
|
|
||||||
const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
||||||
const isDeltaPositive = useMemo(() => {
|
const isDeltaPositive = useMemo(() => {
|
||||||
@@ -12,25 +12,30 @@ const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
|
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
|
||||||
<UsersRound className='size-6' />
|
<UsersRound className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className='flex justify-between'>
|
<CardContent className="flex justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 className='text-title-md font-bold text-foreground'>{dailyStats?.userCount}</h4>
|
<h4 className="text-title-md text-foreground font-bold">
|
||||||
<span className='text-sm font-medium text-muted-foreground'>Total Signups</span>
|
{dailyStats?.userCount}
|
||||||
|
</h4>
|
||||||
|
<span className="text-muted-foreground text-sm font-medium">
|
||||||
|
Total Signups
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
className={cn("flex items-center gap-1 text-sm font-medium", {
|
||||||
'text-success': isDeltaPositive && !isLoading,
|
"text-success": isDeltaPositive && !isLoading,
|
||||||
'text-destructive': !isDeltaPositive && !isLoading && dailyStats?.userDelta !== 0,
|
"text-destructive":
|
||||||
'text-muted-foreground': isLoading || !dailyStats?.userDelta,
|
!isDeltaPositive && !isLoading && dailyStats?.userDelta !== 0,
|
||||||
|
"text-muted-foreground": isLoading || !dailyStats?.userDelta,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isLoading ? '...' : dailyStats?.userDelta ?? '-'}
|
{isLoading ? "..." : (dailyStats?.userDelta ?? "-")}
|
||||||
{!isLoading && (dailyStats?.userDelta ?? 0) > 0 && <ArrowUp />}
|
{!isLoading && (dailyStats?.userDelta ?? 0) > 0 && <ArrowUp />}
|
||||||
</span>
|
</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { MessageCircleMore } from 'lucide-react';
|
import { MessageCircleMore } from "lucide-react";
|
||||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||||
|
|
||||||
const MessageButton = () => {
|
const MessageButton = () => {
|
||||||
return (
|
return (
|
||||||
<li className='relative' x-data='{ dropdownOpen: false, notifying: true }'>
|
<li className="relative" x-data="{ dropdownOpen: false, notifying: true }">
|
||||||
<WaspRouterLink
|
<WaspRouterLink
|
||||||
className='relative flex h-8.5 w-8.5 items-center justify-center rounded-full border-[0.5px] border-stroke bg-gray hover:text-primary dark:border-strokedark dark:bg-meta-4 dark:text-white'
|
className="h-8.5 w-8.5 border-stroke bg-gray hover:text-primary dark:border-strokedark dark:bg-meta-4 relative flex items-center justify-center rounded-full border-[0.5px] dark:text-white"
|
||||||
to={routes.AdminMessagesRoute.to}
|
to={routes.AdminMessagesRoute.to}
|
||||||
>
|
>
|
||||||
<span className='absolute -top-0.5 -right-0.5 z-1 h-2 w-2 rounded-full bg-meta-1'>
|
<span className="z-1 bg-meta-1 absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full">
|
||||||
{/* TODO: only animate if there are new messages */}
|
{/* TODO: only animate if there are new messages */}
|
||||||
<span className='absolute -z-1 inline-flex h-full w-full animate-ping rounded-full bg-meta-1 opacity-75'></span>
|
<span className="-z-1 bg-meta-1 absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
|
||||||
</span>
|
</span>
|
||||||
<MessageCircleMore className='size-5' />
|
<MessageCircleMore className="size-5" />
|
||||||
</WaspRouterLink>
|
</WaspRouterLink>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// TODO: Add messages page
|
// TODO: Add messages page
|
||||||
import type { AuthUser } from "wasp/auth"
|
import type { AuthUser } from "wasp/auth";
|
||||||
import DefaultLayout from "../../layout/DefaultLayout"
|
import DefaultLayout from "../../layout/DefaultLayout";
|
||||||
|
|
||||||
function AdminMessages({user} : {user: AuthUser}) {
|
function AdminMessages({ user }: { user: AuthUser }) {
|
||||||
return (
|
return (
|
||||||
<DefaultLayout user={user}>
|
<DefaultLayout user={user}>
|
||||||
<div>This page is under construction 🚧</div>
|
<div>This page is under construction 🚧</div>
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AdminMessages
|
export default AdminMessages;
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import { Ellipsis, SquarePen, Trash2 } from 'lucide-react';
|
import { Ellipsis, SquarePen, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '../../../components/ui/dropdown-menu';
|
} from "../../../components/ui/dropdown-menu";
|
||||||
|
|
||||||
const DropdownEditDelete = () => {
|
const DropdownEditDelete = () => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button>
|
<button>
|
||||||
<Ellipsis className='size-4' />
|
<Ellipsis className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align='end' className='w-40'>
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<SquarePen className='size-4 mr-2' />
|
<SquarePen className="mr-2 size-4" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Trash2 className='size-4 mr-2' />
|
<Trash2 className="mr-2 size-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { type AuthUser } from 'wasp/auth';
|
import { type AuthUser } from "wasp/auth";
|
||||||
import UsersTable from './UsersTable';
|
import Breadcrumb from "../../layout/Breadcrumb";
|
||||||
import Breadcrumb from '../../layout/Breadcrumb';
|
import DefaultLayout from "../../layout/DefaultLayout";
|
||||||
import DefaultLayout from '../../layout/DefaultLayout';
|
import UsersTable from "./UsersTable";
|
||||||
|
|
||||||
const Users = ({ user }: { user: AuthUser }) => {
|
const Users = ({ user }: { user: AuthUser }) => {
|
||||||
return (
|
return (
|
||||||
<DefaultLayout user={user}>
|
<DefaultLayout user={user}>
|
||||||
<Breadcrumb pageName='Users' />
|
<Breadcrumb pageName="Users" />
|
||||||
<div className='flex flex-col gap-10'>
|
<div className="flex flex-col gap-10">
|
||||||
<UsersTable />
|
<UsersTable />
|
||||||
</div>
|
</div>
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
|
|||||||
@@ -1,27 +1,39 @@
|
|||||||
import { X } from 'lucide-react';
|
import { X } from "lucide-react";
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { useAuth } from 'wasp/client/auth';
|
import { useAuth } from "wasp/client/auth";
|
||||||
import { getPaginatedUsers, updateIsUserAdminById, useQuery } from 'wasp/client/operations';
|
import {
|
||||||
import { type User } from 'wasp/entities';
|
getPaginatedUsers,
|
||||||
import useDebounce from '../../../client/hooks/useDebounce';
|
updateIsUserAdminById,
|
||||||
import { Button } from '../../../components/ui/button';
|
useQuery,
|
||||||
import { Checkbox } from '../../../components/ui/checkbox';
|
} from "wasp/client/operations";
|
||||||
import { Input } from '../../../components/ui/input';
|
import { type User } from "wasp/entities";
|
||||||
import { Label } from '../../../components/ui/label';
|
import useDebounce from "../../../client/hooks/useDebounce";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../components/ui/select';
|
import { Button } from "../../../components/ui/button";
|
||||||
import { Switch } from '../../../components/ui/switch';
|
import { Checkbox } from "../../../components/ui/checkbox";
|
||||||
import { SubscriptionStatus } from '../../../payment/plans';
|
import { Input } from "../../../components/ui/input";
|
||||||
import LoadingSpinner from '../../layout/LoadingSpinner';
|
import { Label } from "../../../components/ui/label";
|
||||||
import DropdownEditDelete from './DropdownEditDelete';
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../../../components/ui/select";
|
||||||
|
import { Switch } from "../../../components/ui/switch";
|
||||||
|
import { SubscriptionStatus } from "../../../payment/plans";
|
||||||
|
import LoadingSpinner from "../../layout/LoadingSpinner";
|
||||||
|
import DropdownEditDelete from "./DropdownEditDelete";
|
||||||
|
|
||||||
function AdminSwitch({ id, isAdmin }: Pick<User, 'id' | 'isAdmin'>) {
|
function AdminSwitch({ id, isAdmin }: Pick<User, "id" | "isAdmin">) {
|
||||||
const { data: currentUser } = useAuth();
|
const { data: currentUser } = useAuth();
|
||||||
const isCurrentUser = currentUser?.id === id;
|
const isCurrentUser = currentUser?.id === id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch
|
<Switch
|
||||||
checked={isAdmin}
|
checked={isAdmin}
|
||||||
onCheckedChange={(value) => updateIsUserAdminById({ id: id, isAdmin: value })}
|
onCheckedChange={(value) =>
|
||||||
|
updateIsUserAdminById({ id: id, isAdmin: value })
|
||||||
|
}
|
||||||
disabled={isCurrentUser}
|
disabled={isCurrentUser}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -30,10 +42,12 @@ function AdminSwitch({ id, isAdmin }: Pick<User, 'id' | 'isAdmin'>) {
|
|||||||
const UsersTable = () => {
|
const UsersTable = () => {
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [emailFilter, setEmailFilter] = useState<string | undefined>(undefined);
|
const [emailFilter, setEmailFilter] = useState<string | undefined>(undefined);
|
||||||
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined);
|
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(
|
||||||
const [subscriptionStatusFilter, setSubscriptionStatusFilter] = useState<Array<SubscriptionStatus | null>>(
|
undefined,
|
||||||
[]
|
|
||||||
);
|
);
|
||||||
|
const [subscriptionStatusFilter, setSubscriptionStatusFilter] = useState<
|
||||||
|
Array<SubscriptionStatus | null>
|
||||||
|
>([]);
|
||||||
|
|
||||||
const debouncedEmailFilter = useDebounce(emailFilter, 300);
|
const debouncedEmailFilter = useDebounce(emailFilter, 300);
|
||||||
|
|
||||||
@@ -44,7 +58,9 @@ const UsersTable = () => {
|
|||||||
filter: {
|
filter: {
|
||||||
...(debouncedEmailFilter && { emailContains: debouncedEmailFilter }),
|
...(debouncedEmailFilter && { emailContains: debouncedEmailFilter }),
|
||||||
...(isAdminFilter !== undefined && { isAdmin: isAdminFilter }),
|
...(isAdminFilter !== undefined && { isAdmin: isAdminFilter }),
|
||||||
...(subscriptionStatusFilter.length > 0 && { subscriptionStatusIn: subscriptionStatusFilter }),
|
...(subscriptionStatusFilter.length > 0 && {
|
||||||
|
subscriptionStatusIn: subscriptionStatusFilter,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +68,7 @@ const UsersTable = () => {
|
|||||||
function backToPageOne() {
|
function backToPageOne() {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
},
|
},
|
||||||
[debouncedEmailFilter, subscriptionStatusFilter, isAdminFilter]
|
[debouncedEmailFilter, subscriptionStatusFilter, isAdminFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStatusToggle = (status: SubscriptionStatus | null) => {
|
const handleStatusToggle = (status: SubscriptionStatus | null) => {
|
||||||
@@ -69,85 +85,99 @@ const UsersTable = () => {
|
|||||||
setSubscriptionStatusFilter([]);
|
setSubscriptionStatusFilter([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveFilters = subscriptionStatusFilter && subscriptionStatusFilter.length > 0;
|
const hasActiveFilters =
|
||||||
|
subscriptionStatusFilter && subscriptionStatusFilter.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-4'>
|
<div className="flex flex-col gap-4">
|
||||||
<div className='rounded-sm border border-border bg-card shadow'>
|
<div className="border-border bg-card rounded-sm border shadow">
|
||||||
<div className='flex-col flex items-start justify-between p-6 gap-3 w-full bg-muted/40'>
|
<div className="bg-muted/40 flex w-full flex-col items-start justify-between gap-3 p-6">
|
||||||
<span className='text-sm font-medium'>Filters:</span>
|
<span className="text-sm font-medium">Filters:</span>
|
||||||
<div className='flex items-center justify-between gap-3 w-full px-2'>
|
<div className="flex w-full items-center justify-between gap-3 px-2">
|
||||||
<div className='relative flex items-center gap-3 '>
|
<div className="relative flex items-center gap-3">
|
||||||
<Label htmlFor='email-filter' className='text-sm text-muted-foreground'>
|
<Label
|
||||||
|
htmlFor="email-filter"
|
||||||
|
className="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
email:
|
email:
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type='text'
|
type="text"
|
||||||
id='email-filter'
|
id="email-filter"
|
||||||
placeholder='dude@example.com'
|
placeholder="dude@example.com"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.currentTarget.value;
|
const value = e.currentTarget.value;
|
||||||
setEmailFilter(value === '' ? undefined : value);
|
setEmailFilter(value === "" ? undefined : value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor='status-filter' className='text-sm ml-2 text-muted-foreground'>
|
<Label
|
||||||
|
htmlFor="status-filter"
|
||||||
|
className="text-muted-foreground ml-2 text-sm"
|
||||||
|
>
|
||||||
status:
|
status:
|
||||||
</Label>
|
</Label>
|
||||||
<div className='relative'>
|
<div className="relative">
|
||||||
<Select>
|
<Select>
|
||||||
<SelectTrigger className='w-full min-w-[200px]'>
|
<SelectTrigger className="w-full min-w-[200px]">
|
||||||
<SelectValue placeholder='Select Status Filter' />
|
<SelectValue placeholder="Select Status Filter" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className='w-[300px]'>
|
<SelectContent className="w-[300px]">
|
||||||
<div className='p-2'>
|
<div className="p-2">
|
||||||
<div className='flex items-center justify-between mb-2'>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<span className='text-sm font-medium'>Subscription Status</span>
|
<span className="text-sm font-medium">
|
||||||
|
Subscription Status
|
||||||
|
</span>
|
||||||
{subscriptionStatusFilter.length > 0 && (
|
{subscriptionStatusFilter.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={clearAllStatusFilters}
|
onClick={clearAllStatusFilters}
|
||||||
className='text-xs text-muted-foreground hover:text-foreground'
|
className="text-muted-foreground hover:text-foreground text-xs"
|
||||||
>
|
>
|
||||||
Clear all
|
Clear all
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<div className='flex items-center space-x-2'>
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id='all-statuses'
|
id="all-statuses"
|
||||||
checked={subscriptionStatusFilter.length === 0}
|
checked={subscriptionStatusFilter.length === 0}
|
||||||
onCheckedChange={() => clearAllStatusFilters()}
|
onCheckedChange={() => clearAllStatusFilters()}
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor='all-statuses'
|
htmlFor="all-statuses"
|
||||||
className='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
>
|
>
|
||||||
All Statuses
|
All Statuses
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center space-x-2'>
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id='has-not-subscribed'
|
id="has-not-subscribed"
|
||||||
checked={subscriptionStatusFilter.includes(null)}
|
checked={subscriptionStatusFilter.includes(null)}
|
||||||
onCheckedChange={() => handleStatusToggle(null)}
|
onCheckedChange={() => handleStatusToggle(null)}
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor='has-not-subscribed'
|
htmlFor="has-not-subscribed"
|
||||||
className='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
>
|
>
|
||||||
Has Not Subscribed
|
Has Not Subscribed
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
{Object.values(SubscriptionStatus).map((status) => (
|
{Object.values(SubscriptionStatus).map((status) => (
|
||||||
<div key={status} className='flex items-center space-x-2'>
|
<div
|
||||||
|
key={status}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={status}
|
id={status}
|
||||||
checked={subscriptionStatusFilter.includes(status)}
|
checked={subscriptionStatusFilter.includes(
|
||||||
|
status,
|
||||||
|
)}
|
||||||
onCheckedChange={() => handleStatusToggle(status)}
|
onCheckedChange={() => handleStatusToggle(status)}
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={status}
|
htmlFor={status}
|
||||||
className='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
</Label>
|
</Label>
|
||||||
@@ -158,63 +188,75 @@ const UsersTable = () => {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-2'>
|
<div className="flex items-center gap-2">
|
||||||
<Label htmlFor='admin-filter' className='text-sm ml-2 text-muted-foreground'>
|
<Label
|
||||||
|
htmlFor="admin-filter"
|
||||||
|
className="text-muted-foreground ml-2 text-sm"
|
||||||
|
>
|
||||||
isAdmin:
|
isAdmin:
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value === 'both') {
|
if (value === "both") {
|
||||||
setIsAdminFilter(undefined);
|
setIsAdminFilter(undefined);
|
||||||
} else {
|
} else {
|
||||||
setIsAdminFilter(value === 'true');
|
setIsAdminFilter(value === "true");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className='w-full'>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder='both' />
|
<SelectValue placeholder="both" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value='both'>both</SelectItem>
|
<SelectItem value="both">both</SelectItem>
|
||||||
<SelectItem value='true'>true</SelectItem>
|
<SelectItem value="true">true</SelectItem>
|
||||||
<SelectItem value='false'>false</SelectItem>
|
<SelectItem value="false">false</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{data?.totalPages && (
|
{data?.totalPages && (
|
||||||
<div className='max-w-60 flex flex-row items-center'>
|
<div className="flex max-w-60 flex-row items-center">
|
||||||
<span className='text-md mr-2 text-foreground'>page</span>
|
<span className="text-md text-foreground mr-2">page</span>
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
defaultValue={currentPage}
|
defaultValue={currentPage}
|
||||||
max={data?.totalPages}
|
max={data?.totalPages}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = parseInt(e.currentTarget.value);
|
const value = parseInt(e.currentTarget.value);
|
||||||
if (data?.totalPages && value <= data?.totalPages && value > 0) {
|
if (
|
||||||
|
data?.totalPages &&
|
||||||
|
value <= data?.totalPages &&
|
||||||
|
value > 0
|
||||||
|
) {
|
||||||
setCurrentPage(value);
|
setCurrentPage(value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className='w-20'
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
<span className='text-md text-foreground'> /{data?.totalPages} </span>
|
<span className="text-md text-foreground">
|
||||||
|
{" "}
|
||||||
|
/{data?.totalPages}{" "}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<div className='flex items-center gap-2 px-2 pt-2 border-border'>
|
<div className="border-border flex items-center gap-2 px-2 pt-2">
|
||||||
<span className='text-sm font-medium text-muted-foreground'>Active Filters:</span>
|
<span className="text-muted-foreground text-sm font-medium">
|
||||||
<div className='flex flex-wrap gap-2'>
|
Active Filters:
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
{subscriptionStatusFilter.map((status) => (
|
{subscriptionStatusFilter.map((status) => (
|
||||||
<Button
|
<Button
|
||||||
key={status ?? 'null'}
|
key={status ?? "null"}
|
||||||
variant='outline'
|
variant="outline"
|
||||||
size='sm'
|
size="sm"
|
||||||
onClick={() => handleStatusToggle(status)}
|
onClick={() => handleStatusToggle(status)}
|
||||||
>
|
>
|
||||||
<X className='w-3 h-3 mr-1' />
|
<X className="mr-1 h-3 w-3" />
|
||||||
{status ?? 'Has Not Subscribed'}
|
{status ?? "Has Not Subscribed"}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -222,46 +264,53 @@ const UsersTable = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid grid-cols-9 border-t-4 border-border py-4.5 px-4 md:px-6 '>
|
<div className="border-border py-4.5 grid grid-cols-9 border-t-4 px-4 md:px-6">
|
||||||
<div className='col-span-3 flex items-center'>
|
<div className="col-span-3 flex items-center">
|
||||||
<p className='font-medium'>Email / Username</p>
|
<p className="font-medium">Email / Username</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='col-span-2 flex items-center'>
|
<div className="col-span-2 flex items-center">
|
||||||
<p className='font-medium'>Subscription Status</p>
|
<p className="font-medium">Subscription Status</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='col-span-2 flex items-center'>
|
<div className="col-span-2 flex items-center">
|
||||||
<p className='font-medium'>Stripe ID</p>
|
<p className="font-medium">Stripe ID</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='col-span-1 flex items-center'>
|
<div className="col-span-1 flex items-center">
|
||||||
<p className='font-medium'>Is Admin</p>
|
<p className="font-medium">Is Admin</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='col-span-1 flex items-center'>
|
<div className="col-span-1 flex items-center">
|
||||||
<p className='font-medium'></p>
|
<p className="font-medium"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isLoading && <LoadingSpinner />}
|
{isLoading && <LoadingSpinner />}
|
||||||
{!!data?.users &&
|
{!!data?.users &&
|
||||||
data?.users?.length > 0 &&
|
data?.users?.length > 0 &&
|
||||||
data.users.map((user) => (
|
data.users.map((user) => (
|
||||||
<div key={user.id} className='grid grid-cols-9 gap-4 py-4.5 px-4 md:px-6 '>
|
<div
|
||||||
<div className='col-span-3 flex items-center'>
|
key={user.id}
|
||||||
<div className='flex flex-col gap-1 '>
|
className="py-4.5 grid grid-cols-9 gap-4 px-4 md:px-6"
|
||||||
<p className='text-sm text-foreground'>{user.email}</p>
|
>
|
||||||
<p className='text-sm text-foreground'>{user.username}</p>
|
<div className="col-span-3 flex items-center">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-foreground text-sm">{user.email}</p>
|
||||||
|
<p className="text-foreground text-sm">{user.username}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='col-span-2 flex items-center'>
|
<div className="col-span-2 flex items-center">
|
||||||
<p className='text-sm text-foreground'>{user.subscriptionStatus}</p>
|
<p className="text-foreground text-sm">
|
||||||
|
{user.subscriptionStatus}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='col-span-2 flex items-center'>
|
<div className="col-span-2 flex items-center">
|
||||||
<p className='text-sm text-muted-foreground'>{user.paymentProcessorUserId}</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{user.paymentProcessorUserId}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='col-span-1 flex items-center'>
|
<div className="col-span-1 flex items-center">
|
||||||
<div className='text-sm text-foreground'>
|
<div className="text-foreground text-sm">
|
||||||
<AdminSwitch {...user} />
|
<AdminSwitch {...user} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='col-span-1 flex items-center'>
|
<div className="col-span-1 flex items-center">
|
||||||
<DropdownEditDelete />
|
<DropdownEditDelete />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,179 +1,191 @@
|
|||||||
import { type AuthUser } from 'wasp/auth';
|
import { type AuthUser } from "wasp/auth";
|
||||||
import Breadcrumb from '../../layout/Breadcrumb';
|
import Breadcrumb from "../../layout/Breadcrumb";
|
||||||
import DefaultLayout from '../../layout/DefaultLayout';
|
import DefaultLayout from "../../layout/DefaultLayout";
|
||||||
|
|
||||||
const Calendar = ({ user }: { user: AuthUser }) => {
|
const Calendar = ({ user }: { user: AuthUser }) => {
|
||||||
return (
|
return (
|
||||||
<DefaultLayout user={user}>
|
<DefaultLayout user={user}>
|
||||||
<Breadcrumb pageName='Calendar' />
|
<Breadcrumb pageName="Calendar" />
|
||||||
<div className='w-full max-w-full rounded-sm border border-border bg-card shadow-default'>
|
<div className="border-border bg-card shadow-default w-full max-w-full rounded-sm border">
|
||||||
<table className='w-full'>
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className='grid grid-cols-7 rounded-t-sm bg-primary text-primary-foreground'>
|
<tr className="bg-primary text-primary-foreground grid grid-cols-7 rounded-t-sm">
|
||||||
<th className='flex h-15 items-center justify-center rounded-tl-sm p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
<th className="h-15 flex items-center justify-center rounded-tl-sm p-1 text-xs font-semibold sm:text-base xl:p-5">
|
||||||
<span className='hidden lg:block'> Sunday </span>
|
<span className="hidden lg:block"> Sunday </span>
|
||||||
<span className='block lg:hidden'> Sun </span>
|
<span className="block lg:hidden"> Sun </span>
|
||||||
</th>
|
</th>
|
||||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
|
||||||
<span className='hidden lg:block'> Monday </span>
|
<span className="hidden lg:block"> Monday </span>
|
||||||
<span className='block lg:hidden'> Mon </span>
|
<span className="block lg:hidden"> Mon </span>
|
||||||
</th>
|
</th>
|
||||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
|
||||||
<span className='hidden lg:block'> Tuesday </span>
|
<span className="hidden lg:block"> Tuesday </span>
|
||||||
<span className='block lg:hidden'> Tue </span>
|
<span className="block lg:hidden"> Tue </span>
|
||||||
</th>
|
</th>
|
||||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
|
||||||
<span className='hidden lg:block'> Wednesday </span>
|
<span className="hidden lg:block"> Wednesday </span>
|
||||||
<span className='block lg:hidden'> Wed </span>
|
<span className="block lg:hidden"> Wed </span>
|
||||||
</th>
|
</th>
|
||||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
|
||||||
<span className='hidden lg:block'> Thursday </span>
|
<span className="hidden lg:block"> Thursday </span>
|
||||||
<span className='block lg:hidden'> Thur </span>
|
<span className="block lg:hidden"> Thur </span>
|
||||||
</th>
|
</th>
|
||||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
|
||||||
<span className='hidden lg:block'> Friday </span>
|
<span className="hidden lg:block"> Friday </span>
|
||||||
<span className='block lg:hidden'> Fri </span>
|
<span className="block lg:hidden"> Fri </span>
|
||||||
</th>
|
</th>
|
||||||
<th className='flex h-15 items-center justify-center rounded-tr-sm p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
<th className="h-15 flex items-center justify-center rounded-tr-sm p-1 text-xs font-semibold sm:text-base xl:p-5">
|
||||||
<span className='hidden lg:block'> Saturday </span>
|
<span className="hidden lg:block"> Saturday </span>
|
||||||
<span className='block lg:hidden'> Sat </span>
|
<span className="block lg:hidden"> Sat </span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{/* <!-- Line 1 --> */}
|
{/* <!-- Line 1 --> */}
|
||||||
<tr className='grid grid-cols-7'>
|
<tr className="grid grid-cols-7">
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>1</span>
|
<span className="font-medium">1</span>
|
||||||
<div className='group h-16 w-full flex-grow cursor-pointer py-1 md:h-30'>
|
<div className="md:h-30 group h-16 w-full flex-grow cursor-pointer py-1">
|
||||||
<span className='group-hover:text-primary md:hidden'>More</span>
|
<span className="group-hover:text-primary md:hidden">
|
||||||
<div className='event invisible absolute left-2 z-99 mb-1 flex w-[200%] flex-col rounded-sm border-l-[3px] border-primary bg-muted px-3 py-1 text-left opacity-0 group-hover:visible group-hover:opacity-100 md:visible md:w-[190%] md:opacity-100'>
|
More
|
||||||
<span className='event-name text-sm font-semibold text-foreground'>Redesign Website</span>
|
</span>
|
||||||
<span className='time text-sm font-medium text-foreground'>1 Dec - 2 Dec</span>
|
<div className="event z-99 border-primary bg-muted invisible absolute left-2 mb-1 flex w-[200%] flex-col rounded-sm border-l-[3px] px-3 py-1 text-left opacity-0 group-hover:visible group-hover:opacity-100 md:visible md:w-[190%] md:opacity-100">
|
||||||
|
<span className="event-name text-foreground text-sm font-semibold">
|
||||||
|
Redesign Website
|
||||||
|
</span>
|
||||||
|
<span className="time text-foreground text-sm font-medium">
|
||||||
|
1 Dec - 2 Dec
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>2</span>
|
<span className="font-medium">2</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>3</span>
|
<span className="font-medium">3</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>4</span>
|
<span className="font-medium">4</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>5</span>
|
<span className="font-medium">5</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>6</span>
|
<span className="font-medium">6</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>7</span>
|
<span className="font-medium">7</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/* <!-- Line 1 --> */}
|
{/* <!-- Line 1 --> */}
|
||||||
{/* <!-- Line 2 --> */}
|
{/* <!-- Line 2 --> */}
|
||||||
<tr className='grid grid-cols-7'>
|
<tr className="grid grid-cols-7">
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>8</span>
|
<span className="font-medium">8</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>9</span>
|
<span className="font-medium">9</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>10</span>
|
<span className="font-medium">10</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>11</span>
|
<span className="font-medium">11</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>12</span>
|
<span className="font-medium">12</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>13</span>
|
<span className="font-medium">13</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>14</span>
|
<span className="font-medium">14</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/* <!-- Line 2 --> */}
|
{/* <!-- Line 2 --> */}
|
||||||
<tr className='grid grid-cols-7'>
|
<tr className="grid grid-cols-7">
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>15</span>
|
<span className="font-medium">15</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>16</span>
|
<span className="font-medium">16</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>17</span>
|
<span className="font-medium">17</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>18</span>
|
<span className="font-medium">18</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>19</span>
|
<span className="font-medium">19</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>20</span>
|
<span className="font-medium">20</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>21</span>
|
<span className="font-medium">21</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/* <!-- Line 3 --> */}
|
{/* <!-- Line 3 --> */}
|
||||||
<tr className='grid grid-cols-7'>
|
<tr className="grid grid-cols-7">
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>22</span>
|
<span className="font-medium">22</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>23</span>
|
<span className="font-medium">23</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>24</span>
|
<span className="font-medium">24</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>25</span>
|
<span className="font-medium">25</span>
|
||||||
<div className='group h-16 w-full flex-grow cursor-pointer py-1 md:h-30'>
|
<div className="md:h-30 group h-16 w-full flex-grow cursor-pointer py-1">
|
||||||
<span className='group-hover:text-primary md:hidden'>More</span>
|
<span className="group-hover:text-primary md:hidden">
|
||||||
<div className='event invisible absolute left-2 z-99 mb-1 flex w-[300%] flex-col rounded-sm border-l-[3px] border-primary bg-muted px-3 py-1 text-left opacity-0 group-hover:visible group-hover:opacity-100 md:visible md:w-[290%] md:opacity-100'>
|
More
|
||||||
<span className='event-name text-sm font-semibold text-foreground'>App Design</span>
|
</span>
|
||||||
<span className='time text-sm font-medium text-foreground'>25 Dec - 27 Dec</span>
|
<div className="event z-99 border-primary bg-muted invisible absolute left-2 mb-1 flex w-[300%] flex-col rounded-sm border-l-[3px] px-3 py-1 text-left opacity-0 group-hover:visible group-hover:opacity-100 md:visible md:w-[290%] md:opacity-100">
|
||||||
|
<span className="event-name text-foreground text-sm font-semibold">
|
||||||
|
App Design
|
||||||
|
</span>
|
||||||
|
<span className="time text-foreground text-sm font-medium">
|
||||||
|
25 Dec - 27 Dec
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>26</span>
|
<span className="font-medium">26</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>27</span>
|
<span className="font-medium">27</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>28</span>
|
<span className="font-medium">28</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/* <!-- Line 4 --> */}
|
{/* <!-- Line 4 --> */}
|
||||||
<tr className='grid grid-cols-7'>
|
<tr className="grid grid-cols-7">
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>29</span>
|
<span className="font-medium">29</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>30</span>
|
<span className="font-medium">30</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>31</span>
|
<span className="font-medium">31</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>1</span>
|
<span className="font-medium">1</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>2</span>
|
<span className="font-medium">2</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>3</span>
|
<span className="font-medium">3</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||||
<span className='font-medium'>4</span>
|
<span className="font-medium">4</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/* <!-- Line 5 --> */}
|
{/* <!-- Line 5 --> */}
|
||||||
|
|||||||
@@ -1,176 +1,202 @@
|
|||||||
import { FileText, Mail, Upload, User } from 'lucide-react';
|
import { FileText, Mail, Upload, User } from "lucide-react";
|
||||||
import { FormEvent } from 'react';
|
import { FormEvent } from "react";
|
||||||
import toast from 'react-hot-toast';
|
import toast from "react-hot-toast";
|
||||||
import { type AuthUser } from 'wasp/auth';
|
import { type AuthUser } from "wasp/auth";
|
||||||
import { Button } from '../../../components/ui/button';
|
import { Button } from "../../../components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '../../../components/ui/card';
|
import {
|
||||||
import { Input } from '../../../components/ui/input';
|
Card,
|
||||||
import { Label } from '../../../components/ui/label';
|
CardContent,
|
||||||
import { Textarea } from '../../../components/ui/textarea';
|
CardHeader,
|
||||||
import Breadcrumb from '../../layout/Breadcrumb';
|
CardTitle,
|
||||||
import DefaultLayout from '../../layout/DefaultLayout';
|
} from "../../../components/ui/card";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { Label } from "../../../components/ui/label";
|
||||||
|
import { Textarea } from "../../../components/ui/textarea";
|
||||||
|
import Breadcrumb from "../../layout/Breadcrumb";
|
||||||
|
import DefaultLayout from "../../layout/DefaultLayout";
|
||||||
|
|
||||||
const SettingsPage = ({ user }: { user: AuthUser }) => {
|
const SettingsPage = ({ user }: { user: AuthUser }) => {
|
||||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
// TODO add toast provider / wrapper
|
// TODO add toast provider / wrapper
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const confirmed = confirm('Are you sure you want to save the changes?');
|
const confirmed = confirm("Are you sure you want to save the changes?");
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
toast.success('Your changes have been saved successfully!');
|
toast.success("Your changes have been saved successfully!");
|
||||||
} else {
|
} else {
|
||||||
toast.error('Your changes have not been saved!');
|
toast.error("Your changes have not been saved!");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DefaultLayout user={user}>
|
<DefaultLayout user={user}>
|
||||||
<div className='mx-auto max-w-270'>
|
<div className="max-w-270 mx-auto">
|
||||||
<Breadcrumb pageName='Settings' />
|
<Breadcrumb pageName="Settings" />
|
||||||
|
|
||||||
<div className='grid grid-cols-5 gap-8'>
|
<div className="grid grid-cols-5 gap-8">
|
||||||
<div className='col-span-5 xl:col-span-3'>
|
<div className="col-span-5 xl:col-span-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Personal Information</CardTitle>
|
<CardTitle>Personal Information</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className='mb-5.5 flex flex-col gap-5.5 sm:flex-row'>
|
<div className="mb-5.5 gap-5.5 flex flex-col sm:flex-row">
|
||||||
<div className='w-full sm:w-1/2'>
|
<div className="w-full sm:w-1/2">
|
||||||
<Label htmlFor='full-name' className='mb-3 block text-sm font-medium text-foreground'>
|
<Label
|
||||||
|
htmlFor="full-name"
|
||||||
|
className="text-foreground mb-3 block text-sm font-medium"
|
||||||
|
>
|
||||||
Full Name
|
Full Name
|
||||||
</Label>
|
</Label>
|
||||||
<div className='relative'>
|
<div className="relative">
|
||||||
<User className='absolute left-4.5 top-2 h-5 w-5 text-muted-foreground' />
|
<User className="left-4.5 text-muted-foreground absolute top-2 h-5 w-5" />
|
||||||
<Input
|
<Input
|
||||||
className='pl-11.5'
|
className="pl-11.5"
|
||||||
type='text'
|
type="text"
|
||||||
name='fullName'
|
name="fullName"
|
||||||
id='full-name'
|
id="full-name"
|
||||||
placeholder='Devid Jhon'
|
placeholder="Devid Jhon"
|
||||||
defaultValue='Devid Jhon'
|
defaultValue="Devid Jhon"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='w-full sm:w-1/2'>
|
<div className="w-full sm:w-1/2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor='phone-number'
|
htmlFor="phone-number"
|
||||||
className='mb-3 block text-sm font-medium text-foreground'
|
className="text-foreground mb-3 block text-sm font-medium"
|
||||||
>
|
>
|
||||||
Phone Number
|
Phone Number
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type=''
|
type=""
|
||||||
name='phoneNumber'
|
name="phoneNumber"
|
||||||
id='phone-number'
|
id="phone-number"
|
||||||
placeholder='+990 3343 7865'
|
placeholder="+990 3343 7865"
|
||||||
defaultValue='+990 3343 7865'
|
defaultValue="+990 3343 7865"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mb-5.5'>
|
<div className="mb-5.5">
|
||||||
<Label htmlFor='email-address' className='mb-3 block text-sm font-medium text-foreground'>
|
<Label
|
||||||
|
htmlFor="email-address"
|
||||||
|
className="text-foreground mb-3 block text-sm font-medium"
|
||||||
|
>
|
||||||
Email Address
|
Email Address
|
||||||
</Label>
|
</Label>
|
||||||
<div className='relative'>
|
<div className="relative">
|
||||||
<Mail className='absolute left-4.5 top-2 h-5 w-5 text-muted-foreground' />
|
<Mail className="left-4.5 text-muted-foreground absolute top-2 h-5 w-5" />
|
||||||
<Input
|
<Input
|
||||||
className='pl-11.5'
|
className="pl-11.5"
|
||||||
type='email'
|
type="email"
|
||||||
name='emailAddress'
|
name="emailAddress"
|
||||||
id='email-address'
|
id="email-address"
|
||||||
placeholder='devidjond45@gmail.com'
|
placeholder="devidjond45@gmail.com"
|
||||||
defaultValue='devidjond45@gmail.com'
|
defaultValue="devidjond45@gmail.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mb-5.5'>
|
<div className="mb-5.5">
|
||||||
<Label htmlFor='username' className='mb-3 block text-sm font-medium text-foreground'>
|
<Label
|
||||||
|
htmlFor="username"
|
||||||
|
className="text-foreground mb-3 block text-sm font-medium"
|
||||||
|
>
|
||||||
Username
|
Username
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type='text'
|
type="text"
|
||||||
name='Username'
|
name="Username"
|
||||||
id='username'
|
id="username"
|
||||||
placeholder='devidjhon24'
|
placeholder="devidjhon24"
|
||||||
defaultValue='devidjhon24'
|
defaultValue="devidjhon24"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mb-5.5'>
|
<div className="mb-5.5">
|
||||||
<Label htmlFor='bio' className='mb-3 block text-sm font-medium text-foreground'>
|
<Label
|
||||||
|
htmlFor="bio"
|
||||||
|
className="text-foreground mb-3 block text-sm font-medium"
|
||||||
|
>
|
||||||
BIO
|
BIO
|
||||||
</Label>
|
</Label>
|
||||||
<div className='relative'>
|
<div className="relative">
|
||||||
<FileText className='absolute left-4.5 top-4 h-5 w-5 text-muted-foreground' />
|
<FileText className="left-4.5 text-muted-foreground absolute top-4 h-5 w-5" />
|
||||||
<Textarea
|
<Textarea
|
||||||
className='w-full rounded border border-border bg-background py-3 pl-11.5 pr-4.5 text-foreground focus:border-primary focus-visible:outline-none'
|
className="border-border bg-background pl-11.5 pr-4.5 text-foreground focus:border-primary w-full rounded border py-3 focus-visible:outline-none"
|
||||||
name='bio'
|
name="bio"
|
||||||
id='bio'
|
id="bio"
|
||||||
rows={6}
|
rows={6}
|
||||||
placeholder='Write your bio here'
|
placeholder="Write your bio here"
|
||||||
defaultValue='Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque posuere fermentum urna, eu condimentum mauris tempus ut. Donec fermentum blandit aliquet.'
|
defaultValue="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque posuere fermentum urna, eu condimentum mauris tempus ut. Donec fermentum blandit aliquet."
|
||||||
></Textarea>
|
></Textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex justify-end gap-4.5'>
|
<div className="gap-4.5 flex justify-end">
|
||||||
<Button variant='outline' type='submit'>
|
<Button variant="outline" type="submit">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type='submit'>Save</Button>
|
<Button type="submit">Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div className='col-span-5 xl:col-span-2'>
|
<div className="col-span-5 xl:col-span-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Your Photo</CardTitle>
|
<CardTitle>Your Photo</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form action='#'>
|
<form action="#">
|
||||||
<div className='mb-4 flex items-center gap-3'>
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<div className='h-14 w-14 rounded-full'>{/* <img src={userThree} alt="User" /> */}</div>
|
<div className="h-14 w-14 rounded-full">
|
||||||
|
{/* <img src={userThree} alt="User" /> */}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className='mb-1.5 text-foreground'>Edit your photo</span>
|
<span className="text-foreground mb-1.5">
|
||||||
<span className='flex gap-2.5'>
|
Edit your photo
|
||||||
<button className='text-sm hover:text-primary'>Delete</button>
|
</span>
|
||||||
<button className='text-sm hover:text-primary'>Update</button>
|
<span className="flex gap-2.5">
|
||||||
|
<button className="hover:text-primary text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button className="hover:text-primary text-sm">
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id='FileUpload'
|
id="FileUpload"
|
||||||
className='relative mb-5.5 block w-full cursor-pointer appearance-none rounded border-2 border-dashed border-primary bg-background py-4 px-4 sm:py-7.5'
|
className="mb-5.5 border-primary bg-background sm:py-7.5 relative block w-full cursor-pointer appearance-none rounded border-2 border-dashed px-4 py-4"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type='file'
|
type="file"
|
||||||
accept='image/*'
|
accept="image/*"
|
||||||
className='absolute inset-0 z-50 m-0 h-full w-full cursor-pointer p-0 opacity-0 outline-none'
|
className="absolute inset-0 z-50 m-0 h-full w-full cursor-pointer p-0 opacity-0 outline-none"
|
||||||
/>
|
/>
|
||||||
<div className='flex flex-col items-center justify-center space-y-3'>
|
<div className="flex flex-col items-center justify-center space-y-3">
|
||||||
<span className='flex h-10 w-10 items-center justify-center rounded-full border border-border bg-background'>
|
<span className="border-border bg-background flex h-10 w-10 items-center justify-center rounded-full border">
|
||||||
<Upload className='h-4 w-4 text-primary' />
|
<Upload className="text-primary h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<p>
|
<p>
|
||||||
<span className='text-primary'>Click to upload</span> or drag and drop
|
<span className="text-primary">Click to upload</span> or
|
||||||
|
drag and drop
|
||||||
</p>
|
</p>
|
||||||
<p className='mt-1.5'>SVG, PNG, JPG or GIF</p>
|
<p className="mt-1.5">SVG, PNG, JPG or GIF</p>
|
||||||
<p>(max, 800 X 800px)</p>
|
<p>(max, 800 X 800px)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex justify-end gap-4.5'>
|
<div className="gap-4.5 flex justify-end">
|
||||||
<Button variant='outline' type='submit'>
|
<Button variant="outline" type="submit">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type='submit'>Save</Button>
|
<Button type="submit">Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
import { Heart, Plus, Trash2 } from 'lucide-react';
|
import { Heart, Plus, Trash2 } from "lucide-react";
|
||||||
import { type AuthUser } from 'wasp/auth';
|
import { type AuthUser } from "wasp/auth";
|
||||||
import { Button } from '../../../components/ui/button';
|
import { Button } from "../../../components/ui/button";
|
||||||
import Breadcrumb from '../../layout/Breadcrumb';
|
import Breadcrumb from "../../layout/Breadcrumb";
|
||||||
import DefaultLayout from '../../layout/DefaultLayout';
|
import DefaultLayout from "../../layout/DefaultLayout";
|
||||||
|
|
||||||
const Buttons = ({ user }: { user: AuthUser }) => {
|
const Buttons = ({ user }: { user: AuthUser }) => {
|
||||||
return (
|
return (
|
||||||
<DefaultLayout user={user}>
|
<DefaultLayout user={user}>
|
||||||
<Breadcrumb pageName='Buttons' />
|
<Breadcrumb pageName="Buttons" />
|
||||||
|
|
||||||
{/* Button Variants */}
|
{/* Button Variants */}
|
||||||
<div className='mb-10 rounded-sm border border-border bg-card shadow-default'>
|
<div className="border-border bg-card shadow-default mb-10 rounded-sm border">
|
||||||
<div className='border-b border-border px-7 py-4'>
|
<div className="border-border border-b px-7 py-4">
|
||||||
<h3 className='font-medium text-foreground'>Button Variants</h3>
|
<h3 className="text-foreground font-medium">Button Variants</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='p-4 md:p-6 xl:p-9'>
|
<div className="p-4 md:p-6 xl:p-9">
|
||||||
<div className='flex flex-wrap gap-4'>
|
<div className="flex flex-wrap gap-4">
|
||||||
<Button variant='default'>Default</Button>
|
<Button variant="default">Default</Button>
|
||||||
<Button variant='outline'>Outline</Button>
|
<Button variant="outline">Outline</Button>
|
||||||
<Button variant='secondary'>Secondary</Button>
|
<Button variant="secondary">Secondary</Button>
|
||||||
<Button variant='ghost'>Ghost</Button>
|
<Button variant="ghost">Ghost</Button>
|
||||||
<Button variant='link'>Link</Button>
|
<Button variant="link">Link</Button>
|
||||||
<Button variant='destructive'>Destructive</Button>
|
<Button variant="destructive">Destructive</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Button Sizes */}
|
{/* Button Sizes */}
|
||||||
<div className='mb-10 rounded-sm border border-border bg-card shadow-default'>
|
<div className="border-border bg-card shadow-default mb-10 rounded-sm border">
|
||||||
<div className='border-b border-border px-7 py-4'>
|
<div className="border-border border-b px-7 py-4">
|
||||||
<h3 className='font-medium text-foreground'>Button Sizes</h3>
|
<h3 className="text-foreground font-medium">Button Sizes</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='p-4 md:p-6 xl:p-9'>
|
<div className="p-4 md:p-6 xl:p-9">
|
||||||
<div className='flex flex-wrap items-center gap-4'>
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<Button size='sm'>Small</Button>
|
<Button size="sm">Small</Button>
|
||||||
<Button size='default'>Default</Button>
|
<Button size="default">Default</Button>
|
||||||
<Button size='lg'>Large</Button>
|
<Button size="lg">Large</Button>
|
||||||
<Button size='icon'>
|
<Button size="icon">
|
||||||
<Plus />
|
<Plus />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,22 +46,22 @@ const Buttons = ({ user }: { user: AuthUser }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Button with Icon */}
|
{/* Button with Icon */}
|
||||||
<div className='mb-10 rounded-sm border border-border bg-card shadow-default'>
|
<div className="border-border bg-card shadow-default mb-10 rounded-sm border">
|
||||||
<div className='border-b border-border px-7 py-4'>
|
<div className="border-border border-b px-7 py-4">
|
||||||
<h3 className='font-medium text-foreground'>Button with Icon</h3>
|
<h3 className="text-foreground font-medium">Button with Icon</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='p-4 md:p-6 xl:p-9'>
|
<div className="p-4 md:p-6 xl:p-9">
|
||||||
<div className='flex flex-wrap gap-4'>
|
<div className="flex flex-wrap gap-4">
|
||||||
<Button>
|
<Button>
|
||||||
<Plus />
|
<Plus />
|
||||||
Add Item
|
Add Item
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='outline'>
|
<Button variant="outline">
|
||||||
<Heart />
|
<Heart />
|
||||||
Like
|
Like
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='destructive'>
|
<Button variant="destructive">
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||||
interface BreadcrumbProps {
|
interface BreadcrumbProps {
|
||||||
pageName: string;
|
pageName: string;
|
||||||
}
|
}
|
||||||
const Breadcrumb = ({ pageName }: BreadcrumbProps) => {
|
const Breadcrumb = ({ pageName }: BreadcrumbProps) => {
|
||||||
return (
|
return (
|
||||||
<div className='mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className='text-title-md2 font-semibold text-foreground'>{pageName}</h2>
|
<h2 className="text-title-md2 text-foreground font-semibold">
|
||||||
|
{pageName}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
<ul className='flex items-center gap-1'>
|
<ul className="flex items-center gap-1">
|
||||||
<li>
|
<li>
|
||||||
<WaspRouterLink to={routes.AdminRoute.to}>Dashboard</WaspRouterLink>
|
<WaspRouterLink to={routes.AdminRoute.to}>Dashboard</WaspRouterLink>
|
||||||
</li>
|
</li>
|
||||||
<li>/</li>
|
<li>/</li>
|
||||||
<li className='font-medium'>{pageName}</li>
|
<li className="font-medium">{pageName}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { type AuthUser } from 'wasp/auth';
|
import { FC, ReactNode, useState } from "react";
|
||||||
import { FC, ReactNode, useState } from 'react';
|
import { Navigate } from "react-router-dom";
|
||||||
import { Navigate } from 'react-router-dom';
|
import { type AuthUser } from "wasp/auth";
|
||||||
import Header from './Header';
|
import Header from "./Header";
|
||||||
import Sidebar from './Sidebar';
|
import Sidebar from "./Sidebar";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
@@ -13,17 +13,23 @@ const DefaultLayout: FC<Props> = ({ children, user }) => {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
if (!user.isAdmin) {
|
if (!user.isAdmin) {
|
||||||
return <Navigate to='/' replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='bg-background text-foreground'>
|
<div className="bg-background text-foreground">
|
||||||
<div className='flex h-screen overflow-hidden'>
|
<div className="flex h-screen overflow-hidden">
|
||||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
||||||
<div className='relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden'>
|
<div className="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
|
||||||
<Header sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} user={user} />
|
<Header
|
||||||
|
sidebarOpen={sidebarOpen}
|
||||||
|
setSidebarOpen={setSidebarOpen}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
<main>
|
<main>
|
||||||
<div className='mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10'>{children}</div>
|
<div className="mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { type AuthUser } from 'wasp/auth';
|
import { type AuthUser } from "wasp/auth";
|
||||||
import DarkModeSwitcher from '../../client/components/DarkModeSwitcher';
|
import DarkModeSwitcher from "../../client/components/DarkModeSwitcher";
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from "../../lib/utils";
|
||||||
import MessageButton from '../dashboards/messages/MessageButton';
|
import { UserDropdown } from "../../user/UserDropdown";
|
||||||
import { UserDropdown } from '../../user/UserDropdown';
|
import MessageButton from "../dashboards/messages/MessageButton";
|
||||||
|
|
||||||
const Header = (props: {
|
const Header = (props: {
|
||||||
sidebarOpen: string | boolean | undefined;
|
sidebarOpen: string | boolean | undefined;
|
||||||
@@ -10,61 +10,61 @@ const Header = (props: {
|
|||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<header className='sticky top-0 z-10 flex w-full bg-background border-b border-border shadow-sm'>
|
<header className="bg-background border-border sticky top-0 z-10 flex w-full border-b shadow-sm">
|
||||||
<div className='flex flex-grow items-center justify-between sm:justify-end sm:gap-5 px-8 py-5'>
|
<div className="flex flex-grow items-center justify-between px-8 py-5 sm:justify-end sm:gap-5">
|
||||||
<div className='flex items-center gap-2 sm:gap-4 lg:hidden'>
|
<div className="flex items-center gap-2 sm:gap-4 lg:hidden">
|
||||||
{/* <!-- Hamburger Toggle BTN --> */}
|
{/* <!-- Hamburger Toggle BTN --> */}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
aria-controls='sidebar'
|
aria-controls="sidebar"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
props.setSidebarOpen(!props.sidebarOpen);
|
props.setSidebarOpen(!props.sidebarOpen);
|
||||||
}}
|
}}
|
||||||
className='z-99999 block rounded-sm border border-border bg-background p-1.5 shadow-sm lg:hidden'
|
className="z-99999 border-border bg-background block rounded-sm border p-1.5 shadow-sm lg:hidden"
|
||||||
>
|
>
|
||||||
<span className='relative block h-5.5 w-5.5 cursor-pointer'>
|
<span className="h-5.5 w-5.5 relative block cursor-pointer">
|
||||||
<span className='du-block absolute right-0 h-full w-full'>
|
<span className="du-block absolute right-0 h-full w-full">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative top-0 left-0 my-1 block h-0.5 w-0 rounded-sm bg-foreground delay-[0] duration-200 ease-in-out',
|
"bg-foreground relative left-0 top-0 my-1 block h-0.5 w-0 rounded-sm delay-[0] duration-200 ease-in-out",
|
||||||
{
|
{
|
||||||
'!w-full delay-300': !props.sidebarOpen,
|
"!w-full delay-300": !props.sidebarOpen,
|
||||||
}
|
},
|
||||||
)}
|
)}
|
||||||
></span>
|
></span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative top-0 left-0 my-1 block h-0.5 w-0 rounded-sm bg-foreground delay-150 duration-200 ease-in-out',
|
"bg-foreground relative left-0 top-0 my-1 block h-0.5 w-0 rounded-sm delay-150 duration-200 ease-in-out",
|
||||||
{
|
{
|
||||||
'delay-400 !w-full': !props.sidebarOpen,
|
"delay-400 !w-full": !props.sidebarOpen,
|
||||||
}
|
},
|
||||||
)}
|
)}
|
||||||
></span>
|
></span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative top-0 left-0 my-1 block h-0.5 w-0 rounded-sm bg-foreground delay-200 duration-200 ease-in-out',
|
"bg-foreground relative left-0 top-0 my-1 block h-0.5 w-0 rounded-sm delay-200 duration-200 ease-in-out",
|
||||||
{
|
{
|
||||||
'!w-full delay-500': !props.sidebarOpen,
|
"!w-full delay-500": !props.sidebarOpen,
|
||||||
}
|
},
|
||||||
)}
|
)}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
<span className='absolute right-0 h-full w-full rotate-45'>
|
<span className="absolute right-0 h-full w-full rotate-45">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute left-2.5 top-0 block h-full w-0.5 rounded-sm bg-foreground delay-300 duration-200 ease-in-out',
|
"bg-foreground absolute left-2.5 top-0 block h-full w-0.5 rounded-sm delay-300 duration-200 ease-in-out",
|
||||||
{
|
{
|
||||||
'!h-0 !delay-[0]': !props.sidebarOpen,
|
"!h-0 !delay-[0]": !props.sidebarOpen,
|
||||||
}
|
},
|
||||||
)}
|
)}
|
||||||
></span>
|
></span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'delay-400 absolute left-0 top-2.5 block h-0.5 w-full rounded-sm bg-foreground duration-200 ease-in-out',
|
"delay-400 bg-foreground absolute left-0 top-2.5 block h-0.5 w-full rounded-sm duration-200 ease-in-out",
|
||||||
{
|
{
|
||||||
'!h-0 !delay-200': !props.sidebarOpen,
|
"!h-0 !delay-200": !props.sidebarOpen,
|
||||||
}
|
},
|
||||||
)}
|
)}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
@@ -74,7 +74,7 @@ const Header = (props: {
|
|||||||
{/* <!-- Hamburger Toggle BTN --> */}
|
{/* <!-- Hamburger Toggle BTN --> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className='flex items-center gap-2 2xsm:gap-4'>
|
<ul className="2xsm:gap-4 flex items-center gap-2">
|
||||||
{/* <!-- Dark Mode Toggler --> */}
|
{/* <!-- Dark Mode Toggler --> */}
|
||||||
<DarkModeSwitcher />
|
<DarkModeSwitcher />
|
||||||
{/* <!-- Dark Mode Toggler --> */}
|
{/* <!-- Dark Mode Toggler --> */}
|
||||||
@@ -84,7 +84,7 @@ const Header = (props: {
|
|||||||
{/* <!-- Chat Notification Area --> */}
|
{/* <!-- Chat Notification Area --> */}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className='flex items-center gap-3 2xsm:gap-7'>
|
<div className="2xsm:gap-7 flex items-center gap-3">
|
||||||
{/* <!-- User Area --> */}
|
{/* <!-- User Area --> */}
|
||||||
<UserDropdown user={props.user} />
|
<UserDropdown user={props.user} />
|
||||||
{/* <!-- User Area --> */}
|
{/* <!-- User Area --> */}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const LoadingSpinner = () => {
|
const LoadingSpinner = () => {
|
||||||
return (
|
return (
|
||||||
<div className='flex py-10 items-center justify-center'>
|
<div className="flex items-center justify-center py-10">
|
||||||
<div className='h-16 w-16 animate-spin rounded-full border-4 border-solid border-primary border-t-transparent'></div>
|
<div className="border-primary h-16 w-16 animate-spin rounded-full border-4 border-solid border-t-transparent"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Sheet,
|
Sheet,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from "lucide-react";
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { NavLink, useLocation } from 'react-router-dom';
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
import Logo from '../../client/static/logo.webp';
|
import Logo from "../../client/static/logo.webp";
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from "../../lib/utils";
|
||||||
import SidebarLinkGroup from './SidebarLinkGroup';
|
import SidebarLinkGroup from "./SidebarLinkGroup";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
@@ -26,20 +26,25 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
|||||||
const trigger = useRef<any>(null);
|
const trigger = useRef<any>(null);
|
||||||
const sidebar = useRef<any>(null);
|
const sidebar = useRef<any>(null);
|
||||||
|
|
||||||
const storedSidebarExpanded = localStorage.getItem('sidebar-expanded');
|
const storedSidebarExpanded = localStorage.getItem("sidebar-expanded");
|
||||||
const [sidebarExpanded, setSidebarExpanded] = useState(
|
const [sidebarExpanded, setSidebarExpanded] = useState(
|
||||||
storedSidebarExpanded === null ? false : storedSidebarExpanded === 'true'
|
storedSidebarExpanded === null ? false : storedSidebarExpanded === "true",
|
||||||
);
|
);
|
||||||
|
|
||||||
// close on click outside
|
// close on click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const clickHandler = ({ target }: MouseEvent) => {
|
const clickHandler = ({ target }: MouseEvent) => {
|
||||||
if (!sidebar.current || !trigger.current) return;
|
if (!sidebar.current || !trigger.current) return;
|
||||||
if (!sidebarOpen || sidebar.current.contains(target) || trigger.current.contains(target)) return;
|
if (
|
||||||
|
!sidebarOpen ||
|
||||||
|
sidebar.current.contains(target) ||
|
||||||
|
trigger.current.contains(target)
|
||||||
|
)
|
||||||
|
return;
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('click', clickHandler);
|
document.addEventListener("click", clickHandler);
|
||||||
return () => document.removeEventListener('click', clickHandler);
|
return () => document.removeEventListener("click", clickHandler);
|
||||||
});
|
});
|
||||||
|
|
||||||
// close if the esc key is pressed
|
// close if the esc key is pressed
|
||||||
@@ -48,16 +53,16 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
|||||||
if (!sidebarOpen || keyCode !== 27) return;
|
if (!sidebarOpen || keyCode !== 27) return;
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', keyHandler);
|
document.addEventListener("keydown", keyHandler);
|
||||||
return () => document.removeEventListener('keydown', keyHandler);
|
return () => document.removeEventListener("keydown", keyHandler);
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('sidebar-expanded', sidebarExpanded.toString());
|
localStorage.setItem("sidebar-expanded", sidebarExpanded.toString());
|
||||||
if (sidebarExpanded) {
|
if (sidebarExpanded) {
|
||||||
document.querySelector('body')?.classList.add('sidebar-expanded');
|
document.querySelector("body")?.classList.add("sidebar-expanded");
|
||||||
} else {
|
} else {
|
||||||
document.querySelector('body')?.classList.remove('sidebar-expanded');
|
document.querySelector("body")?.classList.remove("sidebar-expanded");
|
||||||
}
|
}
|
||||||
}, [sidebarExpanded]);
|
}, [sidebarExpanded]);
|
||||||
|
|
||||||
@@ -65,49 +70,51 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
|||||||
<aside
|
<aside
|
||||||
ref={sidebar}
|
ref={sidebar}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute left-0 top-0 z-9999 flex h-screen w-72.5 flex-col overflow-y-hidden bg-muted border-r duration-300 ease-linear lg:static lg:translate-x-0',
|
"z-9999 w-72.5 bg-muted absolute left-0 top-0 flex h-screen flex-col overflow-y-hidden border-r duration-300 ease-linear lg:static lg:translate-x-0",
|
||||||
{
|
{
|
||||||
'translate-x-0': sidebarOpen,
|
"translate-x-0": sidebarOpen,
|
||||||
'-translate-x-full': !sidebarOpen,
|
"-translate-x-full": !sidebarOpen,
|
||||||
}
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* <!-- SIDEBAR HEADER --> */}
|
{/* <!-- SIDEBAR HEADER --> */}
|
||||||
<div className='flex items-center justify-between gap-2 px-6 py-5.5 lg:py-6.5'>
|
<div className="py-5.5 lg:py-6.5 flex items-center justify-between gap-2 px-6">
|
||||||
<NavLink to='/'>
|
<NavLink to="/">
|
||||||
<img src={Logo} alt='Logo' width={50} />
|
<img src={Logo} alt="Logo" width={50} />
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
ref={trigger}
|
ref={trigger}
|
||||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
aria-controls='sidebar'
|
aria-controls="sidebar"
|
||||||
aria-expanded={sidebarOpen}
|
aria-expanded={sidebarOpen}
|
||||||
className='block lg:hidden'
|
className="block lg:hidden"
|
||||||
>
|
>
|
||||||
<X />
|
<X />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* <!-- SIDEBAR HEADER --> */}
|
{/* <!-- SIDEBAR HEADER --> */}
|
||||||
|
|
||||||
<div className='no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear'>
|
<div className="no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear">
|
||||||
{/* <!-- Sidebar Menu --> */}
|
{/* <!-- Sidebar Menu --> */}
|
||||||
<nav className='mt-5 py-4 px-4 lg:mt-9 lg:px-6'>
|
<nav className="mt-5 px-4 py-4 lg:mt-9 lg:px-6">
|
||||||
{/* <!-- Menu Group --> */}
|
{/* <!-- Menu Group --> */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className='mb-4 ml-4 text-sm font-semibold text-muted-foreground'>MENU</h3>
|
<h3 className="text-muted-foreground mb-4 ml-4 text-sm font-semibold">
|
||||||
|
MENU
|
||||||
|
</h3>
|
||||||
|
|
||||||
<ul className='mb-6 flex flex-col gap-1.5'>
|
<ul className="mb-6 flex flex-col gap-1.5">
|
||||||
{/* <!-- Menu Item Dashboard --> */}
|
{/* <!-- Menu Item Dashboard --> */}
|
||||||
<NavLink
|
<NavLink
|
||||||
to='/admin'
|
to="/admin"
|
||||||
end
|
end
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
|
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
|
||||||
{
|
{
|
||||||
'bg-accent text-accent-foreground': isActive,
|
"bg-accent text-accent-foreground": isActive,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -120,14 +127,14 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
|||||||
{/* <!-- Menu Item Users --> */}
|
{/* <!-- Menu Item Users --> */}
|
||||||
<li>
|
<li>
|
||||||
<NavLink
|
<NavLink
|
||||||
to='/admin/users'
|
to="/admin/users"
|
||||||
end
|
end
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
|
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
|
||||||
{
|
{
|
||||||
'bg-accent text-accent-foreground': isActive,
|
"bg-accent text-accent-foreground": isActive,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -140,14 +147,14 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
|||||||
{/* <!-- Menu Item Settings --> */}
|
{/* <!-- Menu Item Settings --> */}
|
||||||
<li>
|
<li>
|
||||||
<NavLink
|
<NavLink
|
||||||
to='/admin/settings'
|
to="/admin/settings"
|
||||||
end
|
end
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
|
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
|
||||||
{
|
{
|
||||||
'bg-accent text-accent-foreground': isActive,
|
"bg-accent text-accent-foreground": isActive,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -161,20 +168,22 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
|||||||
|
|
||||||
{/* <!-- Others Group --> */}
|
{/* <!-- Others Group --> */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className='mb-4 ml-4 text-sm font-semibold text-muted-foreground'>Extra Components</h3>
|
<h3 className="text-muted-foreground mb-4 ml-4 text-sm font-semibold">
|
||||||
|
Extra Components
|
||||||
|
</h3>
|
||||||
|
|
||||||
<ul className='mb-6 flex flex-col gap-1.5'>
|
<ul className="mb-6 flex flex-col gap-1.5">
|
||||||
{/* <!-- Menu Item Calendar --> */}
|
{/* <!-- Menu Item Calendar --> */}
|
||||||
<li>
|
<li>
|
||||||
<NavLink
|
<NavLink
|
||||||
to='/admin/calendar'
|
to="/admin/calendar"
|
||||||
end
|
end
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
|
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
|
||||||
{
|
{
|
||||||
'bg-accent text-accent-foreground': isActive,
|
"bg-accent text-accent-foreground": isActive,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -185,21 +194,26 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
|||||||
{/* <!-- Menu Item Calendar --> */}
|
{/* <!-- Menu Item Calendar --> */}
|
||||||
|
|
||||||
{/* <!-- Menu Item Ui Elements --> */}
|
{/* <!-- Menu Item Ui Elements --> */}
|
||||||
<SidebarLinkGroup activeCondition={pathname === '/ui' || pathname.includes('ui')}>
|
<SidebarLinkGroup
|
||||||
|
activeCondition={pathname === "/ui" || pathname.includes("ui")}
|
||||||
|
>
|
||||||
{(handleClick, open) => {
|
{(handleClick, open) => {
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<NavLink
|
<NavLink
|
||||||
to='#'
|
to="#"
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
|
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
|
||||||
{
|
{
|
||||||
'bg-accent text-accent-foreground': pathname.includes('ui'),
|
"bg-accent text-accent-foreground":
|
||||||
}
|
pathname.includes("ui"),
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sidebarExpanded ? handleClick() : setSidebarExpanded(true);
|
sidebarExpanded
|
||||||
|
? handleClick()
|
||||||
|
: setSidebarExpanded(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LayoutTemplate />
|
<LayoutTemplate />
|
||||||
@@ -207,16 +221,20 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
|||||||
{open ? <ChevronUp /> : <ChevronDown />}
|
{open ? <ChevronUp /> : <ChevronDown />}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
{/* <!-- Dropdown Menu Start --> */}
|
{/* <!-- Dropdown Menu Start --> */}
|
||||||
<div className={cn('translate transform overflow-hidden', { hidden: !open })}>
|
<div
|
||||||
<ul className='mt-4 mb-5.5 flex flex-col gap-2.5 pl-6'>
|
className={cn("translate transform overflow-hidden", {
|
||||||
|
hidden: !open,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ul className="mb-5.5 mt-4 flex flex-col gap-2.5 pl-6">
|
||||||
<li>
|
<li>
|
||||||
<NavLink
|
<NavLink
|
||||||
to='/admin/ui/buttons'
|
to="/admin/ui/buttons"
|
||||||
end
|
end
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'group relative flex items-center gap-2.5 rounded-md px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:text-accent',
|
"text-muted-foreground hover:text-accent group relative flex items-center gap-2.5 rounded-md px-4 font-medium duration-300 ease-in-out",
|
||||||
{ '!text-accent': isActive }
|
{ "!text-accent": isActive },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode, useState } from 'react';
|
import { ReactNode, useState } from "react";
|
||||||
|
|
||||||
interface SidebarLinkGroupProps {
|
interface SidebarLinkGroupProps {
|
||||||
children: (handleClick: () => void, open: boolean) => ReactNode;
|
children: (handleClick: () => void, open: boolean) => ReactNode;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type DailyStats, type PageViewSource } from 'wasp/entities';
|
import { type DailyStats, type PageViewSource } from "wasp/entities";
|
||||||
import { HttpError, prisma } from 'wasp/server';
|
import { HttpError, prisma } from "wasp/server";
|
||||||
import { type GetDailyStats } from 'wasp/server/operations';
|
import { type GetDailyStats } from "wasp/server/operations";
|
||||||
|
|
||||||
type DailyStatsWithSources = DailyStats & {
|
type DailyStatsWithSources = DailyStats & {
|
||||||
sources: PageViewSource[];
|
sources: PageViewSource[];
|
||||||
@@ -11,18 +11,27 @@ type DailyStatsValues = {
|
|||||||
weeklyStats: DailyStatsWithSources[];
|
weeklyStats: DailyStatsWithSources[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDailyStats: GetDailyStats<void, DailyStatsValues | undefined> = async (_args, context) => {
|
export const getDailyStats: GetDailyStats<
|
||||||
|
void,
|
||||||
|
DailyStatsValues | undefined
|
||||||
|
> = async (_args, context) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
|
throw new HttpError(
|
||||||
|
401,
|
||||||
|
"Only authenticated users are allowed to perform this operation",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!context.user.isAdmin) {
|
if (!context.user.isAdmin) {
|
||||||
throw new HttpError(403, 'Only admins are allowed to perform this operation');
|
throw new HttpError(
|
||||||
|
403,
|
||||||
|
"Only admins are allowed to perform this operation",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statsQuery = {
|
const statsQuery = {
|
||||||
orderBy: {
|
orderBy: {
|
||||||
date: 'desc',
|
date: "desc",
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
sources: true,
|
sources: true,
|
||||||
@@ -35,7 +44,9 @@ export const getDailyStats: GetDailyStats<void, DailyStatsValues | undefined> =
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!dailyStats) {
|
if (!dailyStats) {
|
||||||
console.log('\x1b[34mNote: No daily stats have been generated by the dailyStatsJob yet. \x1b[0m');
|
console.log(
|
||||||
|
"\x1b[34mNote: No daily stats have been generated by the dailyStatsJob yet. \x1b[0m",
|
||||||
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { BetaAnalyticsDataClient } from '@google-analytics/data';
|
import { BetaAnalyticsDataClient } from "@google-analytics/data";
|
||||||
|
|
||||||
const CLIENT_EMAIL = process.env.GOOGLE_ANALYTICS_CLIENT_EMAIL;
|
const CLIENT_EMAIL = process.env.GOOGLE_ANALYTICS_CLIENT_EMAIL;
|
||||||
const PRIVATE_KEY = Buffer.from(process.env.GOOGLE_ANALYTICS_PRIVATE_KEY!, 'base64').toString('utf-8');
|
const PRIVATE_KEY = Buffer.from(
|
||||||
|
process.env.GOOGLE_ANALYTICS_PRIVATE_KEY!,
|
||||||
|
"base64",
|
||||||
|
).toString("utf-8");
|
||||||
const PROPERTY_ID = process.env.GOOGLE_ANALYTICS_PROPERTY_ID;
|
const PROPERTY_ID = process.env.GOOGLE_ANALYTICS_PROPERTY_ID;
|
||||||
|
|
||||||
const analyticsDataClient = new BetaAnalyticsDataClient({
|
const analyticsDataClient = new BetaAnalyticsDataClient({
|
||||||
@@ -16,19 +19,19 @@ export async function getSources() {
|
|||||||
property: `properties/${PROPERTY_ID}`,
|
property: `properties/${PROPERTY_ID}`,
|
||||||
dateRanges: [
|
dateRanges: [
|
||||||
{
|
{
|
||||||
startDate: '2020-01-01',
|
startDate: "2020-01-01",
|
||||||
endDate: 'today',
|
endDate: "today",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// for a list of dimensions and metrics see https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema
|
// for a list of dimensions and metrics see https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema
|
||||||
dimensions: [
|
dimensions: [
|
||||||
{
|
{
|
||||||
name: 'source',
|
name: "source",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
name: 'activeUsers',
|
name: "activeUsers",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -44,7 +47,7 @@ export async function getSources() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No response from Google Analytics');
|
throw new Error("No response from Google Analytics");
|
||||||
}
|
}
|
||||||
|
|
||||||
return activeUsersPerReferrer;
|
return activeUsersPerReferrer;
|
||||||
@@ -65,13 +68,13 @@ async function getTotalPageViews() {
|
|||||||
property: `properties/${PROPERTY_ID}`,
|
property: `properties/${PROPERTY_ID}`,
|
||||||
dateRanges: [
|
dateRanges: [
|
||||||
{
|
{
|
||||||
startDate: '2020-01-01', // go back to earliest date of your app
|
startDate: "2020-01-01", // go back to earliest date of your app
|
||||||
endDate: 'today',
|
endDate: "today",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
name: 'screenPageViews',
|
name: "screenPageViews",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -80,7 +83,7 @@ async function getTotalPageViews() {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
totalViews = parseInt(response.rows[0].metricValues[0].value);
|
totalViews = parseInt(response.rows[0].metricValues[0].value);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No response from Google Analytics');
|
throw new Error("No response from Google Analytics");
|
||||||
}
|
}
|
||||||
return totalViews;
|
return totalViews;
|
||||||
}
|
}
|
||||||
@@ -91,26 +94,26 @@ async function getPrevDayViewsChangePercent() {
|
|||||||
|
|
||||||
dateRanges: [
|
dateRanges: [
|
||||||
{
|
{
|
||||||
startDate: '2daysAgo',
|
startDate: "2daysAgo",
|
||||||
endDate: 'yesterday',
|
endDate: "yesterday",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
orderBys: [
|
orderBys: [
|
||||||
{
|
{
|
||||||
dimension: {
|
dimension: {
|
||||||
dimensionName: 'date',
|
dimensionName: "date",
|
||||||
},
|
},
|
||||||
desc: true,
|
desc: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
dimensions: [
|
dimensions: [
|
||||||
{
|
{
|
||||||
name: 'date',
|
name: "date",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
name: 'screenPageViews',
|
name: "screenPageViews",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -128,14 +131,17 @@ async function getPrevDayViewsChangePercent() {
|
|||||||
viewsFromYesterday = parseInt(viewsFromYesterday);
|
viewsFromYesterday = parseInt(viewsFromYesterday);
|
||||||
viewsFromDayBeforeYesterday = parseInt(viewsFromDayBeforeYesterday);
|
viewsFromDayBeforeYesterday = parseInt(viewsFromDayBeforeYesterday);
|
||||||
if (viewsFromYesterday === 0 || viewsFromDayBeforeYesterday === 0) {
|
if (viewsFromYesterday === 0 || viewsFromDayBeforeYesterday === 0) {
|
||||||
return '0';
|
return "0";
|
||||||
}
|
}
|
||||||
console.table({ viewsFromYesterday, viewsFromDayBeforeYesterday });
|
console.table({ viewsFromYesterday, viewsFromDayBeforeYesterday });
|
||||||
|
|
||||||
const change = ((viewsFromYesterday - viewsFromDayBeforeYesterday) / viewsFromDayBeforeYesterday) * 100;
|
const change =
|
||||||
|
((viewsFromYesterday - viewsFromDayBeforeYesterday) /
|
||||||
|
viewsFromDayBeforeYesterday) *
|
||||||
|
100;
|
||||||
return change.toFixed(0);
|
return change.toFixed(0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return '0';
|
return "0";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const PLAUSIBLE_SITE_ID = process.env.PLAUSIBLE_SITE_ID!;
|
|||||||
const PLAUSIBLE_BASE_URL = process.env.PLAUSIBLE_BASE_URL;
|
const PLAUSIBLE_BASE_URL = process.env.PLAUSIBLE_BASE_URL;
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${PLAUSIBLE_API_KEY}`,
|
Authorization: `Bearer ${PLAUSIBLE_API_KEY}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ type PageViewSourcesResult = {
|
|||||||
{
|
{
|
||||||
source: string;
|
source: string;
|
||||||
visitors: number;
|
visitors: number;
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,12 +38,12 @@ async function getTotalPageViews() {
|
|||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&metrics=pageviews`,
|
`${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&metrics=pageviews`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${PLAUSIBLE_API_KEY}`,
|
Authorization: `Bearer ${PLAUSIBLE_API_KEY}`,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
@@ -56,12 +56,19 @@ async function getTotalPageViews() {
|
|||||||
async function getPrevDayViewsChangePercent() {
|
async function getPrevDayViewsChangePercent() {
|
||||||
// Calculate today, yesterday, and the day before yesterday's dates
|
// Calculate today, yesterday, and the day before yesterday's dates
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const yesterday = new Date(today.setDate(today.getDate() - 1)).toISOString().split('T')[0];
|
const yesterday = new Date(today.setDate(today.getDate() - 1))
|
||||||
const dayBeforeYesterday = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString().split('T')[0];
|
.toISOString()
|
||||||
|
.split("T")[0];
|
||||||
|
const dayBeforeYesterday = new Date(
|
||||||
|
new Date().setDate(new Date().getDate() - 2),
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.split("T")[0];
|
||||||
|
|
||||||
// Fetch page views for yesterday and the day before yesterday
|
// Fetch page views for yesterday and the day before yesterday
|
||||||
const pageViewsYesterday = await getPageviewsForDate(yesterday);
|
const pageViewsYesterday = await getPageviewsForDate(yesterday);
|
||||||
const pageViewsDayBeforeYesterday = await getPageviewsForDate(dayBeforeYesterday);
|
const pageViewsDayBeforeYesterday =
|
||||||
|
await getPageviewsForDate(dayBeforeYesterday);
|
||||||
|
|
||||||
console.table({
|
console.table({
|
||||||
pageViewsYesterday,
|
pageViewsYesterday,
|
||||||
@@ -72,9 +79,12 @@ async function getPrevDayViewsChangePercent() {
|
|||||||
|
|
||||||
let change = 0;
|
let change = 0;
|
||||||
if (pageViewsYesterday === 0 || pageViewsDayBeforeYesterday === 0) {
|
if (pageViewsYesterday === 0 || pageViewsDayBeforeYesterday === 0) {
|
||||||
return '0';
|
return "0";
|
||||||
} else {
|
} else {
|
||||||
change = ((pageViewsYesterday - pageViewsDayBeforeYesterday) / pageViewsDayBeforeYesterday) * 100;
|
change =
|
||||||
|
((pageViewsYesterday - pageViewsDayBeforeYesterday) /
|
||||||
|
pageViewsDayBeforeYesterday) *
|
||||||
|
100;
|
||||||
}
|
}
|
||||||
return change.toFixed(0);
|
return change.toFixed(0);
|
||||||
}
|
}
|
||||||
@@ -82,7 +92,7 @@ async function getPrevDayViewsChangePercent() {
|
|||||||
async function getPageviewsForDate(date: string) {
|
async function getPageviewsForDate(date: string) {
|
||||||
const url = `${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&period=day&date=${date}&metrics=pageviews`;
|
const url = `${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&period=day&date=${date}&metrics=pageviews`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: headers,
|
headers: headers,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -95,7 +105,7 @@ async function getPageviewsForDate(date: string) {
|
|||||||
export async function getSources() {
|
export async function getSources() {
|
||||||
const url = `${PLAUSIBLE_BASE_URL}/v1/stats/breakdown?site_id=${PLAUSIBLE_SITE_ID}&property=visit:source&metrics=visitors`;
|
const url = `${PLAUSIBLE_BASE_URL}/v1/stats/breakdown?site_id=${PLAUSIBLE_SITE_ID}&property=visit:source&metrics=visitors`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: headers,
|
headers: headers,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { type DailyStats } from 'wasp/entities';
|
import { listOrders } from "@lemonsqueezy/lemonsqueezy.js";
|
||||||
import { type DailyStatsJob } from 'wasp/server/jobs';
|
import Stripe from "stripe";
|
||||||
import Stripe from 'stripe';
|
import { type DailyStats } from "wasp/entities";
|
||||||
import { stripe } from '../payment/stripe/stripeClient';
|
import { type DailyStatsJob } from "wasp/server/jobs";
|
||||||
import { listOrders } from '@lemonsqueezy/lemonsqueezy.js';
|
import { stripe } from "../payment/stripe/stripeClient";
|
||||||
import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils';
|
import {
|
||||||
|
getDailyPageViews,
|
||||||
|
getSources,
|
||||||
|
} from "./providers/plausibleAnalyticsUtils";
|
||||||
// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
|
// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
|
||||||
import { paymentProcessor } from '../payment/paymentProcessor';
|
import { paymentProcessor } from "../payment/paymentProcessor";
|
||||||
import { SubscriptionStatus } from '../payment/plans';
|
import { SubscriptionStatus } from "../payment/plans";
|
||||||
|
|
||||||
export type DailyStatsProps = { dailyStats?: DailyStats; weeklyStats?: DailyStats[]; isLoading?: boolean };
|
export type DailyStatsProps = {
|
||||||
|
dailyStats?: DailyStats;
|
||||||
|
weeklyStats?: DailyStats[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, context) => {
|
export const calculateDailyStats: DailyStatsJob<never, void> = async (
|
||||||
|
_args,
|
||||||
|
context,
|
||||||
|
) => {
|
||||||
const nowUTC = new Date(Date.now());
|
const nowUTC = new Date(Date.now());
|
||||||
nowUTC.setUTCHours(0, 0, 0, 0);
|
nowUTC.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
@@ -44,14 +54,16 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
|||||||
|
|
||||||
let totalRevenue;
|
let totalRevenue;
|
||||||
switch (paymentProcessor.id) {
|
switch (paymentProcessor.id) {
|
||||||
case 'stripe':
|
case "stripe":
|
||||||
totalRevenue = await fetchTotalStripeRevenue();
|
totalRevenue = await fetchTotalStripeRevenue();
|
||||||
break;
|
break;
|
||||||
case 'lemonsqueezy':
|
case "lemonsqueezy":
|
||||||
totalRevenue = await fetchTotalLemonSqueezyRevenue();
|
totalRevenue = await fetchTotalLemonSqueezyRevenue();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported payment processor: ${paymentProcessor.id}`);
|
throw new Error(
|
||||||
|
`Unsupported payment processor: ${paymentProcessor.id}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews();
|
const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews();
|
||||||
@@ -63,7 +75,7 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!dailyStats) {
|
if (!dailyStats) {
|
||||||
console.log('No daily stat found for today, creating one...');
|
console.log("No daily stat found for today, creating one...");
|
||||||
dailyStats = await context.entities.DailyStats.create({
|
dailyStats = await context.entities.DailyStats.create({
|
||||||
data: {
|
data: {
|
||||||
date: nowUTC,
|
date: nowUTC,
|
||||||
@@ -77,7 +89,7 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log('Daily stat found for today, updating it...');
|
console.log("Daily stat found for today, updating it...");
|
||||||
dailyStats = await context.entities.DailyStats.update({
|
dailyStats = await context.entities.DailyStats.update({
|
||||||
where: {
|
where: {
|
||||||
id: dailyStats.id,
|
id: dailyStats.id,
|
||||||
@@ -97,7 +109,7 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
|||||||
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
let visitors = source.visitors;
|
let visitors = source.visitors;
|
||||||
if (typeof source.visitors !== 'number') {
|
if (typeof source.visitors !== "number") {
|
||||||
visitors = parseInt(source.visitors);
|
visitors = parseInt(source.visitors);
|
||||||
}
|
}
|
||||||
await context.entities.PageViewSource.upsert({
|
await context.entities.PageViewSource.upsert({
|
||||||
@@ -121,11 +133,11 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
|||||||
|
|
||||||
console.table({ dailyStats });
|
console.table({ dailyStats });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error calculating daily stats: ', error);
|
console.error("Error calculating daily stats: ", error);
|
||||||
await context.entities.Logs.create({
|
await context.entities.Logs.create({
|
||||||
data: {
|
data: {
|
||||||
message: `Error calculating daily stats: ${error?.message}`,
|
message: `Error calculating daily stats: ${error?.message}`,
|
||||||
level: 'job-error',
|
level: "job-error",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -139,7 +151,7 @@ async function fetchTotalStripeRevenue() {
|
|||||||
// gte: startTimestamp,
|
// gte: startTimestamp,
|
||||||
// lt: endTimestamp
|
// lt: endTimestamp
|
||||||
// },
|
// },
|
||||||
type: 'charge',
|
type: "charge",
|
||||||
};
|
};
|
||||||
|
|
||||||
let hasMore = true;
|
let hasMore = true;
|
||||||
@@ -147,14 +159,15 @@ async function fetchTotalStripeRevenue() {
|
|||||||
const balanceTransactions = await stripe.balanceTransactions.list(params);
|
const balanceTransactions = await stripe.balanceTransactions.list(params);
|
||||||
|
|
||||||
for (const transaction of balanceTransactions.data) {
|
for (const transaction of balanceTransactions.data) {
|
||||||
if (transaction.type === 'charge') {
|
if (transaction.type === "charge") {
|
||||||
totalRevenue += transaction.amount;
|
totalRevenue += transaction.amount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (balanceTransactions.has_more) {
|
if (balanceTransactions.has_more) {
|
||||||
// Set the starting point for the next iteration to the last object fetched
|
// Set the starting point for the next iteration to the last object fetched
|
||||||
params.starting_after = balanceTransactions.data[balanceTransactions.data.length - 1].id;
|
params.starting_after =
|
||||||
|
balanceTransactions.data[balanceTransactions.data.length - 1].id;
|
||||||
} else {
|
} else {
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
}
|
}
|
||||||
@@ -194,7 +207,7 @@ async function fetchTotalLemonSqueezyRevenue() {
|
|||||||
// Revenue is in cents so we convert to dollars (or your main currency unit)
|
// Revenue is in cents so we convert to dollars (or your main currency unit)
|
||||||
return totalRevenue / 100;
|
return totalRevenue / 100;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Lemon Squeezy revenue:', error);
|
console.error("Error fetching Lemon Squeezy revenue:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
export function AuthPageLayout({children} : {children: ReactNode }) {
|
export function AuthPageLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className='flex min-h-full flex-col justify-center pt-10 sm:px-6 lg:px-8'>
|
<div className="flex min-h-full flex-col justify-center pt-10 sm:px-6 lg:px-8">
|
||||||
<div className='sm:mx-auto sm:w-full sm:max-w-md'>
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<div className='bg-white py-8 px-4 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10 dark:bg-white dark:text-gray-900'>
|
<div className="bg-white px-4 py-8 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10 dark:bg-white dark:text-gray-900">
|
||||||
<div className='-mt-8'>
|
<div className="-mt-8">{children}</div>
|
||||||
{ children }
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
import { LoginForm } from "wasp/client/auth";
|
||||||
import { LoginForm } from 'wasp/client/auth';
|
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||||
import { AuthPageLayout } from './AuthPageLayout';
|
import { AuthPageLayout } from "./AuthPageLayout";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
return (
|
return (
|
||||||
<AuthPageLayout>
|
<AuthPageLayout>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
<br />
|
<br />
|
||||||
<span className='text-sm font-medium text-gray-900 dark:text-gray-900'>
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-900">
|
||||||
Don't have an account yet?{' '}
|
Don't have an account yet?{" "}
|
||||||
<WaspRouterLink to={routes.SignupRoute.to} className='underline'>
|
<WaspRouterLink to={routes.SignupRoute.to} className="underline">
|
||||||
go to signup
|
go to signup
|
||||||
</WaspRouterLink>
|
</WaspRouterLink>
|
||||||
.
|
.
|
||||||
</span>
|
</span>
|
||||||
<br />
|
<br />
|
||||||
<span className='text-sm font-medium text-gray-900'>
|
<span className="text-sm font-medium text-gray-900">
|
||||||
Forgot your password?{' '}
|
Forgot your password?{" "}
|
||||||
<WaspRouterLink to={routes.RequestPasswordResetRoute.to} className='underline'>
|
<WaspRouterLink
|
||||||
|
to={routes.RequestPasswordResetRoute.to}
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
reset it
|
reset it
|
||||||
</WaspRouterLink>
|
</WaspRouterLink>
|
||||||
.
|
.
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
import { SignupForm } from "wasp/client/auth";
|
||||||
import { SignupForm } from 'wasp/client/auth';
|
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||||
import { AuthPageLayout } from './AuthPageLayout';
|
import { AuthPageLayout } from "./AuthPageLayout";
|
||||||
|
|
||||||
export function Signup() {
|
export function Signup() {
|
||||||
return (
|
return (
|
||||||
<AuthPageLayout>
|
<AuthPageLayout>
|
||||||
<SignupForm />
|
<SignupForm />
|
||||||
<br />
|
<br />
|
||||||
<span className='text-sm font-medium text-gray-900'>
|
<span className="text-sm font-medium text-gray-900">
|
||||||
I already have an account (
|
I already have an account (
|
||||||
<WaspRouterLink to={routes.LoginRoute.to} className='underline'>
|
<WaspRouterLink to={routes.LoginRoute.to} className="underline">
|
||||||
go to login
|
go to login
|
||||||
</WaspRouterLink>
|
</WaspRouterLink>
|
||||||
).
|
).
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
import { VerifyEmailForm } from "wasp/client/auth";
|
||||||
import { VerifyEmailForm } from 'wasp/client/auth';
|
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||||
import { AuthPageLayout } from '../AuthPageLayout';
|
import { AuthPageLayout } from "../AuthPageLayout";
|
||||||
|
|
||||||
export function EmailVerificationPage() {
|
export function EmailVerificationPage() {
|
||||||
return (
|
return (
|
||||||
<AuthPageLayout>
|
<AuthPageLayout>
|
||||||
<VerifyEmailForm />
|
<VerifyEmailForm />
|
||||||
<br />
|
<br />
|
||||||
<span className='text-sm font-medium text-gray-900'>
|
<span className="text-sm font-medium text-gray-900">
|
||||||
If everything is okay, <WaspRouterLink to={routes.LoginRoute.to} className='underline'>go to login</WaspRouterLink>
|
If everything is okay,{" "}
|
||||||
|
<WaspRouterLink to={routes.LoginRoute.to} className="underline">
|
||||||
|
go to login
|
||||||
|
</WaspRouterLink>
|
||||||
</span>
|
</span>
|
||||||
</AuthPageLayout>
|
</AuthPageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
import { ResetPasswordForm } from "wasp/client/auth";
|
||||||
import { ResetPasswordForm } from 'wasp/client/auth';
|
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||||
import { AuthPageLayout } from '../AuthPageLayout';
|
import { AuthPageLayout } from "../AuthPageLayout";
|
||||||
|
|
||||||
export function PasswordResetPage() {
|
export function PasswordResetPage() {
|
||||||
return (
|
return (
|
||||||
<AuthPageLayout>
|
<AuthPageLayout>
|
||||||
<ResetPasswordForm />
|
<ResetPasswordForm />
|
||||||
<br />
|
<br />
|
||||||
<span className='text-sm font-medium text-gray-900'>
|
<span className="text-sm font-medium text-gray-900">
|
||||||
If everything is okay, <WaspRouterLink to={routes.LoginRoute.to}>go to login</WaspRouterLink>
|
If everything is okay,{" "}
|
||||||
|
<WaspRouterLink to={routes.LoginRoute.to}>go to login</WaspRouterLink>
|
||||||
</span>
|
</span>
|
||||||
</AuthPageLayout>
|
</AuthPageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ForgotPasswordForm } from 'wasp/client/auth';
|
import { ForgotPasswordForm } from "wasp/client/auth";
|
||||||
import { AuthPageLayout } from '../AuthPageLayout';
|
import { AuthPageLayout } from "../AuthPageLayout";
|
||||||
|
|
||||||
export function RequestPasswordResetPage() {
|
export function RequestPasswordResetPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { type GetVerificationEmailContentFn, type GetPasswordResetEmailContentFn } from 'wasp/server/auth';
|
import {
|
||||||
|
type GetPasswordResetEmailContentFn,
|
||||||
|
type GetVerificationEmailContentFn,
|
||||||
|
} from "wasp/server/auth";
|
||||||
|
|
||||||
export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ verificationLink }) => ({
|
export const getVerificationEmailContent: GetVerificationEmailContentFn = ({
|
||||||
subject: 'Verify your email',
|
verificationLink,
|
||||||
|
}) => ({
|
||||||
|
subject: "Verify your email",
|
||||||
text: `Click the link below to verify your email: ${verificationLink}`,
|
text: `Click the link below to verify your email: ${verificationLink}`,
|
||||||
html: `
|
html: `
|
||||||
<p>Click the link below to verify your email</p>
|
<p>Click the link below to verify your email</p>
|
||||||
@@ -9,8 +14,10 @@ export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ ver
|
|||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ passwordResetLink }) => ({
|
export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({
|
||||||
subject: 'Password reset',
|
passwordResetLink,
|
||||||
|
}) => ({
|
||||||
|
subject: "Password reset",
|
||||||
text: `Click the link below to reset your password: ${passwordResetLink}`,
|
text: `Click the link below to reset your password: ${passwordResetLink}`,
|
||||||
html: `
|
html: `
|
||||||
<p>Click the link below to reset your password</p>
|
<p>Click the link below to reset your password</p>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { defineUserSignupFields } from "wasp/auth/providers/types";
|
||||||
import { defineUserSignupFields } from 'wasp/auth/providers/types';
|
import { z } from "zod";
|
||||||
|
|
||||||
const adminEmails = process.env.ADMIN_EMAILS?.split(',') || [];
|
const adminEmails = process.env.ADMIN_EMAILS?.split(",") || [];
|
||||||
|
|
||||||
const emailDataSchema = z.object({
|
const emailDataSchema = z.object({
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
@@ -29,9 +29,12 @@ const githubDataSchema = z.object({
|
|||||||
z.object({
|
z.object({
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
verified: z.boolean(),
|
verified: z.boolean(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.min(1, 'You need to have an email address associated with your GitHub account to sign up.'),
|
.min(
|
||||||
|
1,
|
||||||
|
"You need to have an email address associated with your GitHub account to sign up.",
|
||||||
|
),
|
||||||
login: z.string(),
|
login: z.string(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -65,7 +68,7 @@ function getGithubEmailInfo(githubData: z.infer<typeof githubDataSchema>) {
|
|||||||
// instead of ["user"] and access args.profile.username instead
|
// instead of ["user"] and access args.profile.username instead
|
||||||
export function getGitHubAuthConfig() {
|
export function getGitHubAuthConfig() {
|
||||||
return {
|
return {
|
||||||
scopes: ['user'],
|
scopes: ["user"],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +99,7 @@ export const getGoogleUserFields = defineUserSignupFields({
|
|||||||
|
|
||||||
export function getGoogleAuthConfig() {
|
export function getGoogleAuthConfig() {
|
||||||
return {
|
return {
|
||||||
scopes: ['profile', 'email'], // must include at least 'profile' for Google
|
scopes: ["profile", "email"], // must include at least 'profile' for Google
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +116,9 @@ export const getDiscordUserFields = defineUserSignupFields({
|
|||||||
const discordData = discordDataSchema.parse(data);
|
const discordData = discordDataSchema.parse(data);
|
||||||
// Users need to have an email for payment processing.
|
// Users need to have an email for payment processing.
|
||||||
if (!discordData.profile.email) {
|
if (!discordData.profile.email) {
|
||||||
throw new Error('You need to have an email address associated with your Discord account to sign up.');
|
throw new Error(
|
||||||
|
"You need to have an email address associated with your Discord account to sign up.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return discordData.profile.email;
|
return discordData.profile.email;
|
||||||
},
|
},
|
||||||
@@ -132,6 +137,6 @@ export const getDiscordUserFields = defineUserSignupFields({
|
|||||||
|
|
||||||
export function getDiscordAuthConfig() {
|
export function getDiscordAuthConfig() {
|
||||||
return {
|
return {
|
||||||
scopes: ['identify', 'email'],
|
scopes: ["identify", "email"],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from "react";
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation } from "react-router-dom";
|
||||||
import { routes } from 'wasp/client/router';
|
import { routes } from "wasp/client/router";
|
||||||
import './Main.css';
|
import "./Main.css";
|
||||||
import NavBar from './components/NavBar/NavBar';
|
import NavBar from "./components/NavBar/NavBar";
|
||||||
import { demoNavigationitems, marketingNavigationItems } from './components/NavBar/constants';
|
import {
|
||||||
import CookieConsentBanner from './components/cookie-consent/Banner';
|
demoNavigationitems,
|
||||||
|
marketingNavigationItems,
|
||||||
|
} from "./components/NavBar/constants";
|
||||||
|
import CookieConsentBanner from "./components/cookie-consent/Banner";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* use this component to wrap all child components
|
* use this component to wrap all child components
|
||||||
@@ -13,24 +16,29 @@ import CookieConsentBanner from './components/cookie-consent/Banner';
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isMarketingPage = useMemo(() => {
|
const isMarketingPage = useMemo(() => {
|
||||||
return location.pathname === '/' || location.pathname.startsWith('/pricing');
|
return (
|
||||||
|
location.pathname === "/" || location.pathname.startsWith("/pricing")
|
||||||
|
);
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
const navigationItems = isMarketingPage ? marketingNavigationItems : demoNavigationitems;
|
const navigationItems = isMarketingPage
|
||||||
|
? marketingNavigationItems
|
||||||
|
: demoNavigationitems;
|
||||||
|
|
||||||
const shouldDisplayAppNavBar = useMemo(() => {
|
const shouldDisplayAppNavBar = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
location.pathname !== routes.LoginRoute.build() && location.pathname !== routes.SignupRoute.build()
|
location.pathname !== routes.LoginRoute.build() &&
|
||||||
|
location.pathname !== routes.SignupRoute.build()
|
||||||
);
|
);
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
const isAdminDashboard = useMemo(() => {
|
const isAdminDashboard = useMemo(() => {
|
||||||
return location.pathname.startsWith('/admin');
|
return location.pathname.startsWith("/admin");
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.hash) {
|
if (location.hash) {
|
||||||
const id = location.hash.replace('#', '');
|
const id = location.hash.replace("#", "");
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
if (element) {
|
if (element) {
|
||||||
element.scrollIntoView();
|
element.scrollIntoView();
|
||||||
@@ -40,13 +48,15 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='min-h-screen bg-background text-foreground'>
|
<div className="bg-background text-foreground min-h-screen">
|
||||||
{isAdminDashboard ? (
|
{isAdminDashboard ? (
|
||||||
<Outlet />
|
<Outlet />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{shouldDisplayAppNavBar && <NavBar navigationItems={navigationItems} />}
|
{shouldDisplayAppNavBar && (
|
||||||
<div className='mx-auto max-w-screen-2xl'>
|
<NavBar navigationItems={navigationItems} />
|
||||||
|
)}
|
||||||
|
<div className="mx-auto max-w-screen-2xl">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -22,14 +22,22 @@
|
|||||||
|
|
||||||
/* Text gradient utilities */
|
/* Text gradient utilities */
|
||||||
.text-gradient-primary {
|
.text-gradient-primary {
|
||||||
background: linear-gradient(to right, hsl(var(--secondary-muted)), hsl(var(--secondary)));
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
hsl(var(--secondary-muted)),
|
||||||
|
hsl(var(--secondary))
|
||||||
|
);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-gradient-primary-diagonal {
|
.text-gradient-primary-diagonal {
|
||||||
background: linear-gradient(135deg, hsl(var(--secondary-muted)), hsl(var(--secondary)));
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
hsl(var(--secondary-muted)),
|
||||||
|
hsl(var(--secondary))
|
||||||
|
);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
@@ -37,7 +45,11 @@
|
|||||||
|
|
||||||
/* Border gradient utilities */
|
/* Border gradient utilities */
|
||||||
.border-gradient-primary {
|
.border-gradient-primary {
|
||||||
background: linear-gradient(to right, hsl(var(--secondary-muted)), hsl(var(--secondary)));
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
hsl(var(--secondary-muted)),
|
||||||
|
hsl(var(--secondary))
|
||||||
|
);
|
||||||
padding: 1px;
|
padding: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +64,8 @@
|
|||||||
* under `theme.extend.fontFamily`, and then can be used as a tailwind class, e.g. className='font-satoshi'.
|
* under `theme.extend.fontFamily`, and then can be used as a tailwind class, e.g. className='font-satoshi'.
|
||||||
*/
|
*/
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: "Satoshi";
|
||||||
src: url('/fonts/Satoshi-Regular.woff2') format('woff2');
|
src: url("/fonts/Satoshi-Regular.woff2") format("woff2");
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
import { Moon, Sun } from 'lucide-react';
|
import { Moon, Sun } from "lucide-react";
|
||||||
import { Label } from '../../components/ui/label';
|
import { Label } from "../../components/ui/label";
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from "../../lib/utils";
|
||||||
import useColorMode from '../hooks/useColorMode';
|
import useColorMode from "../hooks/useColorMode";
|
||||||
|
|
||||||
const DarkModeSwitcher = () => {
|
const DarkModeSwitcher = () => {
|
||||||
const [colorMode, setColorMode] = useColorMode();
|
const [colorMode, setColorMode] = useColorMode();
|
||||||
const isInLightMode = colorMode === 'light';
|
const isInLightMode = colorMode === "light";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Label
|
<Label
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative m-0 block h-7.5 w-14 rounded-full transition-colors duration-300 ease-in-out cursor-pointer bg-muted'
|
"h-7.5 bg-muted relative m-0 block w-14 cursor-pointer rounded-full transition-colors duration-300 ease-in-out",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type='checkbox'
|
type="checkbox"
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
if (typeof setColorMode === 'function') {
|
if (typeof setColorMode === "function") {
|
||||||
setColorMode(isInLightMode ? 'dark' : 'light');
|
setColorMode(isInLightMode ? "dark" : "light");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className='absolute top-0 z-50 m-0 h-full w-full cursor-pointer opacity-0'
|
className="absolute top-0 z-50 m-0 h-full w-full cursor-pointer opacity-0"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-1/2 left-[3px] flex h-6 w-6 -translate-y-1/2 translate-x-0 items-center justify-center rounded-full bg-white shadow-md border border-border transition-all duration-300 ease-in-out',
|
"border-border absolute left-[3px] top-1/2 flex h-6 w-6 -translate-y-1/2 translate-x-0 items-center justify-center rounded-full border bg-white shadow-md transition-all duration-300 ease-in-out",
|
||||||
{
|
{
|
||||||
'!right-[3px] !translate-x-full': !isInLightMode,
|
"!right-[3px] !translate-x-full": !isInLightMode,
|
||||||
}
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ModeIcon isInLightMode={isInLightMode} />
|
<ModeIcon isInLightMode={isInLightMode} />
|
||||||
@@ -40,14 +40,18 @@ const DarkModeSwitcher = () => {
|
|||||||
|
|
||||||
function ModeIcon({ isInLightMode }: { isInLightMode: boolean }) {
|
function ModeIcon({ isInLightMode }: { isInLightMode: boolean }) {
|
||||||
const iconStyle =
|
const iconStyle =
|
||||||
'absolute inset-0 flex items-center justify-center transition-opacity ease-in-out duration-300';
|
"absolute inset-0 flex items-center justify-center transition-opacity ease-in-out duration-300";
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className={cn(iconStyle, isInLightMode ? 'opacity-100' : 'opacity-0')}>
|
<span
|
||||||
<Sun className='size-4 stroke-amber-500 fill-amber-500' />
|
className={cn(iconStyle, isInLightMode ? "opacity-100" : "opacity-0")}
|
||||||
|
>
|
||||||
|
<Sun className="size-4 fill-amber-500 stroke-amber-500" />
|
||||||
</span>
|
</span>
|
||||||
<span className={cn(iconStyle, !isInLightMode ? 'opacity-100' : 'opacity-0')}>
|
<span
|
||||||
<Moon className='size-4 stroke-slate-600 fill-slate-600' />
|
className={cn(iconStyle, !isInLightMode ? "opacity-100" : "opacity-0")}
|
||||||
|
>
|
||||||
|
<Moon className="size-4 fill-slate-600 stroke-slate-600" />
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
const ANNOUNCEMENT_URL = 'https://github.com/wasp-lang/wasp';
|
const ANNOUNCEMENT_URL = "https://github.com/wasp-lang/wasp";
|
||||||
|
|
||||||
export function Announcement() {
|
export function Announcement() {
|
||||||
return (
|
return (
|
||||||
<div className='relative flex justify-center items-center gap-3 p-3 w-full bg-gradient-to-r from-accent to-secondary font-semibold text-primary-foreground text-center'>
|
<div className="from-accent to-secondary text-primary-foreground relative flex w-full items-center justify-center gap-3 bg-gradient-to-r p-3 text-center font-semibold">
|
||||||
<a
|
<a
|
||||||
href={ANNOUNCEMENT_URL}
|
href={ANNOUNCEMENT_URL}
|
||||||
target='_blank'
|
target="_blank"
|
||||||
rel='noopener noreferrer'
|
rel="noopener noreferrer"
|
||||||
className='hidden lg:block cursor-pointer hover:opacity-90 hover:drop-shadow transition-opacity'
|
className="hidden cursor-pointer transition-opacity hover:opacity-90 hover:drop-shadow lg:block"
|
||||||
>
|
>
|
||||||
Support Open-Source Software!
|
Support Open-Source Software!
|
||||||
</a>
|
</a>
|
||||||
<div className='hidden lg:block self-stretch w-0.5 bg-primary-foreground/20'></div>
|
<div className="bg-primary-foreground/20 hidden w-0.5 self-stretch lg:block"></div>
|
||||||
<a
|
<a
|
||||||
href={ANNOUNCEMENT_URL}
|
href={ANNOUNCEMENT_URL}
|
||||||
target='_blank'
|
target="_blank"
|
||||||
rel='noopener noreferrer'
|
rel="noopener noreferrer"
|
||||||
className='hidden lg:block cursor-pointer rounded-full bg-background/20 px-2.5 py-1 text-xs hover:bg-background/30 transition-colors tracking-wider'
|
className="bg-background/20 hover:bg-background/30 hidden cursor-pointer rounded-full px-2.5 py-1 text-xs tracking-wider transition-colors lg:block"
|
||||||
>
|
>
|
||||||
Star Our Repo on Github ⭐️ →
|
Star Our Repo on Github ⭐️ →
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={ANNOUNCEMENT_URL}
|
href={ANNOUNCEMENT_URL}
|
||||||
target='_blank'
|
target="_blank"
|
||||||
rel='noopener noreferrer'
|
rel="noopener noreferrer"
|
||||||
className='lg:hidden cursor-pointer rounded-full bg-background/20 px-2.5 py-1 text-xs hover:bg-background/30 transition-colors'
|
className="bg-background/20 hover:bg-background/30 cursor-pointer rounded-full px-2.5 py-1 text-xs transition-colors lg:hidden"
|
||||||
>
|
>
|
||||||
⭐️ Star the Our Repo and Support Open-Source! ⭐️
|
⭐️ Star the Our Repo and Support Open-Source! ⭐️
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
import { LogIn, Menu } from 'lucide-react';
|
import { LogIn, Menu } from "lucide-react";
|
||||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||||
import { Link as ReactRouterLink } from 'react-router-dom';
|
import { Link as ReactRouterLink } from "react-router-dom";
|
||||||
import { useAuth } from 'wasp/client/auth';
|
import { useAuth } from "wasp/client/auth";
|
||||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../../../components/ui/sheet';
|
import {
|
||||||
import { cn } from '../../../lib/utils';
|
Sheet,
|
||||||
import { throttleWithTrailingInvocation } from '../../../shared/utils';
|
SheetContent,
|
||||||
import { UserDropdown } from '../../../user/UserDropdown';
|
SheetHeader,
|
||||||
import { UserMenuItems } from '../../../user/UserMenuItems';
|
SheetTitle,
|
||||||
import { useIsLandingPage } from '../../hooks/useIsLandingPage';
|
SheetTrigger,
|
||||||
import logo from '../../static/logo.webp';
|
} from "../../../components/ui/sheet";
|
||||||
import DarkModeSwitcher from '../DarkModeSwitcher';
|
import { cn } from "../../../lib/utils";
|
||||||
import { Announcement } from './Announcement';
|
import { throttleWithTrailingInvocation } from "../../../shared/utils";
|
||||||
|
import { UserDropdown } from "../../../user/UserDropdown";
|
||||||
|
import { UserMenuItems } from "../../../user/UserMenuItems";
|
||||||
|
import { useIsLandingPage } from "../../hooks/useIsLandingPage";
|
||||||
|
import logo from "../../static/logo.webp";
|
||||||
|
import DarkModeSwitcher from "../DarkModeSwitcher";
|
||||||
|
import { Announcement } from "./Announcement";
|
||||||
|
|
||||||
export interface NavigationItem {
|
export interface NavigationItem {
|
||||||
name: string;
|
name: string;
|
||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NavBar({ navigationItems }: { navigationItems: NavigationItem[] }) {
|
export default function NavBar({
|
||||||
|
navigationItems,
|
||||||
|
}: {
|
||||||
|
navigationItems: NavigationItem[];
|
||||||
|
}) {
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const isLandingPage = useIsLandingPage();
|
const isLandingPage = useIsLandingPage();
|
||||||
|
|
||||||
@@ -27,10 +37,10 @@ export default function NavBar({ navigationItems }: { navigationItems: Navigatio
|
|||||||
setIsScrolled(window.scrollY > 0);
|
setIsScrolled(window.scrollY > 0);
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
window.addEventListener('scroll', throttledHandler);
|
window.addEventListener("scroll", throttledHandler);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('scroll', throttledHandler);
|
window.removeEventListener("scroll", throttledHandler);
|
||||||
throttledHandler.cancel();
|
throttledHandler.cancel();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -38,42 +48,57 @@ export default function NavBar({ navigationItems }: { navigationItems: Navigatio
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isLandingPage && <Announcement />}
|
{isLandingPage && <Announcement />}
|
||||||
<header className={cn('sticky top-0 z-50 transition-all duration-300', isScrolled && 'top-4')}>
|
<header
|
||||||
|
className={cn(
|
||||||
|
"sticky top-0 z-50 transition-all duration-300",
|
||||||
|
isScrolled && "top-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn('transition-all duration-300', {
|
className={cn("transition-all duration-300", {
|
||||||
'mx-4 md:mx-20 pr-2 lg:pr-0 rounded-full shadow-lg bg-background/90 backdrop-blur-lg border border-border':
|
"bg-background/90 border-border mx-4 rounded-full border pr-2 shadow-lg backdrop-blur-lg md:mx-20 lg:pr-0":
|
||||||
isScrolled,
|
isScrolled,
|
||||||
'mx-0 bg-background/80 backdrop-blur-lg border-b border-border': !isScrolled,
|
"bg-background/80 border-border mx-0 border-b backdrop-blur-lg":
|
||||||
|
!isScrolled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<nav
|
<nav
|
||||||
className={cn('flex items-center justify-between transition-all duration-300', {
|
className={cn(
|
||||||
'p-3 lg:px-6': isScrolled,
|
"flex items-center justify-between transition-all duration-300",
|
||||||
'p-6 lg:px-8': !isScrolled,
|
{
|
||||||
})}
|
"p-3 lg:px-6": isScrolled,
|
||||||
aria-label='Global'
|
"p-6 lg:px-8": !isScrolled,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
aria-label="Global"
|
||||||
>
|
>
|
||||||
<div className='flex items-center gap-6'>
|
<div className="flex items-center gap-6">
|
||||||
<WaspRouterLink
|
<WaspRouterLink
|
||||||
to={routes.LandingPageRoute.to}
|
to={routes.LandingPageRoute.to}
|
||||||
className='flex items-center text-foreground duration-300 ease-in-out hover:text-primary transition-colors'
|
className="text-foreground hover:text-primary flex items-center transition-colors duration-300 ease-in-out"
|
||||||
>
|
>
|
||||||
<NavLogo isScrolled={isScrolled} />
|
<NavLogo isScrolled={isScrolled} />
|
||||||
<span
|
<span
|
||||||
className={cn('font-semibold leading-6 text-foreground transition-all duration-300', {
|
className={cn(
|
||||||
'ml-2 text-sm': !isScrolled,
|
"text-foreground font-semibold leading-6 transition-all duration-300",
|
||||||
'ml-2 text-xs': isScrolled,
|
{
|
||||||
})}
|
"ml-2 text-sm": !isScrolled,
|
||||||
|
"ml-2 text-xs": isScrolled,
|
||||||
|
},
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Your SaaS
|
Your SaaS
|
||||||
</span>
|
</span>
|
||||||
</WaspRouterLink>
|
</WaspRouterLink>
|
||||||
|
|
||||||
<ul className='hidden lg:flex items-center gap-6 ml-4'>
|
<ul className="ml-4 hidden items-center gap-6 lg:flex">
|
||||||
{renderNavigationItems(navigationItems)}
|
{renderNavigationItems(navigationItems)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<NavBarMobileMenu isScrolled={isScrolled} navigationItems={navigationItems} />
|
<NavBarMobileMenu
|
||||||
|
isScrolled={isScrolled}
|
||||||
|
navigationItems={navigationItems}
|
||||||
|
/>
|
||||||
<NavBarDesktopUserDropdown isScrolled={isScrolled} />
|
<NavBarDesktopUserDropdown isScrolled={isScrolled} />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,31 +111,34 @@ function NavBarDesktopUserDropdown({ isScrolled }: { isScrolled: boolean }) {
|
|||||||
const { data: user, isLoading: isUserLoading } = useAuth();
|
const { data: user, isLoading: isUserLoading } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='hidden lg:flex lg:flex-1 gap-3 justify-end items-center'>
|
<div className="hidden items-center justify-end gap-3 lg:flex lg:flex-1">
|
||||||
<ul className='flex justify-center items-center gap-2 sm:gap-4'>
|
<ul className="flex items-center justify-center gap-2 sm:gap-4">
|
||||||
<DarkModeSwitcher />
|
<DarkModeSwitcher />
|
||||||
</ul>
|
</ul>
|
||||||
{isUserLoading ? null : !user ? (
|
{isUserLoading ? null : !user ? (
|
||||||
<WaspRouterLink
|
<WaspRouterLink
|
||||||
to={routes.LoginRoute.to}
|
to={routes.LoginRoute.to}
|
||||||
className={cn('font-semibold leading-6 ml-3 transition-all duration-300', {
|
className={cn(
|
||||||
'text-sm': !isScrolled,
|
"ml-3 font-semibold leading-6 transition-all duration-300",
|
||||||
'text-xs': isScrolled,
|
{
|
||||||
})}
|
"text-sm": !isScrolled,
|
||||||
|
"text-xs": isScrolled,
|
||||||
|
},
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className='flex items-center duration-300 ease-in-out text-foreground hover:text-primary transition-colors'>
|
<div className="text-foreground hover:text-primary flex items-center transition-colors duration-300 ease-in-out">
|
||||||
Log in{' '}
|
Log in{" "}
|
||||||
<LogIn
|
<LogIn
|
||||||
size={isScrolled ? '1rem' : '1.1rem'}
|
size={isScrolled ? "1rem" : "1.1rem"}
|
||||||
className={cn('transition-all duration-300', {
|
className={cn("transition-all duration-300", {
|
||||||
'ml-1 mt-[0.1rem]': !isScrolled,
|
"ml-1 mt-[0.1rem]": !isScrolled,
|
||||||
'ml-1': isScrolled,
|
"ml-1": isScrolled,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</WaspRouterLink>
|
</WaspRouterLink>
|
||||||
) : (
|
) : (
|
||||||
<div className='ml-3'>
|
<div className="ml-3">
|
||||||
<UserDropdown user={user} />
|
<UserDropdown user={user} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -129,51 +157,56 @@ function NavBarMobileMenu({
|
|||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex lg:hidden'>
|
<div className="flex lg:hidden">
|
||||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-muted hover:bg-accent transition-colors'
|
"text-muted-foreground hover:text-muted hover:bg-accent inline-flex items-center justify-center rounded-md transition-colors",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className='sr-only'>Open main menu</span>
|
<span className="sr-only">Open main menu</span>
|
||||||
<Menu
|
<Menu
|
||||||
className={cn('transition-all duration-300', {
|
className={cn("transition-all duration-300", {
|
||||||
'size-8 p-1': !isScrolled,
|
"size-8 p-1": !isScrolled,
|
||||||
'size-6 p-0.5': isScrolled,
|
"size-6 p-0.5": isScrolled,
|
||||||
})}
|
})}
|
||||||
aria-hidden='true'
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side='right' className='w-[300px] sm:w-[400px]'>
|
<SheetContent side="right" className="w-[300px] sm:w-[400px]">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle className='flex items-center'>
|
<SheetTitle className="flex items-center">
|
||||||
<WaspRouterLink to={routes.LandingPageRoute.to}>
|
<WaspRouterLink to={routes.LandingPageRoute.to}>
|
||||||
<span className='sr-only'>Your SaaS</span>
|
<span className="sr-only">Your SaaS</span>
|
||||||
<NavLogo isScrolled={false} />
|
<NavLogo isScrolled={false} />
|
||||||
</WaspRouterLink>
|
</WaspRouterLink>
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className='mt-6 flow-root'>
|
<div className="mt-6 flow-root">
|
||||||
<div className='-my-6 divide-y divide-border'>
|
<div className="divide-border -my-6 divide-y">
|
||||||
<ul className='space-y-2 py-6'>{renderNavigationItems(navigationItems, setMobileMenuOpen)}</ul>
|
<ul className="space-y-2 py-6">
|
||||||
<div className='py-6'>
|
{renderNavigationItems(navigationItems, setMobileMenuOpen)}
|
||||||
|
</ul>
|
||||||
|
<div className="py-6">
|
||||||
{isUserLoading ? null : !user ? (
|
{isUserLoading ? null : !user ? (
|
||||||
<WaspRouterLink to={routes.LoginRoute.to}>
|
<WaspRouterLink to={routes.LoginRoute.to}>
|
||||||
<div className='flex justify-end items-center duration-300 ease-in-out text-foreground hover:text-primary transition-colors'>
|
<div className="text-foreground hover:text-primary flex items-center justify-end transition-colors duration-300 ease-in-out">
|
||||||
Log in <LogIn size='1.1rem' className='ml-1' />
|
Log in <LogIn size="1.1rem" className="ml-1" />
|
||||||
</div>
|
</div>
|
||||||
</WaspRouterLink>
|
</WaspRouterLink>
|
||||||
) : (
|
) : (
|
||||||
<ul className='space-y-2'>
|
<ul className="space-y-2">
|
||||||
<UserMenuItems user={user} onItemClick={() => setMobileMenuOpen(false)} />
|
<UserMenuItems
|
||||||
|
user={user}
|
||||||
|
onItemClick={() => setMobileMenuOpen(false)}
|
||||||
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='py-6'>
|
<div className="py-6">
|
||||||
<DarkModeSwitcher />
|
<DarkModeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,12 +219,12 @@ function NavBarMobileMenu({
|
|||||||
|
|
||||||
function renderNavigationItems(
|
function renderNavigationItems(
|
||||||
navigationItems: NavigationItem[],
|
navigationItems: NavigationItem[],
|
||||||
setMobileMenuOpen?: Dispatch<SetStateAction<boolean>>
|
setMobileMenuOpen?: Dispatch<SetStateAction<boolean>>,
|
||||||
) {
|
) {
|
||||||
const menuStyles = cn({
|
const menuStyles = cn({
|
||||||
'block rounded-lg px-3 py-2 text-sm font-medium leading-7 text-foreground hover:bg-accent hover:text-accent-foreground transition-colors':
|
"block rounded-lg px-3 py-2 text-sm font-medium leading-7 text-foreground hover:bg-accent hover:text-accent-foreground transition-colors":
|
||||||
!!setMobileMenuOpen,
|
!!setMobileMenuOpen,
|
||||||
'text-sm font-normal leading-6 text-foreground duration-300 ease-in-out hover:text-primary transition-colors':
|
"text-sm font-normal leading-6 text-foreground duration-300 ease-in-out hover:text-primary transition-colors":
|
||||||
!setMobileMenuOpen,
|
!setMobileMenuOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,7 +235,7 @@ function renderNavigationItems(
|
|||||||
to={item.to}
|
to={item.to}
|
||||||
className={menuStyles}
|
className={menuStyles}
|
||||||
onClick={setMobileMenuOpen && (() => setMobileMenuOpen(false))}
|
onClick={setMobileMenuOpen && (() => setMobileMenuOpen(false))}
|
||||||
target={item.to.startsWith('http') ? '_blank' : undefined}
|
target={item.to.startsWith("http") ? "_blank" : undefined}
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</ReactRouterLink>
|
</ReactRouterLink>
|
||||||
@@ -213,11 +246,11 @@ function renderNavigationItems(
|
|||||||
|
|
||||||
const NavLogo = ({ isScrolled }: { isScrolled: boolean }) => (
|
const NavLogo = ({ isScrolled }: { isScrolled: boolean }) => (
|
||||||
<img
|
<img
|
||||||
className={cn('transition-all duration-500', {
|
className={cn("transition-all duration-500", {
|
||||||
'size-8': !isScrolled,
|
"size-8": !isScrolled,
|
||||||
'size-7': isScrolled,
|
"size-7": isScrolled,
|
||||||
})}
|
})}
|
||||||
src={logo}
|
src={logo}
|
||||||
alt='Your SaaS App'
|
alt="Your SaaS App"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { routes } from 'wasp/client/router';
|
import { routes } from "wasp/client/router";
|
||||||
import { BlogUrl, DocsUrl } from '../../../shared/common';
|
import { BlogUrl, DocsUrl } from "../../../shared/common";
|
||||||
import type { NavigationItem } from './NavBar';
|
import type { NavigationItem } from "./NavBar";
|
||||||
|
|
||||||
const staticNavigationItems: NavigationItem[] = [
|
const staticNavigationItems: NavigationItem[] = [
|
||||||
{ name: 'Documentation', to: DocsUrl },
|
{ name: "Documentation", to: DocsUrl },
|
||||||
{ name: 'Blog', to: BlogUrl },
|
{ name: "Blog", to: BlogUrl },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const marketingNavigationItems: NavigationItem[] = [
|
export const marketingNavigationItems: NavigationItem[] = [
|
||||||
{ name: 'Features', to: '/#features' },
|
{ name: "Features", to: "/#features" },
|
||||||
{ name: 'Pricing', to: routes.PricingPageRoute.to },
|
{ name: "Pricing", to: routes.PricingPageRoute.to },
|
||||||
...staticNavigationItems,
|
...staticNavigationItems,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const demoNavigationitems: NavigationItem[] = [
|
export const demoNavigationitems: NavigationItem[] = [
|
||||||
{ name: 'AI Scheduler', to: routes.DemoAppRoute.to },
|
{ name: "AI Scheduler", to: routes.DemoAppRoute.to },
|
||||||
{ name: 'File Upload', to: routes.FileUploadRoute.to },
|
{ name: "File Upload", to: routes.FileUploadRoute.to },
|
||||||
{ name: 'Pricing', to: routes.PricingPageRoute.to },
|
{ name: "Pricing", to: routes.PricingPageRoute.to },
|
||||||
...staticNavigationItems,
|
...staticNavigationItems,
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { useAuth } from 'wasp/client/auth';
|
import { useAuth } from "wasp/client/auth";
|
||||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||||
|
|
||||||
export function NotFoundPage() {
|
export function NotFoundPage() {
|
||||||
const { data: user } = useAuth();
|
const { data: user } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center justify-center min-h-screen'>
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<div className='text-center'>
|
<div className="text-center">
|
||||||
<h1 className='text-6xl font-bold mb-4'>404</h1>
|
<h1 className="mb-4 text-6xl font-bold">404</h1>
|
||||||
<p className='text-lg text-bodydark mb-8'>Oops! The page you're looking for doesn't exist.</p>
|
<p className="text-bodydark mb-8 text-lg">
|
||||||
|
Oops! The page you're looking for doesn't exist.
|
||||||
|
</p>
|
||||||
<WaspRouterLink
|
<WaspRouterLink
|
||||||
to={user ? routes.DemoAppRoute.to : routes.LandingPageRoute.to}
|
to={user ? routes.DemoAppRoute.to : routes.LandingPageRoute.to}
|
||||||
className='inline-block px-8 py-3 text-accent-foreground font-semibold bg-accent rounded-lg hover:bg-accent/90 transition duration-300'
|
className="text-accent-foreground bg-accent hover:bg-accent/90 inline-block rounded-lg px-8 py-3 font-semibold transition duration-300"
|
||||||
>
|
>
|
||||||
Go Back Home
|
Go Back Home
|
||||||
</WaspRouterLink>
|
</WaspRouterLink>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from "react";
|
||||||
import * as CookieConsent from 'vanilla-cookieconsent';
|
import * as CookieConsent from "vanilla-cookieconsent";
|
||||||
import 'vanilla-cookieconsent/dist/cookieconsent.css';
|
import "vanilla-cookieconsent/dist/cookieconsent.css";
|
||||||
import getConfig from './Config';
|
import getConfig from "./Config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NOTE: if you do not want to use the cookie consent banner, you should
|
* NOTE: if you do not want to use the cookie consent banner, you should
|
||||||
@@ -13,7 +13,7 @@ const CookieConsentBanner = () => {
|
|||||||
CookieConsent.run(getConfig());
|
CookieConsent.run(getConfig());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <div id='cookieconsent'></div>;
|
return <div id="cookieconsent"></div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CookieConsentBanner;
|
export default CookieConsentBanner;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CookieConsentConfig } from 'vanilla-cookieconsent';
|
import type { CookieConsentConfig } from "vanilla-cookieconsent";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -10,26 +10,26 @@ const getConfig = () => {
|
|||||||
// See https://cookieconsent.orestbida.com/reference/configuration-reference.html for configuration options.
|
// See https://cookieconsent.orestbida.com/reference/configuration-reference.html for configuration options.
|
||||||
const config: CookieConsentConfig = {
|
const config: CookieConsentConfig = {
|
||||||
// Default configuration for the modal.
|
// Default configuration for the modal.
|
||||||
root: 'body',
|
root: "body",
|
||||||
autoShow: true,
|
autoShow: true,
|
||||||
disablePageInteraction: false,
|
disablePageInteraction: false,
|
||||||
hideFromBots: import.meta.env.PROD ? true : false, // Set this to false for dev/headless tests otherwise the modal will not be visible.
|
hideFromBots: import.meta.env.PROD ? true : false, // Set this to false for dev/headless tests otherwise the modal will not be visible.
|
||||||
mode: 'opt-in',
|
mode: "opt-in",
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
|
||||||
// Default configuration for the cookie.
|
// Default configuration for the cookie.
|
||||||
cookie: {
|
cookie: {
|
||||||
name: 'cc_cookie',
|
name: "cc_cookie",
|
||||||
domain: location.hostname,
|
domain: location.hostname,
|
||||||
path: '/',
|
path: "/",
|
||||||
sameSite: 'Lax',
|
sameSite: "Lax",
|
||||||
expiresAfterDays: 365,
|
expiresAfterDays: 365,
|
||||||
},
|
},
|
||||||
|
|
||||||
guiOptions: {
|
guiOptions: {
|
||||||
consentModal: {
|
consentModal: {
|
||||||
layout: 'box',
|
layout: "box",
|
||||||
position: 'bottom right',
|
position: "bottom right",
|
||||||
equalWeightButtons: true,
|
equalWeightButtons: true,
|
||||||
flipButtons: false,
|
flipButtons: false,
|
||||||
},
|
},
|
||||||
@@ -47,7 +47,7 @@ const getConfig = () => {
|
|||||||
name: /^_ga/, // regex: match all cookies starting with '_ga'
|
name: /^_ga/, // regex: match all cookies starting with '_ga'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '_gid', // string: exact cookie name
|
name: "_gid", // string: exact cookie name
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -55,22 +55,23 @@ const getConfig = () => {
|
|||||||
// https://cookieconsent.orestbida.com/reference/configuration-reference.html#category-services
|
// https://cookieconsent.orestbida.com/reference/configuration-reference.html#category-services
|
||||||
services: {
|
services: {
|
||||||
ga: {
|
ga: {
|
||||||
label: 'Google Analytics',
|
label: "Google Analytics",
|
||||||
onAccept: () => {
|
onAccept: () => {
|
||||||
try {
|
try {
|
||||||
const GA_ANALYTICS_ID = import.meta.env.REACT_APP_GOOGLE_ANALYTICS_ID;
|
const GA_ANALYTICS_ID = import.meta.env
|
||||||
|
.REACT_APP_GOOGLE_ANALYTICS_ID;
|
||||||
if (!GA_ANALYTICS_ID.length) {
|
if (!GA_ANALYTICS_ID.length) {
|
||||||
throw new Error('Google Analytics ID is missing');
|
throw new Error("Google Analytics ID is missing");
|
||||||
}
|
}
|
||||||
window.dataLayer = window.dataLayer || [];
|
window.dataLayer = window.dataLayer || [];
|
||||||
function gtag(..._args: unknown[]) {
|
function gtag(..._args: unknown[]) {
|
||||||
(window.dataLayer as Array<any>).push(arguments);
|
(window.dataLayer as Array<any>).push(arguments);
|
||||||
}
|
}
|
||||||
gtag('js', new Date());
|
gtag("js", new Date());
|
||||||
gtag('config', GA_ANALYTICS_ID);
|
gtag("config", GA_ANALYTICS_ID);
|
||||||
|
|
||||||
// Adding the script tag dynamically to the DOM.
|
// Adding the script tag dynamically to the DOM.
|
||||||
const script = document.createElement('script');
|
const script = document.createElement("script");
|
||||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ANALYTICS_ID}`;
|
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ANALYTICS_ID}`;
|
||||||
script.async = true;
|
script.async = true;
|
||||||
document.body.appendChild(script);
|
document.body.appendChild(script);
|
||||||
@@ -85,15 +86,15 @@ const getConfig = () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
language: {
|
language: {
|
||||||
default: 'en',
|
default: "en",
|
||||||
translations: {
|
translations: {
|
||||||
en: {
|
en: {
|
||||||
consentModal: {
|
consentModal: {
|
||||||
title: 'We use cookies',
|
title: "We use cookies",
|
||||||
description:
|
description:
|
||||||
'We use cookies primarily for analytics to enhance your experience. By accepting, you agree to our use of these cookies. You can manage your preferences or learn more about our cookie policy.',
|
"We use cookies primarily for analytics to enhance your experience. By accepting, you agree to our use of these cookies. You can manage your preferences or learn more about our cookie policy.",
|
||||||
acceptAllBtn: 'Accept all',
|
acceptAllBtn: "Accept all",
|
||||||
acceptNecessaryBtn: 'Reject all',
|
acceptNecessaryBtn: "Reject all",
|
||||||
// showPreferencesBtn: 'Manage Individual preferences', // (OPTIONAL) Activates the preferences modal
|
// showPreferencesBtn: 'Manage Individual preferences', // (OPTIONAL) Activates the preferences modal
|
||||||
// TODO: Add your own privacy policy and terms and conditions links below.
|
// TODO: Add your own privacy policy and terms and conditions links below.
|
||||||
footer: `
|
footer: `
|
||||||
@@ -113,4 +114,4 @@ const getConfig = () => {
|
|||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default getConfig;
|
export default getConfig;
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from "react";
|
||||||
import useLocalStorage from './useLocalStorage';
|
import useLocalStorage from "./useLocalStorage";
|
||||||
|
|
||||||
export default function useColorMode() {
|
export default function useColorMode() {
|
||||||
const [colorMode, setColorMode] = useLocalStorage('color-theme', 'light');
|
const [colorMode, setColorMode] = useLocalStorage("color-theme", "light");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const className = 'dark';
|
const className = "dark";
|
||||||
const bodyClass = window.document.body.classList;
|
const bodyClass = window.document.body.classList;
|
||||||
|
|
||||||
colorMode === 'dark'
|
colorMode === "dark"
|
||||||
? bodyClass.add(className)
|
? bodyClass.add(className)
|
||||||
: bodyClass.remove(className);
|
: bodyClass.remove(className);
|
||||||
}, [colorMode]);
|
}, [colorMode]);
|
||||||
|
|
||||||
return [colorMode, setColorMode];
|
return [colorMode, setColorMode];
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
function useDebounce<T>(value: T, delay: number): T {
|
function useDebounce<T>(value: T, delay: number): T {
|
||||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
export const useIsLandingPage = () => {
|
export const useIsLandingPage = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return location.pathname === '/';
|
return location.pathname === "/";
|
||||||
}, [location]);
|
}, [location]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type SetValue<T> = T | ((val: T) => T);
|
type SetValue<T> = T | ((val: T) => T);
|
||||||
|
|
||||||
function useLocalStorage<T>(
|
function useLocalStorage<T>(
|
||||||
key: string,
|
key: string,
|
||||||
initialValue: T
|
initialValue: T,
|
||||||
): [T, (value: SetValue<T>) => void] {
|
): [T, (value: SetValue<T>) => void] {
|
||||||
// State to store our value
|
// State to store our value
|
||||||
// Pass initial state function to useState so logic is only executed once
|
// Pass initial state function to useState so logic is only executed once
|
||||||
@@ -26,7 +26,7 @@ function useLocalStorage<T>(
|
|||||||
try {
|
try {
|
||||||
// Allow value to be a function so we have same API as useState
|
// Allow value to be a function so we have same API as useState
|
||||||
const valueToStore =
|
const valueToStore =
|
||||||
typeof storedValue === 'function'
|
typeof storedValue === "function"
|
||||||
? storedValue(storedValue)
|
? storedValue(storedValue)
|
||||||
: storedValue;
|
: storedValue;
|
||||||
// Save state
|
// Save state
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from "lucide-react";
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
const Accordion = AccordionPrimitive.Root;
|
const Accordion = AccordionPrimitive.Root;
|
||||||
|
|
||||||
@@ -10,25 +10,29 @@ const AccordionItem = React.forwardRef<
|
|||||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AccordionPrimitive.Item ref={ref} className={cn('border-b', className)} {...props} />
|
<AccordionPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn("border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
AccordionItem.displayName = 'AccordionItem';
|
AccordionItem.displayName = "AccordionItem";
|
||||||
|
|
||||||
const AccordionTrigger = React.forwardRef<
|
const AccordionTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<AccordionPrimitive.Header className='flex'>
|
<AccordionPrimitive.Header className="flex">
|
||||||
<AccordionPrimitive.Trigger
|
<AccordionPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180',
|
"flex flex-1 items-center justify-between py-4 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronDown className='h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200' />
|
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||||
</AccordionPrimitive.Trigger>
|
</AccordionPrimitive.Trigger>
|
||||||
</AccordionPrimitive.Header>
|
</AccordionPrimitive.Header>
|
||||||
));
|
));
|
||||||
@@ -40,10 +44,10 @@ const AccordionContent = React.forwardRef<
|
|||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<AccordionPrimitive.Content
|
<AccordionPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className='overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down'
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||||
</AccordionPrimitive.Content>
|
</AccordionPrimitive.Content>
|
||||||
));
|
));
|
||||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user