Skip to content

Commit

Permalink
move pricing & fix testimonials
Browse files Browse the repository at this point in the history
  • Loading branch information
vincanger committed Dec 12, 2023
1 parent 7423313 commit 46605cb
Show file tree
Hide file tree
Showing 8 changed files with 356 additions and 308 deletions.
9 changes: 3 additions & 6 deletions app/.env.server.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,17 @@ 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=
# if not explicitly set to true, emails will be logged to console but not actually sent during development
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...
Expand Down
6 changes: 6 additions & 0 deletions app/main.wasp
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ app SaaSTemplate {
additionalFields: import setAdminUsers from "@server/auth/setAdminUsers.js",
},
onAuthFailedRedirectTo: "/",
onAuthSucceededRedirectTo: "/gpt",
},
db: {
system: PostgreSQL,
Expand Down Expand Up @@ -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,
Expand Down
190 changes: 98 additions & 92 deletions app/src/client/app/GptPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
152 changes: 152 additions & 0 deletions app/src/client/app/PricingPage.tsx
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 46605cb

Please sign in to comment.