mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-06-24 15:52:30 +02:00
move pricing & fix testimonials
This commit is contained in:
parent
7423313364
commit
46605cbfdc
@ -14,12 +14,9 @@ STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
# set this as a comma-separated list of emails you want to give admin privileges to upon registeration
|
||||
ADMIN_EMAILS=me@example.com,you@example.com,them@example.com
|
||||
|
||||
# this needs to be a string at least 32 characters long
|
||||
JWT_SECRET=
|
||||
|
||||
# see our guide for setting up google auth: https://wasp-lang.dev/docs/auth/social-auth/google
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=722...
|
||||
GOOGLE_CLIENT_SECRET=GOC...
|
||||
|
||||
# get your sendgrid api key at https://app.sendgrid.com/settings/api_keys
|
||||
SENDGRID_API_KEY=
|
||||
@ -27,7 +24,7 @@ SENDGRID_API_KEY=
|
||||
SEND_EMAILS_IN_DEVELOPMENT=false
|
||||
|
||||
# (OPTIONAL) get your openai api key at https://platform.openai.com/account
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_API_KEY=sk-k...
|
||||
|
||||
# (OPTIONAL) get your plausible api key at https://plausible.io/login or https://your-plausible-instance.com/login
|
||||
PLAUSIBLE_API_KEY=gUTgtB...
|
||||
|
@ -49,6 +49,7 @@ app SaaSTemplate {
|
||||
additionalFields: import setAdminUsers from "@server/auth/setAdminUsers.js",
|
||||
},
|
||||
onAuthFailedRedirectTo: "/",
|
||||
onAuthSucceededRedirectTo: "/gpt",
|
||||
},
|
||||
db: {
|
||||
system: PostgreSQL,
|
||||
@ -215,6 +216,11 @@ page GptPage {
|
||||
component: import GptPage from "@client/app/GptPage"
|
||||
}
|
||||
|
||||
route PricingPageRoute { path: "/pricing", to: PricingPage }
|
||||
page PricingPage {
|
||||
component: import PricingPage from "@client/app/PricingPage"
|
||||
}
|
||||
|
||||
route AccountRoute { path: "/account", to: AccountPage }
|
||||
page AccountPage {
|
||||
authRequired: true,
|
||||
|
@ -34,104 +34,110 @@ export default function GptPage() {
|
||||
} = useForm();
|
||||
|
||||
return (
|
||||
<div className='mt-10 px-6'>
|
||||
<div className='overflow-hidden bg-white ring-1 ring-gray-900/10 shadow-lg sm:rounded-lg lg:m-8'>
|
||||
<div className='m-4 py-4 sm:px-6 lg:px-8'>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className='space-y-6 sm:w-[90%] md:w-[60%] mx-auto border-b border-gray-900/10 px-6 pb-12'>
|
||||
<div className='col-span-full'>
|
||||
<label htmlFor='instructions' className='block text-sm font-medium leading-6 text-gray-900'>
|
||||
Instructions -- How should GPT behave?
|
||||
</label>
|
||||
<div className='mt-2'>
|
||||
<textarea
|
||||
id='instructions'
|
||||
placeholder='You are a career advice assistant. You are given a prompt and you must respond with of career advice and 10 actionable items.'
|
||||
rows={3}
|
||||
className='block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6'
|
||||
defaultValue={''}
|
||||
{...register('instructions', {
|
||||
required: 'This is required',
|
||||
minLength: {
|
||||
value: 5,
|
||||
message: 'Minimum length should be 5',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-sm text-red-500'>
|
||||
{typeof formErrors?.instructions?.message === 'string' ? formErrors.instructions.message : null}
|
||||
</span>
|
||||
<div className='my-10 lg:mt-20'>
|
||||
<div className='mx-auto max-w-7xl px-6 lg:px-8'>
|
||||
<div id='pricing' className='mx-auto max-w-4xl text-center'>
|
||||
<h2 className='mt-2 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl'>
|
||||
Create your AI-powered <span className='text-yellow-500'>SaaS</span>
|
||||
</h2>
|
||||
</div>
|
||||
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600'>
|
||||
Below is an example of integrating the OpenAI API into your SaaS.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className='py-8 mt-10 sm:mt-20 ring-1 ring-gray-200 rounded-lg'>
|
||||
<div className='space-y-6 sm:w-[90%] md:w-[60%] mx-auto border-b border-gray-900/10 px-6 pb-12'>
|
||||
<div className='col-span-full'>
|
||||
<label htmlFor='instructions' className='block text-sm font-medium leading-6 text-gray-900'>
|
||||
Instructions -- How should GPT behave?
|
||||
</label>
|
||||
<div className='mt-2'>
|
||||
<textarea
|
||||
id='instructions'
|
||||
placeholder='You are a career advice assistant. You are given a prompt and you must respond with of career advice and 10 actionable items.'
|
||||
rows={3}
|
||||
className='block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6'
|
||||
defaultValue={''}
|
||||
{...register('instructions', {
|
||||
required: 'This is required',
|
||||
minLength: {
|
||||
value: 5,
|
||||
message: 'Minimum length should be 5',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-full'>
|
||||
<label htmlFor='command' className='block text-sm font-medium leading-6 text-gray-900'>
|
||||
Command -- What should GPT do?
|
||||
</label>
|
||||
<div className='mt-2'>
|
||||
<textarea
|
||||
id='command'
|
||||
placeholder='How should I prepare for opening my own speciatly-coffee shop?'
|
||||
rows={3}
|
||||
className='block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6'
|
||||
defaultValue={''}
|
||||
{...register('command', {
|
||||
required: 'This is required',
|
||||
minLength: {
|
||||
value: 5,
|
||||
message: 'Minimum length should be 5',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-sm text-red-500'>
|
||||
{typeof formErrors?.command?.message === 'string' ? formErrors.command.message : null}
|
||||
</span>
|
||||
<span className='text-sm text-red-500'>
|
||||
{typeof formErrors?.instructions?.message === 'string' ? formErrors.instructions.message : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className='col-span-full'>
|
||||
<label htmlFor='command' className='block text-sm font-medium leading-6 text-gray-900'>
|
||||
Command -- What should GPT do?
|
||||
</label>
|
||||
<div className='mt-2'>
|
||||
<textarea
|
||||
id='command'
|
||||
placeholder='How should I prepare for opening my own speciatly-coffee shop?'
|
||||
rows={3}
|
||||
className='block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6'
|
||||
defaultValue={''}
|
||||
{...register('command', {
|
||||
required: 'This is required',
|
||||
minLength: {
|
||||
value: 5,
|
||||
message: 'Minimum length should be 5',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-sm text-red-500'>
|
||||
{typeof formErrors?.command?.message === 'string' ? formErrors.command.message : null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='h-10 '>
|
||||
<label htmlFor='temperature' className='w-full text-gray-700 text-sm font-semibold'>
|
||||
Temperature Input -- Controls How Random GPT's Output is
|
||||
</label>
|
||||
<div className='w-32 mt-2'>
|
||||
<div className='flex flex-row h-10 w-full rounded-lg relative rounded-md border-0 ring-1 ring-inset ring-gray-300 bg-transparent mt-1'>
|
||||
<input
|
||||
type='number'
|
||||
className='outline-none focus:outline-none border-0 rounded-md ring-1 ring-inset ring-gray-300 text-center w-full font-semibold text-md hover:text-black focus:text-black md:text-basecursor-default flex items-center text-gray-700 outline-none'
|
||||
value={temperature}
|
||||
min='0'
|
||||
max='2'
|
||||
step='0.1'
|
||||
{...register('temperature', {
|
||||
onChange: (e) => {
|
||||
console.log(e.target.value);
|
||||
setTemperature(Number(e.target.value));
|
||||
},
|
||||
required: 'This is required',
|
||||
})}
|
||||
></input>
|
||||
</div>
|
||||
<div className='h-10 '>
|
||||
<label htmlFor='temperature' className='w-full text-gray-700 text-sm font-semibold'>
|
||||
Temperature Input -- Controls How Random GPT's Output is
|
||||
</label>
|
||||
<div className='w-32 mt-2'>
|
||||
<div className='flex flex-row h-10 w-full rounded-lg relative rounded-md border-0 ring-1 ring-inset ring-gray-300 bg-transparent mt-1'>
|
||||
<input
|
||||
type='number'
|
||||
className='outline-none focus:outline-none border-0 rounded-md ring-1 ring-inset ring-gray-300 text-center w-full font-semibold text-md hover:text-black focus:text-black md:text-basecursor-default flex items-center text-gray-700 outline-none'
|
||||
value={temperature}
|
||||
min='0'
|
||||
max='2'
|
||||
step='0.1'
|
||||
{...register('temperature', {
|
||||
onChange: (e) => {
|
||||
console.log(e.target.value);
|
||||
setTemperature(Number(e.target.value));
|
||||
},
|
||||
required: 'This is required',
|
||||
})}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-6 flex justify-end gap-x-6 sm:w-[90%] md:w-[50%] mx-auto'>
|
||||
<button
|
||||
type='submit'
|
||||
className={`${
|
||||
isSubmitting && 'animate-puls'
|
||||
} rounded-md bg-yellow-500 py-2 px-3 text-sm font-semibold text-white shadow-sm hover:bg-yellow-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600`}
|
||||
>
|
||||
{!isSubmitting ? 'Submit' : 'Loading...'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
className={`${
|
||||
isSubmitting && 'animate-pulse'
|
||||
} mt-2 mx-6 flex justify-center rounded-lg border border-dashed border-gray-900/25 mt-10 sm:w-[90%] md:w-[50%] mx-auto mt-12 px-6 py-10`}
|
||||
>
|
||||
<div className='space-y-2 text-center'>
|
||||
<p className='text-sm text-gray-500'>{response ? response : 'GPT Response will load here'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-6 flex justify-end gap-x-6 sm:w-[90%] md:w-[50%] mx-auto'>
|
||||
<button
|
||||
type='submit'
|
||||
className={`${
|
||||
isSubmitting && 'animate-puls'
|
||||
} rounded-md bg-yellow-500 py-2 px-3 text-sm font-semibold text-white shadow-sm hover:bg-yellow-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600`}
|
||||
>
|
||||
{!isSubmitting ? 'Submit' : 'Loading...'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
className={`${
|
||||
isSubmitting && 'animate-pulse'
|
||||
} mt-2 mx-6 flex justify-center rounded-lg border border-dashed border-gray-900/25 mt-10 sm:w-[90%] md:w-[50%] mx-auto mt-12 px-6 py-10`}
|
||||
>
|
||||
<div className='space-y-2 text-center'>
|
||||
<p className='text-sm text-gray-500'>{response ? response : 'GPT Response will load here'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
152
app/src/client/app/PricingPage.tsx
Normal file
152
app/src/client/app/PricingPage.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import { TierIds, STRIPE_CUSTOMER_PORTAL_LINK } from '@wasp/shared/constants';
|
||||
import { AiFillCheckCircle } from 'react-icons/ai';
|
||||
import { useState } from 'react';
|
||||
import stripePayment from '@wasp/actions/stripePayment';
|
||||
import useAuth from '@wasp/auth/useAuth';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
export const tiers = [
|
||||
{
|
||||
name: 'Hobby',
|
||||
id: TierIds.HOBBY,
|
||||
priceMonthly: '$9.99',
|
||||
description: 'All you need to get started',
|
||||
features: ['Limited monthly usage', 'Basic support'],
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
id: TierIds.PRO,
|
||||
priceMonthly: '$19.99',
|
||||
description: 'Our most popular plan',
|
||||
features: ['Unlimited monthly usage', 'Priority customer support'],
|
||||
bestDeal: true,
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
id: TierIds.ENTERPRISE,
|
||||
priceMonthly: '$500',
|
||||
description: 'Big business means big money',
|
||||
features: ['Unlimited monthly usage', '24/7 customer support', 'Advanced analytics'],
|
||||
},
|
||||
];
|
||||
|
||||
const PricingPage = () => {
|
||||
const [isStripePaymentLoading, setIsStripePaymentLoading] = useState<boolean | string>(false);
|
||||
|
||||
const { data: user, isLoading: isUserLoading } = useAuth();
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
async function handleBuyNowClick(tierId: string) {
|
||||
if (!user) {
|
||||
history.push('/login');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsStripePaymentLoading(tierId);
|
||||
let stripeResults = await stripePayment(tierId);
|
||||
|
||||
if (stripeResults?.sessionUrl) {
|
||||
window.open(stripeResults.sessionUrl, '_self');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error?.message ?? 'Something went wrong.');
|
||||
} finally {
|
||||
setIsStripePaymentLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='my-10 lg:mt-20'>
|
||||
<div className='mx-auto max-w-7xl px-6 lg:px-8'>
|
||||
<div id='pricing' className='mx-auto max-w-4xl text-center'>
|
||||
<h2 className='mt-2 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl'>
|
||||
Pick your <span className='text-yellow-500'>pricing</span>
|
||||
</h2>
|
||||
</div>
|
||||
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600'>
|
||||
Stripe subscriptions and secure webhooks are built-in. Just add your Stripe Product IDs! Try it out below with
|
||||
test credit card number{' '}
|
||||
<span className='px-2 py-1 bg-gray-100 rounded-md text-gray-500'>4242 4242 4242 4242 4242</span>
|
||||
</p>
|
||||
<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'>
|
||||
{tiers.map((tier) => (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={`relative flex flex-col ${
|
||||
tier.bestDeal ? 'ring-2' : 'ring-1 lg:mt-8'
|
||||
} grow justify-between rounded-3xl ring-gray-200 overflow-hidden p-8 xl:p-10`}
|
||||
>
|
||||
{tier.bestDeal && (
|
||||
<div className='absolute top-0 right-0 -z-10 w-full h-full transform-gpu blur-3xl' aria-hidden='true'>
|
||||
<div
|
||||
className='absolute w-full h-full bg-gradient-to-br from-amber-400 to-purple-300 opacity-30'
|
||||
style={{
|
||||
clipPath: 'circle(670% at 50% 50%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='mb-8'>
|
||||
<div className='flex items-center justify-between gap-x-4'>
|
||||
<h3 id={tier.id} className='text-gray-900 text-lg font-semibold leading-8'>
|
||||
{tier.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className='mt-4 text-sm leading-6 text-gray-600'>{tier.description}</p>
|
||||
<p className='mt-6 flex items-baseline gap-x-1'>
|
||||
<span className='text-4xl font-bold tracking-tight text-gray-900'>{tier.priceMonthly}</span>
|
||||
<span className='text-sm font-semibold leading-6 text-gray-600'>/month</span>
|
||||
</p>
|
||||
<ul role='list' className='mt-8 space-y-3 text-sm leading-6 text-gray-600'>
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className='flex gap-x-3'>
|
||||
<AiFillCheckCircle className='h-6 w-5 flex-none text-yellow-500' aria-hidden='true' />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{!!user && user.hasPaid ? (
|
||||
<a
|
||||
href={STRIPE_CUSTOMER_PORTAL_LINK}
|
||||
aria-describedby='manage-subscription'
|
||||
className={`
|
||||
${tier.id === 'enterprise-tier' ? 'opacity-50 cursor-not-allowed' : 'opacity-100 cursor-pointer'}
|
||||
${
|
||||
tier.bestDeal
|
||||
? 'bg-yellow-500 text-white hover:text-white shadow-sm hover:bg-yellow-400'
|
||||
: 'text-gray-600 ring-1 ring-inset ring-purple-200 hover:ring-purple-400'
|
||||
}
|
||||
'mt-8 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 focus-visible:outline focus-visible:outline-2 focus-visible:outline-yellow-400'
|
||||
`}
|
||||
>
|
||||
{tier.id === 'enterprise-tier' ? 'Contact us' : 'Manage Subscription'}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleBuyNowClick(tier.id)}
|
||||
aria-describedby={tier.id}
|
||||
className={`
|
||||
${tier.id === 'enterprise-tier' ? 'opacity-50 cursor-not-allowed' : 'opacity-100 cursor-pointer'}
|
||||
${
|
||||
tier.bestDeal
|
||||
? 'bg-yellow-500 text-white hover:text-white shadow-sm hover:bg-yellow-400'
|
||||
: 'text-gray-600 ring-1 ring-inset ring-purple-200 hover:ring-purple-400'
|
||||
}
|
||||
${isStripePaymentLoading === tier.id ? 'cursor-wait' : null}
|
||||
'mt-8 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 focus-visible:outline focus-visible:outline-2 focus-visible:outline-yellow-400'
|
||||
`}
|
||||
>
|
||||
{tier.id === 'enterprise-tier' ? 'Contact us' : !!user ? 'Buy plan' : 'Log in to buy plan'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingPage;
|
@ -8,6 +8,7 @@ import { DOCS_URL, BLOG_URL } from '@wasp/shared/constants';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'GPT Wrapper', href: '/gpt' },
|
||||
{ name: 'Pricing', href: '/pricing'},
|
||||
{ name: 'Documentation', href: DOCS_URL },
|
||||
{ name: 'Blog', href: BLOG_URL },
|
||||
];
|
||||
|
@ -1,30 +1,23 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog } from '@headlessui/react';
|
||||
import { AiFillCheckCircle, AiFillCloseCircle } from 'react-icons/ai';
|
||||
import { AiFillCloseCircle } from 'react-icons/ai';
|
||||
import { HiBars3 } from 'react-icons/hi2';
|
||||
import { BiLogIn } from 'react-icons/bi';
|
||||
import { Link } from '@wasp/router';
|
||||
import logo from '../static/logo.png';
|
||||
import daBoi from '../static/da-boi.png';
|
||||
import openSaasBanner from '../static/open-saas-banner.png';
|
||||
// import openSaasBanner from '../static/open-saas-alt-banner.p ng';
|
||||
import { features, navigation, tiers, faqs, footerNavigation } from './contentSections';
|
||||
import useAuth from '@wasp/auth/useAuth';
|
||||
import { features, navigation, faqs, footerNavigation, testimonials } from './contentSections';
|
||||
import DropdownUser from '../components/DropdownUser';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import stripePayment from '@wasp/actions/stripePayment';
|
||||
import { DOCS_URL, STRIPE_CUSTOMER_PORTAL_LINK } from '@wasp/shared/constants';
|
||||
import { DOCS_URL } from '@wasp/shared/constants';
|
||||
import { UserMenuItems } from '../components/UserMenuItems';
|
||||
import useAuth from '@wasp/auth/useAuth';
|
||||
|
||||
export default function LandingPage() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isStripePaymentLoading, setIsStripePaymentLoading] = useState<boolean | string>(false);
|
||||
const [isDemoInfoVisible, setIsDemoInfoVisible] = useState(false);
|
||||
|
||||
const { data: user, isLoading: isUserLoading } = useAuth();
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (localStorage.getItem('isDemoInfoVisible') === 'false') {
|
||||
@ -37,25 +30,6 @@ export default function LandingPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleBuyNowClick(tierId: string) {
|
||||
if (!user) {
|
||||
history.push('/login');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsStripePaymentLoading(tierId);
|
||||
let stripeResults = await stripePayment(tierId);
|
||||
|
||||
if (stripeResults?.sessionUrl) {
|
||||
window.open(stripeResults.sessionUrl, '_self');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error?.message ?? 'Something went wrong.');
|
||||
} finally {
|
||||
setIsStripePaymentLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDemoInfoClose = () => {
|
||||
try {
|
||||
localStorage.setItem('isDemoInfoVisible', 'false');
|
||||
@ -70,16 +44,18 @@ export default function LandingPage() {
|
||||
return (
|
||||
<div className='bg-white'>
|
||||
{/* 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'>
|
||||
This demo app <span className='italic'>is</span> the SaaS template. Feel free to play around!
|
||||
</span>
|
||||
<button className=' pl-2.5 text-gray-400 text-xl font-bold' onClick={() => handleDemoInfoClose()}>
|
||||
X
|
||||
</button>
|
||||
{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'>
|
||||
This demo app <span className='italic'>is</span> the SaaS template. Feel free to play around!
|
||||
</span>
|
||||
<button className=' pl-2.5 text-gray-400 text-xl font-bold' onClick={() => handleDemoInfoClose()}>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
)}
|
||||
{/* Header */}
|
||||
<header className='absolute inset-x-0 top-0 z-50'>
|
||||
<nav className='flex items-center justify-between p-6 lg:px-8' aria-label='Global'>
|
||||
@ -261,7 +237,13 @@ export default function LandingPage() {
|
||||
height={48}
|
||||
/>
|
||||
<div className='flex justify-center col-span-1 max-h-12 w-full object-contain grayscale opacity-80'>
|
||||
<svg width={48} height={48} viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><title>file_type_prisma</title><path 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} viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'>
|
||||
<title>file_type_prisma</title>
|
||||
<path
|
||||
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>
|
||||
</div>
|
||||
<img
|
||||
className=' col-span-1 max-h-12 w-full object-contain grayscale '
|
||||
@ -292,11 +274,10 @@ export default function LandingPage() {
|
||||
</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'>
|
||||
{features.map((feature, idx) => (
|
||||
<div key={feature.name} className={`relative pl-16 ${idx === features.length - 1 ? `mx-auto lg:col-span-2 lg:w-1/2` : ''}`}>
|
||||
<dt className='text-base font-semibold leading-7 text-gray-900'>
|
||||
<div className='absolute left-0 top-0 flex h-10 w-10 items-center justify-center border border-yellow-400 bg-yellow-100/50 rounded-lg'>
|
||||
{/* <feature.icon className='h-6 w-6 text-white' aria-hidden='true' /> */}
|
||||
<div className='text-2xl'>{feature.icon}</div>
|
||||
</div>
|
||||
{feature.name}
|
||||
@ -313,158 +294,35 @@ export default function LandingPage() {
|
||||
<div className='relative sm:left-5 -m-2 rounded-xl bg-yellow-400/20 lg:ring-1 lg:ring-yellow-500/50 lg:-m-4 '>
|
||||
<div className='relative sm:top-5 sm:right-5 bg-gray-900 px-8 py-20 shadow-xl sm:rounded-xl sm:px-10 sm:py-16 md:px-12 lg:px-20'>
|
||||
<h2 className='text-left font-semibold tracking-wide leading-7 text-gray-500'>Testimonials</h2>
|
||||
<div className='relative flex flex-col lg:flex-row gap-12 w-full mt-6 z-10 justify-between lg:mx-0'>
|
||||
<figure className='flex-1 flex-1 flex flex-col justify-between p-8 rounded-xl bg-gray-500/5 '>
|
||||
<blockquote className='text-lg font-semibold text-white sm:text-xl sm:leading-8'>
|
||||
<div className='relative flex flex-wrap gap-6 w-full mt-6 z-10 justify-between lg:mx-0'>
|
||||
{testimonials.map((testimonial) => (
|
||||
<figure className='w-full lg:w-1/4 box-content flex flex-col justify-between p-8 rounded-xl bg-gray-500/5 '>
|
||||
<blockquote className='text-lg text-white sm:text-md sm:leading-8'>
|
||||
<p>
|
||||
“I used Wasp to build and sell my AI-augmented SaaS app for marketplace vendors within two
|
||||
months!”
|
||||
{testimonial.quote}
|
||||
</p>
|
||||
</blockquote>
|
||||
<figcaption className='mt-6 text-base text-white'>
|
||||
<a href='https://twitter.com/maksim36ua' className='flex items-center gap-x-2'>
|
||||
<a href={testimonial.socialUrl} className='flex items-center gap-x-2'>
|
||||
<img
|
||||
src='https://pbs.twimg.com/profile_images/1719397191205179392/V_QrGPSO_400x400.jpg'
|
||||
src={testimonial.avatarSrc}
|
||||
className='h-12 w-12 rounded-full'
|
||||
/>
|
||||
<div>
|
||||
<div className='font-semibold hover:underline'>Maks</div>
|
||||
<div className='mt-1'>Senior Eng @ Red Hat</div>
|
||||
</div>
|
||||
</a>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<figure className='flex-1 flex flex-col justify-between p-8 rounded-xl bg-gray-500/5 '>
|
||||
<blockquote className='text-lg font-semibold text-white sm:text-xl sm:leading-8'>
|
||||
<p>“My cats love it!”</p>
|
||||
</blockquote>
|
||||
<figcaption className='mt-6 text-base text-white'>
|
||||
<a href='https://twitter.com/webrickony' className='flex items-center gap-x-2'>
|
||||
<img
|
||||
src='https://pbs.twimg.com/profile_images/1560677466749943810/QIFuQMqU_400x400.jpg'
|
||||
className='h-12 w-12 rounded-full'
|
||||
/>
|
||||
<div>
|
||||
<div className='font-semibold hover:underline'>Fecony</div>
|
||||
<div className='mt-1'>Wasp Expert</div>
|
||||
</div>
|
||||
</a>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<figure className='flex-1 flex-1 flex flex-col justify-between p-8 rounded-xl bg-gray-500/5 '>
|
||||
<blockquote className='text-lg font-semibold text-white sm:text-xl sm:leading-8'>
|
||||
<p>“I don't even know how to code. I'm just a plushie.”</p>
|
||||
</blockquote>
|
||||
<figcaption className=' mt-6 text-base text-white'>
|
||||
<a href='https://twitter.com/wasp-lang' className='flex items-center gap-x-2'>
|
||||
<img src={daBoi} className='h-14 w-14 rounded-full' />
|
||||
<div>
|
||||
<div className='font-semibold hover:underline'>Da Boi</div>
|
||||
<div className='mt-1'>Wasp Unofficial Mascot</div>
|
||||
<div className='font-semibold hover:underline'>{testimonial.name}</div>
|
||||
<div className='mt-1'>{testimonial.role}</div>
|
||||
</div>
|
||||
</a>
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing section */}
|
||||
<div className='py-24 sm:pt-48'>
|
||||
<div className='mx-auto max-w-7xl px-6 lg:px-8'>
|
||||
<div id='pricing' className='mx-auto max-w-4xl text-center'>
|
||||
<h2 className='mt-2 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl'>
|
||||
Pick your <span className='text-yellow-500'>pricing</span>
|
||||
</h2>
|
||||
</div>
|
||||
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600'>
|
||||
Stripe subscriptions and secure webhooks are built-in. Just add your Stripe
|
||||
Product IDs! Try it out below with test credit card number{' '}<span className='px-2 py-1 bg-gray-100 rounded-md text-gray-500'>4242 4242 4242 4242 4242</span>
|
||||
</p>
|
||||
<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'>
|
||||
{tiers.map((tier) => (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={`relative flex flex-col ${
|
||||
tier.bestDeal ? 'ring-2' : 'ring-1 lg:mt-8'
|
||||
} grow justify-between rounded-3xl ring-gray-200 overflow-hidden p-8 xl:p-10`}
|
||||
>
|
||||
{tier.bestDeal && (
|
||||
<div
|
||||
className='absolute top-0 right-0 -z-10 w-full h-full transform-gpu blur-3xl'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<div
|
||||
className='absolute w-full h-full bg-gradient-to-br from-amber-400 to-purple-300 opacity-30'
|
||||
style={{
|
||||
clipPath: 'circle(670% at 50% 50%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='mb-8'>
|
||||
<div className='flex items-center justify-between gap-x-4'>
|
||||
<h3 id={tier.id} className='text-gray-900 text-lg font-semibold leading-8'>
|
||||
{tier.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className='mt-4 text-sm leading-6 text-gray-600'>{tier.description}</p>
|
||||
<p className='mt-6 flex items-baseline gap-x-1'>
|
||||
<span className='text-4xl font-bold tracking-tight text-gray-900'>{tier.priceMonthly}</span>
|
||||
<span className='text-sm font-semibold leading-6 text-gray-600'>/month</span>
|
||||
</p>
|
||||
<ul role='list' className='mt-8 space-y-3 text-sm leading-6 text-gray-600'>
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className='flex gap-x-3'>
|
||||
<AiFillCheckCircle className='h-6 w-5 flex-none text-yellow-500' aria-hidden='true' />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{!!user && user.hasPaid ? (
|
||||
<a
|
||||
href={STRIPE_CUSTOMER_PORTAL_LINK}
|
||||
aria-describedby='manage-subscription'
|
||||
className={`
|
||||
${tier.id === 'enterprise-tier' ? 'opacity-50 cursor-not-allowed' : 'opacity-100 cursor-pointer'}
|
||||
${
|
||||
tier.bestDeal
|
||||
? 'bg-yellow-500 text-white hover:text-white shadow-sm hover:bg-yellow-400'
|
||||
: 'text-gray-600 ring-1 ring-inset ring-purple-200 hover:ring-purple-400'
|
||||
}
|
||||
'mt-8 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 focus-visible:outline focus-visible:outline-2 focus-visible:outline-yellow-400'
|
||||
`}
|
||||
>
|
||||
{tier.id === 'enterprise-tier' ? 'Contact us' : 'Manage Subscription'}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleBuyNowClick(tier.id)}
|
||||
aria-describedby={tier.id}
|
||||
className={`
|
||||
${tier.id === 'enterprise-tier' ? 'opacity-50 cursor-not-allowed' : 'opacity-100 cursor-pointer'}
|
||||
${
|
||||
tier.bestDeal
|
||||
? 'bg-yellow-500 text-white hover:text-white shadow-sm hover:bg-yellow-400'
|
||||
: 'text-gray-600 ring-1 ring-inset ring-purple-200 hover:ring-purple-400'
|
||||
}
|
||||
${isStripePaymentLoading === tier.id ? 'cursor-wait' : null}
|
||||
'mt-8 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 focus-visible:outline focus-visible:outline-2 focus-visible:outline-yellow-400'
|
||||
`}
|
||||
>
|
||||
{tier.id === 'enterprise-tier' ? 'Contact us' : !!user ? 'Buy plan' : 'Log in to buy plan'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
<div className='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:pb-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'>Frequently asked questions</h2>
|
||||
<dl className='mt-10 space-y-8 divide-y divide-gray-900/10'>
|
||||
{faqs.map((faq) => (
|
||||
|
@ -1,16 +1,22 @@
|
||||
import { TierIds, DOCS_URL, BLOG_URL } from '@wasp/shared/constants';
|
||||
import { DOCS_URL, BLOG_URL } from '@wasp/shared/constants';
|
||||
import daBoiAavatar from '../static/da-boi.png';
|
||||
|
||||
export const navigation = [
|
||||
{ name: 'Features', href: '#features' },
|
||||
{ name: 'Pricing', href: '#pricing' },
|
||||
{ name: 'Documentation', href: DOCS_URL },
|
||||
{ name: 'Blog', href: BLOG_URL },
|
||||
];
|
||||
export const features = [
|
||||
{
|
||||
name: 'Open-Source Philosophy',
|
||||
description:
|
||||
"Forever free and open-source. Create an issue, make a PR, and together let's make the best SaaS template ever!",
|
||||
icon: '🤝',
|
||||
},
|
||||
{
|
||||
name: 'Auto-magic Auth',
|
||||
description:
|
||||
'Not only is Auth pre-configured, but you can integrate more providers with just a few lines of code, thanks to the power of Wasp.',
|
||||
'Not only is full-stack Auth pre-configured, but you can integrate more providers with just a few lines of code.',
|
||||
icon: '🔐',
|
||||
},
|
||||
{
|
||||
@ -27,25 +33,24 @@ export const features = [
|
||||
},
|
||||
{
|
||||
name: 'Admin Dashboard',
|
||||
description:
|
||||
"Graphs! Tables! Analytics! All in one place. Ooooooooooh.",
|
||||
description: 'Graphs! Tables! Analytics w/ Plausible or Google! All in one place. Ooooooooooh.',
|
||||
icon: '📈',
|
||||
},
|
||||
{
|
||||
name: 'Email Sending',
|
||||
description:
|
||||
"Email sending is built-in and pre-configured. Combine it with Wasp's cron jobs feature to easily send emails to your customers.",
|
||||
"Email sending built-in and pre-configured. Combine it with Wasp's cron jobs feature to easily send emails to your customers.",
|
||||
icon: '📧',
|
||||
},
|
||||
{
|
||||
name: 'OpenAI Integration',
|
||||
description:
|
||||
"Technology is changing rapidly. Ship your new AI-powered app before it's already obsolete!",
|
||||
name: 'OpenAI API Implemented',
|
||||
description: "Technology is changing rapidly. Ship your new AI-powered app before it's already obsolete!",
|
||||
icon: '🤖',
|
||||
},
|
||||
{
|
||||
name: 'Deploy Anywhere. Easily.',
|
||||
description: 'You own all your code, so deploy it wherever you want. Or take advantage of Wasp\'s one-command, full-stack deploy.',
|
||||
description:
|
||||
'You own all your code, and can deploy wherever & however you want. Or just let Wasp deploy it for you with a single command.',
|
||||
icon: '🚀 ',
|
||||
},
|
||||
{
|
||||
@ -54,36 +59,58 @@ export const features = [
|
||||
icon: '🫂',
|
||||
},
|
||||
];
|
||||
export const tiers = [
|
||||
export const testimonials = [
|
||||
// {
|
||||
// name: 'Jason Warner',
|
||||
// role: 'former CTO @ GitHub',
|
||||
// avatarSrc: 'https://pbs.twimg.com/profile_images/1538765024021258240/qXJBzw6U_400x400.jpg',
|
||||
// socialUrl: 'https://twitter.com/jasoncwarner',
|
||||
// quote:
|
||||
// "I've actually had a bunch of fun with [Wasp]... I loved Batman.js back in the day and getting some of those vibes.",
|
||||
// },
|
||||
{
|
||||
name: 'Hobby',
|
||||
id: TierIds.HOBBY,
|
||||
priceMonthly: '$9.99',
|
||||
description: 'All you need to get started',
|
||||
features: ['Limited monthly usage', 'Basic support'],
|
||||
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: 'Da Boi',
|
||||
// role: 'Wasp Mascot',
|
||||
// avatarSrc: daBoiAavatar,
|
||||
// socialUrl: 'https://twitter.com/wasplang',
|
||||
// quote: "I don't even know how to code. I'm just a plushie.",
|
||||
// },
|
||||
{
|
||||
name: 'Pro',
|
||||
id: TierIds.PRO,
|
||||
priceMonthly: '$19.99',
|
||||
description: 'Our most popular plan',
|
||||
features: ['Unlimited monthly usage', 'Priority customer support'],
|
||||
bestDeal: true,
|
||||
name: 'Tim Skaggs',
|
||||
role: 'Founder @ Antler US',
|
||||
avatarSrc: 'https://pbs.twimg.com/profile_images/1486119226771480577/VptdEo6A_400x400.png',
|
||||
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: 'Fecony',
|
||||
// role: 'Wasp Expert',
|
||||
// avatarSrc: 'https://pbs.twimg.com/profile_images/1560677466749943810/QIFuQMqU_400x400.jpg',
|
||||
// socialUrl: 'https://twitter.com/webrickony',
|
||||
// quote: 'My cats love it!',
|
||||
// },
|
||||
{
|
||||
name: 'Enterprise',
|
||||
id: TierIds.ENTERPRISE,
|
||||
priceMonthly: '$500',
|
||||
description: 'Big business means big money',
|
||||
features: ['Unlimited monthly usage', '24/7 customer support', 'Advanced analytics'],
|
||||
name: 'Jonathan Cocharan',
|
||||
role: 'Entrepreneur',
|
||||
avatarSrc: 'https://pbs.twimg.com/profile_images/926142421653753857/o6Hmcbr7_400x400.jpg',
|
||||
socialUrl: 'https://twitter.com/jonathancocharan',
|
||||
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: "Why is this amazing SaaS Template free and open-source?",
|
||||
answer:
|
||||
"Because open-source is cool, and we love you ❤️",
|
||||
question: 'Why is this amazing SaaS Template free and open-source?',
|
||||
answer: 'Because open-source is cool, and we love you ❤️',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@ -94,8 +121,7 @@ export const faqs = [
|
||||
];
|
||||
export const footerNavigation = {
|
||||
app: [
|
||||
{ name: 'Pricing', href: '#pricing' },
|
||||
{ name: 'Documentation', href: DOCS_URL },
|
||||
{ name: 'Documentation', href: DOCS_URL },
|
||||
{ name: 'Blog', href: BLOG_URL },
|
||||
],
|
||||
company: [
|
||||
|
@ -124,7 +124,9 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, GptResponse> =
|
||||
});
|
||||
}
|
||||
console.error(error);
|
||||
throw new HttpError(500, error.message);
|
||||
const statusCode = error.statusCode || 500;
|
||||
const errorMessage = error.message || 'Internal server error';
|
||||
throw new HttpError(statusCode, errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user