├── components
├── Logo
│ ├── index.js
│ └── Logo.js
└── AppLayout
│ ├── index.js
│ └── AppLayout.js
├── .eslintrc.json
├── public
├── 3.jpg
├── favicon.ico
├── favicon.png
└── vercel.svg
├── pages
├── api
│ ├── auth
│ │ └── [...auth0].js
│ ├── hello.js
│ ├── deletePost.js
│ ├── getPosts.js
│ ├── addTokens.js
│ ├── webhooks
│ │ └── stripe.js
│ └── generatePost.js
├── success.js
├── index.js
├── token-topup.js
├── _app.js
└── post
│ ├── new.js
│ └── [postId].js
├── postcss.config.js
├── next.config.js
├── .env.local.sample
├── jest.setup.js
├── .gitignore
├── tailwind.config.js
├── __tests__
└── index.test.js
├── jest.config.js
├── lib
└── mongodb.js
├── utils
└── getAppProps.js
├── styles
└── globals.css
├── package.json
├── context
└── postsContext.js
└── README.md
/components/Logo/index.js:
--------------------------------------------------------------------------------
1 | export * from './Logo';
--------------------------------------------------------------------------------
/components/AppLayout/index.js:
--------------------------------------------------------------------------------
1 | export * from './AppLayout';
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/public/3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chris-aqui/blog-post-generator/HEAD/public/3.jpg
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chris-aqui/blog-post-generator/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chris-aqui/blog-post-generator/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/pages/api/auth/[...auth0].js:
--------------------------------------------------------------------------------
1 | import { handleAuth } from "@auth0/nextjs-auth0";
2 |
3 | export default handleAuth();
4 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/pages/api/hello.js:
--------------------------------------------------------------------------------
1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
2 |
3 | export default function handler(req, res) {
4 | res.status(200).json({ name: 'John Doe' })
5 | }
6 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | images: {
5 | domains: ['s.gravatar.com'],
6 | },
7 | }
8 |
9 | module.exports = nextConfig
10 |
--------------------------------------------------------------------------------
/.env.local.sample:
--------------------------------------------------------------------------------
1 | OPENAI_API_KEY=
2 | AUTH0_SECRET=
3 | AUTH0_BASE_URL=
4 | AUTH0_ISSUER_BASE_URL=
5 | AUTH0_CLIENT_ID=
6 | AUTH0_CLIENT_SECRET=
7 | MONGODB_URI=
8 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
9 | STRIPE_SECRET_KEY=
10 | STRIPE_WEBHOOK_SECRET=
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | // Optional: configure or set up a testing framework before each test.
2 | // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js`
3 |
4 | // Used for __tests__/testing-library.js
5 | // Learn more: https://github.com/testing-library/jest-dom
6 | import '@testing-library/jest-dom/extend-expect'
7 |
--------------------------------------------------------------------------------
/components/Logo/Logo.js:
--------------------------------------------------------------------------------
1 | import { faBrain } from '@fortawesome/free-solid-svg-icons';
2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3 |
4 | export const Logo = () => {
5 | return (
6 |
7 | BlogBrain
8 |
9 |
10 | );
11 | };
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | './app/**/*.{js,ts,jsx,tsx}',
5 | './pages/**/*.{js,ts,jsx,tsx}',
6 | './components/**/*.{js,ts,jsx,tsx}',
7 | // Or if using `src` directory:
8 | './src/**/*.{js,ts,jsx,tsx}',
9 | ],
10 | theme: {
11 | extend: {
12 | fontFamily: {
13 | body: 'var(--font-dm-sans)',
14 | heading: 'var(--font-dm-serif)',
15 | },
16 | },
17 | },
18 | plugins: [],
19 | };
20 |
--------------------------------------------------------------------------------
/pages/success.js:
--------------------------------------------------------------------------------
1 | import { withPageAuthRequired } from '@auth0/nextjs-auth0';
2 | import { AppLayout } from '../components/AppLayout';
3 | import { getAppProps } from '../utils/getAppProps';
4 |
5 | export default function Success() {
6 | return (
7 |
8 |
Thank you for your purchase!
9 |
10 | );
11 | }
12 |
13 | Success.getLayout = function getLayout(page, pageProps) {
14 | return {page};
15 | };
16 |
17 | export const getServerSideProps = withPageAuthRequired({
18 | async getServerSideProps(ctx) {
19 | const props = await getAppProps(ctx);
20 | return {
21 | props,
22 | };
23 | },
24 | });
25 |
--------------------------------------------------------------------------------
/__tests__/index.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 | import Home from '../pages/index';
4 |
5 | describe('Home', () => {
6 | it('renders the description', () => {
7 | const { getByText } = render();
8 | const description = getByText(
9 | 'The AI-powered SAAS solution to generate SEO-optimized blog posts in minutes. Get high-quality content, without sacrificing your time.'
10 | );
11 | expect(description).toBeInTheDocument();
12 | });
13 |
14 | it('renders the "Begin" button', () => {
15 | const { getByText } = render();
16 | const button = getByText('Begin');
17 | expect(button).toBeInTheDocument();
18 | });
19 | });
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | const nextJest = require('next/jest')
2 |
3 | const createJestConfig = nextJest({
4 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
5 | dir: './',
6 | })
7 |
8 | // Add any custom config to be passed to Jest
9 | const customJestConfig = {
10 | setupFilesAfterEnv: ['/jest.setup.js'],
11 | testEnvironment: 'jest-environment-jsdom',
12 | transform: {
13 | '^.+\\.js$': 'babel-jest',
14 | '^.+\\.mjs$': 'babel-jest',
15 | '^.+\\.jsx$': 'babel-jest',
16 | },
17 | }
18 |
19 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
20 | module.exports = createJestConfig(customJestConfig)
21 |
--------------------------------------------------------------------------------
/pages/api/deletePost.js:
--------------------------------------------------------------------------------
1 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0';
2 | import { ObjectId } from 'mongodb';
3 | import clientPromise from '../../lib/mongodb';
4 |
5 | export default withApiAuthRequired(async function handler(req, res) {
6 | try {
7 | const {
8 | user: { sub },
9 | } = await getSession(req, res);
10 | const client = await clientPromise;
11 | const db = client.db('BlogBrain');
12 | const userProfile = await db.collection('users').findOne({
13 | auth0Id: sub,
14 | });
15 |
16 | const { postId } = req.body;
17 |
18 | await db.collection('posts').deleteOne({
19 | userId: userProfile._id,
20 | _id: new ObjectId(postId),
21 | });
22 |
23 | res.status(200).json({ success: true });
24 | } catch (e) {
25 | console.log('ERROR TRYING TO DELETE A POST: ', e);
26 | }
27 | return;
28 | });
29 |
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 |
2 | import Image from 'next/image';
3 | import Link from 'next/link';
4 | import { Logo } from '../components/Logo';
5 | import HeroImage from '../public/3.jpg'
6 |
7 | export default function Home() {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 | The AI-powered SAAS solution to generate SEO-optimized blog posts in
15 | minutes. Get high-quality content, without sacrificing your time.
16 |
17 |
18 | Begin
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/pages/api/getPosts.js:
--------------------------------------------------------------------------------
1 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0';
2 | import clientPromise from '../../lib/mongodb';
3 |
4 | export default withApiAuthRequired(async function handler(req, res) {
5 | try {
6 | const {
7 | user: { sub },
8 | } = await getSession(req, res);
9 | const client = await clientPromise;
10 | const db = client.db('BlogBrain');
11 | const userProfile = await db.collection('users').findOne({
12 | auth0Id: sub,
13 | });
14 |
15 | const { lastPostDate, getNewerPosts } = req.body;
16 |
17 | const posts = await db
18 | .collection('posts')
19 | .find({
20 | userId: userProfile._id,
21 | created: { [getNewerPosts ? '$gt' : '$lt']: new Date(lastPostDate) },
22 | })
23 | .limit(getNewerPosts ? 0 : 5)
24 | .sort({ created: -1 })
25 | .toArray();
26 |
27 | res.status(200).json({ posts });
28 | return;
29 | } catch (e) {}
30 | });
31 |
--------------------------------------------------------------------------------
/lib/mongodb.js:
--------------------------------------------------------------------------------
1 | import { MongoClient } from 'mongodb';
2 |
3 | if (!process.env.MONGODB_URI) {
4 | throw new Error('Invalid/Missing environment variable: "MONGODB_URI"');
5 | }
6 |
7 | const uri = process.env.MONGODB_URI;
8 |
9 | let client;
10 | let clientPromise;
11 |
12 | if (process.env.NODE_ENV === 'development') {
13 | // In development mode, use a global variable so that the value
14 | // is preserved across module reloads caused by HMR (Hot Module Replacement).
15 | if (!global._mongoClientPromise) {
16 | client = new MongoClient(uri);
17 | global._mongoClientPromise = client.connect();
18 | }
19 | clientPromise = global._mongoClientPromise;
20 | } else {
21 | // In production mode, it's best to not use a global variable.
22 | client = new MongoClient(uri);
23 | clientPromise = client.connect();
24 | }
25 |
26 | // Export a module-scoped MongoClient promise. By doing this in a
27 | // separate module, the client can be shared across functions.
28 | export default clientPromise;
29 |
--------------------------------------------------------------------------------
/utils/getAppProps.js:
--------------------------------------------------------------------------------
1 | import { getSession } from '@auth0/nextjs-auth0';
2 | import clientPromise from '../lib/mongodb';
3 |
4 | export const getAppProps = async (ctx) => {
5 | const userSession = await getSession(ctx.req, ctx.res);
6 | const client = await clientPromise;
7 | const db = client.db('BlogBrain');
8 | const user = await db.collection('users').findOne({
9 | auth0Id: userSession.user.sub,
10 | });
11 |
12 | if (!user) {
13 | return {
14 | availableTokens: 0,
15 | posts: [],
16 | };
17 | }
18 |
19 | const posts = await db
20 | .collection('posts')
21 | .find({
22 | userId: user._id,
23 | })
24 | .limit(5)
25 | .sort({
26 | created: -1,
27 | })
28 | .toArray();
29 |
30 | return {
31 | availableTokens: user.availableTokens,
32 | posts: posts.map(({ created, _id, userId, ...rest }) => ({
33 | _id: _id.toString(),
34 | created: created.toString(),
35 | ...rest,
36 | })),
37 | postId: ctx.params?.postId || null,
38 | };
39 | };
40 |
--------------------------------------------------------------------------------
/pages/token-topup.js:
--------------------------------------------------------------------------------
1 | import { withPageAuthRequired } from '@auth0/nextjs-auth0';
2 | import { AppLayout } from '../components/AppLayout';
3 | import { getAppProps } from '../utils/getAppProps';
4 |
5 | export default function TokenTopup() {
6 | const handleClick = async () => {
7 | const result = await fetch(`/api/addTokens`, {
8 | method: 'POST',
9 | });
10 | const json = await result.json();
11 | console.log('RESULT: ', json);
12 | window.location.href = json.session.url;
13 | };
14 |
15 | return (
16 |
17 |
this is the token topup
18 |
21 |
22 | );
23 | }
24 |
25 | TokenTopup.getLayout = function getLayout(page, pageProps) {
26 | return {page};
27 | };
28 |
29 | export const getServerSideProps = withPageAuthRequired({
30 | async getServerSideProps(ctx) {
31 | const props = await getAppProps(ctx);
32 | return {
33 | props,
34 | };
35 | },
36 | });
37 |
--------------------------------------------------------------------------------
/pages/api/addTokens.js:
--------------------------------------------------------------------------------
1 | import { getSession } from '@auth0/nextjs-auth0';
2 | import clientPromise from '../../lib/mongodb';
3 | import stripeInit from 'stripe';
4 |
5 | const stripe = stripeInit(process.env.STRIPE_SECRET_KEY);
6 |
7 | export default async function handler(req, res) {
8 | const { user } = await getSession(req, res);
9 |
10 | const lineItems = [
11 | {
12 | price: process.env.STRIPE_PRODUCT_PRICE_ID,
13 | quantity: 1,
14 | },
15 | ];
16 |
17 | const protocol =
18 | process.env.NODE_ENV === 'development' ? 'http://' : 'https://';
19 | const host = req.headers.host;
20 |
21 | const checkoutSession = await stripe.checkout.sessions.create({
22 | line_items: lineItems,
23 | mode: 'payment',
24 | success_url: `${protocol}${host}/success`,
25 | payment_intent_data: {
26 | metadata: {
27 | sub: user.sub,
28 | },
29 | },
30 | metadata: {
31 | sub: user.sub,
32 | },
33 | });
34 |
35 | // console.log('user: ', user);
36 |
37 | res.status(200).json({ session: checkoutSession });
38 | }
39 |
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | a:hover {
7 | @apply underline;
8 | }
9 |
10 | h1,
11 | h2,
12 | h3,
13 | h4,
14 | h5,
15 | h6 {
16 | @apply font-heading my-6 font-bold;
17 | }
18 |
19 | h1 {
20 | @apply text-4xl;
21 | }
22 |
23 | h2 {
24 | @apply text-3xl;
25 | }
26 | h3 {
27 | @apply text-2xl;
28 | }
29 | h4 {
30 | @apply text-xl;
31 | }
32 | h5 {
33 | @apply text-lg;
34 | }
35 |
36 | p {
37 | @apply my-2;
38 | }
39 |
40 | ul,
41 | ol {
42 | @apply my-4;
43 | }
44 |
45 | ul {
46 | list-style-type: disc;
47 | }
48 |
49 | ol {
50 | list-style-type: decimal;
51 | }
52 |
53 | li {
54 | @apply my-2;
55 | }
56 | }
57 |
58 | @layer components {
59 | .btn {
60 | @apply disabled:bg-green-200 disabled:cursor-not-allowed hover:no-underline bg-green-500 tracking-wider w-full text-center text-white font-bold cursor-pointer uppercase px-4 py-2 rounded-md hover:bg-green-600 transition-colors block;
61 | }
62 | }
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pages/_app.js:
--------------------------------------------------------------------------------
1 | import '../styles/globals.css';
2 | import { UserProvider } from '@auth0/nextjs-auth0/client';
3 | import { DM_Sans, DM_Serif_Display } from '@next/font/google';
4 | import '@fortawesome/fontawesome-svg-core/styles.css';
5 | import { config } from '@fortawesome/fontawesome-svg-core';
6 | import { PostsProvider } from '../context/postsContext';
7 | // Prevent fontawesome from adding its CSS since we did it manually above:
8 | config.autoAddCss = false;
9 |
10 | const dmSans = DM_Sans({
11 | weight: ['400', '500', '700'],
12 | subsets: ['latin'],
13 | variable: '--font-dm-sans',
14 | });
15 |
16 | const dmSerifDisplay = DM_Serif_Display({
17 | weight: ['400'],
18 | subsets: ['latin'],
19 | variable: '--font-dm-serif',
20 | });
21 |
22 | function MyApp({ Component, pageProps }) {
23 | const getLayout = Component.getLayout || ((page) => page);
24 | return (
25 |
26 |
27 |
30 | {getLayout(, pageProps)}
31 |
32 |
33 |
34 | );
35 | }
36 |
37 | export default MyApp;
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "saas_blogbrain",
3 | "version": "1.0.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "test": "jest --watch",
11 | "test:ci": "jest --ci"
12 | },
13 | "dependencies": {
14 | "@auth0/nextjs-auth0": "^2.2.1",
15 | "@fortawesome/fontawesome-svg-core": "^6.2.1",
16 | "@fortawesome/free-solid-svg-icons": "^6.2.1",
17 | "@fortawesome/react-fontawesome": "^0.2.0",
18 | "@next/font": "^13.3.2",
19 | "@webdeveducation/next-verify-stripe": "^1.0.1",
20 | "eslint-config-wesbos": "^3.2.3",
21 | "micro-cors": "^0.1.1",
22 | "mongodb": "^4.13.0",
23 | "next": "13.3.1",
24 | "numeral": "^2.0.6",
25 | "openai": "^3.2.1",
26 | "react": "18.2.0",
27 | "react-dom": "18.2.0",
28 | "stripe": "^11.8.0"
29 | },
30 | "devDependencies": {
31 | "@testing-library/jest-dom": "5.16.4",
32 | "@testing-library/react": "14.0.0",
33 | "@testing-library/user-event": "14.4.3",
34 | "@types/testing-library__jest-dom": "5.14.5",
35 | "autoprefixer": "^10.4.13",
36 | "eslint": "8.33.0",
37 | "eslint-config-next": "13.1.6",
38 | "jest": "29.5.0",
39 | "jest-environment-jsdom": "29.5.0",
40 | "jest-transform-stub": "^2.0.0",
41 | "postcss": "^8.4.21",
42 | "tailwindcss": "^3.2.4"
43 | },
44 | "eslintConfig": {
45 | "extends": [
46 | "wesbos"
47 | ],
48 | "rules": {
49 | "no-console": 2
50 | },
51 | "prettier/prettier": [
52 | "error",
53 | {
54 | "singleQuote": true,
55 | "endOfLine": "auto",
56 | "tabWidth": 4
57 | }
58 | ]
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/pages/api/webhooks/stripe.js:
--------------------------------------------------------------------------------
1 | import Cors from 'micro-cors';
2 | import stripeInit from 'stripe';
3 | import verifyStripe from '@webdeveducation/next-verify-stripe';
4 | import clientPromise from '../../../lib/mongodb';
5 |
6 | const cors = Cors({
7 | allowMethods: ['POST', 'HEAD'],
8 | });
9 |
10 | export const config = {
11 | api: {
12 | bodyParser: false,
13 | },
14 | };
15 |
16 | const stripe = stripeInit(process.env.STRIPE_SECRET_KEY);
17 | const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
18 |
19 | const handler = async (req, res) => {
20 | if (req.method === 'POST') {
21 | let event;
22 | try {
23 | event = await verifyStripe({
24 | req,
25 | stripe,
26 | endpointSecret,
27 | });
28 | } catch (e) {
29 | res.status(500).json({ error: e.message })
30 | console.log('ERROR: ', e);
31 | }
32 |
33 | switch (event.type) {
34 | case 'payment_intent.succeeded': {
35 | const client = await clientPromise;
36 | const db = client.db('BlogBrain');
37 |
38 | const paymentIntent = event.data.object;
39 | const auth0Id = paymentIntent.metadata.sub;
40 |
41 | console.log('AUTH 0 ID: ', paymentIntent);
42 | console.log('paymentIntent: ', paymentIntent)
43 | console.log('auth0Id: ', auth0Id)
44 |
45 | const userProfile = await db.collection('users').updateOne(
46 | {
47 | auth0Id,
48 | },
49 | {
50 | $inc: {
51 | availableTokens: 10,
52 | },
53 | $setOnInsert: {
54 | auth0Id,
55 | },
56 | },
57 | {
58 | upsert: true,
59 | }
60 | );
61 | console.log('USER PROFILE: ', userProfile);
62 | break;
63 | }
64 | default:
65 | console.log('UNHANDLED EVENT: ', event.type);
66 | break;
67 | }
68 | res.status(200).json({ received: true });
69 | }
70 | };
71 |
72 | export default cors(handler);
73 |
--------------------------------------------------------------------------------
/context/postsContext.js:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import React, { useCallback, useReducer, useState } from 'react';
3 |
4 | const PostsContext = React.createContext({});
5 |
6 | export default PostsContext;
7 |
8 | function postsReducer(state, action) {
9 | switch (action.type) {
10 | case 'addPosts': {
11 | const newPosts = [...state];
12 | action.posts.forEach((post) => {
13 | const exists = newPosts.find((p) => p._id === post._id);
14 | if (!exists) {
15 | newPosts.push(post);
16 | }
17 | });
18 | return newPosts;
19 | }
20 | case 'deletePost': {
21 | const newPosts = [];
22 | state.forEach((post) => {
23 | if (post._id !== action.postId) {
24 | newPosts.push(post);
25 | }
26 | });
27 | return newPosts;
28 | }
29 | default:
30 | return state;
31 | }
32 | }
33 |
34 | export const PostsProvider = ({ children }) => {
35 | const [posts, dispatch] = useReducer(postsReducer, []);
36 | const [noMorePosts, setNoMorePosts] = useState(false);
37 |
38 | const deletePost = useCallback((postId) => {
39 | dispatch({
40 | type: 'deletePost',
41 | postId,
42 | });
43 | }, []);
44 |
45 | const setPostsFromSSR = useCallback((postsFromSSR = []) => {
46 | dispatch({
47 | type: 'addPosts',
48 | posts: postsFromSSR,
49 | });
50 | }, []);
51 |
52 | const getPosts = useCallback(
53 | async ({ lastPostDate, getNewerPosts = false }) => {
54 | const result = await fetch(`/api/getPosts`, {
55 | method: 'POST',
56 | headers: {
57 | 'content-type': 'application/json',
58 | },
59 | body: JSON.stringify({ lastPostDate, getNewerPosts }),
60 | });
61 | const json = await result.json();
62 | const postsResult = json.posts || [];
63 | if (postsResult.length < 5) {
64 | setNoMorePosts(true);
65 | }
66 | dispatch({
67 | type: 'addPosts',
68 | posts: postsResult,
69 | });
70 | },
71 | []
72 | );
73 |
74 | return (
75 |
78 | {children}
79 |
80 | );
81 | };
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | A fictional Software-As-A-Service (SAAS) product called "BlogBrain".
2 |
3 | BlogBrain is a platform that allows users to create and generate blog posts using OpenAI's GPT API. It is built on a combination of Next.js, MongoDB, Auth0, Stripe, and Tailwind CSS.
4 |
5 | Users can create an account with Auth0 and purchase tokens with Stripe. These tokens can then be used to generate blog posts. The generated blog posts and the user's tokens are stored in MongoDB.
6 |
7 | Here is a more detailed breakdown of the technologies used in BlogBrain:
8 |
9 | - Next.js: A React framework that is used to build dynamic web pages.
10 | - OpenAI's GPT: A large language model that can be used to generate text, translate languages, write different kinds of creative content, and answer your questions in an informative way.
11 | - MongoDB: A document-oriented database that is used to store user data, such as tokens and generated blog posts.
12 | - Auth0: An identity and access management platform that is used to authenticate and authorize users.
13 | - Stripe: A payment processing platform that is used to handle payments for tokens.
14 | - Tailwind CSS: A utility-first CSS framework that is used to style the BlogBrain platform.
15 |
16 | ## Getting Started
17 |
18 | First, run the development server:
19 |
20 | ```bash
21 | npm run dev
22 | # or
23 | yarn dev
24 | ```
25 |
26 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
27 |
28 | ## todo
29 | - add test
30 |
31 | ## Screenshots
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | ## Learn More
48 |
49 | To learn more about Next.js, take a look at the following resources:
50 |
51 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
52 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
53 |
54 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
55 |
56 | ## Deploy on Vercel
57 |
58 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
59 |
60 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
61 |
--------------------------------------------------------------------------------
/components/AppLayout/AppLayout.js:
--------------------------------------------------------------------------------
1 | import { useUser } from '@auth0/nextjs-auth0/client';
2 | import { faCoins } from '@fortawesome/free-solid-svg-icons';
3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
4 | import Image from 'next/image';
5 | import Link from 'next/link';
6 | import { useContext, useEffect } from 'react';
7 | import PostsContext from '../../context/postsContext';
8 | import { Logo } from '../Logo';
9 |
10 | export const AppLayout = ({
11 | children,
12 | availableTokens,
13 | posts: postsFromSSR,
14 | postId,
15 | postCreated,
16 | }) => {
17 | const { user } = useUser();
18 |
19 | const { setPostsFromSSR, posts, getPosts, noMorePosts } = useContext(PostsContext);
20 |
21 | useEffect(() => {
22 | setPostsFromSSR(postsFromSSR);
23 | if (postId) {
24 | const exists = postsFromSSR.find((post) => post._id === postId);
25 | if (!exists) {
26 | getPosts({ getNewerPosts: true, lastPostDate: postCreated });
27 | }
28 | }
29 | }, [postsFromSSR, setPostsFromSSR, postId, postCreated, getPosts]);
30 |
31 | return (
32 |
33 | {children}
34 |
35 |
36 |
37 |
41 | New post
42 |
43 |
44 |
45 | {availableTokens} tokens available
46 |
47 |
48 |
49 | {posts?.map((post) => (
50 |
56 | {post.topic}
57 |
58 | ))}
59 | {!noMorePosts && (
60 |
{
62 | getPosts({ lastPostDate: posts[posts.length - 1].created });
63 | }}
64 | className="hover:underline text-sm text-slate-400 text-center cursor-pointer mt-4"
65 | >
66 | Load more posts
67 |
68 | )}
69 |
70 |
71 | {!!user ? (
72 | <>
73 |
74 |
81 |
82 |
83 |
{user.email}
84 |
85 | Logout
86 |
87 |
88 | >
89 | ) : (
90 |
Login
91 | )}
92 |
93 |
94 | {/* {children} */}
95 |
96 | );
97 | };
--------------------------------------------------------------------------------
/pages/post/new.js:
--------------------------------------------------------------------------------
1 | import { withPageAuthRequired } from '@auth0/nextjs-auth0';
2 | import { faBrain } from '@fortawesome/free-solid-svg-icons';
3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
4 | import { useRouter } from 'next/router';
5 | import { useState } from 'react';
6 | import { AppLayout } from '../../components/AppLayout';
7 | import { getAppProps } from '../../utils/getAppProps';
8 |
9 | export default function NewPost(props) {
10 | const router = useRouter();
11 | const [topic, setTopic] = useState('');
12 | const [keywords, setKeywords] = useState('');
13 | const [generating, setGenerating] = useState(false);
14 |
15 | const handleSubmit = async (e) => {
16 | e.preventDefault();
17 | setGenerating(true);
18 | try {
19 | const response = await fetch(`/api/generatePost`, {
20 | method: 'POST',
21 | headers: {
22 | 'content-type': 'application/json',
23 | },
24 | body: JSON.stringify({ topic, keywords }),
25 | });
26 | const json = await response.json();
27 | if (response.ok) {
28 | // console.log('RESULT: ', json);
29 | if (json?.postId) {
30 | router.push(`/post/${json.postId}`);
31 | }
32 | } else {
33 | console.error('Server error:', json);
34 | }
35 | } catch (e) {
36 | console.error('Network error:', e);
37 | } finally {
38 | setGenerating(false);
39 | }
40 | };
41 |
42 |
43 | return (
44 |
45 | {!!generating && (
46 |
47 |
48 |
Generating...
49 |
50 | )}
51 | {!generating && (
52 |
91 | )}
92 |
93 | );
94 | }
95 |
96 | NewPost.getLayout = function getLayout(page, pageProps) {
97 | return {page};
98 | };
99 |
100 | export const getServerSideProps = withPageAuthRequired({
101 | async getServerSideProps(ctx) {
102 | const props = await getAppProps(ctx);
103 |
104 | if (!props.availableTokens) {
105 | return {
106 | redirect: {
107 | destination: '/token-topup',
108 | permanent: false,
109 | },
110 | };
111 | }
112 |
113 | return {
114 | props,
115 | };
116 | },
117 | });
--------------------------------------------------------------------------------
/pages/post/[postId].js:
--------------------------------------------------------------------------------
1 | import { getSession, withPageAuthRequired } from '@auth0/nextjs-auth0';
2 | import { faHashtag } from '@fortawesome/free-solid-svg-icons';
3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
4 | import { ObjectId } from 'mongodb';
5 | import { useRouter } from 'next/router';
6 | import { useContext, useState } from 'react';
7 | import { AppLayout } from '../../components/AppLayout';
8 | import PostsContext from '../../context/postsContext';
9 | import clientPromise from '../../lib/mongodb';
10 | import { getAppProps } from '../../utils/getAppProps';
11 |
12 | export default function Post(props) {
13 | console.log('PROPS: ', props);
14 | const router = useRouter();
15 | const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
16 | const { deletePost } = useContext(PostsContext);
17 |
18 | const handleDeleteConfirm = async () => {
19 | try {
20 | const response = await fetch(`/api/deletePost`, {
21 | method: 'POST',
22 | headers: {
23 | 'content-type': 'application/json',
24 | },
25 | body: JSON.stringify({ postId: props.id }),
26 | });
27 | const json = await response.json();
28 | if (json.success) {
29 | deletePost(props.id);
30 | router.replace(`/post/new`);
31 | }
32 | } catch (e) {}
33 | };
34 |
35 | return (
36 |
37 |
38 |
39 | SEO title and meta description
40 |
41 |
42 |
{props.title}
43 |
{props.metaDescription}
44 |
45 |
46 | Keywords
47 |
48 |
49 | {props.keywords.split(',').map((keyword, i) => (
50 |
51 | {keyword}
52 |
53 | ))}
54 |
55 |
56 | Blog post
57 |
58 |
59 |
60 | {!showDeleteConfirm && (
61 |
67 | )}
68 | {!!showDeleteConfirm && (
69 |
70 |
71 | Are you sure you want to delete this post? This action is
72 | irreversible
73 |
74 |
75 |
81 |
87 |
88 |
89 | )}
90 |
91 |
92 |
93 | );
94 | }
95 |
96 | Post.getLayout = function getLayout(page, pageProps) {
97 | return {page};
98 | };
99 |
100 | export const getServerSideProps = withPageAuthRequired({
101 | async getServerSideProps(ctx) {
102 | const props = await getAppProps(ctx);
103 | const userSession = await getSession(ctx.req, ctx.res);
104 | const client = await clientPromise;
105 | const db = client.db('BlogBrain');
106 | const user = await db.collection('users').findOne({
107 | auth0Id: userSession.user.sub,
108 | });
109 | const post = await db.collection('posts').findOne({
110 | _id: new ObjectId(ctx.params.postId),
111 | userId: user._id,
112 | });
113 |
114 | if (!post) {
115 | return {
116 | redirect: {
117 | destination: '/post/new',
118 | permanent: false,
119 | },
120 | };
121 | }
122 |
123 | return {
124 | props: {
125 | id: ctx.params.postId,
126 | postContent: post.postContent,
127 | title: post.title,
128 | metaDescription: post.metaDescription,
129 | keywords: post.keywords,
130 | postCreated: post.created.toString(),
131 | ...props,
132 | },
133 | };
134 | },
135 | });
136 |
--------------------------------------------------------------------------------
/pages/api/generatePost.js:
--------------------------------------------------------------------------------
1 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0';
2 | import { Configuration, OpenAIApi } from 'openai';
3 | import clientPromise from '../../lib/mongodb';
4 |
5 | export default withApiAuthRequired(async function handler(req, res) {
6 | const { user } = await getSession(req, res);
7 | const client = await clientPromise;
8 | const db = client.db('BlogBrain');
9 | const userProfile = await db.collection('users').findOne({
10 | auth0Id: user.sub,
11 | });
12 |
13 | if (!userProfile?.availableTokens) {
14 | res.status(403);
15 | return;
16 | }
17 |
18 | const config = new Configuration({
19 | apiKey: process.env.OPENAI_API_KEY,
20 | });
21 | const openai = new OpenAIApi(config);
22 |
23 | const { topic, keywords } = req.body;
24 |
25 | if (!topic || !keywords) {
26 | res.status(422);
27 | return;
28 | }
29 |
30 | if (topic.length > 80 || keywords.length > 80) {
31 | res.status(422);
32 | return;
33 | }
34 |
35 | /*const response = await openai.createCompletion({
36 | model: 'text-davinci-003',
37 | temperature: 0,
38 | max_tokens: 3600,
39 | prompt: `Write a long and detailed SEO-friendly blog post about ${topic}, that targets the following comma-separated keywords: ${keywords}.
40 | The content should be formatted in SEO-friendly HTML.
41 | The response must also include appropriate HTML title and meta description content.
42 | The return format must be stringified JSON in the following format:
43 | {
44 | "postContent": post content here
45 | "title": title goes here
46 | "metaDescription": meta description goes here
47 | }`,
48 | });*/
49 |
50 | const postContentResult = await openai.createChatCompletion({
51 | model: 'gpt-3.5-turbo',
52 | messages: [
53 | {
54 | role: 'system',
55 | content: 'You are a blog post generator.',
56 | },
57 | {
58 | role: 'user',
59 | content: `Write a long and detailed SEO-friendly blog post about ${topic}, that targets the following comma-separated keywords: ${keywords}.
60 | The response should be formatted in SEO-friendly HTML,
61 | limited to the following HTML tags: p, h1, h2, h3, h4, h5, h6, strong, i, ul, li, ol.`,
62 | },
63 | ],
64 | temperature: 0,
65 | });
66 |
67 | const postContent = postContentResult.data.choices[0]?.message.content;
68 |
69 | const titleResult = await openai.createChatCompletion({
70 | model: 'gpt-3.5-turbo',
71 | messages: [
72 | {
73 | role: 'system',
74 | content: 'You are a blog post generator.',
75 | },
76 | {
77 | role: 'user',
78 | content: `Write a long and detailed SEO-friendly blog post about ${topic}, that targets the following comma-separated keywords: ${keywords}.
79 | The response should be formatted in SEO-friendly HTML,
80 | limited to the following HTML tags: p, h1, h2, h3, h4, h5, h6, strong, i, ul, li, ol.`,
81 | },
82 | {
83 | role: 'assistant',
84 | content: postContent,
85 | },
86 | {
87 | role: 'user',
88 | content: 'Generate appropriate title tag text for the above blog post',
89 | },
90 | ],
91 | temperature: 0,
92 | });
93 |
94 | const metaDescriptionResult = await openai.createChatCompletion({
95 | model: 'gpt-3.5-turbo',
96 | messages: [
97 | {
98 | role: 'system',
99 | content: 'You are a blog post generator.',
100 | },
101 | {
102 | role: 'user',
103 | content: `Write a long and detailed SEO-friendly blog post about ${topic}, that targets the following comma-separated keywords: ${keywords}.
104 | The response should be formatted in SEO-friendly HTML,
105 | limited to the following HTML tags: p, h1, h2, h3, h4, h5, h6, strong, i, ul, li, ol.`,
106 | },
107 | {
108 | role: 'assistant',
109 | content: postContent,
110 | },
111 | {
112 | role: 'user',
113 | content:
114 | 'Generate SEO-friendly meta description content for the above blog post',
115 | },
116 | ],
117 | temperature: 0,
118 | });
119 |
120 | const title = titleResult.data.choices[0]?.message.content;
121 | const metaDescription =
122 | metaDescriptionResult.data.choices[0]?.message.content;
123 |
124 | console.log('POST CONTENT: ', postContent);
125 | console.log('TITLE: ', title);
126 | console.log('META DESCRIPTION: ', metaDescription);
127 |
128 | await db.collection('users').updateOne(
129 | {
130 | auth0Id: user.sub,
131 | },
132 | {
133 | $inc: {
134 | availableTokens: -1,
135 | },
136 | }
137 | );
138 |
139 | const post = await db.collection('posts').insertOne({
140 | postContent: postContent || '',
141 | title: title || '',
142 | metaDescription: metaDescription || '',
143 | topic,
144 | keywords,
145 | userId: userProfile._id,
146 | created: new Date(),
147 | });
148 |
149 | res.status(200).json({
150 | postId: post.insertedId,
151 | });
152 | });
153 |
--------------------------------------------------------------------------------