OpenSaaS Redesign - Add ShadCN, and redesign OpenSaaS.sh landing page (#447)

* add shadcn and shadcn script

* cleanup

* navbar and announcement

* remove unnecessary documentation

* cleanup

* dark mode switcher

* update hero

* update features

* update clients

* update testimonials

* update faq

* add avatar and use it in hero

* update the demo app

* update pricing page

* update file upload page

* update account page

* update dropdown

* fix mobile menu

* use card for testimonials

* update analytics to use new color tokens

* use sheet for mobile nav bar

* update user table

* update settings page to use shadcn components

* update testimonials to use new design

* add section title component

* update components to use section header

* add gradients

* add secondary muted

* add dynamic navbar

* cleanup

* fix color tokens for the dark mode

* don't scale all the cards

* Examples component

* fix the carousel scrolling into view

* add highlighted feature component

* add features grid

* cleanup

* fix navbar announcment sticking

* fix padding

* cleanup

* cleanup

* cleanup

* cleanup

* cleanup

* more robust on mouse leave for examples carousel

* auto scroll threshold set to 1

* generate app diff fo creating opensaassh

* add orbit

* fix mobile layout for Hero

* update template FeaturesGrid, and fix logos location

* Update the title for the features grid on landing

* update testimoinals layout

* update contentSections to support new testimonials

* update examples carousel

* update examples carouselui

* cleanup

* fix navbar layout when Announcement not present

* add highlighted features in examples

* cleanup

* fix faq component

* fix testimonials UI

* cleanup

* cleanup

* update contentSections

* update the ui limits

* make highlighted feature items centered

* remove inconsistent styles

* remove legacy classnames

* standardize chart cards

* fix dark mode ui issue with dropdown

* make it so that you can't change your own admin status

* fix changes with filtering on user table

* make filtering more intuitive

* fix calendar visibility issues

* remove forms pages

* use type tel for phone number

* button page redo

* clean up breadrcrumb and remove comments from default layout

* clean up navbar

* throttle scroll for navbar

* clean up remaining template items

* clean up

* fix the examples carousel so that it automatically scrolls

* fix demo app page

* fix FeaturesGrid types

* fix highlighted feature

* fix section title

* Replace old icons

* Remove package-lock.json

* fix opensaas issues

* remove all legacy icons

* remove accidental package files

* update throttleWithTrailingInvocation

* refactor the FeaturesGrid

* clean up highlithted feature

* clean up highlithted feature

* fix behavior of ExamplesCarousel

* clean up ExamplesCarousel

* fix wrong copy and layout on the landing for opensaas.sh

* center examples on the page if there are not enough of them

* Fix layout of pricing

* fix wrong link

* color of sidebar on admin

* add new examples

* fix icon layout

* adds specific tokens for opensaas landing

* layout fix

* remove leftover custom svgs

* update layout to better match figma

* use kebab-case for forms

* revert to h3

* png -> webp

* Update layout for bento grid for opensaas

* parse day views only once

* dropdown edit delete to shadcn

* remove the remaining svgs

* make useDebounce generic

* address small pr comments

* update examples carousel

* remove the banner from the hero

* section title subtitle → description

* remove unnecessary files

* address layout comments for opensaas.sh

* add back git star count

* update contentSections to have currently deployed copy

* useRef instaed of class query

* address layout comments for opensaas.sh

* adjust padding for RepoInfo on diff

* fix gradient on template

* Revert "fix gradient on template"

This reverts commit 4b45f7f437.

* fix gradient on template

* add AI Ready highlighted feature

* update landing page features & add LLM copy button and

Introduced a 'Copy URL for LLMs' button to the blog navbar by creating CopyForLlmButton.astro and integrating it into a new MyRightNavBarItems.astro component, replacing the previous theme select. Updated astro.config.mjs to use the new component. In the app template, added an example highlighted feature component to the landing page and updated testimonial avatars. Also enabled Google, GitHub, Discord, and Slack auth providers in main.wasp.

* Update NavBar announcement for Product Hunt launch

Refactored the Announcement component in NavBar to promote the Open SaaS v2.0 Product Hunt launch, including dynamic messaging based on launch date and updated links. Also updated the landing page to import and render new example components.

---------

Co-authored-by: vincanger <70215737+vincanger@users.noreply.github.com>
This commit is contained in:
Fran
2025-07-28 18:29:22 +02:00
committed by GitHub
parent 318047fa8c
commit 2caac6c1de
158 changed files with 6345 additions and 4772 deletions

View File

@@ -1,91 +0,0 @@
--- template/app/src/admin/dashboards/analytics/AnalyticsDashboardPage.tsx
+++ opensaas-sh/app/src/admin/dashboards/analytics/AnalyticsDashboardPage.tsx
@@ -1,4 +1,6 @@
+import { Link } from 'wasp/client/router';
import { type AuthUser } from 'wasp/auth';
+import { useState, useEffect, useMemo } from 'react';
import { useQuery, getDailyStats } from 'wasp/client/operations';
import TotalSignupsCard from './TotalSignupsCard';
import TotalPageViewsCard from './TotalPageViewsCard';
@@ -11,16 +13,58 @@
import { cn } from '../../../client/cn';
const Dashboard = ({ user }: { user: AuthUser }) => {
+ const [isDemoInfoVisible, setIsDemoInfoVisible] = useState(false);
useRedirectHomeUnlessUserIsAdmin({ user });
const { data: stats, isLoading, error } = useQuery(getDailyStats);
+ const didUserCloseDemoInfo = localStorage.getItem('didUserCloseDemoInfo') === 'true';
+
+ useEffect(() => {
+ if (didUserCloseDemoInfo || !stats) {
+ setIsDemoInfoVisible(false);
+ } else if (!didUserCloseDemoInfo && stats) {
+ setIsDemoInfoVisible(true);
+ }
+ }, [stats]);
+
+ const handleDemoInfoClose = () => {
+ try {
+ localStorage.setItem('didUserCloseDemoInfo', 'true');
+ setIsDemoInfoVisible(false);
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ const sortedSources = useMemo(() => {
+ return stats?.dailyStats?.sources?.slice().sort((a, b) => b.visitors - a.visitors);
+ }, [stats?.dailyStats?.sources]);
+
return (
<DefaultLayout user={user}>
+ {isDemoInfoVisible && (
+ <div className='fixed z-999 bottom-0 mb-2 left-1/2 -translate-x-1/2 lg:mb-4 bg-gray-700 rounded-full px-3.5 py-2 text-sm text-white duration-300 ease-in-out hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-indigo-600'>
+ <div className='px-4 flex flex-row gap-2 items-center my-1'>
+ <span className='text-gray-100 text-center'>
+ This is actual data from Stripe test purchases. <br /> Try out purchasing a{' '}
+ <Link to='/pricing' className='underline text-yellow-400'>
+ test product
+ </Link>
+ !
+ </span>
+ <button className=' pl-2.5 text-gray-400 text-xl font-bold' onClick={() => handleDemoInfoClose()}>
+ X
+ </button>
+ </div>
+ </div>
+ )}
<div className='relative'>
- <div className={cn({
- 'opacity-25': !stats,
- })}>
+ <div
+ className={cn({
+ 'opacity-25': !stats,
+ })}
+ >
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 xl:grid-cols-4 2xl:gap-7.5'>
<TotalPageViewsCard
totalPageViews={stats?.dailyStats.totalViews}
@@ -39,7 +83,7 @@
<RevenueAndProfitChart weeklyStats={stats?.weeklyStats} isLoading={isLoading} />
<div className='col-span-12 xl:col-span-8'>
- <SourcesTable sources={stats?.dailyStats?.sources} />
+ <SourcesTable sources={sortedSources} />
</div>
</div>
</div>
@@ -47,9 +91,7 @@
{!stats && (
<div className='absolute inset-0 flex items-start justify-center bg-white/50 dark:bg-boxdark-2/50'>
<div className='rounded-lg bg-white p-8 shadow-lg dark:bg-boxdark'>
- <p className='text-2xl font-bold text-boxdark dark:text-white'>
- No daily stats generated yet
- </p>
+ <p className='text-2xl font-bold text-boxdark dark:text-white'>No daily stats generated yet</p>
<p className='mt-2 text-sm text-bodydark2'>
Stats will appear here once the daily stats job has run
</p>

View File

@@ -1,54 +0,0 @@
--- template/app/src/admin/dashboards/users/UsersDashboardPage.tsx
+++ opensaas-sh/app/src/admin/dashboards/users/UsersDashboardPage.tsx
@@ -1,14 +1,50 @@
import { type AuthUser } from 'wasp/auth';
+import { useState, useEffect } from 'react';
import UsersTable from './UsersTable';
import Breadcrumb from '../../layout/Breadcrumb';
import DefaultLayout from '../../layout/DefaultLayout';
import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin';
const Users = ({ user }: { user: AuthUser }) => {
- useRedirectHomeUnlessUserIsAdmin({user})
+ const [isDemoInfoVisible, setIsDemoInfoVisible] = useState(false);
+ useRedirectHomeUnlessUserIsAdmin({user});
+
+ useEffect(() => {
+ try {
+ if (localStorage.getItem('isDemoInfoVisible') === 'false') {
+ // do nothing
+ } else {
+ setIsDemoInfoVisible(true);
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ }, []);
+
+ const handleDemoInfoClose = () => {
+ try {
+ localStorage.setItem('isDemoInfoVisible', 'false');
+ setIsDemoInfoVisible(false);
+ } catch (error) {
+ console.error(error);
+ }
+ };
return (
<DefaultLayout user={user}>
+ {/* Floating Demo Announcement */}
+ {isDemoInfoVisible && (
+ <div className='fixed z-999 bottom-0 mb-2 left-1/2 -translate-x-1/2 lg:mb-4 bg-gray-700 rounded-full px-3.5 py-2 text-sm text-white duration-300 ease-in-out hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-indigo-600'>
+ <div className='px-4 flex flex-row gap-2 items-center my-1'>
+ <span className='text-gray-100'>
+ You are viewing mock user data only ;)
+ </span>
+ <button className=' pl-2.5 text-gray-400 text-xl font-bold' onClick={() => handleDemoInfoClose()}>
+ X
+ </button>
+ </div>
+ </div>
+ )}
<Breadcrumb pageName='Users' />
<div className='flex flex-col gap-10'>
<UsersTable />

View File

@@ -1,11 +1,11 @@
--- template/app/src/admin/dashboards/users/UsersTable.tsx
+++ opensaas-sh/app/src/admin/dashboards/users/UsersTable.tsx
@@ -207,7 +207,7 @@
<p className='text-sm text-black dark:text-white'>{user.subscriptionStatus}</p>
@@ -254,7 +254,7 @@
<p className='text-sm text-foreground'>{user.subscriptionStatus}</p>
</div>
<div className='col-span-2 flex items-center'>
- <p className='text-sm text-meta-3'>{user.paymentProcessorUserId}</p>
+ <p className='text-sm text-meta-3'>{user.stripeId}</p>
- <p className='text-sm text-muted-foreground'>{user.paymentProcessorUserId}</p>
+ <p className='text-sm text-muted-foreground'>{user.subscriptionStatus}</p>
</div>
<div className='col-span-1 flex items-center'>
<div className='text-sm text-black dark:text-white'>
<div className='text-sm text-foreground'>

View File

@@ -1,19 +0,0 @@
--- template/app/src/auth/LoginPage.tsx
+++ opensaas-sh/app/src/auth/LoginPage.tsx
@@ -1,8 +1,15 @@
+import { Navigate } from 'react-router-dom';
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
-import { LoginForm } from 'wasp/client/auth';
+import { LoginForm, useAuth } from 'wasp/client/auth';
import { AuthPageLayout } from './AuthPageLayout';
export default function Login() {
+ const { data: user } = useAuth();
+
+ if (user) {
+ return <Navigate to='/demo-app' />;
+ }
+
return (
<AuthPageLayout>
<LoginForm />

View File

@@ -0,0 +1,8 @@
--- 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,8 +1,6 @@
import { z } from 'zod';
@@ -1,7 +1,5 @@
-import { z } from 'zod';
import { defineUserSignupFields } from 'wasp/auth/providers/types';
-const adminEmails = process.env.ADMIN_EMAILS?.split(',') || [];
-
-const adminEmails = process.env.ADMIN_EMAILS?.split(',') || [];
+import { z } from 'zod';
const emailDataSchema = z.object({
email: z.string(),
});
@@ -16,10 +14,6 @@
const emailData = emailDataSchema.parse(data);
return emailData.email;

View File

@@ -0,0 +1,84 @@
--- template/app/src/client/Main.css
+++ opensaas-sh/app/src/client/Main.css
@@ -44,6 +44,15 @@
.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%);
+ }
+
+ .dark .bg-radial-gradient {
+ 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 @@
* 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-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-display: swap;
}
+@font-face {
+ font-family: 'Satoshi';
+ src: url('/fonts/Satoshi-Medium.woff2') format('woff2');
+ font-weight: 500;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: 'Satoshi';
+ src: url('/fonts/Satoshi-Bold.woff2') format('woff2');
+ font-weight: bold;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: 'Satoshi';
+ src: url('/fonts/Satoshi-Black.woff2') format('woff2');
+ font-weight: 900;
+ font-style: normal;
+ font-display: swap;
+}
+
/* third-party libraries CSS */
.tableCheckbox:checked ~ div span {
@@ -177,4 +220,17 @@
body {
@apply bg-background text-foreground;
}
+
+ /* Global typography styles */
+ h1, h2, h3, h4, h5, h6 {
+ @apply font-satoshi font-black sm:text-6xl leading-tight;
+ }
+
+ p {
+ @apply font-mono text-base leading-relaxed;
+ }
+}
+
+.navbar-maxwidth-transition {
+ transition: max-width 300ms cubic-bezier(0.4,0,0.2,1);
}

View File

@@ -1,95 +1,168 @@
--- template/app/src/client/components/NavBar/NavBar.tsx
+++ opensaas-sh/app/src/client/components/NavBar/NavBar.tsx
@@ -32,7 +32,7 @@
!isLandingPage,
})}
>
- {isLandingPage && <Announcement />}
+ {/* {isLandingPage && <Announcement />} */}
<nav className='flex items-center justify-between p-6 lg:px-8' aria-label='Global'>
<div className='flex items-center lg:flex-1'>
<WaspRouterLink
@@ -40,9 +40,7 @@
className='flex items-center -m-1.5 p-1.5 text-gray-900 duration-300 ease-in-out hover:text-yellow-500'
>
<NavLogo />
- {isLandingPage && (
- <span className='ml-2 text-sm font-semibold leading-6 dark:text-white'>Your SaaS</span>
- )}
+ {isLandingPage && <span className='ml-2 text-sm font-semibold leading-6 dark:text-white'>Open SaaS</span>}
</WaspRouterLink>
</div>
<div className='flex lg:hidden'>
@@ -57,13 +55,13 @@
</div>
<div className='hidden lg:flex lg:gap-x-12'>{renderNavigationItems(navigationItems)}</div>
<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'>
+ <ul className='ml-4 flex justify-center items-center gap-2 sm:gap-4'>
<DarkModeSwitcher />
</ul>
{isUserLoading ? null : !user ? (
<WaspRouterLink to={routes.LoginRoute.to} className='text-sm font-semibold leading-6 ml-3'>
- <div className='flex items-center duration-300 ease-in-out text-gray-900 hover:text-yellow-500 dark:text-white'>
- Log in <BiLogIn size='1.1rem' className='ml-1 mt-[0.1rem]' />
+ <div className='flex justify-end items-center duration-300 ease-in-out text-gray-900 hover:text-yellow-500 dark:text-white test-sm'>
+ Try the demo App <BiLogIn size='1.1rem' className='ml-1' />
</div>
</WaspRouterLink>
) : (
@@ -78,7 +76,7 @@
<Dialog.Panel className='fixed inset-y-0 right-0 z-50 w-full overflow-y-auto bg-white dark:text-white dark:bg-boxdark px-6 py-6 sm:max-w-sm sm:ring-1 sm:ring-gray-900/10'>
<div className='flex items-center justify-between'>
<WaspRouterLink to={routes.LandingPageRoute.to} className='-m-1.5 p-1.5'>
- <span className='sr-only'>Your SaaS</span>
+ <span className='sr-only'>Open SaaS</span>
<NavLogo />
</WaspRouterLink>
<button
@@ -97,7 +95,7 @@
{isUserLoading ? null : !user ? (
<WaspRouterLink to={routes.LoginRoute.to}>
<div className='flex justify-end items-center duration-300 ease-in-out text-gray-900 hover:text-yellow-500 dark:text-white'>
- Log in <BiLogIn size='1.1rem' className='ml-1' />
+ Try the Demo App{' '} <BiLogIn size='1.1rem' className='ml-1' />
</div>
</WaspRouterLink>
) : (
@@ -140,30 +138,26 @@
});
}
@@ -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 DropdownUser from '../../../user/DropdownUser';
import { UserMenuItems } from '../../../user/UserMenuItems';
@@ -10,6 +11,7 @@
import { useIsLandingPage } from '../../hooks/useIsLandingPage';
import logo from '../../static/logo.webp';
import DarkModeSwitcher from '../DarkModeSwitcher';
+import RepoInfo from '../RepoInfo';
-const ContestURL = 'https://github.com/wasp-lang/wasp';
+const ContestURL =
+ 'https://docs.opensaas.sh/blog/';
export interface NavigationItem {
name: string;
@@ -39,7 +41,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':
@@ -49,7 +56,7 @@
>
<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'
@@ -67,7 +74,7 @@
'ml-2 text-xs': isScrolled,
})}
>
- Your SaaS
+ Open SaaS
</span>
)}
</WaspRouterLink>
@@ -99,7 +106,7 @@
<SheetHeader>
<SheetTitle className='flex items-center'>
<WaspRouterLink to={routes.LandingPageRoute.to}>
- <span className='sr-only'>Your SaaS</span>
+ <span className='sr-only'>Open SaaS</span>
<NavLogo isScrolled={false} />
</WaspRouterLink>
</SheetTitle>
@@ -112,9 +119,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>
+ <Button variant='outline'>
+ <span>Demo App</span> <LogIn className='ml-1' />
+ </Button>
</WaspRouterLink>
) : (
<div className='space-y-2'>
@@ -123,7 +130,14 @@
)}
</div>
<div className='py-6'>
- <DarkModeSwitcher />
+ <ul className='flex items-center justify-between gap-4'>
+ <li>
+ <DarkModeSwitcher />
+ </li>
+ <li>
+ <RepoInfo />
+ </li>
+ </ul>
</div>
</div>
</div>
@@ -133,7 +147,12 @@
<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'>
- <DarkModeSwitcher />
+ <li>
+ <RepoInfo />
+ </li>
+ <li>
+ <DarkModeSwitcher />
+ </li>
</ul>
{isUserLoading ? null : !user ? (
<WaspRouterLink
@@ -143,10 +162,10 @@
'text-xs': isScrolled,
})}
>
- <div className='flex items-center duration-300 ease-in-out text-foreground hover:text-primary transition-colors'>
- Log in{' '}
+ <div className='flex items-center duration-300 gap-1 ease-in-out text-foreground hover:text-primary transition-colors'>
+ <span>Demo App</span>
<LogIn
- size={isScrolled ? '1rem' : '1.1rem'}
+ size='1rem'
className={cn('transition-all duration-300', {
'ml-1 mt-[0.1rem]': !isScrolled,
'ml-1': isScrolled,
@@ -246,39 +265,40 @@
'size-6': isScrolled,
})}
src={logo}
- alt='Your SaaS App'
+ alt='Open SaaS App'
/>
);
-const announcementUrl = 'https://github.com/wasp-lang/wasp';
+const announcementUrl = 'https://www.producthunt.com/products/open-saas'
function Announcement() {
+ const launchDate = new Date('2025-07-29T00:00:00-07:00'); // July 29, 2025 PST
+ const today = new Date();
+ const hasLaunched = today.toISOString().slice(0, 10) >= launchDate.toISOString().slice(0, 10);
+
return (
<div className='flex justify-center items-center gap-3 p-3 w-full bg-gradient-to-r from-[#d946ef] to-[#fc0] font-semibold text-white text-center z-49'>
- <p
- onClick={() => window.open(ContestURL, '_blank')}
- className='hidden lg:block cursor-pointer hover:opacity-90 hover:drop-shadow'
- >
- <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 z-[51]'>
+ <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]'>
<a
href={announcementUrl}
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'
>
- Support Open-Source Software!
- </p>
+ <p onClick={() => window.open(ContestURL, '_blank')} className='hidden lg:block cursor-pointer hover:opacity-90 hover:drop-shadow'>🍪 THE MOST ANNOYING COOKIE BANNER EVER HACKATHON 🤬</p>
<div className='hidden lg:block self-stretch w-0.5 bg-white'></div>
<div
onClick={() => window.open(ContestURL, '_blank')}
className='hidden lg:block cursor-pointer rounded-full bg-neutral-700 px-2.5 py-1 text-xs hover:bg-neutral-600 tracking-wider'
+ 🚀 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>
<a
href={announcementUrl}
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'
>
- Star Our Repo on Github ⭐️ →
+ Enter here and win prizes!
</div>
<div
onClick={() => window.open(ContestURL, '_blank')}
className='lg:hidden cursor-pointer rounded-full bg-neutral-700 px-2.5 py-1 text-xs hover:bg-neutral-600 tracking-wider'
+ {hasLaunched ? 'Check out the Launch 🎉' : 'Get notified! 📆'}
</a>
<a
href={announcementUrl}
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'
>
- ⭐️ Star the Our Repo on Github and Support Open-Source! ⭐️
+ 🍪 The Most Annoying Cookie Banner Contest! 🤬 →
</div>
- ⭐️ Star the Our Repo and Support Open-Source! ⭐️
+ 🎉 The Open SaaS v2.0 Launch is Live! 🚀
</a>
</div>
);
-}
+}
\ No newline at end of file

View File

@@ -0,0 +1,43 @@
--- 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';
+
+const RepoInfo = () => {
+ const [repoInfo, setRepoInfo] = useState<null | any>(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchRepoInfo = async () => {
+ try {
+ setIsLoading(true);
+ const response = await fetch('https://api.github.com/repos/wasp-lang/open-saas');
+ const data = await response.json();
+ setRepoInfo(data);
+ } catch (error) {
+ console.error('Error fetching repo info', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchRepoInfo();
+ }, []);
+
+ if (isLoading || !repoInfo) {
+ return null;
+ }
+
+ return (
+ <a href='https://github.com/wasp-lang/open-saas' target='_blank' rel='noopener noreferrer'>
+ <Button variant='ghost' className='rounded-full py-0 pl-2 pr-3 h-8'>
+ <FaGithub />
+ <span className='text-sm leading-none'>{formatNumber(repoInfo.stargazers_count)}</span>
+ </Button>
+ </a>
+ );
+};
+
+export default RepoInfo;

View File

@@ -1,13 +0,0 @@
--- template/app/src/client/components/cookie-consent/Config.ts
+++ opensaas-sh/app/src/client/components/cookie-consent/Config.ts
@@ -97,8 +97,8 @@
// showPreferencesBtn: 'Manage Individual preferences', // (OPTIONAL) Activates the preferences modal
// TODO: Add your own privacy policy and terms and conditions links below.
footer: `
- <a href="<your-url-here>" target="_blank">Privacy Policy</a>
- <a href="<your-url-here>" target="_blank">Terms and Conditions</a>
+ <a>Privacy Policy</a>
+ <a>Terms and Conditions</a>
`,
},
// The showPreferencesBtn activates this modal to manage individual preferences https://cookieconsent.orestbida.com/reference/configuration-reference.html#translation-preferencesmodal

View File

@@ -0,0 +1,32 @@
--- template/app/src/components/ui/button.tsx
+++ opensaas-sh/app/src/components/ui/button.tsx
@@ -5,22 +5,26 @@
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',
{
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',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
+ iconLg: 'h-12 w-12',
},
},
defaultVariants: {

View File

@@ -0,0 +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',
+ 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',
},
},
});

View File

@@ -0,0 +1,9 @@
--- 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,5 +1,23 @@
--- 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 @@
userId: context.user.id,
});

View File

@@ -0,0 +1,15 @@
--- 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

@@ -0,0 +1,24 @@
--- 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';
export default function LandingPage() {
@@ -13,6 +13,9 @@
<main className='isolate'>
<Hero />
<ExamplesCarousel examples={examples} />
+ <Auth />
+ <Payments />
+ <Admin />
<AIReady />
<FeaturesGrid features={features} />
<Testimonials testimonials={testimonials} />

View File

@@ -1,78 +0,0 @@
--- template/app/src/landing-page/components/Clients.tsx
+++ opensaas-sh/app/src/landing-page/components/Clients.tsx
@@ -1,23 +1,64 @@
+import logo from '../../client/static/logo.webp';
import AstroLogo from "../logos/AstroLogo";
-import OpenAILogo from "../logos/OpenAILogo";
import PrismaLogo from "../logos/PrismaLogo";
-import SalesforceLogo from "../logos/SalesforceLogo";
+import OpenAILogo from "../logos/OpenAILogo";
export default function Clients() {
return (
<div className='mt-12 mx-auto max-w-7xl px-6 lg:px-8 flex flex-col items-between gap-y-6'>
- <h2 className='mb-6 text-center font-semibold tracking-wide text-gray-500 dark:text-white'>
- Built with / Used by:
+ <h2 className='mb-6 text-center font-semibold tracking-wide text-gray-500'>
+ Built and Ships with
</h2>
<div className='mx-auto grid max-w-lg grid-cols-2 items-center gap-x-8 gap-y-12 sm:max-w-xl md:grid-cols-4 sm:gap-x-10 sm:gap-y-14 lg:mx-0 lg:max-w-none'>
- {
- [<SalesforceLogo />, <PrismaLogo />, <AstroLogo />, <OpenAILogo />].map((logo, index) => (
- <div key={index} className='flex justify-center col-span-1 max-h-12 w-full object-contain dark:opacity-80'>
- {logo}
- </div>
- ))
- }
+
+ <img
+ className='col-span-1 max-h-12 w-full object-contain grayscale opacity-100'
+ src='https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/512px-React-icon.svg.png'
+ alt='React'
+ height={48}
+ />
+
+ <img
+ className='col-span-1 max-h-12 w-full object-contain grayscale opacity-60 dark:filter dark:brightness-0 dark:invert'
+ src='https://upload.wikimedia.org/wikipedia/commons/thumb/d/d9/Node.js_logo.svg/590px-Node.js_logo.svg.png'
+ alt='NodeJS'
+ height={48}
+ />
+
+ <img
+ className='col-span-1 max-h-12 w-full object-contain grayscale opacity-80'
+ src={logo}
+ alt='Wasp'
+ height={48}
+ />
+
+ <div className='flex justify-center col-span-1 max-h-12 w-full object-contain grayscale opacity-80'>
+ <PrismaLogo />
+ </div>
+
+ <img
+ className='col-span-1 max-h-12 w-full object-contain grayscale '
+ src='https://upload.wikimedia.org/wikipedia/commons/thumb/d/d5/Tailwind_CSS_Logo.svg/512px-Tailwind_CSS_Logo.svg.png'
+ alt='Tailwind CSS'
+ height={48}
+ />
+
+ <img
+ className='col-span-1 max-h-12 w-full object-contain grayscale opacity-80 dark:opacity-60 dark:filter dark:brightness-0 dark:invert'
+ src='https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Stripe_Logo%2C_revised_2016.svg/512px-Stripe_Logo%2C_revised_2016.svg.png'
+ alt='Stripe'
+ height={48}
+ />
+
+ <div className='flex justify-center col-span-1 w-full max-h-12 object-contain grayscale opacity-75'>
+ <AstroLogo />
+ </div>
+
+ <div className='flex justify-center col-span-1 w-full max-h-12 object-contain grayscale opacity-80'>
+ <OpenAILogo />
+ </div>
+
</div>
</div>
)

View File

@@ -0,0 +1,26 @@
--- template/app/src/landing-page/components/Examples/AIReady.tsx
+++ opensaas-sh/app/src/landing-page/components/Examples/AIReady.tsx
@@ -0,0 +1,23 @@
+import aiReadyDark from '../../../client/static/assets/aiready-dark.webp';
+import aiReady from '../../../client/static/assets/aiready.webp';
+import HighlightedFeature from '../HighlightedFeature';
+
+export 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.'
+ highlightedComponent={<AIReadyExample />}
+ 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>
+ );
+};

View File

@@ -0,0 +1,23 @@
--- template/app/src/landing-page/components/Examples/Admin.tsx
+++ opensaas-sh/app/src/landing-page/components/Examples/Admin.tsx
@@ -0,0 +1,20 @@
+import admin from '../../../client/static/assets/admin.webp';
+import HighlightedFeature from '../HighlightedFeature';
+
+export default function Admin() {
+ return (
+ <HighlightedFeature
+ name='Admin Dashboard'
+ description='Graphs! Tables! Analytics w/ Plausible or Google! All in one place. Ooooooooh.'
+ highlightedComponent={<AdminExample />}
+ />
+ );
+}
+
+const AdminExample = () => {
+ return (
+ <div className='w-full'>
+ <img src={admin} alt='Admin' />
+ </div>
+ );
+};

View File

@@ -0,0 +1,133 @@
--- 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';
+
+const SupportedAuthProviders = ['google', 'github', 'discord', 'slack'] as const;
+type AuthProvider = (typeof SupportedAuthProviders)[number];
+
+const providers: { name: AuthProvider; icon: React.ComponentType<{ className?: string }> }[] = [
+ {
+ name: 'google',
+ icon: FaGoogle,
+ },
+ {
+ name: 'github',
+ icon: FaGithub,
+ },
+ {
+ name: 'discord',
+ icon: FaDiscord,
+ },
+ {
+ name: 'slack',
+ icon: FaSlack,
+ },
+];
+
+export default function Auth() {
+ const [selectedProviders, setSelectedProviders] = useState<AuthProvider[]>(['google', 'github']);
+
+ const toggleProvider = (provider: AuthProvider) => {
+ setSelectedProviders((prev) =>
+ 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]'
+ />
+ );
+}
+
+const AuthPlayground = ({
+ toggleProvider,
+ selectedProviders,
+}: {
+ toggleProvider: (provider: AuthProvider) => void;
+ 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'>
+ {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'}
+ >
+ <IconComponent />
+ </Button>
+ );
+ })}
+ </div>
+ </div>
+ );
+};
+
+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>
+ );
+};
+
+const AuthExample = ({ selectedProviders }: { selectedProviders: AuthProvider[] }) => {
+ return (
+ <Card className='py-10 w-full max-w-md transition-all duration-300 ease-in-out' variant='outer'>
+ <CardHeader>
+ <p className='text-2xl font-bold'>Log in to your account</p>
+ </CardHeader>
+ <CardContent>
+ <div className='flex flex-col gap-4'>
+ <div className='flex flex-col'>
+ {selectedProviders.length > 0 ? <p className='text-xs mb-2'>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/'}
+ key={provider}
+ className='w-full mb-2'
+ target='_blank'
+ >
+ <Button variant='outline' className='w-full'>
+ {IconComponent && <IconComponent className='w-4 h-4' />}
+ </Button>
+ </a>
+ );
+ })}
+ </div>
+ </div>
+ {selectedProviders.length > 0 ? <Divider /> : null}
+ <div className='flex flex-col gap-2'>
+ <Input type='email' placeholder='Email' />
+ <Input type='password' placeholder='Password' />
+ <Button className='mt-2'>Log in</Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
+};

View File

@@ -0,0 +1,24 @@
--- template/app/src/landing-page/components/Examples/Payments.tsx
+++ opensaas-sh/app/src/landing-page/components/Examples/Payments.tsx
@@ -0,0 +1,21 @@
+import payments from '../../../client/static/assets/payments.webp';
+import HighlightedFeature from '../HighlightedFeature';
+
+export default function Payments() {
+ return (
+ <HighlightedFeature
+ 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'
+ />
+ );
+}
+
+const PaymentsExample = () => {
+ return (
+ <div className='w-full max-w-lg'>
+ <img src={payments} alt='Payments' />
+ </div>
+ );
+};

View File

@@ -0,0 +1,7 @@
--- template/app/src/landing-page/components/Examples/index.ts
+++ opensaas-sh/app/src/landing-page/components/Examples/index.ts
@@ -0,0 +1,4 @@
+export { 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

@@ -0,0 +1,11 @@
--- template/app/src/landing-page/components/ExamplesCarousel.tsx
+++ opensaas-sh/app/src/landing-page/components/ExamplesCarousel.tsx
@@ -100,7 +100,7 @@
ref={containerRef}
className='relative w-screen left-1/2 -translate-x-1/2 flex flex-col items-center my-16'
>
- <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'

View File

@@ -1,28 +0,0 @@
--- template/app/src/landing-page/components/FAQ.tsx
+++ opensaas-sh/app/src/landing-page/components/FAQ.tsx
@@ -7,20 +7,20 @@
export default function FAQ({ faqs }: { faqs: FAQ[] }) {
return (
- <div className='mt-32 mx-auto max-w-2xl divide-y divide-gray-900/10 dark:divide-gray-200/10 px-6 pb-8 sm:pb-24 sm:pt-12 lg:max-w-7xl lg:px-8 lg:py-32'>
+ <div className='mt-32 mx-auto max-w-2xl divide-y divide-gray-900/10 px-6 pb-8 sm:pb-24 sm:pt-12 lg:max-w-7xl lg:px-8 lg:py-32'>
<h2 className='text-2xl font-bold leading-10 tracking-tight text-gray-900 dark:text-white'>
Frequently asked questions
</h2>
- <dl className='mt-10 space-y-8 divide-y divide-gray-900/10'>
+ <dl className='mt-10 space-y-8 divide-y divide-gray-900/10 dark:divide-gray-100/10'>
{faqs.map((faq) => (
<div key={faq.id} className='pt-8 lg:grid lg:grid-cols-12 lg:gap-8'>
<dt className='text-base font-semibold leading-7 text-gray-900 lg:col-span-5 dark:text-white'>
{faq.question}
</dt>
- <dd className='flex items-center justify-start gap-2 mt-4 lg:col-span-7 lg:mt-0'>
- <p className='text-base leading-7 text-gray-600 dark:text-white'>{faq.answer}</p>
+ <dd className='mt-4 lg:col-span-7 lg:mt-0'>
+ <p className='text-base leading-7 text-gray-600 dark:text-gray-400'>{faq.answer}</p>
{faq.href && (
- <a href={faq.href} className='text-base leading-7 text-yellow-500 hover:text-yellow-600'>
+ <a href={faq.href} className='mt-4 text-base leading-7 text-yellow-500 hover:text-yellow-600'>
Learn more →
</a>
)}

View File

@@ -1,43 +0,0 @@
--- template/app/src/landing-page/components/Features.tsx
+++ opensaas-sh/app/src/landing-page/components/Features.tsx
@@ -10,25 +10,27 @@
<div id='features' className='mx-auto mt-48 max-w-7xl px-6 lg:px-8'>
<div className='mx-auto max-w-2xl text-center'>
<p className='mt-2 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl dark:text-white'>
- The <span className='text-yellow-500'>Best</span> Features
+ <span className='text-yellow-500'>100%</span> Open-Source
</p>
- <p className='mt-6 text-lg leading-8 text-gray-600 dark:text-white'>
- Don't work harder.
- <br /> Work smarter.
+ <p className='mt-6 text-lg leading-8 text-gray-600 dark:text-gray-400'>
+ No vendor lock-in.
+ <br /> Deploy anywhere.
</p>
</div>
<div className='mx-auto mt-16 max-w-2xl sm:mt-20 lg:mt-24 lg:max-w-4xl'>
<dl className='grid max-w-xl grid-cols-1 gap-x-8 gap-y-10 lg:max-w-none lg:grid-cols-2 lg:gap-y-16'>
{features.map((feature) => (
- <div key={feature.name} className='relative pl-16'>
- <dt className='text-base font-semibold leading-7 text-gray-900 dark:text-white'>
- <div className='absolute left-0 top-0 flex h-10 w-10 items-center justify-center border border-yellow-400 bg-yellow-100/50 dark:bg-boxdark rounded-lg'>
- <div className='text-2xl'>{feature.icon}</div>
- </div>
- {feature.name}
- </dt>
- <dd className='mt-2 text-base leading-7 text-gray-600 dark:text-white'>{feature.description}</dd>
- </div>
+ <a href={feature.href} className='group'>
+ <div key={feature.name} className='relative pl-16'>
+ <dt className='text-base font-semibold leading-7 text-gray-900 dark:text-white group-hover:underline'>
+ <div className='absolute left-0 top-0 flex h-10 w-10 items-center justify-center border border-yellow-400 bg-yellow-100/50 dark:bg-boxdark rounded-lg group-hover:border-yellow-500'>
+ <div className='text-2xl group-hover:opacity-80 '>{feature.icon}</div>
+ </div>
+ {feature.name}
+ </dt>
+ <dd className='mt-2 text-base leading-7 text-gray-600 dark:text-gray-400'>{feature.description}</dd>
+ </div>
+ </a>
))}
</dl>
</div>

View File

@@ -0,0 +1,153 @@
--- 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';
-export interface GridFeature extends Omit<Feature, 'icon'> {
+export interface GridFeature extends Omit<Feature, 'icon' | 'name'> {
+ name?: string;
icon?: React.ReactNode;
emoji?: string;
direction?: 'col' | 'row' | 'col-reverse' | 'row-reverse';
align?: 'center' | 'left';
size: 'small' | 'medium' | 'large';
fullWidthIcon?: boolean;
+ highlight?: boolean;
}
interface FeaturesGridProps {
@@ -45,7 +47,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 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],
+ mobileOrderClass,
+ highlight && 'bg-radial-gradient'
)}
- variant='bento'
+ variant={highlight ? 'bentoHighlight' : 'bento'}
>
- <CardContent className='p-4 h-full flex flex-col justify-center items-center'>
+ <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'
+ )}
+ >
{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={cn(
+ 'w-full flex items-center justify-center',
+ direction === 'col-reverse' ? 'order-2 mt-6' : 'order-1'
+ )}
+ >
+ {icon ? (
+ <div className='w-full flex justify-center'>{icon}</div>
+ ) : emoji ? (
+ <span className='text-4xl'>{emoji}</span>
+ ) : null}
+ </div>
+ {fullWidthIcon && (icon || emoji) && (
+ <CardTitle
+ className={cn('text-center text-2xl', direction === 'col-reverse' ? 'order-1' : 'order-2')}
+ >
+ {name}
+ </CardTitle>
+ )}
+ <CardDescription
+ className={cn(
+ 'text-xs leading-relaxed px-8',
+ 'text-center',
+ direction === 'col-reverse' ? 'order-1' : 'order-2'
+ )}
+ >
+ {description}
+ </CardDescription>
</div>
) : (
<div
className={cn(
- 'flex items-center gap-3',
+ 'flex items-center',
+ (icon || emoji) && 'gap-3',
directionToClass[direction],
align === 'center' ? 'justify-center items-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>
- <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>
+ )}
+ <CardTitle
+ className={cn(
+ highlight ? 'text-4xl font-bold' : 'text-lg',
+ align === 'center' ? 'text-center' : 'text-left'
+ )}
+ >
+ {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 && (
+ <CardDescription
+ className={cn(
+ 'text-xs leading-relaxed px-8',
+ 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'
+ className={cn(gridFeatureSizeToClasses[size], mobileOrderClass)}
+ >
{gridFeatureCard}
</a>
);

View File

@@ -1,11 +0,0 @@
--- template/app/src/landing-page/components/Footer.tsx
+++ opensaas-sh/app/src/landing-page/components/Footer.tsx
@@ -13,7 +13,7 @@
<div className='mx-auto mt-6 max-w-7xl px-6 lg:px-8 dark:bg-boxdark-2'>
<footer
aria-labelledby='footer-heading'
- className='relative border-t border-gray-900/10 dark:border-gray-200/10 py-24 sm:mt-32'
+ className='relative border-t border-gray-900/10 dark:border-gray-100/10 py-24 sm:mt-32'
>
<h2 id='footer-heading' className='sr-only'>
Footer

View File

@@ -1,93 +0,0 @@
--- template/app/src/landing-page/components/Hero.tsx
+++ opensaas-sh/app/src/landing-page/components/Hero.tsx
@@ -1,7 +1,25 @@
-import openSaasBannerWebp from '../../client/static/open-saas-banner.webp';
-import { DocsUrl } from '../../shared/common';
+import { useState, useEffect } from 'react';
+import { AiFillGithub } from 'react-icons/ai';
+import { DocsUrl, GithubUrl } from '../../shared/common';
export default function Hero() {
+ const [repoInfo, setRepoInfo] = useState<null | any>(null);
+
+ useEffect(() => {
+ const fetchRepoInfo = async () => {
+ try {
+ 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);
+ }
+ };
+ fetchRepoInfo();
+ }, []);
+
return (
<div className='relative pt-14 w-full'>
<TopGradient />
@@ -9,31 +27,47 @@
<div className='py-24 sm:py-32'>
<div className='mx-auto max-w-8xl px-6 lg:px-8'>
<div className='lg:mb-18 mx-auto max-w-3xl text-center'>
- <h1 className='text-4xl font-bold text-gray-900 sm:text-6xl dark:text-white'>
- Some <span className='italic'>cool</span> words about your product
+ <h1 className='text-4xl font-bold text-gray-900 sm:text-6xl dark:text-white tracking-tight'>
+ The <span className='italic'>free</span> SaaS template with
+ superpowers
</h1>
- <p className='mt-6 mx-auto max-w-2xl text-lg leading-8 text-gray-600 dark:text-white'>
- With some more exciting words about your product!
+ <p className='mt-6 mx-auto max-w-2xl text-lg leading-8 text-gray-600 dark:text-gray-400'>
+ An open-source, feature-rich, full-stack React + NodeJS
+ template that manages features for you.
</p>
<div className='mt-10 flex items-center justify-center gap-x-6'>
<a
href={DocsUrl}
- className='rounded-md px-3.5 py-2.5 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 hover:ring-2 hover:ring-yellow-300 shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-white'
+ className='rounded-md px-6 py-4 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 hover:ring-2 hover:ring-yellow-300 shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-white'
>
Get Started <span aria-hidden='true'>→</span>
</a>
+ <a
+ href={GithubUrl}
+ className='group relative flex items-center justify-center rounded-md bg-gray-100 px-6 py-4 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-200 dark:bg-gray-700 hover:ring-2 hover:ring-yellow-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600'
+ >
+ {/* <AiFillGithub size='1.25rem' className='mr-2' /> */}
+ View the Repo
+ {repoInfo!! && (
+ <>
+ <span className='absolute -top-3 -right-7 inline-flex items-center gap-x-1 rounded-full ring-1 group-hover:ring-2 ring-inset ring-yellow-300 bg-yellow-100 px-2 py-1 text-sm font-medium text-yellow-800'>
+ <AiFillGithub size='1rem' />
+ {repoInfo.stargazers_count}
+ </span>
+ </>
+ )}
+ </a>
</div>
</div>
- <div className='mt-14 flow-root sm:mt-14'>
- <div className='-m-2 flex justify-center rounded-xl lg:-m-4 lg:rounded-2xl lg:p-4'>
- <img
- src={openSaasBannerWebp}
- alt='App screenshot'
- width={1000}
- height={530}
- loading='lazy'
- className='rounded-md shadow-2xl ring-1 ring-gray-900/10'
- />
+ <div className='mt-14 flow-root sm:mt-14 '>
+ <div className='-m-2 mx-auto rounded-xl lg:-m-4 lg:rounded-2xl lg:p-4'>
+ <iframe
+ className=' mx-auto w-full md:w-[85%] aspect-[4/3] shadow-2xl'
+ src='https://cards.producthunt.com/cards/posts/436467?v=1'
+ // width={850}
+ // height={689}
+ allowFullScreen
+ ></iframe>
</div>
</div>
</div>

View File

@@ -0,0 +1,85 @@
--- template/app/src/landing-page/components/Hero/Hero.tsx
+++ opensaas-sh/app/src/landing-page/components/Hero/Hero.tsx
@@ -0,0 +1,82 @@
+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 } from '../../../shared/common';
+import Orbit from './Orbit';
+
+export default function Hero() {
+ const { data: user } = useAuth();
+
+ return (
+ <div className='relative pt-32 w-full'>
+ <TopGradient />
+ <BottomGradient />
+ <div className='flex flex-col lg:flex-row max-w-7xl mx-auto'>
+ <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>
+ </h1>
+ <p className='mt-6 max-w-2xl text-lg leading-8 text-muted-foreground font-mono'>
+ An open-source, feature-rich, full-stack React + NodeJS template that manages features for
+ you.
+ </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}>
+ Try the Demo App
+ </WaspRouterLink>
+ </Button>
+ <Button size='lg' variant='default' asChild>
+ <ReactRouterLink to={DocsUrl}>
+ Get Started
+ <ArrowRight />
+ </ReactRouterLink>
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div className='hidden lg:block '>
+ <Orbit />
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function TopGradient() {
+ return (
+ <div
+ className='absolute top-0 right-0 -z-10 transform-gpu overflow-hidden w-full 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'
+ style={{
+ clipPath: 'polygon(80% 20%, 90% 55%, 50% 100%, 70% 30%, 20% 50%, 50% 0)',
+ }}
+ />
+ </div>
+ );
+}
+
+function BottomGradient() {
+ return (
+ <div
+ className='absolute inset-x-0 top-[calc(100%-40rem)] sm:top-[calc(100%-65rem)] -z-10 transform-gpu overflow-hidden blur-3xl'
+ 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]'
+ style={{
+ clipPath: 'ellipse(80% 30% at 80% 50%)',
+ }}
+ />
+ </div>
+ );
+}

View File

@@ -0,0 +1,220 @@
--- 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';
+
+interface LogoConfig {
+ id: string;
+ component: React.ComponentType;
+ circleIndex: number;
+ position: number;
+ size?: number;
+}
+
+const ImageLogo = ({
+ src,
+ alt,
+ className,
+ dark,
+}: {
+ src: string;
+ alt: string;
+ className?: string;
+ dark?: boolean;
+}) => {
+ return (
+ <img
+ src={src}
+ alt={alt}
+ className={cn('w-8 h-8', dark ? 'dark:block hidden' : 'dark:hidden', className)}
+ />
+ );
+};
+
+const logoConfigs: LogoConfig[] = [
+ {
+ id: 'wasp',
+ component: () => <ImageLogo src={logo} alt='Wasp Logo' className='w-8 h-8 dark:block' dark={false} />,
+ circleIndex: 1,
+ position: 0,
+ },
+ { id: 'openai', component: OpenAILogo, circleIndex: 1, position: 120, size: 24 },
+ { id: 'astro', component: AstroLogo, circleIndex: 1, position: 270, size: 24 },
+ { id: 'prisma', component: PrismaLogo, circleIndex: 2, position: 90, size: 24 },
+ { id: 'shadcn', component: ShadCNLogo, circleIndex: 2, position: 210, size: 24 },
+ {
+ id: 'tailwind-light',
+ component: () => (
+ <ImageLogo src={tailwindLogo} alt='Tailwind CSS Logo' className='w-8 h-8 dark:hidden' dark={false} />
+ ),
+ circleIndex: 2,
+ position: 330,
+ },
+ {
+ id: 'tailwind-dark',
+ component: () => <ImageLogo src={tailwindLogoDark} alt='Tailwind CSS Logo' dark={true} />,
+ circleIndex: 2,
+ position: 330,
+ },
+ {
+ id: 'stripe-light',
+ component: () => <ImageLogo src={stripeLogo} alt='Stripe Logo' dark={false} />,
+ circleIndex: 3,
+ position: 180,
+ },
+ {
+ id: 'stripe-dark',
+ component: () => <ImageLogo src={stripeLogoDark} alt='Stripe Logo' dark={true} />,
+ circleIndex: 3,
+ position: 180,
+ },
+ { id: 'react', component: ReactLogo, circleIndex: 3, position: 300, size: 32 },
+ {
+ id: 'node-light',
+ component: () => <ImageLogo src={nodeLogo} alt='Node.js Logo' dark={false} />,
+ circleIndex: 3,
+ position: 50,
+ },
+ {
+ id: 'node-dark',
+ component: () => <ImageLogo src={nodeLogoDark} alt='Node.js Logo' dark={true} />,
+ circleIndex: 3,
+ position: 50,
+ },
+];
+
+const circles = [
+ { radius: 120, rotationSpeed: 0.5, circleRotationSpeed: 0.2 }, // Innermost circle
+ { radius: 180, rotationSpeed: 0.3, circleRotationSpeed: 0.15 }, // Second circle
+ { radius: 240, rotationSpeed: 0.2, circleRotationSpeed: 0.1 }, // Third circle
+ { radius: 300, rotationSpeed: 0.1, circleRotationSpeed: 0.05 }, // Outermost circle
+];
+
+const gradients = [];
+
+export default function Orbit() {
+ const [rotation, setRotation] = useState(0);
+
+ useEffect(() => {
+ const animate = () => {
+ setRotation((prev) => prev + 0.5);
+ };
+
+ const interval = setInterval(animate, 50);
+ return () => clearInterval(interval);
+ }, []);
+
+ const getLogoPosition = (circleIndex: number, position: number, rotation: number) => {
+ const circle = circles[circleIndex];
+ const totalRotation = rotation * circle.rotationSpeed + position;
+ const radians = (totalRotation * Math.PI) / 180;
+
+ return {
+ x: Math.cos(radians) * circle.radius,
+ y: Math.sin(radians) * circle.radius,
+ };
+ };
+
+ 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>
+
+ {circles.map((circle, circleIndex) => {
+ const gradients = [
+ `conic-gradient(from ${rotation * circle.circleRotationSpeed}deg,
+ hsl(var(--primary) / 0.15),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.15),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.05),
+ hsl(var(--primary) / 0.15)
+ )`,
+ `conic-gradient(from ${rotation * circle.circleRotationSpeed + 45}deg,
+ hsl(var(--primary) / 0.12),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.08),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.2),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.12)
+ )`,
+ `conic-gradient(from ${rotation * circle.circleRotationSpeed + 90}deg,
+ hsl(var(--primary) / 0.1),
+ hsl(var(--primary) / 0.18),
+ hsl(var(--primary) / 0.1),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.15),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.1)
+ )`,
+ `conic-gradient(from ${rotation * circle.circleRotationSpeed + 135}deg,
+ hsl(var(--primary) / 0.08),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.16),
+ hsl(var(--primary) / 0.06),
+ hsl(var(--primary) / 0),
+ hsl(var(--primary) / 0.12),
+ hsl(var(--primary) / 0.08)
+ )`,
+ ];
+
+ return (
+ <div
+ key={circleIndex}
+ className='absolute rounded-full'
+ style={{
+ width: circle.radius * 2,
+ height: circle.radius * 2,
+ background: gradients[circleIndex],
+ mask: `radial-gradient(circle at center, transparent ${circle.radius - 2}px, black ${
+ circle.radius - 1
+ }px, black ${circle.radius}px, transparent ${circle.radius + 1}px)`,
+ WebkitMask: `radial-gradient(circle at center, transparent ${circle.radius - 2}px, black ${
+ circle.radius - 1
+ }px, black ${circle.radius}px, transparent ${circle.radius + 1}px)`,
+ }}
+ />
+ );
+ })}
+
+ {logoConfigs.map((logoConfig) => {
+ 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'
+ style={{
+ left: '50%',
+ top: '50%',
+ transform: `translate(calc(${x}px - ${logoSize / 2}px), calc(${y}px - ${logoSize / 2}px))`,
+ width: logoSize,
+ height: logoSize,
+ }}
+ >
+ <LogoComponent />
+ </div>
+ );
+ })}
+ </div>
+ );
+}

View File

@@ -0,0 +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';
+
+export default Hero;

View File

@@ -0,0 +1,47 @@
--- 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';
highlightedComponent: React.ReactNode;
tilt?: 'left' | 'right';
+ className?: string;
+ url?: string;
}
/**
@@ -18,6 +20,8 @@
direction = 'row',
highlightedComponent,
tilt,
+ className,
+ url,
}: FeatureProps) => {
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'
)}
>
<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>
+ </a>
{typeof description === 'string' ? (
<p className='text-muted-foreground'>{description}</p>
) : (
@@ -42,7 +48,8 @@
<div
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
)}
>
{highlightedComponent}

View File

@@ -0,0 +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[] }) {
- const [isExpanded, setIsExpanded] = useState(false);
- const shouldShowExpand = testimonials.length > 5;
- const mobileItemsToShow = 3;
- const itemsToShow = shouldShowExpand && !isExpanded ? mobileItemsToShow : testimonials.length;
-
return (
<div className='mx-auto mt-32 max-w-7xl sm:mt-56 sm:px-6 lg:px-8'>
<SectionTitle title='What Our Users Say' />
<div className='relative w-full z-10 px-4 md:px-0 columns-1 md:columns-2 lg:columns-3 gap-2 md:gap-6'>
- {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>
))}
</div>
-
- {shouldShowExpand && (
- <div className='flex justify-center mt-8 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'
- >
- {isExpanded ? 'Show Less' : `Show ${testimonials.length - mobileItemsToShow} More`}
- </button>
- </div>
- )}
</div>
);
}

View File

@@ -1,171 +0,0 @@
--- template/app/src/landing-page/contentSections.ts
+++ opensaas-sh/app/src/landing-page/contentSections.ts
@@ -1,75 +1,132 @@
import type { NavigationItem } from '../client/components/NavBar/NavBar';
-import { routes } from 'wasp/client/router';
-import { DocsUrl, BlogUrl } from '../shared/common';
-import daBoiAvatar from '../client/static/da-boi.webp';
-import avatarPlaceholder from '../client/static/avatar-placeholder.webp';
+import { DocsUrl, BlogUrl, GithubUrl } from '../shared/common';
export const landingPageNavigationItems: NavigationItem[] = [
{ name: 'Features', to: '#features' },
- { name: 'Pricing', to: routes.PricingPageRoute.to },
{ name: 'Documentation', to: DocsUrl },
{ name: 'Blog', to: BlogUrl },
];
export const features = [
{
- name: 'Cool Feature #1',
- description: 'Describe your cool feature here.',
+ name: 'Open-Source Philosophy',
+ description:
+ 'The repo and framework are 100% open-source, and so are the services wherever possible. Still missing something? Contribute!',
icon: '🤝',
href: DocsUrl,
},
{
- name: 'Cool Feature #2',
- description: 'Describe your cool feature here.',
+ name: 'DIY Auth, Done For You',
+ description: 'Pre-configured full-stack Auth that you own. No 3rd-party services or hidden fees.',
icon: '🔐',
- href: DocsUrl,
+ href: DocsUrl + '/guides/authentication/',
},
{
- name: 'Cool Feature #3',
- description: 'Describe your cool feature here.',
+ name: 'Full-stack Type Safety',
+ description:
+ 'Full support for TypeScript with auto-generated types that span the whole stack. Nothing to configure!',
icon: '🥞',
href: DocsUrl,
},
{
- name: 'Cool Feature #4',
- description: 'Describe your cool feature here.',
+ 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.",
icon: '💸',
+ href: DocsUrl + '/guides/payments-integration/',
+ },
+ {
+ name: 'Admin Dashboard',
+ description: 'Graphs! Tables! Analytics w/ Plausible or Google! All in one place. Ooooooooooh.',
+ icon: '📈',
+ href: DocsUrl + '/general/admin-dashboard/',
+ },
+ {
+ name: 'Email Sending',
+ description:
+ 'Email sending built-in. Combine it with the cron jobs feature to easily send emails to your customers.',
+ icon: '📧',
+ href: DocsUrl + '/guides/email-sending/',
+ },
+ {
+ name: 'OpenAI API Implemented',
+ description: 'Have a sweet AI-powered app concept? Get your idea shipped to potential customers in days!',
+ icon: '🤖',
+ href: DocsUrl,
+ },
+ {
+ name: 'File Uploads with AWS',
+ description: 'File upload examples with AWS S3 presigned URLs are included and fully documented!',
+ icon: '📁',
+ href: DocsUrl + '/guides/file-uploading/',
+ },
+ {
+ 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: '📝',
+ href: DocsUrl + '/start/guided-tour/',
+ },
+ {
+ 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.',
+ icon: '🚀 ',
+ href: DocsUrl + '/guides/deploying/',
+ },
+ {
+ name: 'E2E Tests w/ Playwright',
+ description: 'Move fast without breaking too much. Tests and a CI pipeline w/ GitHub Actions are set up for you.',
+ icon: '🧪',
+ href: DocsUrl + '/guides/tests/',
+ },
+ {
+ name: 'Complete Documentation & Support',
+ description: "We don't leave you hanging. We have detailed docs and a Discord community to help!",
+ icon: '🫂',
href: DocsUrl,
},
];
export const testimonials = [
{
- name: 'Da Boi',
- role: 'Wasp Mascot',
- avatarSrc: daBoiAvatar,
- socialUrl: 'https://twitter.com/wasplang',
- quote: "I don't even know how to code. I'm just a plushie.",
- },
- {
- name: 'Mr. Foobar',
- role: 'Founder @ Cool Startup',
- avatarSrc: avatarPlaceholder,
- socialUrl: '',
- quote: 'This product makes me cooler than I already am.',
- },
- {
- name: 'Jamie',
- role: 'Happy Customer',
- avatarSrc: avatarPlaceholder,
- socialUrl: '#',
- quote: 'My cats love it!',
+ name: 'Max Khamrovskyi',
+ role: 'Senior Eng @ Red Hat',
+ avatarSrc: 'https://pbs.twimg.com/profile_images/1719397191205179392/V_QrGPSO_400x400.jpg',
+ socialUrl: 'https://twitter.com/maksim36ua',
+ quote: 'I used Wasp to build and sell my AI-augmented SaaS app for marketplace vendors within two months!',
+ },
+ {
+ name: '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: 'Jonathan Cocharan',
+ role: 'Entrepreneur',
+ avatarSrc: 'https://pbs.twimg.com/profile_images/1910056203863883776/jtfVWaEG_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 🤯!',
},
];
export const faqs = [
{
id: 1,
- question: 'Whats the meaning of life?',
- answer: '42.',
- href: 'https://en.wikipedia.org/wiki/42_(number)',
+ question: 'Why is this SaaS Template free and open-source?',
+ answer:
+ 'We believe the best product is made when the community puts their heads together. We also believe a quality starting point for a web app should be free and available to everyone. Our hope is that together we can create the best SaaS template out there and bring our ideas to customers quickly.',
+ },
+ {
+ id: 2,
+ question: "What's Wasp?",
+ href: 'https://wasp-lang.dev',
+ answer: "It's the fastest way to develop full-stack React + NodeJS + Prisma apps and it's what gives this template superpowers. Wasp relies on React, NodeJS, and Prisma to define web components and server queries and actions. Wasp's secret sauce is its compiler which takes the client, server code, and config file and outputs the client app, server app and deployment code, supercharging the development experience. Combined with this template, you can build a SaaS app in record time.",
},
];
export const footerNavigation = {
app: [
+ { name: 'Github', href: GithubUrl },
{ name: 'Documentation', href: DocsUrl },
{ name: 'Blog', href: BlogUrl },
],

View File

@@ -0,0 +1,250 @@
--- template/app/src/landing-page/contentSections.tsx
+++ opensaas-sh/app/src/landing-page/contentSections.tsx
@@ -0,0 +1,247 @@
+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 } from '../shared/common';
+import { GridFeature } from './components/FeaturesGrid';
+
+export const landingPageNavigationItems: NavigationItem[] = [
+ { 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: '🥞',
+ 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',
+ 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',
+ highlight: true,
+ },
+ {
+ name: 'Blog w/ Astro',
+ description:
+ 'Built-in blog with the Astro framework. Write your posts in Markdown, and watch your SEO performance take off.',
+ icon: <img src={blog} alt='Blog illustration' />,
+ href: DocsUrl + '/start/guided-tour/',
+ size: 'medium',
+ fullWidthIcon: true,
+ },
+ {
+ name: 'Deploy Anywhere. Easily.',
+ description:
+ 'No vendor lock-in because you own all your code. Deploy yourself, or let Wasp deploy it for you with a single command.',
+ emoji: '🚀',
+ href: DocsUrl + '/guides/deploying/',
+ size: 'medium',
+ },
+ {
+ name: 'Complete Documentation & Support',
+ description: 'And a Discord community to help!',
+ href: DocsUrl,
+ size: 'small',
+ },
+ {
+ name: 'E2E Tests w/ Playwright',
+ description: 'Tests and a CI pipeline w/ GitHub Actions',
+ href: DocsUrl + '/guides/tests/',
+ size: 'small',
+ },
+ {
+ name: 'Open-Source Philosophy',
+ description:
+ 'The repo and framework are 100% open-source, and so are the services wherever possible. Still missing something? Contribute!',
+ emoji: '🤝',
+ href: DocsUrl,
+ size: 'medium',
+ },
+];
+export const testimonials = [
+ {
+ name: 'Max Khamrovskyi',
+ role: 'Senior Eng @ Red Hat',
+ avatarSrc: 'https://pbs.twimg.com/profile_images/1719397191205179392/V_QrGPSO_400x400.jpg',
+ socialUrl: 'https://twitter.com/maksim36ua',
+ quote:
+ 'I used Wasp to build and sell my AI-augmented SaaS app for marketplace vendors within two months!',
+ },
+ {
+ name: 'Jonathan Cocharan',
+ role: 'Entrepreneur',
+ avatarSrc: 'https://pbs.twimg.com/profile_images/1910056203863883776/jtfVWaEG_400x400.jpg',
+ socialUrl: 'https://twitter.com/JonathanCochran',
+ quote:
+ 'In just 6 nights... my SaaS app is live 🎉! Huge thanks to the amazing @wasplang community 🙌 for their guidance along the way. These tools are incredibly efficient 🤯!',
+ },
+ {
+ name: 'Billy Howell',
+ role: 'Entrepreneur',
+ avatarSrc: 'https://pbs.twimg.com/profile_images/1877734205561430016/jjpG4mS6_400x400.jpg',
+ socialUrl: 'https://twitter.com/billyjhowell',
+ quote:
+ "Congrats! I am loving Wasp. It's really helped me, a self-taught coder increase my confidence. I feel like I've finally found the perfect, versatile stack for all my projects instead of trying out a new one each time.",
+ },
+ {
+ name: 'Tim Skaggs',
+ role: 'Founder @ Antler US',
+ avatarSrc: 'https://pbs.twimg.com/profile_images/1802196804236091392/ZG0OE_fO_400x400.jpg',
+ socialUrl: 'https://twitter.com/tskaggs',
+ quote: 'Nearly done with a MVP in 3 days of part-time work... and deployed on Fly.io in 10 minutes.',
+ },
+ {
+ name: 'Cam Blackwood',
+ role: 'Founder @ Microinfluencer.club',
+ avatarSrc: 'https://pbs.twimg.com/profile_images/1927721707164377089/it8oCAkf_400x400.jpg',
+ socialUrl: 'https://twitter.com/CamBlackwood95',
+ quote: 'Setting up a full stack SaaS in 1 minute with WaspLang.',
+ },
+ {
+ name: 'JLegendz',
+ role: 'Enterpreneur',
+ avatarSrc:
+ 'https://cdn.discordapp.com/avatars/1003468772251811921/a_c6124fcbee5621d1ad9cca83a102c4a9.png?size=80',
+ socialUrl: 'https://discord.com/channels/686873244791210014/1080864617347162122/1246388561020850188',
+ quote:
+ "Just randomly wanted to say that I've been loving working with Wasp so far. The open-saas template is great starting point and great way to learn how Wasp works. The documentation is superb and I see the GitHub is super active. The team is super responsive and the ai kapa rocks! So thanks for the work you all are doing. Ive done plenty of with react in the past but Im a front end person. With wasp though, I'm managing my db, back end functions, actions, queries, all with so much ease. I occasionally get stuck on an issue but within a day or two, and thanks to a couple of AI assistants, I get through it. So thank you!",
+ },
+ {
+ name: 'Dimitrios Mastrogiannis',
+ role: 'Founder @ Kivo.dev',
+ avatarSrc: 'https://pbs.twimg.com/profile_images/1771240035020324864/bcNSr-dA_400x400.jpg',
+ socialUrl: 'https://twitter.com/dmastroyiannis',
+ quote: "Without Wasp & Open SaaS, Kivo.dev wouldn't exist",
+ },
+ {
+ name: 'Alex Ionascu',
+ role: 'Entrepreneur',
+ avatarSrc: 'https://pbs.twimg.com/profile_images/1356710335001018376/HEfgRu8X_400x400.jpg',
+ socialUrl: 'https://twitter.com/alexandrionascu',
+ quote: "Wasp is like hot water. You don't realise how much you need it unless you try it.",
+ },
+ {
+ name: 'Emm Ajayi',
+ role: 'Enterpreneur',
+ avatarSrc:
+ 'https://media2.dev.to/dynamic/image/width=320,height=320,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1215082%2F860c57b3-ac41-420b-9420-b6efda743596.jpg',
+ socialUrl: 'https://dev.to/wasp/our-web-framework-reached-9000-stars-on-github-9000-jij#comment-2dech',
+ quote:
+ "This is exactly the framework I've been dreaming of ever since I've been waiting to fully venture into the JS Backend Dev world. I believe Wasp will go above 50k stars this year. The documentation alone gives me the confidence that this is my permanent Nodejs framework and I'm staying with Wasp. Phenomenal work by the team... Please keep up your amazing spirits. Thank you",
+ },
+];
+
+export const faqs = [
+ {
+ id: 1,
+ question: 'Why is this SaaS Template free and open-source?',
+ answer:
+ 'We believe the best product is made when the community puts their heads together. We also believe a quality starting point for a web app should be free and available to everyone. Our hope is that together we can create the best SaaS template out there and bring our ideas to customers quickly.',
+ },
+ {
+ id: 2,
+ question: "What's Wasp?",
+ href: 'https://wasp-lang.dev',
+ answer:
+ "It's the fastest way to develop full-stack React + NodeJS + Prisma apps and it's what gives this template superpowers. Wasp relies on React, NodeJS, and Prisma to define web components and server queries and actions. Wasp's secret sauce is its compiler which takes the client, server code, and config file and outputs the client app, server app and deployment code, supercharging the development experience. Combined with this template, you can build a SaaS app in record time.",
+ },
+];
+export const footerNavigation = {
+ app: [
+ { name: 'Github', href: GithubUrl },
+ { name: 'Documentation', href: DocsUrl },
+ { name: 'Blog', href: BlogUrl },
+ ],
+ company: [
+ { name: 'About', href: 'https://wasp.sh' },
+ { name: 'Privacy', href: '#' },
+ { name: 'Terms of Service', href: '#' },
+ ],
+};
+
+export const examples = [
+ {
+ name: 'Microinfluencers',
+ description: 'microinfluencer.club',
+ imageSrc: microinfluencerClub,
+ href: 'https://microinfluencer.club',
+ },
+ {
+ name: 'Kivo',
+ description: 'kivo.dev',
+ imageSrc: kivo,
+ href: 'https://kivo.dev',
+ },
+ {
+ name: 'Searchcraft',
+ description: 'searchcraft.io',
+ imageSrc: searchcraft,
+ href: 'https://www.searchcraft.io',
+ },
+ {
+ name: 'Scribeist',
+ description: 'scribeist.com',
+ imageSrc: scribeist,
+ href: 'https://scribeist.com',
+ },
+ {
+ name: 'Messync',
+ description: 'messync.com',
+ imageSrc: messync,
+ href: 'https://messync.com',
+ },
+ {
+ name: 'Prompt Panda',
+ description: 'promptpanda.io',
+ imageSrc: promptpanda,
+ href: 'https://promptpanda.io',
+ },
+ {
+ name: 'Review Radar',
+ description: 'reviewradar.ai',
+ imageSrc: reviewradar,
+ href: 'https://reviewradar.ai',
+ },
+];

View File

@@ -0,0 +1,16 @@
--- template/app/src/landing-page/logos/PrismaLogo.tsx
+++ opensaas-sh/app/src/landing-page/logos/PrismaLogo.tsx
@@ -1,11 +1,11 @@
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>
- )
+ );
}

View File

@@ -0,0 +1,15 @@
--- template/app/src/landing-page/logos/ReactLogo.tsx
+++ opensaas-sh/app/src/landing-page/logos/ReactLogo.tsx
@@ -0,0 +1,12 @@
+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)' />
+ </g>
+ </svg>
+ );
+}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,32 @@
--- template/app/src/landing-page/logos/ShadCNLogo.tsx
+++ opensaas-sh/app/src/landing-page/logos/ShadCNLogo.tsx
@@ -0,0 +1,29 @@
+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>
+ <line
+ 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'
+ ></line>
+ </svg>
+ );
+}

View File

@@ -0,0 +1,15 @@
--- template/app/src/lib/utils.ts
+++ opensaas-sh/app/src/lib/utils.ts
@@ -4,3 +4,12 @@
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+
+export function formatNumber(number: number) {
+ if (number >= 1_000_000) {
+ return (number / 1_000_000).toFixed(1) + 'M';
+ }
+ if (number >= 1_000) {
+ return (number / 1_000).toFixed(1) + 'K';
+ }
+}

View File

@@ -1,49 +1,29 @@
--- template/app/src/payment/PricingPage.tsx
+++ opensaas-sh/app/src/payment/PricingPage.tsx
@@ -7,6 +7,7 @@
import { cn } from '../client/cn';
@@ -10,6 +10,7 @@
import { PaymentPlanId, paymentPlans, prettyPaymentPlanName, SubscriptionStatus } from './plans';
const bestDealPaymentPlanId: PaymentPlanId = PaymentPlanId.Pro;
+const PaymentsDocsURL = 'https://docs.opensaas.sh/guides/payments-integration/';
interface PaymentPlanCard {
name: string;
@@ -105,16 +106,24 @@
Pick your <span className='text-yellow-500'>pricing</span>
@@ -109,8 +110,16 @@
</h2>
</div>
- <p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600 dark:text-white'>
<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 />
- <span className='px-2 py-1 bg-gray-100 rounded-md text-gray-500'>4242 4242 4242 4242 4242</span>
- </p>
+ <div className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600 dark:text-white flex flex-wrap items-center justify-center space-x-2'>
+ <p>
+ Choose between
+ <a href={PaymentsDocsURL} target='_blank' className='text-purple-400 drop-shadow-sm'> Stripe </a>
+ and
+ <a href={PaymentsDocsURL} target='_blank' className='text-yellow-500 drop-shadow-sm'> Lemon Squeezy </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-gray-100 rounded-md text-gray-500'>4242 4242 4242 4242 4242</span>
+ </p>
+ </div>
+
{errorMessage && (
<div className='mt-8 p-4 bg-red-100 text-red-600 rounded-md dark:bg-red-200 dark:text-red-800'>
{errorMessage}
</div>
)}
+
<div className='isolate mx-auto mt-16 grid max-w-md grid-cols-1 gap-y-8 lg:gap-x-8 sm:mt-20 lg:mx-0 lg:max-w-none lg:grid-cols-3'>
{Object.values(PaymentPlanId).map((planId) => (
<div
@@ -201,7 +210,7 @@
)}
disabled={isPaymentLoading}
>
- {!!user ? 'Buy plan' : 'Log in to buy plan'}
+ {!!user ? 'Buy Plan' : 'Log in to buy plan'}
</button>
)}
</div>
+ Choose between{' '}
+ <a href={PaymentsDocsURL} target='_blank' rel='noopener noreferrer'>
+ Stripe
+ </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'>
4242 4242 4242 4242 4242
</span>

View File

@@ -3,5 +3,4 @@
@@ -1,2 +1,3 @@
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';
\ No newline at end of file
+export const GithubUrl = 'https://github.com/wasp-lang/opensaas';