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
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
open_collective: # Replace with a single Open Collective 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:
|
||||
- main
|
||||
paths:
|
||||
- 'opensaas-sh/blog/**'
|
||||
- "opensaas-sh/blog/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'opensaas-sh/blog/**'
|
||||
- "opensaas-sh/blog/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./opensaas-sh/blog
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./opensaas-sh/blog
|
||||
@@ -64,10 +64,10 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@v2
|
||||
with:
|
||||
publish-dir: './opensaas-sh/blog/dist'
|
||||
publish-dir: "./opensaas-sh/blog/dist"
|
||||
production-branch: main
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
deploy-message: 'Deploy from GitHub Actions'
|
||||
deploy-message: "Deploy from GitHub Actions"
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: "22"
|
||||
|
||||
- name: Docker setup
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
run: |
|
||||
./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'
|
||||
working-directory: ./template
|
||||
run: |
|
||||
@@ -78,14 +78,14 @@ jobs:
|
||||
path: ~/.cache/ms-playwright
|
||||
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'
|
||||
working-directory: ./template
|
||||
run: |
|
||||
cd e2e-tests
|
||||
npx playwright install --with-deps
|
||||
|
||||
- name: '[e2e-tests] Install Stripe CLI'
|
||||
- name: "[e2e-tests] Install Stripe CLI"
|
||||
run: |
|
||||
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
|
||||
@@ -95,13 +95,13 @@ jobs:
|
||||
# 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
|
||||
# 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:
|
||||
STRIPE_DEVICE_NAME: ${{ secrets.STRIPE_DEVICE_NAME }}
|
||||
run: |
|
||||
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:
|
||||
# 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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
Thanks so much for considering contributing to Open SaaS 🙏
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
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).
|
||||
3. Create a new git branch for your work (aka feature branch) and do your changes on it.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
## 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>
|
||||
|
||||
https://github.com/user-attachments/assets/3856276b-23e9-455e-a564-b5f26f4f0e98
|
||||
@@ -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.
|
||||
|
||||
You also get access to Wasp's diverse, helpful community if you get stuck or need help.
|
||||
|
||||
- 🤝 [Wasp Discord](https://discord.gg/aCamt5wCpS)
|
||||
|
||||
## Getting Started
|
||||
@@ -45,6 +47,7 @@ You also get access to Wasp's diverse, helpful community if you get stuck or nee
|
||||
### Simple Instructions
|
||||
|
||||
First, to install the latest version of [Wasp](https://wasp.sh/) on macOS, Linux, or Windows with WSL, run the following command:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
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)
|
||||
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.
|
||||
|
||||
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*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
This is the docs and blog for the OpenSaaS.sh website, [](https://starlight.astro.build)
|
||||
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro + Starlight project, you'll see the following folders and files:
|
||||
@@ -56,7 +55,10 @@ title: "Open SaaS Tutorial"
|
||||
date: 2024-12-10
|
||||
//...
|
||||
---
|
||||
import VideoPlayer from '../../../components/VideoPlayer.astro';
|
||||
|
||||
import VideoPlayer from "../../../components/VideoPlayer.astro";
|
||||
|
||||
;
|
||||
```
|
||||
|
||||
### HeadWithOGImage
|
||||
@@ -99,7 +101,8 @@ title: "Open SaaS Tutorial"
|
||||
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%" />
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
|
||||
## Authoring Content
|
||||
|
||||
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
|
||||
date: 2024-11-26
|
||||
tags:
|
||||
- cookie consent
|
||||
- saas
|
||||
- sideproject
|
||||
- hackathon
|
||||
subtitle: and it was totally worth it
|
||||
hideBannerImage: true
|
||||
authors: vince
|
||||
|
||||
- cookie consent
|
||||
- saas
|
||||
- sideproject
|
||||
- hackathon
|
||||
subtitle: and it was totally worth it
|
||||
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).
|
||||
|
||||
### 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:
|
||||
|
||||
```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.
|
||||
|
||||
```mdx
|
||||
import { Image } from 'astro:assets';
|
||||
import myImage from '../../../assets/my-image.jpg';
|
||||
import { Image } from "astro:assets";
|
||||
import myImage from "../../../assets/my-image.jpg";
|
||||
|
||||
<Image src={myImage} alt="My Image" />
|
||||
```
|
||||
|
||||
@@ -1,100 +1,102 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import starlightBlog from 'starlight-blog';
|
||||
import starlight from "@astrojs/starlight";
|
||||
import { defineConfig } from "astro/config";
|
||||
import starlightBlog from "starlight-blog";
|
||||
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://docs.opensaas.sh',
|
||||
trailingSlash: 'always',
|
||||
site: "https://docs.opensaas.sh",
|
||||
trailingSlash: "always",
|
||||
integrations: [
|
||||
starlight({
|
||||
title: 'OpenSaaS.sh',
|
||||
description: 'Open SaaS is a free, open-source, full-stack SaaS starter kit for React + NodeJS.',
|
||||
customCss: ['./src/styles/tailwind.css'],
|
||||
title: "OpenSaaS.sh",
|
||||
description:
|
||||
"Open SaaS is a free, open-source, full-stack SaaS starter kit for React + NodeJS.",
|
||||
customCss: ["./src/styles/tailwind.css"],
|
||||
logo: {
|
||||
src: '/src/assets/logo.webp',
|
||||
alt: 'Open SaaS',
|
||||
src: "/src/assets/logo.webp",
|
||||
alt: "Open SaaS",
|
||||
},
|
||||
head: [
|
||||
{
|
||||
tag: 'script',
|
||||
tag: "script",
|
||||
attrs: {
|
||||
defer: true,
|
||||
'data-domain': 'docs.opensaas.sh',
|
||||
'data-api': 'https://opensaas.sh/wasparadocs/wasp/event',
|
||||
src: 'https://opensaas.sh/wasparadocs/wasp/script.js',
|
||||
"data-domain": "docs.opensaas.sh",
|
||||
"data-api": "https://opensaas.sh/wasparadocs/wasp/event",
|
||||
src: "https://opensaas.sh/wasparadocs/wasp/script.js",
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: 'script',
|
||||
tag: "script",
|
||||
attrs: {
|
||||
defer: true,
|
||||
src: '/piggy.js',
|
||||
src: "/piggy.js",
|
||||
},
|
||||
},
|
||||
],
|
||||
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: {
|
||||
SiteTitle: './src/components/MyHeader.astro',
|
||||
SiteTitle: "./src/components/MyHeader.astro",
|
||||
// We customized ThemeSelect to include a "Copy URL for LLMs" button
|
||||
ThemeSelect: './src/components/MyRightNavBarItems.astro',
|
||||
Head: './src/components/HeadWithOGImage.astro',
|
||||
ThemeSelect: "./src/components/MyRightNavBarItems.astro",
|
||||
Head: "./src/components/HeadWithOGImage.astro",
|
||||
},
|
||||
social: {
|
||||
github: 'https://github.com/wasp-lang/open-saas',
|
||||
twitter: 'https://twitter.com/wasplang',
|
||||
discord: 'https://discord.gg/aCamt5wCpS',
|
||||
github: "https://github.com/wasp-lang/open-saas",
|
||||
twitter: "https://twitter.com/wasplang",
|
||||
discord: "https://discord.gg/aCamt5wCpS",
|
||||
},
|
||||
sidebar: [
|
||||
{
|
||||
label: 'Start Here',
|
||||
label: "Start Here",
|
||||
items: [
|
||||
{ label: 'Introduction', link: '/' },
|
||||
{ label: 'Getting Started', link: '/start/getting-started/' },
|
||||
{ label: 'Guided Tour', link: '/start/guided-tour/' },
|
||||
{ label: "Introduction", link: "/" },
|
||||
{ label: "Getting Started", link: "/start/getting-started/" },
|
||||
{ label: "Guided Tour", link: "/start/guided-tour/" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Guides',
|
||||
autogenerate: { directory: '/guides/' },
|
||||
label: "Guides",
|
||||
autogenerate: { directory: "/guides/" },
|
||||
},
|
||||
{
|
||||
label: 'General',
|
||||
autogenerate: { directory: '/general/' },
|
||||
label: "General",
|
||||
autogenerate: { directory: "/general/" },
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
starlightBlog({
|
||||
title: 'Blog',
|
||||
customCss: ['./src/styles/tailwind.css'],
|
||||
title: "Blog",
|
||||
customCss: ["./src/styles/tailwind.css"],
|
||||
authors: {
|
||||
vince: {
|
||||
name: 'Vince',
|
||||
title: 'Dev Rel @ Wasp',
|
||||
picture: '/CRAIG_ROCK.png', // Images in the `public` directory are supported.
|
||||
url: 'https://wasp.sh',
|
||||
name: "Vince",
|
||||
title: "Dev Rel @ Wasp",
|
||||
picture: "/CRAIG_ROCK.png", // Images in the `public` directory are supported.
|
||||
url: "https://wasp.sh",
|
||||
},
|
||||
matija: {
|
||||
name: 'Matija',
|
||||
title: 'CEO @ Wasp',
|
||||
picture: '/matija.jpeg', // Images in the `public` directory are supported.
|
||||
url: 'https://wasp.sh',
|
||||
name: "Matija",
|
||||
title: "CEO @ Wasp",
|
||||
picture: "/matija.jpeg", // Images in the `public` directory are supported.
|
||||
url: "https://wasp.sh",
|
||||
},
|
||||
milica: {
|
||||
name: 'Milica',
|
||||
title: 'Growth @ Wasp',
|
||||
picture: '/milica.jpg', // Images in the `public` directory are supported.
|
||||
url: 'https://wasp.sh',
|
||||
name: "Milica",
|
||||
title: "Growth @ Wasp",
|
||||
picture: "/milica.jpg", // Images in the `public` directory are supported.
|
||||
url: "https://wasp.sh",
|
||||
},
|
||||
martin: {
|
||||
name: 'Martin',
|
||||
title: 'CTO @ Wasp',
|
||||
picture: '/martin.jpg', // Images in the `public` directory are supported.
|
||||
url: 'https://wasp.sh',
|
||||
name: "Martin",
|
||||
title: "CTO @ Wasp",
|
||||
picture: "/martin.jpg", // Images in the `public` directory are supported.
|
||||
url: "https://wasp.sh",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"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": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
|
||||
@@ -8,7 +8,7 @@ This is because OG Image URLs and Banner Images are automatically generated for
|
||||
|
||||
```tsx
|
||||
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,
|
||||
)
|
||||
);
|
||||
```
|
||||
@@ -1,57 +1,59 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { globSync } from 'glob';
|
||||
import fm from 'front-matter';
|
||||
import fm from "front-matter";
|
||||
import fs from "fs-extra";
|
||||
import { globSync } from "glob";
|
||||
import path from "path";
|
||||
|
||||
const BLOG_ROOT = process.cwd();
|
||||
const PUBLIC_DIR = path.join(BLOG_ROOT, 'public');
|
||||
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 GITHUB_RAW_BASE_URL = 'https://raw.githubusercontent.com/wasp-lang/open-saas/main/opensaas-sh/blog/';
|
||||
const OPEN_SAAS_DOCS_BASE_URL = 'https://docs.opensaas.sh/';
|
||||
const LLM_FULL_FILENAME = 'llms-full.txt';
|
||||
const LLM_OVERVIEW_FILENAME = 'llms.txt';
|
||||
const PUBLIC_DIR = path.join(BLOG_ROOT, "public");
|
||||
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 GITHUB_RAW_BASE_URL =
|
||||
"https://raw.githubusercontent.com/wasp-lang/open-saas/main/opensaas-sh/blog/";
|
||||
const OPEN_SAAS_DOCS_BASE_URL = "https://docs.opensaas.sh/";
|
||||
const LLM_FULL_FILENAME = "llms-full.txt";
|
||||
const LLM_OVERVIEW_FILENAME = "llms.txt";
|
||||
const OVERVIEW_FILE = path.join(PUBLIC_DIR, LLM_OVERVIEW_FILENAME);
|
||||
const FULL_CONCAT_FILE = path.join(PUBLIC_DIR, LLM_FULL_FILENAME);
|
||||
|
||||
function cleanContent(content) {
|
||||
if (!content) return '';
|
||||
if (!content) return "";
|
||||
let cleaned = content;
|
||||
// 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: ... */}
|
||||
cleaned = cleaned.replace(/^\{\/\*.*\*\/\}\s*$/gm, '');
|
||||
cleaned = cleaned.replace(/^\{\/\*.*\*\/\}\s*$/gm, "");
|
||||
// Remove lines like <!-- TODO: ... -->
|
||||
cleaned = cleaned.replace(/^<!--.*-->\s*$/gm, '');
|
||||
cleaned = cleaned.replace(/^<!--.*-->\s*$/gm, "");
|
||||
// Remove <Image ... /> tags
|
||||
cleaned = cleaned.replace(/<Image[^>]*?\/>/g, '');
|
||||
cleaned = cleaned.replace(/<Image[^>]*?\/>/g, "");
|
||||
// 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)
|
||||
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,
|
||||
''
|
||||
"",
|
||||
);
|
||||
cleaned = cleaned.replace(/\u00A0/g, ' ');
|
||||
cleaned = cleaned.replace(/\u00A0/g, " ");
|
||||
// Remove Box Drawing Characters (single and double line)
|
||||
cleaned = cleaned.replace(/[│├└─╔═╗║╚╝]/g, '');
|
||||
cleaned = cleaned.replace(/[│├└─╔═╗║╚╝]/g, "");
|
||||
// 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();
|
||||
}
|
||||
|
||||
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 = [];
|
||||
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 fullConcatContent = '';
|
||||
let fullConcatContent = "";
|
||||
|
||||
console.log('Gathering source files...');
|
||||
console.log("Gathering source files...");
|
||||
for (const sourceItem of ORDERED_SOURCES) {
|
||||
const itemPath = path.join(DOCS_BASE_DIR, sourceItem);
|
||||
try {
|
||||
@@ -60,56 +62,66 @@ async function generateFiles() {
|
||||
orderedSourceFiles.push(itemPath);
|
||||
console.log(` Added file: ${sourceItem}`);
|
||||
} 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);
|
||||
console.log(` Added ${files.length} files from directory: ${sourceItem}`);
|
||||
console.log(
|
||||
` Added ${files.length} files from directory: ${sourceItem}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
if (error.code === "ENOENT") {
|
||||
console.warn(`Warning: Source item not found: ${itemPath}`);
|
||||
} 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('Processing files...');
|
||||
console.log("Processing files...");
|
||||
for (const sourceFilePath of orderedSourceFiles) {
|
||||
try {
|
||||
const rawContent = await fs.readFile(sourceFilePath, 'utf8');
|
||||
const rawContent = await fs.readFile(sourceFilePath, "utf8");
|
||||
const { attributes, body } = fm(rawContent);
|
||||
|
||||
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;
|
||||
overviewContent += `- [${title}](${githubRawUrl})\n`;
|
||||
|
||||
fullConcatContent += `# ${title}\n\n${processedContent}\n\n---\n\n`;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error processing file ${sourceFilePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Writing output files to public/ ...');
|
||||
console.log("Writing output files to public/ ...");
|
||||
try {
|
||||
await fs.writeFile(OVERVIEW_FILE, overviewContent.trim(), 'utf8');
|
||||
await fs.writeFile(OVERVIEW_FILE, overviewContent.trim(), "utf8");
|
||||
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}`);
|
||||
} 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 => {
|
||||
console.error('Unhandled error during LLM file generation:', error);
|
||||
generateFiles().catch((error) => {
|
||||
console.error("Unhandled error during LLM file generation:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,16 +1,24 @@
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
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 }) =>
|
||||
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 imagePath = path.join(__dirname, `../../public/${BANNER_PATH}`, bannerImageFileName);
|
||||
const imagePath = path.join(
|
||||
__dirname,
|
||||
`../../public/${BANNER_PATH}`,
|
||||
bannerImageFileName,
|
||||
);
|
||||
return existsSync(imagePath);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { i18nSchema, docsSchema } from '@astrojs/starlight/schema';
|
||||
import { blogSchema } from 'starlight-blog/schema';
|
||||
import { z } from 'astro:content';
|
||||
import { docsSchema, i18nSchema } from "@astrojs/starlight/schema";
|
||||
import { defineCollection, z } from "astro:content";
|
||||
import { blogSchema } from "starlight-blog/schema";
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({
|
||||
@@ -16,5 +15,5 @@ export const collections = {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
i18n: defineCollection({ type: 'data', schema: i18nSchema() }),
|
||||
i18n: defineCollection({ type: "data", schema: i18nSchema() }),
|
||||
};
|
||||
@@ -4,18 +4,32 @@ date: 2023-11-21
|
||||
tags: ["indiehacker", "saas", "sideproject"]
|
||||
authors: vince
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
|
||||
import { Image } from "astro:assets";
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
<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!
|
||||
|
||||
@@ -26,7 +40,6 @@ I’ll be the first to admit that the results aren’t spectacular, but they’r
|
||||
|
||||
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?
|
||||
|
||||
[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.
|
||||
@@ -45,7 +58,13 @@ It also lets you save and manage your cover letters per each job, making it easy
|
||||
|
||||
## 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!
|
||||
|
||||
@@ -59,7 +78,13 @@ All you have to focus on is writing the client and server-side logic, and Wasp w
|
||||
|
||||
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)
|
||||
|
||||
@@ -83,13 +108,25 @@ First of all, the number of people who will realistically spend the time and ene
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -110,7 +147,13 @@ My initial payment options were:
|
||||
- $4.95 for a 3 months access
|
||||
- $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:
|
||||
|
||||
@@ -155,6 +198,12 @@ Here are also the most important links from this article along with some further
|
||||
|
||||
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)
|
||||
@@ -9,11 +9,12 @@ tags:
|
||||
hideBannerImage: true
|
||||
authors: vince
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import wheel from '@assets/cookie-consent/wheel.gif';
|
||||
import enter from '@assets/cookie-consent/enter.gif';
|
||||
import keyboard from '@assets/cookie-consent/keyboard.jpg';
|
||||
import share from '@assets/cookie-consent/image.png';
|
||||
|
||||
import { Image } from "astro:assets";
|
||||
import wheel from "@assets/cookie-consent/wheel.gif";
|
||||
import enter from "@assets/cookie-consent/enter.gif";
|
||||
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;">
|
||||
<iframe
|
||||
@@ -23,8 +24,8 @@ import share from '@assets/cookie-consent/image.png';
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen>
|
||||
</iframe>
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
## 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:
|
||||
|
||||
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:*
|
||||
|
||||
<Image src={enter} alt='Enter to win' loading='lazy' />
|
||||
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" />
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
@@ -55,7 +55,7 @@ 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.
|
||||
|
||||
<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 😃)
|
||||
|
||||
@@ -64,13 +64,10 @@ The community will get a chance to vote in a battle royale style elimination tou
|
||||
- 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
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
- Make sure you also ⭐️ [star the Open Saas repository](https://github.com/wasp-lang/open-saas) to be eligible to win!
|
||||
|
||||
## Deadline & Results
|
||||
|
||||
@@ -9,14 +9,15 @@ tags:
|
||||
subtitle: and it was totally worth it
|
||||
authors: vince
|
||||
---
|
||||
import VideoPlayer from '../../../components/VideoPlayer.astro';
|
||||
import { Image } from 'astro:assets';
|
||||
import camblackwood from '@assets/cookie-banner-hackathon/295-camblackwood.mp4';
|
||||
import gangnam from '@assets/cookie-banner-hackathon/300-lezzz-sound.mp4';
|
||||
import wheredaway from '@assets/cookie-banner-hackathon/302-fecony-whereda.mp4';
|
||||
import henryboyd from '@assets/cookie-banner-hackathon/296-henryboyd.mp4';
|
||||
import wardbox from '@assets/cookie-banner-hackathon/286-wardbox.mp4';
|
||||
import gangnamwinner from '@assets/cookie-banner-hackathon/285-3umaGH-gangnam.mp4';
|
||||
|
||||
import VideoPlayer from "../../../components/VideoPlayer.astro";
|
||||
import { Image } from "astro:assets";
|
||||
import camblackwood from "@assets/cookie-banner-hackathon/295-camblackwood.mp4";
|
||||
import gangnam from "@assets/cookie-banner-hackathon/300-lezzz-sound.mp4";
|
||||
import wheredaway from "@assets/cookie-banner-hackathon/302-fecony-whereda.mp4";
|
||||
import henryboyd from "@assets/cookie-banner-hackathon/296-henryboyd.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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<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">
|
||||
<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 💪
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -7,16 +7,17 @@ tags:
|
||||
- sideproject
|
||||
authors: matija
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import marc1 from '../../../assets/boilerplate-starters/marc1.png';
|
||||
import opensaas from '../../../assets/boilerplate-starters/opensaas.png';
|
||||
import marc2 from '../../../assets/boilerplate-starters/marc2.png';
|
||||
import daveShipfast from '../../../assets/boilerplate-starters/dave-shipfast-tweet.png';
|
||||
import osGhStats from '../../../assets/boilerplate-starters/os-gh-stats.png';
|
||||
import osCommits from '../../../assets/boilerplate-starters/os-commits.png';
|
||||
import freeUpdates from '../../../assets/boilerplate-starters/free-updates-vs-not.png';
|
||||
import communityContributions from '../../../assets/boilerplate-starters/community-contributions.png';
|
||||
import boilerplateLicenses from '../../../assets/boilerplate-starters/boilerplate-licenses.png';
|
||||
|
||||
import { Image } from "astro:assets";
|
||||
import marc1 from "../../../assets/boilerplate-starters/marc1.png";
|
||||
import opensaas from "../../../assets/boilerplate-starters/opensaas.png";
|
||||
import marc2 from "../../../assets/boilerplate-starters/marc2.png";
|
||||
import daveShipfast from "../../../assets/boilerplate-starters/dave-shipfast-tweet.png";
|
||||
import osGhStats from "../../../assets/boilerplate-starters/os-gh-stats.png";
|
||||
import osCommits from "../../../assets/boilerplate-starters/os-commits.png";
|
||||
import freeUpdates from "../../../assets/boilerplate-starters/free-updates-vs-not.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.
|
||||
|
||||
@@ -104,7 +105,11 @@ An another direct benefit of the SaaS starter code being open-source is that you
|
||||
|
||||
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
|
||||
|
||||
@@ -120,14 +125,18 @@ 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!
|
||||
|
||||
<Image src={communityContributions} alt="Community contributions" loading="lazy" />
|
||||
<Image
|
||||
src={communityContributions}
|
||||
alt="Community contributions"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
## 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:
|
||||
|
||||
| | Cost | Lifetime updates | Unlimited apps | Maintainers | Community | Air Jordans Effect | Easily contribute |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| ------------------------ | ----- | ---------------- | -------------- | ------------- | ------------------ | ------------------ | ----------------- |
|
||||
| Open-source SaaS starter | $0 | YES | YES | Many | Big, public | Rarely | YES |
|
||||
| Paid starter | $300+ | Depends | Depends | Typically one | Sometimes, private | Often | No |
|
||||
|
||||
|
||||
@@ -8,13 +8,14 @@ tags:
|
||||
- indiehackers
|
||||
authors: milica
|
||||
---
|
||||
import VideoPlayer from '../../../components/VideoPlayer.astro';
|
||||
import { Image } from 'astro:assets';
|
||||
import landing from '../../../assets/turboreel/landing.webp';
|
||||
import studioInterface from '../../../assets/turboreel/studio-interface.mp4';
|
||||
import opensaas from '../../../assets/turboreel/opensaas.mp4';
|
||||
import reddit100Users from '../../../assets/turboreel/reddit-100-users.webp'
|
||||
import reddit200Upvotes from '../../../assets/turboreel/reddit-200-upvotes.webp'
|
||||
|
||||
import VideoPlayer from "../../../components/VideoPlayer.astro";
|
||||
import { Image } from "astro:assets";
|
||||
import landing from "../../../assets/turboreel/landing.webp";
|
||||
import studioInterface from "../../../assets/turboreel/studio-interface.mp4";
|
||||
import opensaas from "../../../assets/turboreel/opensaas.mp4";
|
||||
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.
|
||||
|
||||
@@ -28,9 +29,9 @@ In this post, we'll cover three main things: what inspired Peter to kickstart th
|
||||
|
||||
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 🐝
|
||||
|
||||
@@ -75,7 +76,6 @@ 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`."
|
||||
|
||||
|
||||
Here's what Wasp's config file looks like, through which you can define full-stack auth in a Wasp app.
|
||||
|
||||
```bash
|
||||
@@ -111,26 +111,35 @@ app myApp {
|
||||
```
|
||||
|
||||
<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>
|
||||
|
||||
### 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.
|
||||
|
||||
> *"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
|
||||
|
||||
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
|
||||
|
||||
<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.
|
||||
|
||||
|
||||
@@ -9,16 +9,17 @@ tags:
|
||||
authors: vince
|
||||
---
|
||||
|
||||
import { Image } from 'astro:assets';
|
||||
import Tweet from '../../../components/Tweet.astro';
|
||||
import landingPage from '../../../assets/cover-letter-gpt/coverlettergpt.webp';
|
||||
import mrrGraph from '../../../assets/cover-letter-gpt/mrr-graph.webp';
|
||||
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
|
||||
import redditPost from '../../../assets/cover-letter-gpt/coverlettergpt-reddit.png';
|
||||
import { Image } from "astro:assets";
|
||||
import Tweet from "../../../components/Tweet.astro";
|
||||
import landingPage from "../../../assets/cover-letter-gpt/coverlettergpt.webp";
|
||||
import mrrGraph from "../../../assets/cover-letter-gpt/mrr-graph.webp";
|
||||
import StarOpenSaaSCTA from "../../../components/StarOpenSaaSCTA.astro";
|
||||
import redditPost from "../../../assets/cover-letter-gpt/coverlettergpt-reddit.png";
|
||||
|
||||
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.
|
||||
|
||||
<Tweet id="1863553258586820976" />
|
||||
|
||||
### Quick Stats:
|
||||
|
||||
@@ -8,11 +8,12 @@ tags:
|
||||
- indiehackers
|
||||
authors: milica
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import articleGeneration from '../../../assets/ricardo-growth-hacks/article-generation.png';
|
||||
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
|
||||
import meetingReminders from '../../../assets/ricardo-growth-hacks/meeting-reminders.png';
|
||||
import googleAddons from '../../../assets/ricardo-growth-hacks/google-addons.png';
|
||||
|
||||
import { Image } from "astro:assets";
|
||||
import articleGeneration from "../../../assets/ricardo-growth-hacks/article-generation.png";
|
||||
import StarOpenSaaSCTA from "../../../components/StarOpenSaaSCTA.astro";
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
> *"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">
|
||||
<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>
|
||||
|
||||
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.
|
||||
|
||||
> *"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.
|
||||
- **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
|
||||
|
||||
@@ -52,7 +56,6 @@ Ricardo started a few projects with Wasp, while working on the third one he star
|
||||
|
||||
[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" />
|
||||
|
||||
Ricardo is using Open SaaS to focus on feature development while testing pricing strategies to differentiate the product from the rest of the market. Integrations with Stripe, Open AI, and similar helped him move faster than he could on his own. His first clients came from Reddit and he has a standard subscription monetization set up.
|
||||
@@ -86,34 +89,92 @@ He also relies on SEO, and guess what, he pushed a couple of blog posts with his
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
2. **Diversify Launch Strategies**
|
||||
|
||||
- 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.
|
||||
|
||||
<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>
|
||||
|
||||
> *"Their algorithm is great for targeting the right audience."*
|
||||
<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>> *"Their algorithm
|
||||
is great for targeting the right audience."*
|
||||
|
||||
- Use targeted Reddit ads to reach niche communities.
|
||||
|
||||
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**
|
||||
|
||||
> *"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?**
|
||||
|
||||
|
||||
@@ -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
|
||||
tags:
|
||||
- incident-report
|
||||
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`.
|
||||
|
||||
@@ -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):
|
||||
|
||||
```tsx
|
||||
export const updateCurrentUser: UpdateCurrentUser<Partial<User>, User> = async (user, context) => {
|
||||
export const updateCurrentUser: UpdateCurrentUser<Partial<User>, User> = async (
|
||||
user,
|
||||
context,
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
}
|
||||
@@ -56,7 +60,10 @@ In the Open SaaS template, as it comes when you create a new project with it, th
|
||||
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
|
||||
export const updateCurrentUserLastActiveTimestamp: UpdateCurrentUserLastActiveTimestamp<void, User> = async (_args, context) => {
|
||||
export const updateCurrentUserLastActiveTimestamp: UpdateCurrentUserLastActiveTimestamp<
|
||||
void,
|
||||
User
|
||||
> = async (_args, context) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
}
|
||||
@@ -82,7 +89,6 @@ In the Open SaaS template, as it comes when you create a new project with it, th
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Implement additional Wasp Action(s) for updating user data if needed
|
||||
|
||||
If you were using `updateCurrentUser` in your code beyond just updating `lastActiveTimestamp`, to allow the user to update some other `User` fields, we recommend also defining additional, more specialized Wasp Action(s) that will handle this additional usage.
|
||||
@@ -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:
|
||||
|
||||
```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) {
|
||||
throw new HttpError(401);
|
||||
}
|
||||
|
||||
// NOTE: This is also a good place to do data validation if you want to.
|
||||
const fullName = personalData.fullName
|
||||
const address = personalData.address
|
||||
const fullName = personalData.fullName;
|
||||
const address = personalData.address;
|
||||
|
||||
return context.entities.User.update({
|
||||
where: {
|
||||
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:
|
||||
|
||||
```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) {
|
||||
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!
|
||||
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
|
||||
- **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 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.**
|
||||
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
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ tags:
|
||||
- saas
|
||||
authors: milica
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
|
||||
import plausibleCommunity from '../../../assets/plausible/plausible-community.png';
|
||||
|
||||
import { Image } from "astro:assets";
|
||||
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
<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
|
||||
src="https://demo.arcade.software/4kph6Di7Pv5wlVhhsmEw?embed"
|
||||
title="Plausible Analytics: Live Demo"
|
||||
frameBorder="0"
|
||||
loading="lazy"
|
||||
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>
|
||||
</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.
|
||||
|
||||
>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?
|
||||
|
||||
|
||||
@@ -8,15 +8,16 @@ tags:
|
||||
- indiehackers
|
||||
authors: milica
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
|
||||
import plausibleCommunity from '../../../assets/plausible/plausible-community.png';
|
||||
import interfaceImg from '../../../assets/promptpanda/interface.png';
|
||||
import meme1 from '../../../assets/promptpanda/meme1.jpg';
|
||||
import meme2 from '../../../assets/promptpanda/meme2.jpg';
|
||||
import ph1 from '../../../assets/promptpanda/ph1.png';
|
||||
import ph2 from '../../../assets/promptpanda/ph2.png';
|
||||
import listImg from '../../../assets/promptpanda/list.png';
|
||||
|
||||
import { Image } from "astro:assets";
|
||||
import StarOpenSaaSCTA from "../../../components/StarOpenSaaSCTA.astro";
|
||||
import plausibleCommunity from "../../../assets/plausible/plausible-community.png";
|
||||
import interfaceImg from "../../../assets/promptpanda/interface.png";
|
||||
import meme1 from "../../../assets/promptpanda/meme1.jpg";
|
||||
import meme2 from "../../../assets/promptpanda/meme2.jpg";
|
||||
import ph1 from "../../../assets/promptpanda/ph1.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.
|
||||
|
||||
@@ -34,15 +35,28 @@ People who share prompts usually do so through messaging apps such as Slack, Mic
|
||||
|
||||
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 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
|
||||
src="https://demo.arcade.software/JiVvKE3oDWzbar0DKUDX?embed"
|
||||
title="PromptPanda: Live Demo"
|
||||
frameBorder="0"
|
||||
loading="lazy"
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +65,7 @@ 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.
|
||||
|
||||
<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>
|
||||
|
||||
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.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image src={interfaceImg} alt="PromptPanda interface" loading="lazy" />
|
||||
<Image src={interfaceImg} alt="PromptPanda interface" loading="lazy" />
|
||||
</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.
|
||||
@@ -71,19 +85,19 @@ 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.
|
||||
|
||||
<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>
|
||||
|
||||
[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">
|
||||
<Image src={ph2} alt="PromptPanda on Product Hunt" loading="lazy" />
|
||||
<Image src={ph2} alt="PromptPanda on Product Hunt" loading="lazy" />
|
||||
</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.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image src={meme2} alt="Trying again" loading="lazy" />
|
||||
<Image src={meme2} alt="Trying again" loading="lazy" />
|
||||
</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.**
|
||||
@@ -103,7 +117,7 @@ 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:
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image src={listImg} alt="PromptPanda tech stack" loading="lazy" />
|
||||
<Image src={listImg} alt="PromptPanda tech stack" loading="lazy" />
|
||||
</div>
|
||||
|
||||
## Are you ready to ship your SaaS now?
|
||||
|
||||
@@ -2,34 +2,35 @@
|
||||
title: "Product Hunt doesn't really work, but you should still use it to launch your product"
|
||||
date: 2025-05-07
|
||||
tags:
|
||||
- saas
|
||||
- webdev
|
||||
- sideproject
|
||||
- indiehackers
|
||||
- saas
|
||||
- webdev
|
||||
- sideproject
|
||||
- indiehackers
|
||||
authors: matija
|
||||
---
|
||||
import VideoPlayer from '../../../components/VideoPlayer.astro';
|
||||
import { Image } from 'astro:assets';
|
||||
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
|
||||
import ph1 from '../../../assets/ph/1.png';
|
||||
import ph2 from '../../../assets/ph/2.png';
|
||||
import ph3 from '../../../assets/ph/3.png';
|
||||
import ph4 from '../../../assets/ph/4.png';
|
||||
import ph5 from '../../../assets/ph/5.png';
|
||||
import ph6 from '../../../assets/ph/6.gif';
|
||||
import ph7 from '../../../assets/ph/7.png';
|
||||
import ph8 from '../../../assets/ph/8.png';
|
||||
import ph9 from '../../../assets/ph/9.png';
|
||||
import ph10 from '../../../assets/ph/10.png';
|
||||
import ph11 from '../../../assets/ph/11.png';
|
||||
import ph12 from '../../../assets/ph/12.png';
|
||||
import ph13 from '../../../assets/ph/13.png';
|
||||
import ph14 from '../../../assets/ph/14.png';
|
||||
import ph15 from '../../../assets/ph/15.png';
|
||||
import ph16 from '../../../assets/ph/16.png';
|
||||
import ph17 from '../../../assets/ph/17.png';
|
||||
import ph18 from '../../../assets/ph/18.png';
|
||||
import ph19 from '../../../assets/ph/19.png';
|
||||
|
||||
import VideoPlayer from "../../../components/VideoPlayer.astro";
|
||||
import { Image } from "astro:assets";
|
||||
import StarOpenSaaSCTA from "../../../components/StarOpenSaaSCTA.astro";
|
||||
import ph1 from "../../../assets/ph/1.png";
|
||||
import ph2 from "../../../assets/ph/2.png";
|
||||
import ph3 from "../../../assets/ph/3.png";
|
||||
import ph4 from "../../../assets/ph/4.png";
|
||||
import ph5 from "../../../assets/ph/5.png";
|
||||
import ph6 from "../../../assets/ph/6.gif";
|
||||
import ph7 from "../../../assets/ph/7.png";
|
||||
import ph8 from "../../../assets/ph/8.png";
|
||||
import ph9 from "../../../assets/ph/9.png";
|
||||
import ph10 from "../../../assets/ph/10.png";
|
||||
import ph11 from "../../../assets/ph/11.png";
|
||||
import ph12 from "../../../assets/ph/12.png";
|
||||
import ph13 from "../../../assets/ph/13.png";
|
||||
import ph14 from "../../../assets/ph/14.png";
|
||||
import ph15 from "../../../assets/ph/15.png";
|
||||
import ph16 from "../../../assets/ph/16.png";
|
||||
import ph17 from "../../../assets/ph/17.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**.
|
||||
|
||||
@@ -44,7 +45,7 @@ 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/).
|
||||
|
||||
<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>
|
||||
|
||||
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.
|
||||
@@ -54,7 +55,7 @@ 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"
|
||||
|
||||
<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>
|
||||
|
||||
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.
|
||||
@@ -62,7 +63,7 @@ Once you schedule your Product Hunt launch, you can create a banner to appear on
|
||||
**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">
|
||||
<Image src={ph18} alt="Make apps for everyone" loading="lazy" />
|
||||
<Image src={ph18} alt="Make apps for everyone" loading="lazy" />
|
||||
</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.
|
||||
@@ -82,18 +83,17 @@ 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!
|
||||
|
||||
<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>
|
||||
|
||||
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.
|
||||
>
|
||||
|
||||
But when the leaderboard was finally revealed, Open SaaS was barely in the top 10 launches of the day!
|
||||
|
||||
<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>
|
||||
|
||||
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.**
|
||||
@@ -103,17 +103,17 @@ 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:
|
||||
|
||||
<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>
|
||||
|
||||
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">
|
||||
<Image src={ph14} alt="Make apps for everyone" loading="lazy" />
|
||||
<Image src={ph14} alt="Make apps for everyone" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
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.
|
||||
@@ -121,19 +121,19 @@ Even some of our direct contacts knew "a guy" that could get you to the top of P
|
||||
## What we got from the launch - #7, HN front page, trending on GitHub, ...
|
||||
|
||||
<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>
|
||||
|
||||
**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">
|
||||
<Image src={ph11} alt="Make apps for everyone" loading="lazy" />
|
||||
<Image src={ph11} alt="Make apps for everyone" loading="lazy" />
|
||||
</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/)).
|
||||
|
||||
<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>
|
||||
|
||||
### The resulting traffic
|
||||
@@ -141,11 +141,11 @@ Finally, all that engagement combined allowed us to get trending globally on Git
|
||||
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">
|
||||
<Image src={ph9} alt="Make apps for everyone" loading="lazy" />
|
||||
<Image src={ph9} alt="Make apps for everyone" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
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.
|
||||
@@ -153,7 +153,7 @@ HackerNews launch brought in more than 3 times more people than Product Hunt. Gi
|
||||
## Getting featured in PH's daily newsletter - does it help?
|
||||
|
||||
<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>
|
||||
|
||||
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.
|
||||
@@ -161,13 +161,13 @@ Open SaaS ended the launch as the #7 product of the day, with about ~400 upvotes
|
||||
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">
|
||||
<Image src={ph6} alt="Make apps for everyone" loading="lazy" />
|
||||
<Image src={ph6} alt="Make apps for everyone" loading="lazy" />
|
||||
</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).
|
||||
|
||||
<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>
|
||||
|
||||
## Is it even possible to win #1 of the day without any boosting strategies?
|
||||
@@ -191,7 +191,7 @@ 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.**
|
||||
|
||||
<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>
|
||||
|
||||
**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.
|
||||
@@ -203,7 +203,7 @@ 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/).**
|
||||
|
||||
<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>
|
||||
|
||||
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.
|
||||
|
||||
> 💡 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">
|
||||
<Image src={ph2} alt="Make apps for everyone" loading="lazy" />
|
||||
<Image src={ph2} alt="Make apps for everyone" loading="lazy" />
|
||||
</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:
|
||||
@@ -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 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
|
||||
- [*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
|
||||
- And finally, [**Open SaaS - A free, open-source alternative to $300+ React & Node.js SaaS starters**](https://github.com/wasp-lang/open-saas/)
|
||||
|
||||
|
||||
@@ -13,25 +13,89 @@ tags:
|
||||
authors: vince
|
||||
---
|
||||
|
||||
import { Image } from 'astro:assets';
|
||||
import VideoPlayer from '../../../components/VideoPlayer.astro';
|
||||
import demoVideo from '../../../assets/cover-letter-gpt/demo-video.mp4';
|
||||
import Tweet from '../../../components/Tweet.astro';
|
||||
import landingPage from '../../../assets/cover-letter-gpt/coverlettergpt-may2025.png';
|
||||
import mrrRevenue from '../../../assets/cover-letter-gpt/mrr-revenue.png';
|
||||
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
|
||||
import redditPost from '../../../assets/cover-letter-gpt/coverlettergpt-reddit.png';
|
||||
import coverlettergptPricing from '../../../assets/cover-letter-gpt/coverlettergpt-plans.png';
|
||||
import openAiCost from '../../../assets/cover-letter-gpt/openai-cost.png';
|
||||
import openAiRequests from '../../../assets/cover-letter-gpt/openai-requests.png';
|
||||
import jeep from '../../../assets/cover-letter-gpt/jeep.png';
|
||||
import coverlettergptPhLaunch from '../../../assets/cover-letter-gpt/coverlettergpt-ph-launch.png';
|
||||
import coverlettergptIndieHackers from '../../../assets/cover-letter-gpt/coverlettergpt-indiehackers.png';
|
||||
import { Image } from "astro:assets";
|
||||
import VideoPlayer from "../../../components/VideoPlayer.astro";
|
||||
import demoVideo from "../../../assets/cover-letter-gpt/demo-video.mp4";
|
||||
import Tweet from "../../../components/Tweet.astro";
|
||||
import landingPage from "../../../assets/cover-letter-gpt/coverlettergpt-may2025.png";
|
||||
import mrrRevenue from "../../../assets/cover-letter-gpt/mrr-revenue.png";
|
||||
import StarOpenSaaSCTA from "../../../components/StarOpenSaaSCTA.astro";
|
||||
import redditPost from "../../../assets/cover-letter-gpt/coverlettergpt-reddit.png";
|
||||
import coverlettergptPricing from "../../../assets/cover-letter-gpt/coverlettergpt-plans.png";
|
||||
import openAiCost from "../../../assets/cover-letter-gpt/openai-cost.png";
|
||||
import openAiRequests from "../../../assets/cover-letter-gpt/openai-requests.png";
|
||||
import jeep from "../../../assets/cover-letter-gpt/jeep.png";
|
||||
import coverlettergptPhLaunch from "../../../assets/cover-letter-gpt/coverlettergpt-ph-launch.png";
|
||||
import coverlettergptIndieHackers from "../../../assets/cover-letter-gpt/coverlettergpt-indiehackers.png";
|
||||
|
||||
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.
|
||||
<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
|
||||
|
||||
@@ -40,6 +104,7 @@ I wanted to share my journey building a micro SaaS, [CoverLetterGPT](https://cov
|
||||
<VideoPlayer src={demoVideo} lgWidth="80%" />
|
||||
|
||||
Here are some quick numbers:
|
||||
|
||||
- **Built in 1 week**
|
||||
- using [Open SaaS](https://opensaas.sh/), a free, open-source React, NodeJS, SaaS boilerplate template with tons of features.
|
||||
- **Runs on autopilot**
|
||||
@@ -59,11 +124,11 @@ Here are some quick numbers:
|
||||
### Costs Breakdown
|
||||
|
||||
| Cost Type | Monthly Cost | Notes |
|
||||
|----------------|--------------|----------------------------------------------------|
|
||||
| ----------- | ------------ | ----------------------------------------------- |
|
||||
| App Hosting | $12 | Railway.com |
|
||||
| OpenAI API | $3 | ~1,500 requests / 1.5m tokens |
|
||||
| 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.
|
||||
|
||||
@@ -82,24 +147,27 @@ Besides that, my hosting bill is about $12/month on [Railway](https://railway.co
|
||||
### Revenue & Profit Breakdown
|
||||
|
||||
| Metric | Value | Notes |
|
||||
|---------------------------|----------------------|--------------------------------------------|
|
||||
| --------------------------- | ---------- | ---------------------------------- |
|
||||
| Avg. Monthly Revenue | $615 | Past 8 months, converted from €543 |
|
||||
| Total Net Revenue | $9,912 | Since launch, after Stripe fees |
|
||||
| Total Costs to Date | $345 | $15/month × 23 months |
|
||||
| Avg. Monthly Profit | $416 | $9,567 ÷ 23 months |
|
||||
| **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
|
||||
|
||||
- **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
|
||||
|
||||
- **$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
|
||||
|
||||
This brings my total profit since launch to
|
||||
|
||||
- **$9,567** total profit
|
||||
or
|
||||
- **$416/month** average profit
|
||||
@@ -113,7 +181,7 @@ Not bad for a side project that I built in 1 week!
|
||||
### Marketing Breakdown
|
||||
|
||||
| Channel | Effort Level | Return | Result / Notes |
|
||||
|-------------------|---------------|-------------------------------|-----------------------------------------------------|
|
||||
| ------------- | ---------------- | ------ | ---------------------------------------------------------------------- |
|
||||
| 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 |
|
||||
| Indie Hackers | Easy | Medium | Shared open-source story, featured in newsletter, good feedback |
|
||||
@@ -196,7 +264,7 @@ The most important lesson I've learned: **speed is everything.** The faster you
|
||||
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.
|
||||
- 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.
|
||||
|
||||
<Image src={landingPage} alt="CoverLetterGPT landing page" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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.'
|
||||
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."
|
||||
date: 2025-07-29
|
||||
tags:
|
||||
- gpt
|
||||
@@ -13,18 +13,18 @@ tags:
|
||||
authors: vince
|
||||
---
|
||||
|
||||
import { Image } from 'astro:assets';
|
||||
import VideoPlayer from '../../../components/VideoPlayer.astro';
|
||||
import openSaasV1 from '../../../assets/open-saas-v2/open-saas-v1.mp4';
|
||||
import openSaasV2 from '../../../assets/open-saas-v2/open-saas-v2.mp4';
|
||||
import Tweet from '../../../components/Tweet.astro';
|
||||
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
|
||||
import redditPost from '../../../assets/cover-letter-gpt/coverlettergpt-reddit.png';
|
||||
import polar from '../../../assets/open-saas-v2/polar.webp';
|
||||
import llmsFull from '../../../assets/ai/llm-txt-chat.webp';
|
||||
import openSaasBanner from '../../../assets/open-saas-v2/open-saas-banner-light.png';
|
||||
import waitTheresMore from '../../../assets/open-saas-v2/wait-theres-more.jpg';
|
||||
import playwrightUi from '../../../assets/open-saas-v2/playwright-ui.png';
|
||||
import { Image } from "astro:assets";
|
||||
import VideoPlayer from "../../../components/VideoPlayer.astro";
|
||||
import openSaasV1 from "../../../assets/open-saas-v2/open-saas-v1.mp4";
|
||||
import openSaasV2 from "../../../assets/open-saas-v2/open-saas-v2.mp4";
|
||||
import Tweet from "../../../components/Tweet.astro";
|
||||
import StarOpenSaaSCTA from "../../../components/StarOpenSaaSCTA.astro";
|
||||
import redditPost from "../../../assets/cover-letter-gpt/coverlettergpt-reddit.png";
|
||||
import polar from "../../../assets/open-saas-v2/polar.webp";
|
||||
import llmsFull from "../../../assets/ai/llm-txt-chat.webp";
|
||||
import openSaasBanner from "../../../assets/open-saas-v2/open-saas-banner-light.png";
|
||||
import waitTheresMore from "../../../assets/open-saas-v2/wait-theres-more.jpg";
|
||||
import playwrightUi from "../../../assets/open-saas-v2/playwright-ui.png";
|
||||
|
||||
{/* <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! 🙏
|
||||
|
||||
<div style='border-radius: 8px; overflow: hidden;'>
|
||||
<div style="border-radius: 8px; overflow: hidden;">
|
||||
<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'
|
||||
style='position: relative; display: flex; justify-content: center; align-items: center; height: 200px; width: 100%;'
|
||||
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"
|
||||
style="position: relative; display: flex; justify-content: center; align-items: center; height: 200px; width: 100%;"
|
||||
>
|
||||
<Image
|
||||
src={openSaasBanner}
|
||||
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;'
|
||||
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;"
|
||||
/>
|
||||
<img
|
||||
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'
|
||||
style='width: 250px; height: 54px; position: relative; z-index: 2'
|
||||
width='250'
|
||||
height='54'
|
||||
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"
|
||||
style="width: 250px; height: 54px; position: relative; z-index: 2"
|
||||
width="250"
|
||||
height="54"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
@@ -67,11 +67,11 @@ So we've done just that.
|
||||
|
||||
🧐 <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>
|
||||
|
||||
<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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
<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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
<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.
|
||||
|
||||
**Open SaaS AI-friendly docs:**
|
||||
|
||||
```
|
||||
https://docs.opensaas.sh/llms-full.txt
|
||||
```
|
||||
|
||||
**Wasp framework AI-friendly docs:**
|
||||
|
||||
```
|
||||
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.
|
||||
|
||||
<div style='display: flex; justify-content: center;'>
|
||||
<Image src={playwrightUi} width={1050} alt='Playwright UI' style='border-radius: 8px;' />
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<Image
|
||||
src={playwrightUi}
|
||||
width={1050}
|
||||
alt="Playwright UI"
|
||||
style="border-radius: 8px;"
|
||||
/>
|
||||
</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.
|
||||
|
||||
### More Refactors & Improvements
|
||||
|
||||
<div style='display: flex; justify-content: center;'>
|
||||
<Image src={waitTheresMore} width={250} alt='But wait theres more!' style='border-radius: 8px;' />
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<Image
|
||||
src={waitTheresMore}
|
||||
width={250}
|
||||
alt="But wait theres more!"
|
||||
style="border-radius: 8px;"
|
||||
/>
|
||||
</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:
|
||||
|
||||
@@ -4,9 +4,10 @@ banner:
|
||||
content: |
|
||||
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 adminDashboard from '@assets/admin/admin-dashboard.png';
|
||||
|
||||
import { Image } from "astro:assets";
|
||||
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
```sh title=".env.server"
|
||||
ADMIN_EMAILS=me@example.com
|
||||
|
||||
@@ -53,6 +55,7 @@ If you're finding this template and its guides useful, consider giving us [a sta
|
||||
## Admin Dashboard Pages
|
||||
|
||||
### 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:
|
||||
|
||||
- [Payments Processor](/guides/payments-integration/):
|
||||
@@ -83,6 +86,7 @@ job dailyStatsJob {
|
||||
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 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
|
||||
|
||||
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
|
||||
- subscription/payment status
|
||||
- admin status
|
||||
|
||||
@@ -68,26 +68,27 @@ In general, we determine if a user has paid for an initial subscription by check
|
||||
- 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:
|
||||
|
||||
```tsx title="src/payment/stripe/webhook.ts"
|
||||
import { emailSender } from "wasp/server/email";
|
||||
//...
|
||||
|
||||
if (subscription.status === 'past_due') {
|
||||
if (subscription.status === "past_due") {
|
||||
const updatedCustomer = await context.entities.User.update({
|
||||
where: {
|
||||
id: customer.id,
|
||||
},
|
||||
data: {
|
||||
subscriptionStatus: 'past_due',
|
||||
subscriptionStatus: "past_due",
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedCustomer.email) {
|
||||
await emailSender.send({
|
||||
to: updatedCustomer.email,
|
||||
subject: 'Your Payment is Past Due',
|
||||
text: 'Please update your payment information to continue using our service.',
|
||||
html: '...',
|
||||
subject: "Your Payment is Past Due",
|
||||
text: "Please update your payment information to continue using our service.",
|
||||
html: "...",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ banner:
|
||||
content: |
|
||||
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).
|
||||
|
||||
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
|
||||
|
||||
### Hosted Plausible
|
||||
|
||||
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.
|
||||
@@ -47,8 +49,8 @@ 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.
|
||||
|
||||
*coming soon...*
|
||||
*until then, check out the [official documentation](https://plausible.io/docs)*
|
||||
_coming soon..._
|
||||
_until then, check out the [official documentation](https://plausible.io/docs)_
|
||||
|
||||
:::tip[Contribute!]
|
||||
If you'd like to help us write this guide, click the "Edit page" button at the bottom of this page
|
||||
@@ -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"
|
||||
//...
|
||||
import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils';
|
||||
import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
|
||||
import {
|
||||
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.
|
||||
@@ -77,6 +88,7 @@ Once you've created a new Property, some Installation Instructions will pop up.
|
||||
```sh title="<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:
|
||||
|
||||
```sh title=".env.client"
|
||||
@@ -99,15 +111,14 @@ Then, set up the Google Analytics API access by following these steps:
|
||||
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.
|
||||
|
||||
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:
|
||||
```sh
|
||||
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.
|
||||
|
||||
## Adding Analytics to your Blog
|
||||
|
||||
@@ -51,6 +51,7 @@ In order to use the `email` auth method in production, you'll need to switch fro
|
||||
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.
|
||||
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}
|
||||
auth: {
|
||||
methods: {
|
||||
@@ -74,8 +75,7 @@ In order to use the `email` auth method in production, you'll need to switch fro
|
||||
email: "me@example.com"
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
And that's it. Wasp will take care of the rest and update your AuthUI components accordingly.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
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 }"
|
||||
import { type User } from "wasp/entities";
|
||||
|
||||
export default function Example({ user }: { user: User }) {
|
||||
|
||||
if (user.subscriptionStatus === 'past_due') {
|
||||
return (<span>Your subscription is past due. Please update your payment information.</span>)
|
||||
if (user.subscriptionStatus === "past_due") {
|
||||
return (
|
||||
<span>
|
||||
Your subscription is past due. Please update your payment information.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (user.subscriptionStatus === 'cancel_at_period_end') {
|
||||
return (<span>Your susbscription will end on 01.01.2024</span>)
|
||||
if (user.subscriptionStatus === "cancel_at_period_end") {
|
||||
return <span>Your susbscription will end on 01.01.2024</span>;
|
||||
}
|
||||
if (user.subscriptionStatus === 'active') {
|
||||
return (<span>Thanks so much for your support!</span>)
|
||||
if (user.subscriptionStatus === "active") {
|
||||
return <span>Thanks so much for your support!</span>;
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
@@ -66,9 +69,7 @@ import { useAuth } from "wasp/client/auth";
|
||||
export default function ExampleHomePage() {
|
||||
const { data: user } = useAuth();
|
||||
|
||||
return (
|
||||
<h1> Hi {user.email || 'there'} 👋 </h1>
|
||||
)
|
||||
return <h1> Hi {user.email || "there"} 👋 </h1>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -90,5 +91,3 @@ export const someServerAction: SomeServerAction<...> = async (args, context) =>
|
||||
//...
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ banner:
|
||||
content: |
|
||||
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 preferences from '@assets/cookie-consent/preferences.png';
|
||||
|
||||
import { Image } from "astro:assets";
|
||||
import cookieBanner from "@assets/cookie-consent/cookiebanner.png";
|
||||
import preferences from "@assets/cookie-consent/preferences.png";
|
||||
|
||||
<Image src={cookieBanner} alt="cookie banner" width="400px" />
|
||||
|
||||
@@ -14,9 +15,10 @@ Cookie consent banners are annoying, we know. But they are legally required in m
|
||||
|
||||
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:
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
```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.
|
||||
|
||||
@@ -4,10 +4,11 @@ banner:
|
||||
content: |
|
||||
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 stripeListenEvents from '@assets/stripe/listen-to-stripe-events.png';
|
||||
import stripeSigningSecret from '@assets/stripe/stripe-webhook-signing-secret.png';
|
||||
|
||||
import { Image } from "astro:assets";
|
||||
import npmVersion from "@assets/stripe/npm-version.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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Deploying your App
|
||||
|
||||
### Steps for Deploying
|
||||
|
||||
These are the steps necessary for you to deploy your app. We recommend you follow these steps in order.
|
||||
@@ -30,39 +32,51 @@ 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.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
#### AWS S3 CORS configuration
|
||||
|
||||
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
|
||||
uploading guide](/guides/file-uploading/#change-the-cors-settings) for details.
|
||||
|
||||
#### Env Vars
|
||||
|
||||
Make sure you've got all your API keys and environment variables set up before you deploy.
|
||||
|
||||
##### 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.
|
||||
|
||||
##### 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.
|
||||
|
||||
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
|
||||
|
||||
- [ ] `DATABASE_URL`
|
||||
- [ ] `JWT_SECRET`
|
||||
- [ ] `WASP_WEB_CLIENT_URL`
|
||||
- [ ] `WASP_SERVER_URL`
|
||||
|
||||
###### Open AI API Key
|
||||
|
||||
- [ ] `OPENAI_API_KEY`
|
||||
|
||||
###### Sendgrid API Key
|
||||
|
||||
- [ ] `SENDGRID_API_KEY`
|
||||
|
||||
###### Social Auth Vars
|
||||
|
||||
- [ ] `GOOGLE_CLIENT_ID`
|
||||
- [ ] `GOOGLE_CLIENT_SECRET`
|
||||
- [ ] `GITHUB_CLIENT_ID`
|
||||
- [ ] `GITHUB_CLIENT_SECRET`
|
||||
|
||||
###### Analytics Vars
|
||||
|
||||
- [ ] `REACT_APP_PLAUSIBLE_ANALYTICS_ID` (for client-side)
|
||||
- [ ] `PLAUSIBLE_API_KEY`
|
||||
- [ ] `PLAUSIBLE_SITE_ID`
|
||||
@@ -71,9 +85,10 @@ Here are a list of all of them (some of which you may not be using, e.g. Analyti
|
||||
- [ ] `GOOGLE_ANALYTICS_CLIENT_EMAIL`
|
||||
- [ ] `GOOGLE_ANALYTICS_PROPERTY_ID`
|
||||
- [ ] `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_IAM_ACCESS_KEY`
|
||||
- [ ] `AWS_S3_IAM_SECRET_KEY`
|
||||
- [ ] `AWS_S3_FILES_BUCKET`
|
||||
@@ -95,11 +110,13 @@ To learn how, please follow the detailed guide for [deploying to Fly via the Was
|
||||
|
||||
:::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:
|
||||
|
||||
```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:
|
||||
|
||||
- `DATABASE_URL`
|
||||
- `PORT`
|
||||
- `JWT_SECRET`
|
||||
@@ -115,9 +132,11 @@ If you prefer to deploy manually, your frontend and backend separately, or just
|
||||
|
||||
:::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.
|
||||
|
||||
```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
|
||||
@@ -138,11 +157,13 @@ When you create your Stripe account, Stripe will automatically assign you to the
|
||||
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
|
||||
|
||||
```ts title="stripeClient.ts"
|
||||
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.
|
||||
|
||||
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,14 +173,17 @@ 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.
|
||||
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}
|
||||
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:
|
||||
- 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.
|
||||
- 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:
|
||||
- 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" />
|
||||
@@ -167,18 +191,20 @@ export const stripe = new Stripe(process.env.STRIPE_KEY!, {
|
||||
```sh
|
||||
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.
|
||||
|
||||
|
||||
#### Creating Your Production Webhook
|
||||
|
||||
1. go to [https://dashboard.stripe.com/webhooks](https://dashboard.stripe.com/webhooks)
|
||||
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`
|
||||
<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):
|
||||
<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.
|
||||
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
|
||||
wasp deploy fly cmd --context server secrets set STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
```
|
||||
@@ -188,6 +214,7 @@ wasp deploy fly cmd --context server secrets set STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
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):
|
||||
|
||||
- click the `+` button.
|
||||
- add the webhook forwarding url to the `Callback URL` section.
|
||||
- give your webhook a signing secret (a long, random string).
|
||||
@@ -199,12 +226,12 @@ With the webhook url ready, go to your [Lemon Squeezy Webhooks Dashboard](https:
|
||||
- subscription_cancelled
|
||||
- click `save`
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
Make sure you are logged in with Netlify CLI.
|
||||
|
||||
- You can check if you are logged in with `netlify status`,
|
||||
- you can log in with `netlify login`.
|
||||
|
||||
@@ -227,4 +254,3 @@ Finally, if the deployment looks good, you can deploy your blog to production wi
|
||||
```sh
|
||||
netlify deploy --prod
|
||||
```
|
||||
|
||||
|
||||
@@ -4,15 +4,18 @@ banner:
|
||||
content: |
|
||||
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.
|
||||
|
||||
## Sending Emails
|
||||
|
||||
### 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!
|
||||
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"
|
||||
app SaaSTemplate {
|
||||
// ...
|
||||
@@ -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.
|
||||
|
||||
### 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:
|
||||
|
||||
```tsx title="main.wasp"
|
||||
@@ -50,14 +54,14 @@ import { emailSender } from "wasp/server/email";
|
||||
|
||||
//...
|
||||
|
||||
if (subscription.cancel_at_period_end) {
|
||||
if (subscription.cancel_at_period_end) {
|
||||
await emailSender.send({
|
||||
to: customer.email,
|
||||
subject: 'We hate to see you go :(',
|
||||
text: 'We hate to see you go. Here is a sweet offer...',
|
||||
html: 'We hate to see you go. Here is a sweet offer...',
|
||||
subject: "We hate to see you go :(",
|
||||
text: "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.
|
||||
@@ -83,6 +87,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 SendGrid account to send emails with!
|
||||
},
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Mailgun">
|
||||
- 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!
|
||||
},
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -4,19 +4,21 @@ banner:
|
||||
content: |
|
||||
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 createBucket from '@assets/file-uploads/create-bucket.png';
|
||||
import defaultSettings from '@assets/file-uploads/default-settings.png';
|
||||
import newBucket from '@assets/file-uploads/new-bucket.png';
|
||||
import permissions from '@assets/file-uploads/permissions.png';
|
||||
import cors from '@assets/file-uploads/cors.png';
|
||||
import username from '@assets/file-uploads/username.png';
|
||||
import keys from '@assets/file-uploads/keys.png';
|
||||
|
||||
import { Image } from "astro:assets";
|
||||
import findS3 from "@assets/file-uploads/find-s3.png";
|
||||
import createBucket from "@assets/file-uploads/create-bucket.png";
|
||||
import defaultSettings from "@assets/file-uploads/default-settings.png";
|
||||
import newBucket from "@assets/file-uploads/new-bucket.png";
|
||||
import permissions from "@assets/file-uploads/permissions.png";
|
||||
import cors from "@assets/file-uploads/cors.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.
|
||||
|
||||
There are two options we recommend:
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
The process of generating a presigned URL is as follows:
|
||||
|
||||
1. The client sends a request to the server to upload a file
|
||||
2. The server generates a presigned URL using its AWS credentials
|
||||
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
|
||||
|
||||
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
|
||||
2. IAM users
|
||||
|
||||
@@ -78,7 +82,7 @@ Now we need to change some permissions on the bucket to allow for file uploads f
|
||||
<Image src={permissions} alt="permissions" loading="lazy" />
|
||||
3. Scroll down to the `Cross-origin resource sharing (CORS)` section and click `Edit`
|
||||
<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
|
||||
`AllowedOrigins` field** to fit your app. Include `http://localhost:3000` for
|
||||
local development, and `https://<your domain>` for production.
|
||||
@@ -86,20 +90,13 @@ 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
|
||||
now. We'll remind you to add your domain before deploying to production in
|
||||
the [Deployment docs](/guides/deploying/#aws-s3-cors-configuration).
|
||||
|
||||
```json {11,12}
|
||||
[
|
||||
{
|
||||
"AllowedHeaders": [
|
||||
"*"
|
||||
],
|
||||
"AllowedMethods": [
|
||||
"POST",
|
||||
"GET"
|
||||
],
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:3000",
|
||||
"https://<your-domain>"
|
||||
],
|
||||
"AllowedHeaders": ["*"],
|
||||
"AllowedMethods": ["POST", "GET"],
|
||||
"AllowedOrigins": ["http://localhost:3000", "https://<your-domain>"],
|
||||
"ExposeHeaders": []
|
||||
}
|
||||
]
|
||||
@@ -116,6 +113,7 @@ Now that you have your S3 bucket set up, you'll need to get your S3 credentials
|
||||
4. Select the `Application running on an AWS service` option and create the access key
|
||||
<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:
|
||||
|
||||
```sh
|
||||
AWS_S3_IAM_ACCESS_KEY=ACK...
|
||||
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.
|
||||
|
||||
To begin customizing file uploads, is important to know where everything lives in your app. Here's a quick overview:
|
||||
|
||||
- `main.wasp`:
|
||||
- The `File entity` can be found here. Here you can modify the fields to suit your needs.
|
||||
- `src/file-upload/FileUploadPage.tsx`:
|
||||
|
||||
@@ -4,20 +4,22 @@ banner:
|
||||
content: |
|
||||
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 testProduct from '@assets/stripe/test-product.png';
|
||||
import priceIds from '@assets/stripe/price-ids.png';
|
||||
import switchPlans from '@assets/stripe/switch-plans.png';
|
||||
import dbStudio from '@assets/stripe/db-studio.png';
|
||||
import addProduct from '@assets/lemon-squeezy/add-product.png';
|
||||
import addVariant from '@assets/lemon-squeezy/add-variant.png';
|
||||
import variantId from '@assets/lemon-squeezy/variant-id.png';
|
||||
import subscriptionVariantIds from '@assets/lemon-squeezy/subscription-variant-ids.png';
|
||||
import ngrok from '@assets/lemon-squeezy/ngrok.png';
|
||||
import storeId from '@assets/lemon-squeezy/store-id.png';
|
||||
|
||||
import { Image } from "astro:assets";
|
||||
import testApiKeys from "@assets/stripe/api-keys.png";
|
||||
import testProduct from "@assets/stripe/test-product.png";
|
||||
import priceIds from "@assets/stripe/price-ids.png";
|
||||
import switchPlans from "@assets/stripe/switch-plans.png";
|
||||
import dbStudio from "@assets/stripe/db-studio.png";
|
||||
import addProduct from "@assets/lemon-squeezy/add-product.png";
|
||||
import addVariant from "@assets/lemon-squeezy/add-variant.png";
|
||||
import variantId from "@assets/lemon-squeezy/variant-id.png";
|
||||
import subscriptionVariantIds from "@assets/lemon-squeezy/subscription-variant-ids.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:
|
||||
|
||||
- Stripe
|
||||
- Lemon Squeezy
|
||||
|
||||
@@ -31,8 +33,8 @@ 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:
|
||||
|
||||
```ts title="src/payment/paymentProcessor.ts" ins={5, 7}
|
||||
import { stripePaymentProcessor } from './stripe/paymentProcessor';
|
||||
import { lemonSqueezyPaymentProcessor } from './lemonSqueezy/paymentProcessor';
|
||||
import { stripePaymentProcessor } from "./stripe/paymentProcessor";
|
||||
import { lemonSqueezyPaymentProcessor } from "./lemonSqueezy/paymentProcessor";
|
||||
//...
|
||||
|
||||
export const paymentProcessor: PaymentProcessor = stripePaymentProcessor;
|
||||
@@ -41,6 +43,7 @@ export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor;
|
||||
```
|
||||
|
||||
At this point, you can delete:
|
||||
|
||||
- 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):
|
||||
- e.g. `STRIPE_API_KEY`, `STRIPE_CUSTOMER_PORTAL_URL`, `LEMONSQUEEZY_API_KEY`, `LEMONSQUEEZY_WEBHOOK_SECRET`
|
||||
@@ -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).
|
||||
|
||||
- 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
|
||||
:::
|
||||
:::
|
||||
|
||||
### Set up the Customer Portal
|
||||
|
||||
@@ -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.
|
||||
|
||||
First, login:
|
||||
|
||||
```sh
|
||||
stripe login
|
||||
```
|
||||
@@ -183,12 +187,12 @@ The results of the event firing will be visible in the initial terminal window.
|
||||
|
||||
```sh
|
||||
...
|
||||
2023-11-21 09:31:09 --> invoice.paid [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 <-- [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:11 <-- [200] POST http://localhost:3001/payments-webhook [evt_1OEpMQILOQf67J5ThTZ0999r]
|
||||
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: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 -- [evt_1OEpMQILOQf67J5ThTZ0999r] > checkout.session.completed
|
||||
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
|
||||
@@ -260,14 +264,18 @@ To create a test product, go to the test products url [https://app.lemonsqueezy.
|
||||
- Click on the `+ New Product` button and fill in the relevant information for your product.
|
||||
- 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.
|
||||
<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.
|
||||
- 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`
|
||||
<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.
|
||||
<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:
|
||||
- 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=`.
|
||||
@@ -280,6 +288,7 @@ 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/).
|
||||
|
||||
Once installed, and with your wasp app running, run:
|
||||
|
||||
```sh
|
||||
ngrok http 3001
|
||||
```
|
||||
@@ -293,6 +302,7 @@ 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):
|
||||
|
||||
- click the `+` button.
|
||||
- add the newly created webhook forwarding url to the `Callback URL` section.
|
||||
- give your webhook a signing secret (a long, random string).
|
||||
|
||||
@@ -4,8 +4,9 @@ banner:
|
||||
content: |
|
||||
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
|
||||
|
||||
@@ -46,13 +47,13 @@ Astro, being a static-site generator, will automatically inject relevant informa
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: 'My First Blog Post'
|
||||
title: "My First Blog Post"
|
||||
pubDate: 2022-07-01
|
||||
description: 'This is the first post of my new Astro blog.'
|
||||
author: 'Astro Learner'
|
||||
description: "This is the first post of my new Astro blog."
|
||||
author: "Astro Learner"
|
||||
image:
|
||||
url: 'https://docs.astro.build/assets/full-logo-light.png'
|
||||
alt: 'The full Astro logo.'
|
||||
url: "https://docs.astro.build/assets/full-logo-light.png"
|
||||
alt: "The full Astro logo."
|
||||
tags: ["astro", "blogging", "learning in public"]
|
||||
---
|
||||
```
|
||||
@@ -66,4 +67,5 @@ Open SaaS and Wasp do not currently have a SSR option (although it is coming soo
|
||||
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!
|
||||
|
||||
<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.
|
||||
|
||||
## 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).
|
||||
|
||||
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,7 +11,6 @@ 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.
|
||||
:::
|
||||
|
||||
|
||||
## 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.
|
||||
@@ -20,7 +19,7 @@ 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.
|
||||
|
||||
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.
|
||||
- Updates might not be compatible with your version of Wasp.
|
||||
@@ -32,13 +31,15 @@ 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.
|
||||
|
||||
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,
|
||||
- being mindful of the Wasp version you're using,
|
||||
- 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,
|
||||
- and then copying over the logic from your existing project that you want to keep.
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ banner:
|
||||
content: |
|
||||
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 llmsTextChat from '@assets/ai/llm-txt-chat.webp';
|
||||
import vibeBoi from '@assets/ai/vibe-boi.png';
|
||||
|
||||
import { Image } from "astro:assets";
|
||||
import llmsFullCursor from "@assets/ai/llm-full-cursor.webp";
|
||||
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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
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 set of example prompts, `app/.cursor/example-prompts.md`, to help you get started.
|
||||
|
||||
### 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-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.
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
- [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: |
|
||||
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!
|
||||
|
||||
{/* TODO: add a screenshot of the app */}
|
||||
|
||||
<HiddenLLMHelper />
|
||||
|
||||
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?
|
||||
|
||||
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
|
||||
- 💸 [Stripe](https://stripe.com) or [Lemon Squeezy](https://lemonsqueezy.com/) - for products and payments
|
||||
- 📈 [Plausible](https://plausible.io) or [Google](https://analytics.google.com/) Analytics
|
||||
- 🤖 [OpenAI](https://openai.com) - OpenAI API integrated into the app or [Replicate](https://replicate.com/) (coming soon 👀)
|
||||
- 📦 [AWS S3](https://aws.amazon.com/s3/) - for file uploads
|
||||
- 📧 [SendGrid](https://sendgrid.com), [MailGun](https://mailgun.com), or SMTP - for email sending
|
||||
- 💅 [TailwindCSS](https://tailwindcss.com) - for styling
|
||||
- 💼 [TailAdmin](https://tailadmin.com/) - admin dashboard & components for TailwindCSS
|
||||
|
||||
- 🐝 [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
|
||||
- 💸 [Stripe](https://stripe.com) or [Lemon Squeezy](https://lemonsqueezy.com/) - for products and payments
|
||||
- 📈 [Plausible](https://plausible.io) or [Google](https://analytics.google.com/) Analytics
|
||||
- 🤖 [OpenAI](https://openai.com) - OpenAI API integrated into the app or [Replicate](https://replicate.com/) (coming soon 👀)
|
||||
- 📦 [AWS S3](https://aws.amazon.com/s3/) - for file uploads
|
||||
- 📧 [SendGrid](https://sendgrid.com), [MailGun](https://mailgun.com), or SMTP - for email sending
|
||||
- 💅 [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:
|
||||
- 🔐 [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!
|
||||
- 🤖 [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).
|
||||
|
||||
- 🔐 [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!
|
||||
- 🤖 [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.
|
||||
- 🤝 [Wasp Discord](https://discord.gg/rzdnErX)
|
||||
|
||||
- 🤝 [Wasp Discord](https://discord.gg/rzdnErX)
|
||||
|
||||
:::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.
|
||||
|
||||
@@ -4,7 +4,8 @@ banner:
|
||||
content: |
|
||||
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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
:::note[Installing and using nvm]
|
||||
|
||||
<details aria-label="Installing and using nvm">
|
||||
<summary aria-label="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.
|
||||
|
||||
Check NVM repo for more details: [https://github.com/nvm-sh/nvm](https://github.com/nvm-sh/nvm).
|
||||
|
||||
</div>
|
||||
</details>
|
||||
:::
|
||||
@@ -65,6 +68,7 @@ curl -sSL https://get.wasp.sh/installer.sh | sh
|
||||
```
|
||||
|
||||
:::caution[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)?">
|
||||
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
|
||||
softwareupdate --install-rosetta
|
||||
```
|
||||
|
||||
Once Rosetta is installed, you should be able to run Wasp without any issues.
|
||||
|
||||
</details>
|
||||
@@ -114,18 +119,22 @@ su -s $USER
|
||||
</details>
|
||||
:::
|
||||
|
||||
|
||||
Once in WSL2, run the following command in your **WSL2 environment**:
|
||||
|
||||
```sh
|
||||
curl -sSL https://get.wasp.sh/installer.sh | sh
|
||||
```
|
||||
|
||||
:::caution[WSL2 and file system issues]
|
||||
|
||||
<details 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?
|
||||
</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>
|
||||
:::
|
||||
|
||||
@@ -148,6 +157,7 @@ You can install the Wasp VSCode extension by searching for "Wasp" in the Extensi
|
||||
### Cloning the OpenSaaS template
|
||||
|
||||
From the directory where you'd like to create your new project run:
|
||||
|
||||
```sh
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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:
|
||||
|
||||
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"
|
||||
[ Server ] ╔═══════════════════════╗
|
||||
[ Server ] ║ Dummy email sender ✉️ ║
|
||||
@@ -22,6 +23,7 @@ Awesome, you now have your very own SaaS app up and running! But, first, here ar
|
||||
[ Server ] <a href="http://localhost:3000/email-verification?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InZpbm55QHdhc3Auc2giLCJleHAiOjE3MTg5NjUyNTB9.PkRGrmuDPuYFXkTprf7QpAye0e_O9a70xbER6LfxGJw">Verify email</a>
|
||||
[ 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**.
|
||||
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.
|
||||
|
||||
@@ -30,9 +32,11 @@ In the sections below, we will take a short guide through the codebase and the a
|
||||
We're looking forward to seeing what you build!
|
||||
|
||||
## Getting acquainted with 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:
|
||||
|
||||
```sh
|
||||
.
|
||||
├── app
|
||||
@@ -113,15 +117,14 @@ The `src/client` folder contains any additional client-side code that doesn't be
|
||||
```sh
|
||||
.
|
||||
└── client
|
||||
├── components # Your shared React components.
|
||||
├── fonts # Extra fonts
|
||||
├── hooks # Your shared React hooks.
|
||||
├── icons # Your shared SVG icons.
|
||||
├── 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.
|
||||
├── cn.ts # Helper function for dynamic and conditional Tailwind CSS classes.
|
||||
└── Main.css
|
||||
|
||||
├── components # Your shared React components.
|
||||
├── fonts # Extra fonts
|
||||
├── hooks # Your shared React hooks.
|
||||
├── icons # Your shared SVG icons.
|
||||
├── 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.
|
||||
├── cn.ts # Helper function for dynamic and conditional Tailwind CSS classes.
|
||||
└── Main.css
|
||||
```
|
||||
|
||||
### Server
|
||||
@@ -130,8 +133,8 @@ The `src/server` folder contains any additional server-side code that does not b
|
||||
|
||||
```sh
|
||||
└── server
|
||||
├── scripts # Scripts to run via Wasp, e.g. database seeding.
|
||||
└── utils.ts
|
||||
├── scripts # Scripts to run via Wasp, e.g. database seeding.
|
||||
└── utils.ts
|
||||
```
|
||||
|
||||
## Main Features
|
||||
@@ -156,6 +159,7 @@ 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:
|
||||
|
||||
- Email verified login with reset password
|
||||
- Social login with Google and/or GitHub
|
||||
- Auth-related database entities for user credentials, sessions, and social logins
|
||||
@@ -171,7 +175,7 @@ You can get started developing your app with the `email` method right away!
|
||||
:::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.
|
||||
|
||||
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"
|
||||
emailSender: {
|
||||
@@ -182,6 +186,7 @@ For development purposes, Wasp provides a `Dummy` email sender which Open SaaS c
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
We will explain more about these auth methods, and how to properly integrate them into your app, in the [Authentication Guide](/guides/authentication/).
|
||||
@@ -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:
|
||||
|
||||
a) define the action in the `main.wasp` file
|
||||
|
||||
```js title="main.wasp"
|
||||
action generateCheckoutSession {
|
||||
fn: import { generateCheckoutSession } from "@src/payment/operations",
|
||||
@@ -211,13 +217,15 @@ action generateCheckoutSession {
|
||||
```
|
||||
|
||||
b) implement the action in the `src/payment/operations` file
|
||||
|
||||
```js title="src/server/actions.ts"
|
||||
export const generateCheckoutSession = async (paymentPlanId, context) => {
|
||||
//...
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
c) call the action on the client-side
|
||||
|
||||
```js title="src/client/app/SubscriptionPage.tsx"
|
||||
import { generateCheckoutSession } from "wasp/client/operations";
|
||||
|
||||
@@ -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)
|
||||
:::
|
||||
|
||||
|
||||
### 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.
|
||||
@@ -294,8 +301,11 @@ 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.
|
||||
|
||||
### Customizations Checklist
|
||||
|
||||
#### `main.wasp` Config File
|
||||
|
||||
- [ ] Change the app name and title:
|
||||
|
||||
```ts title="main.wasp" {1, 6}
|
||||
app YourAppName {
|
||||
wasp: {
|
||||
@@ -304,9 +314,11 @@ But before you start setting up the main features, let's walk through the custom
|
||||
|
||||
title: "Your App Name",
|
||||
```
|
||||
|
||||
:::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`.
|
||||
:::
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
|
||||
#### Customizing the Look / Style of the App
|
||||
|
||||
- [ ] 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 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!**
|
||||
|
||||
#### Customizing the Analytics & Admin Dashboard
|
||||
|
||||
- [ ] 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).
|
||||
- [ ] 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.
|
||||
|
||||
#### `.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.
|
||||
- [ ] Delete any redundant environment variables that you're not using, from the `.env.*` files as well as the `.env.*.example` files.
|
||||
|
||||
#### Other Customizations
|
||||
|
||||
- [ ] Make a new GitHub Repo for your app.
|
||||
- [ ] Deploy your app to a hosting provider.
|
||||
- [ ] Buy a domain name for your app and get it set up with your hosting provider.
|
||||
|
||||
16
opensaas-sh/blog/src/virtual.d.ts
vendored
16
opensaas-sh/blog/src/virtual.d.ts
vendored
@@ -1,22 +1,22 @@
|
||||
declare module 'virtual:starlight-blog-config' {
|
||||
const StarlightBlogConfig: import('./libs/config').StarlightBlogConfig;
|
||||
declare module "virtual:starlight-blog-config" {
|
||||
const StarlightBlogConfig: import("./libs/config").StarlightBlogConfig;
|
||||
|
||||
export default StarlightBlogConfig;
|
||||
}
|
||||
declare module 'virtual:starlight/user-config' {
|
||||
const Config: import('@astrojs/starlight/types').StarlightConfig;
|
||||
declare module "virtual:starlight/user-config" {
|
||||
const Config: import("@astrojs/starlight/types").StarlightConfig;
|
||||
|
||||
export default Config;
|
||||
}
|
||||
declare module 'virtual:starlight/user-images' {
|
||||
type ImageMetadata = import('astro').ImageMetadata;
|
||||
declare module "virtual:starlight/user-images" {
|
||||
type ImageMetadata = import("astro").ImageMetadata;
|
||||
export const logos: {
|
||||
dark?: ImageMetadata;
|
||||
light?: ImageMetadata;
|
||||
};
|
||||
}
|
||||
declare module 'virtual:astro-config' {
|
||||
const Config: import('@astrojs/types').Config;
|
||||
declare module "virtual:astro-config" {
|
||||
const Config: import("@astrojs/types").Config;
|
||||
|
||||
export default Config;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import starlightPlugin from "@astrojs/starlight-tailwind";
|
||||
import colors from "tailwindcss/colors";
|
||||
|
||||
const yellow = colors.yellow
|
||||
const gray = colors.gray
|
||||
const yellow = colors.yellow;
|
||||
const gray = colors.gray;
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
@@ -10,7 +10,8 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
accent: yellow, gray
|
||||
accent: yellow,
|
||||
gray,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -142,7 +142,7 @@ recreate_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;
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GREEN_COLOR}All patches successfully applied.${RESET_COLOR}"
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# <YOUR_APP_NAME>
|
||||
|
||||
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).
|
||||
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,9 +3,10 @@
|
||||
### 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:
|
||||
- `<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.
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ This template includes [ShadCN UI](https://ui.shadcn.com/) v2 for beautiful, acc
|
||||
## Development
|
||||
|
||||
### 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 { getDailyStats, useQuery } from 'wasp/client/operations';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import RevenueAndProfitChart from './RevenueAndProfitChart';
|
||||
import SourcesTable from './SourcesTable';
|
||||
import TotalPageViewsCard from './TotalPageViewsCard';
|
||||
import TotalPayingUsersCard from './TotalPayingUsersCard';
|
||||
import TotalRevenueCard from './TotalRevenueCard';
|
||||
import TotalSignupsCard from './TotalSignupsCard';
|
||||
import { type AuthUser } from "wasp/auth";
|
||||
import { getDailyStats, useQuery } from "wasp/client/operations";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import DefaultLayout from "../../layout/DefaultLayout";
|
||||
import RevenueAndProfitChart from "./RevenueAndProfitChart";
|
||||
import SourcesTable from "./SourcesTable";
|
||||
import TotalPageViewsCard from "./TotalPageViewsCard";
|
||||
import TotalPayingUsersCard from "./TotalPayingUsersCard";
|
||||
import TotalRevenueCard from "./TotalRevenueCard";
|
||||
import TotalSignupsCard from "./TotalSignupsCard";
|
||||
|
||||
const Dashboard = ({ user }: { user: AuthUser }) => {
|
||||
const { data: stats, isLoading, error } = useQuery(getDailyStats);
|
||||
@@ -15,11 +15,11 @@ const Dashboard = ({ user }: { user: AuthUser }) => {
|
||||
if (error) {
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<div className='rounded-lg bg-card p-8 shadow-lg'>
|
||||
<p className='text-2xl font-bold text-red-500'>Error</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{error.message || 'Something went wrong while fetching stats.'}
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="bg-card rounded-lg p-8 shadow-lg">
|
||||
<p className="text-2xl font-bold text-red-500">Error</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{error.message || "Something went wrong while fetching stats."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,40 +29,53 @@ const Dashboard = ({ user }: { user: AuthUser }) => {
|
||||
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<div className='relative'>
|
||||
<div className="relative">
|
||||
<div
|
||||
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
|
||||
totalPageViews={stats?.dailyStats.totalViews}
|
||||
prevDayViewsChangePercent={stats?.dailyStats.prevDayViewsChangePercent}
|
||||
prevDayViewsChangePercent={
|
||||
stats?.dailyStats.prevDayViewsChangePercent
|
||||
}
|
||||
/>
|
||||
<TotalRevenueCard
|
||||
dailyStats={stats?.dailyStats}
|
||||
weeklyStats={stats?.weeklyStats}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<TotalPayingUsersCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
|
||||
<TotalSignupsCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
|
||||
<TotalPayingUsersCard
|
||||
dailyStats={stats?.dailyStats}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<TotalSignupsCard
|
||||
dailyStats={stats?.dailyStats}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</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'>
|
||||
<RevenueAndProfitChart weeklyStats={stats?.weeklyStats} isLoading={isLoading} />
|
||||
<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}
|
||||
/>
|
||||
|
||||
<div className='col-span-12 xl:col-span-8'>
|
||||
<div className="col-span-12 xl:col-span-8">
|
||||
<SourcesTable sources={stats?.dailyStats?.sources} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!stats && (
|
||||
<div className='absolute inset-0 flex items-start justify-center bg-background/50'>
|
||||
<div className='rounded-lg bg-card p-8 shadow-lg'>
|
||||
<p className='text-2xl font-bold text-foreground'>No daily stats generated yet</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
<div className="bg-background/50 absolute inset-0 flex items-start justify-center">
|
||||
<div className="bg-card rounded-lg p-8 shadow-lg">
|
||||
<p className="text-foreground text-2xl font-bold">
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { ApexOptions } from 'apexcharts';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import ReactApexChart from 'react-apexcharts';
|
||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
||||
import { ApexOptions } from "apexcharts";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import ReactApexChart from "react-apexcharts";
|
||||
import { type DailyStatsProps } from "../../../analytics/stats";
|
||||
|
||||
const options: ApexOptions = {
|
||||
legend: {
|
||||
show: false,
|
||||
position: 'top',
|
||||
horizontalAlign: 'left',
|
||||
position: "top",
|
||||
horizontalAlign: "left",
|
||||
},
|
||||
colors: ['#3C50E0', '#80CAEE'],
|
||||
colors: ["#3C50E0", "#80CAEE"],
|
||||
chart: {
|
||||
fontFamily: 'Satoshi, sans-serif',
|
||||
fontFamily: "Satoshi, sans-serif",
|
||||
height: 335,
|
||||
type: 'area',
|
||||
type: "area",
|
||||
dropShadow: {
|
||||
enabled: true,
|
||||
color: '#623CEA14',
|
||||
color: "#623CEA14",
|
||||
top: 10,
|
||||
blur: 4,
|
||||
left: 0,
|
||||
@@ -47,7 +47,7 @@ const options: ApexOptions = {
|
||||
],
|
||||
stroke: {
|
||||
width: [2, 2],
|
||||
curve: 'straight',
|
||||
curve: "straight",
|
||||
},
|
||||
// labels: {
|
||||
// show: false,
|
||||
@@ -70,8 +70,8 @@ const options: ApexOptions = {
|
||||
},
|
||||
markers: {
|
||||
size: 4,
|
||||
colors: '#fff',
|
||||
strokeColors: ['#3056D3', '#80CAEE'],
|
||||
colors: "#fff",
|
||||
strokeColors: ["#3056D3", "#80CAEE"],
|
||||
strokeWidth: 3,
|
||||
strokeOpacity: 0.9,
|
||||
strokeDashArray: 0,
|
||||
@@ -83,7 +83,7 @@ const options: ApexOptions = {
|
||||
},
|
||||
},
|
||||
xaxis: {
|
||||
type: 'category',
|
||||
type: "category",
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
@@ -94,7 +94,7 @@ const options: ApexOptions = {
|
||||
yaxis: {
|
||||
title: {
|
||||
style: {
|
||||
fontSize: '0px',
|
||||
fontSize: "0px",
|
||||
},
|
||||
},
|
||||
min: 0,
|
||||
@@ -123,8 +123,8 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
if (!!weeklyStats && weeklyStats?.length > 0) {
|
||||
const datesArr = weeklyStats?.map((stat) => {
|
||||
// get day of week, month, and day of month
|
||||
const dateArr = stat.date.toString().split(' ');
|
||||
return dateArr.slice(0, 3).join(' ');
|
||||
const dateArr = stat.date.toString().split(" ");
|
||||
return dateArr.slice(0, 3).join(" ");
|
||||
});
|
||||
return datesArr;
|
||||
}
|
||||
@@ -133,7 +133,7 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
const [state, setState] = useState<ChartOneState>({
|
||||
series: [
|
||||
{
|
||||
name: 'Profit',
|
||||
name: "Profit",
|
||||
data: [4, 7, 10, 11, 13, 14, 17],
|
||||
},
|
||||
],
|
||||
@@ -144,7 +144,9 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
if (dailyRevenueArray && dailyRevenueArray.length > 0) {
|
||||
setState((prevState) => {
|
||||
// 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) {
|
||||
// Update existing "Revenue" series data
|
||||
@@ -164,7 +166,7 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
series: [
|
||||
...prevState.series,
|
||||
{
|
||||
name: 'Revenue',
|
||||
name: "Revenue",
|
||||
data: dailyRevenueArray,
|
||||
},
|
||||
],
|
||||
@@ -198,37 +200,41 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
}, [daysOfWeekArr, dailyRevenueArray]);
|
||||
|
||||
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='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 min-w-47.5'>
|
||||
<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='block h-2.5 w-full max-w-2.5 rounded-full bg-primary'></span>
|
||||
<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 w-full flex-wrap gap-3 sm:gap-5">
|
||||
<div className="min-w-47.5 flex">
|
||||
<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="bg-primary block h-2.5 w-full max-w-2.5 rounded-full"></span>
|
||||
</span>
|
||||
<div className='w-full'>
|
||||
<p className='font-semibold text-primary'>Total Profit</p>
|
||||
<p className='text-sm font-medium text-muted-foreground'>Last 7 Days</p>
|
||||
<div className="w-full">
|
||||
<p className="text-primary font-semibold">Total Profit</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Last 7 Days
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex min-w-47.5'>
|
||||
<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='block h-2.5 w-full max-w-2.5 rounded-full bg-secondary'></span>
|
||||
<div className="min-w-47.5 flex">
|
||||
<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="bg-secondary block h-2.5 w-full max-w-2.5 rounded-full"></span>
|
||||
</span>
|
||||
<div className='w-full'>
|
||||
<p className='font-semibold text-secondary'>Total Revenue</p>
|
||||
<p className='text-sm font-medium text-muted-foreground'>Last 7 Days</p>
|
||||
<div className="w-full">
|
||||
<p className="text-secondary font-semibold">Total Revenue</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Last 7 Days
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex w-full max-w-45 justify-end'>
|
||||
<div className='inline-flex items-center rounded-md bg-muted 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'>
|
||||
<div className="max-w-45 flex w-full justify-end">
|
||||
<div className="bg-muted inline-flex items-center rounded-md p-1.5">
|
||||
<button className="bg-background text-foreground shadow-card hover:bg-background hover:shadow-card rounded px-3 py-1 text-xs font-medium">
|
||||
Day
|
||||
</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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -236,8 +242,13 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div id='chartOne' className='-ml-5'>
|
||||
<ReactApexChart options={chartOptions} series={state.series} type='area' height={350} />
|
||||
<div id="chartOne" className="-ml-5">
|
||||
<ReactApexChart
|
||||
options={chartOptions}
|
||||
series={state.series}
|
||||
type="area"
|
||||
height={350}
|
||||
/>
|
||||
</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 (
|
||||
<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'>
|
||||
<h4 className='mb-6 text-xl font-semibold text-foreground'>Top Sources</h4>
|
||||
<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="text-foreground mb-6 text-xl font-semibold">
|
||||
Top Sources
|
||||
</h4>
|
||||
|
||||
<div className='flex flex-col'>
|
||||
<div className='grid grid-cols-3 rounded-sm bg-gray-2 '>
|
||||
<div className='p-2.5 xl:p-5'>
|
||||
<h5 className='text-sm font-medium uppercase xsm:text-base'>Source</h5>
|
||||
<div className="flex flex-col">
|
||||
<div className="bg-gray-2 grid grid-cols-3 rounded-sm">
|
||||
<div className="p-2.5 xl:p-5">
|
||||
<h5 className="xsm:text-base text-sm font-medium uppercase">
|
||||
Source
|
||||
</h5>
|
||||
</div>
|
||||
<div className='p-2.5 text-center xl:p-5'>
|
||||
<h5 className='text-sm font-medium uppercase xsm:text-base'>Visitors</h5>
|
||||
<div className="p-2.5 text-center xl:p-5">
|
||||
<h5 className="xsm:text-base text-sm font-medium uppercase">
|
||||
Visitors
|
||||
</h5>
|
||||
</div>
|
||||
<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>
|
||||
<div className="hidden p-2.5 text-center sm:block xl:p-5">
|
||||
<h5 className="xsm:text-base text-sm font-medium uppercase">
|
||||
Sales
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sources && sources.length > 0 ? (
|
||||
sources.map((source) => (
|
||||
<div className='grid grid-cols-3 border-b border-border'>
|
||||
<div className='flex items-center gap-3 p-2.5 xl:p-5'>
|
||||
<p className='text-foreground'>{source.name}</p>
|
||||
<div className="border-border grid grid-cols-3 border-b">
|
||||
<div className="flex items-center gap-3 p-2.5 xl:p-5">
|
||||
<p className="text-foreground">{source.name}</p>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-center p-2.5 xl:p-5'>
|
||||
<p className='text-foreground'>{source.visitors}</p>
|
||||
<div className="flex items-center justify-center p-2.5 xl:p-5">
|
||||
<p className="text-foreground">{source.visitors}</p>
|
||||
</div>
|
||||
|
||||
<div className='hidden items-center justify-center p-2.5 sm:flex xl:p-5'>
|
||||
<p className='text-foreground'>--</p>
|
||||
<div className="hidden items-center justify-center p-2.5 sm:flex xl:p-5">
|
||||
<p className="text-foreground">--</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className='flex items-center justify-center p-2.5 xl:p-5'>
|
||||
<p className='text-foreground'>No data to display</p>
|
||||
<div className="flex items-center justify-center p-2.5 xl:p-5">
|
||||
<p className="text-foreground">No data to display</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,42 +1,57 @@
|
||||
import { ArrowDown, ArrowUp, Eye } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { ArrowDown, ArrowUp, Eye } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
type PageViewsStats = {
|
||||
totalPageViews: number | undefined;
|
||||
prevDayViewsChangePercent: string | undefined;
|
||||
};
|
||||
|
||||
const TotalPageViewsCard = ({ totalPageViews, prevDayViewsChangePercent }: PageViewsStats) => {
|
||||
const prevDayViewsChangePercentValue = parseInt(prevDayViewsChangePercent || '');
|
||||
const TotalPageViewsCard = ({
|
||||
totalPageViews,
|
||||
prevDayViewsChangePercent,
|
||||
}: PageViewsStats) => {
|
||||
const prevDayViewsChangePercentValue = parseInt(
|
||||
prevDayViewsChangePercent || "",
|
||||
);
|
||||
const isDeltaPositive = prevDayViewsChangePercentValue > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
|
||||
<Eye className='size-6' />
|
||||
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
|
||||
<Eye className="size-6" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='flex justify-between'>
|
||||
<CardContent className="flex justify-between">
|
||||
<div>
|
||||
<h4 className='text-title-md font-bold text-foreground'>{totalPageViews}</h4>
|
||||
<span className='text-sm font-medium text-muted-foreground'>Total page views</span>
|
||||
<h4 className="text-title-md text-foreground font-bold">
|
||||
{totalPageViews}
|
||||
</h4>
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
Total page views
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
||||
'text-success':
|
||||
isDeltaPositive && prevDayViewsChangePercent && prevDayViewsChangePercentValue !== 0,
|
||||
'text-destructive':
|
||||
!isDeltaPositive && prevDayViewsChangePercent && prevDayViewsChangePercentValue !== 0,
|
||||
'text-muted-foreground': !prevDayViewsChangePercent || prevDayViewsChangePercentValue === 0,
|
||||
className={cn("flex items-center gap-1 text-sm font-medium", {
|
||||
"text-success":
|
||||
isDeltaPositive &&
|
||||
prevDayViewsChangePercent &&
|
||||
prevDayViewsChangePercentValue !== 0,
|
||||
"text-destructive":
|
||||
!isDeltaPositive &&
|
||||
prevDayViewsChangePercent &&
|
||||
prevDayViewsChangePercentValue !== 0,
|
||||
"text-muted-foreground":
|
||||
!prevDayViewsChangePercent ||
|
||||
prevDayViewsChangePercentValue === 0,
|
||||
})}
|
||||
>
|
||||
{prevDayViewsChangePercent && prevDayViewsChangePercentValue !== 0
|
||||
? `${prevDayViewsChangePercent}%`
|
||||
: '-'}
|
||||
: "-"}
|
||||
{prevDayViewsChangePercent &&
|
||||
prevDayViewsChangePercentValue !== 0 &&
|
||||
(isDeltaPositive ? <ArrowUp /> : <ArrowDown />)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ArrowDown, ArrowUp, ShoppingBag } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { ArrowDown, ArrowUp, ShoppingBag } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { type DailyStatsProps } from "../../../analytics/stats";
|
||||
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
||||
const isDeltaPositive = useMemo(() => {
|
||||
@@ -12,25 +12,30 @@ const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
|
||||
<ShoppingBag className='size-6' />
|
||||
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
|
||||
<ShoppingBag className="size-6" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='flex justify-between'>
|
||||
<CardContent className="flex justify-between">
|
||||
<div>
|
||||
<h4 className='text-title-md font-bold text-foreground'>{dailyStats?.paidUserCount}</h4>
|
||||
<span className='text-sm font-medium text-muted-foreground'>Total Paying Users</span>
|
||||
<h4 className="text-title-md text-foreground font-bold">
|
||||
{dailyStats?.paidUserCount}
|
||||
</h4>
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
Total Paying Users
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
||||
'text-success': isDeltaPositive && !isLoading,
|
||||
'text-destructive': !isDeltaPositive && !isLoading && dailyStats?.paidUserDelta !== 0,
|
||||
'text-muted-foreground': isLoading || !dailyStats?.paidUserDelta,
|
||||
className={cn("flex items-center gap-1 text-sm font-medium", {
|
||||
"text-success": isDeltaPositive && !isLoading,
|
||||
"text-destructive":
|
||||
!isDeltaPositive && !isLoading && dailyStats?.paidUserDelta !== 0,
|
||||
"text-muted-foreground": isLoading || !dailyStats?.paidUserDelta,
|
||||
})}
|
||||
>
|
||||
{isLoading ? '...' : dailyStats?.paidUserDelta ?? '-'}
|
||||
{isLoading ? "..." : (dailyStats?.paidUserDelta ?? "-")}
|
||||
{!isLoading &&
|
||||
(dailyStats?.paidUserDelta ?? 0) !== 0 &&
|
||||
(isDeltaPositive ? <ArrowUp /> : <ArrowDown />)}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { ArrowDown, ArrowUp, ShoppingCart } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { ArrowDown, ArrowUp, ShoppingCart } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { type DailyStatsProps } from "../../../analytics/stats";
|
||||
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
const TotalRevenueCard = ({ dailyStats, weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
const TotalRevenueCard = ({
|
||||
dailyStats,
|
||||
weeklyStats,
|
||||
isLoading,
|
||||
}: DailyStatsProps) => {
|
||||
const isDeltaPositive = useMemo(() => {
|
||||
if (!weeklyStats) return false;
|
||||
return weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue > 0;
|
||||
@@ -12,37 +16,54 @@ const TotalRevenueCard = ({ dailyStats, weeklyStats, isLoading }: DailyStatsProp
|
||||
|
||||
const deltaPercentage = useMemo(() => {
|
||||
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);
|
||||
|
||||
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);
|
||||
}, [weeklyStats]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
|
||||
<ShoppingCart className='size-6' />
|
||||
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
|
||||
<ShoppingCart className="size-6" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='flex justify-between'>
|
||||
<CardContent className="flex justify-between">
|
||||
<div>
|
||||
<h4 className='text-title-md font-bold text-foreground'>${dailyStats?.totalRevenue}</h4>
|
||||
<span className='text-sm font-medium text-muted-foreground'>Total Revenue</span>
|
||||
<h4 className="text-title-md text-foreground font-bold">
|
||||
${dailyStats?.totalRevenue}
|
||||
</h4>
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
Total Revenue
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
||||
'text-success': isDeltaPositive && !isLoading && deltaPercentage !== 0,
|
||||
'text-destructive': !isDeltaPositive && !isLoading && deltaPercentage !== 0,
|
||||
'text-muted-foreground': isLoading || !deltaPercentage || deltaPercentage === 0,
|
||||
className={cn("flex items-center gap-1 text-sm font-medium", {
|
||||
"text-success":
|
||||
isDeltaPositive && !isLoading && 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 &&
|
||||
deltaPercentage &&
|
||||
deltaPercentage !== 0 &&
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ArrowUp, UsersRound } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { ArrowUp, UsersRound } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { type DailyStatsProps } from "../../../analytics/stats";
|
||||
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
||||
const isDeltaPositive = useMemo(() => {
|
||||
@@ -12,25 +12,30 @@ const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
|
||||
<UsersRound className='size-6' />
|
||||
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
|
||||
<UsersRound className="size-6" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='flex justify-between'>
|
||||
<CardContent className="flex justify-between">
|
||||
<div>
|
||||
<h4 className='text-title-md font-bold text-foreground'>{dailyStats?.userCount}</h4>
|
||||
<span className='text-sm font-medium text-muted-foreground'>Total Signups</span>
|
||||
<h4 className="text-title-md text-foreground font-bold">
|
||||
{dailyStats?.userCount}
|
||||
</h4>
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
Total Signups
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
||||
'text-success': isDeltaPositive && !isLoading,
|
||||
'text-destructive': !isDeltaPositive && !isLoading && dailyStats?.userDelta !== 0,
|
||||
'text-muted-foreground': isLoading || !dailyStats?.userDelta,
|
||||
className={cn("flex items-center gap-1 text-sm font-medium", {
|
||||
"text-success": isDeltaPositive && !isLoading,
|
||||
"text-destructive":
|
||||
!isDeltaPositive && !isLoading && dailyStats?.userDelta !== 0,
|
||||
"text-muted-foreground": isLoading || !dailyStats?.userDelta,
|
||||
})}
|
||||
>
|
||||
{isLoading ? '...' : dailyStats?.userDelta ?? '-'}
|
||||
{isLoading ? "..." : (dailyStats?.userDelta ?? "-")}
|
||||
{!isLoading && (dailyStats?.userDelta ?? 0) > 0 && <ArrowUp />}
|
||||
</span>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { MessageCircleMore } from 'lucide-react';
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { MessageCircleMore } from "lucide-react";
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
|
||||
const MessageButton = () => {
|
||||
return (
|
||||
<li className='relative' x-data='{ dropdownOpen: false, notifying: true }'>
|
||||
<li className="relative" x-data="{ dropdownOpen: false, notifying: true }">
|
||||
<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}
|
||||
>
|
||||
<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 */}
|
||||
<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>
|
||||
<MessageCircleMore className='size-5' />
|
||||
<MessageCircleMore className="size-5" />
|
||||
</WaspRouterLink>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// TODO: Add messages page
|
||||
import type { AuthUser } from "wasp/auth"
|
||||
import DefaultLayout from "../../layout/DefaultLayout"
|
||||
import type { AuthUser } from "wasp/auth";
|
||||
import DefaultLayout from "../../layout/DefaultLayout";
|
||||
|
||||
function AdminMessages({user} : {user: AuthUser}) {
|
||||
function AdminMessages({ user }: { user: AuthUser }) {
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<div>This page is under construction 🚧</div>
|
||||
</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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../../components/ui/dropdown-menu';
|
||||
} from "../../../components/ui/dropdown-menu";
|
||||
|
||||
const DropdownEditDelete = () => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button>
|
||||
<Ellipsis className='size-4' />
|
||||
<Ellipsis className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-40'>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem>
|
||||
<SquarePen className='size-4 mr-2' />
|
||||
<SquarePen className="mr-2 size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Trash2 className='size-4 mr-2' />
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import UsersTable from './UsersTable';
|
||||
import Breadcrumb from '../../layout/Breadcrumb';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import { type AuthUser } from "wasp/auth";
|
||||
import Breadcrumb from "../../layout/Breadcrumb";
|
||||
import DefaultLayout from "../../layout/DefaultLayout";
|
||||
import UsersTable from "./UsersTable";
|
||||
|
||||
const Users = ({ user }: { user: AuthUser }) => {
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<Breadcrumb pageName='Users' />
|
||||
<div className='flex flex-col gap-10'>
|
||||
<Breadcrumb pageName="Users" />
|
||||
<div className="flex flex-col gap-10">
|
||||
<UsersTable />
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from 'wasp/client/auth';
|
||||
import { getPaginatedUsers, updateIsUserAdminById, useQuery } from 'wasp/client/operations';
|
||||
import { type User } from 'wasp/entities';
|
||||
import useDebounce from '../../../client/hooks/useDebounce';
|
||||
import { Button } from '../../../components/ui/button';
|
||||
import { Checkbox } from '../../../components/ui/checkbox';
|
||||
import { Input } from '../../../components/ui/input';
|
||||
import { Label } from '../../../components/ui/label';
|
||||
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';
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "wasp/client/auth";
|
||||
import {
|
||||
getPaginatedUsers,
|
||||
updateIsUserAdminById,
|
||||
useQuery,
|
||||
} from "wasp/client/operations";
|
||||
import { type User } from "wasp/entities";
|
||||
import useDebounce from "../../../client/hooks/useDebounce";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { Checkbox } from "../../../components/ui/checkbox";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
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 isCurrentUser = currentUser?.id === id;
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={isAdmin}
|
||||
onCheckedChange={(value) => updateIsUserAdminById({ id: id, isAdmin: value })}
|
||||
onCheckedChange={(value) =>
|
||||
updateIsUserAdminById({ id: id, isAdmin: value })
|
||||
}
|
||||
disabled={isCurrentUser}
|
||||
/>
|
||||
);
|
||||
@@ -30,10 +42,12 @@ function AdminSwitch({ id, isAdmin }: Pick<User, 'id' | 'isAdmin'>) {
|
||||
const UsersTable = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [emailFilter, setEmailFilter] = useState<string | undefined>(undefined);
|
||||
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined);
|
||||
const [subscriptionStatusFilter, setSubscriptionStatusFilter] = useState<Array<SubscriptionStatus | null>>(
|
||||
[]
|
||||
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [subscriptionStatusFilter, setSubscriptionStatusFilter] = useState<
|
||||
Array<SubscriptionStatus | null>
|
||||
>([]);
|
||||
|
||||
const debouncedEmailFilter = useDebounce(emailFilter, 300);
|
||||
|
||||
@@ -44,7 +58,9 @@ const UsersTable = () => {
|
||||
filter: {
|
||||
...(debouncedEmailFilter && { emailContains: debouncedEmailFilter }),
|
||||
...(isAdminFilter !== undefined && { isAdmin: isAdminFilter }),
|
||||
...(subscriptionStatusFilter.length > 0 && { subscriptionStatusIn: subscriptionStatusFilter }),
|
||||
...(subscriptionStatusFilter.length > 0 && {
|
||||
subscriptionStatusIn: subscriptionStatusFilter,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,7 +68,7 @@ const UsersTable = () => {
|
||||
function backToPageOne() {
|
||||
setCurrentPage(1);
|
||||
},
|
||||
[debouncedEmailFilter, subscriptionStatusFilter, isAdminFilter]
|
||||
[debouncedEmailFilter, subscriptionStatusFilter, isAdminFilter],
|
||||
);
|
||||
|
||||
const handleStatusToggle = (status: SubscriptionStatus | null) => {
|
||||
@@ -69,85 +85,99 @@ const UsersTable = () => {
|
||||
setSubscriptionStatusFilter([]);
|
||||
};
|
||||
|
||||
const hasActiveFilters = subscriptionStatusFilter && subscriptionStatusFilter.length > 0;
|
||||
const hasActiveFilters =
|
||||
subscriptionStatusFilter && subscriptionStatusFilter.length > 0;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='rounded-sm border border-border bg-card shadow'>
|
||||
<div className='flex-col flex items-start justify-between p-6 gap-3 w-full bg-muted/40'>
|
||||
<span className='text-sm font-medium'>Filters:</span>
|
||||
<div className='flex items-center justify-between gap-3 w-full px-2'>
|
||||
<div className='relative flex items-center gap-3 '>
|
||||
<Label htmlFor='email-filter' className='text-sm text-muted-foreground'>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="border-border bg-card rounded-sm border shadow">
|
||||
<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>
|
||||
<div className="flex w-full items-center justify-between gap-3 px-2">
|
||||
<div className="relative flex items-center gap-3">
|
||||
<Label
|
||||
htmlFor="email-filter"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
email:
|
||||
</Label>
|
||||
<Input
|
||||
type='text'
|
||||
id='email-filter'
|
||||
placeholder='dude@example.com'
|
||||
type="text"
|
||||
id="email-filter"
|
||||
placeholder="dude@example.com"
|
||||
onChange={(e) => {
|
||||
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:
|
||||
</Label>
|
||||
<div className='relative'>
|
||||
<div className="relative">
|
||||
<Select>
|
||||
<SelectTrigger className='w-full min-w-[200px]'>
|
||||
<SelectValue placeholder='Select Status Filter' />
|
||||
<SelectTrigger className="w-full min-w-[200px]">
|
||||
<SelectValue placeholder="Select Status Filter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className='w-[300px]'>
|
||||
<div className='p-2'>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<span className='text-sm font-medium'>Subscription Status</span>
|
||||
<SelectContent className="w-[300px]">
|
||||
<div className="p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
Subscription Status
|
||||
</span>
|
||||
{subscriptionStatusFilter.length > 0 && (
|
||||
<button
|
||||
onClick={clearAllStatusFilters}
|
||||
className='text-xs text-muted-foreground hover:text-foreground'
|
||||
className="text-muted-foreground hover:text-foreground text-xs"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id='all-statuses'
|
||||
id="all-statuses"
|
||||
checked={subscriptionStatusFilter.length === 0}
|
||||
onCheckedChange={() => clearAllStatusFilters()}
|
||||
/>
|
||||
<Label
|
||||
htmlFor='all-statuses'
|
||||
className='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
htmlFor="all-statuses"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
All Statuses
|
||||
</Label>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id='has-not-subscribed'
|
||||
id="has-not-subscribed"
|
||||
checked={subscriptionStatusFilter.includes(null)}
|
||||
onCheckedChange={() => handleStatusToggle(null)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor='has-not-subscribed'
|
||||
className='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
htmlFor="has-not-subscribed"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Has Not Subscribed
|
||||
</Label>
|
||||
</div>
|
||||
{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
|
||||
id={status}
|
||||
checked={subscriptionStatusFilter.includes(status)}
|
||||
checked={subscriptionStatusFilter.includes(
|
||||
status,
|
||||
)}
|
||||
onCheckedChange={() => handleStatusToggle(status)}
|
||||
/>
|
||||
<Label
|
||||
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}
|
||||
</Label>
|
||||
@@ -158,63 +188,75 @@ const UsersTable = () => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='admin-filter' className='text-sm ml-2 text-muted-foreground'>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor="admin-filter"
|
||||
className="text-muted-foreground ml-2 text-sm"
|
||||
>
|
||||
isAdmin:
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (value === 'both') {
|
||||
if (value === "both") {
|
||||
setIsAdminFilter(undefined);
|
||||
} else {
|
||||
setIsAdminFilter(value === 'true');
|
||||
setIsAdminFilter(value === "true");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder='both' />
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="both" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='both'>both</SelectItem>
|
||||
<SelectItem value='true'>true</SelectItem>
|
||||
<SelectItem value='false'>false</SelectItem>
|
||||
<SelectItem value="both">both</SelectItem>
|
||||
<SelectItem value="true">true</SelectItem>
|
||||
<SelectItem value="false">false</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{data?.totalPages && (
|
||||
<div className='max-w-60 flex flex-row items-center'>
|
||||
<span className='text-md mr-2 text-foreground'>page</span>
|
||||
<div className="flex max-w-60 flex-row items-center">
|
||||
<span className="text-md text-foreground mr-2">page</span>
|
||||
<Input
|
||||
type='number'
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={currentPage}
|
||||
max={data?.totalPages}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.currentTarget.value);
|
||||
if (data?.totalPages && value <= data?.totalPages && value > 0) {
|
||||
if (
|
||||
data?.totalPages &&
|
||||
value <= data?.totalPages &&
|
||||
value > 0
|
||||
) {
|
||||
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>
|
||||
{hasActiveFilters && (
|
||||
<div className='flex items-center gap-2 px-2 pt-2 border-border'>
|
||||
<span className='text-sm font-medium text-muted-foreground'>Active Filters:</span>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<div className="border-border flex items-center gap-2 px-2 pt-2">
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
Active Filters:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscriptionStatusFilter.map((status) => (
|
||||
<Button
|
||||
key={status ?? 'null'}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
key={status ?? "null"}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStatusToggle(status)}
|
||||
>
|
||||
<X className='w-3 h-3 mr-1' />
|
||||
{status ?? 'Has Not Subscribed'}
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
{status ?? "Has Not Subscribed"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -222,46 +264,53 @@ const UsersTable = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-9 border-t-4 border-border py-4.5 px-4 md:px-6 '>
|
||||
<div className='col-span-3 flex items-center'>
|
||||
<p className='font-medium'>Email / Username</p>
|
||||
<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">
|
||||
<p className="font-medium">Email / Username</p>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
<p className='font-medium'>Subscription Status</p>
|
||||
<div className="col-span-2 flex items-center">
|
||||
<p className="font-medium">Subscription Status</p>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
<p className='font-medium'>Stripe ID</p>
|
||||
<div className="col-span-2 flex items-center">
|
||||
<p className="font-medium">Stripe ID</p>
|
||||
</div>
|
||||
<div className='col-span-1 flex items-center'>
|
||||
<p className='font-medium'>Is Admin</p>
|
||||
<div className="col-span-1 flex items-center">
|
||||
<p className="font-medium">Is Admin</p>
|
||||
</div>
|
||||
<div className='col-span-1 flex items-center'>
|
||||
<p className='font-medium'></p>
|
||||
<div className="col-span-1 flex items-center">
|
||||
<p className="font-medium"></p>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && <LoadingSpinner />}
|
||||
{!!data?.users &&
|
||||
data?.users?.length > 0 &&
|
||||
data.users.map((user) => (
|
||||
<div key={user.id} className='grid grid-cols-9 gap-4 py-4.5 px-4 md:px-6 '>
|
||||
<div className='col-span-3 flex items-center'>
|
||||
<div className='flex flex-col gap-1 '>
|
||||
<p className='text-sm text-foreground'>{user.email}</p>
|
||||
<p className='text-sm text-foreground'>{user.username}</p>
|
||||
<div
|
||||
key={user.id}
|
||||
className="py-4.5 grid grid-cols-9 gap-4 px-4 md:px-6"
|
||||
>
|
||||
<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 className='col-span-2 flex items-center'>
|
||||
<p className='text-sm text-foreground'>{user.subscriptionStatus}</p>
|
||||
<div className="col-span-2 flex items-center">
|
||||
<p className="text-foreground text-sm">
|
||||
{user.subscriptionStatus}
|
||||
</p>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
<p className='text-sm text-muted-foreground'>{user.paymentProcessorUserId}</p>
|
||||
<div className="col-span-2 flex items-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{user.paymentProcessorUserId}
|
||||
</p>
|
||||
</div>
|
||||
<div className='col-span-1 flex items-center'>
|
||||
<div className='text-sm text-foreground'>
|
||||
<div className="col-span-1 flex items-center">
|
||||
<div className="text-foreground text-sm">
|
||||
<AdminSwitch {...user} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='col-span-1 flex items-center'>
|
||||
<div className="col-span-1 flex items-center">
|
||||
<DropdownEditDelete />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,179 +1,191 @@
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import Breadcrumb from '../../layout/Breadcrumb';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import { type AuthUser } from "wasp/auth";
|
||||
import Breadcrumb from "../../layout/Breadcrumb";
|
||||
import DefaultLayout from "../../layout/DefaultLayout";
|
||||
|
||||
const Calendar = ({ user }: { user: AuthUser }) => {
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<Breadcrumb pageName='Calendar' />
|
||||
<div className='w-full max-w-full rounded-sm border border-border bg-card shadow-default'>
|
||||
<table className='w-full'>
|
||||
<Breadcrumb pageName="Calendar" />
|
||||
<div className="border-border bg-card shadow-default w-full max-w-full rounded-sm border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className='grid grid-cols-7 rounded-t-sm bg-primary text-primary-foreground'>
|
||||
<th className='flex h-15 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='block lg:hidden'> Sun </span>
|
||||
<tr className="bg-primary text-primary-foreground grid grid-cols-7 rounded-t-sm">
|
||||
<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="block lg:hidden"> Sun </span>
|
||||
</th>
|
||||
<th className='flex h-15 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='block lg:hidden'> Mon </span>
|
||||
<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="block lg:hidden"> Mon </span>
|
||||
</th>
|
||||
<th className='flex h-15 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='block lg:hidden'> Tue </span>
|
||||
<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="block lg:hidden"> Tue </span>
|
||||
</th>
|
||||
<th className='flex h-15 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='block lg:hidden'> Wed </span>
|
||||
<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="block lg:hidden"> Wed </span>
|
||||
</th>
|
||||
<th className='flex h-15 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='block lg:hidden'> Thur </span>
|
||||
<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="block lg:hidden"> Thur </span>
|
||||
</th>
|
||||
<th className='flex h-15 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='block lg:hidden'> Fri </span>
|
||||
<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="block lg:hidden"> Fri </span>
|
||||
</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'>
|
||||
<span className='hidden lg:block'> Saturday </span>
|
||||
<span className='block lg:hidden'> Sat </span>
|
||||
<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="block lg:hidden"> Sat </span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* <!-- Line 1 --> */}
|
||||
<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'>
|
||||
<span className='font-medium'>1</span>
|
||||
<div className='group h-16 w-full flex-grow cursor-pointer py-1 md:h-30'>
|
||||
<span className='group-hover:text-primary md:hidden'>More</span>
|
||||
<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'>
|
||||
<span className='event-name text-sm font-semibold text-foreground'>Redesign Website</span>
|
||||
<span className='time text-sm font-medium text-foreground'>1 Dec - 2 Dec</span>
|
||||
<tr className="grid grid-cols-7">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>2</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>3</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>4</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>5</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>6</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>7</span>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 1 --> */}
|
||||
{/* <!-- Line 2 --> */}
|
||||
<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'>
|
||||
<span className='font-medium'>8</span>
|
||||
<tr className="grid grid-cols-7">
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>9</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>10</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>11</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>12</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>13</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>14</span>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 2 --> */}
|
||||
<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'>
|
||||
<span className='font-medium'>15</span>
|
||||
<tr className="grid grid-cols-7">
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>16</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>17</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>18</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>19</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>20</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>21</span>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 3 --> */}
|
||||
<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'>
|
||||
<span className='font-medium'>22</span>
|
||||
<tr className="grid grid-cols-7">
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>23</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>24</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>25</span>
|
||||
<div className='group h-16 w-full flex-grow cursor-pointer py-1 md:h-30'>
|
||||
<span className='group-hover:text-primary md:hidden'>More</span>
|
||||
<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'>
|
||||
<span className='event-name text-sm font-semibold text-foreground'>App Design</span>
|
||||
<span className='time text-sm font-medium text-foreground'>25 Dec - 27 Dec</span>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>26</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>27</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>28</span>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 4 --> */}
|
||||
<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'>
|
||||
<span className='font-medium'>29</span>
|
||||
<tr className="grid grid-cols-7">
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>30</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>31</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>1</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>2</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>3</span>
|
||||
<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>
|
||||
</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'>
|
||||
<span className='font-medium'>4</span>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 5 --> */}
|
||||
|
||||
@@ -1,176 +1,202 @@
|
||||
import { FileText, Mail, Upload, User } from 'lucide-react';
|
||||
import { FormEvent } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import { Button } from '../../../components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } 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';
|
||||
import { FileText, Mail, Upload, User } from "lucide-react";
|
||||
import { FormEvent } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { type AuthUser } from "wasp/auth";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} 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 handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
// TODO add toast provider / wrapper
|
||||
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) {
|
||||
toast.success('Your changes have been saved successfully!');
|
||||
toast.success("Your changes have been saved successfully!");
|
||||
} else {
|
||||
toast.error('Your changes have not been saved!');
|
||||
toast.error("Your changes have not been saved!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<div className='mx-auto max-w-270'>
|
||||
<Breadcrumb pageName='Settings' />
|
||||
<div className="max-w-270 mx-auto">
|
||||
<Breadcrumb pageName="Settings" />
|
||||
|
||||
<div className='grid grid-cols-5 gap-8'>
|
||||
<div className='col-span-5 xl:col-span-3'>
|
||||
<div className="grid grid-cols-5 gap-8">
|
||||
<div className="col-span-5 xl:col-span-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Personal Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className='mb-5.5 flex flex-col gap-5.5 sm:flex-row'>
|
||||
<div className='w-full sm:w-1/2'>
|
||||
<Label htmlFor='full-name' className='mb-3 block text-sm font-medium text-foreground'>
|
||||
<div className="mb-5.5 gap-5.5 flex flex-col sm:flex-row">
|
||||
<div className="w-full sm:w-1/2">
|
||||
<Label
|
||||
htmlFor="full-name"
|
||||
className="text-foreground mb-3 block text-sm font-medium"
|
||||
>
|
||||
Full Name
|
||||
</Label>
|
||||
<div className='relative'>
|
||||
<User className='absolute left-4.5 top-2 h-5 w-5 text-muted-foreground' />
|
||||
<div className="relative">
|
||||
<User className="left-4.5 text-muted-foreground absolute top-2 h-5 w-5" />
|
||||
<Input
|
||||
className='pl-11.5'
|
||||
type='text'
|
||||
name='fullName'
|
||||
id='full-name'
|
||||
placeholder='Devid Jhon'
|
||||
defaultValue='Devid Jhon'
|
||||
className="pl-11.5"
|
||||
type="text"
|
||||
name="fullName"
|
||||
id="full-name"
|
||||
placeholder="Devid Jhon"
|
||||
defaultValue="Devid Jhon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full sm:w-1/2'>
|
||||
<div className="w-full sm:w-1/2">
|
||||
<Label
|
||||
htmlFor='phone-number'
|
||||
className='mb-3 block text-sm font-medium text-foreground'
|
||||
htmlFor="phone-number"
|
||||
className="text-foreground mb-3 block text-sm font-medium"
|
||||
>
|
||||
Phone Number
|
||||
</Label>
|
||||
<Input
|
||||
type=''
|
||||
name='phoneNumber'
|
||||
id='phone-number'
|
||||
placeholder='+990 3343 7865'
|
||||
defaultValue='+990 3343 7865'
|
||||
type=""
|
||||
name="phoneNumber"
|
||||
id="phone-number"
|
||||
placeholder="+990 3343 7865"
|
||||
defaultValue="+990 3343 7865"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-5.5'>
|
||||
<Label htmlFor='email-address' className='mb-3 block text-sm font-medium text-foreground'>
|
||||
<div className="mb-5.5">
|
||||
<Label
|
||||
htmlFor="email-address"
|
||||
className="text-foreground mb-3 block text-sm font-medium"
|
||||
>
|
||||
Email Address
|
||||
</Label>
|
||||
<div className='relative'>
|
||||
<Mail className='absolute left-4.5 top-2 h-5 w-5 text-muted-foreground' />
|
||||
<div className="relative">
|
||||
<Mail className="left-4.5 text-muted-foreground absolute top-2 h-5 w-5" />
|
||||
<Input
|
||||
className='pl-11.5'
|
||||
type='email'
|
||||
name='emailAddress'
|
||||
id='email-address'
|
||||
placeholder='devidjond45@gmail.com'
|
||||
defaultValue='devidjond45@gmail.com'
|
||||
className="pl-11.5"
|
||||
type="email"
|
||||
name="emailAddress"
|
||||
id="email-address"
|
||||
placeholder="devidjond45@gmail.com"
|
||||
defaultValue="devidjond45@gmail.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-5.5'>
|
||||
<Label htmlFor='username' className='mb-3 block text-sm font-medium text-foreground'>
|
||||
<div className="mb-5.5">
|
||||
<Label
|
||||
htmlFor="username"
|
||||
className="text-foreground mb-3 block text-sm font-medium"
|
||||
>
|
||||
Username
|
||||
</Label>
|
||||
<Input
|
||||
type='text'
|
||||
name='Username'
|
||||
id='username'
|
||||
placeholder='devidjhon24'
|
||||
defaultValue='devidjhon24'
|
||||
type="text"
|
||||
name="Username"
|
||||
id="username"
|
||||
placeholder="devidjhon24"
|
||||
defaultValue="devidjhon24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mb-5.5'>
|
||||
<Label htmlFor='bio' className='mb-3 block text-sm font-medium text-foreground'>
|
||||
<div className="mb-5.5">
|
||||
<Label
|
||||
htmlFor="bio"
|
||||
className="text-foreground mb-3 block text-sm font-medium"
|
||||
>
|
||||
BIO
|
||||
</Label>
|
||||
<div className='relative'>
|
||||
<FileText className='absolute left-4.5 top-4 h-5 w-5 text-muted-foreground' />
|
||||
<div className="relative">
|
||||
<FileText className="left-4.5 text-muted-foreground absolute top-4 h-5 w-5" />
|
||||
<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'
|
||||
name='bio'
|
||||
id='bio'
|
||||
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"
|
||||
id="bio"
|
||||
rows={6}
|
||||
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.'
|
||||
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."
|
||||
></Textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-4.5'>
|
||||
<Button variant='outline' type='submit'>
|
||||
<div className="gap-4.5 flex justify-end">
|
||||
<Button variant="outline" type="submit">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit'>Save</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className='col-span-5 xl:col-span-2'>
|
||||
<div className="col-span-5 xl:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Photo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action='#'>
|
||||
<div className='mb-4 flex items-center gap-3'>
|
||||
<div className='h-14 w-14 rounded-full'>{/* <img src={userThree} alt="User" /> */}</div>
|
||||
<form action="#">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="h-14 w-14 rounded-full">
|
||||
{/* <img src={userThree} alt="User" /> */}
|
||||
</div>
|
||||
<div>
|
||||
<span className='mb-1.5 text-foreground'>Edit your photo</span>
|
||||
<span className='flex gap-2.5'>
|
||||
<button className='text-sm hover:text-primary'>Delete</button>
|
||||
<button className='text-sm hover:text-primary'>Update</button>
|
||||
<span className="text-foreground mb-1.5">
|
||||
Edit your photo
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
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'
|
||||
id="FileUpload"
|
||||
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
|
||||
type='file'
|
||||
accept='image/*'
|
||||
className='absolute inset-0 z-50 m-0 h-full w-full cursor-pointer p-0 opacity-0 outline-none'
|
||||
type="file"
|
||||
accept="image/*"
|
||||
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'>
|
||||
<span className='flex h-10 w-10 items-center justify-center rounded-full border border-border bg-background'>
|
||||
<Upload className='h-4 w-4 text-primary' />
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<span className="border-border bg-background flex h-10 w-10 items-center justify-center rounded-full border">
|
||||
<Upload className="text-primary h-4 w-4" />
|
||||
</span>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-4.5'>
|
||||
<Button variant='outline' type='submit'>
|
||||
<div className="gap-4.5 flex justify-end">
|
||||
<Button variant="outline" type="submit">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit'>Save</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import { Heart, Plus, Trash2 } from 'lucide-react';
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import { Button } from '../../../components/ui/button';
|
||||
import Breadcrumb from '../../layout/Breadcrumb';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import { Heart, Plus, Trash2 } from "lucide-react";
|
||||
import { type AuthUser } from "wasp/auth";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import Breadcrumb from "../../layout/Breadcrumb";
|
||||
import DefaultLayout from "../../layout/DefaultLayout";
|
||||
|
||||
const Buttons = ({ user }: { user: AuthUser }) => {
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<Breadcrumb pageName='Buttons' />
|
||||
<Breadcrumb pageName="Buttons" />
|
||||
|
||||
{/* Button Variants */}
|
||||
<div className='mb-10 rounded-sm border border-border bg-card shadow-default'>
|
||||
<div className='border-b border-border px-7 py-4'>
|
||||
<h3 className='font-medium text-foreground'>Button Variants</h3>
|
||||
<div className="border-border bg-card shadow-default mb-10 rounded-sm border">
|
||||
<div className="border-border border-b px-7 py-4">
|
||||
<h3 className="text-foreground font-medium">Button Variants</h3>
|
||||
</div>
|
||||
|
||||
<div className='p-4 md:p-6 xl:p-9'>
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
<Button variant='default'>Default</Button>
|
||||
<Button variant='outline'>Outline</Button>
|
||||
<Button variant='secondary'>Secondary</Button>
|
||||
<Button variant='ghost'>Ghost</Button>
|
||||
<Button variant='link'>Link</Button>
|
||||
<Button variant='destructive'>Destructive</Button>
|
||||
<div className="p-4 md:p-6 xl:p-9">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button variant="default">Default</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="link">Link</Button>
|
||||
<Button variant="destructive">Destructive</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button Sizes */}
|
||||
<div className='mb-10 rounded-sm border border-border bg-card shadow-default'>
|
||||
<div className='border-b border-border px-7 py-4'>
|
||||
<h3 className='font-medium text-foreground'>Button Sizes</h3>
|
||||
<div className="border-border bg-card shadow-default mb-10 rounded-sm border">
|
||||
<div className="border-border border-b px-7 py-4">
|
||||
<h3 className="text-foreground font-medium">Button Sizes</h3>
|
||||
</div>
|
||||
|
||||
<div className='p-4 md:p-6 xl:p-9'>
|
||||
<div className='flex flex-wrap items-center gap-4'>
|
||||
<Button size='sm'>Small</Button>
|
||||
<Button size='default'>Default</Button>
|
||||
<Button size='lg'>Large</Button>
|
||||
<Button size='icon'>
|
||||
<div className="p-4 md:p-6 xl:p-9">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="default">Default</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
<Button size="icon">
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -46,22 +46,22 @@ const Buttons = ({ user }: { user: AuthUser }) => {
|
||||
</div>
|
||||
|
||||
{/* Button with Icon */}
|
||||
<div className='mb-10 rounded-sm border border-border bg-card shadow-default'>
|
||||
<div className='border-b border-border px-7 py-4'>
|
||||
<h3 className='font-medium text-foreground'>Button with Icon</h3>
|
||||
<div className="border-border bg-card shadow-default mb-10 rounded-sm border">
|
||||
<div className="border-border border-b px-7 py-4">
|
||||
<h3 className="text-foreground font-medium">Button with Icon</h3>
|
||||
</div>
|
||||
|
||||
<div className='p-4 md:p-6 xl:p-9'>
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
<div className="p-4 md:p-6 xl:p-9">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button>
|
||||
<Plus />
|
||||
Add Item
|
||||
</Button>
|
||||
<Button variant='outline'>
|
||||
<Button variant="outline">
|
||||
<Heart />
|
||||
Like
|
||||
</Button>
|
||||
<Button variant='destructive'>
|
||||
<Button variant="destructive">
|
||||
<Trash2 />
|
||||
Delete
|
||||
</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 {
|
||||
pageName: string;
|
||||
}
|
||||
const Breadcrumb = ({ pageName }: BreadcrumbProps) => {
|
||||
return (
|
||||
<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>
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-title-md2 text-foreground font-semibold">
|
||||
{pageName}
|
||||
</h2>
|
||||
|
||||
<nav>
|
||||
<ul className='flex items-center gap-1'>
|
||||
<ul className="flex items-center gap-1">
|
||||
<li>
|
||||
<WaspRouterLink to={routes.AdminRoute.to}>Dashboard</WaspRouterLink>
|
||||
</li>
|
||||
<li>/</li>
|
||||
<li className='font-medium'>{pageName}</li>
|
||||
<li className="font-medium">{pageName}</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import { FC, ReactNode, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
import { FC, ReactNode, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { type AuthUser } from "wasp/auth";
|
||||
import Header from "./Header";
|
||||
import Sidebar from "./Sidebar";
|
||||
|
||||
interface Props {
|
||||
user: AuthUser;
|
||||
@@ -13,17 +13,23 @@ const DefaultLayout: FC<Props> = ({ children, user }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
if (!user.isAdmin) {
|
||||
return <Navigate to='/' replace />;
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='bg-background text-foreground'>
|
||||
<div className='flex h-screen overflow-hidden'>
|
||||
<div className="bg-background text-foreground">
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
||||
<div className='relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden'>
|
||||
<Header sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} user={user} />
|
||||
<div className="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
|
||||
<Header
|
||||
sidebarOpen={sidebarOpen}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
user={user}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import DarkModeSwitcher from '../../client/components/DarkModeSwitcher';
|
||||
import { cn } from '../../lib/utils';
|
||||
import MessageButton from '../dashboards/messages/MessageButton';
|
||||
import { UserDropdown } from '../../user/UserDropdown';
|
||||
import { type AuthUser } from "wasp/auth";
|
||||
import DarkModeSwitcher from "../../client/components/DarkModeSwitcher";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { UserDropdown } from "../../user/UserDropdown";
|
||||
import MessageButton from "../dashboards/messages/MessageButton";
|
||||
|
||||
const Header = (props: {
|
||||
sidebarOpen: string | boolean | undefined;
|
||||
@@ -10,61 +10,61 @@ const Header = (props: {
|
||||
user: AuthUser;
|
||||
}) => {
|
||||
return (
|
||||
<header className='sticky top-0 z-10 flex w-full bg-background border-b border-border shadow-sm'>
|
||||
<div className='flex flex-grow items-center justify-between sm:justify-end sm:gap-5 px-8 py-5'>
|
||||
<div className='flex items-center gap-2 sm:gap-4 lg:hidden'>
|
||||
<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 px-8 py-5 sm:justify-end sm:gap-5">
|
||||
<div className="flex items-center gap-2 sm:gap-4 lg:hidden">
|
||||
{/* <!-- Hamburger Toggle BTN --> */}
|
||||
|
||||
<button
|
||||
aria-controls='sidebar'
|
||||
aria-controls="sidebar"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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='du-block absolute right-0 h-full w-full'>
|
||||
<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={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
|
||||
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
|
||||
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 className='absolute right-0 h-full w-full rotate-45'>
|
||||
<span className="absolute right-0 h-full w-full rotate-45">
|
||||
<span
|
||||
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
|
||||
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>
|
||||
@@ -74,7 +74,7 @@ const Header = (props: {
|
||||
{/* <!-- Hamburger Toggle BTN --> */}
|
||||
</div>
|
||||
|
||||
<ul className='flex items-center gap-2 2xsm:gap-4'>
|
||||
<ul className="2xsm:gap-4 flex items-center gap-2">
|
||||
{/* <!-- Dark Mode Toggler --> */}
|
||||
<DarkModeSwitcher />
|
||||
{/* <!-- Dark Mode Toggler --> */}
|
||||
@@ -84,7 +84,7 @@ const Header = (props: {
|
||||
{/* <!-- Chat Notification Area --> */}
|
||||
</ul>
|
||||
|
||||
<div className='flex items-center gap-3 2xsm:gap-7'>
|
||||
<div className="2xsm:gap-7 flex items-center gap-3">
|
||||
{/* <!-- User Area --> */}
|
||||
<UserDropdown user={props.user} />
|
||||
{/* <!-- User Area --> */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const LoadingSpinner = () => {
|
||||
return (
|
||||
<div className='flex py-10 items-center justify-center'>
|
||||
<div className='h-16 w-16 animate-spin rounded-full border-4 border-solid border-primary border-t-transparent'></div>
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="border-primary h-16 w-16 animate-spin rounded-full border-4 border-solid border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
Settings,
|
||||
Sheet,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import Logo from '../../client/static/logo.webp';
|
||||
import { cn } from '../../lib/utils';
|
||||
import SidebarLinkGroup from './SidebarLinkGroup';
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import Logo from "../../client/static/logo.webp";
|
||||
import { cn } from "../../lib/utils";
|
||||
import SidebarLinkGroup from "./SidebarLinkGroup";
|
||||
|
||||
interface SidebarProps {
|
||||
sidebarOpen: boolean;
|
||||
@@ -26,20 +26,25 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
||||
const trigger = useRef<any>(null);
|
||||
const sidebar = useRef<any>(null);
|
||||
|
||||
const storedSidebarExpanded = localStorage.getItem('sidebar-expanded');
|
||||
const storedSidebarExpanded = localStorage.getItem("sidebar-expanded");
|
||||
const [sidebarExpanded, setSidebarExpanded] = useState(
|
||||
storedSidebarExpanded === null ? false : storedSidebarExpanded === 'true'
|
||||
storedSidebarExpanded === null ? false : storedSidebarExpanded === "true",
|
||||
);
|
||||
|
||||
// close on click outside
|
||||
useEffect(() => {
|
||||
const clickHandler = ({ target }: MouseEvent) => {
|
||||
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);
|
||||
};
|
||||
document.addEventListener('click', clickHandler);
|
||||
return () => document.removeEventListener('click', clickHandler);
|
||||
document.addEventListener("click", clickHandler);
|
||||
return () => document.removeEventListener("click", clickHandler);
|
||||
});
|
||||
|
||||
// close if the esc key is pressed
|
||||
@@ -48,16 +53,16 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
||||
if (!sidebarOpen || keyCode !== 27) return;
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', keyHandler);
|
||||
return () => document.removeEventListener('keydown', keyHandler);
|
||||
document.addEventListener("keydown", keyHandler);
|
||||
return () => document.removeEventListener("keydown", keyHandler);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebar-expanded', sidebarExpanded.toString());
|
||||
localStorage.setItem("sidebar-expanded", sidebarExpanded.toString());
|
||||
if (sidebarExpanded) {
|
||||
document.querySelector('body')?.classList.add('sidebar-expanded');
|
||||
document.querySelector("body")?.classList.add("sidebar-expanded");
|
||||
} else {
|
||||
document.querySelector('body')?.classList.remove('sidebar-expanded');
|
||||
document.querySelector("body")?.classList.remove("sidebar-expanded");
|
||||
}
|
||||
}, [sidebarExpanded]);
|
||||
|
||||
@@ -65,49 +70,51 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
||||
<aside
|
||||
ref={sidebar}
|
||||
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-full': !sidebarOpen,
|
||||
}
|
||||
"translate-x-0": sidebarOpen,
|
||||
"-translate-x-full": !sidebarOpen,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{/* <!-- SIDEBAR HEADER --> */}
|
||||
<div className='flex items-center justify-between gap-2 px-6 py-5.5 lg:py-6.5'>
|
||||
<NavLink to='/'>
|
||||
<img src={Logo} alt='Logo' width={50} />
|
||||
<div className="py-5.5 lg:py-6.5 flex items-center justify-between gap-2 px-6">
|
||||
<NavLink to="/">
|
||||
<img src={Logo} alt="Logo" width={50} />
|
||||
</NavLink>
|
||||
|
||||
<button
|
||||
ref={trigger}
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-controls='sidebar'
|
||||
aria-controls="sidebar"
|
||||
aria-expanded={sidebarOpen}
|
||||
className='block lg:hidden'
|
||||
className="block lg:hidden"
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
{/* <!-- 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 --> */}
|
||||
<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 --> */}
|
||||
<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 --> */}
|
||||
<NavLink
|
||||
to='/admin'
|
||||
to="/admin"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
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 --> */}
|
||||
<li>
|
||||
<NavLink
|
||||
to='/admin/users'
|
||||
to="/admin/users"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
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 --> */}
|
||||
<li>
|
||||
<NavLink
|
||||
to='/admin/settings'
|
||||
to="/admin/settings"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
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 --> */}
|
||||
<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 --> */}
|
||||
<li>
|
||||
<NavLink
|
||||
to='/admin/calendar'
|
||||
to="/admin/calendar"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
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 Ui Elements --> */}
|
||||
<SidebarLinkGroup activeCondition={pathname === '/ui' || pathname.includes('ui')}>
|
||||
<SidebarLinkGroup
|
||||
activeCondition={pathname === "/ui" || pathname.includes("ui")}
|
||||
>
|
||||
{(handleClick, open) => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<NavLink
|
||||
to='#'
|
||||
to="#"
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
sidebarExpanded ? handleClick() : setSidebarExpanded(true);
|
||||
sidebarExpanded
|
||||
? handleClick()
|
||||
: setSidebarExpanded(true);
|
||||
}}
|
||||
>
|
||||
<LayoutTemplate />
|
||||
@@ -207,16 +221,20 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
||||
{open ? <ChevronUp /> : <ChevronDown />}
|
||||
</NavLink>
|
||||
{/* <!-- Dropdown Menu Start --> */}
|
||||
<div className={cn('translate transform overflow-hidden', { hidden: !open })}>
|
||||
<ul className='mt-4 mb-5.5 flex flex-col gap-2.5 pl-6'>
|
||||
<div
|
||||
className={cn("translate transform overflow-hidden", {
|
||||
hidden: !open,
|
||||
})}
|
||||
>
|
||||
<ul className="mb-5.5 mt-4 flex flex-col gap-2.5 pl-6">
|
||||
<li>
|
||||
<NavLink
|
||||
to='/admin/ui/buttons'
|
||||
to="/admin/ui/buttons"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
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-accent': isActive }
|
||||
"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 },
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { ReactNode, useState } from "react";
|
||||
|
||||
interface SidebarLinkGroupProps {
|
||||
children: (handleClick: () => void, open: boolean) => ReactNode;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type DailyStats, type PageViewSource } from 'wasp/entities';
|
||||
import { HttpError, prisma } from 'wasp/server';
|
||||
import { type GetDailyStats } from 'wasp/server/operations';
|
||||
import { type DailyStats, type PageViewSource } from "wasp/entities";
|
||||
import { HttpError, prisma } from "wasp/server";
|
||||
import { type GetDailyStats } from "wasp/server/operations";
|
||||
|
||||
type DailyStatsWithSources = DailyStats & {
|
||||
sources: PageViewSource[];
|
||||
@@ -11,18 +11,27 @@ type DailyStatsValues = {
|
||||
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) {
|
||||
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) {
|
||||
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 = {
|
||||
orderBy: {
|
||||
date: 'desc',
|
||||
date: "desc",
|
||||
},
|
||||
include: {
|
||||
sources: true,
|
||||
@@ -35,7 +44,9 @@ export const getDailyStats: GetDailyStats<void, DailyStatsValues | undefined> =
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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 analyticsDataClient = new BetaAnalyticsDataClient({
|
||||
@@ -16,19 +19,19 @@ export async function getSources() {
|
||||
property: `properties/${PROPERTY_ID}`,
|
||||
dateRanges: [
|
||||
{
|
||||
startDate: '2020-01-01',
|
||||
endDate: 'today',
|
||||
startDate: "2020-01-01",
|
||||
endDate: "today",
|
||||
},
|
||||
],
|
||||
// for a list of dimensions and metrics see https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema
|
||||
dimensions: [
|
||||
{
|
||||
name: 'source',
|
||||
name: "source",
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
name: 'activeUsers',
|
||||
name: "activeUsers",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -44,7 +47,7 @@ export async function getSources() {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error('No response from Google Analytics');
|
||||
throw new Error("No response from Google Analytics");
|
||||
}
|
||||
|
||||
return activeUsersPerReferrer;
|
||||
@@ -65,13 +68,13 @@ async function getTotalPageViews() {
|
||||
property: `properties/${PROPERTY_ID}`,
|
||||
dateRanges: [
|
||||
{
|
||||
startDate: '2020-01-01', // go back to earliest date of your app
|
||||
endDate: 'today',
|
||||
startDate: "2020-01-01", // go back to earliest date of your app
|
||||
endDate: "today",
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
name: 'screenPageViews',
|
||||
name: "screenPageViews",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -80,7 +83,7 @@ async function getTotalPageViews() {
|
||||
// @ts-ignore
|
||||
totalViews = parseInt(response.rows[0].metricValues[0].value);
|
||||
} else {
|
||||
throw new Error('No response from Google Analytics');
|
||||
throw new Error("No response from Google Analytics");
|
||||
}
|
||||
return totalViews;
|
||||
}
|
||||
@@ -91,26 +94,26 @@ async function getPrevDayViewsChangePercent() {
|
||||
|
||||
dateRanges: [
|
||||
{
|
||||
startDate: '2daysAgo',
|
||||
endDate: 'yesterday',
|
||||
startDate: "2daysAgo",
|
||||
endDate: "yesterday",
|
||||
},
|
||||
],
|
||||
orderBys: [
|
||||
{
|
||||
dimension: {
|
||||
dimensionName: 'date',
|
||||
dimensionName: "date",
|
||||
},
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
dimensions: [
|
||||
{
|
||||
name: 'date',
|
||||
name: "date",
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
name: 'screenPageViews',
|
||||
name: "screenPageViews",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -128,14 +131,17 @@ async function getPrevDayViewsChangePercent() {
|
||||
viewsFromYesterday = parseInt(viewsFromYesterday);
|
||||
viewsFromDayBeforeYesterday = parseInt(viewsFromDayBeforeYesterday);
|
||||
if (viewsFromYesterday === 0 || viewsFromDayBeforeYesterday === 0) {
|
||||
return '0';
|
||||
return "0";
|
||||
}
|
||||
console.table({ viewsFromYesterday, viewsFromDayBeforeYesterday });
|
||||
|
||||
const change = ((viewsFromYesterday - viewsFromDayBeforeYesterday) / viewsFromDayBeforeYesterday) * 100;
|
||||
const change =
|
||||
((viewsFromYesterday - viewsFromDayBeforeYesterday) /
|
||||
viewsFromDayBeforeYesterday) *
|
||||
100;
|
||||
return change.toFixed(0);
|
||||
}
|
||||
} 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 headers = {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${PLAUSIBLE_API_KEY}`,
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ type PageViewSourcesResult = {
|
||||
{
|
||||
source: string;
|
||||
visitors: number;
|
||||
}
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -38,12 +38,12 @@ async function getTotalPageViews() {
|
||||
const response = await fetch(
|
||||
`${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&metrics=pageviews`,
|
||||
{
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${PLAUSIBLE_API_KEY}`,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
@@ -56,12 +56,19 @@ async function getTotalPageViews() {
|
||||
async function getPrevDayViewsChangePercent() {
|
||||
// Calculate today, yesterday, and the day before yesterday's dates
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today.setDate(today.getDate() - 1)).toISOString().split('T')[0];
|
||||
const dayBeforeYesterday = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString().split('T')[0];
|
||||
const yesterday = new Date(today.setDate(today.getDate() - 1))
|
||||
.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
|
||||
const pageViewsYesterday = await getPageviewsForDate(yesterday);
|
||||
const pageViewsDayBeforeYesterday = await getPageviewsForDate(dayBeforeYesterday);
|
||||
const pageViewsDayBeforeYesterday =
|
||||
await getPageviewsForDate(dayBeforeYesterday);
|
||||
|
||||
console.table({
|
||||
pageViewsYesterday,
|
||||
@@ -72,9 +79,12 @@ async function getPrevDayViewsChangePercent() {
|
||||
|
||||
let change = 0;
|
||||
if (pageViewsYesterday === 0 || pageViewsDayBeforeYesterday === 0) {
|
||||
return '0';
|
||||
return "0";
|
||||
} else {
|
||||
change = ((pageViewsYesterday - pageViewsDayBeforeYesterday) / pageViewsDayBeforeYesterday) * 100;
|
||||
change =
|
||||
((pageViewsYesterday - pageViewsDayBeforeYesterday) /
|
||||
pageViewsDayBeforeYesterday) *
|
||||
100;
|
||||
}
|
||||
return change.toFixed(0);
|
||||
}
|
||||
@@ -82,7 +92,7 @@ async function getPrevDayViewsChangePercent() {
|
||||
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 response = await fetch(url, {
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
headers: headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -95,7 +105,7 @@ async function getPageviewsForDate(date: string) {
|
||||
export async function getSources() {
|
||||
const url = `${PLAUSIBLE_BASE_URL}/v1/stats/breakdown?site_id=${PLAUSIBLE_SITE_ID}&property=visit:source&metrics=visitors`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
headers: headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { type DailyStats } from 'wasp/entities';
|
||||
import { type DailyStatsJob } from 'wasp/server/jobs';
|
||||
import Stripe from 'stripe';
|
||||
import { stripe } from '../payment/stripe/stripeClient';
|
||||
import { listOrders } from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils';
|
||||
import { listOrders } from "@lemonsqueezy/lemonsqueezy.js";
|
||||
import Stripe from "stripe";
|
||||
import { type DailyStats } from "wasp/entities";
|
||||
import { type DailyStatsJob } from "wasp/server/jobs";
|
||||
import { stripe } from "../payment/stripe/stripeClient";
|
||||
import {
|
||||
getDailyPageViews,
|
||||
getSources,
|
||||
} from "./providers/plausibleAnalyticsUtils";
|
||||
// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
|
||||
import { paymentProcessor } from '../payment/paymentProcessor';
|
||||
import { SubscriptionStatus } from '../payment/plans';
|
||||
import { paymentProcessor } from "../payment/paymentProcessor";
|
||||
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());
|
||||
nowUTC.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
@@ -44,14 +54,16 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
||||
|
||||
let totalRevenue;
|
||||
switch (paymentProcessor.id) {
|
||||
case 'stripe':
|
||||
case "stripe":
|
||||
totalRevenue = await fetchTotalStripeRevenue();
|
||||
break;
|
||||
case 'lemonsqueezy':
|
||||
case "lemonsqueezy":
|
||||
totalRevenue = await fetchTotalLemonSqueezyRevenue();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported payment processor: ${paymentProcessor.id}`);
|
||||
throw new Error(
|
||||
`Unsupported payment processor: ${paymentProcessor.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews();
|
||||
@@ -63,7 +75,7 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
||||
});
|
||||
|
||||
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({
|
||||
data: {
|
||||
date: nowUTC,
|
||||
@@ -77,7 +89,7 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
||||
},
|
||||
});
|
||||
} 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({
|
||||
where: {
|
||||
id: dailyStats.id,
|
||||
@@ -97,7 +109,7 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
||||
|
||||
for (const source of sources) {
|
||||
let visitors = source.visitors;
|
||||
if (typeof source.visitors !== 'number') {
|
||||
if (typeof source.visitors !== "number") {
|
||||
visitors = parseInt(source.visitors);
|
||||
}
|
||||
await context.entities.PageViewSource.upsert({
|
||||
@@ -121,11 +133,11 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
||||
|
||||
console.table({ dailyStats });
|
||||
} catch (error: any) {
|
||||
console.error('Error calculating daily stats: ', error);
|
||||
console.error("Error calculating daily stats: ", error);
|
||||
await context.entities.Logs.create({
|
||||
data: {
|
||||
message: `Error calculating daily stats: ${error?.message}`,
|
||||
level: 'job-error',
|
||||
level: "job-error",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -139,7 +151,7 @@ async function fetchTotalStripeRevenue() {
|
||||
// gte: startTimestamp,
|
||||
// lt: endTimestamp
|
||||
// },
|
||||
type: 'charge',
|
||||
type: "charge",
|
||||
};
|
||||
|
||||
let hasMore = true;
|
||||
@@ -147,14 +159,15 @@ async function fetchTotalStripeRevenue() {
|
||||
const balanceTransactions = await stripe.balanceTransactions.list(params);
|
||||
|
||||
for (const transaction of balanceTransactions.data) {
|
||||
if (transaction.type === 'charge') {
|
||||
if (transaction.type === "charge") {
|
||||
totalRevenue += transaction.amount;
|
||||
}
|
||||
}
|
||||
|
||||
if (balanceTransactions.has_more) {
|
||||
// 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 {
|
||||
hasMore = false;
|
||||
}
|
||||
@@ -194,7 +207,7 @@ async function fetchTotalLemonSqueezyRevenue() {
|
||||
// Revenue is in cents so we convert to dollars (or your main currency unit)
|
||||
return totalRevenue / 100;
|
||||
} catch (error) {
|
||||
console.error('Error fetching Lemon Squeezy revenue:', error);
|
||||
console.error("Error fetching Lemon Squeezy revenue:", 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 (
|
||||
<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='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='-mt-8'>
|
||||
{ children }
|
||||
</div>
|
||||
<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="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">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { LoginForm } from 'wasp/client/auth';
|
||||
import { AuthPageLayout } from './AuthPageLayout';
|
||||
import { LoginForm } from "wasp/client/auth";
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
import { AuthPageLayout } from "./AuthPageLayout";
|
||||
|
||||
export default function Login() {
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<LoginForm />
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900 dark:text-gray-900'>
|
||||
Don't have an account yet?{' '}
|
||||
<WaspRouterLink to={routes.SignupRoute.to} className='underline'>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-900">
|
||||
Don't have an account yet?{" "}
|
||||
<WaspRouterLink to={routes.SignupRoute.to} className="underline">
|
||||
go to signup
|
||||
</WaspRouterLink>
|
||||
.
|
||||
</span>
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900'>
|
||||
Forgot your password?{' '}
|
||||
<WaspRouterLink to={routes.RequestPasswordResetRoute.to} className='underline'>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
Forgot your password?{" "}
|
||||
<WaspRouterLink
|
||||
to={routes.RequestPasswordResetRoute.to}
|
||||
className="underline"
|
||||
>
|
||||
reset it
|
||||
</WaspRouterLink>
|
||||
.
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { SignupForm } from 'wasp/client/auth';
|
||||
import { AuthPageLayout } from './AuthPageLayout';
|
||||
import { SignupForm } from "wasp/client/auth";
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
import { AuthPageLayout } from "./AuthPageLayout";
|
||||
|
||||
export function Signup() {
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<SignupForm />
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900'>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
I already have an account (
|
||||
<WaspRouterLink to={routes.LoginRoute.to} className='underline'>
|
||||
<WaspRouterLink to={routes.LoginRoute.to} className="underline">
|
||||
go to login
|
||||
</WaspRouterLink>
|
||||
).
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { VerifyEmailForm } from 'wasp/client/auth';
|
||||
import { AuthPageLayout } from '../AuthPageLayout';
|
||||
import { VerifyEmailForm } from "wasp/client/auth";
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
import { AuthPageLayout } from "../AuthPageLayout";
|
||||
|
||||
export function EmailVerificationPage() {
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<VerifyEmailForm />
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900'>
|
||||
If everything is okay, <WaspRouterLink to={routes.LoginRoute.to} className='underline'>go to login</WaspRouterLink>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
If everything is okay,{" "}
|
||||
<WaspRouterLink to={routes.LoginRoute.to} className="underline">
|
||||
go to login
|
||||
</WaspRouterLink>
|
||||
</span>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { ResetPasswordForm } from 'wasp/client/auth';
|
||||
import { AuthPageLayout } from '../AuthPageLayout';
|
||||
import { ResetPasswordForm } from "wasp/client/auth";
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
import { AuthPageLayout } from "../AuthPageLayout";
|
||||
|
||||
export function PasswordResetPage() {
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<ResetPasswordForm />
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900'>
|
||||
If everything is okay, <WaspRouterLink to={routes.LoginRoute.to}>go to login</WaspRouterLink>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
If everything is okay,{" "}
|
||||
<WaspRouterLink to={routes.LoginRoute.to}>go to login</WaspRouterLink>
|
||||
</span>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ForgotPasswordForm } from 'wasp/client/auth';
|
||||
import { AuthPageLayout } from '../AuthPageLayout';
|
||||
import { ForgotPasswordForm } from "wasp/client/auth";
|
||||
import { AuthPageLayout } from "../AuthPageLayout";
|
||||
|
||||
export function RequestPasswordResetPage() {
|
||||
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 }) => ({
|
||||
subject: 'Verify your email',
|
||||
export const getVerificationEmailContent: GetVerificationEmailContentFn = ({
|
||||
verificationLink,
|
||||
}) => ({
|
||||
subject: "Verify your email",
|
||||
text: `Click the link below to verify your email: ${verificationLink}`,
|
||||
html: `
|
||||
<p>Click the link below to verify your email</p>
|
||||
@@ -9,8 +14,10 @@ export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ ver
|
||||
`,
|
||||
});
|
||||
|
||||
export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ passwordResetLink }) => ({
|
||||
subject: 'Password reset',
|
||||
export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({
|
||||
passwordResetLink,
|
||||
}) => ({
|
||||
subject: "Password reset",
|
||||
text: `Click the link below to reset your password: ${passwordResetLink}`,
|
||||
html: `
|
||||
<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({
|
||||
email: z.string(),
|
||||
@@ -29,9 +29,12 @@ const githubDataSchema = z.object({
|
||||
z.object({
|
||||
email: z.string(),
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
@@ -65,7 +68,7 @@ function getGithubEmailInfo(githubData: z.infer<typeof githubDataSchema>) {
|
||||
// instead of ["user"] and access args.profile.username instead
|
||||
export function getGitHubAuthConfig() {
|
||||
return {
|
||||
scopes: ['user'],
|
||||
scopes: ["user"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,7 +99,7 @@ export const getGoogleUserFields = defineUserSignupFields({
|
||||
|
||||
export function getGoogleAuthConfig() {
|
||||
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);
|
||||
// Users need to have an email for payment processing.
|
||||
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;
|
||||
},
|
||||
@@ -132,6 +137,6 @@ export const getDiscordUserFields = defineUserSignupFields({
|
||||
|
||||
export function getDiscordAuthConfig() {
|
||||
return {
|
||||
scopes: ['identify', 'email'],
|
||||
scopes: ["identify", "email"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { routes } from 'wasp/client/router';
|
||||
import './Main.css';
|
||||
import NavBar from './components/NavBar/NavBar';
|
||||
import { demoNavigationitems, marketingNavigationItems } from './components/NavBar/constants';
|
||||
import CookieConsentBanner from './components/cookie-consent/Banner';
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { routes } from "wasp/client/router";
|
||||
import "./Main.css";
|
||||
import NavBar from "./components/NavBar/NavBar";
|
||||
import {
|
||||
demoNavigationitems,
|
||||
marketingNavigationItems,
|
||||
} from "./components/NavBar/constants";
|
||||
import CookieConsentBanner from "./components/cookie-consent/Banner";
|
||||
|
||||
/**
|
||||
* use this component to wrap all child components
|
||||
@@ -13,24 +16,29 @@ import CookieConsentBanner from './components/cookie-consent/Banner';
|
||||
export default function App() {
|
||||
const location = useLocation();
|
||||
const isMarketingPage = useMemo(() => {
|
||||
return location.pathname === '/' || location.pathname.startsWith('/pricing');
|
||||
return (
|
||||
location.pathname === "/" || location.pathname.startsWith("/pricing")
|
||||
);
|
||||
}, [location]);
|
||||
|
||||
const navigationItems = isMarketingPage ? marketingNavigationItems : demoNavigationitems;
|
||||
const navigationItems = isMarketingPage
|
||||
? marketingNavigationItems
|
||||
: demoNavigationitems;
|
||||
|
||||
const shouldDisplayAppNavBar = useMemo(() => {
|
||||
return (
|
||||
location.pathname !== routes.LoginRoute.build() && location.pathname !== routes.SignupRoute.build()
|
||||
location.pathname !== routes.LoginRoute.build() &&
|
||||
location.pathname !== routes.SignupRoute.build()
|
||||
);
|
||||
}, [location]);
|
||||
|
||||
const isAdminDashboard = useMemo(() => {
|
||||
return location.pathname.startsWith('/admin');
|
||||
return location.pathname.startsWith("/admin");
|
||||
}, [location]);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.hash) {
|
||||
const id = location.hash.replace('#', '');
|
||||
const id = location.hash.replace("#", "");
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView();
|
||||
@@ -40,13 +48,15 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='min-h-screen bg-background text-foreground'>
|
||||
<div className="bg-background text-foreground min-h-screen">
|
||||
{isAdminDashboard ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<>
|
||||
{shouldDisplayAppNavBar && <NavBar navigationItems={navigationItems} />}
|
||||
<div className='mx-auto max-w-screen-2xl'>
|
||||
{shouldDisplayAppNavBar && (
|
||||
<NavBar navigationItems={navigationItems} />
|
||||
)}
|
||||
<div className="mx-auto max-w-screen-2xl">
|
||||
<Outlet />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -22,14 +22,22 @@
|
||||
|
||||
/* Text gradient utilities */
|
||||
.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-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.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-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
@@ -37,7 +45,11 @@
|
||||
|
||||
/* Border gradient utilities */
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -52,8 +64,8 @@
|
||||
* under `theme.extend.fontFamily`, and then can be used as a tailwind class, e.g. className='font-satoshi'.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src: url('/fonts/Satoshi-Regular.woff2') format('woff2');
|
||||
font-family: "Satoshi";
|
||||
src: url("/fonts/Satoshi-Regular.woff2") format("woff2");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { cn } from '../../lib/utils';
|
||||
import useColorMode from '../hooks/useColorMode';
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { cn } from "../../lib/utils";
|
||||
import useColorMode from "../hooks/useColorMode";
|
||||
|
||||
const DarkModeSwitcher = () => {
|
||||
const [colorMode, setColorMode] = useColorMode();
|
||||
const isInLightMode = colorMode === 'light';
|
||||
const isInLightMode = colorMode === "light";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label
|
||||
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
|
||||
type='checkbox'
|
||||
type="checkbox"
|
||||
onChange={() => {
|
||||
if (typeof setColorMode === 'function') {
|
||||
setColorMode(isInLightMode ? 'dark' : 'light');
|
||||
if (typeof setColorMode === "function") {
|
||||
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
|
||||
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} />
|
||||
@@ -40,14 +40,18 @@ const DarkModeSwitcher = () => {
|
||||
|
||||
function ModeIcon({ isInLightMode }: { isInLightMode: boolean }) {
|
||||
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 (
|
||||
<>
|
||||
<span className={cn(iconStyle, isInLightMode ? 'opacity-100' : 'opacity-0')}>
|
||||
<Sun className='size-4 stroke-amber-500 fill-amber-500' />
|
||||
<span
|
||||
className={cn(iconStyle, isInLightMode ? "opacity-100" : "opacity-0")}
|
||||
>
|
||||
<Sun className="size-4 fill-amber-500 stroke-amber-500" />
|
||||
</span>
|
||||
<span className={cn(iconStyle, !isInLightMode ? 'opacity-100' : 'opacity-0')}>
|
||||
<Moon className='size-4 stroke-slate-600 fill-slate-600' />
|
||||
<span
|
||||
className={cn(iconStyle, !isInLightMode ? "opacity-100" : "opacity-0")}
|
||||
>
|
||||
<Moon className="size-4 fill-slate-600 stroke-slate-600" />
|
||||
</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() {
|
||||
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
|
||||
href={ANNOUNCEMENT_URL}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='hidden lg:block cursor-pointer hover:opacity-90 hover:drop-shadow transition-opacity'
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden cursor-pointer transition-opacity hover:opacity-90 hover:drop-shadow lg:block"
|
||||
>
|
||||
Support Open-Source Software!
|
||||
</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
|
||||
href={ANNOUNCEMENT_URL}
|
||||
target='_blank'
|
||||
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'
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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 ⭐️ →
|
||||
</a>
|
||||
<a
|
||||
href={ANNOUNCEMENT_URL}
|
||||
target='_blank'
|
||||
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'
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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! ⭐️
|
||||
</a>
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import { LogIn, Menu } from 'lucide-react';
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
import { Link as ReactRouterLink } from 'react-router-dom';
|
||||
import { useAuth } from 'wasp/client/auth';
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../../../components/ui/sheet';
|
||||
import { cn } from '../../../lib/utils';
|
||||
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';
|
||||
import { LogIn, Menu } from "lucide-react";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { Link as ReactRouterLink } from "react-router-dom";
|
||||
import { useAuth } from "wasp/client/auth";
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "../../../components/ui/sheet";
|
||||
import { cn } from "../../../lib/utils";
|
||||
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 {
|
||||
name: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export default function NavBar({ navigationItems }: { navigationItems: NavigationItem[] }) {
|
||||
export default function NavBar({
|
||||
navigationItems,
|
||||
}: {
|
||||
navigationItems: NavigationItem[];
|
||||
}) {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const isLandingPage = useIsLandingPage();
|
||||
|
||||
@@ -27,10 +37,10 @@ export default function NavBar({ navigationItems }: { navigationItems: Navigatio
|
||||
setIsScrolled(window.scrollY > 0);
|
||||
}, 50);
|
||||
|
||||
window.addEventListener('scroll', throttledHandler);
|
||||
window.addEventListener("scroll", throttledHandler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', throttledHandler);
|
||||
window.removeEventListener("scroll", throttledHandler);
|
||||
throttledHandler.cancel();
|
||||
};
|
||||
}, []);
|
||||
@@ -38,42 +48,57 @@ export default function NavBar({ navigationItems }: { navigationItems: Navigatio
|
||||
return (
|
||||
<>
|
||||
{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
|
||||
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':
|
||||
className={cn("transition-all duration-300", {
|
||||
"bg-background/90 border-border mx-4 rounded-full border pr-2 shadow-lg backdrop-blur-lg md:mx-20 lg:pr-0":
|
||||
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
|
||||
className={cn('flex items-center justify-between transition-all duration-300', {
|
||||
'p-3 lg:px-6': isScrolled,
|
||||
'p-6 lg:px-8': !isScrolled,
|
||||
})}
|
||||
aria-label='Global'
|
||||
className={cn(
|
||||
"flex items-center justify-between transition-all duration-300",
|
||||
{
|
||||
"p-3 lg:px-6": isScrolled,
|
||||
"p-6 lg:px-8": !isScrolled,
|
||||
},
|
||||
)}
|
||||
aria-label="Global"
|
||||
>
|
||||
<div className='flex items-center gap-6'>
|
||||
<div className="flex items-center gap-6">
|
||||
<WaspRouterLink
|
||||
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} />
|
||||
<span
|
||||
className={cn('font-semibold leading-6 text-foreground transition-all duration-300', {
|
||||
'ml-2 text-sm': !isScrolled,
|
||||
'ml-2 text-xs': isScrolled,
|
||||
})}
|
||||
className={cn(
|
||||
"text-foreground font-semibold leading-6 transition-all duration-300",
|
||||
{
|
||||
"ml-2 text-sm": !isScrolled,
|
||||
"ml-2 text-xs": isScrolled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
Your SaaS
|
||||
</span>
|
||||
</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)}
|
||||
</ul>
|
||||
</div>
|
||||
<NavBarMobileMenu isScrolled={isScrolled} navigationItems={navigationItems} />
|
||||
<NavBarMobileMenu
|
||||
isScrolled={isScrolled}
|
||||
navigationItems={navigationItems}
|
||||
/>
|
||||
<NavBarDesktopUserDropdown isScrolled={isScrolled} />
|
||||
</nav>
|
||||
</div>
|
||||
@@ -86,31 +111,34 @@ function NavBarDesktopUserDropdown({ isScrolled }: { isScrolled: boolean }) {
|
||||
const { data: user, isLoading: isUserLoading } = useAuth();
|
||||
|
||||
return (
|
||||
<div className='hidden lg:flex lg:flex-1 gap-3 justify-end items-center'>
|
||||
<ul className='flex justify-center items-center gap-2 sm:gap-4'>
|
||||
<div className="hidden items-center justify-end gap-3 lg:flex lg:flex-1">
|
||||
<ul className="flex items-center justify-center gap-2 sm:gap-4">
|
||||
<DarkModeSwitcher />
|
||||
</ul>
|
||||
{isUserLoading ? null : !user ? (
|
||||
<WaspRouterLink
|
||||
to={routes.LoginRoute.to}
|
||||
className={cn('font-semibold leading-6 ml-3 transition-all duration-300', {
|
||||
'text-sm': !isScrolled,
|
||||
'text-xs': isScrolled,
|
||||
})}
|
||||
className={cn(
|
||||
"ml-3 font-semibold leading-6 transition-all duration-300",
|
||||
{
|
||||
"text-sm": !isScrolled,
|
||||
"text-xs": isScrolled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center duration-300 ease-in-out text-foreground hover:text-primary transition-colors'>
|
||||
Log in{' '}
|
||||
<div className="text-foreground hover:text-primary flex items-center transition-colors duration-300 ease-in-out">
|
||||
Log in{" "}
|
||||
<LogIn
|
||||
size={isScrolled ? '1rem' : '1.1rem'}
|
||||
className={cn('transition-all duration-300', {
|
||||
'ml-1 mt-[0.1rem]': !isScrolled,
|
||||
'ml-1': isScrolled,
|
||||
size={isScrolled ? "1rem" : "1.1rem"}
|
||||
className={cn("transition-all duration-300", {
|
||||
"ml-1 mt-[0.1rem]": !isScrolled,
|
||||
"ml-1": isScrolled,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</WaspRouterLink>
|
||||
) : (
|
||||
<div className='ml-3'>
|
||||
<div className="ml-3">
|
||||
<UserDropdown user={user} />
|
||||
</div>
|
||||
)}
|
||||
@@ -129,51 +157,56 @@ function NavBarMobileMenu({
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className='flex lg:hidden'>
|
||||
<div className="flex lg:hidden">
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
type="button"
|
||||
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
|
||||
className={cn('transition-all duration-300', {
|
||||
'size-8 p-1': !isScrolled,
|
||||
'size-6 p-0.5': isScrolled,
|
||||
className={cn("transition-all duration-300", {
|
||||
"size-8 p-1": !isScrolled,
|
||||
"size-6 p-0.5": isScrolled,
|
||||
})}
|
||||
aria-hidden='true'
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side='right' className='w-[300px] sm:w-[400px]'>
|
||||
<SheetContent side="right" className="w-[300px] sm:w-[400px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className='flex items-center'>
|
||||
<SheetTitle className="flex items-center">
|
||||
<WaspRouterLink to={routes.LandingPageRoute.to}>
|
||||
<span className='sr-only'>Your SaaS</span>
|
||||
<span className="sr-only">Your SaaS</span>
|
||||
<NavLogo isScrolled={false} />
|
||||
</WaspRouterLink>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className='mt-6 flow-root'>
|
||||
<div className='-my-6 divide-y divide-border'>
|
||||
<ul className='space-y-2 py-6'>{renderNavigationItems(navigationItems, setMobileMenuOpen)}</ul>
|
||||
<div className='py-6'>
|
||||
<div className="mt-6 flow-root">
|
||||
<div className="divide-border -my-6 divide-y">
|
||||
<ul className="space-y-2 py-6">
|
||||
{renderNavigationItems(navigationItems, setMobileMenuOpen)}
|
||||
</ul>
|
||||
<div className="py-6">
|
||||
{isUserLoading ? null : !user ? (
|
||||
<WaspRouterLink to={routes.LoginRoute.to}>
|
||||
<div className='flex justify-end items-center duration-300 ease-in-out text-foreground hover:text-primary transition-colors'>
|
||||
Log in <LogIn size='1.1rem' className='ml-1' />
|
||||
<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" />
|
||||
</div>
|
||||
</WaspRouterLink>
|
||||
) : (
|
||||
<ul className='space-y-2'>
|
||||
<UserMenuItems user={user} onItemClick={() => setMobileMenuOpen(false)} />
|
||||
<ul className="space-y-2">
|
||||
<UserMenuItems
|
||||
user={user}
|
||||
onItemClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className='py-6'>
|
||||
<div className="py-6">
|
||||
<DarkModeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,12 +219,12 @@ function NavBarMobileMenu({
|
||||
|
||||
function renderNavigationItems(
|
||||
navigationItems: NavigationItem[],
|
||||
setMobileMenuOpen?: Dispatch<SetStateAction<boolean>>
|
||||
setMobileMenuOpen?: Dispatch<SetStateAction<boolean>>,
|
||||
) {
|
||||
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,
|
||||
'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,
|
||||
});
|
||||
|
||||
@@ -202,7 +235,7 @@ function renderNavigationItems(
|
||||
to={item.to}
|
||||
className={menuStyles}
|
||||
onClick={setMobileMenuOpen && (() => setMobileMenuOpen(false))}
|
||||
target={item.to.startsWith('http') ? '_blank' : undefined}
|
||||
target={item.to.startsWith("http") ? "_blank" : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</ReactRouterLink>
|
||||
@@ -213,11 +246,11 @@ function renderNavigationItems(
|
||||
|
||||
const NavLogo = ({ isScrolled }: { isScrolled: boolean }) => (
|
||||
<img
|
||||
className={cn('transition-all duration-500', {
|
||||
'size-8': !isScrolled,
|
||||
'size-7': isScrolled,
|
||||
className={cn("transition-all duration-500", {
|
||||
"size-8": !isScrolled,
|
||||
"size-7": isScrolled,
|
||||
})}
|
||||
src={logo}
|
||||
alt='Your SaaS App'
|
||||
alt="Your SaaS App"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { routes } from 'wasp/client/router';
|
||||
import { BlogUrl, DocsUrl } from '../../../shared/common';
|
||||
import type { NavigationItem } from './NavBar';
|
||||
import { routes } from "wasp/client/router";
|
||||
import { BlogUrl, DocsUrl } from "../../../shared/common";
|
||||
import type { NavigationItem } from "./NavBar";
|
||||
|
||||
const staticNavigationItems: NavigationItem[] = [
|
||||
{ name: 'Documentation', to: DocsUrl },
|
||||
{ name: 'Blog', to: BlogUrl },
|
||||
{ name: "Documentation", to: DocsUrl },
|
||||
{ name: "Blog", to: BlogUrl },
|
||||
];
|
||||
|
||||
export const marketingNavigationItems: NavigationItem[] = [
|
||||
{ name: 'Features', to: '/#features' },
|
||||
{ name: 'Pricing', to: routes.PricingPageRoute.to },
|
||||
{ name: "Features", to: "/#features" },
|
||||
{ name: "Pricing", to: routes.PricingPageRoute.to },
|
||||
...staticNavigationItems,
|
||||
] as const;
|
||||
|
||||
export const demoNavigationitems: NavigationItem[] = [
|
||||
{ name: 'AI Scheduler', to: routes.DemoAppRoute.to },
|
||||
{ name: 'File Upload', to: routes.FileUploadRoute.to },
|
||||
{ name: 'Pricing', to: routes.PricingPageRoute.to },
|
||||
{ name: "AI Scheduler", to: routes.DemoAppRoute.to },
|
||||
{ name: "File Upload", to: routes.FileUploadRoute.to },
|
||||
{ name: "Pricing", to: routes.PricingPageRoute.to },
|
||||
...staticNavigationItems,
|
||||
] as const;
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useAuth } from 'wasp/client/auth';
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { useAuth } from "wasp/client/auth";
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
|
||||
export function NotFoundPage() {
|
||||
const { data: user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-center min-h-screen'>
|
||||
<div className='text-center'>
|
||||
<h1 className='text-6xl font-bold mb-4'>404</h1>
|
||||
<p className='text-lg text-bodydark mb-8'>Oops! The page you're looking for doesn't exist.</p>
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-6xl font-bold">404</h1>
|
||||
<p className="text-bodydark mb-8 text-lg">
|
||||
Oops! The page you're looking for doesn't exist.
|
||||
</p>
|
||||
<WaspRouterLink
|
||||
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
|
||||
</WaspRouterLink>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import * as CookieConsent from 'vanilla-cookieconsent';
|
||||
import 'vanilla-cookieconsent/dist/cookieconsent.css';
|
||||
import getConfig from './Config';
|
||||
import { useEffect } from "react";
|
||||
import * as CookieConsent from "vanilla-cookieconsent";
|
||||
import "vanilla-cookieconsent/dist/cookieconsent.css";
|
||||
import getConfig from "./Config";
|
||||
|
||||
/**
|
||||
* NOTE: if you do not want to use the cookie consent banner, you should
|
||||
@@ -13,7 +13,7 @@ const CookieConsentBanner = () => {
|
||||
CookieConsent.run(getConfig());
|
||||
}, []);
|
||||
|
||||
return <div id='cookieconsent'></div>;
|
||||
return <div id="cookieconsent"></div>;
|
||||
};
|
||||
|
||||
export default CookieConsentBanner;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CookieConsentConfig } from 'vanilla-cookieconsent';
|
||||
import type { CookieConsentConfig } from "vanilla-cookieconsent";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -10,26 +10,26 @@ const getConfig = () => {
|
||||
// See https://cookieconsent.orestbida.com/reference/configuration-reference.html for configuration options.
|
||||
const config: CookieConsentConfig = {
|
||||
// Default configuration for the modal.
|
||||
root: 'body',
|
||||
root: "body",
|
||||
autoShow: true,
|
||||
disablePageInteraction: false,
|
||||
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,
|
||||
|
||||
// Default configuration for the cookie.
|
||||
cookie: {
|
||||
name: 'cc_cookie',
|
||||
name: "cc_cookie",
|
||||
domain: location.hostname,
|
||||
path: '/',
|
||||
sameSite: 'Lax',
|
||||
path: "/",
|
||||
sameSite: "Lax",
|
||||
expiresAfterDays: 365,
|
||||
},
|
||||
|
||||
guiOptions: {
|
||||
consentModal: {
|
||||
layout: 'box',
|
||||
position: 'bottom right',
|
||||
layout: "box",
|
||||
position: "bottom right",
|
||||
equalWeightButtons: true,
|
||||
flipButtons: false,
|
||||
},
|
||||
@@ -47,7 +47,7 @@ const getConfig = () => {
|
||||
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
|
||||
services: {
|
||||
ga: {
|
||||
label: 'Google Analytics',
|
||||
label: "Google Analytics",
|
||||
onAccept: () => {
|
||||
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) {
|
||||
throw new Error('Google Analytics ID is missing');
|
||||
throw new Error("Google Analytics ID is missing");
|
||||
}
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(..._args: unknown[]) {
|
||||
(window.dataLayer as Array<any>).push(arguments);
|
||||
}
|
||||
gtag('js', new Date());
|
||||
gtag('config', GA_ANALYTICS_ID);
|
||||
gtag("js", new Date());
|
||||
gtag("config", GA_ANALYTICS_ID);
|
||||
|
||||
// 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.async = true;
|
||||
document.body.appendChild(script);
|
||||
@@ -85,15 +86,15 @@ const getConfig = () => {
|
||||
},
|
||||
|
||||
language: {
|
||||
default: 'en',
|
||||
default: "en",
|
||||
translations: {
|
||||
en: {
|
||||
consentModal: {
|
||||
title: 'We use cookies',
|
||||
title: "We use cookies",
|
||||
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.',
|
||||
acceptAllBtn: 'Accept all',
|
||||
acceptNecessaryBtn: 'Reject all',
|
||||
"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",
|
||||
acceptNecessaryBtn: "Reject all",
|
||||
// showPreferencesBtn: 'Manage Individual preferences', // (OPTIONAL) Activates the preferences modal
|
||||
// TODO: Add your own privacy policy and terms and conditions links below.
|
||||
footer: `
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import useLocalStorage from './useLocalStorage';
|
||||
import { useEffect } from "react";
|
||||
import useLocalStorage from "./useLocalStorage";
|
||||
|
||||
export default function useColorMode() {
|
||||
const [colorMode, setColorMode] = useLocalStorage('color-theme', 'light');
|
||||
const [colorMode, setColorMode] = useLocalStorage("color-theme", "light");
|
||||
|
||||
useEffect(() => {
|
||||
const className = 'dark';
|
||||
const className = "dark";
|
||||
const bodyClass = window.document.body.classList;
|
||||
|
||||
colorMode === 'dark'
|
||||
colorMode === "dark"
|
||||
? bodyClass.add(className)
|
||||
: bodyClass.remove(className);
|
||||
}, [colorMode]);
|
||||
|
||||
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 {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export const useIsLandingPage = () => {
|
||||
const location = useLocation();
|
||||
|
||||
return useMemo(() => {
|
||||
return location.pathname === '/';
|
||||
return location.pathname === "/";
|
||||
}, [location]);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type SetValue<T> = T | ((val: T) => T);
|
||||
|
||||
function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
initialValue: T,
|
||||
): [T, (value: SetValue<T>) => void] {
|
||||
// State to store our value
|
||||
// Pass initial state function to useState so logic is only executed once
|
||||
@@ -26,7 +26,7 @@ function useLocalStorage<T>(
|
||||
try {
|
||||
// Allow value to be a function so we have same API as useState
|
||||
const valueToStore =
|
||||
typeof storedValue === 'function'
|
||||
typeof storedValue === "function"
|
||||
? storedValue(storedValue)
|
||||
: storedValue;
|
||||
// Save state
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
@@ -10,25 +10,29 @@ const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ 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<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className='flex'>
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
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',
|
||||
className
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{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.Header>
|
||||
));
|
||||
@@ -40,10 +44,10 @@ const AccordionContent = React.forwardRef<
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
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}
|
||||
>
|
||||
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
@@ -1,44 +1,59 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role='alert' className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = 'Alert';
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5 ref={ref} className={cn('mb-1 font-medium leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
);
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||
)
|
||||
);
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertDescription, AlertTitle };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
@@ -9,7 +9,10 @@ const Avatar = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -19,7 +22,11 @@ const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image ref={ref} className={cn('aspect-square h-full w-full', className)} {...props} />
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
@@ -29,7 +36,10 @@ const AvatarFallback = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)}
|
||||
className={cn(
|
||||
"bg-muted flex h-full w-full items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
@@ -38,10 +42,16 @@ export interface ButtonProps
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
}
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,59 +1,99 @@
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
import { cva, VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const cardVariants = cva('rounded-xl border shadow hover:shadow-lg transition-all duration-300 xur', {
|
||||
const cardVariants = cva(
|
||||
"rounded-xl border shadow hover:shadow-lg transition-all duration-300 xur",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
accent: 'bg-card-accent text-card-accent-foreground hover:scale-[1.02]',
|
||||
faded: 'text-card-faded-foreground scale-95 opacity-50',
|
||||
bento: 'bg-card-subtle text-card-subtle-foreground hover:scale-[1.02] border-none shadow-none',
|
||||
default: "bg-card text-card-foreground",
|
||||
accent: "bg-card-accent text-card-accent-foreground hover:scale-[1.02]",
|
||||
faded: "text-card-faded-foreground scale-95 opacity-50",
|
||||
bento:
|
||||
"bg-card-subtle text-card-subtle-foreground hover:scale-[1.02] border-none shadow-none",
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof cardVariants> {}
|
||||
interface CardProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof cardVariants> {}
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant = 'default', ...props }, ref) => (
|
||||
<div ref={ref} className={cn(cardVariants({ variant, className }))} {...props} />
|
||||
)
|
||||
({ className, variant = "default", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(cardVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user