move pricing & fix testimonials

This commit is contained in:
vincanger 2023-12-12 10:53:08 -05:00
parent 7423313364
commit 46605cbfdc
8 changed files with 356 additions and 308 deletions

View File

@ -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 # 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 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 # see our guide for setting up google auth: https://wasp-lang.dev/docs/auth/social-auth/google
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=722...
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=GOC...
# get your sendgrid api key at https://app.sendgrid.com/settings/api_keys # get your sendgrid api key at https://app.sendgrid.com/settings/api_keys
SENDGRID_API_KEY= SENDGRID_API_KEY=
@ -27,7 +24,7 @@ SENDGRID_API_KEY=
SEND_EMAILS_IN_DEVELOPMENT=false SEND_EMAILS_IN_DEVELOPMENT=false
# (OPTIONAL) get your openai api key at https://platform.openai.com/account # (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 # (OPTIONAL) get your plausible api key at https://plausible.io/login or https://your-plausible-instance.com/login
PLAUSIBLE_API_KEY=gUTgtB... PLAUSIBLE_API_KEY=gUTgtB...

View File

@ -49,6 +49,7 @@ app SaaSTemplate {
additionalFields: import setAdminUsers from "@server/auth/setAdminUsers.js", additionalFields: import setAdminUsers from "@server/auth/setAdminUsers.js",
}, },
onAuthFailedRedirectTo: "/", onAuthFailedRedirectTo: "/",
onAuthSucceededRedirectTo: "/gpt",
}, },
db: { db: {
system: PostgreSQL, system: PostgreSQL,
@ -215,6 +216,11 @@ page GptPage {
component: import GptPage from "@client/app/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 } route AccountRoute { path: "/account", to: AccountPage }
page AccountPage { page AccountPage {
authRequired: true, authRequired: true,

View File

@ -34,104 +34,110 @@ export default function GptPage() {
} = useForm(); } = useForm();
return ( return (
<div className='mt-10 px-6'> <div className='my-10 lg:mt-20'>
<div className='overflow-hidden bg-white ring-1 ring-gray-900/10 shadow-lg sm:rounded-lg lg:m-8'> <div className='mx-auto max-w-7xl px-6 lg:px-8'>
<div className='m-4 py-4 sm:px-6 lg:px-8'> <div id='pricing' className='mx-auto max-w-4xl text-center'>
<form onSubmit={handleSubmit(onSubmit)}> <h2 className='mt-2 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl'>
<div className='space-y-6 sm:w-[90%] md:w-[60%] mx-auto border-b border-gray-900/10 px-6 pb-12'> Create your AI-powered <span className='text-yellow-500'>SaaS</span>
<div className='col-span-full'> </h2>
<label htmlFor='instructions' className='block text-sm font-medium leading-6 text-gray-900'> </div>
Instructions -- How should GPT behave? <p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600'>
</label> Below is an example of integrating the OpenAI API into your SaaS.
<div className='mt-2'> </p>
<textarea <form onSubmit={handleSubmit(onSubmit)} className='py-8 mt-10 sm:mt-20 ring-1 ring-gray-200 rounded-lg'>
id='instructions' <div className='space-y-6 sm:w-[90%] md:w-[60%] mx-auto border-b border-gray-900/10 px-6 pb-12'>
placeholder='You are a career advice assistant. You are given a prompt and you must respond with of career advice and 10 actionable items.' <div className='col-span-full'>
rows={3} <label htmlFor='instructions' className='block text-sm font-medium leading-6 text-gray-900'>
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' Instructions -- How should GPT behave?
defaultValue={''} </label>
{...register('instructions', { <div className='mt-2'>
required: 'This is required', <textarea
minLength: { id='instructions'
value: 5, placeholder='You are a career advice assistant. You are given a prompt and you must respond with of career advice and 10 actionable items.'
message: 'Minimum length should be 5', 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', {
</div> required: 'This is required',
<span className='text-sm text-red-500'> minLength: {
{typeof formErrors?.instructions?.message === 'string' ? formErrors.instructions.message : null} value: 5,
</span> message: 'Minimum length should be 5',
},
})}
/>
</div> </div>
<div className='col-span-full'> <span className='text-sm text-red-500'>
<label htmlFor='command' className='block text-sm font-medium leading-6 text-gray-900'> {typeof formErrors?.instructions?.message === 'string' ? formErrors.instructions.message : null}
Command -- What should GPT do? </span>
</label> </div>
<div className='mt-2'> <div className='col-span-full'>
<textarea <label htmlFor='command' className='block text-sm font-medium leading-6 text-gray-900'>
id='command' Command -- What should GPT do?
placeholder='How should I prepare for opening my own speciatly-coffee shop?' </label>
rows={3} <div className='mt-2'>
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' <textarea
defaultValue={''} id='command'
{...register('command', { placeholder='How should I prepare for opening my own speciatly-coffee shop?'
required: 'This is required', rows={3}
minLength: { 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'
value: 5, defaultValue={''}
message: 'Minimum length should be 5', {...register('command', {
}, required: 'This is required',
})} minLength: {
/> value: 5,
</div> message: 'Minimum length should be 5',
<span className='text-sm text-red-500'> },
{typeof formErrors?.command?.message === 'string' ? formErrors.command.message : null} })}
</span> />
</div> </div>
<span className='text-sm text-red-500'>
{typeof formErrors?.command?.message === 'string' ? formErrors.command.message : null}
</span>
</div>
<div className='h-10 '> <div className='h-10 '>
<label htmlFor='temperature' className='w-full text-gray-700 text-sm font-semibold'> <label htmlFor='temperature' className='w-full text-gray-700 text-sm font-semibold'>
Temperature Input -- Controls How Random GPT's Output is Temperature Input -- Controls How Random GPT's Output is
</label> </label>
<div className='w-32 mt-2'> <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'> <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 <input
type='number' 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' 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} value={temperature}
min='0' min='0'
max='2' max='2'
step='0.1' step='0.1'
{...register('temperature', { {...register('temperature', {
onChange: (e) => { onChange: (e) => {
console.log(e.target.value); console.log(e.target.value);
setTemperature(Number(e.target.value)); setTemperature(Number(e.target.value));
}, },
required: 'This is required', required: 'This is required',
})} })}
></input> ></input>
</div>
</div> </div>
</div> </div>
</div> </div>
<div className='mt-6 flex justify-end gap-x-6 sm:w-[90%] md:w-[50%] mx-auto'> </div>
<button <div className='mt-6 flex justify-end gap-x-6 sm:w-[90%] md:w-[50%] mx-auto'>
type='submit' <button
className={`${ type='submit'
isSubmitting && 'animate-puls' className={`${
} 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 && '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> {!isSubmitting ? 'Submit' : 'Loading...'}
</div> </button>
</form> </div>
<div </form>
className={`${ <div
isSubmitting && 'animate-pulse' className={`${
} 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`} 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 className='space-y-2 text-center'>
</div> <p className='text-sm text-gray-500'>{response ? response : 'GPT Response will load here'}</p>
</div> </div>
</div> </div>
</div> </div>

View 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;

View File

@ -8,6 +8,7 @@ import { DOCS_URL, BLOG_URL } from '@wasp/shared/constants';
const navigation = [ const navigation = [
{ name: 'GPT Wrapper', href: '/gpt' }, { name: 'GPT Wrapper', href: '/gpt' },
{ name: 'Pricing', href: '/pricing'},
{ name: 'Documentation', href: DOCS_URL }, { name: 'Documentation', href: DOCS_URL },
{ name: 'Blog', href: BLOG_URL }, { name: 'Blog', href: BLOG_URL },
]; ];

View File

@ -1,30 +1,23 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Dialog } from '@headlessui/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 { HiBars3 } from 'react-icons/hi2';
import { BiLogIn } from 'react-icons/bi'; import { BiLogIn } from 'react-icons/bi';
import { Link } from '@wasp/router'; import { Link } from '@wasp/router';
import logo from '../static/logo.png'; 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-banner.png';
// import openSaasBanner from '../static/open-saas-alt-banner.p ng'; import { features, navigation, faqs, footerNavigation, testimonials } from './contentSections';
import { features, navigation, tiers, faqs, footerNavigation } from './contentSections';
import useAuth from '@wasp/auth/useAuth';
import DropdownUser from '../components/DropdownUser'; import DropdownUser from '../components/DropdownUser';
import { useHistory } from 'react-router-dom'; import { DOCS_URL } from '@wasp/shared/constants';
import stripePayment from '@wasp/actions/stripePayment';
import { DOCS_URL, STRIPE_CUSTOMER_PORTAL_LINK } from '@wasp/shared/constants';
import { UserMenuItems } from '../components/UserMenuItems'; import { UserMenuItems } from '../components/UserMenuItems';
import useAuth from '@wasp/auth/useAuth';
export default function LandingPage() { export default function LandingPage() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isStripePaymentLoading, setIsStripePaymentLoading] = useState<boolean | string>(false);
const [isDemoInfoVisible, setIsDemoInfoVisible] = useState(false); const [isDemoInfoVisible, setIsDemoInfoVisible] = useState(false);
const { data: user, isLoading: isUserLoading } = useAuth(); const { data: user, isLoading: isUserLoading } = useAuth();
const history = useHistory();
useEffect(() => { useEffect(() => {
try { try {
if (localStorage.getItem('isDemoInfoVisible') === 'false') { 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 = () => { const handleDemoInfoClose = () => {
try { try {
localStorage.setItem('isDemoInfoVisible', 'false'); localStorage.setItem('isDemoInfoVisible', 'false');
@ -70,16 +44,18 @@ export default function LandingPage() {
return ( return (
<div className='bg-white'> <div className='bg-white'>
{/* Floating Demo Announcement */} {/* 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'> {isDemoInfoVisible && (
<div className='px-4 flex flex-row gap-2 items-center my-1'> <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'>
<span className='text-gray-100'> <div className='px-4 flex flex-row gap-2 items-center my-1'>
This demo app <span className='italic'>is</span> the SaaS template. Feel free to play around! <span className='text-gray-100'>
</span> This demo app <span className='italic'>is</span> the SaaS template. Feel free to play around!
<button className=' pl-2.5 text-gray-400 text-xl font-bold' onClick={() => handleDemoInfoClose()}> </span>
X <button className=' pl-2.5 text-gray-400 text-xl font-bold' onClick={() => handleDemoInfoClose()}>
</button> X
</button>
</div>
</div> </div>
</div>} )}
{/* Header */} {/* Header */}
<header className='absolute inset-x-0 top-0 z-50'> <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'> <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} height={48}
/> />
<div className='flex justify-center col-span-1 max-h-12 w-full object-contain grayscale opacity-80'> <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> </div>
<img <img
className=' col-span-1 max-h-12 w-full object-contain grayscale ' className=' col-span-1 max-h-12 w-full object-contain grayscale '
@ -292,11 +274,10 @@ export default function LandingPage() {
</div> </div>
<div className='mx-auto mt-16 max-w-2xl sm:mt-20 lg:mt-24 lg:max-w-4xl'> <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'> <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) => ( {features.map((feature, idx) => (
<div key={feature.name} className='relative pl-16'> <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'> <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'> <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 className='text-2xl'>{feature.icon}</div>
</div> </div>
{feature.name} {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: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'> <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> <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'> <div className='relative flex flex-wrap gap-6 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 '> {testimonials.map((testimonial) => (
<blockquote className='text-lg font-semibold text-white sm:text-xl sm:leading-8'> <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> <p>
I used Wasp to build and sell my AI-augmented SaaS app for marketplace vendors within two {testimonial.quote}
months!
</p> </p>
</blockquote> </blockquote>
<figcaption className='mt-6 text-base text-white'> <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 <img
src='https://pbs.twimg.com/profile_images/1719397191205179392/V_QrGPSO_400x400.jpg' src={testimonial.avatarSrc}
className='h-12 w-12 rounded-full' className='h-12 w-12 rounded-full'
/> />
<div> <div>
<div className='font-semibold hover:underline'>Maks</div> <div className='font-semibold hover:underline'>{testimonial.name}</div>
<div className='mt-1'>Senior Eng @ Red Hat</div> <div className='mt-1'>{testimonial.role}</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> </div>
</a> </a>
</figcaption> </figcaption>
</figure> </figure>
))}
</div> </div>
</div> </div>
</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 */} {/* 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> <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'> <dl className='mt-10 space-y-8 divide-y divide-gray-900/10'>
{faqs.map((faq) => ( {faqs.map((faq) => (

View File

@ -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 = [ export const navigation = [
{ name: 'Features', href: '#features' }, { name: 'Features', href: '#features' },
{ name: 'Pricing', href: '#pricing' },
{ name: 'Documentation', href: DOCS_URL }, { name: 'Documentation', href: DOCS_URL },
{ name: 'Blog', href: BLOG_URL }, { name: 'Blog', href: BLOG_URL },
]; ];
export const features = [ 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', name: 'Auto-magic Auth',
description: 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: '🔐', icon: '🔐',
}, },
{ {
@ -27,25 +33,24 @@ export const features = [
}, },
{ {
name: 'Admin Dashboard', name: 'Admin Dashboard',
description: description: 'Graphs! Tables! Analytics w/ Plausible or Google! All in one place. Ooooooooooh.',
"Graphs! Tables! Analytics! All in one place. Ooooooooooh.",
icon: '📈', icon: '📈',
}, },
{ {
name: 'Email Sending', name: 'Email Sending',
description: 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: '📧', icon: '📧',
}, },
{ {
name: 'OpenAI Integration', name: 'OpenAI API Implemented',
description: description: "Technology is changing rapidly. Ship your new AI-powered app before it's already obsolete!",
"Technology is changing rapidly. Ship your new AI-powered app before it's already obsolete!",
icon: '🤖', icon: '🤖',
}, },
{ {
name: 'Deploy Anywhere. Easily.', 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: '🚀 ', icon: '🚀 ',
}, },
{ {
@ -54,36 +59,58 @@ export const features = [
icon: '🫂', 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', name: 'Max Khamrovskyi',
id: TierIds.HOBBY, role: 'Senior Eng @ Red Hat',
priceMonthly: '$9.99', avatarSrc: 'https://pbs.twimg.com/profile_images/1719397191205179392/V_QrGPSO_400x400.jpg',
description: 'All you need to get started', socialUrl: 'https://twitter.com/maksim36ua',
features: ['Limited monthly usage', 'Basic support'], 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', name: 'Tim Skaggs',
id: TierIds.PRO, role: 'Founder @ Antler US',
priceMonthly: '$19.99', avatarSrc: 'https://pbs.twimg.com/profile_images/1486119226771480577/VptdEo6A_400x400.png',
description: 'Our most popular plan', socialUrl: 'https://twitter.com/tskaggs',
features: ['Unlimited monthly usage', 'Priority customer support'], quote: 'Nearly done with a MVP in 3 days of part-time work... and deployed on Fly.io in 10 minutes.',
bestDeal: true,
}, },
// {
// 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', name: 'Jonathan Cocharan',
id: TierIds.ENTERPRISE, role: 'Entrepreneur',
priceMonthly: '$500', avatarSrc: 'https://pbs.twimg.com/profile_images/926142421653753857/o6Hmcbr7_400x400.jpg',
description: 'Big business means big money', socialUrl: 'https://twitter.com/jonathancocharan',
features: ['Unlimited monthly usage', '24/7 customer support', 'Advanced analytics'], 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 = [ export const faqs = [
{ {
id: 1, id: 1,
question: "Why is this amazing SaaS Template free and open-source?", question: 'Why is this amazing SaaS Template free and open-source?',
answer: answer: 'Because open-source is cool, and we love you ❤️',
"Because open-source is cool, and we love you ❤️",
}, },
{ {
id: 2, id: 2,
@ -94,8 +121,7 @@ export const faqs = [
]; ];
export const footerNavigation = { export const footerNavigation = {
app: [ app: [
{ name: 'Pricing', href: '#pricing' }, { name: 'Documentation', href: DOCS_URL },
{ name: 'Documentation', href: DOCS_URL },
{ name: 'Blog', href: BLOG_URL }, { name: 'Blog', href: BLOG_URL },
], ],
company: [ company: [

View File

@ -124,7 +124,9 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, GptResponse> =
}); });
} }
console.error(error); 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);
} }
}; };