mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-04-05 02:20:20 +02:00
💁
This commit is contained in:
commit
fe6633e658
.gitignore.wasprootREADME.mdenv.server.examplemain.wasp
migrations
postcss.config.cjssrc
.waspignore
tailwind.config.cjsclient
AccountPage.tsxApp.tsxCheckoutPage.tsxGptPage.tsxLogin.tsxMain.cssMainPage.tsxNavBar.tsxPricingPage.tsx
static
tsconfig.jsonvite-env.d.tsserver
shared
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/.wasp/
|
||||
/.env.server
|
||||
/.env.client
|
1
.wasproot
Normal file
1
.wasproot
Normal file
@ -0,0 +1 @@
|
||||
File marking the root of Wasp project.
|
32
README.md
Normal file
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 app’s 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
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
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]
|
||||
}
|
49
migrations/20230327110535_init/migration.sql
Normal file
49
migrations/20230327110535_init/migration.sql
Normal file
@ -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;
|
12
migrations/20230327110727_email/migration.sql
Normal file
12
migrations/20230327110727_email/migration.sql
Normal file
@ -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");
|
8
migrations/20230329081150_remove_title/migration.sql
Normal file
8
migrations/20230329081150_remove_title/migration.sql
Normal file
@ -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";
|
3
migrations/migration_lock.toml
Normal file
3
migrations/migration_lock.toml
Normal file
@ -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
6
postcss.config.cjs
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
3
src/.waspignore
Normal file
3
src/.waspignore
Normal file
@ -0,0 +1,3 @@
|
||||
# Ignore editor tmp files
|
||||
**/*~
|
||||
**/#*#
|
104
src/client/AccountPage.tsx
Normal file
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
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>
|
||||
);
|
||||
}
|
51
src/client/CheckoutPage.tsx
Normal file
51
src/client/CheckoutPage.tsx
Normal file
@ -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
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
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
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
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 {'= }'}
|
||||
</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
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>
|
||||
);
|
||||
}
|
91
src/client/PricingPage.tsx
Normal file
91
src/client/PricingPage.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
BIN
src/client/static/gptsaastemplate.png
Normal file
BIN
src/client/static/gptsaastemplate.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 420 KiB |
BIN
src/client/static/logo.png
Normal file
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
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
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
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
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
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
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
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
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
src/server/utils.ts
Normal file
55
src/server/workers/checkAndQueueEmails.ts
Normal file
55
src/server/workers/checkAndQueueEmails.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);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
47
src/server/workers/sendGrid.ts
Normal file
47
src/server/workers/sendGrid.ts
Normal file
@ -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
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
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')],
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user