This commit is contained in:
vincanger 2023-03-29 12:31:46 +02:00
commit fe6633e658
35 changed files with 1588 additions and 0 deletions

3
.gitignore vendored Normal file

@ -0,0 +1,3 @@
/.wasp/
/.env.server
/.env.client

1
.wasproot Normal file

@ -0,0 +1 @@
File marking the root of Wasp project.

32
README.md Normal file

@ -0,0 +1,32 @@
# Wasp SaaS Template w/ GPT API, Google Auth, Tailwind, & Stripe Payments
<img src='src/client/static/gptsaastemplate.png' width='700px'/>
## Running it locally
After cloning this repo, you can run it locally by following these steps:
1. Install [Wasp](https://wasp-lang.dev) by running `curl -sSL https://get.wasp-lang.dev/installer.sh | sh` in your terminal.
2. Create a `.env.server` file in the root of the project
3. Copy the `env.example` file contents to `.env.server` and fill in your API keys
4. Run `wasp db migrate-dev`
5. Run `wasp start`
6. Go to `localhost:3000` in your browser (your NodeJS server will be running on port `3001`)
7. Install the Wasp extension for VSCode to get syntax highlighting on the `main.wasp` file and other features
8. Check the files for comments containing specific instructions
9. Enjoy and Have fun. When you create an App with this template, be kind and let me know by tagging me on twitter [@hot_town](https://twitter.com/hot_town)
## How it works
- 🐝 [Wasp](https://wasp-lang.dev) - allows you to build full-stack apps with 10x less boilerplate
- 🎨 [Tailwind CSS](https://tailwindcss.com/) - CSS that's easy to work with
- 🤖 [OpenAI](https://openai.com/) - GPT-3.5 turbo API
- 💸 [Stripe](https://stripe.com/) - for payments
- 📧 [SendGrid](https://sendgrid.com/) - for email
[Wasp](https://wasp-lang.dev) as the full-stack framework allows you to describe your apps core features in the `main.wasp` config file in the root directory. Then it builds and glues these features into a React-Express-Prisma app for you so that you can focus on writing the client and server-side logic instead of configuring. For example, I did not have to use any third-party libraries for Google Authentication. I just wrote a couple lines of code in the config file stating that I want to use Google Auth, and Wasp configures it for me. Check out the comments `main.wasp` file for more.
[Stripe](https://stripe.com/) makes the payment functionality super easy. I just used their `Subscription` feature. After the user pays, their `hasPaid` and `datePaid` fields are updated in the database via the webhook found in the `src/server/serverSetup.ts` file.
[Wasp's integrated Jobs](https://wasp-lang.dev/docs/language/features#jobs) feature is used to run a cron job every week to send an newsletter email. I used [SendGrid](https://sendgrid.com/) for the email service.
If you have any other questions, feel free to reach out to me on [twitter](https://twitter.com/hot_town)

11
env.server.example Normal file

@ -0,0 +1,11 @@
DATABASE_URL=
STRIPE_KEY=
SUBSCRIPTION_PRICE_ID=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
OPENAI_API_KEY=
SENDGRID_API_KEY=

180
main.wasp Normal file

@ -0,0 +1,180 @@
app SaaSTemplate {
wasp: {
version: "^0.9.0"
},
title: "My SaaS App",
head: [
"<meta property='og:type' content='website' />",
"<meta property='og:url' content='https://mySaaSapp.com' />",
"<meta property='og:description' content='I made a SaaS App. Buy my stuff.' />",
"<meta property='og:image' content='src/client/static/image.png' />",
// put your google analytics script here, too!
],
// 🔐 Auth out of the box! https://wasp-lang.dev/docs/language/features#authentication--authorization
auth: {
userEntity: User,
externalAuthEntity: SocialLogin,
methods: {
google: { // Guide for setting up Auth via Google https://wasp-lang.dev/docs/integrations/google
getUserFieldsFn: import { getUserFields } from "@server/auth/google.js",
configFn: import { config } from "@server/auth/google.js",
},
},
onAuthFailedRedirectTo: "/",
},
db: {
system: PostgreSQL
},
server: {
setupFn: import serverSetup from "@server/serverSetup.js"
},
client: {
rootComponent: import App from "@client/App",
},
dependencies: [
("@headlessui/react", "1.7.13"),
("@tailwindcss/forms", "^0.5.3"),
("@tailwindcss/typography", "^0.5.7"),
("react-hook-form", "7.43.1"),
("react-icons", "4.8.0"),
("@sendgrid/mail", "7.7.0"),
("request-ip", "3.3.0"),
("@types/request-ip", "0.0.37"),
("node-fetch", "3.3.0"),
("react-hook-form", "7.43.1"),
("stripe", "11.15.0"),
],
}
/* 💽 Wasp defines DB entities via Prisma Database Models:
* https://wasp-lang.dev/docs/language/features#entity
*/
entity User {=psl
id Int @id @default(autoincrement())
username String @unique
email String @unique
password String
stripeId String?
checkoutSessionId String?
hasPaid Boolean @default(false)
sendEmail Boolean @default(false)
datePaid DateTime?
credits Int @default(3)
relatedObject RelatedObject[]
externalAuthAssociations SocialLogin[]
psl=}
entity SocialLogin {=psl
id String @id @default(uuid())
provider String
providerId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
createdAt DateTime @default(now())
@@unique([provider, providerId, userId])
psl=}
// This can be anything. In most cases, this will be your product
entity RelatedObject {=psl
id String @id @default(uuid())
content String
user User @relation(fields: [userId], references: [id])
userId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
psl=}
/* 📡 These are the Wasp Routes (You can protect them easily w/ 'authRequired: true');
* https://wasp-lang.dev/docs/language/features#route
*/
route RootRoute { path: "/", to: MainPage }
page MainPage {
component: import Main from "@client/MainPage"
}
route LoginRoute { path: "/login", to: LoginPage }
page LoginPage {
component: import Login from "@client/Login"
}
route GptRoute { path: "/gpt", to: GptPage }
page GptPage {
authRequired: true,
component: import GptPage from "@client/GptPage"
}
route PricingRoute { path: "/pricing", to: PricingPage }
page PricingPage {
component: import Pricing from "@client/PricingPage"
}
route AccountRoute { path: "/account", to: AccountPage }
page AccountPage {
authRequired: true,
component: import Account from "@client/AccountPage"
}
route CheckoutRoute { path: "/checkout", to: CheckoutPage }
page CheckoutPage {
authRequired: true,
component: import Checkout from "@client/CheckoutPage"
}
/* ⛑ These are the Wasp Operations, which allow the client and server to interact:
* https://wasp-lang.dev/docs/language/features#queries-and-actions-aka-operations
*/
// 📝 Actions aka Mutations
action generateGptResponse {
fn: import { generateGptResponse } from "@server/actions.js",
entities: [User, RelatedObject]
}
action stripePayment {
fn: import { stripePayment } from "@server/actions.js",
entities: [User]
}
// action stripeCreditsPayment {
// fn: import { stripeCreditsPayment } from "@server/actions.js",
// entities: [User]
// }
// action updateUser {
// fn: import { updateUser } from "@server/actions.js",
// entities: [User]
// }
// 📚 Queries
query getRelatedObjects {
fn: import { getRelatedObjects } from "@server/queries.js",
entities: [User, RelatedObject]
}
/* 🕵️‍♂️ These are the Wasp Cron Jobs. Use them to set up recurring tasks and/or queues:
* https://wasp-lang.dev/docs/language/features#jobs
*/
job emailChecker {
executor: PgBoss,
perform: {
fn: import { checkAndQueueEmails } from "@server/workers/checkAndQueueEmails.js"
},
schedule: {
cron: "0 7 * * 1" // at 7:00 am every Monday
},
entities: [User]
}
job emailSender {
executor: PgBoss,
perform: {
fn: import { sendGrid } from "@server/workers/sendGrid.js"
},
entities: [User]
}

@ -0,0 +1,49 @@
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"stripeId" TEXT,
"checkoutSessionId" TEXT,
"hasPaid" BOOLEAN NOT NULL DEFAULT false,
"sendEmail" BOOLEAN NOT NULL DEFAULT false,
"datePaid" TIMESTAMP(3),
"credits" INTEGER NOT NULL DEFAULT 3,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SocialLogin" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SocialLogin_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RelatedObject" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RelatedObject_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "SocialLogin_provider_providerId_userId_key" ON "SocialLogin"("provider", "providerId", "userId");
-- AddForeignKey
ALTER TABLE "SocialLogin" ADD CONSTRAINT "SocialLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RelatedObject" ADD CONSTRAINT "RelatedObject_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail.
- Added the required column `email` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "email" TEXT NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `title` on the `RelatedObject` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "RelatedObject" DROP COLUMN "title";

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

6
postcss.config.cjs Normal file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

3
src/.waspignore Normal file

@ -0,0 +1,3 @@
# Ignore editor tmp files
**/*~
**/#*#

104
src/client/AccountPage.tsx Normal file

@ -0,0 +1,104 @@
import { User } from '@wasp/entities';
import { useQuery } from '@wasp/queries'
import getRelatedObjects from '@wasp/queries/getRelatedObjects'
import logout from '@wasp/auth/logout';
import stripePayment from '@wasp/actions/stripePayment';
import { useState, Dispatch, SetStateAction } from 'react';
// get your own link from your stripe dashboard: https://dashboard.stripe.com/settings/billing/portal
const CUSTOMER_PORTAL_LINK = 'https://billing.stripe.com/p/login/test_8wM8x17JN7DT4zC000';
export default function Example({ user }: { user: User }) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const { data: relatedObjects, isLoading: isLoadingRelatedObjects } = useQuery(getRelatedObjects)
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='px-4 py-5 sm:px-6 lg:px-8'>
<h3 className='text-base font-semibold leading-6 text-gray-900'>Account Information</h3>
</div>
<div className='border-t border-gray-200 px-4 py-5 sm:p-0'>
<dl className='sm:divide-y sm:divide-gray-200'>
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
<dt className='text-sm font-medium text-gray-500'>Email address</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{user.email}</dd>
</div>
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
<dt className='text-sm font-medium text-gray-500'>Your Plan</dt>
{user.hasPaid ? (
<>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-1 sm:mt-0'>Premium Monthly Subscription</dd>
<CustomerPortalButton isLoading={isLoading} setIsLoading={setIsLoading} />
</>
) : (
<>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-1 sm:mt-0'>
Credits remaining: {user.credits}
</dd>
<BuyMoreButton isLoading={isLoading} setIsLoading={setIsLoading} />
</>
)}
</div>
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
<dt className='text-sm font-medium text-gray-500'>About</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>I'm a cool customer.</dd>
</div>
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
<dt className='text-sm font-medium text-gray-500'>Most Recent User RelatedObject</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
{!!relatedObjects
? relatedObjects[relatedObjects.length - 1].content
: "You don't have any at this time."}
</dd>
</div>
</dl>
</div>
</div>
<div className='inline-flex w-full justify-end'>
<button
onClick={logout}
className='inline-flex justify-center mx-8 py-2 px-4 border border-transparent shadow-md text-sm font-medium rounded-md text-white bg-yellow-500 hover:bg-yellow-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'
>
logout
</button>
</div>
</div>
);
}
function BuyMoreButton({ isLoading, setIsLoading }: { isLoading: boolean, setIsLoading: Dispatch<SetStateAction<boolean>> }) {
const handleClick = async () => {
setIsLoading(true);
const stripeResults = await stripePayment();
if (stripeResults) {
window.open(stripeResults.sessionUrl, '_self');
}
setIsLoading(false);
};
return (
<div className='ml-4 flex-shrink-0 sm:col-span-1 sm:mt-0'>
<button onClick={handleClick} className={`font-medium text-sm text-indigo-600 hover:text-indigo-500 ${isLoading && 'animate-pulse'}`}>
{!isLoading ? 'Buy More/Upgrade' : 'Loading...'}
</button>
</div>
);
}
function CustomerPortalButton({ isLoading, setIsLoading }: { isLoading: boolean, setIsLoading: Dispatch<SetStateAction<boolean>> }) {
const handleClick = () => {
setIsLoading(true);
window.open(CUSTOMER_PORTAL_LINK, '_blank');
setIsLoading(false);
};
return (
<div className='ml-4 flex-shrink-0 sm:col-span-1 sm:mt-0'>
<button onClick={handleClick} className={`font-medium text-sm text-indigo-600 hover:text-indigo-500 ${isLoading && 'animate-pulse'}`}>
{!isLoading ? 'Manage Subscription' : 'Loading...'}
</button>
</div>
);
}

17
src/client/App.tsx Normal file

@ -0,0 +1,17 @@
import './Main.css';
import NavBar from './NavBar';
import { ReactNode } from 'react';
export default function App({ children }: { children: ReactNode }) {
/**
* use this component to wrap all child components
* this is useful for templates, themes, and context
* in this case the NavBar will always be rendered
*/
return (
<div>
<NavBar />
<div className='mx-auto max-w-7xl sm:px-6 lg:px-8 '>{children}</div>
</div>
);
}

@ -0,0 +1,51 @@
import { User } from '@wasp/entities';
import { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
export default function CheckoutPage({ user }: { user: User }) {
const [hasPaid, setHasPaid] = useState('loading');
const history = useHistory();
useEffect(() => {
function delayedRedirect() {
return setTimeout(() => {
history.push('/profile');
}, 4000);
}
const urlParams = new URLSearchParams(window.location.search);
const cancel = urlParams.get('canceled');
const success = urlParams.get('success');
const credits = urlParams.get('credits');
if (cancel) {
setHasPaid('canceled');
} else if (success) {
setHasPaid('paid');
} else {
history.push('/profile');
}
delayedRedirect();
return () => {
clearTimeout(delayedRedirect());
};
}, []);
return (
<>
<h1>
{hasPaid === 'paid'
? '🥳 Payment Successful!'
: hasPaid === 'canceled'
? '😢 Payment Canceled'
: hasPaid === 'error' && '🙄 Payment Error'}
</h1>
{hasPaid !== 'loading' && (
<span className='text-center'>
You are being redirected to your profile page... <br />
</span>
)}
</>
);
}

129
src/client/GptPage.tsx Normal file

@ -0,0 +1,129 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { RelatedObject } from '@wasp/entities';
import generateGptResponse from '@wasp/actions/generateGptResponse';
type GptPayload = {
instructions: string;
command: string;
temperature: number;
};
export default function GptPage() {
const [temperature, setTemperature] = useState<number>(1);
const [response, setResponse] = useState<string>('');
const onSubmit = async ({ instructions, command, temperature }: any) => {
try {
const response = (await generateGptResponse({ instructions, command, temperature })) as RelatedObject;
if (response) {
setResponse(response.content)
}
} catch (e) {
alert('Something went wrong. Please try again.');
console.error(e);
}
};
const {
handleSubmit,
register,
reset,
formState: { errors: formErrors, isSubmitting },
} = useForm();
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className='space-y-6 mt-10 sm:w-[90%] md:w-[50%] 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>
<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>
</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>
<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>
);
}

43
src/client/Login.tsx Normal file

@ -0,0 +1,43 @@
import { signInUrl } from '@wasp/auth/helpers/Google';
import { AiOutlineGoogle } from 'react-icons/ai';
import { useHistory } from 'react-router-dom';
import { useEffect } from 'react';
import useAuth from '@wasp/auth/useAuth';
export default function Login() {
const history = useHistory();
const { data: user } = useAuth();
useEffect(() => {
if (user) {
history.push('/');
}
}, [user, history]);
return (
<>
<div className='flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8'>
<div className='sm:mx-auto sm:w-full sm:max-w-md'>
<h2 className='mt-6 text-center text-3xl font-bold tracking-tight text-gray-900'>Sign in to your account</h2>
</div>
<div className='mt-8 sm:mx-auto sm:w-full sm:max-w-md'>
<div className='bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10'>
<div className='mt-6'>
<div>
<a
href={signInUrl}
className='inline-flex w-full justify-center items-center rounded-md bg-white py-2 px-4 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0'
>
<AiOutlineGoogle className='h-5 w-5 mr-2' />
<span >Sign in with Google</span>
</a>
</div>
</div>
</div>
</div>
</div>
</>
);
}

5
src/client/Main.css Normal file

@ -0,0 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* rest of content below */

146
src/client/MainPage.tsx Normal file

@ -0,0 +1,146 @@
export default function MainPage() {
return (
<div>
<div className='mx-auto max-w-7xl pt-10 pb-24 sm:pb-32 lg:grid lg:grid-cols-2 lg:gap-x-8 lg:py-32 lg:px-8'>
<div className='px-6 lg:px-0 lg:pt-4'>
<div className='mx-auto max-w-2xl'>
<div className='max-w-lg'>
<h1 className=' text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl'>SaaS Template</h1>
<a href='https://wasp-lang.dev'>
<h2 className='ml-4 max-w-2xl text-2xl f tracking-tight text-gray-800 slg:col-span-2 xl:col-auto'>
by Wasp &nbsp; {'= }'}
</h2>
</a>
<p className='mt-4 text-lg leading-8 text-gray-600'>
Hey 🧙! This template will help you get a SaaS App up and running in no time. It's got:
</p>
<ul className='list-disc ml-8 my-2 leading-8 text-gray-600'>
<li>Stripe integration</li>
<li>Authentication w/ Google</li>
<li>OpenAI GPT API configuration</li>
<li>Managed Server-Side Routes</li>
<li>Tailwind styling</li>
<li>Client-side Caching</li>
<li>One-command Deploy 🚀</li>
</ul>
<p className='mt-4 text-lg leading-8 text-gray-600'>
Make sure to check out the <code>README.md</code> file before you begin
</p>
<div className='mt-10 flex items-center gap-x-6'>
<a
href='#'
className='rounded-md bg-yellow-500 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-yellow-6 00 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500'
>
Documentation
</a>
<a href='#' className='text-sm font-semibold leading-6 text-gray-900'>
View on GitHub <span aria-hidden='true'></span>
</a>
</div>
</div>
</div>
</div>
<div className='mt-20 sm:mt-24 lg:mx-0 md:mx-auto md:max-w-2xl lg:w-screen lg:mt-0 '>
<div className='shadow-lg md:rounded-3xl relative isolate overflow-hidden'>
<div className='bg-yellow-500 [clip-path:inset(0)] md:[clip-path:inset(0_round_theme(borderRadius.3xl))]'>
<div
className='absolute -inset-y-px -z-10 ml-40 w-[200%] bg-yellow-100 opacity-20 ring-1 ring-inset ring-white '
aria-hidden='true'
/>
<div className='relative px-6 pt-8 sm:pt-16 md:pl-16 md:pr-0'>
<div className='mx-auto max-w-2xl md:mx-0 md:max-w-none'>
<div className='overflow-hidden rounded-tl-xl bg-gray-900'>
<div className='bg-white/40 ring-1 ring-white/5'>
<div className='-mb-px flex text-sm font-medium leading-6 text-gray-400'>
<div className='border-b border-r border-b-white/20 border-r-white/10 bg-white/5 py-2 px-4 text-white'>
main.wasp
</div>
<div className='border-r border-gray-600/10 py-2 px-4'>App.tsx</div>
</div>
</div>
<div className='px-6 pt-6 pb-14 bg-gray-100'>
<code className='language-javascript' style={{ whiteSpace: 'pre' }}>
<span>{'app todoApp {'}</span>
<br />
<span>{' '}</span>
<span style={{ color: '#986801' }}>title</span>
<span>: </span>
<span style={{ color: '#50a14f' }}>"ToDo App"</span>
<span>, </span>
<span style={{ color: '#a0a1a7', fontStyle: 'italic' }}>/* visible in the browser tab */</span>
<br />
<span>{' '}</span>
<span style={{ color: '#986801' }}>auth</span>
<span>{': {'} </span>
<span style={{ color: '#a0a1a7', fontStyle: 'italic' }}>
/* full-stack auth out-of-the-box */
</span>
<br />
<span>{' '}</span>
<span style={{ color: '#986801' }}>userEntity</span>
<span>: User,</span>
<br />
<span>{' '}</span>
<span style={{ color: '#986801' }}>externalAuthEntity</span>
<span>: SocialLogin,</span>
<br />
<span>{' '}</span>
<span style={{ color: '#986801' }}>methods</span>
<span>{': {'}</span>
<br />
<span>{' '}</span>
<span style={{ color: '#986801' }}>google</span>
<span>{': {}'}</span>
<br></br>
{' }'}
<br></br>
{'}'}
{/* */}
<br />
{/* */}
<br />
<span>{'route RootRoute { '}</span>
<span style={{ color: '#986801' }}>path</span>
<span>: </span>
<span style={{ color: '#50a14f' }}>'/'</span>
<span>, </span>
<span style={{ color: '#986801' }}>to</span>
<span>{': MainPage }'}</span>
<br />
{'page MainPage {'}
<span> </span>
<span style={{ color: '#a0a1a7', fontStyle: 'italic' }}>
{'/* Only logged in users can access this. */'}
</span>
<span></span>
<br />
<span>{' '}</span>
<span style={{ color: '#986801' }}>authRequired</span>
<span>: </span>
<span style={{ color: '#0184bb' }}>true</span>
<span>,</span>
<br />
<span>{' '}</span>
<span style={{ color: '#986801' }}>component</span>
<span>: </span>
<span style={{ color: '#a626a4' }}>import</span>
<span> Main </span>
<span style={{ color: '#a626a4' }}>from</span>
<span> </span>
<span style={{ color: '#50a14f' }}>'@client/Main.jsx'</span>
<br />
<span></span>
{'}'}
</code>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

96
src/client/NavBar.tsx Normal file

@ -0,0 +1,96 @@
import logo from './static/logo.png'
import { Disclosure } from '@headlessui/react';
import { AiOutlineBars, AiOutlineClose, AiOutlineUser } from 'react-icons/ai';
import useAuth from '@wasp/auth/useAuth';
const active = 'inline-flex items-center border-b-2 border-indigo-300 px-1 pt-1 text-sm font-medium text-gray-900';
const inactive = 'inline-flex items-center border-b-2 border-transparent px-1 pt-1 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700'
const current = window.location.pathname;
export default function NavBar() {
const { data: user } = useAuth();
console.log(current);
return (
<Disclosure as='nav' className='bg-white shadow sticky top-0 z-50 '>
{({ open }) => (
<>
<div className='mx-auto max-w-7xl px-4 sm:px-6 lg:px-16'>
<div className='flex h-16 justify-between'>
<div className='flex'>
<div className='flex flex-shrink-0 items-center'>
<a href='https://wasp-lang.dev/docs' target='_blank'>
<img className='h-8 w-8' src={logo} alt='My SaaS App' />
</a>
</div>
<div className='hidden sm:ml-6 sm:flex sm:space-x-8'>
<a href='/' className={current === '/' ? active : inactive}>
Landing Page
</a>
<a href='/pricing' className={current.includes('pricing') ? active : inactive}>
Pricing
</a>
<a href='/gpt' className={current.includes('gpt') ? active : inactive}>
GPT
</a>
</div>
</div>
<div className='hidden sm:ml-6 sm:flex sm:space-x-8'>
<a href={!!user ? '/account' : '/login'} className={current === '/account' ? active : inactive}>
<AiOutlineUser className='h-6 w-6 mr-2' />
Account
</a>
</div>
<div className='-mr-2 flex items-center sm:hidden'>
{/* Mobile menu */}
<Disclosure.Button className='inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-300'>
<span className='sr-only'>Open menu</span>
{open ? (
<AiOutlineClose className='block h-6 w-6' aria-hidden='true' />
) : (
<AiOutlineBars className='block h-6 w-6' aria-hidden='true' />
)}
</Disclosure.Button>
</div>
</div>
</div>
<Disclosure.Panel className='sm:hidden'>
<div className='space-y-1 pt-2 pb-3'>
<Disclosure.Button
as='a'
href='/'
className='block border-l-4 border-indigo-300 bg-indigo-50 py-2 pl-3 pr-4 text-base font-medium text-indigo-500'
>
Landing Page
</Disclosure.Button>
<Disclosure.Button
as='a'
href='/pricing'
className='block border-l-4 border-transparent py-2 pl-3 pr-4 text-base font-medium text-gray-500 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-700'
>
Pricing
</Disclosure.Button>
<Disclosure.Button
as='a'
href='/gpt'
className='block border-l-4 border-transparent py-2 pl-3 pr-4 text-base font-medium text-gray-500 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-700'
>
GPT
</Disclosure.Button>
<Disclosure.Button
as='a'
href='/account'
className='block px-4 py-2 text-base font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-800'
>
Account
</Disclosure.Button>
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
}

@ -0,0 +1,91 @@
import { AiOutlineCheck } from 'react-icons/ai';
import stripePayment from '@wasp/actions/stripePayment';
import { useState } from 'react';
const prices = [
{
name: 'Credits',
id: 'credits',
href: '',
price: '$2.95',
description: 'Buy credits to use for your projects.',
features: ['10 credits', 'Use them any time', 'No expiration date'],
disabled: true,
},
{
name: 'Monthly Subscription',
id: 'monthly',
href: '#',
priceMonthly: '$9.99',
description: 'Get unlimited usage for your projects.',
features: ['Unlimited usage of all features', 'Priority support', 'Cancel any time'],
},
];
export default function PricingPage() {
const [isLoading, setIsLoading] = useState(false);
const clickHandler = async () => {
setIsLoading(true);
try {
const response = await stripePayment();
if (response) {
window.open(response.sessionUrl, '_self');
}
} catch (e) {
alert('Something went wrong. Please try again.');
console.error(e);
} finally {
setIsLoading(false);
}
};
return (
<div className='mt-10 pb-24 sm:pb-32'>
<div className='mx-auto max-w-7xl px-6 lg:px-8'>
<div className='mx-auto grid max-w-md grid-cols-1 gap-8 lg:max-w-4xl lg:grid-cols-2'>
{prices.map((price) => (
<div
key={price.id}
className='flex flex-col justify-between rounded-3xl bg-white p-8 shadow-xl ring-1 ring-gray-900/10 sm:p-10'
>
<div>
<h3 id={price.id} className='text-base font-semibold leading-7 text-indigo-600'>
{price.name}
</h3>
<div className='mt-4 flex items-baseline gap-x-2'>
<span className='text-5xl font-bold tracking-tight text-gray-900'>
{price.priceMonthly || price.price}
</span>
{price.priceMonthly && (
<span className='text-base font-semibold leading-7 text-gray-600'>/month</span>
)}
</div>
<p className='mt-6 text-base leading-7 text-gray-600'>{price.description}</p>
<ul role='list' className='mt-10 space-y-4 text-sm leading-6 text-gray-600'>
{price.features.map((feature) => (
<li key={feature} className='flex gap-x-3'>
<AiOutlineCheck className='h-6 w-5 flex-none text-indigo-600' aria-hidden='true' />
{feature}
</li>
))}
</ul>
</div>
<button
onClick={clickHandler}
aria-describedby={price.id}
disabled={price.disabled}
className={`${
price.disabled && 'disabled:opacity-25 disabled:cursor-not-allowed'
} mt-8 block rounded-md bg-yellow-400 px-3.5 py-2 text-center text-sm font-semibold leading-6 text-black shadow-sm hover:bg-yellow-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-yellow-600`}
>
{isLoading ? 'Loading...' : 'Buy Now'}
</button>
</div>
))}
</div>
</div>
</div>
);
}

Binary file not shown.

After

(image error) Size: 420 KiB

BIN
src/client/static/logo.png Normal file

Binary file not shown.

After

(image error) Size: 24 KiB

55
src/client/tsconfig.json Normal file

@ -0,0 +1,55 @@
// =============================== IMPORTANT =================================
//
// This file is only used for Wasp IDE support. You can change it to configure
// your IDE checks, but none of these options will affect the TypeScript
// compiler. Proper TS compiler configuration in Wasp is coming soon :)
{
"compilerOptions": {
// JSX support
"jsx": "preserve",
"strict": true,
// Allow default imports.
"esModuleInterop": true,
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
// Wasp needs the following settings enable IDE support in your source
// files. Editing them might break features like import autocompletion and
// definition lookup. Don't change them unless you know what you're doing.
//
// The relative path to the generated web app's root directory. This must be
// set to define the "paths" option.
"baseUrl": "../../.wasp/out/web-app/",
"paths": {
// Resolve all "@wasp" imports to the generated source code.
"@wasp/*": [
"src/*"
],
// Resolve all non-relative imports to the correct node module. Source:
// https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping
"*": [
// Start by looking for the definiton inside the node modules root
// directory...
"node_modules/*",
// ... If that fails, try to find it inside definitely-typed type
// definitions.
"node_modules/@types/*"
]
},
// Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots
"typeRoots": [
"../../.wasp/out/web-app/node_modules/@types"
],
// Since this TS config is used only for IDE support and not for
// compilation, the following directory doesn't exist. We need to specify
// it to prevent this error:
// https://stackoverflow.com/questions/42609768/typescript-error-cannot-write-file-because-it-would-overwrite-input-file
"outDir": "phantom"
},
"exclude": [
"phantom"
],
}

1
src/client/vite-env.d.ts vendored Normal file

@ -0,0 +1 @@
/// <reference types="../../.wasp/out/web-app/node_modules/vite/client" />

156
src/server/actions.ts Normal file

@ -0,0 +1,156 @@
import HttpError from '@wasp/core/HttpError.js';
import fetch from 'node-fetch';
import type { RelatedObject, User } from '@wasp/entities';
import type {
GenerateGptResponse,
StripePayment,
} from '@wasp/actions/types';
import type { StripePaymentResult, OpenAIResponse } from './types';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15',
});
// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp-lang.dev/docs/deploying
const DOMAIN = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000';
export const stripePayment: StripePayment<string, StripePaymentResult> = async (_args, context) => {
if (!context.user) {
throw new HttpError(401);
}
let customer: Stripe.Customer;
const stripeCustomers = await stripe.customers.list({
email: context.user.email,
});
if (!stripeCustomers.data.length) {
console.log('creating customer');
customer = await stripe.customers.create({
email: context.user.email,
});
} else {
console.log('using existing customer');
customer = stripeCustomers.data[0];
}
const session: Stripe.Checkout.Session = await stripe.checkout.sessions.create({
line_items: [
{
price: process.env.SUBSCRIPTION_PRICE_ID!,
quantity: 1,
},
],
mode: 'subscription',
success_url: `${DOMAIN}/checkout?success=true`,
cancel_url: `${DOMAIN}/checkout?canceled=true`,
automatic_tax: { enabled: true },
customer_update: {
address: 'auto',
},
customer: customer.id,
});
await context.entities.User.update({
where: {
id: context.user.id,
},
data: {
checkoutSessionId: session?.id ?? null,
stripeId: customer.id ?? null,
},
});
return new Promise((resolve, reject) => {
if (!session) {
reject(new HttpError(402, 'Could not create a Stripe session'));
} else {
resolve({
sessionUrl: session.url,
sessionId: session.id,
});
}
});
};
type GptPayload = {
instructions: string;
command: string;
temperature: number;
};
export const generateGptResponse: GenerateGptResponse<GptPayload, RelatedObject> = async (
{ instructions, command, temperature },
context
) => {
if (!context.user) {
throw new HttpError(401);
}
const payload = {
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: instructions,
},
{
role: 'user',
content: command,
},
],
temperature: Number(temperature),
};
try {
if (!context.user.hasPaid && !context.user.credits) {
throw new HttpError(402, 'User has not paid or is out of credits');
} else if (context.user.credits && !context.user.hasPaid) {
console.log('decrementing credits');
await context.entities.User.update({
where: { id: context.user.id },
data: {
credits: {
decrement: 1,
},
},
});
}
console.log('fetching', payload)
const response = await fetch('https://api.openai.com/v1/chat/completions', {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY!}`,
},
method: 'POST',
body: JSON.stringify(payload),
});
const json = (await response.json()) as OpenAIResponse
console.log('response json', json)
return context.entities.RelatedObject.create({
data: {
content: json?.choices[0].message.content,
user: { connect: { id: context.user.id } },
},
});
} catch (error) {
if (!context.user.hasPaid) {
await context.entities.User.update({
where: { id: context.user.id },
data: {
credits: {
increment: 1,
},
},
});
}
console.error(error);
}
return new Promise((resolve, reject) => {
reject(new HttpError(500, 'Something went wrong'));
});
};

18
src/server/auth/google.ts Normal file

@ -0,0 +1,18 @@
// More info on auth config: https://wasp-lang.dev/docs/language/features#social-login-providers-oauth-20
export async function getUserFields(_context: unknown, args: any) {
console.log('args', args.profile)
const username = args.profile.emails[0].value
const email = args.profile.emails[0].value
return { username, email };
}
export function config() {
const clientID = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
return {
clientID, // look up from env or elsewhere,
clientSecret, // look up from env or elsewhere,
scope: ['profile', 'email'], // must include at least 'profile' for Google
};
}

16
src/server/queries.ts Normal file

@ -0,0 +1,16 @@
import HttpError from '@wasp/core/HttpError.js';
import type { RelatedObject } from '@wasp/entities';
import type { GetRelatedObjects } from '@wasp/queries/types';
export const getRelatedObjects: GetRelatedObjects<unknown, RelatedObject[]> = async (args, context) => {
if (!context.user) {
throw new HttpError(401);
}
return context.entities.RelatedObject.findMany({
where: {
user: {
id: context.user.id
}
},
})
}

132
src/server/serverSetup.ts Normal file

@ -0,0 +1,132 @@
import type { ServerSetupFnContext } from '@wasp/types';
import Stripe from 'stripe';
import { PrismaClient } from '@prisma/client';
import { emailSender } from '@wasp/jobs/emailSender.js';
import requestIp from 'request-ip';
export const STRIPE_WEBHOOK_IPS = [
'3.18.12.63',
'3.130.192.231',
'13.235.14.237',
'13.235.122.149',
'18.211.135.69',
'35.154.171.200',
'52.15.183.38',
'54.88.130.119',
'54.88.130.237',
'54.187.174.169',
'54.187.205.235',
'54.187.216.72',
];
export const prisma = new PrismaClient();
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15',
});
/** 🪝 Server Setup
* This is a custom API endpoint that is used to handle Stripe webhooks.
* Wasp will setup all the other endpoints for you automatically
* based on your queries and actions in the main.wasp file 🎉
*/
export default async function ({ app, server }: ServerSetupFnContext) {
// this just tests that the sendgrid worker is working correctly
// it can be removed here after sendgrid is properly configured
// await emailSender.submit({
// to: 'your@email.com',
// subject: 'Test',
// text: 'Test',
// html: 'Test',
// });
app.post('/stripe-webhook', async (request, response) => {
if (process.env.NODE_ENV === 'production') {
const detectedIp = requestIp.getClientIp(request) as string;
const isStripeIP = STRIPE_WEBHOOK_IPS.includes(detectedIp);
if (!isStripeIP) {
console.log('IP address not from Stripe: ', detectedIp);
return response.status(403).json({ received: false });
}
}
let event: Stripe.Event = request.body;
let userStripeId: string | null = null;
// console.log('event', event)
if (event.type === 'invoice.paid') {
const charge = event.data.object as Stripe.Invoice;
userStripeId = charge.customer as string;
if (charge.amount_paid === 999) {
console.log('Subscription purchased: ', charge.amount_paid);
await prisma.user.updateMany({
where: {
stripeId: userStripeId,
},
data: {
hasPaid: true,
},
});
}
if (charge.amount_paid === 295) {
console.log('Credits purchased: ', charge.amount_paid);
await prisma.user.updateMany({
where: {
stripeId: userStripeId,
},
data: {
credits: {
increment: 10,
},
},
});
}
} else if (event.type === 'customer.subscription.updated') {
const subscription = event.data.object as Stripe.Subscription;
userStripeId = subscription.customer as string;
if (subscription.cancel_at_period_end) {
const customerEmail = await prisma.user.findFirst({
where: {
stripeId: userStripeId,
},
select: {
email: true,
},
});
if (customerEmail) {
await emailSender.submit({
to: customerEmail.email,
subject: 'We hate to see you go :(',
text: 'We hate to see you go. Here is a sweet offer...',
html: 'We hate to see you go. Here is a sweet offer...',
});
}
}
} else if (event.type === 'customer.subscription.deleted' || event.type === 'customer.subscription.canceled') {
const subscription = event.data.object as Stripe.Subscription;
userStripeId = subscription.customer as string;
console.log('Subscription canceled');
await prisma.user.updateMany({
where: {
stripeId: userStripeId,
},
data: {
hasPaid: false,
},
});
} else {
console.log(`Unhandled event type ${event.type}`);
}
// Return a 200 response to acknowledge receipt of the event
response.json({ received: true });
});
}

48
src/server/tsconfig.json Normal file

@ -0,0 +1,48 @@
// =============================== IMPORTANT =================================
//
// This file is only used for Wasp IDE support. You can change it to configure
// your IDE checks, but none of these options will affect the TypeScript
// compiler. Proper TS compiler configuration in Wasp is coming soon :)
{
"compilerOptions": {
// Allows default imports.
"esModuleInterop": true,
"allowJs": true,
"strict": true,
// Wasp needs the following settings enable IDE support in your source
// files. Editing them might break features like import autocompletion and
// definition lookup. Don't change them unless you know what you're doing.
//
// The relative path to the generated web app's root directory. This must be
// set to define the "paths" option.
"baseUrl": "../../.wasp/out/server/",
"paths": {
// Resolve all "@wasp" imports to the generated source code.
"@wasp/*": [
"src/*"
],
// Resolve all non-relative imports to the correct node module. Source:
// https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping
"*": [
// Start by looking for the definiton inside the node modules root
// directory...
"node_modules/*",
// ... If that fails, try to find it inside definitely-typed type
// definitions.
"node_modules/@types/*"
]
},
// Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots
"typeRoots": [
"../../.wasp/out/server/node_modules/@types"
],
// Since this TS config is used only for IDE support and not for
// compilation, the following directory doesn't exist. We need to specify
// it to prevent this error:
// https://stackoverflow.com/questions/42609768/typescript-error-cannot-write-file-because-it-would-overwrite-input-file
"outDir": "phantom",
},
"exclude": [
"phantom"
],
}

34
src/server/types.ts Normal file

@ -0,0 +1,34 @@
import { User, Prisma } from '@prisma/client';
export type Context = {
user: User;
entities: {
User: Prisma.UserDelegate<{}>;
};
};
export type StripePaymentResult = {
sessionUrl: string | null;
sessionId: string;
};
export type OpenAIResponse = {
id: string;
object: string;
created: number;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
choices: [
{
index: number;
message: {
role: string;
content: string;
};
finish_reason: string;
}
];
};

0
src/server/utils.ts Normal file

@ -0,0 +1,55 @@
import { emailSender } from '@wasp/jobs/emailSender.js';
import type { Email } from './sendGrid';
import type { Context } from '../types';
const emailToSend: Email = {
to: '',
subject: 'The SaaS App Newsletter',
text: "Hey There! \n\nThis is just a newsletter that sends automatically via cron jobs",
html: `<html lang="en">
<head>
<meta charset="UTF-8">
<title>SaaS App Newsletter</title>
</head>
<body>
<p>Hey There!</p>
<p>This is just a newsletter that sends automatically via cron jobs</p>
</body>
</html>`,
};
export async function checkAndQueueEmails(_args: unknown, context: Context) {
const currentDate = new Date();
const twoWeeksFromNow = new Date(currentDate.getTime() + 14 * 24 * 60 * 60 * 1000);
console.log('Starting CRON JOB: \n\nSending expiration notices...');
const users = await context.entities.User.findMany({
where: {
datePaid: {
equals: twoWeeksFromNow,
},
sendEmail: true,
},
});
console.log('Sending expiration notices to users: ', users.length);
if (users.length === 0) {
console.log('No users to send expiration notices to.');
return;
}
await Promise.allSettled(
users.map(async (user) => {
if (user.email) {
try {
emailToSend.to = user.email;
await emailSender.submit(emailToSend);
} catch (error) {
console.error('Error sending expiration notice to user: ', user.id, error);
}
}
})
);
}

@ -0,0 +1,47 @@
import SendGrid from '@sendgrid/mail';
export type Email = {
from?: EmailFromField;
to: string;
subject: string;
text: string;
html: string;
};
export type EmailFromField = {
name?: string;
email: string;
};
export type SendGridProvider = {
type: 'sendgrid';
apiKey: string;
};
SendGrid.setApiKey(process.env.SENDGRID_API_KEY!);
const MyAppName: string = 'MyAppName';
const MyEmail: string = 'email@saasapp.com';
export async function sendGrid(email: Email, context: any) {
const fromField = {
name: MyAppName,
email: MyEmail,
};
try {
const sentEmail = await SendGrid.send({
from: {
email: fromField.email,
name: fromField.name,
},
to: email.to,
subject: email.subject,
text: email.text,
html: email.html,
});
console.log('Email sent: ', sentEmail);
} catch (error) {
console.error('Error sending email: ', error);
}
}

28
src/shared/tsconfig.json Normal file

@ -0,0 +1,28 @@
{
"compilerOptions": {
// Enable default imports in TypeScript.
"esModuleInterop": true,
"allowJs": true,
// The following settings enable IDE support in user-provided source files.
// Editing them might break features like import autocompletion and
// definition lookup. Don't change them unless you know what you're doing.
//
// The relative path to the generated web app's root directory. This must be
// set to define the "paths" option.
"baseUrl": "../../.wasp/out/server/",
"paths": {
// Resolve all non-relative imports to the correct node module. Source:
// https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping
"*": [
// Start by looking for the definiton inside the node modules root
// directory...
"node_modules/*",
// ... If that fails, try to find it inside definitely-typed type
// definitions.
"node_modules/@types/*"
]
},
// Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots
"typeRoots": ["../../.wasp/out/server/node_modules/@types"]
}
}

8
tailwind.config.cjs Normal file

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {},
},
plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography'), require('@tailwindcss/forms')],
};