chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:31 +08:00
commit 4cfe66a27d
588 changed files with 62391 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
charset = utf-8
indent_style = space
indent_size = 2
tab_width = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
+1
View File
@@ -0,0 +1 @@
*.wasp linguist-language=TypeScript
+14
View File
@@ -0,0 +1,14 @@
# These are supported funding model platforms
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
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+11
View File
@@ -0,0 +1,11 @@
## Description
> Describe your PR! If it fixes specific issue, mention it with "Fixes # (issue)".
## Contributor Checklist
> Make sure to do the following steps if they are applicable to your PR:
- [ ] **Update e2e tests**: If you changed the [/template/app](/template/app), then make sure to do any neccessary updates to [/template/e2e-tests](/template/e2e-tests) also.
- [ ] **Update demo app**: If you changed the [/template/app](/template/app), then make sure to do any neccessary updates to [/opensaas-sh/app_diff](/opensaas-sh/app_diff) also. Check [/opensaas-sh/README.md](/opensaas-sh/README.md) for details.
- [ ] **Update docs**: If needed, update the [/opensaas-sh/blog/src/content/docs](/opensaas-sh/blog/src/content/docs).
@@ -0,0 +1,39 @@
name: "Automation - Label external PRs"
############################################################
# CAUTION: This workflow should not check out the PR code! #
############################################################
# The `pull_request_target` event is only intended for simple automations on the
# PRs themselves (e.g., labeling, commenting). If we checked out the PR code here,
# we would be running untrusted code and giving it access to our repository
# secrets, which is a major security risk.
#
# More info at:
# https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
on:
pull_request_target:
types:
- opened
- reopened
- ready_for_review
jobs:
label-external-prs:
name: Label external PRs
runs-on: ubuntu-latest
permissions:
pull-requests: write
# We check if the PR comes from a different repo than ours:
if: github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Label external PR
run: gh pr edit "$PR_NUMBER" --add-label "$LABEL_NAME"
env:
LABEL_NAME: "external"
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
+66
View File
@@ -0,0 +1,66 @@
name: Deploy Blog to Netlify
on:
push:
branches:
- main
paths:
- "opensaas-sh/blog/**"
pull_request:
branches:
- main
paths:
- "opensaas-sh/blog/**"
jobs:
build:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Install dependencies
working-directory: ./opensaas-sh/blog
run: npm ci
- name: Build site
working-directory: ./opensaas-sh/blog
run: npm run build
deploy:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Install dependencies
working-directory: ./opensaas-sh/blog
run: npm ci
- name: Build site
working-directory: ./opensaas-sh/blog
run: npm run build
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@v3
with:
publish-dir: "./opensaas-sh/blog/dist"
production-branch: main
github-token: ${{ secrets.GITHUB_TOKEN }}
deploy-message: "Deploy from GitHub Actions"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
timeout-minutes: 1
@@ -0,0 +1,29 @@
name: Check `opensaas-sh` diffs are up to date
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
jobs:
check-diffs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Generate diffs
run: |
./opensaas-sh/tools/patch.sh
./opensaas-sh/tools/diff.sh
- name: Check for uncommitted changes
run: |
if [[ -n $(git status --porcelain) ]]; then
echo "Error: There are uncommitted diff changes"
echo "Please run './opensaas-sh/tools/patch.sh' and './opensaas-sh/tools/diff.sh' locally and commit the changes"
git status
exit 1
fi
+124
View File
@@ -0,0 +1,124 @@
name: e2e tests
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
env:
WASP_TELEMETRY_DISABLE: 1
WASP_VERSION:
# If you're copying this workflow to your own project, set this to your app's Wasp version:
main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout the repo
uses: actions/checkout@v6
- name: Setup Node.js
id: setup-node
uses: actions/setup-node@v6
with:
node-version: "24.14.1"
- name: Docker setup
uses: docker/setup-buildx-action@v4
- name: Install Wasp ${{ env.WASP_VERSION }}
if: env.WASP_VERSION != 'main'
run: npm i -g @wasp.sh/wasp-cli@${{ env.WASP_VERSION }}
# If you're copying this workflow to your own project, you can remove this step:
- name: Install latest development Wasp
if: env.WASP_VERSION == 'main'
# Installs the latest published build of the CLI from the `main` branch.
run: npm i -g https://pkg.pr.new/@wasp.sh/wasp-cli@main
- name: Cache global node modules
uses: actions/cache@v5
with:
path: ~/.npm
key: node-modules-${{ runner.os }}-${{ hashFiles('template/app/package-lock.json') }}-${{ hashFiles('template/e2e-tests/package-lock.json') }}-wasp${{ env.WASP_VERSION }}-node${{ steps.setup-node.outputs.node-version }}
restore-keys: |
node-modules-${{ runner.os }}-
# In order for the app to run in the dev mode we need to set the required env vars even if
# they aren't actually used by the app. This step sets mock env vars in order to pass the
# validation steps so the app can run in the CI environment. For env vars that are actually
# used in tests and therefore need real values, we set them in the GitHub secrets settings and
# access them in a step below.
- name: Prepare the template for tests
working-directory: ./template
run: |
cd app
cp .env.server.example .env.server && cp .env.client.example .env.client
wasp install
- name: "Install Node.js dependencies for Playwright tests"
if: steps.cache-e2e-tests.outputs.cache-hit != 'true'
working-directory: ./template
run: |
cd e2e-tests
npm ci
- name: "Store Playwright's Version"
working-directory: ./template
run: |
cd e2e-tests
PLAYWRIGHT_VERSION=$(npm ls @playwright/test | grep @playwright | sed 's/.*@//')
echo "Playwright's Version: $PLAYWRIGHT_VERSION"
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
- name: "Cache Playwright Browsers for Playwright's Version"
id: cache-playwright-browsers
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}-${{ runner.os }}
- name: "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: "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
sudo apt update
sudo apt install stripe
# 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: "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: "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
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
STRIPE_API_KEY: ${{ secrets.STRIPE_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID: ${{ secrets.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID }}
PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID: ${{ secrets.STRIPE_PRO_SUBSCRIPTION_PRICE_ID }}
PAYMENTS_CREDITS_10_PLAN_ID: ${{ secrets.STRIPE_CREDITS_PRICE_ID }}
SKIP_EMAIL_VERIFICATION_IN_DEV: true
# Client-side env vars
REACT_APP_GOOGLE_ANALYTICS_ID: G-H3LSJCK95H
working-directory: ./template
run: |
cd e2e-tests
npm run e2e:playwright
+48
View File
@@ -0,0 +1,48 @@
name: Lint
on:
push:
branches:
- main
pull_request:
jobs:
prettier:
name: Prettier Check
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Install dependencies
run: npm ci
- name: Run Prettier check
run: npm run prettier:check
eslint:
name: ESLint Check
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
+48
View File
@@ -0,0 +1,48 @@
---
name: Template Release
"on":
push:
tags:
- "wasp-v*-template"
workflow_dispatch:
inputs:
tag:
description: "Tag to release (e.g., wasp-v0.18-template)"
required: true
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
env:
tag_name: ${{ inputs.tag || github.ref_name }}
archive_name: template.tar.gz
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ env.tag_name}}
- name: Create template archive
# The Wasp CLI expects the template contents to be in the root of the archive.
working-directory: template
run: tar -czf "../$archive_name" .
- name: Create release if it doesn't exist
run: |
if ! gh release view "$tag_name"; then
gh release create "$tag_name"
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release asset
run: gh release upload "$tag_name" "$archive_name" --clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+10
View File
@@ -0,0 +1,10 @@
# Dependencies
node_modules/
# macOS-specific files.
.DS_Store
# We want to keep the template clean from the usual build artifacts.
# We also don't want to ship these ignore rules to the users.
template/app/migrations
template/app/package-lock.json
+2
View File
@@ -0,0 +1,2 @@
# Helps prevent supply chain attacks
min-release-age=7
+2
View File
@@ -0,0 +1,2 @@
24.14.1
# This is not the only place where the Node version is specified. Please do a grep as well.
+16
View File
@@ -0,0 +1,16 @@
# Build outputs.
.wasp/
dist/
# Open SaaS app diff folder.
app_diff/
# Formatting `.md` and `.mdx` files can introduce logical changes.
**/blog/**/*.md
**/blog/**/*.mdx
# Ignore minified JS files in the public folder.
**/public/**/*.js
# Blog scripts.
opensaas-sh/blog/scripts/
+47
View File
@@ -0,0 +1,47 @@
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.
This helps, especially with the more complex issues, as it allows us to discuss the solution upfront and make sure it is well planned and fits with the rest of the project.
## Repo organization
Repo is divided into two main parts: [template](/template) dir and [opensaas-sh](/opensaas-sh) dir.
`template` contains the actual open saas template that will be used by Wasp to create your new open-saas-based app when you run `wasp new -t saas`.
`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
> [!IMPORTANT]
> The in-development version of the template uses the in-development version of Wasp. We've set up the `./tools/wasp` script.
> To use it, whenever you would normally run `wasp <command>`, run `<path-to-repo>/tools/wasp <command>` instead.
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.
4. Update e2e tests in [template/e2e-tests](/template/e2e-tests/) if needed and make sure they are passing.
5. Update docs in [opensaas-sh/blog/src/content/docs](/opensaas-sh/blog/src/content/docs/) if needed. Check [opensaas-sh/README.md](/opensaas-sh/README.md) for more details.
6. Update demo app in [opensaas-sh/app_diff](/opensaas-sh/app_diff) if needed. Check [opensaas-sh/README.md](/opensaas-sh/README.md) for more details.
7. Create a pull request (towards `main` as a base branch).
8. Make a "Da Boi" meme while you wait for us to review your PR(s).
9. If you don't know who "Da Boi" is, head back to the [Wasp Discord](https://discord.gg/aCamt5wCpS) and find out :)
## Additional Info
### Template Versioning
Whenever a user starts a new Wasp project with `wasp new -t <template_name>`, Wasp looks for a specific tag on the template repo, and pulls the project at the commit associated with that tag.
In the case of Open SaaS, which is a Wasp template, the tag is `wasp-v{{version}}-template`, where `{{version}}` is the current version of Wasp, e.g. `wasp-v0.13-template`.
We manually (re)assign the appropriate tag when we are ready to release a new version of Open Saas.
### Releasing
1. Assign appropriate tag to the commit we want to release (usually current `main`) so `wasp new -t saas` can pick up code from the [/template](/template).
2. Deploy the demo app + landing page and blog + docs: [/opensaas-sh/README.md#Deployment](/opensaas-sh/README.md#Deployment).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 wasp-lang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+128
View File
@@ -0,0 +1,128 @@
## Welcome to your new SaaS App! 🎉
<div style="display: flex; gap: 16px; align-items: center;">
<a href="https://www.producthunt.com/products/open-saas?embed=true&utm_source=badge-top-post-topic-badge&utm_medium=badge&utm_source=badge-open&#0045;saas&#0045;2&#0045;0" target="_blank">
<img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-topic-badge.svg?post_id=1023519&theme=neutral&period=weekly&topic_id=237&t=1760520428563" alt="Open&#0032;SaaS&#0032;2&#0046;0 - Free&#0044;&#0032;open&#0045;source&#0032;SaaS&#0032;starter&#0032;kit&#0032;with&#0032;superpowers | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" />
</a>
<a href="https://www.producthunt.com/products/open-saas?embed=true&utm_source=badge-top-post-badge&utm_medium=badge&utm_source=badge-open&#0045;saas&#0045;2&#0045;0" target="_blank">
<img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=1023519&theme=neutral&period=daily&t=1760520428563" alt="Open&#0032;SaaS&#0032;2&#0046;0 - Free&#0044;&#0032;open&#0045;source&#0032;SaaS&#0032;starter&#0032;kit&#0032;with&#0032;superpowers | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" />
</a>
</div>
https://github.com/user-attachments/assets/3856276b-23e9-455e-a564-b5f26f4f0e98
You've decided to build a SaaS app with the Open SaaS template. Great choice!
This template is:
1. fully open-source
2. completely free to use and distribute
3. comes with a ton of features out of the box!
4. ready to work with your favorite AI coding tool or agent (Claude Code, Cursor, Codex, OpenCode, etc.)
🧑‍💻 Check it out in action here: [OpenSaaS.sh](https://opensaas.sh)
📚 Check out the Docs here: [Open SaaS Docs](https://docs.opensaas.sh)
## 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), [Polar.sh](https://polar.sh), or [Lemon Squeezy](https://lemonsqueezy.com/) - for products and payments
- 💅 [ShadCN UI](https://tailwindcss.com) - for components & styling (plus admin dashboard!)
- 🤖 [AI-Ready](https://docs.opensaas.sh/) - Custom Plugins, Skills, & Rules for AI-assisted coding with Claude Code, Cursor, or your favorite AI-assisted coding tool
- 📈 [Plausible](https://plausible.io) or [Google](https://analytics.google.com/) Analytics
- 🤖 [OpenAI](https://openai.com) - OpenAI API w/ function calling example
- 📦 [AWS S3](https://aws.amazon.com/s3/) - for file uploads
- 📧 [SendGrid](https://sendgrid.com), [MailGun](https://mailgun.com), or SMTP - for email sending
- 🧪 [Playwright](https://playwright.dev) - end-to-end tests with Playwright
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 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
### 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
npm i -g @wasp.sh/wasp-cli
```
Then, create a new SaaS app with the following command:
```bash
wasp new -t saas
```
This will create a **clean copy of the Open SaaS template** into a new directory, and you can start building your SaaS app right away!
### Detailed Instructions
For everything you need to know about getting started and using this template, check out the [Open SaaS Docs](https://docs.opensaas.sh).
We've documented everything in great detail, including installation instructions, pulling updates to the template, guides for integrating services, SEO, deployment, and more. 🚀
## 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
## Development Tools
### Code Quality Tools
This repository includes comprehensive code quality tooling to help maintain code standards:
#### Prettier (Code Formatting)
Prettier is configured for automatic code formatting across all JavaScript, TypeScript, and other supported files.
```bash
# Check if files are formatted correctly
npm run prettier:check
# Automatically format all files
npm run prettier:format
```
#### ESLint (Code Linting)
ESLint is configured with TypeScript and React support to catch potential bugs and enforce code quality standards.
```bash
# Run ESLint to check for issues
npm run lint
# Automatically fix fixable issues
npm run lint:fix
```
The ESLint configuration includes:
- TypeScript support with `@typescript-eslint`
- React and React Hooks linting
- Sensible defaults tuned for a SaaS application
- Automatic support for CommonJS (.cjs), ES Modules (.mjs), and TypeScript files
Both Prettier and ESLint checks are automatically run in CI/CD pipelines to ensure code quality.
For information about other development tools used to maintain derived projects (like opensaas.sh and template-test), see [tools/README.md](./tools/README.md).
## Contributing
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.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`wasp-lang/open-saas`
- 原始仓库:https://github.com/wasp-lang/open-saas
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+76
View File
@@ -0,0 +1,76 @@
import js from "@eslint/js";
import reactPlugin from "eslint-plugin-react";
import reactHooksPlugin from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
export default tseslint.config(
// Ignore patterns
{
ignores: [
"**/node_modules/**",
"**/.wasp/**",
"**/dist/**",
"**/build/**",
"app_diff/**",
"**/public/**/*.js",
"**/.astro/**",
"opensaas-sh/blog/scripts/**",
],
},
// Base JavaScript rules for all files
js.configs.recommended,
// TypeScript rules for TS/TSX files
...tseslint.configs.recommended,
// React-specific rules
{
files: ["**/*.jsx", "**/*.tsx"],
plugins: {
react: reactPlugin,
"react-hooks": reactHooksPlugin,
},
settings: {
react: {
version: "detect",
},
},
rules: {
...reactPlugin.configs.recommended.rules,
...reactHooksPlugin.configs.recommended.rules,
"react/react-in-jsx-scope": "off", // Not needed in React 17+
"react/prop-types": "warn", // Using TypeScript for type checking
"react/no-unescaped-entities": "off", // Allow apostrophes in JSX
},
},
// CommonJS configuration files (postcss.config.cjs, etc.)
{
files: ["**/*.cjs"],
languageOptions: {
sourceType: "commonjs",
globals: {
module: "readonly",
require: "readonly",
__dirname: "readonly",
__filename: "readonly",
process: "readonly",
console: "readonly",
},
},
},
// Node.js scripts and MJS files
{
files: ["**/*.mjs", "**/scripts/**/*.js"],
languageOptions: {
globals: {
process: "readonly",
console: "readonly",
__dirname: "readonly",
__filename: "readonly",
},
},
},
);
+1
View File
@@ -0,0 +1 @@
app/
+36
View File
@@ -0,0 +1,36 @@
# OpenSaas.sh
This is the https://opensaas.sh page and demo app, built with the Open Saas template!
It consists of a Wasp app for showcasing the Open Saas template (+ landing page), while the Astro blog is blog and docs for the Open Saas template, found at https://docs.opensaas.sh.
Inception :)!
## Development
### Demo app (app_diff/)
> [!IMPORTANT]
> The in-development version of the template uses the in-development version of Wasp. We've set up the `./tools/wasp` script.
> To use it, whenever you would normally run `wasp <command>`, run `<path-to-repo>/tools/wasp <command>` instead.
Since the demo app is just the open saas template with some small tweaks, and we want to be able to easily keep it up to date as the template changes, we don't version (in git) the actual demo app code, instead we version the diffs between it and the template: `app_diff/`.
#### Workflow
- Generate `app/` from template and diffs: `./tools/patch.sh`
- Update diffs after modifying `app/`: `./tools/diff.sh`
For detailed information about the diff/patch workflow and macOS setup requirements, see [../tools/README.md](../tools/README.md).
### Blog (blog/)
Blog (and docs in it) is currently tracked in whole, as it has quite some content, so updating it to the latest version of Open Saas is done manually, but it might be interesting to also move it to the `diff` approach, as we use for the demo app, if it turns out to be a good match.
For more info on authoring content for the docs and blog, including information on custom components, see the [blog/README.md](blog/README.md).
## Deployment
App: check its README.md (after you generate it with `.tools/patch.sh`) .
Blog (docs): hosted on Netlify.
@@ -0,0 +1,14 @@
--- template/app/.claude/settings.json
+++ opensaas-sh/app/.claude/settings.json
@@ -0,0 +1,11 @@
+{
+ "enableAllProjectMcpServers": true,
+ "enabledMcpjsonServers": ["chrome-devtools"],
+ "permissions": {
+ "allow": [
+ "Bash(ln:*)",
+ "Bash(wasp start:*)",
+ "Bash(npx -y https://pkg.pr.new/@wasp.sh/wasp-cli@main:*)"
+ ]
+ }
+}
+4
View File
@@ -0,0 +1,4 @@
--- template/app/.env.client
+++ opensaas-sh/app/.env.client
@@ -0,0 +1 @@
+REACT_APP_GOOGLE_ANALYTICS_ID=G-H3LSJCK95H
+28
View File
@@ -0,0 +1,28 @@
--- template/app/.env.vault
+++ opensaas-sh/app/.env.vault
@@ -0,0 +1,25 @@
+#/-------------------.env.vault---------------------/
+#/ cloud-agnostic vaulting standard /
+#/ [how it works](https://dotenv.org/env-vault) /
+#/--------------------------------------------------/
+
+# development
+DOTENV_VAULT_DEVELOPMENT="SoTXvOA7WDmjPwt0IbqGyXjvBdID/up9D4CU6eyCe85ORp4sgB/w6APQ38Iojk7TubYChAom+Of3MHU3XzSCWyT1kUh4XMU6+1CoBv+xeCI+eBcKYmhcrOJXaYp4O0Hy2RYq/OZaQT+ybVihVFZWylaMNdsLG41ytKx3HvsPq0JePIv7SQcTUo50zBiUP44LbS4St76DEsao8hN00zPeg/59VFoVgWBrnR2J+TclMxB87dWB02ddBpWJtXVWfTmy7Q9wCfKWsUVAjVvePu6gu0SvIUROG/WXUbqMIbebIkJ00oTi+z72h7Ssx8PgLJOy559bOVPgJvJWiZSD7D6Bnj9JLPUG3D2Vu9gJOwxsGPs7sJzt7/+pXaYcMb9pHQHZKPYDtojrkI+RgT/PPLLxX3gp95Cq1tkRQfqYwnSfihin0R1e/4AW2PcFYGoJWFkdSkaYPGWF82YBoQDr+7NJuvwcxTs1LaqRllxUIKLGgBE5nX+1wlkrXoVU8taWexuT/KGGTMGTY7UAxcJkSmr94+Rk5bjEGIt/xHZPkh7QIVUEl8X6gcNJ2r+DwKAFVH73WX+H0hYDf61/nAHj6kK7sjVLS+C2BoZ1Pg3G/Gwue+AFoLbUgZCBhBFoLnrEfFNNGKbU0YK6NKtWYFaLpKDAQLxmRh+nsLap+QI1OdHKCP2byBNiML92IvyEIxnqUBprRp1TMdAo522OBzzY4GNuUnpy3qAv+/rU07G0n/3FGpgpSZPX/xN6IFyKOoSbO2BD9sv3BPHouT68Q3Vi2miu3XGb7nuqe5Dun/6TdBpKey4bj0UU9WTElLH/wZ3leUDftW05zJF/e9Nv/nZ8sRCGPik2VTmF2hdOVjZ7tjM2dV9l/cko3PBXnWcPzXJ1XprnPnswfqq8YN4b7fZPVC9puVWqF0veuRDI9YkbusBQWUOvJjf5yg+BHihnOqIGSELqzmGS99SzvzL7RFRD9gXAVKD5e9F1QyPVBb/jdw6hdG5ZdwoS+5x75RtsUR9HhcYo1+Vwg4FLiwyBjJuHsGezS4waMetPvKT6XCjz1Vb+M00lVpoF0ABajRkKqS+NgEBMs521XStQ4eAFOVbtUkW0iMqzKeKgL+FZSYqQS4vLRYlDx1yJ3BgMk5+serX+QuNiVdzTMDaEt2W4l4TObneMuwBz5RentbzgDzhWylhtRliVHBfaP6XpCH1k13UBG1rrpjIaBv09go4UpXp36FfV/z+FdnjX8az6nDLRr7xZ6nxaR+9k5KGB1G/VwHtYJzrVbUnwJHGw7EZraJd6RmYICzy987HuOXp1SJk1AcJbQq4HR+PnZzjZLKdnBfPWCaA69hhYfuunYOM7lcz9LB0CjEAFTwxb0vyrTXrS+W+sbWKCBSqAbdCzlNZ3LO6yGoUHuunDyzqbPEWGtMfvoTtnce6teKOBh0zXkzA8i0P12h7qCt93HFvwBd9kKRRaAs4tSNQ7L8E2wQl7QP4U4BadcOf3pwuxZKUy69iTi7NXEceUr0M7FGuP/7VQ9jUsVNRliyhoG+N8wmdXhBuc5YP3BGUC6WzK0krPcWkxEzQKBq+JcbkHE0GnLwJjV0aV/JcLBIWacg1f4ehmYUVm1Ox54TQIES6BK8m7TgDpYQp4TUS7RU5lh4vvqxxbo2visY0VKFUXncEMYpX7G4eJVPFGjUghAlpinx3psHiJUe0crE6pRwPzHEZzVZ2W9KPhKkaCrrunX/8kyMvcm6RHc5WizAvuh2pPWiWax3bg7/rVLG84akL5LzLLnNJQuT7qCRYLB35X17+Onr2mTjB4hPpek2nsaR8VAeyGCAgw2q41Opy2v7vKIGq3h8F+1Baqiy8bzOmTEdzjD7No+c1YBeS75+/4BiUCrlQ9zUVKRR7RVjEDOrTGUCAWf6evsPKcqg4Arg0BxOrtfsd9EWZvstk53cbkPc6kGS0eN6Esv/+Xz+3sEiKiC7nntiMJPBCvx5uKPVKlduKJLEVipoIdJhf3GrWTDCich7+b4vPj3T6Jc6sRKZm5zVSyvUlgu7WSr0HnrlMMrhp2SKmxTMFkscBeNd1x7Z0JaNZylG5yB83ezhTgm2d5IB1BMLfuTgl29bg79B47YEQTFyg2HK0lcVAiMtXjybbqgJeTwuNs+/zvb+7Ry2UMQBJuli8atQaTjSPl4Bpkj+Ky090yj2WKbyR1mEGqD/blOy5kDGf1RuigOYjoDepJBmdOYyt1T1bB7lrPYEK2yoOdyOUqF5UtP1SJGLTBTEIDej4kEiQOulrokRvDl4DgbC0GQ/y/bV423C23zhbS8r+e9H2t6wtD2Mviq38gAIpgQSFK2iZPPnTdSgklTlcCWGzO7cGVk79QIqa/p8pwSXi7mjbPcyYH5QKiblIWJBV/bmaj5QyDdTpf59tlc5vWeuMZA4cpIo8NC90QhI/Jdz1/FoFKUngxD83CA9hngCUcZMz7fPXZyJTwWYNJkX8ZEJr/qsfeKXI8aLgTtqsZCX1a1Ab5awft4UUhW9MrubyJEZQhDRBek6vpysUOGtvocOPhqGP95aIzAg=="
+DOTENV_VAULT_DEVELOPMENT_VERSION=16
+
+# ci
+DOTENV_VAULT_CI="gLPj+GuhKqDgQnDt+OEBzLtZLSVQwXeNPDBBH+vyrA7w9BrTr1vyxopeWfbvadoLEO9VUsaMZVKhl1jWWFzujljN/37ekawekwWwEVZeMHilyuAxtx/kk5HOwq4PNG126YXVP4UCi0KneD7Qy1KMVqkzTMmIpGZ3Stp+4/pmoKF8hFGSYsElBoNCFzCLwNzIZ3qVzITkAVMKvAbFDsl0A3S1FAUuJvmupxSJYVT6zQsSBkG6jkCG8oKDDMe+XRHAf8da6L635p1+v3FsPGt438q9yyWXvWsne5NOE/GzLzLOWlQQJEP/PJ3673sBSnqP0UirIVJTxXLsNT++U56O4J2bhz6hjvcllkzz7QW/aD5t/VkuhEEaKY44aoglW7FraBETxpo4lFcw0rbCdOQqFOxe1GdN82/ZY8Z7NftMCN2rFPTBVw6tp5GMJsN+q6ShS2j/nILyUiLoUbgFsdIn2WJdTlvowNjIKcs27LvEG5E55Lg/tNWD1TQcLYzJkGAecWbZeCFm/9E1HOZXpHvxIe5ZD+GDl9D7UATZr08Jy3ntMX+i2FW2eilz14g0SsgBkwaOvGFKfiyCAwTaU31PCKmEkqdQSSaJ36oKXj9OFojdf9/+dJLLEGS+BI6u0u+32dl3ASm8ftgcTqdziKDKluVv6DY4MCne9wEFtJQi4lEU2JD9XfATGxLfdyPM9/PgeL7dPQrlSnbOZX7scPsHeQQB0kp8MC6b86lhTqgcqaC4lAG7dK6fE1R/f2j28ININkr+cLhtWvZb0sBhMXhMMxJPmA7usHXYn2ual30egREo+hkAeQbBYBB2QD/TJFjlo3qoKpvjEVy5LpwDLwy/11sQBBMjlxCcjJv49Y6VyAA3V2WH3HDAEO+fnNbDvoji+sUjf0UlHzCFwJzkU8jCxEYjXiSWIWltNtPzM9r6By1qnv4mIl4lhCEEsX4MOkPJaPaOMnGdMQnvDwaUmxochDKDgoTJUm8BjZlp/UMOcSj22oxw6MmOOh9TSzrD6Cf+KtncKLsCW8/fr1b1I/wUmR2ASSTHpVhpGFv4Mgq6GhGP5wlZdBfcmEwcu1QIsyHTzgJgeysKf2Q42hJGOYHdHvPwb4/5oXwD71sZHe1FvIea5S2WN3agfYJ8J1FTm8MC+sQX+I/ZQvpcojUa7qkKJylrqv05Mo11+HaHxSgaYgaenuPOIOBTTSinw9QcB7GGAyonpjamG2LRpKZ4KgSBH+MgNA1+bbA5V8syOSGsSseAikFtjnbavXe4Rbm8gLRS3lFa0avAIZb1s68AW80feXMIsXeaBv86gkrbzkWugchUPdJjfwIm1Qx1DdY+NynMq55Ey5jK6miGDd7yIY1CQnkKTTRGH0VmhNt7imdVrWTtJKdL2DxPS9IaEEHZqPJiWU49Q1eR344zJuXgFezaC9gyH0CqGbrZymlSc8a94HZWNbdhz6tx+cjZdTzHNW8r5jTUmF8G/v9Mx4EHqIViFoLgY4IlMl8qu3wWOOOl5uZBMpJkaav7VXiko/f166EhE2uC5N4t3v7QwaG8Iqal7FM8QQX9zqp4BNht+MjFTWfIik2tH9E3E+qmIAwLYyyaonYF7WKAfv9mNlr29JjFX8CK1Q=="
+DOTENV_VAULT_CI_VERSION=10
+
+# staging
+DOTENV_VAULT_STAGING="e0RuXVNGV1PeU7FikDvw6tGaihOhf8hcgqMwAGBjfIVQVXgSbhaIzMiGC1fbcHZ1MAfIzKB81ptEkdy2sGRFJzx9FuZkLg/ane0QYjzxd1d+XRPxm+wTYIKLzkJtrP3jymJj4qXv43JrOtg3PjVHvM+/zZGkdm29D4qQrq4LbhO7mLL9TA2ob7ptfVT3ifKt0zlVV2DJPaHOS5jIt/N+rk695t5cFs5xcIEtjg+14TbU+4RF7gKFOjlQDgkr39UKe1bti4q3GdsQHnHAP1iYELz/eWFjZbM6k2VYTj5XHw7fu4lB0pSdiAfkHfg4IJ81YIcCLVQ7LiP3wqNnvUZqS2eXAm1eoJPZfXOutsrkaj23kbBcbVAByDZFDnNhae3jH3I5/CteO4jWDllxDpl7LBcF4tnWzdXkIu/syLr3TXFFl0WywyuMsVeDaA9UCLrHpfXak64I9iNuOdGKnA78hdaeTTCMjjxXjPCH9DmPuBm3k8YTT7QzuqgvccP56GZ28Nawz9lXFPAhNCCfEaXqQxstzpVIEAenElgfB/3JrJJRF0BUSt94gafMFf3TAWaRHKmGTpEYrQu/c4QggTjQb3L0cb1EnU5nSHKGY6Dwfg5qvLJ8u06lHY5f2ryv3q8RMme+sukf6mUzzBXGs2ibl/DCVj0u+Ti9LiF8CKw5mSN5g1IxTV1G+C+F8iLqZG3sfb2PQXOHZCavLgWSajCiUcikOwRn2OKLuRdOL5Jt0Vjca7EwT9sbEf8SQdwd61SVcQhjgCFBzMCWI3VSqunX8uoNSTWz+4cVlbYIiCmULOTuER0OxMKISL3kAF7XYQN6Zu7Md6Wyb5K3oBmjdiBYvlBDQHb0zjc8LoLCFT4dz9Yqt7leGMBbTc75F9KQJ8NnjLGTh6xXbKtCu15HmgSf8qgVyuWDWQtFLBLMjVUXvtGTdYEPUaxwG95ktZ46IcVuwKo+CHIkH8WNuZHW9qB3beWITvLT9jpyqnj9dRUhJvRPorh0Y0L2nBwvrcWSQmehNj6RKw/xv3wIKmUtYWDcWHFGfqkq1CHuW7rz5FEWJk94FIljt0ChoanTae2VTWJ2mh15qcFAOfq/xAs3f7W5C6TuAXXxeeEYHvy/GQ2RfEQ+BSBRi0EsYHUBnLRZMceHat1Wkm7yB2ybLZu7VZpT+RZbpMlTtz6HfzLjvqv05OAgNyuYc0gfuIS2fljWBkNJ/AQvFebYaw9PW9nLEsj419SMy8DqobGTMJpvIwEwQLBOajmS2yzb//reAgxaJ6JYA16cLHSPjwoWHGIuHGG8EYaEUhFCZIEgYcfvETOZzY8PN/+Rrf784aG1XCyno+aComWpxfJBPhz9fKCtmp7oYLBe1OfSqLOy/wQpnBz5K+pRpRUOC+6cHQAYZ3JQ7z8isHSs9JkeCVZaTc8yJQi6erR2hw5xiKnju2twpXgds64wcrv2QmPLZgmfvHmihG5PbG9PKn9lGjZ8T154TqzoHQMbvGCxknhTwRX/ToLWnMMVUsiKCgyT5dPlxrhzpRm8lF6Rsj7k0VqaVfrNkjORmcLXtcWfo6J8LsYQBzXVGxC6OLzJ/ZSenrtvheaUc7uGZZ7Av40WgvnEq8pv9q3lTSfRxw=="
+DOTENV_VAULT_STAGING_VERSION=10
+
+# production
+DOTENV_VAULT_PRODUCTION="Nd5c0+PfKA4LH1MRMo50wM2etCkrd7CrV69hSeGM2bQMzeKc1gat5TVSI/X/1MutGqvJqbhSnVdnEp8xC2MoybCX3SYJWpM718NvVw8lffNn0NJ56dTIyk5il95T4Cydc/IjQEDQjyKer4uydJIiiqdMJIszoCrJDTrDtx4i3Efs1I5aWeYhiGymUFes5ZQbuWeXT3FQ0hcE0BYGzVYKvNCFRb6xrvtABldhpy0jNOXZ/lB2FCJr7xx3bKplfOKKv9ZmUXWW5EDCV+tccbzmGSZIn1jvKKHQYgZUA1FAGXM1gecE8qNtjZokfsRO9yaoIymeItMObRxAHtSoQNPlEVJMT5/ReuJHqA3U8qr8IQefb6FSzoJj6L62TUyYJgKaLMW6wga65AuSymbKK2AYCN6c9G7/HEqLcA1s6OQeZHG5z+RmOGiAAmudOTNwNAsI6/P2Z+x/qsJN2MB7T2T5zFb9W+1IDmjczNfVPH+J9bkWJ8Zw1n8mzPVcSIDnX3M0HW/HrFIaBKduzPp/CQtpIgnoZ+PEfs/vHYf2Fji0FBW3surZUpxE8lz1qB9DvfzPVYyZzJZhw3nHoj0XeZNBz1iFsmIeH3+NQFqYmywMgWcGr/V50kkdT8ucsPSnSsfPLTY0DfXaWX+8Rbgs/dhX1CZ32ojq7xGjuNVRLJgmq1+IYVLuue23X4h8EFMjdeQGXtV/CyRLIDKMYQFooDGFlPGwUaZJxWlD80kx3xXDV2H1kZGuYyIPFvMCRlbRPXkM6ZhxxcsPEfQecb0wrazfw4O3r0YMFGPUhMPAZa0HbXlsd5FEKYbsQVpLe8qAFi8nHeq4c12JZWQr7rfPqJyKvh9sYCARw+p1gp1i/7tKOnzcU5qtM4EaPX4C/mlnf5iHMXKWbuEfwU4PwtqSTzXf75qUBLNKqF7W/soOsrrS3FbMZJThzrXyJqLlUaSvnQSJGWBCwm08eNqjIQ+k8dF8lYafOQN+Du87RFEhDIgD54aR5EuqMKjQPSRHZgWMlSY3wDfXAy5sRsNcXQ035mKhF8VDTtbPcROoHkWD2xvaVPgp2e6JWU3d9MpinphBgz83E3c7wi5GnpwZnXn3EMzOFLqoFcaJvlVR3cTD56awox/I6HEfp5FitWKcgtwXlO5WfAmiNondYTzKR09fs4GCvJ76zZThh/6Q5amfHxtKVIlde5jDuMD5ZzGwJM/M3zmWOra7W0SQKVJUZ1U8JD6Tqk4TtafMNyuxCf+93omio8rm7TPkBlFE5D2aoH0Spivc/2D0U1iNQU04edwtX8qIKIizseWwlRyCT1k8LaychC4XVZqgHLt9j0C4z9sbaGxjbxU/1nuIVxgLEJOalqpuNFkaSLH7cOcs2lsUX7htPOYkCAuSMYT10ORNLZk008+kHX5o4Z77KranYLM2LknY6l6dPwdqGKyu5Q/yGdN4MWvA3d5lKePvKsY8xVqa7j4K8mYJW8J0qXIzeMlx/+Z+AyOBVW8S9hK652CjDCAjaDLv1Cc5CNT8gjucbr7HEt6+jiR+eXPrPatgcOnF8pwxJeVx4FHVVBqfyt3AUFjbHa1T8EZVdpFGp0Qm9CWKpX52IsVKmIA7yJKjexqjEIaTmKe6AA=="
+DOTENV_VAULT_PRODUCTION_VERSION=10
+
+#/----------------settings/metadata-----------------/
+DOTENV_VAULT="vlt_47e3eeb0730e831e688049600e59f8975260a1f00302ae08684ed87ba67872d0"
+DOTENV_API_URL="https://vault.dotenv.org"
+DOTENV_CLI="npx dotenv-vault@latest"
+26
View File
@@ -0,0 +1,26 @@
--- template/app/.gitignore
+++ opensaas-sh/app/.gitignore
@@ -1,2 +1,23 @@
node_modules/
.wasp/
+
+.env
+.env.*
+
+# These two we added only because dotenv-vault keeps adding them if it doesn't find them,
+# even though we don't need them. Remove them once dotenv-vault stops doing that.
+.env*
+.flaskenv*
+
+# Don't ignore example dotenv files.
+!.env.example
+!.env.*.example
+
+# We don't want to ignore .env.client as it doesn't have any secrets.
+!.env.client
+# These are config files for dotenv-vault, so we don't want to ignore them.
+!.env.project
+!.env.vault
+
+# Generated by scripts/generate-llms-txt.mjs at predeploy
+public/llms.txt
+32
View File
@@ -0,0 +1,32 @@
--- template/app/README.md
+++ opensaas-sh/app/README.md
@@ -1,12 +1,27 @@
-# <YOUR_APP_NAME>
+# opensaas.sh (demo) app
-Built with [Wasp](https://wasp.sh), based on the [Open Saas](https://opensaas.sh) template.
+This is a Wasp app based on Open Saas template with minimal modifications that make it into a demo app that showcases Open Saas's abilities.
+
+It is deployed to https://opensaas.sh and serves both as a landing page for Open Saas and as a demo app.
## Development
+### .env files
+
+`.env.client` file is versioned, but `.env.server` file you have to obtain by running `npm run env:pull`, since it has secrets in it.
+This will generate `.env.server` based on the `.env.vault`.
+We are using https://vault.dotenv.org to power this and have an account/organization up there.
+If you modify .env.server and want to persist the changes (for yourself and for the other team members), do `npm run env:push`.
+
### 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`.
+
+## Deployment
+
+This app is deployed to fly.io, Wasp org, via `wasp deploy fly deploy`.
+
+You can run `npm run deploy` to deploy it via `wasp deploy fly deploy` with required client side env vars correctly set.
+22
View File
@@ -0,0 +1,22 @@
.agents/skills/add-wasp-skills/SKILL.md
.agents/skills/getting-started/SKILL.md
.agents/skills/guided-tour/SKILL.md
.claude/skills/add-wasp-skills
.claude/skills/getting-started
.claude/skills/guided-tour
public/llms.txt
src/client/static/open-saas-banner-dark.svg
src/client/static/open-saas-banner-light.svg
src/landing-page/components/Hero.tsx
src/payment/lemonSqueezy/checkoutUtils.ts
src/payment/lemonSqueezy/env.ts
src/payment/lemonSqueezy/paymentDetails.ts
src/payment/lemonSqueezy/paymentProcessor.ts
src/payment/lemonSqueezy/webhook.ts
src/payment/lemonSqueezy/webhookPayload.ts
src/payment/polar/checkoutUtils.ts
src/payment/polar/env.ts
src/payment/polar/paymentProcessor.ts
src/payment/polar/polarClient.ts
src/payment/polar/webhook.ts
src/payment/webhook.ts
+25
View File
@@ -0,0 +1,25 @@
--- template/app/fly-client.toml
+++ opensaas-sh/app/fly-client.toml
@@ -0,0 +1,22 @@
+# fly.toml app configuration file generated for open-saas-wasp-sh-client on 2025-07-18T17:12:02+02:00
+#
+# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
+#
+
+app = 'open-saas-wasp-sh-client'
+primary_region = 'ams'
+
+[build]
+
+[http_service]
+ internal_port = 8043
+ force_https = true
+ auto_stop_machines = 'stop'
+ auto_start_machines = true
+ min_machines_running = 0
+ processes = ['app']
+
+[[vm]]
+ memory = '1gb'
+ cpu_kind = 'shared'
+ cpus = 1
+25
View File
@@ -0,0 +1,25 @@
--- template/app/fly-server.toml
+++ opensaas-sh/app/fly-server.toml
@@ -0,0 +1,22 @@
+# fly.toml app configuration file generated for open-saas-wasp-sh-server on 2025-07-18T17:11:52+02:00
+#
+# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
+#
+
+app = 'open-saas-wasp-sh-server'
+primary_region = 'ams'
+
+[build]
+
+[http_service]
+ internal_port = 8080
+ force_https = true
+ auto_stop_machines = 'stop'
+ auto_start_machines = true
+ min_machines_running = 1
+ processes = ['app']
+
+[[vm]]
+ memory = '1gb'
+ cpu_kind = 'shared'
+ cpus = 1
+40
View File
@@ -0,0 +1,40 @@
--- template/app/main.wasp.ts
+++ opensaas-sh/app/main.wasp.ts
@@ -3,7 +3,6 @@
import { App } from "./src/client/App" with { type: "ref" };
import { NotFoundPage } from "./src/client/components/NotFoundPage" with { type: "ref" };
import { serverEnvValidationSchema } from "./src/env" with { type: "ref" };
-import { LandingPage } from "./src/landing-page/LandingPage" with { type: "ref" };
import { seedMockUsers } from "./src/server/scripts/dbSeeds" with { type: "ref" };
import { adminSpec } from "./src/admin/admin.wasp";
@@ -12,6 +11,7 @@
import { head } from "./src/client/head.wasp";
import { demoAiAppSpec } from "./src/demo-ai-app/demo-ai-app.wasp";
import { fileUploadSpec } from "./src/file-upload/file-upload.wasp";
+import { landingPage } from "./src/landing-page/landing-page.wasp";
import { paymentSpec } from "./src/payment/payment.wasp";
import { emailSender } from "./src/server/emailSender.wasp";
import { userSpec } from "./src/user/user.wasp";
@@ -19,7 +19,7 @@
export default app({
name: "OpenSaaS",
wasp: { version: "^0.25.0" },
- title: "My Open SaaS App",
+ title: "Open SaaS",
head,
auth: authConfig,
db: {
@@ -37,10 +37,9 @@
},
emailSender,
spec: [
- // Prerendering routes with static content creates HTML files at build time that are served immediately,
- // improving SEO, search engine/AI crawling, and performance: https://wasp.sh/docs/advanced/prerendering
- route("LandingPageRoute", "/", page(LandingPage), { prerender: true }),
route("NotFoundRoute", "*", page(NotFoundPage)),
+
+ landingPage,
authSpec,
userSpec,
demoAiAppSpec,
@@ -0,0 +1,121 @@
--- template/app/migrations/20231213174854_init/migration.sql
+++ opensaas-sh/app/migrations/20231213174854_init/migration.sql
@@ -0,0 +1,118 @@
+-- CreateTable
+CREATE TABLE "User" (
+ "id" SERIAL NOT NULL,
+ "email" TEXT,
+ "username" TEXT,
+ "password" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastActiveTimestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "isEmailVerified" BOOLEAN NOT NULL DEFAULT false,
+ "isMockUser" BOOLEAN NOT NULL DEFAULT false,
+ "isAdmin" BOOLEAN NOT NULL DEFAULT true,
+ "emailVerificationSentAt" TIMESTAMP(3),
+ "passwordResetSentAt" TIMESTAMP(3),
+ "stripeId" TEXT,
+ "checkoutSessionId" TEXT,
+ "hasPaid" BOOLEAN NOT NULL DEFAULT false,
+ "subscriptionTier" TEXT,
+ "subscriptionStatus" TEXT,
+ "sendEmail" BOOLEAN NOT NULL DEFAULT false,
+ "datePaid" TIMESTAMP(3),
+ "credits" INTEGER NOT NULL DEFAULT 3,
+
+ CONSTRAINT "User_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "SocialLogin" (
+ "id" TEXT NOT NULL,
+ "provider" TEXT NOT NULL,
+ "providerId" TEXT NOT NULL,
+ "userId" INTEGER NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "SocialLogin_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "GptResponse" (
+ "id" TEXT NOT NULL,
+ "content" TEXT NOT NULL,
+ "userId" INTEGER NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "GptResponse_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ContactFormMessage" (
+ "id" TEXT NOT NULL,
+ "content" TEXT NOT NULL,
+ "userId" INTEGER NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "isRead" BOOLEAN NOT NULL DEFAULT false,
+ "repliedAt" TIMESTAMP(3),
+
+ CONSTRAINT "ContactFormMessage_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "DailyStats" (
+ "id" SERIAL NOT NULL,
+ "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "totalViews" INTEGER NOT NULL DEFAULT 0,
+ "prevDayViewsChangePercent" TEXT NOT NULL DEFAULT '0',
+ "userCount" INTEGER NOT NULL DEFAULT 0,
+ "paidUserCount" INTEGER NOT NULL DEFAULT 0,
+ "userDelta" INTEGER NOT NULL DEFAULT 0,
+ "paidUserDelta" INTEGER NOT NULL DEFAULT 0,
+ "totalRevenue" DOUBLE PRECISION NOT NULL DEFAULT 0,
+ "totalProfit" DOUBLE PRECISION NOT NULL DEFAULT 0,
+
+ CONSTRAINT "DailyStats_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "PageViewSource" (
+ "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "name" TEXT NOT NULL,
+ "visitors" INTEGER NOT NULL,
+ "dailyStatsId" INTEGER,
+
+ CONSTRAINT "PageViewSource_pkey" PRIMARY KEY ("date","name")
+);
+
+-- CreateTable
+CREATE TABLE "Logs" (
+ "id" SERIAL NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "message" TEXT NOT NULL,
+ "level" TEXT NOT NULL,
+
+ CONSTRAINT "Logs_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "SocialLogin_provider_providerId_userId_key" ON "SocialLogin"("provider", "providerId", "userId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DailyStats_date_key" ON "DailyStats"("date");
+
+-- AddForeignKey
+ALTER TABLE "SocialLogin" ADD CONSTRAINT "SocialLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "GptResponse" ADD CONSTRAINT "GptResponse_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ContactFormMessage" ADD CONSTRAINT "ContactFormMessage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PageViewSource" ADD CONSTRAINT "PageViewSource_dailyStatsId_fkey" FOREIGN KEY ("dailyStatsId") REFERENCES "DailyStats"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,17 @@
--- template/app/migrations/20240105224550_tasks/migration.sql
+++ opensaas-sh/app/migrations/20240105224550_tasks/migration.sql
@@ -0,0 +1,14 @@
+-- CreateTable
+CREATE TABLE "Task" (
+ "id" TEXT NOT NULL,
+ "description" TEXT NOT NULL,
+ "time" TEXT NOT NULL DEFAULT '1',
+ "isDone" BOOLEAN NOT NULL DEFAULT false,
+ "userId" INTEGER NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,18 @@
--- template/app/migrations/20240207164719_files/migration.sql
+++ opensaas-sh/app/migrations/20240207164719_files/migration.sql
@@ -0,0 +1,15 @@
+-- CreateTable
+CREATE TABLE "File" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "type" TEXT NOT NULL,
+ "key" TEXT NOT NULL,
+ "uploadUrl" TEXT NOT NULL,
+ "userId" INTEGER NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "File_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "File" ADD CONSTRAINT "File_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,47 @@
--- template/app/migrations/20240226123357_new_auth_structure/migration.sql
+++ opensaas-sh/app/migrations/20240226123357_new_auth_structure/migration.sql
@@ -0,0 +1,44 @@
+-- CreateTable
+CREATE TABLE "Auth" (
+ "id" TEXT NOT NULL,
+ "userId" INTEGER,
+
+ CONSTRAINT "Auth_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "AuthIdentity" (
+ "providerName" TEXT NOT NULL,
+ "providerUserId" TEXT NOT NULL,
+ "providerData" TEXT NOT NULL DEFAULT '{}',
+ "authId" TEXT NOT NULL,
+
+ CONSTRAINT "AuthIdentity_pkey" PRIMARY KEY ("providerName","providerUserId")
+);
+
+-- CreateTable
+CREATE TABLE "Session" (
+ "id" TEXT NOT NULL,
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+ "userId" TEXT NOT NULL,
+
+ CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id");
+
+-- CreateIndex
+CREATE INDEX "Session_userId_idx" ON "Session"("userId");
+
+-- AddForeignKey
+ALTER TABLE "Auth" ADD CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "AuthIdentity" ADD CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,24 @@
--- template/app/migrations/20240226130234_remove_old_auth_structure/migration.sql
+++ opensaas-sh/app/migrations/20240226130234_remove_old_auth_structure/migration.sql
@@ -0,0 +1,21 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `emailVerificationSentAt` on the `User` table. All the data in the column will be lost.
+ - You are about to drop the column `isEmailVerified` on the `User` table. All the data in the column will be lost.
+ - You are about to drop the column `password` on the `User` table. All the data in the column will be lost.
+ - You are about to drop the column `passwordResetSentAt` on the `User` table. All the data in the column will be lost.
+ - You are about to drop the `SocialLogin` table. If the table is not empty, all the data it contains will be lost.
+
+*/
+-- DropForeignKey
+ALTER TABLE "SocialLogin" DROP CONSTRAINT "SocialLogin_userId_fkey";
+
+-- AlterTable
+ALTER TABLE "User" DROP COLUMN "emailVerificationSentAt",
+DROP COLUMN "isEmailVerified",
+DROP COLUMN "password",
+DROP COLUMN "passwordResetSentAt";
+
+-- DropTable
+DROP TABLE "SocialLogin";
@@ -0,0 +1,8 @@
--- template/app/migrations/20240226145340_remove_unique_email_username/migration.sql
+++ opensaas-sh/app/migrations/20240226145340_remove_unique_email_username/migration.sql
@@ -0,0 +1,5 @@
+-- DropIndex
+DROP INDEX "User_email_key";
+
+-- DropIndex
+DROP INDEX "User_username_key";
@@ -0,0 +1,11 @@
--- template/app/migrations/20240605151848_remove_has_paid/migration.sql
+++ opensaas-sh/app/migrations/20240605151848_remove_has_paid/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `hasPaid` on the `User` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "User" DROP COLUMN "hasPaid";
@@ -0,0 +1,68 @@
--- template/app/migrations/20240702143707_update_user_entity/migration.sql
+++ opensaas-sh/app/migrations/20240702143707_update_user_entity/migration.sql
@@ -0,0 +1,65 @@
+/*
+ Warnings:
+
+ - The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint.
+ - A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail.
+ - A unique constraint covering the columns `[username]` on the table `User` will be added. If there are existing duplicate values, this will fail.
+
+*/
+-- DropForeignKey
+ALTER TABLE "Auth" DROP CONSTRAINT "Auth_userId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "ContactFormMessage" DROP CONSTRAINT "ContactFormMessage_userId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "File" DROP CONSTRAINT "File_userId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "GptResponse" DROP CONSTRAINT "GptResponse_userId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "Task" DROP CONSTRAINT "Task_userId_fkey";
+
+-- AlterTable
+ALTER TABLE "Auth" ALTER COLUMN "userId" SET DATA TYPE TEXT;
+
+-- AlterTable
+ALTER TABLE "ContactFormMessage" ALTER COLUMN "userId" SET DATA TYPE TEXT;
+
+-- AlterTable
+ALTER TABLE "File" ALTER COLUMN "userId" SET DATA TYPE TEXT;
+
+-- AlterTable
+ALTER TABLE "GptResponse" ALTER COLUMN "userId" SET DATA TYPE TEXT;
+
+-- AlterTable
+ALTER TABLE "Task" ALTER COLUMN "userId" SET DATA TYPE TEXT;
+
+-- AlterTable
+ALTER TABLE "User" DROP CONSTRAINT "User_pkey",
+ALTER COLUMN "id" DROP DEFAULT,
+ALTER COLUMN "id" SET DATA TYPE TEXT,
+ADD CONSTRAINT "User_pkey" PRIMARY KEY ("id");
+DROP SEQUENCE "User_id_seq";
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
+
+-- AddForeignKey
+ALTER TABLE "GptResponse" ADD CONSTRAINT "GptResponse_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "File" ADD CONSTRAINT "File_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ContactFormMessage" ADD CONSTRAINT "ContactFormMessage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Auth" ADD CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
--- template/app/migrations/20240715142249_version_14/migration.sql
+++ opensaas-sh/app/migrations/20240715142249_version_14/migration.sql
@@ -0,0 +1,16 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `sendEmail` on the `User` table. All the data in the column will be lost.
+ - You are about to drop the column `subscriptionTier` on the `User` table. All the data in the column will be lost.
+ - A unique constraint covering the columns `[stripeId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
+
+*/
+-- AlterTable
+ALTER TABLE "User" DROP COLUMN "sendEmail",
+DROP COLUMN "subscriptionTier",
+ADD COLUMN "sendNewsletter" BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN "subscriptionPlan" TEXT;
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_stripeId_key" ON "User"("stripeId");
@@ -0,0 +1,11 @@
--- template/app/migrations/20241126132514_remove_checkout_session_id/migration.sql
+++ opensaas-sh/app/migrations/20241126132514_remove_checkout_session_id/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `checkoutSessionId` on the `User` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "User" DROP COLUMN IF EXISTS "checkoutSessionId";
@@ -0,0 +1,11 @@
--- template/app/migrations/20250220095333_remove_last_active_timestamp/migration.sql
+++ opensaas-sh/app/migrations/20250220095333_remove_last_active_timestamp/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `lastActiveTimestamp` on the `User` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "User" DROP COLUMN "lastActiveTimestamp";
@@ -0,0 +1,11 @@
--- template/app/migrations/20250710084843_remove_newsletter_fields/migration.sql
+++ opensaas-sh/app/migrations/20250710084843_remove_newsletter_fields/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `sendNewsletter` on the `User` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "User" DROP COLUMN "sendNewsletter";
@@ -0,0 +1,11 @@
--- template/app/migrations/20250731133938_drop_upload_url_from_file/migration.sql
+++ opensaas-sh/app/migrations/20250731133938_drop_upload_url_from_file/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `uploadUrl` on the `File` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "File" DROP COLUMN "uploadUrl";
@@ -0,0 +1,14 @@
--- template/app/migrations/20250806121259_add_s3_key_file/migration.sql
+++ opensaas-sh/app/migrations/20250806121259_add_s3_key_file/migration.sql
@@ -0,0 +1,11 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `key` on the `File` table. All the data in the column will be lost.
+ - Added the required column `s3Key` to the `File` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- AlterTable
+DELETE FROM "File";
+ALTER TABLE "File" DROP COLUMN "key",
+ADD COLUMN "s3Key" TEXT NOT NULL;
@@ -0,0 +1,7 @@
--- template/app/migrations/migration_lock.toml
+++ opensaas-sh/app/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (i.e. Git)
+provider = "postgresql"
\ No newline at end of file
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
--- template/app/package.json
+++ opensaas-sh/app/package.json
@@ -5,14 +5,19 @@
".wasp/out/*",
".wasp/out/sdk/wasp"
],
+ "scripts": {
+ "generate-llms-txt": "node ./scripts/generate-llms-txt.mjs",
+ "predeploy": "npm run generate-llms-txt",
+ "deploy": "REACT_APP_GOOGLE_ANALYTICS_ID=G-H3LSJCK95H wasp deploy fly deploy",
+ "env:pull": "npx dotenv-vault@latest pull development .env.server",
+ "env:push": "npx dotenv-vault@latest push development .env.server"
+ },
"dependencies": {
"@aws-sdk/client-s3": "^3.523.0",
"@aws-sdk/s3-presigned-post": "^3.750.0",
"@aws-sdk/s3-request-presigner": "^3.523.0",
"@google-analytics/data": "4.1.0",
"@hookform/resolvers": "^5.1.1",
- "@lemonsqueezy/lemonsqueezy.js": "^3.2.0",
- "@polar-sh/sdk": "^0.34.3",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
@@ -39,6 +44,7 @@
"react-apexcharts": "2.1.0",
"react-dom": "^19.2.1",
"react-hook-form": "^7.60.0",
+ "react-icons": "^5.5.0",
"react-router": "^8.0.1",
"stripe": "18.1.0",
"tailwind-merge": "^2.2.1",
@@ -0,0 +1,9 @@
--- template/app/public/scripts/posthog.js
+++ opensaas-sh/app/public/scripts/posthog.js
@@ -0,0 +1,5 @@
+!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
+posthog.init('CdDd2A0jKTI2vFAsrI9JWm3MqpOcgHz1bMyogAcwsE4', {
+ api_host: 'https://us.i.posthog.com',
+ person_profiles: 'identified_only',
+})
\ No newline at end of file
@@ -0,0 +1,15 @@
--- template/app/public/scripts/reo.js
+++ opensaas-sh/app/public/scripts/reo.js
@@ -0,0 +1,12 @@
+!(function () {
+ var e, t, n;
+ (e = "96f303a127451b4"),
+ (t = function () {
+ Reo.init({ clientID: "96f303a127451b4" });
+ }),
+ ((n = document.createElement("script")).src =
+ "https://static.reo.dev/" + e + "/reo.js"),
+ (n.defer = !0),
+ (n.onload = t),
+ document.head.appendChild(n);
+})();
+18
View File
@@ -0,0 +1,18 @@
--- template/app/schema.prisma
+++ opensaas-sh/app/schema.prisma
@@ -13,10 +13,12 @@
email String? @unique
username String? @unique
- isAdmin Boolean @default(false)
+ isAdmin Boolean @default(true)
+ // isMockUser is an extra property for the demo app ensuring that all users can access
+ // the admin dashboard but won't be able to see the other users' data, only mock user data.
+ isMockUser Boolean @default(false)
- paymentProcessorUserId String? @unique
- lemonSqueezyCustomerPortalUrl String? // You can delete this if you're not using Lemon Squeezy as your payments processor.
+ stripeId String? @unique
subscriptionStatus String? // 'active', 'cancel_at_period_end', 'past_due', 'deleted'
subscriptionPlan String? // 'hobby', 'pro'
datePaid DateTime?
@@ -0,0 +1,97 @@
--- template/app/scripts/generate-llms-txt.mjs
+++ opensaas-sh/app/scripts/generate-llms-txt.mjs
@@ -0,0 +1,94 @@
+import { readdir, readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const APP_ROOT = path.resolve(__dirname, "..");
+const BLOG_POSTS_DIR = path.resolve(
+ APP_ROOT,
+ "../blog/src/content/docs/blog",
+);
+const OUTPUT_FILE = path.join(APP_ROOT, "public/llms.txt");
+
+const DOCS_SITE = "https://docs.opensaas.sh";
+
+function parseFrontmatter(source) {
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+ if (!match) return {};
+ const out = {};
+ for (const line of match[1].split(/\r?\n/)) {
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
+ if (!m) continue;
+ const [, key, rawValue] = m;
+ let value = rawValue.trim();
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value.slice(1, -1);
+ }
+ out[key] = value;
+ }
+ return out;
+}
+
+async function collectBlogPosts() {
+ const entries = await readdir(BLOG_POSTS_DIR);
+ const posts = [];
+ for (const file of entries) {
+ if (!file.endsWith(".mdx") && !file.endsWith(".md")) continue;
+ const slug = file.replace(/\.(mdx|md)$/, "");
+ const source = await readFile(path.join(BLOG_POSTS_DIR, file), "utf8");
+ const fm = parseFrontmatter(source);
+ if (!fm.title) {
+ console.warn(`Skipping ${file}: no title in frontmatter`);
+ continue;
+ }
+ posts.push({
+ title: fm.title,
+ date: fm.date ?? "",
+ url: `${DOCS_SITE}/blog/${slug}/`,
+ });
+ }
+ posts.sort((a, b) => b.date.localeCompare(a.date));
+ return posts;
+}
+
+function renderLlmsTxt(posts) {
+ const lines = [
+ "# Open SaaS",
+ "",
+ "> Open SaaS is a free, open-source, full-stack SaaS boilerplate starter kit built on the Wasp framework (React, Node.js, Prisma). It ships with auth, Stripe/Lemon Squeezy/Polar payments, an OpenAI demo app, AWS S3 file uploads, an admin dashboard, email sending, and AI agent rules and skills.",
+ "",
+ "## Site",
+ "- [Homepage](https://opensaas.sh): product overview, features, demo, FAQ, etc.",
+ "- [GitHub repository](https://github.com/wasp-lang/open-saas): source code, issues, and releases",
+ "",
+ "## Documentation",
+ `- [Documentation site](${DOCS_SITE}/)`,
+ `- [LLM-formatted docs index](${DOCS_SITE}/llms.txt): per-guide links to raw markdown sources`,
+ `- [Full LLM-formatted docs](${DOCS_SITE}/llms-full.txt): every guide concatenated into one file`,
+ "",
+ "## Blog",
+ ];
+ for (const post of posts) {
+ const datePart = post.date ? ` — ${post.date}` : "";
+ lines.push(`- [${post.title}](${post.url})${datePart}`);
+ }
+ lines.push("");
+ return lines.join("\n");
+}
+
+async function main() {
+ const posts = await collectBlogPosts();
+ const content = renderLlmsTxt(posts);
+ await writeFile(OUTPUT_FILE, content, "utf8");
+ console.log(
+ `Wrote ${OUTPUT_FILE} with ${posts.length} blog post${posts.length === 1 ? "" : "s"}.`,
+ );
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
@@ -0,0 +1,11 @@
--- template/app/src/admin/dashboards/analytics/RevenueAndProfitChart.tsx
+++ opensaas-sh/app/src/admin/dashboards/analytics/RevenueAndProfitChart.tsx
@@ -11,7 +11,7 @@
},
colors: ["#3C50E0", "#80CAEE"],
chart: {
- fontFamily: "system-ui, sans-serif",
+ fontFamily: "Satoshi, system-ui, sans-serif",
height: 335,
type: "area",
dropShadow: {
@@ -0,0 +1,11 @@
--- template/app/src/admin/dashboards/users/UsersTable.tsx
+++ opensaas-sh/app/src/admin/dashboards/users/UsersTable.tsx
@@ -303,7 +303,7 @@
</div>
<div className="col-span-2 flex items-center">
<p className="text-muted-foreground text-sm">
- {user.paymentProcessorUserId}
+ {user.subscriptionStatus}
</p>
</div>
<div className="col-span-1 flex items-center">
@@ -0,0 +1,29 @@
--- template/app/src/analytics/providers/plausibleAnalyticsUtils.ts
+++ opensaas-sh/app/src/analytics/providers/plausibleAnalyticsUtils.ts
@@ -34,7 +34,7 @@
async function getTotalPageViews() {
const response = await fetch(
- `${env.PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${env.PLAUSIBLE_SITE_ID}&metrics=pageviews`,
+ `${env.PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${env.PLAUSIBLE_SITE_ID}&metrics=pageviews&with_imported=true`,
{
method: "GET",
headers,
@@ -83,7 +83,7 @@
}
async function getPageviewsForDate(date: string) {
- const url = `${env.PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${env.PLAUSIBLE_SITE_ID}&period=day&date=${date}&metrics=pageviews`;
+ const url = `${env.PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${env.PLAUSIBLE_SITE_ID}&period=day&date=${date}&metrics=pageviews&with_imported=true`;
const response = await fetch(url, {
method: "GET",
headers,
@@ -96,7 +96,7 @@
}
export async function getSources() {
- const url = `${env.PLAUSIBLE_BASE_URL}/v1/stats/breakdown?site_id=${env.PLAUSIBLE_SITE_ID}&property=visit:source&metrics=visitors`;
+ const url = `${env.PLAUSIBLE_BASE_URL}/v1/stats/breakdown?site_id=${env.PLAUSIBLE_SITE_ID}&property=visit:source&metrics=visitors&with_imported=true`;
const response = await fetch(url, {
method: "GET",
headers,
@@ -0,0 +1,52 @@
--- template/app/src/auth/auth.wasp.ts
+++ opensaas-sh/app/src/auth/auth.wasp.ts
@@ -28,7 +28,7 @@
const emailAuthMethod: NonNullable<AuthMethods["email"]> = {
fromField: {
name: "Open SaaS App",
- email: "me@example.com",
+ email: "noreply@mg.wasp.sh",
},
emailVerification: {
clientRoute: "EmailVerificationRoute",
@@ -41,27 +41,20 @@
userSignupFields: getEmailUserFields,
};
-// Plug the following authentication methods in the `authConfig` below to enable them.
// Do note that `email` and `usernameAndPassword` are mutually exclusive.
// @ts-expect-error Demo purposes
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const usernameAndPasswordAuthMethod: NonNullable<
AuthMethods["usernameAndPassword"]
> = {};
-// @ts-expect-error Demo purposes
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
const googleAuthMethod: NonNullable<AuthMethods["google"]> = {
userSignupFields: getGoogleUserFields,
configFn: getGoogleAuthConfig,
};
-// @ts-expect-error Demo purposes
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
const gitGubAuthMethod: NonNullable<AuthMethods["gitHub"]> = {
userSignupFields: getGitHubUserFields,
configFn: getGitHubAuthConfig,
};
-// @ts-expect-error Demo purposes
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
const discordAuthMethod: NonNullable<AuthMethods["discord"]> = {
userSignupFields: getDiscordUserFields,
configFn: getDiscordAuthConfig,
@@ -75,9 +68,9 @@
// (RequestPasswordResetRoute, PasswordResetRoute, EmailVerificationRoute)
email: emailAuthMethod,
// usernameAndPassword: usernameAndPasswordAuthMethod,
- // google: googleAuthMethod,
- // gitHub: gitGubAuthMethod,
- // discord: discordAuthMethod,
+ google: googleAuthMethod,
+ gitHub: gitGubAuthMethod,
+ discord: discordAuthMethod,
},
onAuthFailedRedirectTo: "/login",
onAuthSucceededRedirectTo: "/demo-app",
@@ -0,0 +1,30 @@
--- template/app/src/auth/email-and-pass/emails.ts
+++ opensaas-sh/app/src/auth/email-and-pass/emails.ts
@@ -6,10 +6,10 @@
export const getVerificationEmailContent: GetVerificationEmailContentFn = ({
verificationLink,
}) => ({
- subject: "Verify your email",
- text: `Click the link below to verify your email: ${verificationLink}`,
+ subject: "Open SaaS - Verify your email",
+ text: `Open SaaS - Click the link to verify your email: ${verificationLink}`,
html: `
- <p>Click the link below to verify your email</p>
+ <p>Open SaaS - Click the link to verify your email:</p>
<a href="${verificationLink}">Verify email</a>
`,
});
@@ -17,10 +17,10 @@
export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({
passwordResetLink,
}) => ({
- subject: "Password reset",
- text: `Click the link below to reset your password: ${passwordResetLink}`,
+ subject: "Open SaaS - Password reset",
+ text: `Open SaaS - Click the link to reset your password: ${passwordResetLink}`,
html: `
- <p>Click the link below to reset your password</p>
+ <p>Open SaaS - Click the link to reset your password:</p>
<a href="${passwordResetLink}">Reset password</a>
`,
});
@@ -0,0 +1,68 @@
--- template/app/src/auth/userSignupFields.ts
+++ opensaas-sh/app/src/auth/userSignupFields.ts
@@ -1,11 +1,6 @@
import { defineUserSignupFields } from "wasp/auth/providers/types";
-import { env } from "wasp/server";
import { z } from "zod";
-function isAdminEmail(email: string): boolean {
- return env.ADMIN_EMAILS.includes(email);
-}
-
const emailDataSchema = z.object({
email: z.string(),
});
@@ -19,10 +14,6 @@
const emailData = emailDataSchema.parse(data);
return emailData.email;
},
- isAdmin: (data) => {
- const emailData = emailDataSchema.parse(data);
- return isAdminEmail(emailData.email);
- },
});
const githubDataSchema = z.object({
@@ -51,14 +42,6 @@
const githubData = githubDataSchema.parse(data);
return githubData.profile.login;
},
- isAdmin: (data) => {
- const githubData = githubDataSchema.parse(data);
- const emailInfo = getGithubEmailInfo(githubData);
- if (!emailInfo.verified) {
- return false;
- }
- return isAdminEmail(emailInfo.email);
- },
});
// We are using the first email from the list of emails returned by GitHub.
@@ -91,13 +74,6 @@
const googleData = googleDataSchema.parse(data);
return googleData.profile.email;
},
- isAdmin: (data) => {
- const googleData = googleDataSchema.parse(data);
- if (!googleData.profile.email_verified) {
- return false;
- }
- return isAdminEmail(googleData.profile.email);
- },
});
export function getGoogleAuthConfig() {
@@ -129,13 +105,6 @@
const discordData = discordDataSchema.parse(data);
return discordData.profile.username;
},
- isAdmin: (data) => {
- const discordData = discordDataSchema.parse(data);
- if (!discordData.profile.email || !discordData.profile.verified) {
- return false;
- }
- return isAdminEmail(discordData.profile.email);
- },
});
export function getDiscordAuthConfig() {
@@ -0,0 +1,55 @@
--- template/app/src/client/App.tsx
+++ opensaas-sh/app/src/client/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo } from "react";
+import { useEffect, useMemo, useRef } from "react";
import { Outlet, useLocation } from "react-router";
import { routes } from "wasp/client/router";
import { Toaster } from "../client/components/ui/toaster";
@@ -17,10 +17,7 @@
export function App() {
const location = useLocation();
const isMarketingPage = useMemo(() => {
- return (
- location.pathname === routes.LandingPageRoute.to ||
- location.pathname === routes.PricingPageRoute.to
- );
+ return location.pathname === routes.LandingPageRoute.to;
}, [location]);
const navigationItems = isMarketingPage
@@ -38,14 +35,33 @@
return location.pathname.startsWith(routes.AdminRoute.to);
}, [location]);
+ const resizeObserverRef = useRef<ResizeObserver | null>(null);
+
useEffect(() => {
if (location.hash) {
+ console.log("location ..", location.hash);
const id = location.hash.replace("#", "");
const element = document.getElementById(id);
+
if (element) {
- element.scrollIntoView();
+ // Scroll immediately but watches for size changes (async content loading) and re-scrolls
+ element.scrollIntoView({ behavior: "smooth" });
+ console.log("element scrolled ..", element);
+ resizeObserverRef.current = new ResizeObserver(() => {
+ element.scrollIntoView({ behavior: "smooth" });
+ });
+ resizeObserverRef.current.observe(element);
+ }
+ } else if (location.pathname === "/") {
+ // Only scroll to top when navigating TO homepage from another page
+ if (window.scrollY > 0) {
+ window.scrollTo({ top: 0, behavior: "smooth" });
}
}
+
+ return () => {
+ resizeObserverRef.current?.disconnect();
+ };
}, [location]);
return (
@@ -0,0 +1,130 @@
--- template/app/src/client/Main.css
+++ opensaas-sh/app/src/client/Main.css
@@ -122,6 +122,25 @@
0px 2px 4px rgba(0, 0, 0, 0.2), inset 0px 2px 2px #ffffff,
inset 0px -1px 1px rgba(0, 0, 0, 0.1);
--shadow-switch-1: 0px 0px 5px rgba(0, 0, 0, 0.15);
+ --shadow-outer:
+ 0px 1px 3px 0px hsl(var(--background)/0.8) inset,
+ 0px 0.5px 1px 0px hsl(var(--background)/0.8) inset,
+ 0px -1px 3px 0px hsl(var(--primary)/0.3) inset,
+ 0px -0.5px 1px 0px hsl(var(--primary)/0.15) inset,
+ 0px -2px 4.8px 0px hsl(var(--background)/0.05),
+ 0px 1px 2px 0px hsl(var(--background)/0.1),
+ 0px 2px 4px 0px hsl(var(--background)/0.1),
+ 0px 4px 8px 0px hsl(var(--background)/0.15);
+ --shadow-inner:
+ 0px 1px 3px 0px hsl(var(--background)/0.8),
+ 0px 0.5px 1px 0px hsl(var(--background)/0.5),
+ 0px -1px 3px 0px hsl(var(--primary)/0.5),
+ 0px -0.5px 1px 0px hsl(var(--primary)/0.5),
+ 0px -1px 4px 0px hsl(var(--background)/0.06) inset,
+ 0px -2px 4.8px 0px hsl(var(--background)/0.06) inset,
+ 0px 1px 2px 0px hsl(var(--background)/0.06) inset,
+ 0px 2px 4px 0px hsl(var(--background)/0.06) inset,
+ 0px 4px 8px 0px hsl(var(--background)/0.06) inset;
--drop-shadow-1: 0px 1px 0px #e2e8f0;
--drop-shadow-2: 0px 1px 4px rgba(0, 0, 0, 0.12);
@@ -136,6 +155,8 @@
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
+ --font-satoshi: "Satoshi", system-ui, sans-serif;
+
@keyframes rotating {
0%,
100% {
@@ -209,6 +230,64 @@
& > * {
background: hsl(var(--background));
}
+
+ /* Radial gradient utilities */
+ .bg-radial-gradient {
+ background: radial-gradient(
+ circle at 30% 30%,
+ hsl(var(--card-accent) / 0.5) 0%,
+ hsl(var(--accent) / 0.15) 100%
+ );
+ }
+
+ .dark .bg-radial-gradient {
+ background: radial-gradient(
+ circle at 30% 30%,
+ hsl(var(--card-subtle) / 0.8) 0%,
+ hsl(var(--card)) 100%
+ );
+ }
+}
+
+/* Satoshi Font Family */
+@font-face {
+ font-family: "Satoshi";
+ src: url("/fonts/Satoshi-Light.woff2") format("woff2");
+ font-weight: 300;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Satoshi";
+ src: url("/fonts/Satoshi-Regular.woff2") format("woff2");
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Satoshi";
+ src: url("/fonts/Satoshi-Medium.woff2") format("woff2");
+ font-weight: 500;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Satoshi";
+ src: url("/fonts/Satoshi-Bold.woff2") format("woff2");
+ font-weight: bold;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Satoshi";
+ src: url("/fonts/Satoshi-Black.woff2") format("woff2");
+ font-weight: 900;
+ font-style: normal;
+ font-display: swap;
}
@layer base {
@@ -285,6 +364,19 @@
body {
@apply bg-background text-foreground;
}
+ /* Global typography styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ @apply font-satoshi font-black leading-tight;
+ }
+
+ p {
+ @apply font-mono text-base leading-relaxed;
+ }
}
/* third-party libraries CSS */
@@ -330,3 +422,7 @@
.apexcharts-xaxistooltip-bottom:before {
@apply dark:border-b-muted!;
}
+
+.navbar-maxwidth-transition {
+ transition: max-width 300ms cubic-bezier(0.4, 0, 0.2, 1);
+}
@@ -0,0 +1,46 @@
--- template/app/src/client/components/NavBar/Announcement.tsx
+++ opensaas-sh/app/src/client/components/NavBar/Announcement.tsx
@@ -1,32 +1,33 @@
-const ANNOUNCEMENT_URL = "https://github.com/wasp-lang/wasp";
+const ANNOUNCEMENT_URL = "https:/openvibe.sh";
export function Announcement() {
+
return (
- <div className="from-accent to-secondary text-primary-foreground bg-linear-to-r relative flex w-full items-center justify-center gap-3 p-3 text-center font-semibold">
+ <div className="from-accent to-secondary text-primary-foreground relative z-51 flex w-full items-center justify-center gap-3 bg-linear-to-r p-3 text-center font-semibold tracking-wider">
<a
href={ANNOUNCEMENT_URL}
target="_blank"
- rel="noopener noreferrer"
- className="hidden cursor-pointer transition-opacity hover:opacity-90 hover:drop-shadow-sm lg:block"
+ className="hidden transition-opacity hover:opacity-90 hover:drop-shadow-sm lg:block"
+ rel="noreferrer"
>
- Support Open-Source Software!
+ 🧑‍🏫 Learn to build a SaaS with our free, interactive course
</a>
<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="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"
+ className="bg-background/20 hover:bg-background/30 hidden cursor-pointer rounded-full px-2.5 py-1 tracking-wider opacity-95 transition-colors lg:block"
+ rel="noreferrer"
>
- Star Our Repo on Github ⭐️ →
+ Become a pro with Open Vibe 😎 →
</a>
<a
href={ANNOUNCEMENT_URL}
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"
+ rel="noreferrer"
>
- ⭐️ Star the Our Repo and Support Open-Source! ⭐️
+ 🧑‍🏫 Learn to Build a SaaS w/ our free course →
</a>
</div>
);
@@ -0,0 +1,120 @@
--- template/app/src/client/components/NavBar/NavBar.tsx
+++ opensaas-sh/app/src/client/components/NavBar/NavBar.tsx
@@ -3,6 +3,7 @@
import { Link as ReactRouterLink } from "react-router";
import { useAuth } from "wasp/client/auth";
import { Link as WaspRouterLink, routes } from "wasp/client/router";
+import { Button } from "../../../client/components/ui/button";
import {
Sheet,
SheetContent,
@@ -17,6 +18,7 @@
import logo from "../../static/logo.webp";
import { cn } from "../../utils";
import { DarkModeSwitcher } from "../DarkModeSwitcher";
+import { RepoInfo } from "../RepoInfo";
import { Announcement } from "./Announcement";
export interface NavigationItem {
@@ -51,7 +53,7 @@
<header
className={cn(
"sticky top-0 z-50 transition-all duration-300",
- isScrolled && "top-4",
+ isScrolled && "xl:mx-30 top-4 mx-4 lg:mx-10",
)}
>
<div
@@ -66,7 +68,7 @@
className={cn(
"flex items-center justify-between transition-all duration-300",
{
- "p-3 lg:px-6": isScrolled,
+ "p-3 px-4 lg:p-4 lg:px-5": isScrolled,
"p-6 lg:px-8": !isScrolled,
},
)}
@@ -87,7 +89,7 @@
},
)}
>
- Your SaaS
+ Open SaaS
</span>
</WaspRouterLink>
@@ -113,7 +115,12 @@
return (
<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 />
+ <li>
+ <RepoInfo />
+ </li>
+ <li>
+ <DarkModeSwitcher />
+ </li>
</ul>
{isUserLoading ? null : !user ? (
<WaspRouterLink
@@ -126,10 +133,11 @@
},
)}
>
- <div className="text-foreground hover:text-primary flex items-center transition-colors duration-300 ease-in-out">
+ <div className="text-foreground hover:text-primary flex items-center gap-1 transition-colors duration-300 ease-in-out">
+ <span>Demo App</span>
Log in{" "}
<LogIn
- size={isScrolled ? "1rem" : "1.1rem"}
+ size="1rem"
className={cn("transition-all duration-300", {
"ml-1 mt-[0.1rem]": !isScrolled,
"ml-1": isScrolled,
@@ -180,7 +188,7 @@
<SheetHeader>
<SheetTitle className="flex items-center">
<WaspRouterLink to={routes.LandingPageRoute.to}>
- <span className="sr-only">Your SaaS</span>
+ <span className="sr-only">Open SaaS</span>
<NavLogo isScrolled={false} />
</WaspRouterLink>
</SheetTitle>
@@ -193,9 +201,9 @@
<div className="py-6">
{isUserLoading ? null : !user ? (
<WaspRouterLink to={routes.LoginRoute.to}>
- <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>
+ <Button variant="outline">
+ <span>Demo App</span> <LogIn className="ml-1" />
+ </Button>
</WaspRouterLink>
) : (
<ul className="space-y-2">
@@ -207,7 +215,14 @@
)}
</div>
<div className="py-6">
- <DarkModeSwitcher />
+ <ul className="flex items-center justify-between gap-4">
+ <li>
+ <DarkModeSwitcher />
+ </li>
+ <li>
+ <RepoInfo />
+ </li>
+ </ul>
</div>
</div>
</div>
@@ -252,7 +267,7 @@
"size-7": isScrolled,
})}
src={logo}
- alt="Your SaaS App"
+ alt="Open SaaS App"
/>
);
}
@@ -0,0 +1,19 @@
--- template/app/src/client/components/NavBar/constants.ts
+++ opensaas-sh/app/src/client/components/NavBar/constants.ts
@@ -8,13 +8,14 @@
];
export const marketingNavigationItems: NavigationItem[] = [
- { name: "Features", to: "/#features" },
- { name: "Pricing", to: routes.PricingPageRoute.to },
+ { name: "Features", to: "/#auth-feature" },
+ { name: "Roadmap", to: "/#roadmap" },
...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 },
...staticNavigationItems,
] as const;
@@ -0,0 +1,50 @@
--- template/app/src/client/components/RepoInfo.tsx
+++ opensaas-sh/app/src/client/components/RepoInfo.tsx
@@ -0,0 +1,47 @@
+import { useEffect, useState } from "react";
+import { FaGithub } from "react-icons/fa";
+import { Button } from "../../client/components/ui/button";
+import { formatNumber } from "../utils";
+
+export function RepoInfo() {
+ const [repoInfo, setRepoInfo] = useState<null | any>(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchRepoInfo = async () => {
+ try {
+ setIsLoading(true);
+ const response = await fetch(
+ "https://api.github.com/repos/wasp-lang/open-saas",
+ );
+ const data = await response.json();
+ setRepoInfo(data);
+ } catch (error) {
+ console.error("Error fetching repo info", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchRepoInfo();
+ }, []);
+
+ if (isLoading || !repoInfo) {
+ return null;
+ }
+
+ return (
+ <a
+ href="https://github.com/wasp-lang/open-saas"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Button variant="ghost" className="h-8 rounded-full py-0 pl-2 pr-3">
+ <FaGithub />
+ <span className="text-sm leading-none">
+ {formatNumber(repoInfo.stargazers_count)}
+ </span>
+ </Button>
+ </a>
+ );
+}
+
@@ -0,0 +1,74 @@
--- template/app/src/client/components/cookie-consent/Config.ts
+++ opensaas-sh/app/src/client/components/cookie-consent/Config.ts
@@ -1,4 +1,5 @@
import type { CookieConsentConfig } from "vanilla-cookieconsent";
+import * as CookieConsent from "vanilla-cookieconsent";
declare global {
interface Window {
@@ -15,8 +16,9 @@
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",
- // Bump the revision field when you add new services
- revision: 0,
+ // Bump the revision field when you add new services.
+ // Keep this in sync with: opensaas-sh/blog/src/components/CookieConsentBanner.astro
+ revision: 2,
// Default configuration for the cookie.
cookie: {
@@ -50,6 +52,12 @@
{
name: "_gid", // string: exact cookie name
},
+ {
+ name: /^__sec__/, // regex: match all REO.dev cookies (https://docs.reo.dev/integrations/tracking-beacon/reo.dev-javascript-cookies-and-consent-guide#key-features-of-reo.dev-cookies)
+ },
+ {
+ name: /^ph_/, // regex: match all PostHog cookies
+ },
],
},
@@ -87,6 +95,28 @@
},
onReject: () => {},
},
+ posthog: {
+ label: "PostHog",
+ onAccept: async () => {
+ try {
+ await CookieConsent.loadScript("/scripts/posthog.js");
+ } catch (error) {
+ console.error(error);
+ }
+ },
+ onReject: () => {},
+ },
+ reo: {
+ label: "REO.dev",
+ onAccept: async () => {
+ try {
+ await CookieConsent.loadScript("/scripts/reo.js");
+ } catch (error) {
+ console.error(error);
+ }
+ },
+ onReject: () => {},
+ },
},
},
},
@@ -103,10 +133,7 @@
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: `
- <a href="<your-url-here>" target="_blank">Privacy Policy</a>
- <a href="<your-url-here>" target="_blank">Terms and Conditions</a>
- `,
+ footer: `<a href="https://docs.opensaas.sh/general/privacy-policy" target="_blank">Privacy Policy</a>`,
},
// The showPreferencesBtn activates this modal to manage individual preferences https://cookieconsent.orestbida.com/reference/configuration-reference.html#translation-preferencesmodal
preferencesModal: {
@@ -0,0 +1,31 @@
--- template/app/src/client/components/ui/button.tsx
+++ opensaas-sh/app/src/client/components/ui/button.tsx
@@ -10,21 +10,26 @@
variants: {
variant: {
default:
- "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ "bg-primary text-primary-foreground shadow-outer 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",
+ ghost: "hover:bg-muted hover:text-foreground text-muted-foreground",
link: "text-primary underline-offset-4 hover:underline",
+ selected: "border bg-muted text-muted-foreground",
+ outer: "shadow-outer bg-card text-card-foreground",
+ inner:
+ "shadow-inner bg-secondary-muted text-secondary-muted-foreground",
},
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",
+ iconLg: "h-12 w-12",
},
},
defaultVariants: {
@@ -0,0 +1,15 @@
--- template/app/src/client/components/ui/card.tsx
+++ opensaas-sh/app/src/client/components/ui/card.tsx
@@ -12,7 +12,11 @@
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",
+ "bg-card-subtle text-card-subtle-foreground border-none shadow-none hover:shadow-none",
+ bentoHighlight:
+ "bg-card-subtle text-card-subtle-foreground border-none shadow-outer dark:shadow-lg hover:shadow-outer",
+ outer: "bg-card shadow-outer text-card-foreground hover:shadow-outer",
+ inner: "bg-card shadow-inner text-card-foreground hover:shadow-inner",
},
},
},
@@ -0,0 +1,42 @@
--- template/app/src/client/head.wasp.ts
+++ opensaas-sh/app/src/client/head.wasp.ts
@@ -3,24 +3,24 @@
export const head: App["head"] = [
"<link rel='icon' href='/favicon.ico' />",
- "<meta name='description' content='Your apps main description and features.' />",
- "<meta name='author' content='Your (App) Name' />",
- "<meta name='keywords' content='saas, solution, product, app, service' />",
+ "<meta name='description' content='Build and launch your SaaS application faster with our free, open-source starter kit. Features include auth, payments, AI example app, and admin dashboard.' />",
+ "<meta name='author' content='Open SaaS' />",
+ "<meta name='keywords' content='saas, starter, boilerplate, free, open source, authentication, payments' />",
+ "<meta property='og:site_name' content='Open SaaS' />",
"<meta property='og:type' content='website' />",
- "<meta property='og:title' content='Your Open SaaS App' />",
- "<meta property='og:site_name' content='Your Open SaaS App' />",
- "<meta property='og:url' content='https://your-saas-app.com' />",
- "<meta property='og:description' content='Your apps main description and features.' />",
- "<meta property='og:image' content='https://your-saas-app.com/public-banner.webp' />",
- "<meta name='twitter:image' content='https://your-saas-app.com/public-banner.webp' />",
+ "<meta property='og:title' content='Open SaaS' />",
+ "<meta property='og:url' content='https://opensaas.sh' />",
+ "<meta property='og:description' content='Free, open-source SaaS boilerplate starter for React & NodeJS. Powered by Wasp.' />",
+ "<meta property='og:image' content='https://opensaas.sh/public-banner.webp' />",
+
+ "<meta name=\"twitter:title\" content=\"Open SaaS\" />",
+ "<meta name=\"twitter:text:title\" content=\"Open SaaS\" />",
+ "<meta name='twitter:image' content='https://opensaas.sh/public-banner.webp' />",
+ "<meta name=\"twitter:image:alt\" content=\"Open SaaS\" />",
"<meta name='twitter:image:width' content='800' />",
"<meta name='twitter:image:height' content='400' />",
"<meta name='twitter:card' content='summary_large_image' />",
- // TODO: You can put your Plausible analytics scripts below (https://docs.opensaas.sh/guides/analytics/):
- // NOTE: Plausible does not use Cookies, so you can simply add the scripts here.
- // Google, on the other hand, does, so you must instead add the script dynamically
- // via the Cookie Consent component after the user clicks the "Accept" cookies button.
- "<script async data-domain='<your-site-id>' src='https://plausible.io/js/script.js'></script>", // for production
- "<script async data-domain='<your-site-id>' src='https://plausible.io/js/script.local.js'></script>", // for development
+
+ "<script async data-domain='opensaas.sh' data-api='/waspara/wasp/event' src='/waspara/wasp/script.js'></script>",
];
@@ -0,0 +1,15 @@
--- template/app/src/client/utils.ts
+++ opensaas-sh/app/src/client/utils.ts
@@ -4,3 +4,12 @@
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+
+export function formatNumber(number: number) {
+ if (number >= 1_000_000) {
+ return (number / 1_000_000).toFixed(1) + "M";
+ }
+ if (number >= 1_000) {
+ return (number / 1_000).toFixed(1) + "K";
+ }
+}
+20
View File
@@ -0,0 +1,20 @@
--- template/app/src/env.ts
+++ opensaas-sh/app/src/env.ts
@@ -5,8 +5,6 @@
import { authEnvSchema } from "./auth/env";
import { demoAiAppEnvSchema } from "./demo-ai-app/env";
import { fileUploadEnvSchema } from "./file-upload/env";
-import { lemonSqueezyEnvSchema } from "./payment/lemonSqueezy/env";
-import { polarEnvSchema } from "./payment/polar/env";
import { stripeEnvSchema } from "./payment/stripe/env";
// Wasp merges this schema with its built-in env var validations and uses it
@@ -20,8 +18,6 @@
z.object({
...authEnvSchema.shape,
...stripeEnvSchema.shape,
- ...lemonSqueezyEnvSchema.shape,
- ...polarEnvSchema.shape,
...demoAiAppEnvSchema.shape,
...fileUploadEnvSchema.shape,
...plausibleEnvSchema.shape,
@@ -0,0 +1,28 @@
--- template/app/src/file-upload/file-upload.wasp.ts
+++ opensaas-sh/app/src/file-upload/file-upload.wasp.ts
@@ -1,4 +1,4 @@
-import { action, page, query, route, type Spec } from "@wasp.sh/spec";
+import { action, job, page, query, route, type Spec } from "@wasp.sh/spec";
import { FileUploadPage } from "./FileUploadPage" with { type: "ref" };
import {
@@ -8,6 +8,7 @@
getAllFilesByUser,
getDownloadFileSignedURL,
} from "./operations" with { type: "ref" };
+import { deleteFilesJob } from "./workers" with { type: "ref" };
export const fileUploadSpec: Spec = [
route(
@@ -20,4 +21,11 @@
action(addFileToDb, { entities: ["User", "File"] }),
action(createFileUploadUrl, { entities: ["User", "File"] }),
action(deleteFile, { entities: ["User", "File"] }),
+ job(deleteFilesJob, {
+ executor: "PgBoss",
+ schedule: {
+ cron: "0 5 * * *", // every day at 5am
+ },
+ entities: ["File"],
+ }),
];
@@ -0,0 +1,33 @@
--- template/app/src/file-upload/fileUploading.ts
+++ opensaas-sh/app/src/file-upload/fileUploading.ts
@@ -1,4 +1,6 @@
+import { PrismaClient } from "@prisma/client";
import ky from "ky";
+import type { User } from "wasp/entities";
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from "./validation";
type AllowedFileTypes = (typeof ALLOWED_FILE_TYPES)[number];
@@ -55,3 +57,23 @@
function isFileWithAllowedFileType(file: File): file is FileWithValidType {
return ALLOWED_FILE_TYPES.includes(file.type as AllowedFileTypes);
}
+
+export async function checkIfUserHasReachedFileUploadLimit({
+ userId,
+ prismaFileDelegate,
+}: {
+ userId: User["id"];
+ prismaFileDelegate: PrismaClient["file"];
+}) {
+ const numberOfFilesByUser = await prismaFileDelegate.count({
+ where: {
+ user: {
+ id: userId,
+ },
+ },
+ });
+ if (numberOfFilesByUser >= 2) {
+ return true;
+ }
+ return false;
+}
@@ -0,0 +1,25 @@
--- template/app/src/file-upload/operations.ts
+++ opensaas-sh/app/src/file-upload/operations.ts
@@ -7,6 +7,7 @@
type GetAllFilesByUser,
type GetDownloadFileSignedURL,
} from "wasp/server/operations";
+import { checkIfUserHasReachedFileUploadLimit } from "./fileUploading";
import * as z from "zod";
import { ensureArgsSchemaOrThrowHttpError } from "../server/validation";
@@ -37,6 +38,14 @@
throw new HttpError(401);
}
+ const userFileLimitReached = await checkIfUserHasReachedFileUploadLimit({
+ userId: context.user.id,
+ prismaFileDelegate: context.entities.File,
+ });
+ if (userFileLimitReached) {
+ throw new HttpError(403, "This demo only allows 2 file uploads per user.");
+ }
+
const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(
createFileInputSchema,
rawArgs,
@@ -0,0 +1,40 @@
--- template/app/src/file-upload/workers.ts
+++ opensaas-sh/app/src/file-upload/workers.ts
@@ -0,0 +1,37 @@
+import type { DeleteFilesJob } from "wasp/server/jobs";
+import { deleteFileFromS3 } from "./s3Utils";
+
+export const deleteFilesJob: DeleteFilesJob<never, void> = async (
+ _args,
+ context,
+) => {
+ const dayInMiliseconds = 1000 * 60 * 60 * 24;
+ const sevenDaysAgo = Date.now() - 7 * dayInMiliseconds;
+ const filesToDelete = await context.entities.File.findMany({
+ where: {
+ createdAt: {
+ lt: new Date(sevenDaysAgo),
+ },
+ },
+ select: { s3Key: true, id: true },
+ });
+
+ const deletionResults = await Promise.allSettled(
+ filesToDelete.map(async (file) => {
+ await deleteFileFromS3({ s3Key: file.s3Key });
+ return file.id;
+ }),
+ );
+
+ const successfullyDeletedFromS3Ids = deletionResults
+ .filter((result) => result.status === "fulfilled")
+ .map((result) => result.value);
+
+ const deletedFiles = await context.entities.File.deleteMany({
+ where: {
+ id: { in: successfullyDeletedFromS3Ids },
+ },
+ });
+
+ console.log(`Deleted ${deletedFiles.count} files`);
+};
@@ -0,0 +1,33 @@
--- template/app/src/landing-page/LandingPage.tsx
+++ opensaas-sh/app/src/landing-page/LandingPage.tsx
@@ -1,8 +1,10 @@
+import { Admin, AIReady, Auth, Payments } from "./components/Examples";
import { ExamplesCarousel } from "./components/ExamplesCarousel";
import { FAQ } from "./components/FAQ";
import { FeaturesGrid } from "./components/FeaturesGrid";
import { Footer } from "./components/Footer";
import { Hero } from "./components/Hero";
+import { Roadmap } from "./components/Roadmap";
import { SchemaMarkup } from "./components/SchemaMarkup";
import { Testimonials } from "./components/Testimonials";
import {
@@ -12,7 +14,6 @@
footerNavigation,
testimonials,
} from "./contentSections";
-import { AIReady } from "./ExampleHighlightedFeature";
export function LandingPage() {
return (
@@ -22,7 +23,11 @@
<Hero />
<ExamplesCarousel examples={examples} />
<AIReady />
+ <Auth />
+ <Payments />
+ <Admin />
<FeaturesGrid features={features} />
+ <Roadmap />
<Testimonials testimonials={testimonials} />
<FAQ faqs={faqs} />
</main>
@@ -0,0 +1,26 @@
--- template/app/src/landing-page/components/Examples/AIReady.tsx
+++ opensaas-sh/app/src/landing-page/components/Examples/AIReady.tsx
@@ -0,0 +1,23 @@
+import aiReadyDark from "../../../client/static/assets/aiready-dark.webp";
+import aiReady from "../../../client/static/assets/aiready.webp";
+import { HighlightedFeature } from "../HighlightedFeature";
+
+export function AIReady() {
+ return (
+ <HighlightedFeature
+ name="AI Ready"
+ description="With Wasp's abstractions and opinionated defaults, plus custom agent rules, skills, and tools to use with your favorite AI-assisted coding tool, this is the perfect stack for building complex apps in the AI era."
+ highlightedComponent={<AIReadyExample />}
+ direction="row-reverse"
+ />
+ );
+}
+
+function AIReadyExample() {
+ return (
+ <div className="w-full">
+ <img src={aiReady} alt="AI Ready" className="dark:hidden" />
+ <img src={aiReadyDark} alt="AI Ready" className="hidden dark:block" />
+ </div>
+ );
+}
@@ -0,0 +1,23 @@
--- template/app/src/landing-page/components/Examples/Admin.tsx
+++ opensaas-sh/app/src/landing-page/components/Examples/Admin.tsx
@@ -0,0 +1,20 @@
+import admin from "../../../client/static/assets/admin.webp";
+import { HighlightedFeature } from "../HighlightedFeature";
+
+export function Admin() {
+ return (
+ <HighlightedFeature
+ name="Admin Dashboard"
+ description="Graphs! Tables! Analytics w/ Plausible or Google! All in one place. Ooooooooh."
+ highlightedComponent={<AdminExample />}
+ />
+ );
+}
+
+function AdminExample() {
+ return (
+ <div className="w-full">
+ <img src={admin} alt="Admin" />
+ </div>
+ );
+}
@@ -0,0 +1,158 @@
--- template/app/src/landing-page/components/Examples/Auth.tsx
+++ opensaas-sh/app/src/landing-page/components/Examples/Auth.tsx
@@ -0,0 +1,155 @@
+import { type ComponentType, useState } from "react";
+import { FaDiscord, FaGithub, FaGoogle, FaSlack } from "react-icons/fa";
+import { Button } from "../../../client/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+} from "../../../client/components/ui/card";
+import { Input } from "../../../client/components/ui/input";
+import { DocsUrl } from "../../../shared/common";
+import { HighlightedFeature } from "../HighlightedFeature";
+
+const Provider = {
+ google: { displayName: "Google", icon: FaGoogle },
+ github: { displayName: "GitHub", icon: FaGithub },
+ discord: { displayName: "Discord", icon: FaDiscord },
+ slack: { displayName: "Slack", icon: FaSlack },
+} as const satisfies Record<
+ string,
+ { displayName: string; icon: ComponentType<{ className?: string }> }
+>;
+
+type AuthProvider = keyof typeof Provider;
+
+const providers = Object.entries(Provider) as (readonly [
+ key: AuthProvider,
+ provider: (typeof Provider)[AuthProvider],
+])[];
+
+export function Auth() {
+ const [selectedProviders, setSelectedProviders] = useState<
+ readonly AuthProvider[]
+ >(["google", "github"]);
+
+ const toggleProvider = (provider: AuthProvider) => {
+ setSelectedProviders((prev) =>
+ prev.includes(provider)
+ ? prev.filter((p) => p !== provider)
+ : [...prev, provider],
+ );
+ };
+
+ return (
+ <HighlightedFeature
+ id="auth-feature"
+ name="DIY Auth Done for you"
+ description={
+ <AuthPlayground
+ toggleProvider={toggleProvider}
+ selectedProviders={selectedProviders}
+ />
+ }
+ highlightedComponent={
+ <AuthExample selectedProviders={selectedProviders} />
+ }
+ tilt="left"
+ className="h-100"
+ />
+ );
+}
+
+function AuthPlayground({
+ toggleProvider,
+ selectedProviders,
+}: {
+ toggleProvider: (provider: AuthProvider) => void;
+ selectedProviders: readonly AuthProvider[];
+}) {
+ return (
+ <div className="flex flex-col gap-2">
+ <p className="text-muted-foreground">
+ Pre-configured full-stack Auth that you own.
+ </p>
+ <div className="flex flex-row gap-2">
+ {providers.map(([key, provider]) => {
+ const IconComponent = provider.icon;
+ return (
+ <Button
+ key={key}
+ onClick={() => toggleProvider(key)}
+ size="iconLg"
+ variant={selectedProviders.includes(key) ? "inner" : "outer"}
+ title={provider.displayName}
+ >
+ <IconComponent />
+ </Button>
+ );
+ })}
+ </div>
+ </div>
+ );
+}
+
+function Divider() {
+ return (
+ <div className="flex flex-row items-center gap-2">
+ <div className="bg-muted h-px flex-1" />
+ <p className="text-xs">Or continue with</p>
+ <div className="bg-muted h-px flex-1" />
+ </div>
+ );
+}
+
+function AuthExample({
+ selectedProviders,
+}: {
+ selectedProviders: readonly AuthProvider[];
+}) {
+ return (
+ <Card
+ className="w-full max-w-md py-10 transition-all duration-300 ease-in-out"
+ variant="outer"
+ >
+ <CardHeader>
+ <p className="text-2xl font-bold">Log in to your account</p>
+ </CardHeader>
+ <CardContent>
+ <div className="flex flex-col gap-4">
+ <div className="flex flex-col">
+ {selectedProviders.length > 0 ? (
+ <p className="mb-2 text-xs">Log in with</p>
+ ) : null}
+ <div className="flex flex-row gap-2">
+ {selectedProviders.map((provider) => {
+ const { icon: IconComponent, displayName } = Provider[provider];
+ return (
+ <a
+ href={DocsUrl + "/guides/authentication/"}
+ key={provider}
+ className="mb-2 w-full"
+ target="_blank"
+ >
+ <Button
+ variant="outline"
+ className="w-full"
+ title={displayName}
+ >
+ <IconComponent className="h-4 w-4" />
+ </Button>
+ </a>
+ );
+ })}
+ </div>
+ </div>
+ {selectedProviders.length > 0 ? <Divider /> : null}
+ <div className="flex flex-col gap-2">
+ <Input type="email" placeholder="Email" />
+ <Input type="password" placeholder="Password" />
+ <Button className="mt-2">Log in</Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
@@ -0,0 +1,24 @@
--- template/app/src/landing-page/components/Examples/Payments.tsx
+++ opensaas-sh/app/src/landing-page/components/Examples/Payments.tsx
@@ -0,0 +1,21 @@
+import payments from "../../../client/static/assets/payments.webp";
+import { HighlightedFeature } from "../HighlightedFeature";
+
+export function Payments() {
+ return (
+ <HighlightedFeature
+ name="Stripe / Polar.sh / Lemon Squeezy Integration"
+ description="No SaaS is complete without payments. We've pre-configured checkout and webhooks for Stripe, Polar.sh, and Lemon Squeezy. Just choose a provider and start cashing out."
+ highlightedComponent={<PaymentsExample />}
+ direction="row-reverse"
+ />
+ );
+}
+
+function PaymentsExample() {
+ return (
+ <div className="w-full max-w-lg">
+ <img src={payments} alt="Payments" />
+ </div>
+ );
+}
@@ -0,0 +1,7 @@
--- template/app/src/landing-page/components/Examples/index.ts
+++ opensaas-sh/app/src/landing-page/components/Examples/index.ts
@@ -0,0 +1,4 @@
+export { Admin } from "./Admin";
+export { AIReady } from "./AIReady";
+export { Auth } from "./Auth";
+export { Payments } from "./Payments";
@@ -0,0 +1,11 @@
--- template/app/src/landing-page/components/ExamplesCarousel.tsx
+++ opensaas-sh/app/src/landing-page/components/ExamplesCarousel.tsx
@@ -108,7 +108,7 @@
ref={containerRef}
className="relative left-1/2 my-16 flex w-screen -translate-x-1/2 flex-col items-center"
>
- <h2 className="text-muted-foreground mb-6 text-center font-semibold tracking-wide">
+ <h2 className="text-muted-foreground mb-6 text-center text-lg font-semibold tracking-wide">
Used by:
</h2>
<div className="w-full max-w-full overflow-hidden">
@@ -0,0 +1,166 @@
--- template/app/src/landing-page/components/FeaturesGrid.tsx
+++ opensaas-sh/app/src/landing-page/components/FeaturesGrid.tsx
@@ -9,13 +9,15 @@
import { Feature } from "./Features";
import { SectionTitle } from "./SectionTitle";
-export interface GridFeature extends Omit<Feature, "icon"> {
+export interface GridFeature extends Omit<Feature, "icon" | "name"> {
+ name?: string;
icon?: React.ReactNode;
emoji?: string;
direction?: "col" | "row" | "col-reverse" | "row-reverse";
align?: "center" | "left";
size: "small" | "medium" | "large";
fullWidthIcon?: boolean;
+ highlight?: boolean;
}
interface FeaturesGridProps {
@@ -59,7 +61,8 @@
direction = "col",
align = "center",
size = "medium",
- fullWidthIcon = true,
+ fullWidthIcon = false,
+ highlight = false,
}: GridFeature) {
const gridFeatureSizeToClasses: Record<GridFeature["size"], string> = {
small: "col-span-1",
@@ -77,60 +80,98 @@
"col-reverse": "flex-col-reverse",
};
+ const mobileOrderClass = highlight ? "order-last md:order-none" : "";
+
const gridFeatureCard = (
<Card
className={cn(
"h-full min-h-[140px] cursor-pointer transition-all duration-300 hover:shadow-lg",
gridFeatureSizeToClasses[size],
+ mobileOrderClass,
+ highlight && "bg-radial-gradient",
)}
- variant="bento"
+ variant={highlight ? "bentoHighlight" : "bento"}
>
- <CardContent className="flex h-full flex-col items-center justify-center p-4">
+ <CardContent
+ className={cn(
+ "flex h-full flex-col items-center justify-center px-4 py-8",
+ fullWidthIcon && direction === "col-reverse"
+ ? "justify-end p-0 pb-0 pt-8"
+ : fullWidthIcon && "justify-start p-0 pb-8 pt-0",
+ )}
+ >
{fullWidthIcon && (icon || emoji) ? (
- <div className="mb-3 flex w-full items-center justify-center">
- {icon ? (
- icon
- ) : emoji ? (
- <span className="text-4xl">{emoji}</span>
- ) : null}
+ <div className="flex w-full flex-col">
+ <div
+ className={cn(
+ "flex w-full items-center justify-center",
+ direction === "col-reverse" ? "order-2 mt-6" : "order-1",
+ )}
+ >
+ {icon ? (
+ <div className="flex w-full justify-center">{icon}</div>
+ ) : emoji ? (
+ <span className="text-4xl">{emoji}</span>
+ ) : null}
+ </div>
+ {fullWidthIcon && (icon || emoji) && (
+ <CardTitle
+ className={cn(
+ "text-center text-2xl",
+ direction === "col-reverse" ? "order-1" : "order-2",
+ )}
+ >
+ {name}
+ </CardTitle>
+ )}
+ <CardDescription
+ className={cn(
+ "px-8 text-xs leading-relaxed",
+ "text-center",
+ direction === "col-reverse" ? "order-1" : "order-2",
+ )}
+ >
+ {description}
+ </CardDescription>
</div>
) : (
<div
className={cn(
- "flex items-center gap-3",
+ "flex items-center",
+ (icon || emoji) && "gap-3",
directionToClass[direction],
align === "center"
? "items-center justify-center"
: "justify-start",
)}
>
- <div className="flex h-10 w-10 items-center justify-center rounded-lg">
- {icon ? (
- icon
- ) : emoji ? (
- <span className="text-2xl">{emoji}</span>
- ) : null}
- </div>
+ {(icon || emoji) && (
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg">
+ {icon ? icon : <span className="text-2xl">{emoji}</span>}
+ </div>
+ )}
<CardTitle
- className={cn(align === "center" ? "text-center" : "text-left")}
+ className={cn(
+ highlight ? "text-4xl font-bold" : "text-lg",
+ align === "center" ? "text-center" : "text-left",
+ )}
>
{name}
</CardTitle>
</div>
)}
- {fullWidthIcon && (icon || emoji) && (
- <CardTitle className="mb-2 text-center">{name}</CardTitle>
+ {!fullWidthIcon && (
+ <CardDescription
+ className={cn(
+ "px-8 text-xs leading-relaxed",
+ direction === "col" || align === "center"
+ ? "text-center"
+ : "text-left",
+ )}
+ >
+ {description}
+ </CardDescription>
)}
- <CardDescription
- className={cn(
- "text-xs leading-relaxed",
- fullWidthIcon || direction === "col" || align === "center"
- ? "text-center"
- : "text-left",
- )}
- >
- {description}
- </CardDescription>
</CardContent>
</Card>
);
@@ -141,7 +182,7 @@
href={href}
target="_blank"
rel="noopener noreferrer"
- className={gridFeatureSizeToClasses[size]}
+ className={cn(gridFeatureSizeToClasses[size], mobileOrderClass)}
>
{gridFeatureCard}
</a>
@@ -0,0 +1,96 @@
--- template/app/src/landing-page/components/Hero/Hero.tsx
+++ opensaas-sh/app/src/landing-page/components/Hero/Hero.tsx
@@ -0,0 +1,93 @@
+import { ArrowRight } from "lucide-react";
+import { Link as ReactRouterLink } from "react-router";
+import { useAuth } from "wasp/client/auth";
+import { Link as WaspRouterLink, routes } from "wasp/client/router";
+import { Button } from "../../../client/components/ui/button";
+import { DocsUrl, WaspUrl } from "../../../shared/common";
+import { Orbit } from "./Orbit";
+
+export function Hero() {
+ const { data: user } = useAuth();
+
+ return (
+ <div className="relative w-full overflow-x-clip pt-12">
+ <TopGradient />
+ <BottomGradient />
+ <div className="mx-auto flex max-w-7xl flex-col pt-20 lg:flex-row">
+ <div className="py-24 sm:py-32">
+ <div className="max-w-8xl px-6 lg:px-8">
+ <div className="lg:mb-18 mx-auto max-w-3xl text-center md:text-left">
+ <h1 className="text-foreground text-5xl font-extrabold leading-none md:text-6xl">
+ The <span className="italic">free</span> SaaS template with{" "}
+ <span className="text-gradient-primary font-black">
+ superpowers
+ </span>
+ </h1>
+ <p className="text-md text-muted-foreground mt-6 max-w-2xl font-mono leading-8">
+ An open-source, feature-rich, React + NodeJS + Prisma starter
+ kit built for the AI era. Powered by
+ <a
+ href={WaspUrl}
+ className="group font-bold transition-all duration-300"
+ >
+ {" Wasp =}"}
+ </a>
+ </p>
+ <div className="mt-10 flex items-center justify-center gap-x-6 md:justify-start">
+ <Button size="lg" variant="outline" asChild>
+ <WaspRouterLink
+ to={user ? routes.DemoAppRoute.to : routes.LoginRoute.to}
+ >
+ Try the Demo App
+ </WaspRouterLink>
+ </Button>
+ <Button size="lg" variant="default" asChild>
+ <ReactRouterLink to={DocsUrl}>
+ Get Started
+ <ArrowRight />
+ </ReactRouterLink>
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div className="hidden lg:block">
+ <Orbit />
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function TopGradient() {
+ return (
+ <div
+ className="absolute right-0 top-0 -z-10 w-full transform-gpu overflow-hidden blur-3xl sm:top-0"
+ aria-hidden="true"
+ >
+ <div
+ className="aspect-1020/880 bg-linear-to-tr w-[70rem] flex-none from-amber-400 to-purple-300 opacity-10 sm:right-1/4 sm:translate-x-1/2 dark:hidden"
+ style={{
+ clipPath:
+ "polygon(80% 20%, 90% 55%, 50% 100%, 70% 30%, 20% 50%, 50% 0)",
+ }}
+ />
+ </div>
+ );
+}
+
+function BottomGradient() {
+ return (
+ <div
+ className="absolute inset-x-0 top-[calc(100%-40rem)] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[calc(100%-65rem)]"
+ aria-hidden="true"
+ >
+ <div
+ className="aspect-1020/880 bg-linear-to-br relative w-[90rem] from-amber-400 to-purple-300 opacity-10 sm:-left-3/4 sm:translate-x-1/4 dark:hidden"
+ style={{
+ clipPath: "ellipse(80% 30% at 80% 50%)",
+ }}
+ />
+ </div>
+ );
+}
@@ -0,0 +1,285 @@
--- template/app/src/landing-page/components/Hero/Orbit.tsx
+++ opensaas-sh/app/src/landing-page/components/Hero/Orbit.tsx
@@ -0,0 +1,282 @@
+import logo from "../../../client/static/logo.webp";
+import nodeLogoDark from "../../../client/static/logos/nodejs-dark.webp";
+import nodeLogo from "../../../client/static/logos/nodejs-light.webp";
+import stripeLogoDark from "../../../client/static/logos/stripe-dark.webp";
+import stripeLogo from "../../../client/static/logos/stripe-light.webp";
+import tailwindLogoDark from "../../../client/static/logos/tailwind-dark.webp";
+import tailwindLogo from "../../../client/static/logos/tailwind-light.webp";
+import { cn } from "../../../client/utils";
+import { AstroLogo } from "../../logos/AstroLogo";
+import { OpenAILogo } from "../../logos/OpenAILogo";
+import { PrismaLogo } from "../../logos/PrismaLogo";
+import { ReactLogo } from "../../logos/ReactLogo";
+import { ShadCNLogo } from "../../logos/ShadCNLogo";
+
+interface LogoConfig {
+ id: string;
+ component: React.ComponentType;
+ circleIndex: number;
+ position: number;
+ size?: number;
+}
+
+function ImageLogo({
+ src,
+ alt,
+ className,
+ dark,
+}: {
+ src: string;
+ alt: string;
+ className?: string;
+ dark?: boolean;
+}) {
+ return (
+ <img
+ src={src}
+ alt={alt}
+ className={cn(
+ "h-8 w-8",
+ dark ? "hidden dark:block" : "dark:hidden",
+ className,
+ )}
+ />
+ );
+}
+
+const logoConfigs: LogoConfig[] = [
+ {
+ id: "wasp",
+ component: () => (
+ <ImageLogo
+ src={logo}
+ alt="Wasp Logo"
+ className="h-8 w-8 dark:block"
+ dark={false}
+ />
+ ),
+ circleIndex: 1,
+ position: 0,
+ },
+ {
+ id: "openai",
+ component: OpenAILogo,
+ circleIndex: 1,
+ position: 120,
+ size: 24,
+ },
+ {
+ id: "astro",
+ component: AstroLogo,
+ circleIndex: 1,
+ position: 270,
+ size: 24,
+ },
+ {
+ id: "prisma",
+ component: PrismaLogo,
+ circleIndex: 2,
+ position: 90,
+ size: 24,
+ },
+ {
+ id: "shadcn",
+ component: ShadCNLogo,
+ circleIndex: 2,
+ position: 210,
+ size: 24,
+ },
+ {
+ id: "tailwind-light",
+ component: () => (
+ <ImageLogo
+ src={tailwindLogo}
+ alt="Tailwind CSS Logo"
+ className="h-8 w-8 dark:hidden"
+ dark={false}
+ />
+ ),
+ circleIndex: 2,
+ position: 330,
+ },
+ {
+ id: "tailwind-dark",
+ component: () => (
+ <ImageLogo src={tailwindLogoDark} alt="Tailwind CSS Logo" dark={true} />
+ ),
+ circleIndex: 2,
+ position: 330,
+ },
+ {
+ id: "stripe-light",
+ component: () => (
+ <ImageLogo src={stripeLogo} alt="Stripe Logo" dark={false} />
+ ),
+ circleIndex: 3,
+ position: 180,
+ },
+ {
+ id: "stripe-dark",
+ component: () => (
+ <ImageLogo src={stripeLogoDark} alt="Stripe Logo" dark={true} />
+ ),
+ circleIndex: 3,
+ position: 180,
+ },
+ {
+ id: "react",
+ component: ReactLogo,
+ circleIndex: 3,
+ position: 300,
+ size: 32,
+ },
+ {
+ id: "node-light",
+ component: () => (
+ <ImageLogo src={nodeLogo} alt="Node.js Logo" dark={false} />
+ ),
+ circleIndex: 3,
+ position: 50,
+ },
+ {
+ id: "node-dark",
+ component: () => (
+ <ImageLogo src={nodeLogoDark} alt="Node.js Logo" dark={true} />
+ ),
+ circleIndex: 3,
+ position: 50,
+ },
+];
+
+const circles = [
+ // Innermost to outermost
+ { radius: 120, orbitPeriod: 72, ringPeriod: 180 },
+ { radius: 180, orbitPeriod: 120, ringPeriod: 240 },
+ { radius: 240, orbitPeriod: 180, ringPeriod: 360 },
+ { radius: 300, orbitPeriod: 360, ringPeriod: 720 },
+];
+
+const ringGradients = [
+ `conic-gradient(from 0deg,
+ hsl(var(--primary) / 0.15),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.15),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.05),
+ hsl(var(--primary) / 0.15)
+ )`,
+ `conic-gradient(from 45deg,
+ hsl(var(--primary) / 0.12),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.08),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.2),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.12)
+ )`,
+ `conic-gradient(from 90deg,
+ hsl(var(--primary) / 0.1),
+ hsl(var(--primary) / 0.18),
+ hsl(var(--primary) / 0.1),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.15),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.1)
+ )`,
+ `conic-gradient(from 135deg,
+ hsl(var(--primary) / 0.08),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.16),
+ hsl(var(--primary) / 0.06),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.12),
+ hsl(var(--primary) / 0.08)
+ )`,
+];
+
+export function Orbit() {
+ return (
+ <div className="relative flex h-[500px] w-[500px] items-center justify-center">
+ <style>{`
+ @keyframes ring-spin {
+ to { transform: rotate(360deg); }
+ }
+ @keyframes orbit-spin {
+ from { transform: rotate(var(--start-angle, 0deg)); }
+ to { transform: rotate(calc(var(--start-angle, 0deg) + 360deg)); }
+ }
+ @keyframes orbit-counter-spin {
+ from { transform: rotate(calc(var(--start-angle, 0deg) * -1)); }
+ to { transform: rotate(calc(var(--start-angle, 0deg) * -1 - 360deg)); }
+ }
+ `}</style>
+
+ <div className="absolute flex flex-col items-center gap-2">
+ <p className="text-gradient-primary font-sans text-6xl font-black leading-none">
+ 100%
+ </p>
+ <p className="text-muted-foreground text-lg">Open Source</p>
+ </div>
+
+ {circles.map((circle, circleIndex) => (
+ <div
+ key={circleIndex}
+ className="absolute rounded-full"
+ style={{
+ width: circle.radius * 2,
+ height: circle.radius * 2,
+ background: ringGradients[circleIndex],
+ mask: `radial-gradient(circle at center, transparent ${
+ circle.radius - 2
+ }px, black ${circle.radius - 1}px, black ${
+ circle.radius
+ }px, transparent ${circle.radius + 1}px)`,
+ WebkitMask: `radial-gradient(circle at center, transparent ${
+ circle.radius - 2
+ }px, black ${circle.radius - 1}px, black ${
+ circle.radius
+ }px, transparent ${circle.radius + 1}px)`,
+ animation: `ring-spin ${circle.ringPeriod}s linear infinite`,
+ }}
+ />
+ ))}
+
+ {logoConfigs.map((logoConfig) => {
+ const circle = circles[logoConfig.circleIndex];
+ const logoSize = logoConfig.size || 32;
+ const LogoComponent = logoConfig.component;
+
+ return (
+ <div
+ key={logoConfig.id}
+ className="absolute left-1/2 top-1/2"
+ style={
+ {
+ width: 0,
+ height: 0,
+ "--start-angle": `${logoConfig.position}deg`,
+ animation: `orbit-spin ${circle.orbitPeriod}s linear infinite`,
+ } as React.CSSProperties
+ }
+ >
+ <div style={{ transform: `translateX(${circle.radius}px)` }}>
+ <div
+ className="z-20 flex items-center justify-center"
+ style={{
+ width: logoSize,
+ height: logoSize,
+ marginLeft: -logoSize / 2,
+ marginTop: -logoSize / 2,
+ animation: `orbit-counter-spin ${circle.orbitPeriod}s linear infinite`,
+ }}
+ >
+ <LogoComponent />
+ </div>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ );
+}
@@ -0,0 +1,4 @@
--- template/app/src/landing-page/components/Hero/index.ts
+++ opensaas-sh/app/src/landing-page/components/Hero/index.ts
@@ -0,0 +1 @@
+export { Hero } from "./Hero";
@@ -0,0 +1,59 @@
--- template/app/src/landing-page/components/HighlightedFeature.tsx
+++ opensaas-sh/app/src/landing-page/components/HighlightedFeature.tsx
@@ -1,11 +1,14 @@
import { cn } from "../../client/utils";
interface FeatureProps {
+ id?: string;
name: string;
description: string | React.ReactNode;
direction?: "row" | "row-reverse";
highlightedComponent: React.ReactNode;
tilt?: "left" | "right";
+ className?: string;
+ url?: string;
}
/**
@@ -13,11 +16,14 @@
* Shows text description on one side, and whatever component you want to show on the other side to demonstrate the functionality.
*/
export function HighlightedFeature({
+ id,
name,
description,
direction = "row",
highlightedComponent,
tilt,
+ className,
+ url,
}: FeatureProps) {
const tiltToClass: Record<Required<FeatureProps>["tilt"], string> = {
left: "rotate-1",
@@ -26,13 +32,16 @@
return (
<div
+ id={id}
className={cn(
- "my-50 mx-auto flex max-w-6xl flex-col items-center justify-between gap-x-20 gap-y-10 px-8 transition-all duration-300 ease-in-out md:px-4",
+ "my-50 mx-auto flex max-w-6xl scroll-mt-24 flex-col justify-between gap-x-20 gap-y-10 px-8 transition-all duration-300 ease-in-out md:items-center md:px-4",
direction === "row" ? "md:flex-row" : "md:flex-row-reverse",
)}
>
<div className="flex-1 flex-col">
- <h2 className="mb-2 text-4xl font-bold">{name}</h2>
+ <a href={url} target="_blank" rel="noopener noreferrer">
+ <h2 className="mb-2 text-4xl font-bold">{name}</h2>
+ </a>
{typeof description === "string" ? (
<p className="text-muted-foreground">{description}</p>
) : (
@@ -43,6 +52,7 @@
className={cn(
"my-10 flex w-full flex-1 items-center justify-center transition-transform duration-300 ease-in-out",
tilt && tiltToClass[tilt],
+ className,
)}
>
{highlightedComponent}
@@ -0,0 +1,94 @@
--- template/app/src/landing-page/components/Roadmap.tsx
+++ opensaas-sh/app/src/landing-page/components/Roadmap.tsx
@@ -0,0 +1,91 @@
+import { GithubEpicStatus } from "../operations";
+
+import { GitPullRequestArrow, Loader2 } from "lucide-react";
+import { Link } from "react-router";
+import { getGithubRoadmap, useQuery } from "wasp/client/operations";
+import { RoadmapStatusColumn } from "./RoadmapStatusColumn";
+
+export function Roadmap() {
+ const { data: epics, isLoading, error } = useQuery(getGithubRoadmap);
+
+ if (isLoading) {
+ return (
+ <RoadmapWrapper>
+ <div className="flex justify-center py-12">
+ <Loader2 className="h-8 w-8 animate-spin text-yellow-500" />
+ </div>
+ </RoadmapWrapper>
+ );
+ }
+
+ if (error) {
+ return (
+ <RoadmapWrapper>
+ <div className="py-12 text-center text-red-500">
+ Failed to load roadmap
+ </div>
+ </RoadmapWrapper>
+ );
+ }
+
+ if (!epics || epics.length === 0) {
+ return (
+ <RoadmapWrapper>
+ <div className="flex items-center justify-center gap-1 py-12 text-center text-gray-500 dark:text-gray-400">
+ <GitPullRequestArrow className="mr-1 h-5 w-5" />
+ <p>No roadmap items yet.</p>
+ <p>
+ Add your idea{" "}
+ <Link
+ to="https://github.com/wasp-lang/open-saas/issues/new"
+ target="_blank"
+ rel="noreferrer"
+ className="text-primary hover:text-primary/80 hover:underline"
+ >
+ here
+ </Link>
+ .
+ </p>
+ </div>
+ </RoadmapWrapper>
+ );
+ }
+
+ return (
+ <RoadmapWrapper>
+ <div className="grid gap-6 md:grid-cols-4">
+ {Object.values(GithubEpicStatus).map((status) => (
+ <RoadmapStatusColumn
+ key={status}
+ status={status}
+ epics={epics.filter((e) => e.status === status)}
+ />
+ ))}
+ </div>
+ </RoadmapWrapper>
+ );
+}
+
+function RoadmapWrapper({ children }: { children?: React.ReactNode }) {
+ return (
+ <div className="scroll-mt-24 py-12 md:py-20" id="roadmap">
+ <div className="mx-auto max-w-7xl px-6 lg:px-8">
+ <RoadmapHeader />
+ {children}
+ </div>
+ </div>
+ );
+}
+
+function RoadmapHeader() {
+ return (
+ <div className="mx-auto mb-12 max-w-2xl text-center">
+ <h2 className="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl dark:text-white">
+ The Roadmap
+ </h2>
+ <p className="mt-4 text-lg leading-8 text-gray-600 dark:text-gray-300">
+ High-level topics we are planning and working on.
+ </p>
+ </div>
+ );
+}
@@ -0,0 +1,95 @@
--- template/app/src/landing-page/components/RoadmapEpicCard.tsx
+++ opensaas-sh/app/src/landing-page/components/RoadmapEpicCard.tsx
@@ -0,0 +1,92 @@
+import { Plus } from "lucide-react";
+import { cn } from "../../client/utils";
+import type { GithubEpic } from "../operations";
+import { GithubEpicStatus } from "../operations";
+
+type RoadmapEpicCardProps = {
+ epic: GithubEpic;
+};
+
+export function RoadmapEpicCard({ epic }: RoadmapEpicCardProps) {
+ const progress =
+ epic.totalIssues > 0
+ ? Math.round((epic.doneIssues / epic.totalIssues) * 100)
+ : 0;
+ const progressColor = getProgressBarColor(epic.status);
+ const hoverBorderColor = getHoverBorderColor(epic.status);
+
+ return (
+ <a
+ href={epic.url}
+ target="_blank"
+ rel="noreferrer"
+ className={cn(
+ "group flex flex-col gap-3 rounded-xl border border-gray-200 bg-white p-5 transition-all hover:shadow-md dark:border-gray-700 dark:bg-gray-900/50",
+ hoverBorderColor,
+ )}
+ >
+ <h4 className="text-base font-semibold leading-tight text-gray-900 transition-opacity dark:text-white">
+ {epic.name}
+ </h4>
+
+ <div className="mt-auto pt-1">
+ <div className="mb-1.5 flex justify-between text-xs text-gray-500 dark:text-gray-400">
+ <span>Progress</span>
+ <span>
+ {progress}% ({epic.doneIssues}/{epic.totalIssues})
+ </span>
+ </div>
+ <div className="relative h-1.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
+ <div
+ className={cn("absolute h-full rounded-full", progressColor)}
+ style={{ width: `${progress}%` }}
+ />
+ </div>
+ </div>
+ </a>
+ );
+}
+
+export function RoadmapEpicAddIssueCard() {
+ return (
+ <a
+ href="https://github.com/wasp-lang/open-saas/issues/new"
+ target="_blank"
+ rel="noreferrer"
+ className="group flex min-h-[100px] items-center justify-center rounded-xl border border-dashed border-gray-300 bg-gray-100/50 p-5 transition-all hover:border-blue-400 hover:bg-blue-50 dark:border-gray-700 dark:bg-gray-900/30 dark:hover:border-blue-600 dark:hover:bg-blue-900/20"
+ title="Add your idea to the roadmap"
+ >
+ <Plus className="h-8 w-8 text-gray-400 transition-colors group-hover:text-blue-500 dark:text-gray-600 dark:group-hover:text-blue-400" />
+ </a>
+ );
+}
+
+const getProgressBarColor = (status: GithubEpicStatus) => {
+ switch (status) {
+ case GithubEpicStatus.Ideas:
+ return "bg-blue-500";
+ case GithubEpicStatus.Planned:
+ return "bg-green-500";
+ case GithubEpicStatus.InProgress:
+ return "bg-yellow-500";
+ case GithubEpicStatus.Done:
+ return "bg-purple-500";
+ default:
+ return "bg-gray-500";
+ }
+};
+
+const getHoverBorderColor = (status: GithubEpicStatus) => {
+ switch (status) {
+ case GithubEpicStatus.Ideas:
+ return "hover:border-blue-400 dark:hover:border-blue-600";
+ case GithubEpicStatus.Planned:
+ return "hover:border-green-400 dark:hover:border-green-600";
+ case GithubEpicStatus.InProgress:
+ return "hover:border-yellow-400 dark:hover:border-yellow-600";
+ case GithubEpicStatus.Done:
+ return "hover:border-purple-400 dark:hover:border-purple-600";
+ default:
+ return "hover:border-gray-400 dark:hover:border-gray-600";
+ }
+};
@@ -0,0 +1,95 @@
--- template/app/src/landing-page/components/RoadmapStatusColumn.tsx
+++ opensaas-sh/app/src/landing-page/components/RoadmapStatusColumn.tsx
@@ -0,0 +1,92 @@
+import { cn } from "../../client/utils";
+import type { GithubEpic } from "../operations";
+import { GithubEpicStatus } from "../operations";
+import { RoadmapEpicAddIssueCard, RoadmapEpicCard } from "./RoadmapEpicCard";
+
+type RoadmapStatusColumnProps = {
+ status: GithubEpicStatus;
+ epics: GithubEpic[];
+};
+
+export function RoadmapStatusColumn({
+ status,
+ epics,
+}: RoadmapStatusColumnProps) {
+ const borderColor = getBorderColor(status);
+ const statusBgColor = getStatusBgColor(status);
+ const statusTextColor = getStatusTextColor(status);
+
+ return (
+ <div className="flex flex-col gap-4">
+ <div
+ className={cn(
+ "flex items-center justify-center rounded-lg border bg-opacity-10 p-3",
+ borderColor,
+ statusBgColor,
+ )}
+ >
+ <h3 className={cn("font-bold", statusTextColor)}>{status}</h3>
+ <span className="ml-2 text-xs opacity-70">({epics.length})</span>
+ </div>
+
+ <div className="flex flex-col gap-4">
+ {epics.map((epic) => (
+ <RoadmapEpicCard key={epic.name} epic={epic} />
+ ))}
+
+ {epics.length === 0 && (
+ <div className="rounded-lg border border-dashed py-8 text-center text-sm italic text-gray-400 dark:border-gray-800">
+ No epics
+ </div>
+ )}
+
+ {status === GithubEpicStatus.Ideas && <RoadmapEpicAddIssueCard />}
+ </div>
+ </div>
+ );
+}
+
+const getStatusBgColor = (status: GithubEpicStatus) => {
+ switch (status) {
+ case GithubEpicStatus.Ideas:
+ return "bg-blue-100 dark:bg-blue-900/30";
+ case GithubEpicStatus.Planned:
+ return "bg-green-100 dark:bg-green-900/30";
+ case GithubEpicStatus.InProgress:
+ return "bg-yellow-100 dark:bg-yellow-900/30";
+ case GithubEpicStatus.Done:
+ return "bg-purple-100 dark:bg-purple-900/30";
+ default:
+ return "bg-gray-100 dark:bg-gray-800";
+ }
+};
+
+const getStatusTextColor = (status: GithubEpicStatus) => {
+ switch (status) {
+ case GithubEpicStatus.Ideas:
+ return "text-blue-700 dark:text-blue-300";
+ case GithubEpicStatus.Planned:
+ return "text-green-700 dark:text-green-300";
+ case GithubEpicStatus.InProgress:
+ return "text-yellow-700 dark:text-yellow-300";
+ case GithubEpicStatus.Done:
+ return "text-purple-700 dark:text-purple-300";
+ default:
+ return "text-gray-700 dark:text-gray-300";
+ }
+};
+
+const getBorderColor = (status: GithubEpicStatus) => {
+ switch (status) {
+ case GithubEpicStatus.Ideas:
+ return "border-blue-200 dark:border-blue-800";
+ case GithubEpicStatus.Planned:
+ return "border-green-200 dark:border-green-800";
+ case GithubEpicStatus.InProgress:
+ return "border-yellow-200 dark:border-yellow-800";
+ case GithubEpicStatus.Done:
+ return "border-purple-200 dark:border-purple-800";
+ default:
+ return "border-gray-200 dark:border-gray-700";
+ }
+};
@@ -0,0 +1,93 @@
--- template/app/src/landing-page/components/SchemaMarkup.tsx
+++ opensaas-sh/app/src/landing-page/components/SchemaMarkup.tsx
@@ -1,30 +1,77 @@
-// JSON-LD structured data for SEO. Helps search engines and LLMs understand
-// your app so it can appear in rich results and AI answers. Customize the
-// placeholders below to match your product. See https://schema.org for types.
const schema = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "SoftwareApplication",
- "@id": "https://your-saas-app.com/#software",
- name: "Your Open SaaS App",
- description: "Your apps main description and features.",
- url: "https://your-saas-app.com",
- applicationCategory: "BusinessApplication",
+ "@id": "https://opensaas.sh/#software",
+ name: "Open SaaS",
+ alternateName: "OpenSaaS",
+ description:
+ "Free, open-source, full-stack SaaS boilerplate starter kit for React, NodeJS, & Prisma. Built on the Wasp framework. Includes auth, Stripe/Lemon Squeezy/Polar payments, an OpenAI demo app, AWS S3 file uploads, an admin dashboard, and email sending.",
+ url: "https://opensaas.sh",
+ applicationCategory: "DeveloperApplication",
+ applicationSubCategory: "SaaS Starter Kit",
operatingSystem: "Cross-platform",
- image: "https://your-saas-app.com/public-banner.webp",
+ image: "https://opensaas.sh/public-banner.webp",
+ license: "https://github.com/wasp-lang/open-saas/blob/main/LICENSE",
+ codeRepository: "https://github.com/wasp-lang/open-saas",
+ programmingLanguage: ["TypeScript", "JavaScript"],
+ isAccessibleForFree: true,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
+ creator: { "@id": "https://wasp.sh/#org" },
+ publisher: { "@id": "https://wasp.sh/#org" },
+ maintainer: { "@id": "https://wasp.sh/#org" },
},
{
"@type": "WebSite",
- "@id": "https://your-saas-app.com/#website",
- url: "https://your-saas-app.com",
- name: "Your Open SaaS App",
- description: "Your apps main description and features.",
+ "@id": "https://opensaas.sh/#website",
+ url: "https://opensaas.sh",
+ name: "Open SaaS",
+ description:
+ "Free, open-source SaaS boilerplate starter for React, NodeJS & Prisma. Powered by Wasp.",
+ publisher: { "@id": "https://wasp.sh/#org" },
+ about: { "@id": "https://opensaas.sh/#software" },
+ },
+ {
+ "@type": "Organization",
+ "@id": "https://wasp.sh/#org",
+ name: "Wasp",
+ alternateName: "Wasp-lang",
+ description:
+ "Wasp is the fastest way to develop full-stack React, Node.js, and Prisma web applications.",
+ url: "https://wasp.sh",
+ logo: "https://wasp.sh/favicon.ico",
+ sameAs: [
+ "https://github.com/wasp-lang/wasp",
+ "https://twitter.com/wasplang",
+ "https://discord.gg/aCamt5wCpS",
+ ],
+ },
+ {
+ "@type": "FAQPage",
+ "@id": "https://opensaas.sh/#faq",
+ mainEntity: [
+ {
+ "@type": "Question",
+ name: "Why is this SaaS Template free and open-source?",
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: "We believe the best product is made when the community puts their heads together. We also believe a quality starting point for a web app should be free and available to everyone. Our hope is that together we can create the best SaaS template out there and bring our ideas to customers quickly.",
+ },
+ },
+ {
+ "@type": "Question",
+ name: "What's Wasp?",
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: "It's the fastest way to develop full-stack React + NodeJS + Prisma apps and it's what gives this template superpowers. Wasp relies on React, NodeJS, and Prisma to define web components and server queries and actions. Wasp's secret sauce is its compiler which takes the client, server code, and config file and outputs the client app, server app and deployment code, supercharging the development experience. Combined with this template, you can build a SaaS app in record time.",
+ },
+ },
+ ],
},
],
};
@@ -0,0 +1,47 @@
--- template/app/src/landing-page/components/Testimonials.tsx
+++ opensaas-sh/app/src/landing-page/components/Testimonials.tsx
@@ -1,4 +1,3 @@
-import { useState } from "react";
import {
Card,
CardContent,
@@ -21,18 +20,12 @@
}: {
testimonials: Testimonial[];
}) {
- const [isExpanded, setIsExpanded] = useState(false);
- const shouldShowExpand = testimonials.length > 5;
- const mobileItemsToShow = 3;
- const itemsToShow =
- shouldShowExpand && !isExpanded ? mobileItemsToShow : testimonials.length;
-
return (
<div className="mx-auto mt-32 max-w-7xl sm:mt-56 sm:px-6 lg:px-8">
<SectionTitle title="What Our Users Say" />
<div className="relative z-10 w-full columns-1 gap-2 px-4 md:columns-2 md:gap-6 md:px-0 lg:columns-3">
- {testimonials.slice(0, itemsToShow).map((testimonial, idx) => (
+ {testimonials.map((testimonial, idx) => (
<div key={idx} className="mb-6 break-inside-avoid">
<Card className="flex flex-col justify-between">
<CardContent className="p-6">
@@ -65,19 +58,6 @@
</div>
))}
</div>
-
- {shouldShowExpand && (
- <div className="mt-8 flex justify-center md:hidden">
- <button
- onClick={() => setIsExpanded(!isExpanded)}
- className="text-primary bg-primary/10 hover:bg-primary/20 rounded-lg px-6 py-3 text-sm font-medium transition-colors duration-200"
- >
- {isExpanded
- ? "Show Less"
- : `Show ${testimonials.length - mobileItemsToShow} More`}
- </button>
- </div>
- )}
</div>
);
}
@@ -0,0 +1,358 @@
--- template/app/src/landing-page/contentSections.tsx
+++ opensaas-sh/app/src/landing-page/contentSections.tsx
@@ -1,4 +1,8 @@
-import daBoiAvatar from "../client/static/da-boi.webp";
+import { routes } from "wasp/client/router";
+import blog from "../client/static/assets/blog.webp";
+import email from "../client/static/assets/email.webp";
+import fileupload from "../client/static/assets/fileupload.webp";
+import ai from "../client/static/assets/openapi.webp";
import kivo from "../client/static/examples/kivo.webp";
import messync from "../client/static/examples/messync.webp";
import microinfluencerClub from "../client/static/examples/microinfluencers.webp";
@@ -6,161 +10,248 @@
import reviewradar from "../client/static/examples/reviewradar.webp";
import scribeist from "../client/static/examples/scribeist.webp";
import searchcraft from "../client/static/examples/searchcraft.webp";
-import { BlogUrl, DocsUrl } from "../shared/common";
-import type { GridFeature } from "./components/FeaturesGrid";
+import logo from "../client/static/logo.webp";
+import { BlogUrl, DocsUrl, GithubUrl, WaspUrl } from "../shared/common";
+import { GridFeature } from "./components/FeaturesGrid";
export const features: GridFeature[] = [
{
- name: "Cool Feature 1",
- description: "Your feature",
- emoji: "🤝",
+ description:
+ "Have a sweet AI-powered app concept? Get your idea shipped to potential customers in days!",
+ icon: <img src={ai} alt="AI illustration" />,
href: DocsUrl,
- size: "small",
+ size: "medium",
+ fullWidthIcon: true,
+ align: "left",
},
{
- name: "Cool Feature 2",
- description: "Feature description",
- emoji: "🔐",
+ name: "Full-stack Type Safety",
+ description:
+ "Full support for TypeScript with auto-generated types that span the whole stack. Nothing to configure!",
+ emoji: "🥞",
href: DocsUrl,
- size: "small",
+ size: "medium",
},
{
- name: "Cool Feature 3",
- description: "Describe your cool feature here",
- emoji: "🥞",
- href: DocsUrl,
+ description:
+ "File upload examples with AWS S3 presigned URLs are included and fully documented!",
+ icon: (
+ <img
+ src={fileupload}
+ alt="File upload illustration"
+ className="h-auto w-full"
+ />
+ ),
+ href: DocsUrl + "/guides/file-uploading/",
size: "medium",
+ fullWidthIcon: true,
},
{
- name: "Cool Feature 4",
- description: "Describe your cool feature here",
- emoji: "💸",
- href: DocsUrl,
- size: "large",
+ name: "Email Sending",
+ description:
+ "Email sending built-in. Combine it with the cron jobs feature to easily send emails to your customers.",
+ icon: <img src={email} alt="Email sending illustration" />,
+ href: DocsUrl + "/guides/email-sending/",
+ size: "medium",
+ fullWidthIcon: true,
+ direction: "col-reverse",
},
{
- name: "Cool Feature 5",
- description: "Describe your cool feature here",
- emoji: "💼",
- href: DocsUrl,
- size: "large",
+ name: "Open SaaS",
+ description: "Try the demo app",
+ icon: <img src={logo} alt="Wasp Logo" />,
+ href: routes.LoginRoute.to,
+ size: "medium",
+ highlight: true,
},
{
- name: "Cool Feature 6",
- description: "It is cool",
- emoji: "📈",
- href: DocsUrl,
- size: "small",
+ name: "Blog w/ Astro",
+ description:
+ "Built-in blog with the Astro framework. Write your posts in Markdown, and watch your SEO performance take off.",
+ icon: <img src={blog} alt="Blog illustration" />,
+ href: DocsUrl + "/start/guided-tour/",
+ size: "medium",
+ fullWidthIcon: true,
},
{
- name: "Cool Feature 7",
- description: "Cool feature",
- emoji: "📧",
+ name: "Deploy Anywhere. Easily.",
+ description:
+ "No vendor lock-in because you own all your code. Deploy yourself, or let Wasp deploy it for you with a single command.",
+ emoji: "🚀",
+ href: DocsUrl + "/guides/deploying/",
+ size: "medium",
+ },
+ {
+ name: "Complete Documentation & Support",
+ description: "And a Discord community to help!",
href: DocsUrl,
size: "small",
},
{
- name: "Cool Feature 8",
- description: "Describe your cool feature here",
- emoji: "🤖",
- href: DocsUrl,
- size: "medium",
+ name: "E2E Tests w/ Playwright",
+ description: "Tests and a CI pipeline w/ GitHub Actions",
+ href: DocsUrl + "/guides/tests/",
+ size: "small",
},
{
- name: "Cool Feature 9",
- description: "Describe your cool feature here",
- emoji: "🚀",
+ name: "Open-Source Philosophy",
+ description:
+ "The repo and framework are 100% open-source, and so are the services wherever possible. Still missing something? Contribute!",
+ emoji: "🤝",
href: DocsUrl,
size: "medium",
},
];
-
export const testimonials = [
{
- name: "Da Boi",
- role: "Wasp Mascot",
- avatarSrc: daBoiAvatar,
- socialUrl: "https://twitter.com/wasplang",
- quote: "I don't even know how to code. I'm just a plushie.",
- },
- {
- name: "Mr. Foobar",
- role: "Founder @ Cool Startup",
- avatarSrc: daBoiAvatar,
- socialUrl: "",
- quote: "This product makes me cooler than I already am.",
- },
- {
- name: "Jamie",
- role: "Happy Customer",
- avatarSrc: daBoiAvatar,
- socialUrl: "#",
- quote: "My cats love it!",
+ name: "Max Khamrovskyi",
+ role: "Senior Eng @ Red Hat",
+ avatarSrc:
+ "https://pbs.twimg.com/profile_images/1719397191205179392/V_QrGPSO_400x400.jpg",
+ socialUrl: "https://twitter.com/maksim36ua",
+ quote:
+ "I used Wasp to build and sell my AI-augmented SaaS app for marketplace vendors within two months!",
+ },
+ {
+ name: "Jonathan Cocharan",
+ role: "Entrepreneur",
+ avatarSrc:
+ "https://pbs.twimg.com/profile_images/1950172296376639488/sZ0JIqfR_400x400.jpg",
+ socialUrl: "https://twitter.com/JonathanCochran",
+ quote:
+ "In just 6 nights... my SaaS app is live 🎉! Huge thanks to the amazing @wasplang community 🙌 for their guidance along the way. These tools are incredibly efficient 🤯!",
+ },
+ {
+ name: "Billy Howell",
+ role: "Entrepreneur",
+ avatarSrc:
+ "https://pbs.twimg.com/profile_images/1877734205561430016/jjpG4mS6_400x400.jpg",
+ socialUrl: "https://twitter.com/billyjhowell",
+ quote:
+ "Congrats! I am loving Wasp & Open SaaS. It's really helped me, a self-taught coder increase my confidence. I feel like I've finally found the perfect, versatile stack for all my projects instead of trying out a new one each time.",
+ },
+ {
+ name: "Tim Skaggs",
+ role: "Founder @ Antler US",
+ avatarSrc:
+ "https://pbs.twimg.com/profile_images/1802196804236091392/ZG0OE_fO_400x400.jpg",
+ socialUrl: "https://twitter.com/tskaggs",
+ quote:
+ "Nearly done with a MVP in 3 days of part-time work... and deployed on Fly.io in 10 minutes.",
+ },
+ {
+ name: "Cam Blackwood",
+ role: "Founder @ Microinfluencer.club",
+ avatarSrc:
+ "https://pbs.twimg.com/profile_images/1927721707164377089/it8oCAkf_400x400.jpg",
+ socialUrl: "https://twitter.com/CamBlackwood95",
+ quote: "Setting up a full stack SaaS in 1 minute with WaspLang.",
+ },
+ {
+ name: "JLegendz",
+ role: "Enterpreneur",
+ avatarSrc:
+ "https://cdn.discordapp.com/avatars/1003468772251811921/a_c6124fcbee5621d1ad9cca83a102c4a9.png?size=80",
+ socialUrl:
+ "https://discord.com/channels/686873244791210014/1080864617347162122/1246388561020850188",
+ quote:
+ "Just randomly wanted to say that I've been loving working with Wasp so far. The open-saas template is great starting point and great way to learn how Wasp works. The documentation is superb and I see the GitHub is super active. The team is super responsive and the ai kapa rocks! So thanks for the work you all are doing. Ive done plenty of with react in the past but Im a front end person. With wasp though, I'm managing my db, back end functions, actions, queries, all with so much ease. I occasionally get stuck on an issue but within a day or two, and thanks to a couple of AI assistants, I get through it. So thank you!",
+ },
+ {
+ name: "Dimitrios Mastrogiannis",
+ role: "Founder @ Kivo.dev",
+ avatarSrc:
+ "https://pbs.twimg.com/profile_images/1771240035020324864/bcNSr-dA_400x400.jpg",
+ socialUrl: "https://twitter.com/dmastroyiannis",
+ quote: "Without Wasp & Open SaaS, Kivo.dev wouldn't exist",
+ },
+ {
+ name: "Alex Ionascu",
+ role: "Entrepreneur",
+ avatarSrc:
+ "https://pbs.twimg.com/profile_images/1356710335001018376/HEfgRu8X_400x400.jpg",
+ socialUrl: "https://twitter.com/alexandrionascu",
+ quote:
+ "Wasp is like hot water. You don't realise how much you need it unless you try it.",
+ },
+ {
+ name: "Emm Ajayi",
+ role: "Enterpreneur",
+ avatarSrc:
+ "https://media2.dev.to/dynamic/image/width=320,height=320,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1215082%2F860c57b3-ac41-420b-9420-b6efda743596.jpg",
+ socialUrl:
+ "https://dev.to/wasp/our-web-framework-reached-9000-stars-on-github-9000-jij#comment-2dech",
+ quote:
+ "This is exactly the framework I've been dreaming of ever since I've been waiting to fully venture into the JS Backend Dev world. I believe Wasp will go above 50k stars this year. The documentation alone gives me the confidence that this is my permanent Nodejs framework and I'm staying with Wasp. Phenomenal work by the team... Please keep up your amazing spirits. Thank you",
},
];
-
export const faqs = [
{
id: 1,
- question: "Whats the meaning of life?",
- answer: "42.",
- href: "https://en.wikipedia.org/wiki/42_(number)",
+ question: "Why is this SaaS Template free and open-source?",
+ answer:
+ "We believe the best product is made when the community puts their heads together. We also believe a quality starting point for a web app should be free and available to everyone. Our hope is that together we can create the best SaaS template out there and bring our ideas to customers quickly.",
+ },
+ {
+ id: 2,
+ question: "What's Wasp?",
+ href: "https://wasp-lang.dev",
+ answer:
+ "It's the fastest way to develop full-stack React + NodeJS + Prisma apps and it's what gives this template superpowers. Wasp relies on React, NodeJS, and Prisma to define web components and server queries and actions. Wasp's secret sauce is its compiler which takes the client, server code, and config file and outputs the client app, server app and deployment code, supercharging the development experience. Combined with this template, you can build a SaaS app in record time.",
},
];
-
export const footerNavigation = {
app: [
+ { name: "Github", href: GithubUrl },
{ name: "Documentation", href: DocsUrl },
{ name: "Blog", href: BlogUrl },
],
company: [
- { name: "About", href: "https://wasp.sh" },
- { name: "Privacy", href: "#" },
- { name: "Terms of Service", href: "#" },
+ { name: "Terms of Service", href: GithubUrl + "/blob/main/LICENSE" },
+ { name: "Made by the Wasp team = }", href: WaspUrl },
],
};
-
export const examples = [
{
- name: "Example #1",
- description: "Describe your example here.",
- imageSrc: kivo,
- href: "#",
+ name: "Microinfluencers",
+ description: "microinfluencer.club",
+ imageSrc: microinfluencerClub,
+ href: "https://microinfluencer.club",
},
{
- name: "Example #2",
- description: "Describe your example here.",
- imageSrc: messync,
- href: "#",
+ name: "Kivo",
+ description: "kivo.dev",
+ imageSrc: kivo,
+ href: "https://kivo.dev",
},
{
- name: "Example #3",
- description: "Describe your example here.",
- imageSrc: microinfluencerClub,
- href: "#",
+ name: "Searchcraft",
+ description: "searchcraft.io",
+ imageSrc: searchcraft,
+ href: "https://www.searchcraft.io",
},
{
- name: "Example #4",
- description: "Describe your example here.",
- imageSrc: promptpanda,
- href: "#",
+ name: "Scribeist",
+ description: "scribeist.com",
+ imageSrc: scribeist,
+ href: "https://scribeist.com",
},
{
- name: "Example #5",
- description: "Describe your example here.",
- imageSrc: reviewradar,
- href: "#",
+ name: "Messync",
+ description: "messync.com",
+ imageSrc: messync,
+ href: "https://messync.com",
},
{
- name: "Example #6",
- description: "Describe your example here.",
- imageSrc: scribeist,
- href: "#",
+ name: "Prompt Panda",
+ description: "promptpanda.io",
+ imageSrc: promptpanda,
+ href: "https://promptpanda.io",
},
{
- name: "Example #7",
- description: "Describe your example here.",
- imageSrc: searchcraft,
- href: "#",
+ name: "Review Radar",
+ description: "reviewradar.ai",
+ imageSrc: reviewradar,
+ href: "https://reviewradar.ai",
},
];
@@ -0,0 +1,12 @@
--- template/app/src/landing-page/landing-page.wasp.ts
+++ opensaas-sh/app/src/landing-page/landing-page.wasp.ts
@@ -0,0 +1,9 @@
+import { page, query, route, type Spec } from "@wasp.sh/spec";
+
+import { LandingPage } from "./LandingPage" with { type: "ref" };
+import { getGithubRoadmap } from "./operations" with { type: "ref" };
+
+export const landingPage: Spec = [
+ route("LandingPageRoute", "/", page(LandingPage), { prerender: true }),
+ query(getGithubRoadmap),
+];
@@ -0,0 +1,13 @@
--- template/app/src/landing-page/logos/PrismaLogo.tsx
+++ opensaas-sh/app/src/landing-page/logos/PrismaLogo.tsx
@@ -1,8 +1,8 @@
export function PrismaLogo() {
return (
<svg
- width={48}
- height={48}
+ width={32}
+ height={32}
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
@@ -0,0 +1,31 @@
--- template/app/src/landing-page/logos/ReactLogo.tsx
+++ opensaas-sh/app/src/landing-page/logos/ReactLogo.tsx
@@ -0,0 +1,28 @@
+export function ReactLogo() {
+ return (
+ <svg
+ width={32}
+ height={32}
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="-11.5 -10.23174 23 20.46348"
+ >
+ <circle
+ cx="0"
+ cy="0"
+ r="2.05"
+ fill="currentColor"
+ className="dark:fill-white"
+ />
+ <g
+ stroke="currentColor"
+ strokeWidth="1"
+ fill="none"
+ className="dark:stroke-white"
+ >
+ <ellipse rx="11" ry="4.2" />
+ <ellipse rx="11" ry="4.2" transform="rotate(60)" />
+ <ellipse rx="11" ry="4.2" transform="rotate(120)" />
+ </g>
+ </svg>
+ );
+}
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More