i run patch on main, checked out this branch, run prettier format, and then run diff

This commit is contained in:
Franjo Mindek
2025-09-19 18:21:45 +02:00
parent 0773fb9995
commit 10dd2c743d
44 changed files with 961 additions and 813 deletions

View File

@@ -11,22 +11,24 @@
## UI Components
@@ -8,9 +10,20 @@
@@ -8,9 +10,22 @@
## 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`.

View File

@@ -5,9 +5,9 @@
"name": "opensaas",
"type": "module",
+ "scripts": {
+ "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",
+ "deploy": "REACT_APP_GOOGLE_ANALYTICS_ID=G-H3LSJCK95H wasp deploy fly deploy"
+ "env:push": "npx dotenv-vault@latest push development .env.server"
+ },
"dependencies": {
"@aws-sdk/client-s3": "^3.523.0",

View File

@@ -1,11 +1,11 @@
--- template/app/src/admin/dashboards/users/UsersTable.tsx
+++ opensaas-sh/app/src/admin/dashboards/users/UsersTable.tsx
@@ -254,7 +254,7 @@
<p className='text-sm text-foreground'>{user.subscriptionStatus}</p>
@@ -302,7 +302,7 @@
</div>
<div className='col-span-2 flex items-center'>
- <p className='text-sm text-muted-foreground'>{user.paymentProcessorUserId}</p>
+ <p className='text-sm text-muted-foreground'>{user.subscriptionStatus}</p>
<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'>
<div className='text-sm text-foreground'>
<div className="col-span-1 flex items-center">

View File

@@ -7,23 +7,23 @@
- `${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&metrics=pageviews`,
+ `${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&metrics=pageviews&with_imported=true`,
{
method: 'GET',
method: "GET",
headers: {
@@ -80,7 +80,7 @@
@@ -90,7 +90,7 @@
}
async function getPageviewsForDate(date: string) {
- const url = `${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&period=day&date=${date}&metrics=pageviews`;
+ const url = `${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&period=day&date=${date}&metrics=pageviews&with_imported=true`;
const response = await fetch(url, {
method: 'GET',
method: "GET",
headers: headers,
@@ -93,7 +93,7 @@
@@ -103,7 +103,7 @@
}
export async function getSources() {
- const url = `${PLAUSIBLE_BASE_URL}/v1/stats/breakdown?site_id=${PLAUSIBLE_SITE_ID}&property=visit:source&metrics=visitors`;
+ const url = `${PLAUSIBLE_BASE_URL}/v1/stats/breakdown?site_id=${PLAUSIBLE_SITE_ID}&property=visit:source&metrics=visitors&with_imported=true`;
const response = await fetch(url, {
method: 'GET',
method: "GET",
headers: headers,

View File

@@ -1,38 +1,44 @@
--- template/app/src/analytics/stats.ts
+++ opensaas-sh/app/src/analytics/stats.ts
@@ -2,11 +2,9 @@
import { type DailyStatsJob } from 'wasp/server/jobs';
import Stripe from 'stripe';
import { stripe } from '../payment/stripe/stripeClient';
-import { listOrders } from '@lemonsqueezy/lemonsqueezy.js';
import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils';
-// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
-import { paymentProcessor } from '../payment/paymentProcessor';
import { SubscriptionStatus } from '../payment/plans';
+// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
@@ -1,15 +1,13 @@
-import { listOrders } from "@lemonsqueezy/lemonsqueezy.js";
import Stripe from "stripe";
import { type DailyStats } from "wasp/entities";
import { type DailyStatsJob } from "wasp/server/jobs";
+import { SubscriptionStatus } from "../payment/plans";
import { stripe } from "../payment/stripe/stripeClient";
import {
getDailyPageViews,
getSources,
} from "./providers/plausibleAnalyticsUtils";
// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
-import { paymentProcessor } from "../payment/paymentProcessor";
-import { SubscriptionStatus } from "../payment/plans";
export type DailyStatsProps = { dailyStats?: DailyStats; weeklyStats?: DailyStats[]; isLoading?: boolean };
@@ -42,17 +40,7 @@
export type DailyStatsProps = {
dailyStats?: DailyStats;
@@ -52,19 +50,7 @@
paidUserDelta -= yesterdaysStats.paidUserCount;
}
- let totalRevenue;
- switch (paymentProcessor.id) {
- case 'stripe':
- case "stripe":
- totalRevenue = await fetchTotalStripeRevenue();
- break;
- case 'lemonsqueezy':
- case "lemonsqueezy":
- totalRevenue = await fetchTotalLemonSqueezyRevenue();
- break;
- default:
- throw new Error(`Unsupported payment processor: ${paymentProcessor.id}`);
- throw new Error(
- `Unsupported payment processor: ${paymentProcessor.id}`,
- );
- }
+ let totalRevenue = await fetchTotalStripeRevenue();
const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews();
@@ -163,38 +151,3 @@
@@ -176,38 +162,3 @@
// Revenue is in cents so we convert to dollars (or your main currency unit)
return totalRevenue / 100;
}
@@ -67,7 +73,7 @@
- // Revenue is in cents so we convert to dollars (or your main currency unit)
- return totalRevenue / 100;
- } catch (error) {
- console.error('Error fetching Lemon Squeezy revenue:', error);
- console.error("Error fetching Lemon Squeezy revenue:", error);
- throw error;
- }
-}

View File

@@ -1,8 +0,0 @@
--- template/app/src/auth/email-and-pass/emails.ts
+++ opensaas-sh/app/src/auth/email-and-pass/emails.ts
@@ -1,4 +1,4 @@
-import { type GetVerificationEmailContentFn, type GetPasswordResetEmailContentFn } from 'wasp/server/auth';
+import { type GetPasswordResetEmailContentFn, type GetVerificationEmailContentFn } from 'wasp/server/auth';
export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ verificationLink }) => ({
subject: 'Verify your email',

View File

@@ -1,14 +1,14 @@
--- template/app/src/auth/userSignupFields.ts
+++ opensaas-sh/app/src/auth/userSignupFields.ts
@@ -1,7 +1,5 @@
-import { z } from 'zod';
import { defineUserSignupFields } from 'wasp/auth/providers/types';
-
-const adminEmails = process.env.ADMIN_EMAILS?.split(',') || [];
+import { z } from 'zod';
@@ -1,8 +1,6 @@
import { defineUserSignupFields } from "wasp/auth/providers/types";
import { z } from "zod";
-const adminEmails = process.env.ADMIN_EMAILS?.split(",") || [];
-
const emailDataSchema = z.object({
email: z.string(),
});
@@ -16,10 +14,6 @@
const emailData = emailDataSchema.parse(data);
return emailData.email;
@@ -20,7 +20,7 @@
});
const githubDataSchema = z.object({
@@ -45,14 +39,6 @@
@@ -48,14 +42,6 @@
const githubData = githubDataSchema.parse(data);
return githubData.profile.login;
},
@@ -35,7 +35,7 @@
});
// We are using the first email from the list of emails returned by GitHub.
@@ -85,13 +71,6 @@
@@ -88,13 +74,6 @@
const googleData = googleDataSchema.parse(data);
return googleData.profile.email;
},
@@ -49,7 +49,7 @@
});
export function getGoogleAuthConfig() {
@@ -121,13 +100,6 @@
@@ -126,13 +105,6 @@
const discordData = discordDataSchema.parse(data);
return discordData.profile.username;
},

View File

@@ -1,61 +1,69 @@
--- template/app/src/client/Main.css
+++ opensaas-sh/app/src/client/Main.css
@@ -44,6 +44,15 @@
@@ -56,6 +56,23 @@
.border-gradient-primary > * {
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%);
+ 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%);
+ background: radial-gradient(
+ circle at 30% 30%,
+ hsl(var(--card-subtle) / 0.8) 0%,
+ hsl(var(--card)) 100%
+ );
+ }
}
/* Here is an example of how to add a custom font.
@@ -51,6 +60,16 @@
@@ -63,6 +80,16 @@
* They are defined first here, then need to be referenced in the tailwind.config.js file
* under `theme.extend.fontFamily`, and then can be used as a tailwind class, e.g. className='font-satoshi'.
*/
+
+/* Satoshi Font Family */
+@font-face {
+ font-family: 'Satoshi';
+ src: url('/fonts/Satoshi-Light.woff2') format('woff2');
+ 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');
@@ -59,6 +78,30 @@
font-family: "Satoshi";
src: url("/fonts/Satoshi-Regular.woff2") format("woff2");
@@ -71,6 +98,30 @@
font-display: swap;
}
+@font-face {
+ font-family: 'Satoshi';
+ src: url('/fonts/Satoshi-Medium.woff2') format('woff2');
+ 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-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-family: "Satoshi";
+ src: url("/fonts/Satoshi-Black.woff2") format("woff2");
+ font-weight: 900;
+ font-style: normal;
+ font-display: swap;
@@ -64,13 +72,18 @@
/* third-party libraries CSS */
.tableCheckbox:checked ~ div span {
@@ -177,4 +220,17 @@
@@ -189,4 +240,22 @@
body {
@apply bg-background text-foreground;
}
+
+ /* Global typography styles */
+ h1, h2, h3, h4, h5, h6 {
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ @apply font-satoshi font-black leading-tight;
+ }
+
@@ -80,5 +93,5 @@
+}
+
+.navbar-maxwidth-transition {
+ transition: max-width 300ms cubic-bezier(0.4,0,0.2,1);
+ transition: max-width 300ms cubic-bezier(0.4, 0, 0.2, 1);
}

View File

@@ -1,43 +1,44 @@
--- 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://x.com/hot_town/status/1950117243246821770';
@@ -1,32 +1,34 @@
-const ANNOUNCEMENT_URL = "https://github.com/wasp-lang/wasp";
+const ANNOUNCEMENT_URL = "https://x.com/hot_town/status/1950117243246821770";
export function Announcement() {
+ const launchDate = new Date('2025-07-29T00:00:00-07:00');
+ const launchDate = new Date("2025-07-29T00:00:00-07:00");
+ const today = new Date();
+ const hasLaunched = today.toISOString().slice(0, 10) >= launchDate.toISOString().slice(0, 10);
+ const hasLaunched =
+ today.toISOString().slice(0, 10) >= launchDate.toISOString().slice(0, 10);
+
return (
- <div className='relative flex justify-center items-center gap-3 p-3 w-full bg-gradient-to-r from-accent to-secondary font-semibold text-primary-foreground text-center'>
+ <div className='relative flex justify-center items-center gap-3 p-3 w-full bg-gradient-to-r from-accent to-secondary font-semibold text-primary-foreground text-center tracking-wider z-[51]'>
- <div className="from-accent to-secondary text-primary-foreground relative flex w-full items-center justify-center gap-3 bg-gradient-to-r p-3 text-center font-semibold">
+ <div className="from-accent to-secondary text-primary-foreground relative z-[51] flex w-full items-center justify-center gap-3 bg-gradient-to-r p-3 text-center font-semibold tracking-wider">
<a
href={ANNOUNCEMENT_URL}
target='_blank'
- rel='noopener noreferrer'
- className='hidden lg:block cursor-pointer hover:opacity-90 hover:drop-shadow transition-opacity'
+ className='hidden lg:block hover:opacity-90 hover:drop-shadow transition-opacity'
target="_blank"
- rel="noopener noreferrer"
- className="hidden cursor-pointer transition-opacity hover:opacity-90 hover:drop-shadow lg:block"
+ className="hidden transition-opacity hover:opacity-90 hover:drop-shadow lg:block"
>
- Support Open-Source Software!
+ 🚀 Open SaaS v2.0 {hasLaunched ? 'is here!' : 'launches tomorrow!'}
+ 🚀 Open SaaS v2.0 {hasLaunched ? "is here!" : "launches tomorrow!"}
</a>
<div className='hidden lg:block self-stretch w-0.5 bg-primary-foreground/20'></div>
<div className="bg-primary-foreground/20 hidden w-0.5 self-stretch lg:block"></div>
<a
href={ANNOUNCEMENT_URL}
target='_blank'
- rel='noopener noreferrer'
- className='hidden lg:block cursor-pointer rounded-full bg-background/20 px-2.5 py-1 text-xs hover:bg-background/30 transition-colors tracking-wider'
+ className='hidden lg:block opacity-95 cursor-pointer rounded-full bg-background/20 px-2.5 py-1 hover:bg-background/30 transition-colors tracking-wider'
target="_blank"
- rel="noopener noreferrer"
- className="bg-background/20 hover:bg-background/30 hidden cursor-pointer rounded-full px-2.5 py-1 text-xs tracking-wider transition-colors lg:block"
+ 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"
>
- Star Our Repo on Github ⭐️ →
+ {hasLaunched ? 'Check out the Launch 🎉' : 'Get notified! 📆'} →
+ {hasLaunched ? "Check out the Launch 🎉" : "Get notified! 📆"} →
</a>
<a
href={ANNOUNCEMENT_URL}
target='_blank'
- rel='noopener noreferrer'
className='lg:hidden cursor-pointer rounded-full bg-background/20 px-2.5 py-1 text-xs hover:bg-background/30 transition-colors'
target="_blank"
- rel="noopener noreferrer"
className="bg-background/20 hover:bg-background/30 cursor-pointer rounded-full px-2.5 py-1 text-xs transition-colors lg:hidden"
>
- ⭐️ Star the Our Repo and Support Open-Source! ⭐️
+ 🎉 The Open SaaS v2.0 Launch is Live! 🚀

View File

@@ -1,57 +1,52 @@
--- 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-dom';
import { useAuth } from 'wasp/client/auth';
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
+import { Button } from '../../../components/ui/button';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../../../components/ui/sheet';
import { cn } from '../../../lib/utils';
import { throttleWithTrailingInvocation } from '../../../shared/utils';
@@ -11,6 +12,7 @@
import { useIsLandingPage } from '../../hooks/useIsLandingPage';
import logo from '../../static/logo.webp';
import DarkModeSwitcher from '../DarkModeSwitcher';
+import RepoInfo from '../RepoInfo';
import { Announcement } from './Announcement';
import { Link as ReactRouterLink } from "react-router-dom";
import { useAuth } from "wasp/client/auth";
import { Link as WaspRouterLink, routes } from "wasp/client/router";
+import { Button } from "../../../components/ui/button";
import {
Sheet,
SheetContent,
@@ -17,6 +18,7 @@
import { useIsLandingPage } from "../../hooks/useIsLandingPage";
import logo from "../../static/logo.webp";
import DarkModeSwitcher from "../DarkModeSwitcher";
+import RepoInfo from "../RepoInfo";
import { Announcement } from "./Announcement";
export interface NavigationItem {
@@ -38,7 +40,12 @@
return (
<>
{isLandingPage && <Announcement />}
- <header className={cn('sticky top-0 z-50 transition-all duration-300', isScrolled && 'top-4')}>
+ <header
+ className={cn(
+ 'sticky top-0 z-50 transition-all duration-300',
+ isScrolled && 'top-4 mx-4 xl:mx-30 lg:mx-10'
+ )}
+ >
<div
className={cn('transition-all duration-300', {
'mx-4 md:mx-20 pr-2 lg:pr-0 rounded-full shadow-lg bg-background/90 backdrop-blur-lg border border-border':
@@ -48,7 +55,7 @@
@@ -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",
)}
>
<nav
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,
})}
aria-label='Global'
@@ -65,7 +72,7 @@
'ml-2 text-xs': isScrolled,
})}
<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>
@@ -88,7 +95,12 @@
@@ -113,7 +115,12 @@
return (
<div className='hidden lg:flex lg:flex-1 gap-3 justify-end items-center'>
<ul className='flex justify-center items-center gap-2 sm:gap-4'>
<div className="hidden items-center justify-end gap-3 lg:flex lg:flex-1">
<ul className="flex items-center justify-center gap-2 sm:gap-4">
- <DarkModeSwitcher />
+ <li>
+ <RepoInfo />
@@ -62,48 +57,48 @@
</ul>
{isUserLoading ? null : !user ? (
<WaspRouterLink
@@ -98,10 +110,11 @@
'text-xs': isScrolled,
})}
@@ -126,10 +133,11 @@
},
)}
>
- <div className='flex items-center duration-300 ease-in-out text-foreground hover:text-primary transition-colors'>
+ <div className='flex items-center duration-300 gap-1 ease-in-out text-foreground hover:text-primary transition-colors'>
- <div className="text-foreground hover:text-primary flex items-center transition-colors duration-300 ease-in-out">
+ <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{' '}
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,
@@ -152,7 +165,7 @@
- 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'>
<SheetTitle className="flex items-center">
<WaspRouterLink to={routes.LandingPageRoute.to}>
- <span className='sr-only'>Your SaaS</span>
+ <span className='sr-only'>Open SaaS</span>
- <span className="sr-only">Your SaaS</span>
+ <span className="sr-only">Open SaaS</span>
<NavLogo isScrolled={false} />
</WaspRouterLink>
</SheetTitle>
@@ -163,9 +176,9 @@
<div className='py-6'>
@@ -193,9 +201,9 @@
<div className="py-6">
{isUserLoading ? null : !user ? (
<WaspRouterLink to={routes.LoginRoute.to}>
- <div className='flex justify-end items-center duration-300 ease-in-out text-foreground hover:text-primary transition-colors'>
- Log in <LogIn size='1.1rem' className='ml-1' />
- <div className="text-foreground hover:text-primary flex items-center justify-end transition-colors duration-300 ease-in-out">
- Log in <LogIn size="1.1rem" className="ml-1" />
- </div>
+ <Button variant='outline'>
+ <span>Demo App</span> <LogIn className='ml-1' />
+ <Button variant="outline">
+ <span>Demo App</span> <LogIn className="ml-1" />
+ </Button>
</WaspRouterLink>
) : (
<ul className='space-y-2'>
@@ -174,7 +187,14 @@
<ul className="space-y-2">
@@ -207,7 +215,14 @@
)}
</div>
<div className='py-6'>
<div className="py-6">
- <DarkModeSwitcher />
+ <ul className='flex items-center justify-between gap-4'>
+ <ul className="flex items-center justify-between gap-4">
+ <li>
+ <DarkModeSwitcher />
+ </li>
@@ -114,11 +109,11 @@
</div>
</div>
</div>
@@ -218,6 +238,6 @@
'size-7': isScrolled,
@@ -251,6 +266,6 @@
"size-7": isScrolled,
})}
src={logo}
- alt='Your SaaS App'
+ alt='Open SaaS App'
- alt="Your SaaS App"
+ alt="Open SaaS App"
/>
);

View File

@@ -3,8 +3,8 @@
@@ -9,7 +9,6 @@
export const marketingNavigationItems: NavigationItem[] = [
{ name: 'Features', to: '/#features' },
- { name: 'Pricing', to: routes.PricingPageRoute.to },
{ name: "Features", to: "/#features" },
- { name: "Pricing", to: routes.PricingPageRoute.to },
...staticNavigationItems,
] as const;

View File

@@ -1,10 +1,10 @@
--- template/app/src/client/components/RepoInfo.tsx
+++ opensaas-sh/app/src/client/components/RepoInfo.tsx
@@ -0,0 +1,40 @@
+import { useEffect, useState } from 'react';
+import { FaGithub } from 'react-icons/fa';
+import { Button } from '../../components/ui/button';
+import { formatNumber } from '../../lib/utils';
@@ -0,0 +1,48 @@
+import { useEffect, useState } from "react";
+import { FaGithub } from "react-icons/fa";
+import { Button } from "../../components/ui/button";
+import { formatNumber } from "../../lib/utils";
+
+const RepoInfo = () => {
+ const [repoInfo, setRepoInfo] = useState<null | any>(null);
@@ -14,11 +14,13 @@
+ const fetchRepoInfo = async () => {
+ try {
+ setIsLoading(true);
+ const response = await fetch('https://api.github.com/repos/wasp-lang/open-saas');
+ 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);
+ console.error("Error fetching repo info", error);
+ } finally {
+ setIsLoading(false);
+ }
@@ -31,10 +33,16 @@
+ }
+
+ return (
+ <a href='https://github.com/wasp-lang/open-saas' target='_blank' rel='noopener noreferrer'>
+ <Button variant='ghost' className='rounded-full py-0 pl-2 pr-3 h-8'>
+ <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>
+ <span className="text-sm leading-none">
+ {formatNumber(repoInfo.stargazers_count)}
+ </span>
+ </Button>
+ </a>
+ );

View File

@@ -1,32 +1,37 @@
--- template/app/src/components/ui/button.tsx
+++ opensaas-sh/app/src/components/ui/button.tsx
@@ -5,22 +5,26 @@
import { cn } from '../../lib/utils';
@@ -5,26 +5,31 @@
import { cn } from "../../lib/utils";
const buttonVariants = cva(
- 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
+ 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
- default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
+ default: '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',
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',
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: {

View File

@@ -1,15 +1,15 @@
--- template/app/src/components/ui/card.tsx
+++ opensaas-sh/app/src/components/ui/card.tsx
@@ -9,7 +9,11 @@
default: 'bg-card text-card-foreground',
accent: 'bg-card-accent text-card-accent-foreground hover:scale-[1.02]',
faded: 'text-card-faded-foreground scale-95 opacity-50',
- bento: 'bg-card-subtle text-card-subtle-foreground hover:scale-[1.02] border-none shadow-none',
+ bento: 'bg-card-subtle text-card-subtle-foreground border-none shadow-none hover:shadow-none',
@@ -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',
+ "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",
},
},
},
});

View File

@@ -1,9 +0,0 @@
--- template/app/src/file-upload/fileUploading.ts
+++ opensaas-sh/app/src/file-upload/fileUploading.ts
@@ -1,5 +1,5 @@
-import { createFile } from 'wasp/client/operations';
import axios from 'axios';
+import { createFile } from 'wasp/client/operations';
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from './validation';
export type FileWithValidType = Omit<File, 'type'> & { type: AllowedFileType };

View File

@@ -1,24 +1,6 @@
--- template/app/src/file-upload/operations.ts
+++ opensaas-sh/app/src/file-upload/operations.ts
@@ -1,14 +1,14 @@
-import * as z from 'zod';
-import { HttpError } from 'wasp/server';
import { type File } from 'wasp/entities';
+import { HttpError } from 'wasp/server';
import {
type CreateFile,
type GetAllFilesByUser,
type GetDownloadFileSignedURL,
} from 'wasp/server/operations';
+import * as z from 'zod';
-import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils';
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
+import { getDownloadFileSignedURLFromS3, getUploadFileSignedURLFromS3 } from './s3Utils';
import { ALLOWED_FILE_TYPES } from './validation';
const createFileInputSchema = z.object({
@@ -37,6 +37,18 @@
@@ -44,6 +44,21 @@
userId: context.user.id,
});
@@ -31,7 +13,10 @@
+ });
+
+ if (numberOfFilesByUser >= 2) {
+ throw new HttpError(403, 'Thanks for trying Open SaaS. This demo only allows 2 file uploads per user.');
+ throw new HttpError(
+ 403,
+ "Thanks for trying Open SaaS. This demo only allows 2 file uploads per user.",
+ );
+ }
+
await context.entities.File.create({

View File

@@ -1,15 +0,0 @@
--- template/app/src/file-upload/s3Utils.ts
+++ opensaas-sh/app/src/file-upload/s3Utils.ts
@@ -1,8 +1,8 @@
-import * as path from 'path';
-import { randomUUID } from 'crypto';
-import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
-import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
+import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
+import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
+import { randomUUID } from 'crypto';
+import * as path from 'path';
import { MAX_FILE_SIZE_BYTES } from './validation';
const s3Client = new S3Client({

View File

@@ -1,19 +1,20 @@
--- template/app/src/landing-page/LandingPage.tsx
+++ opensaas-sh/app/src/landing-page/LandingPage.tsx
@@ -1,10 +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 Testimonials from './components/Testimonials';
-import AIReady from './ExampleHighlightedFeature';
import { examples, faqs, features, footerNavigation, testimonials } from './contentSections';
@@ -1,3 +1,4 @@
+import { Admin, AIReady, Auth, Payments } from "./components/Examples";
import ExamplesCarousel from "./components/ExamplesCarousel";
import FAQ from "./components/FAQ";
import FeaturesGrid from "./components/FeaturesGrid";
@@ -11,7 +12,6 @@
footerNavigation,
testimonials,
} from "./contentSections";
-import AIReady from "./ExampleHighlightedFeature";
export default function LandingPage() {
@@ -13,6 +13,9 @@
<main className='isolate'>
return (
@@ -19,6 +19,9 @@
<main className="isolate">
<Hero />
<ExamplesCarousel examples={examples} />
+ <Auth />

View File

@@ -1,26 +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';
+import aiReadyDark from "../../../client/static/assets/aiready-dark.webp";
+import aiReady from "../../../client/static/assets/aiready.webp";
+import HighlightedFeature from "../HighlightedFeature";
+
+export default function AIReady() {
+ return (
+ <HighlightedFeature
+ name='AI Ready'
+ description='Built-in OpenAI integration with LLM-optimized docs and AI-friendly architecture for seamless AI-assisted development.'
+ name="AI Ready"
+ description="Built-in OpenAI integration with LLM-optimized docs and AI-friendly architecture for seamless AI-assisted development."
+ highlightedComponent={<AIReadyExample />}
+ direction='row-reverse'
+ direction="row-reverse"
+ />
+ );
+}
+
+const 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 className="w-full">
+ <img src={aiReady} alt="AI Ready" className="dark:hidden" />
+ <img src={aiReadyDark} alt="AI Ready" className="hidden dark:block" />
+ </div>
+ );
+};

View File

@@ -1,14 +1,14 @@
--- 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';
+import admin from "../../../client/static/assets/admin.webp";
+import HighlightedFeature from "../HighlightedFeature";
+
+export default function Admin() {
+ return (
+ <HighlightedFeature
+ name='Admin Dashboard'
+ description='Graphs! Tables! Analytics w/ Plausible or Google! All in one place. Ooooooooh.'
+ name="Admin Dashboard"
+ description="Graphs! Tables! Analytics w/ Plausible or Google! All in one place. Ooooooooh."
+ highlightedComponent={<AdminExample />}
+ />
+ );
@@ -16,8 +16,8 @@
+
+const AdminExample = () => {
+ return (
+ <div className='w-full'>
+ <img src={admin} alt='Admin' />
+ <div className="w-full">
+ <img src={admin} alt="Admin" />
+ </div>
+ );
+};

View File

@@ -1,53 +1,73 @@
--- template/app/src/landing-page/components/Examples/Auth.tsx
+++ opensaas-sh/app/src/landing-page/components/Examples/Auth.tsx
@@ -0,0 +1,130 @@
+import { useState } from 'react';
+import { FaDiscord, FaGithub, FaGoogle, FaSlack } from 'react-icons/fa';
+import { Button } from '../../../components/ui/button';
+import { Card, CardContent, CardHeader } from '../../../components/ui/card';
+import { Input } from '../../../components/ui/input';
+import { DocsUrl } from '../../../shared/common';
+import HighlightedFeature from '../HighlightedFeature';
@@ -0,0 +1,163 @@
+import { useState } from "react";
+import { FaDiscord, FaGithub, FaGoogle, FaSlack } from "react-icons/fa";
+import { Button } from "../../../components/ui/button";
+import { Card, CardContent, CardHeader } from "../../../components/ui/card";
+import { Input } from "../../../components/ui/input";
+import { DocsUrl } from "../../../shared/common";
+import HighlightedFeature from "../HighlightedFeature";
+
+const SupportedAuthProviders = ['google', 'github', 'discord', 'slack'] as const;
+const SupportedAuthProviders = [
+ "google",
+ "github",
+ "discord",
+ "slack",
+] as const;
+type AuthProvider = (typeof SupportedAuthProviders)[number];
+
+const providers: { name: AuthProvider; icon: React.ComponentType<{ className?: string }> }[] = [
+const providers: {
+ name: AuthProvider;
+ icon: React.ComponentType<{ className?: string }>;
+}[] = [
+ {
+ name: 'google',
+ name: "google",
+ icon: FaGoogle,
+ },
+ {
+ name: 'github',
+ name: "github",
+ icon: FaGithub,
+ },
+ {
+ name: 'discord',
+ name: "discord",
+ icon: FaDiscord,
+ },
+ {
+ name: 'slack',
+ name: "slack",
+ icon: FaSlack,
+ },
+];
+
+export default function Auth() {
+ const [selectedProviders, setSelectedProviders] = useState<AuthProvider[]>(['google', 'github']);
+ const [selectedProviders, setSelectedProviders] = useState<AuthProvider[]>([
+ "google",
+ "github",
+ ]);
+
+ const toggleProvider = (provider: AuthProvider) => {
+ setSelectedProviders((prev) =>
+ prev.includes(provider) ? prev.filter((p) => p !== provider) : [...prev, provider]
+ prev.includes(provider)
+ ? prev.filter((p) => p !== provider)
+ : [...prev, provider],
+ );
+ };
+
+ return (
+ <HighlightedFeature
+ name='DIY Auth
+Done for you'
+ description={<AuthPlayground toggleProvider={toggleProvider} selectedProviders={selectedProviders} />}
+ highlightedComponent={<AuthExample selectedProviders={selectedProviders} />}
+ tilt='left'
+ className='h-[400px]'
+ name="DIY Auth
+Done for you"
+ description={
+ <AuthPlayground
+ toggleProvider={toggleProvider}
+ selectedProviders={selectedProviders}
+ />
+ }
+ highlightedComponent={
+ <AuthExample selectedProviders={selectedProviders} />
+ }
+ tilt="left"
+ className="h-[400px]"
+ />
+ );
+}
@@ -60,17 +80,21 @@
+ selectedProviders: 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'>
+ <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((provider) => {
+ const IconComponent = provider.icon;
+ return (
+ <Button
+ key={provider.name}
+ onClick={() => toggleProvider(provider.name)}
+ size='iconLg'
+ variant={selectedProviders.includes(provider.name) ? 'inner' : 'outer'}
+ size="iconLg"
+ variant={
+ selectedProviders.includes(provider.name) ? "inner" : "outer"
+ }
+ >
+ <IconComponent />
+ </Button>
@@ -83,37 +107,46 @@
+
+const Divider = () => {
+ return (
+ <div className='flex flex-row gap-2 items-center'>
+ <div className='flex-1 h-px bg-muted' />
+ <p className=' text-xs'>Or continue with</p>
+ <div className='flex-1 h-px bg-muted' />
+ <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>
+ );
+};
+
+const AuthExample = ({ selectedProviders }: { selectedProviders: AuthProvider[] }) => {
+const AuthExample = ({
+ selectedProviders,
+}: {
+ selectedProviders: AuthProvider[];
+}) => {
+ return (
+ <Card className='py-10 w-full max-w-md transition-all duration-300 ease-in-out' variant='outer'>
+ <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>
+ <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='text-xs mb-2'>Log in with</p> : null}
+ <div className='flex flex-row gap-2'>
+ <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 providerData = providers.find((p) => p.name === provider);
+ const IconComponent = providerData?.icon;
+ return (
+ <a
+ href={DocsUrl + '/guides/authentication/'}
+ href={DocsUrl + "/guides/authentication/"}
+ key={provider}
+ className='w-full mb-2'
+ target='_blank'
+ className="mb-2 w-full"
+ target="_blank"
+ >
+ <Button variant='outline' className='w-full'>
+ {IconComponent && <IconComponent className='w-4 h-4' />}
+ <Button variant="outline" className="w-full">
+ {IconComponent && <IconComponent className="h-4 w-4" />}
+ </Button>
+ </a>
+ );
@@ -121,10 +154,10 @@
+ </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 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>

View File

@@ -1,24 +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';
+import payments from "../../../client/static/assets/payments.webp";
+import HighlightedFeature from "../HighlightedFeature";
+
+export default function Payments() {
+ return (
+ <HighlightedFeature
+ name='Stripe / Lemon Squeezy Integration'
+ name="Stripe / Lemon Squeezy Integration"
+ description="No SaaS is complete without payments. We've pre-configured checkout and webhooks. Just choose a provider and start cashing out."
+ highlightedComponent={<PaymentsExample />}
+ direction='row-reverse'
+ direction="row-reverse"
+ />
+ );
+}
+
+const PaymentsExample = () => {
+ return (
+ <div className='w-full max-w-lg'>
+ <img src={payments} alt='Payments' />
+ <div className="w-full max-w-lg">
+ <img src={payments} alt="Payments" />
+ </div>
+ );
+};

View File

@@ -1,7 +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 { default as Admin } from './Admin';
+export { default as AIReady } from './AIReady';
+export { default as Auth } from './Auth';
+export { default as Payments } from './Payments';
+export { default as Admin } from "./Admin";
+export { default as AIReady } from "./AIReady";
+export { default as Auth } from "./Auth";
+export { default as Payments } from "./Payments";

View File

@@ -1,11 +1,11 @@
--- template/app/src/landing-page/components/ExamplesCarousel.tsx
+++ opensaas-sh/app/src/landing-page/components/ExamplesCarousel.tsx
@@ -100,7 +100,7 @@
@@ -108,7 +108,7 @@
ref={containerRef}
className='relative w-screen left-1/2 -translate-x-1/2 flex flex-col items-center my-16'
className="relative left-1/2 my-16 flex w-screen -translate-x-1/2 flex-col items-center"
>
- <h2 className='mb-6 text-center font-semibold tracking-wide text-muted-foreground'>Used by:</h2>
+ <h2 className='mb-6 text-lg text-center font-semibold tracking-wide text-muted-foreground'>Used by:</h2>
<div className='w-full max-w-full overflow-hidden'>
<div
className='flex overflow-x-auto no-scrollbar scroll-smooth pb-10 pt-4 gap-4 px-4 snap-x snap-mandatory'
- <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">

View File

@@ -1,87 +1,93 @@
--- template/app/src/landing-page/components/FeaturesGrid.tsx
+++ opensaas-sh/app/src/landing-page/components/FeaturesGrid.tsx
@@ -4,13 +4,15 @@
import { Feature } from './Features';
import SectionTitle from './SectionTitle';
@@ -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'> {
-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';
direction?: "col" | "row" | "col-reverse" | "row-reverse";
align?: "center" | "left";
size: "small" | "medium" | "large";
fullWidthIcon?: boolean;
+ highlight?: boolean;
}
interface FeaturesGridProps {
@@ -45,7 +47,8 @@
direction = 'col',
align = 'center',
size = 'medium',
@@ -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',
@@ -60,49 +63,103 @@
'col-reverse': 'flex-col-reverse',
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 mobileOrderClass = highlight ? "order-last md:order-none" : "";
+
const gridFeatureCard = (
<Card
className={cn(
'h-full min-h-[140px] transition-all duration-300 hover:shadow-lg cursor-pointer',
- gridFeatureSizeToClasses[size]
+ gridFeatureSizeToClasses[size],
"h-full min-h-[140px] cursor-pointer transition-all duration-300 hover:shadow-lg",
gridFeatureSizeToClasses[size],
+ mobileOrderClass,
+ highlight && 'bg-radial-gradient'
+ highlight && "bg-radial-gradient",
)}
- variant='bento'
+ variant={highlight ? 'bentoHighlight' : 'bento'}
- variant="bento"
+ variant={highlight ? "bentoHighlight" : "bento"}
>
- <CardContent className='p-4 h-full flex flex-col justify-center items-center'>
- <CardContent className="flex h-full flex-col items-center justify-center p-4">
+ <CardContent
+ className={cn(
+ 'py-8 px-4 h-full flex flex-col justify-center items-center',
+ fullWidthIcon && direction === 'col-reverse'
+ ? 'p-0 pt-8 pb-0 justify-end'
+ : fullWidthIcon && 'p-0 pb-8 pt-0 justify-start'
+ "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='w-full flex justify-center items-center mb-3'>
- {icon ? icon : emoji ? <span className='text-4xl'>{emoji}</span> : null}
+ <div className='flex flex-col w-full'>
- <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(
+ 'w-full flex items-center justify-center',
+ direction === 'col-reverse' ? 'order-2 mt-6' : 'order-1'
+ "flex w-full items-center justify-center",
+ direction === "col-reverse" ? "order-2 mt-6" : "order-1",
+ )}
+ >
+ {icon ? (
+ <div className='w-full flex justify-center'>{icon}</div>
+ <div className="flex w-full justify-center">{icon}</div>
+ ) : emoji ? (
+ <span className='text-4xl'>{emoji}</span>
+ <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')}
+ className={cn(
+ "text-center text-2xl",
+ direction === "col-reverse" ? "order-1" : "order-2",
+ )}
+ >
+ {name}
+ </CardTitle>
+ )}
+ <CardDescription
+ className={cn(
+ 'text-xs leading-relaxed px-8',
+ 'text-center',
+ direction === 'col-reverse' ? 'order-1' : 'order-2'
+ "px-8 text-xs leading-relaxed",
+ "text-center",
+ direction === "col-reverse" ? "order-1" : "order-2",
+ )}
+ >
+ {description}
@@ -90,64 +96,71 @@
) : (
<div
className={cn(
- 'flex items-center gap-3',
+ 'flex items-center',
+ (icon || emoji) && 'gap-3',
- "flex items-center gap-3",
+ "flex items-center",
+ (icon || emoji) && "gap-3",
directionToClass[direction],
align === 'center' ? 'justify-center items-center' : 'justify-start'
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 className="flex h-10 w-10 items-center justify-center rounded-lg">
- {icon ? (
- icon
- ) : emoji ? (
- <span className="text-2xl">{emoji}</span>
- ) : null}
- </div>
- <CardTitle className={cn(align === 'center' ? 'text-center' : 'text-left')}>{name}</CardTitle>
+ {(icon || emoji) && (
+ <div className='flex h-10 w-10 items-center justify-center rounded-lg'>
+ {icon ? icon : <span className='text-2xl'>{emoji}</span>}
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg">
+ {icon ? icon : <span className="text-2xl">{emoji}</span>}
+ </div>
+ )}
+ <CardTitle
<CardTitle
- className={cn(align === "center" ? "text-center" : "text-left")}
+ className={cn(
+ highlight ? 'text-4xl font-bold' : 'text-lg',
+ align === 'center' ? 'text-center' : 'text-left'
+ highlight ? "text-4xl font-bold" : "text-lg",
+ align === "center" ? "text-center" : "text-left",
+ )}
+ >
+ {name}
+ </CardTitle>
>
{name}
</CardTitle>
</div>
)}
- {fullWidthIcon && (icon || emoji) && <CardTitle className='text-center mb-2'>{name}</CardTitle>}
- <CardDescription
- className={cn(
- 'text-xs leading-relaxed',
- fullWidthIcon || direction === 'col' || align === 'center' ? 'text-center' : 'text-left'
- )}
- >
- {description}
- </CardDescription>
- {fullWidthIcon && (icon || emoji) && (
- <CardTitle className="mb-2 text-center">{name}</CardTitle>
+ {!fullWidthIcon && (
+ <CardDescription
+ className={cn(
+ 'text-xs leading-relaxed px-8',
+ direction === 'col' || align === 'center' ? 'text-center' : 'text-left'
+ "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>
);
if (href) {
return (
- <a href={href} target='_blank' rel='noopener noreferrer' className={gridFeatureSizeToClasses[size]}>
+ <a
+ href={href}
+ target='_blank'
+ rel='noopener noreferrer'
@@ -141,7 +182,7 @@
href={href}
target="_blank"
rel="noopener noreferrer"
- className={gridFeatureSizeToClasses[size]}
+ className={cn(gridFeatureSizeToClasses[size], mobileOrderClass)}
+ >
>
{gridFeatureCard}
</a>
);

View File

@@ -1,43 +1,50 @@
--- template/app/src/landing-page/components/Hero/Hero.tsx
+++ opensaas-sh/app/src/landing-page/components/Hero/Hero.tsx
@@ -0,0 +1,85 @@
+import { ArrowRight } from 'lucide-react';
+import { Link as ReactRouterLink } from 'react-router-dom';
+import { useAuth } from 'wasp/client/auth';
+import { Link as WaspRouterLink, routes } from 'wasp/client/router';
+import { Button } from '../../../components/ui/button';
+import { DocsUrl, WaspUrl } from '../../../shared/common';
+import Orbit from './Orbit';
@@ -0,0 +1,93 @@
+import { ArrowRight } from "lucide-react";
+import { Link as ReactRouterLink } from "react-router-dom";
+import { useAuth } from "wasp/client/auth";
+import { Link as WaspRouterLink, routes } from "wasp/client/router";
+import { Button } from "../../../components/ui/button";
+import { DocsUrl, WaspUrl } from "../../../shared/common";
+import Orbit from "./Orbit";
+
+export default function Hero() {
+ const { data: user } = useAuth();
+
+ return (
+ <div className='relative pt-32 w-full'>
+ <div className="relative w-full pt-32">
+ <TopGradient />
+ <BottomGradient />
+ <div className='flex flex-col lg:flex-row max-w-7xl mx-auto overflow-x-hidden'>
+ <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-5xl font-extrabold text-foreground md:text-6xl'>
+ The <span className='italic'>free</span> SaaS template with{' '}
+ <span className='text-gradient-primary font-black'>superpowers</span>
+ <div className="mx-auto flex max-w-7xl flex-col overflow-x-hidden 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 md:text-6xl">
+ The <span className="italic">free</span> SaaS template with{" "}
+ <span className="text-gradient-primary font-black">
+ superpowers
+ </span>
+ </h1>
+ <p className='mt-6 max-w-2xl text-md leading-8 text-muted-foreground font-mono'>
+ An open-source, feature-rich, full-stack React + NodeJS starter kit that manages boilerplate
+ for you. Powered by
+ <a href={WaspUrl} className='group font-bold transition-all duration-300'>
+ {' Wasp =}'}
+ <p className="text-md text-muted-foreground mt-6 max-w-2xl font-mono leading-8">
+ An open-source, feature-rich, full-stack React + NodeJS starter
+ kit that manages boilerplate for you. 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 md:justify-start gap-x-6'>
+ <Button size='lg' variant='outline' asChild>
+ <WaspRouterLink to={user ? routes.DemoAppRoute.to : routes.LoginRoute.to}>
+ <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>
+ <Button size="lg" variant="default" asChild>
+ <ReactRouterLink to={DocsUrl}>
+ Get Started
+ <ArrowRight />
@@ -47,7 +54,7 @@
+ </div>
+ </div>
+ </div>
+ <div className='hidden lg:block '>
+ <div className="hidden lg:block">
+ <Orbit />
+ </div>
+ </div>
@@ -58,13 +65,14 @@
+function TopGradient() {
+ return (
+ <div
+ className='absolute top-0 right-0 -z-10 transform-gpu overflow-hidden w-full blur-3xl sm:top-0'
+ aria-hidden='true'
+ 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] w-[70rem] flex-none sm:right-1/4 sm:translate-x-1/2 dark:hidden bg-gradient-to-tr from-amber-400 to-purple-300 opacity-10'
+ className="aspect-[1020/880] w-[70rem] flex-none bg-gradient-to-tr 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)',
+ clipPath:
+ "polygon(80% 20%, 90% 55%, 50% 100%, 70% 30%, 20% 50%, 50% 0)",
+ }}
+ />
+ </div>
@@ -74,13 +82,13 @@
+function BottomGradient() {
+ return (
+ <div
+ className='absolute inset-x-0 top-[calc(100%-40rem)] sm:top-[calc(100%-65rem)] -z-10 transform-gpu overflow-hidden blur-3xl'
+ aria-hidden='true'
+ 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='relative aspect-[1020/880] sm:-left-3/4 sm:translate-x-1/4 dark:hidden bg-gradient-to-br from-amber-400 to-purple-300 opacity-10 w-[90rem]'
+ className="relative aspect-[1020/880] w-[90rem] bg-gradient-to-br 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%)',
+ clipPath: "ellipse(80% 30% at 80% 50%)",
+ }}
+ />
+ </div>

View File

@@ -1,20 +1,20 @@
--- template/app/src/landing-page/components/Hero/Orbit.tsx
+++ opensaas-sh/app/src/landing-page/components/Hero/Orbit.tsx
@@ -0,0 +1,217 @@
+import { useEffect, useState } from 'react';
+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 '../../../lib/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';
@@ -0,0 +1,283 @@
+import { useEffect, useState } from "react";
+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 "../../../lib/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;
@@ -39,58 +39,114 @@
+ <img
+ src={src}
+ alt={alt}
+ className={cn('w-8 h-8', dark ? 'dark:block hidden' : 'dark:hidden', className)}
+ 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='w-8 h-8 dark:block' dark={false} />,
+ 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',
+ 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='w-8 h-8 dark:hidden' dark={false} />
+ <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} />,
+ 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} />,
+ 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} />,
+ 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} />,
+ 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} />,
+ id: "node-dark",
+ component: () => (
+ <ImageLogo src={nodeLogoDark} alt="Node.js Logo" dark={true} />
+ ),
+ circleIndex: 3,
+ position: 50,
+ },
@@ -117,7 +173,11 @@
+ return () => clearInterval(interval);
+ }, []);
+
+ const getLogoPosition = (circleIndex: number, position: number, rotation: number) => {
+ const getLogoPosition = (
+ circleIndex: number,
+ position: number,
+ rotation: number,
+ ) => {
+ const circle = circles[circleIndex];
+ const totalRotation = rotation * circle.rotationSpeed + position;
+ const radians = (totalRotation * Math.PI) / 180;
@@ -129,10 +189,12 @@
+ };
+
+ return (
+ <div className='relative w-[500px] h-[500px] flex items-center justify-center'>
+ <div className='absolute flex items-center gap-2 flex-col'>
+ <p className='text-6xl font-black font-sans text-gradient-primary'>100%</p>
+ <p className='text-lg text-muted-foreground'>Open Source</p>
+ <div className="relative flex h-[500px] w-[500px] items-center justify-center">
+ <div className="absolute flex flex-col items-center gap-2">
+ <p className="text-gradient-primary font-sans text-6xl font-black">
+ 100%
+ </p>
+ <p className="text-muted-foreground text-lg">Open Source</p>
+ </div>
+
+ {circles.map((circle, circleIndex) => {
@@ -178,7 +240,7 @@
+ return (
+ <div
+ key={circleIndex}
+ className='absolute rounded-full'
+ className="absolute rounded-full"
+ style={{
+ width: circle.radius * 2,
+ height: circle.radius * 2,
@@ -195,17 +257,21 @@
+ })}
+
+ {logoConfigs.map((logoConfig) => {
+ const { x, y } = getLogoPosition(logoConfig.circleIndex, logoConfig.position, rotation);
+ const { x, y } = getLogoPosition(
+ logoConfig.circleIndex,
+ logoConfig.position,
+ rotation,
+ );
+ const LogoComponent = logoConfig.component;
+ const logoSize = logoConfig.size || 32;
+
+ return (
+ <div
+ key={logoConfig.id}
+ className='absolute z-20'
+ className="absolute z-20"
+ style={{
+ left: '50%',
+ top: '50%',
+ left: "50%",
+ top: "50%",
+ transform: `translate(calc(${x}px - ${logoSize / 2}px), calc(${y}px - ${logoSize / 2}px))`,
+ width: logoSize,
+ height: logoSize,

View File

@@ -1,6 +1,6 @@
--- template/app/src/landing-page/components/Hero/index.ts
+++ opensaas-sh/app/src/landing-page/components/Hero/index.ts
@@ -0,0 +1,3 @@
+import Hero from './Hero';
+import Hero from "./Hero";
+
+export default Hero;

View File

@@ -1,47 +1,45 @@
--- template/app/src/landing-page/components/HighlightedFeature.tsx
+++ opensaas-sh/app/src/landing-page/components/HighlightedFeature.tsx
@@ -6,6 +6,8 @@
direction?: 'row' | 'row-reverse';
direction?: "row" | "row-reverse";
highlightedComponent: React.ReactNode;
tilt?: 'left' | 'right';
tilt?: "left" | "right";
+ className?: string;
+ url?: string;
}
/**
@@ -18,6 +20,8 @@
direction = 'row',
direction = "row",
highlightedComponent,
tilt,
+ className,
+ url,
}: FeatureProps) => {
const tiltToClass: Record<Required<FeatureProps>['tilt'], string> = {
left: 'rotate-1',
const tiltToClass: Record<Required<FeatureProps>["tilt"], string> = {
left: "rotate-1",
@@ -27,12 +31,14 @@
return (
<div
className={cn(
- 'max-w-6xl mx-auto flex flex-col items-center my-50 gap-x-20 gap-y-10 justify-between px-8 md:px-4 transition-all duration-300 ease-in-out',
+ 'max-w-6xl mx-auto flex flex-col md:items-center my-50 gap-x-20 gap-y-10 justify-between px-8 md:px-4 transition-all duration-300 ease-in-out',
direction === 'row' ? 'md:flex-row' : 'md:flex-row-reverse'
- "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 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-col flex-1'>
- <h2 className='text-4xl font-bold mb-2'>{name}</h2>
+ <a href={url} target='_blank' rel='noopener noreferrer'>
+ <h2 className='text-4xl font-bold mb-2'>{name}</h2>
<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>
{typeof description === "string" ? (
<p className="text-muted-foreground">{description}</p>
) : (
@@ -42,7 +48,8 @@
<div
@@ -43,6 +49,7 @@
className={cn(
'flex flex-1 my-10 transition-transform duration-300 ease-in-out w-full items-center justify-center',
- tilt && tiltToClass[tilt]
+ tilt && tiltToClass[tilt],
+ className
"my-10 flex w-full flex-1 items-center justify-center transition-transform duration-300 ease-in-out",
tilt && tiltToClass[tilt],
+ className,
)}
>
{highlightedComponent}

View File

@@ -1,41 +1,44 @@
--- 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, CardDescription, CardFooter, CardTitle } from '../../components/ui/card';
import SectionTitle from './SectionTitle';
@@ -11,17 +10,12 @@
}
export default function Testimonials({ testimonials }: { testimonials: Testimonial[] }) {
-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;
- 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="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 w-full z-10 px-4 md:px-0 columns-1 md:columns-2 lg:columns-3 gap-2 md:gap-6'>
<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='break-inside-avoid mb-6'>
<Card className='flex flex-col justify-between'>
<CardContent className='p-6'>
@@ -52,17 +46,6 @@
<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='flex justify-center mt-8 md:hidden'>
- <div className="mt-8 flex justify-center md:hidden">
- <button
- onClick={() => setIsExpanded(!isExpanded)}
- className='px-6 py-3 text-sm font-medium text-primary bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors duration-200'
- 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`}
- {isExpanded
- ? "Show Less"
- : `Show ${testimonials.length - mobileItemsToShow} More`}
- </button>
- </div>
- )}

View File

@@ -1,176 +1,195 @@
--- template/app/src/landing-page/contentSections.tsx
+++ opensaas-sh/app/src/landing-page/contentSections.tsx
@@ -0,0 +1,244 @@
+import { routes } from 'wasp/client/router';
+import type { NavigationItem } from '../client/components/NavBar/NavBar';
+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';
+import promptpanda from '../client/static/examples/promptpanda.webp';
+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 logo from '../client/static/logo.webp';
+import { BlogUrl, DocsUrl, GithubUrl, WaspUrl } from '../shared/common';
+import { GridFeature } from './components/FeaturesGrid';
@@ -0,0 +1,263 @@
+import { routes } from "wasp/client/router";
+import type { NavigationItem } from "../client/components/NavBar/NavBar";
+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";
+import promptpanda from "../client/static/examples/promptpanda.webp";
+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 logo from "../client/static/logo.webp";
+import { BlogUrl, DocsUrl, GithubUrl, WaspUrl } from "../shared/common";
+import { GridFeature } from "./components/FeaturesGrid";
+
+export const landingPageNavigationItems: NavigationItem[] = [
+ { name: 'Features', to: '#features' },
+ { name: 'Documentation', to: DocsUrl },
+ { name: 'Blog', to: BlogUrl },
+ { name: "Features", to: "#features" },
+ { name: "Documentation", to: DocsUrl },
+ { name: "Blog", to: BlogUrl },
+];
+export const features: GridFeature[] = [
+ {
+ 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: 'medium',
+ fullWidthIcon: true,
+ align: 'left',
+ },
+ {
+ name: 'Full-stack Type Safety',
+ description:
+ 'Full support for TypeScript with auto-generated types that span the whole stack. Nothing to configure!',
+ emoji: '🥞',
+ "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: 'medium',
+ },
+ {
+ description: 'File upload examples with AWS S3 presigned URLs are included and fully documented!',
+ icon: <img src={fileupload} alt='File upload illustration' className='w-full h-auto' />,
+ href: DocsUrl + '/guides/file-uploading/',
+ size: 'medium',
+ size: "medium",
+ fullWidthIcon: true,
+ align: "left",
+ },
+ {
+ name: 'Email Sending',
+ name: "Full-stack Type Safety",
+ 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',
+ "Full support for TypeScript with auto-generated types that span the whole stack. Nothing to configure!",
+ emoji: "🥞",
+ href: DocsUrl,
+ size: "medium",
+ },
+ {
+ name: 'Open SaaS',
+ description: 'Try the demo app',
+ icon: <img src={logo} alt='Wasp Logo' />,
+ 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: "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: "Open SaaS",
+ description: "Try the demo app",
+ icon: <img src={logo} alt="Wasp Logo" />,
+ href: routes.LoginRoute.to,
+ size: 'medium',
+ size: "medium",
+ highlight: true,
+ },
+ {
+ name: 'Blog w/ Astro',
+ 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',
+ "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: 'Deploy Anywhere. Easily.',
+ 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',
+ "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!',
+ name: "Complete Documentation & Support",
+ description: "And a Discord community to help!",
+ href: DocsUrl,
+ size: 'small',
+ size: "small",
+ },
+ {
+ name: 'E2E Tests w/ Playwright',
+ description: 'Tests and a CI pipeline w/ GitHub Actions',
+ href: DocsUrl + '/guides/tests/',
+ size: 'small',
+ name: "E2E Tests w/ Playwright",
+ description: "Tests and a CI pipeline w/ GitHub Actions",
+ href: DocsUrl + "/guides/tests/",
+ size: "small",
+ },
+ {
+ name: 'Open-Source Philosophy',
+ 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: '🤝',
+ "The repo and framework are 100% open-source, and so are the services wherever possible. Still missing something? Contribute!",
+ emoji: "🤝",
+ href: DocsUrl,
+ size: 'medium',
+ size: "medium",
+ },
+];
+export const testimonials = [
+ {
+ 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',
+ 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!',
+ "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',
+ 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 🤯!',
+ "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',
+ 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. 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',
+ name: "Tim Skaggs",
+ role: "Founder @ Antler US",
+ avatarSrc:
+ 'https://cdn.discordapp.com/avatars/1003468772251811921/a_c6124fcbee5621d1ad9cca83a102c4a9.png?size=80',
+ socialUrl: 'https://discord.com/channels/686873244791210014/1080864617347162122/1246388561020850188',
+ "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',
+ 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: "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',
+ 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',
+ "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",
+ },
@@ -178,70 +197,70 @@
+export const faqs = [
+ {
+ id: 1,
+ question: 'Why is this SaaS Template free and open-source?',
+ 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.',
+ "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',
+ 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 },
+ { name: "Github", href: GithubUrl },
+ { name: "Documentation", href: DocsUrl },
+ { name: "Blog", href: BlogUrl },
+ ],
+ company: [
+ { name: 'Terms of Service', href: GithubUrl + '/blob/main/LICENSE' },
+ { name: 'Made by the Wasp team = }', href: WaspUrl },
+ { name: "Terms of Service", href: GithubUrl + "/blob/main/LICENSE" },
+ { name: "Made by the Wasp team = }", href: WaspUrl },
+ ],
+};
+export const examples = [
+ {
+ name: 'Microinfluencers',
+ description: 'microinfluencer.club',
+ name: "Microinfluencers",
+ description: "microinfluencer.club",
+ imageSrc: microinfluencerClub,
+ href: 'https://microinfluencer.club',
+ href: "https://microinfluencer.club",
+ },
+ {
+ name: 'Kivo',
+ description: 'kivo.dev',
+ name: "Kivo",
+ description: "kivo.dev",
+ imageSrc: kivo,
+ href: 'https://kivo.dev',
+ href: "https://kivo.dev",
+ },
+ {
+ name: 'Searchcraft',
+ description: 'searchcraft.io',
+ name: "Searchcraft",
+ description: "searchcraft.io",
+ imageSrc: searchcraft,
+ href: 'https://www.searchcraft.io',
+ href: "https://www.searchcraft.io",
+ },
+ {
+ name: 'Scribeist',
+ description: 'scribeist.com',
+ name: "Scribeist",
+ description: "scribeist.com",
+ imageSrc: scribeist,
+ href: 'https://scribeist.com',
+ href: "https://scribeist.com",
+ },
+ {
+ name: 'Messync',
+ description: 'messync.com',
+ name: "Messync",
+ description: "messync.com",
+ imageSrc: messync,
+ href: 'https://messync.com',
+ href: "https://messync.com",
+ },
+ {
+ name: 'Prompt Panda',
+ description: 'promptpanda.io',
+ name: "Prompt Panda",
+ description: "promptpanda.io",
+ imageSrc: promptpanda,
+ href: 'https://promptpanda.io',
+ href: "https://promptpanda.io",
+ },
+ {
+ name: 'Review Radar',
+ description: 'reviewradar.ai',
+ name: "Review Radar",
+ description: "reviewradar.ai",
+ imageSrc: reviewradar,
+ href: 'https://reviewradar.ai',
+ href: "https://reviewradar.ai",
+ },
+];

View File

@@ -1,16 +1,13 @@
--- template/app/src/landing-page/logos/PrismaLogo.tsx
+++ opensaas-sh/app/src/landing-page/logos/PrismaLogo.tsx
@@ -1,11 +1,11 @@
@@ -1,8 +1,8 @@
export default function PrismaLogo() {
return (
- <svg width={48} height={48} viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'>
+ <svg width={32} height={32} viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'>
<path
className='dark:fill-white'
fill='#545454'
d='M25.21,24.21,12.739,27.928a.525.525,0,0,1-.667-.606L16.528,5.811a.43.43,0,0,1,.809-.094l8.249,17.661A.6.6,0,0,1,25.21,24.21Zm2.139-.878L17.8,2.883h0A1.531,1.531,0,0,0,16.491,2a1.513,1.513,0,0,0-1.4.729L4.736,19.648a1.592,1.592,0,0,0,.018,1.7l5.064,7.909a1.628,1.628,0,0,0,1.83.678l14.7-4.383a1.6,1.6,0,0,0,1-2.218Z'
/>
</svg>
- )
+ );
}
<svg
- width={48}
- height={48}
+ width={32}
+ height={32}
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>

View File

@@ -1,14 +1,30 @@
--- template/app/src/landing-page/logos/ReactLogo.tsx
+++ opensaas-sh/app/src/landing-page/logos/ReactLogo.tsx
@@ -0,0 +1,12 @@
@@ -0,0 +1,28 @@
+export default 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)' />
+ <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

View File

@@ -1,31 +1,36 @@
--- template/app/src/landing-page/logos/ShadCNLogo.tsx
+++ opensaas-sh/app/src/landing-page/logos/ShadCNLogo.tsx
@@ -0,0 +1,29 @@
@@ -0,0 +1,34 @@
+export default function ShadCNLogo() {
+ return (
+ <svg width={32} height={32} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'>
+ <rect width='256' height='256' fill='none'></rect>
+ <svg
+ width={32}
+ height={32}
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 256 256"
+ >
+ <rect width="256" height="256" fill="none"></rect>
+ <line
+ x1='208'
+ y1='128'
+ x2='128'
+ y2='208'
+ fill='none'
+ stroke='currentColor'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ strokeWidth='32'
+ x1="208"
+ y1="128"
+ x2="128"
+ y2="208"
+ fill="none"
+ stroke="currentColor"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth="32"
+ ></line>
+ <line
+ x1='192'
+ y1='40'
+ x2='40'
+ y2='192'
+ fill='none'
+ stroke='currentColor'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ strokeWidth='32'
+ x1="192"
+ y1="40"
+ x2="40"
+ y2="192"
+ fill="none"
+ stroke="currentColor"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth="32"
+ ></line>
+ </svg>
+ );

View File

@@ -7,9 +7,9 @@
+
+export function formatNumber(number: number) {
+ if (number >= 1_000_000) {
+ return (number / 1_000_000).toFixed(1) + 'M';
+ return (number / 1_000_000).toFixed(1) + "M";
+ }
+ if (number >= 1_000) {
+ return (number / 1_000).toFixed(1) + 'K';
+ return (number / 1_000).toFixed(1) + "K";
+ }
+}

View File

@@ -1,29 +1,30 @@
--- template/app/src/payment/PricingPage.tsx
+++ opensaas-sh/app/src/payment/PricingPage.tsx
@@ -10,6 +10,7 @@
import { PaymentPlanId, paymentPlans, prettyPaymentPlanName, SubscriptionStatus } from './plans';
@@ -24,6 +24,7 @@
} from "./plans";
const bestDealPaymentPlanId: PaymentPlanId = PaymentPlanId.Pro;
+const PaymentsDocsURL = 'https://docs.opensaas.sh/guides/payments-integration/';
+const PaymentsDocsURL = "https://docs.opensaas.sh/guides/payments-integration/";
interface PaymentPlanCard {
name: string;
@@ -109,8 +110,16 @@
@@ -125,9 +126,16 @@
</h2>
</div>
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-muted-foreground'>
- Choose between Stripe and LemonSqueezy as your payment provider. Just add your Product IDs! Try it
- out below with test credit card number <br />
+ Choose between{' '}
+ <a href={PaymentsDocsURL} target='_blank' rel='noopener noreferrer'>
<p className="text-muted-foreground mx-auto mt-6 max-w-2xl text-center text-lg leading-8">
- Choose between Stripe and LemonSqueezy as your payment provider. Just
- add your Product IDs! Try it out below with test credit card number{" "}
- <br />
+ Choose between{" "}
+ <a href={PaymentsDocsURL} target="_blank" rel="noopener noreferrer">
+ Stripe
+ </a>{' '}
+ and{' '}
+ <a href={PaymentsDocsURL} target='_blank' rel='noopener noreferrer'>
+ </a>{" "}
+ and{" "}
+ <a href={PaymentsDocsURL} target="_blank" rel="noopener noreferrer">
+ LemonSqueezy
+ </a>{' '}
+ as your payment provider. Just add your Product IDs! Try it out below with test credit card number{' '}
+ <br />
<span className='px-2 py-1 bg-muted rounded-md text-muted-foreground font-mono text-sm'>
+ </a>{" "}
+ as your payment provider. Just add your Product IDs! Try it out below
+ with test credit card number <br />
<span className="bg-muted text-muted-foreground rounded-md px-2 py-1 font-mono text-sm">
4242 4242 4242 4242 4242
</span>

View File

@@ -1,14 +1,6 @@
--- template/app/src/payment/paymentProcessor.ts
+++ opensaas-sh/app/src/payment/paymentProcessor.ts
@@ -3,7 +3,6 @@
import type { MiddlewareConfigFn } from 'wasp/server';
import { PrismaClient } from '@prisma/client';
import { stripePaymentProcessor } from './stripe/paymentProcessor';
-import { lemonSqueezyPaymentProcessor } from './lemonSqueezy/paymentProcessor';
export interface CreateCheckoutSessionArgs {
userId: string;
@@ -24,9 +23,4 @@
@@ -27,9 +27,4 @@
webhookMiddlewareConfigFn: MiddlewareConfigFn;
}

View File

@@ -1,11 +1,11 @@
--- template/app/src/payment/stripe/paymentDetails.ts
+++ opensaas-sh/app/src/payment/stripe/paymentDetails.ts
@@ -14,10 +14,10 @@
@@ -20,10 +20,10 @@
) => {
return userDelegate.update({
where: {
- paymentProcessorUserId: userStripeId
+ stripeId: userStripeId
- paymentProcessorUserId: userStripeId,
+ stripeId: userStripeId,
},
data: {
- paymentProcessorUserId: userStripeId,

View File

@@ -1,11 +1,11 @@
--- template/app/src/payment/stripe/paymentProcessor.ts
+++ opensaas-sh/app/src/payment/stripe/paymentProcessor.ts
@@ -20,7 +20,7 @@
id: userId
@@ -32,7 +32,7 @@
id: userId,
},
data: {
- paymentProcessorUserId: customer.id
+ stripeId: customer.id
}
})
if (!stripeSession.url) throw new Error('Error creating Stripe Checkout Session');
- paymentProcessorUserId: customer.id,
+ stripeId: customer.id,
},
});
if (!stripeSession.url)

View File

@@ -1,14 +1,20 @@
--- template/app/src/server/scripts/dbSeeds.ts
+++ opensaas-sh/app/src/server/scripts/dbSeeds.ts
@@ -37,9 +37,11 @@
@@ -45,15 +45,15 @@
isAdmin: false,
credits,
subscriptionStatus,
- lemonSqueezyCustomerPortalUrl: null,
- paymentProcessorUserId: hasUserPaidOnStripe ? `cus_test_${faker.string.uuid()}` : null,
- paymentProcessorUserId: hasUserPaidOnStripe
- ? `cus_test_${faker.string.uuid()}`
- : null,
+ stripeId: hasUserPaidOnStripe ? `cus_test_${faker.string.uuid()}` : null,
datePaid: hasUserPaidOnStripe ? faker.date.between({ from: createdAt, to: timePaid }) : null,
subscriptionPlan: subscriptionStatus ? faker.helpers.arrayElement(getSubscriptionPaymentPlanIds()) : null,
datePaid: hasUserPaidOnStripe
? faker.date.between({ from: createdAt, to: timePaid })
: null,
subscriptionPlan: subscriptionStatus
? faker.helpers.arrayElement(getSubscriptionPaymentPlanIds())
: null,
+ // For the demo app, we want to default isMockUser to true so that our admin dash only shows mock users
+ // and not real users signing up to test the app
+ isMockUser: true,

View File

@@ -1,7 +1,7 @@
--- template/app/src/shared/common.ts
+++ opensaas-sh/app/src/shared/common.ts
@@ -1,2 +1,4 @@
export const DocsUrl = 'https://docs.opensaas.sh';
export const BlogUrl = 'https://docs.opensaas.sh/blog';
+export const GithubUrl = 'https://github.com/wasp-lang/open-saas';
+export const WaspUrl = 'https://wasp.sh';
export const DocsUrl = "https://docs.opensaas.sh";
export const BlogUrl = "https://docs.opensaas.sh/blog";
+export const GithubUrl = "https://github.com/wasp-lang/open-saas";
+export const WaspUrl = "https://wasp.sh";

View File

@@ -1,26 +1,28 @@
--- template/app/src/user/operations.ts
+++ opensaas-sh/app/src/user/operations.ts
@@ -34,10 +34,7 @@
};
@@ -48,12 +48,7 @@
type GetPaginatedUsersOutput = {
- users: Pick<
- User,
- 'id' | 'email' | 'username' | 'subscriptionStatus' | 'paymentProcessorUserId' | 'isAdmin'
- >[];
+ users: Pick<User, 'id' | 'email' | 'username' | 'subscriptionStatus' | 'stripeId' | 'isAdmin'>[];
users: Pick<
User,
- | "id"
- | "email"
- | "username"
- | "subscriptionStatus"
- | "paymentProcessorUserId"
- | "isAdmin"
+ "id" | "email" | "username" | "subscriptionStatus" | "stripeId" | "isAdmin"
>[];
totalPages: number;
};
@@ -85,6 +82,7 @@
mode: 'insensitive',
@@ -118,6 +113,7 @@
mode: "insensitive",
},
isAdmin,
+ isMockUser: true,
},
{
OR: [
@@ -106,7 +104,7 @@
@@ -139,7 +135,7 @@
username: true,
isAdmin: true,
subscriptionStatus: true,
@@ -28,4 +30,4 @@
+ stripeId: true,
},
orderBy: {
username: 'asc',
username: "asc",

View File

@@ -1,13 +1,13 @@
--- template/app/tailwind.config.js
+++ opensaas-sh/app/tailwind.config.js
@@ -254,6 +254,10 @@
7: '-5px 0 0 #313D4A, 5px 0 0 #313D4A',
8: '1px 0 0 #313D4A, -1px 0 0 #313D4A, 0 1px 0 #313D4A, 0 -1px 0 #313D4A, 0 3px 13px rgb(0 0 0 / 8%)',
default: '0px 8px 13px -3px rgba(0, 0, 0, 0.07)',
7: "-5px 0 0 #313D4A, 5px 0 0 #313D4A",
8: "1px 0 0 #313D4A, -1px 0 0 #313D4A, 0 1px 0 #313D4A, 0 -1px 0 #313D4A, 0 3px 13px rgb(0 0 0 / 8%)",
default: "0px 8px 13px -3px rgba(0, 0, 0, 0.07)",
+ outer:
+ '0px 1px 3px 0px hsl(var(--background)/.8) inset, 0px 0.5px 1px 0px hsl(var(--background)/.8) inset, 0px -1px 3px 0px hsl(var(--primary)/.3) inset, 0px -0.5px 1px 0px hsl(var(--primary)/.15) inset, 0px -2px 4.8px 0px hsl(var(--background)/.05), 0px 1px 2px 0px hsl(var(--background)/.1), 0px 2px 4px 0px hsl(var(--background)/.1), 0px 4px 8px 0px hsl(var(--background)/.15);',
+ "0px 1px 3px 0px hsl(var(--background)/.8) inset, 0px 0.5px 1px 0px hsl(var(--background)/.8) inset, 0px -1px 3px 0px hsl(var(--primary)/.3) inset, 0px -0.5px 1px 0px hsl(var(--primary)/.15) inset, 0px -2px 4.8px 0px hsl(var(--background)/.05), 0px 1px 2px 0px hsl(var(--background)/.1), 0px 2px 4px 0px hsl(var(--background)/.1), 0px 4px 8px 0px hsl(var(--background)/.15);",
+ inner:
+ '0px 1px 3px 0px hsl(var(--background)/.8), 0px 0.5px 1px 0px hsl(var(--background)/.5), 0px -1px 3px 0px hsl(var(--primary)/.5), 0px -0.5px 1px 0px hsl(var(--primary)/.5), 0px -1px 4px 0px hsl(var(--background)/.06) inset, 0px -2px 4.8px 0px hsl(var(--background)/.06) inset, 0px 1px 2px 0px hsl(var(--background)/.06) inset, 0px 2px 4px 0px hsl(var(--background)/.06) inset, 0px 4px 8px 0px hsl(var(--background)/.06) inset',
card: '0px 1px 3px rgba(0, 0, 0, 0.12)',
'card-2': '0px 1px 2px rgba(0, 0, 0, 0.05)',
+ "0px 1px 3px 0px hsl(var(--background)/.8), 0px 0.5px 1px 0px hsl(var(--background)/.5), 0px -1px 3px 0px hsl(var(--primary)/.5), 0px -0.5px 1px 0px hsl(var(--primary)/.5), 0px -1px 4px 0px hsl(var(--background)/.06) inset, 0px -2px 4.8px 0px hsl(var(--background)/.06) inset, 0px 1px 2px 0px hsl(var(--background)/.06) inset, 0px 2px 4px 0px hsl(var(--background)/.06) inset, 0px 4px 8px 0px hsl(var(--background)/.06) inset",
card: "0px 1px 3px rgba(0, 0, 0, 0.12)",
"card-2": "0px 1px 2px rgba(0, 0, 0, 0.05)",
switcher: