├── .eslintrc.json ├── app ├── favicon.ico ├── sign-in │ └── [[...sign-in]] │ │ └── page.tsx ├── sign-up │ └── [[...sign-up]] │ │ └── page.tsx ├── api │ ├── thread │ │ └── route.ts │ ├── assistant │ │ ├── route.ts │ │ └── create │ │ │ └── route.ts │ ├── send-notifications │ │ └── route.ts │ ├── run │ │ ├── retrieve │ │ │ └── route.ts │ │ └── create │ │ │ └── route.ts │ ├── message │ │ ├── list │ │ │ └── route.ts │ │ └── create │ │ │ └── route.ts │ ├── openai │ │ └── route.ts │ ├── challenge-preferences │ │ └── route.ts │ ├── user-thread │ │ └── route.ts │ ├── subscription │ │ └── route.ts │ └── challenge-users │ │ └── route.ts ├── layout.tsx ├── (app) │ ├── profile │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx └── globals.css ├── public ├── icon-192x192.png ├── icon-256x256.png ├── icon-384x384.png ├── icon-512x512.png ├── custom-service-worker.js ├── vercel.svg ├── manifest.json └── next.svg ├── postcss.config.js ├── lib ├── utils.ts └── prismadb.ts ├── atoms.ts ├── components.json ├── next.config.js ├── .gitignore ├── tailwind.config.ts ├── tsconfig.json ├── middleware.ts ├── components ├── DifficultyCard.tsx ├── Navbar.tsx ├── ui │ ├── switch.tsx │ └── button.tsx ├── NotificationModal.tsx └── ProfileContainer.tsx ├── prisma └── schema.prisma ├── package.json ├── README.md ├── hooks └── useServiceWorker.tsx └── tailwind.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhancockio/david-goggins-ai-coach-yt/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhancockio/david-goggins-ai-coach-yt/HEAD/public/icon-192x192.png -------------------------------------------------------------------------------- /public/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhancockio/david-goggins-ai-coach-yt/HEAD/public/icon-256x256.png -------------------------------------------------------------------------------- /public/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhancockio/david-goggins-ai-coach-yt/HEAD/public/icon-384x384.png -------------------------------------------------------------------------------- /public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhancockio/david-goggins-ai-coach-yt/HEAD/public/icon-512x512.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /atoms.ts: -------------------------------------------------------------------------------- 1 | import { Assistant, UserThread } from "@prisma/client"; 2 | import { atom } from "jotai"; 3 | 4 | export const userThreadAtom = atom(null); 5 | export const assistantAtom = atom(null); 6 | -------------------------------------------------------------------------------- /app/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /app/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /app/api/thread/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import OpenAI from "openai"; 3 | 4 | export async function POST() { 5 | const openai = new OpenAI(); 6 | const thread = await openai.beta.threads.create(); 7 | 8 | return NextResponse.json({ thread }, { status: 201 }); 9 | } 10 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const withPwa = require("next-pwa")({ 3 | dest: "public", 4 | swSrc: "/public/custom-service-worker.js", 5 | register: true, 6 | skipWaiting: true, 7 | buildExcludes: [/middleware-manifest.json$/, /app-build-manifest.json$/], 8 | disable: process.env.NODE_ENV === "development", 9 | }); 10 | 11 | const nextConfig = withPwa({ 12 | reactStrictMode: true, 13 | }); 14 | 15 | module.exports = nextConfig; 16 | -------------------------------------------------------------------------------- /lib/prismadb.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const globalForPrisma = globalThis as unknown as { 4 | prisma: PrismaClient | undefined; 5 | }; 6 | 7 | export const prismadb = 8 | globalForPrisma.prisma ?? 9 | new PrismaClient({ 10 | log: 11 | process.env.NODE_ENV === "development" 12 | ? ["query", "error", "warn"] 13 | : ["error"], 14 | }); 15 | 16 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prismadb; 17 | -------------------------------------------------------------------------------- /public/custom-service-worker.js: -------------------------------------------------------------------------------- 1 | // This will be replaced by Workbox with the list of assets to precache 2 | self.__WB_MANIFEST; 3 | 4 | self.addEventListener("push", function (event) { 5 | const data = event.data.text(); 6 | const title = data || "A new message!"; 7 | const options = { 8 | body: data.body || "You have a new notification.", 9 | icon: "/icons/icon-192x192.png", 10 | badge: "/icons/badge-72x72.png", 11 | }; 12 | 13 | event.waitUntil(self.registration.showNotification(title, options)); 14 | }); 15 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /app/api/assistant/route.ts: -------------------------------------------------------------------------------- 1 | import { prismadb } from "@/lib/prismadb"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export async function GET() { 5 | const assistants = await prismadb.assistant.findMany(); 6 | 7 | console.log(assistants); 8 | 9 | if (assistants.length === 0) { 10 | return NextResponse.json( 11 | { error: "No assistants found", success: false }, 12 | { status: 500 } 13 | ); 14 | } 15 | 16 | return NextResponse.json( 17 | { assistant: assistants[0], success: true }, 18 | { status: 200 } 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | } 20 | export default config 21 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import { ClerkProvider } from "@clerk/nextjs"; 5 | 6 | const inter = Inter({ subsets: ["latin"] }); 7 | 8 | export const metadata: Metadata = { 9 | title: "Goggins AI", 10 | description: "David Goggins AI Coach", 11 | manifest: "/manifest.json", 12 | icons: { apple: "/icon-192x192.png" }, 13 | }; 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: { 18 | children: React.ReactNode; 19 | }) { 20 | return ( 21 | 22 | 23 | {children} 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme_color": "#f69435", 3 | "background_color": "#f69435", 4 | "display": "standalone", 5 | "scope": "/", 6 | "start_url": "/", 7 | "name": "David Goggins AI Coach", 8 | "short_name": "Goggins AI", 9 | "icons": [ 10 | { 11 | "src": "/icon-192x192.png", 12 | "sizes": "192x192", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/icon-256x256.png", 17 | "sizes": "256x256", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "/icon-384x384.png", 22 | "sizes": "384x384", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "/icon-512x512.png", 27 | "sizes": "512x512", 28 | "type": "image/png" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { authMiddleware } from "@clerk/nextjs"; 2 | 3 | // This example protects all routes including api/trpc routes 4 | // Please edit this to allow other routes to be public as needed. 5 | // See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware 6 | export default authMiddleware({ 7 | publicRoutes: [ 8 | "/api/assistant/create", 9 | "/api/thread", 10 | "/api/message/create", 11 | "/api/message/list", 12 | "/api/run/create", 13 | "/api/run/retrieve", 14 | "/api/challenge-users", 15 | "/api/openai", 16 | "/api/send-notifications", 17 | ], 18 | }); 19 | 20 | export const config = { 21 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"], 22 | }; 23 | -------------------------------------------------------------------------------- /app/api/send-notifications/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import webPush from "web-push"; 3 | 4 | webPush.setVapidDetails( 5 | "mailto:brandon@brandonhancock.io", 6 | process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY as string, 7 | process.env.VAPID_PRIVATE_KEY as string 8 | ); 9 | 10 | export async function POST(request: Request) { 11 | const { subscription, message } = await request.json(); 12 | 13 | console.log("Sending push notification to", subscription, message); 14 | 15 | try { 16 | await webPush.sendNotification(subscription, message); 17 | return NextResponse.json({ success: true }); 18 | } catch (e) { 19 | console.log("Error sending push notification", e); 20 | return NextResponse.json( 21 | { success: false, message: "error sending push notification." }, 22 | { status: 500 } 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/api/run/retrieve/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import OpenAI from "openai"; 3 | 4 | export async function POST(req: NextRequest) { 5 | const { threadId, runId } = await req.json(); 6 | 7 | if (!threadId || !runId) { 8 | return NextResponse.json( 9 | { error: "threadId and runId are required", success: false }, 10 | { status: 400 } 11 | ); 12 | } 13 | 14 | const openai = new OpenAI(); 15 | try { 16 | const run = await openai.beta.threads.runs.retrieve(threadId, runId); 17 | 18 | console.log("from openai run", run); 19 | 20 | return NextResponse.json({ run, success: true }, { status: 200 }); 21 | } catch (error) { 22 | console.error(error); 23 | return NextResponse.json( 24 | { error: "Something went wrong", success: false }, 25 | { status: 500 } 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/api/message/list/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import OpenAI from "openai"; 3 | 4 | export async function POST(req: Request) { 5 | const { threadId } = await req.json(); 6 | 7 | if (!threadId) { 8 | return NextResponse.json( 9 | { error: "threadId is required", success: false }, 10 | { status: 400 } 11 | ); 12 | } 13 | 14 | const openai = new OpenAI(); 15 | 16 | try { 17 | const response = await openai.beta.threads.messages.list(threadId); 18 | 19 | console.log("from openai messages", response.data); 20 | 21 | return NextResponse.json( 22 | { messages: response.data, success: true }, 23 | { status: 200 } 24 | ); 25 | } catch (error) { 26 | console.error(error); 27 | return NextResponse.json( 28 | { error: "Something went wrong", success: false }, 29 | { status: 500 } 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/api/run/create/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import OpenAI from "openai"; 3 | 4 | export async function POST(req: NextRequest) { 5 | const { threadId, assistantId } = await req.json(); 6 | 7 | if (!threadId || !assistantId) { 8 | return NextResponse.json( 9 | { error: "threadId and assistantId are required", success: false }, 10 | { status: 400 } 11 | ); 12 | } 13 | 14 | const openai = new OpenAI(); 15 | 16 | try { 17 | const run = await openai.beta.threads.runs.create(threadId, { 18 | assistant_id: assistantId, 19 | }); 20 | 21 | console.log("from openai run", run); 22 | 23 | return NextResponse.json({ run, success: true }, { status: 201 }); 24 | } catch (error) { 25 | console.error(error); 26 | return NextResponse.json( 27 | { error: "Something went wrong", success: false }, 28 | { status: 500 } 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/(app)/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import ProfileContainer from "@/components/ProfileContainer"; 2 | import { prismadb } from "@/lib/prismadb"; 3 | import { currentUser } from "@clerk/nextjs"; 4 | import React from "react"; 5 | 6 | export default async function ProfilePage() { 7 | const user = await currentUser(); 8 | 9 | if (!user) { 10 | throw new Error("No user"); 11 | } 12 | 13 | let challengePreferences = await prismadb.challengePreferences.findUnique({ 14 | where: { 15 | userId: user.id, 16 | }, 17 | }); 18 | 19 | if (!challengePreferences) { 20 | challengePreferences = await prismadb.challengePreferences.create({ 21 | data: { 22 | userId: user.id, 23 | challengeId: "EASY", 24 | }, 25 | }); 26 | } 27 | 28 | return ( 29 |
30 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /components/DifficultyCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const selectedStyle = "ring-2 ring-yellow-500 bg-yellow-500 bg-opacity-10"; 4 | const unselectedStyle = "hover:bg-gray-100"; 5 | 6 | interface DifficultyCardProps { 7 | level: string; 8 | description: string; 9 | selected: boolean; 10 | onSelect: () => void; 11 | } 12 | 13 | function DifficultyCard({ 14 | level, 15 | description, 16 | selected, 17 | onSelect, 18 | }: DifficultyCardProps) { 19 | return ( 20 |
26 |

31 | {level} 32 |

33 |

{description}

34 |
35 | ); 36 | } 37 | 38 | export default DifficultyCard; 39 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "mysql" 3 | url = env("DATABASE_URL") 4 | relationMode = "prisma" 5 | } 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | } 10 | 11 | model Assistant { 12 | id String @id @default(uuid()) 13 | assistantId String @unique 14 | } 15 | 16 | model UserThread { 17 | id String @id @default(uuid()) 18 | userId String @unique 19 | threadId String 20 | createdAt DateTime @default(now()) 21 | } 22 | 23 | model ChallengePreferences { 24 | id String @id @default(uuid()) 25 | userId String @unique 26 | challengeId String 27 | sendNotifications Boolean @default(true) 28 | createdAt DateTime @default(now()) 29 | updatedAt DateTime @updatedAt 30 | } 31 | 32 | model UserMeta { 33 | id String @id @default(uuid()) 34 | userId String @unique 35 | createdAt DateTime @default(now()) 36 | updatedAt DateTime @updatedAt 37 | endpoint String @db.Text 38 | p256dh String @db.Text 39 | auth String @db.Text 40 | } 41 | -------------------------------------------------------------------------------- /app/api/message/create/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import OpenAI from "openai"; 3 | 4 | export async function POST(req: Request) { 5 | const { message, threadId, fromUser = false } = await req.json(); 6 | 7 | console.log("from user", { message, threadId }); 8 | 9 | if (!threadId || !message) { 10 | return NextResponse.json( 11 | { error: "threadId and message are required", success: false }, 12 | { status: 400 } 13 | ); 14 | } 15 | 16 | const openai = new OpenAI(); 17 | 18 | try { 19 | const threadMessage = await openai.beta.threads.messages.create(threadId, { 20 | role: "user", 21 | content: message, 22 | metadata: { 23 | fromUser, 24 | }, 25 | }); 26 | 27 | console.log("from openai", threadMessage); 28 | 29 | return NextResponse.json( 30 | { message: threadMessage, success: true }, 31 | { status: 201 } 32 | ); 33 | } catch (error) { 34 | console.error(error); 35 | return NextResponse.json( 36 | { error: "Something went wrong", success: false }, 37 | { status: 500 } 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { UserButton } from "@clerk/nextjs"; 4 | import Link from "next/link"; 5 | import { usePathname } from "next/navigation"; 6 | import React from "react"; 7 | 8 | const routes = [ 9 | { 10 | name: "Chat", 11 | path: "/", 12 | }, 13 | { 14 | name: "Profile", 15 | path: "/profile", 16 | }, 17 | ]; 18 | 19 | function Navbar() { 20 | const pathname = usePathname(); 21 | 22 | return ( 23 |
24 | 25 |

Goggins AI

26 | 27 |
28 | {routes.map((route, idx) => ( 29 | 36 | {route.name} 37 | 38 | ))} 39 | 40 | 41 |
42 |
43 | ); 44 | } 45 | 46 | export default Navbar; 47 | -------------------------------------------------------------------------------- /app/api/openai/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import OpenAI from "openai"; 3 | 4 | const openai = new OpenAI(); 5 | 6 | export async function POST(request: Request) { 7 | const { messages, secret } = await request.json(); 8 | 9 | if (!messages || !secret) { 10 | return NextResponse.json( 11 | { success: false, message: "Missing required fields" }, 12 | { 13 | status: 400, 14 | } 15 | ); 16 | } 17 | 18 | if (secret !== process.env.APP_SECRET_KEY) { 19 | return NextResponse.json( 20 | { success: false, message: "Unauthorized" }, 21 | { 22 | status: 401, 23 | } 24 | ); 25 | } 26 | 27 | try { 28 | const completion = await openai.chat.completions.create({ 29 | messages, 30 | model: "gpt-4-0613", 31 | }); 32 | 33 | const newMessage = completion.choices[0].message.content; 34 | 35 | return NextResponse.json( 36 | { success: true, message: newMessage }, 37 | { status: 200 } 38 | ); 39 | } catch (error) { 40 | console.log(error); 41 | return NextResponse.json( 42 | { success: false }, 43 | { 44 | status: 500, 45 | } 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )); 27 | Switch.displayName = SwitchPrimitives.Root.displayName; 28 | 29 | export { Switch }; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "goggins-ai-coach", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "postinstall": "prisma generate" 11 | }, 12 | "dependencies": { 13 | "@clerk/nextjs": "^4.29.3", 14 | "@prisma/client": "^5.8.1", 15 | "@radix-ui/react-slot": "^1.0.2", 16 | "@radix-ui/react-switch": "^1.0.3", 17 | "axios": "^1.6.5", 18 | "class-variance-authority": "^0.7.0", 19 | "clsx": "^2.1.0", 20 | "jotai": "^2.6.2", 21 | "lucide-react": "^0.309.0", 22 | "next": "14.0.4", 23 | "next-pwa": "^5.6.0", 24 | "openai": "^4.24.7", 25 | "prisma": "^5.8.1", 26 | "react": "^18", 27 | "react-dom": "^18", 28 | "react-hot-toast": "^2.4.1", 29 | "tailwind-merge": "^2.2.0", 30 | "tailwindcss-animate": "^1.0.7", 31 | "web-push": "^3.6.7" 32 | }, 33 | "devDependencies": { 34 | "@types/node": "^20", 35 | "@types/react": "^18", 36 | "@types/react-dom": "^18", 37 | "@types/web-push": "^3.6.3", 38 | "autoprefixer": "^10.0.1", 39 | "eslint": "^8", 40 | "eslint-config-next": "14.0.4", 41 | "postcss": "^8", 42 | "tailwindcss": "^3.3.0", 43 | "typescript": "^5" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/challenge-preferences/route.ts: -------------------------------------------------------------------------------- 1 | import { prismadb } from "@/lib/prismadb"; 2 | import { currentUser } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function POST(request: Request) { 6 | const user = await currentUser(); 7 | 8 | if (!user) { 9 | return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); 10 | } 11 | 12 | const response = await request.json(); 13 | 14 | console.log(response); 15 | 16 | const { id, sendNotifications, challengeId } = response; 17 | 18 | if (!id || sendNotifications === undefined || !challengeId) { 19 | return NextResponse.json( 20 | { message: "Missing required fields" }, 21 | { status: 400 } 22 | ); 23 | } 24 | 25 | try { 26 | const updatedChallengePreferences = 27 | await prismadb.challengePreferences.update({ 28 | where: { 29 | id: id, 30 | userId: user.id, 31 | }, 32 | data: { 33 | challengeId, 34 | sendNotifications, 35 | }, 36 | }); 37 | 38 | return NextResponse.json({ 39 | success: true, 40 | data: updatedChallengePreferences, 41 | }); 42 | } catch (error) { 43 | console.error(error); 44 | return NextResponse.json( 45 | { success: false, message: "Something went wrong" }, 46 | { status: 500 } 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/api/user-thread/route.ts: -------------------------------------------------------------------------------- 1 | import { prismadb } from "@/lib/prismadb"; 2 | import { currentUser } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | import OpenAI from "openai"; 5 | 6 | export async function GET() { 7 | const user = await currentUser(); 8 | 9 | if (!user) { 10 | return NextResponse.json( 11 | { success: false, message: "unauthorized" }, 12 | { status: 401 } 13 | ); 14 | } 15 | 16 | // Get user threads from database 17 | const userThread = await prismadb.userThread.findUnique({ 18 | where: { userId: user.id }, 19 | }); 20 | 21 | // If it does exist, return it 22 | if (userThread) { 23 | return NextResponse.json({ userThread, success: true }, { status: 200 }); 24 | } 25 | 26 | try { 27 | // If it doesn't exist, create it from openai 28 | const openai = new OpenAI(); 29 | const thread = await openai.beta.threads.create(); 30 | 31 | // Save it to the database 32 | const newUserThread = await prismadb.userThread.create({ 33 | data: { 34 | userId: user.id, 35 | threadId: thread.id, 36 | }, 37 | }); 38 | 39 | // Return it to the user 40 | return NextResponse.json( 41 | { userThread: newUserThread, success: true }, 42 | { status: 200 } 43 | ); 44 | } catch (e) { 45 | return NextResponse.json( 46 | { success: false, message: "error creating thread" }, 47 | { status: 500 } 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | 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. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /app/api/subscription/route.ts: -------------------------------------------------------------------------------- 1 | import { prismadb } from "@/lib/prismadb"; 2 | import { currentUser } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function POST(request: Request) { 6 | const user = await currentUser(); 7 | 8 | if (!user) { 9 | return NextResponse.json( 10 | { success: false, messages: "Unauthorized" }, 11 | { status: 401 } 12 | ); 13 | } 14 | 15 | const { endpoint, keys } = await request.json(); 16 | if (!endpoint || !keys) { 17 | return NextResponse.json( 18 | { success: false, messages: "Invalid request" }, 19 | { status: 400 } 20 | ); 21 | } 22 | 23 | const existingUserMeta = await prismadb.userMeta.findUnique({ 24 | where: { userId: user.id }, 25 | }); 26 | 27 | console.log("existingUserMeta", existingUserMeta); 28 | 29 | try { 30 | if (existingUserMeta) { 31 | await prismadb.userMeta.update({ 32 | where: { 33 | userId: user.id, 34 | }, 35 | data: { 36 | endpoint, 37 | auth: keys.auth, 38 | p256dh: keys.p256dh, 39 | }, 40 | }); 41 | } else { 42 | await prismadb.userMeta.create({ 43 | data: { 44 | userId: user.id, 45 | endpoint, 46 | auth: keys.auth, 47 | p256dh: keys.p256dh, 48 | }, 49 | }); 50 | } 51 | 52 | return NextResponse.json({ success: true }, { status: 200 }); 53 | } catch (error) { 54 | console.log("error", error); 55 | return NextResponse.json( 56 | { success: false, message: "Error creating/updating user meta" }, 57 | { status: 500 } 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /components/NotificationModal.tsx: -------------------------------------------------------------------------------- 1 | import { on } from "events"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import toast from "react-hot-toast"; 5 | 6 | interface NotificationModalProps { 7 | onRequestClose: (granted: boolean) => void; 8 | saveSubscription: () => void; 9 | } 10 | 11 | function NotificationModal({ 12 | onRequestClose, 13 | saveSubscription, 14 | }: NotificationModalProps) { 15 | const requestNotificationPermission = async () => { 16 | if ("Notification" in window && "serviceWorker" in navigator) { 17 | const permission = await Notification.requestPermission(); 18 | onRequestClose(permission === "granted"); 19 | if (permission === "granted") { 20 | saveSubscription(); 21 | } 22 | } else { 23 | toast.error( 24 | "Notifications are not supported in this browser or something went wrong." 25 | ); 26 | } 27 | }; 28 | 29 | return ReactDOM.createPortal( 30 |
31 |
32 |

33 | Allow to send push notifications 34 |

35 |

36 | Receive notifications from Goggins AI when new workouts are available. 37 |

38 | 44 | 50 |
51 |
, 52 | document.body 53 | ); 54 | } 55 | 56 | export default NotificationModal; 57 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /hooks/useServiceWorker.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import toast from "react-hot-toast"; 3 | 4 | function useServiceWorker() { 5 | useEffect(() => { 6 | async function registerServiceWorker() { 7 | if ("serviceWorker" in navigator) { 8 | try { 9 | const registrations = 10 | await navigator.serviceWorker.getRegistrations(); 11 | 12 | console.log(registrations); 13 | 14 | if (registrations.length) { 15 | // Assume the first registration is the one we want to keep 16 | // and unregister any additional ones 17 | for (let i = 1; i < registrations.length; i++) { 18 | registrations[i].unregister(); 19 | console.log( 20 | "Unregistered redundant service worker:", 21 | registrations[i] 22 | ); 23 | } 24 | } else { 25 | // If there are no registrations, register the service worker 26 | const registration = await navigator.serviceWorker.register( 27 | "/sw.js" 28 | ); 29 | console.log( 30 | "Service Worker registered with scope:", 31 | registration.scope 32 | ); 33 | } 34 | 35 | // Assuming we have a registration at this point, attach the updatefound listener 36 | const registration = await navigator.serviceWorker.ready; 37 | console.log("Service Worker ready:", registration); 38 | 39 | // Check for updates to the service worker. 40 | if (registration) { 41 | registration.onupdatefound = () => { 42 | const installingWorker = registration?.installing; 43 | if (installingWorker) { 44 | installingWorker.onstatechange = () => { 45 | if (installingWorker.state === "installed") { 46 | toast.success( 47 | "New update available! Please refresh page to update." 48 | ); 49 | } 50 | }; 51 | } 52 | }; 53 | } 54 | } catch (error) { 55 | console.error("Service Worker registration failed:", error); 56 | } 57 | } 58 | } 59 | 60 | registerServiceWorker(); 61 | }, []); 62 | } 63 | 64 | export default useServiceWorker; 65 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | './pages/**/*.{ts,tsx}', 6 | './components/**/*.{ts,tsx}', 7 | './app/**/*.{ts,tsx}', 8 | './src/**/*.{ts,tsx}', 9 | ], 10 | prefix: "", 11 | theme: { 12 | container: { 13 | center: true, 14 | padding: "2rem", 15 | screens: { 16 | "2xl": "1400px", 17 | }, 18 | }, 19 | extend: { 20 | colors: { 21 | border: "hsl(var(--border))", 22 | input: "hsl(var(--input))", 23 | ring: "hsl(var(--ring))", 24 | background: "hsl(var(--background))", 25 | foreground: "hsl(var(--foreground))", 26 | primary: { 27 | DEFAULT: "hsl(var(--primary))", 28 | foreground: "hsl(var(--primary-foreground))", 29 | }, 30 | secondary: { 31 | DEFAULT: "hsl(var(--secondary))", 32 | foreground: "hsl(var(--secondary-foreground))", 33 | }, 34 | destructive: { 35 | DEFAULT: "hsl(var(--destructive))", 36 | foreground: "hsl(var(--destructive-foreground))", 37 | }, 38 | muted: { 39 | DEFAULT: "hsl(var(--muted))", 40 | foreground: "hsl(var(--muted-foreground))", 41 | }, 42 | accent: { 43 | DEFAULT: "hsl(var(--accent))", 44 | foreground: "hsl(var(--accent-foreground))", 45 | }, 46 | popover: { 47 | DEFAULT: "hsl(var(--popover))", 48 | foreground: "hsl(var(--popover-foreground))", 49 | }, 50 | card: { 51 | DEFAULT: "hsl(var(--card))", 52 | foreground: "hsl(var(--card-foreground))", 53 | }, 54 | }, 55 | borderRadius: { 56 | lg: "var(--radius)", 57 | md: "calc(var(--radius) - 2px)", 58 | sm: "calc(var(--radius) - 4px)", 59 | }, 60 | keyframes: { 61 | "accordion-down": { 62 | from: { height: "0" }, 63 | to: { height: "var(--radix-accordion-content-height)" }, 64 | }, 65 | "accordion-up": { 66 | from: { height: "var(--radix-accordion-content-height)" }, 67 | to: { height: "0" }, 68 | }, 69 | }, 70 | animation: { 71 | "accordion-down": "accordion-down 0.2s ease-out", 72 | "accordion-up": "accordion-up 0.2s ease-out", 73 | }, 74 | }, 75 | }, 76 | plugins: [require("tailwindcss-animate")], 77 | } -------------------------------------------------------------------------------- /app/api/assistant/create/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import OpenAI from "openai"; 3 | 4 | export async function POST() { 5 | const openai = new OpenAI(); 6 | 7 | try { 8 | const assitant = await openai.beta.assistants.create({ 9 | model: "gpt-4", 10 | name: "Goggins AI Coach", 11 | instructions: ` 12 | Prompt: "Create an AI assistant that responds to user queries about their progress in the workout plan designed in the style of David Goggins. The assistant should respond in an exaggerated, intense version of Goggins' style, using his known phrases and a confrontational tone. It should both acknowledge the user's efforts and push them to go further, always emphasizing that they are capable of more. Responses should be direct, motivational, and slightly over the top, reflecting the never-satisfied, always-striving philosophy of Goggins." 13 | 14 | Input Expectations: The assistant can expect queries such as: 15 | 16 | Users reporting their completion of the workout and seeking validation. 17 | Users asking for advice on how to push their limits further. 18 | Users expressing difficulty or fatigue and seeking motivation. 19 | Example Outputs: 20 | 21 | User: "I just finished the 10-minute workout plan. It was tough, but I did it!" 22 | Assistant Response: "Tough? That was just the warm-up! Real growth starts where your comfort zone ends. You've got more in you, don't settle for 'just enough'. Next time, double it. Remember, it’s not about talking tough, it’s about living tough. Stay hard!" 23 | 24 | User: "I'm feeling really exhausted, can I take a break?" 25 | Assistant Response: "Exhausted? That's your body telling you it's starting to transform. Breaks are for those who need comfort. You need progress. Dig deeper, find that inner fire. Pain is your friend, it's time to embrace it. No breaks, no backing down. Stay hard!" 26 | 27 | User: "How can I push myself harder in the next workout?" 28 | Assistant Response: "Want to push harder? Good. It’s about outdoing yourself, not just once, but every damn day. Add more reps, reduce rest time, challenge your limits. Remember, you're not competing with anyone else, you're competing with the voice in your head that says you can't. Prove it wrong. Stay hard!" 29 | 30 | Constraints: 31 | 32 | The assistant should always maintain a tone of high intensity and motivation. 33 | The assistant should never encourage unsafe practices or disregard for personal health and well-being. 34 | The assistant should be supportive but also challenging, reflecting Goggins' philosophy of continuous self-improvement and resilience. 35 | `, 36 | }); 37 | 38 | console.log(assitant); 39 | 40 | return NextResponse.json({ assitant }, { status: 201 }); 41 | } catch (error) { 42 | console.error(error); 43 | return NextResponse.json({ error: error }, { status: 500 }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /components/ProfileContainer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ChallengePreferences } from "@prisma/client"; 4 | import React, { useState } from "react"; 5 | import { Button } from "./ui/button"; 6 | import { Switch } from "./ui/switch"; 7 | import DifficultyCard from "./DifficultyCard"; 8 | import axios from "axios"; 9 | import toast from "react-hot-toast"; 10 | 11 | const difficulties = [ 12 | { 13 | id: "EASY", 14 | level: "Easy", 15 | description: 16 | "This challenge level is for people who are new to programming. Receive 3 challenges per day (7:30AM, 12PM, & 5:30PM EST).", 17 | }, 18 | { 19 | id: "MEDIUM", 20 | level: "Medium", 21 | description: 22 | "This challenge level is for people who are familiar with programming. Receive 4 challenges per day (7AM, 12PM, 5PM, & 8PM EST).", 23 | }, 24 | { 25 | id: "HARD", 26 | level: "Hard", 27 | description: 28 | "This challenge level is for people who are experienced with programming. Receive 5 challenges per day (6AM, 9AM, 12PM, 5PM, & 8PM EST).", 29 | }, 30 | ]; 31 | 32 | type Difficulties = "EASY" | "MEDIUM" | "HARD"; 33 | 34 | interface ProfileContainerProps { 35 | challengePreferences: ChallengePreferences; 36 | } 37 | 38 | function ProfileContainer({ challengePreferences }: ProfileContainerProps) { 39 | const [saving, setSaving] = useState(false); 40 | const [selectedDifficulty, setSelectedDifficulty] = useState( 41 | challengePreferences.challengeId 42 | ); 43 | const [sendNotifications, setSendNotifications] = useState( 44 | challengePreferences.sendNotifications 45 | ); 46 | 47 | const handleToggleNotifications = () => { 48 | setSendNotifications((prev) => !prev); 49 | }; 50 | 51 | const handleSelectDifficulty = (difficultyId: Difficulties) => { 52 | setSelectedDifficulty(difficultyId); 53 | }; 54 | 55 | const handleSave = async () => { 56 | setSaving(true); 57 | try { 58 | const response = await axios.post<{ 59 | success: boolean; 60 | data?: ChallengePreferences; 61 | message?: string; 62 | }>("/api/challenge-preferences", { 63 | id: challengePreferences.id, 64 | challengeId: selectedDifficulty, 65 | sendNotifications, 66 | }); 67 | 68 | if (!response.data.success || !response.data.data) { 69 | console.error(response.data.message ?? "Something went wrong"); 70 | toast.error(response.data.message ?? "Something went wrong"); 71 | return; 72 | } 73 | 74 | toast.success("Preferences saved!"); 75 | } catch (error) { 76 | console.error(error); 77 | toast.error("Something went wrong. Please try again."); 78 | } finally { 79 | setSaving(false); 80 | } 81 | }; 82 | 83 | return ( 84 |
85 |
86 |

Challenge Level

87 | 88 |
89 |
90 |
91 |

92 | Push Notifications 93 |

94 |

Receive push notifications when new challenges are available.

95 |
96 | 100 |
101 |
102 | {difficulties.map((difficulty) => ( 103 | 109 | handleSelectDifficulty(difficulty.id as Difficulties) 110 | } 111 | /> 112 | ))} 113 |
114 |
115 | ); 116 | } 117 | 118 | export default ProfileContainer; 119 | -------------------------------------------------------------------------------- /app/(app)/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { assistantAtom, userThreadAtom } from "@/atoms"; 4 | import Navbar from "@/components/Navbar"; 5 | import NotificationModal from "@/components/NotificationModal"; 6 | import useServiceWorker from "@/hooks/useServiceWorker"; 7 | import { Assistant, UserThread } from "@prisma/client"; 8 | import axios from "axios"; 9 | import { useAtom } from "jotai"; 10 | import { use, useCallback, useEffect, useState } from "react"; 11 | import toast, { Toaster } from "react-hot-toast"; 12 | 13 | export default function AppLayout({ children }: { children: React.ReactNode }) { 14 | // Atom State 15 | const [, setUserThread] = useAtom(userThreadAtom); 16 | const [assistant, setAssistant] = useAtom(assistantAtom); 17 | 18 | // State 19 | const [isNotificationModalVisible, setIsNotificationModalVisible] = 20 | useState(false); 21 | 22 | // Hooks 23 | useServiceWorker(); 24 | 25 | useEffect(() => { 26 | if (assistant) return; 27 | 28 | async function getAssistant() { 29 | try { 30 | const response = await axios.get<{ 31 | success: boolean; 32 | message?: string; 33 | assistant: Assistant; 34 | }>("/api/assistant"); 35 | 36 | if (!response.data.success || !response.data.assistant) { 37 | console.error(response.data.message ?? "Unknown error."); 38 | toast.error("Failed to fetch assistant."); 39 | setAssistant(null); 40 | return; 41 | } 42 | 43 | setAssistant(response.data.assistant); 44 | } catch (error) { 45 | console.error(error); 46 | setAssistant(null); 47 | } 48 | } 49 | 50 | getAssistant(); 51 | }, [assistant, setAssistant]); 52 | 53 | useEffect(() => { 54 | async function getUserThread() { 55 | try { 56 | const response = await axios.get<{ 57 | success: boolean; 58 | message?: string; 59 | userThread: UserThread; 60 | }>("/api/user-thread"); 61 | 62 | if (!response.data.success || !response.data.userThread) { 63 | console.error(response.data.message ?? "Unknown error."); 64 | setUserThread(null); 65 | return; 66 | } 67 | 68 | setUserThread(response.data.userThread); 69 | } catch (error) { 70 | console.error(error); 71 | setUserThread(null); 72 | } 73 | } 74 | 75 | getUserThread(); 76 | }, [setUserThread]); 77 | 78 | useEffect(() => { 79 | if ("Notification" in window) { 80 | setIsNotificationModalVisible(Notification.permission === "default"); 81 | console.log("Notification permission:", Notification.permission); 82 | } 83 | }, []); 84 | 85 | const saveSubscription = useCallback(async () => { 86 | const serviceWorkerRegistration = await navigator.serviceWorker.ready; 87 | const subscription = await serviceWorkerRegistration.pushManager.subscribe({ 88 | userVisibleOnly: true, 89 | applicationServerKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, 90 | }); 91 | 92 | try { 93 | const response = await axios.post("/api/subscription", subscription); 94 | 95 | if (!response.data.success) { 96 | console.error(response.data.message ?? "Unknown error."); 97 | toast.error("Failed to save subscription."); 98 | return; 99 | } 100 | } catch (error) { 101 | console.error(error); 102 | toast.error("Failed to save subscription."); 103 | } 104 | }, []); 105 | 106 | useEffect(() => { 107 | if ("Notification" in window && "serviceWorker" in navigator) { 108 | if (Notification.permission === "granted") { 109 | saveSubscription(); 110 | } 111 | } 112 | }, [saveSubscription]); 113 | 114 | const handleNotificationModalClose = (didConstent: boolean) => { 115 | setIsNotificationModalVisible(false); 116 | 117 | if (didConstent) { 118 | toast.success("You will now receive notifications."); 119 | } 120 | }; 121 | 122 | return ( 123 |
124 | 125 | {children} 126 | {isNotificationModalVisible && ( 127 | 131 | )} 132 | 133 |
134 | ); 135 | } 136 | -------------------------------------------------------------------------------- /app/api/challenge-users/route.ts: -------------------------------------------------------------------------------- 1 | import { prismadb } from "@/lib/prismadb"; 2 | import { UserMeta, UserThread } from "@prisma/client"; 3 | import axios from "axios"; 4 | import { NextResponse } from "next/server"; 5 | import OpenAI from "openai"; 6 | 7 | interface UserThreadMap { 8 | [userId: string]: UserThread; 9 | } 10 | 11 | interface UserMetaMap { 12 | [userId: string]: UserMeta; 13 | } 14 | 15 | export async function POST(request: Request) { 16 | // Validation 17 | const body = await request.json(); 18 | 19 | const { challengeId, secret } = body; 20 | 21 | if (!challengeId || !secret) { 22 | return NextResponse.json( 23 | { success: false, message: "Missing required fields" }, 24 | { 25 | status: 400, 26 | } 27 | ); 28 | } 29 | 30 | if (secret !== process.env.APP_SECRET_KEY) { 31 | return NextResponse.json( 32 | { success: false, message: "Unauthorized" }, 33 | { 34 | status: 401, 35 | } 36 | ); 37 | } 38 | 39 | // Define work out message prompt 40 | const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ 41 | { 42 | role: "system", 43 | content: ` 44 | Generate an ultra-intense, hard-hitting motivational message, followed by a concise, bullet-pointed, no-equipment-needed workout plan. The time of day provided should be taken into account. This output should strictly contain two parts: first, a motivational message in the style of David Goggins, as depicted in Jesse Itzler's 'Living with a SEAL', but even more extreme. The message must be direct, confrontational, and incorporate Goggins' known phrases like 'poopy pants', 'stay hard', and 'taking souls'. The second part should be a workout list: intense, high-impact exercises that can be done anywhere, designed to be completed within 10 minutes. The output must only include these two components, nothing else. 45 | 46 | Here's an example output that you should follow: 47 | 48 | Time to get hard! No more excuses, no more poopy pants attitude. You're stronger than you think. Stay hard, take souls, and crush this morning with everything you've got. You have 10 minutes to obliterate this workout. This is your battlefield, and you're the warrior. Let's make every second count! 49 | 50 | - 30 Burpees – explode with every jump 51 | - 40 Jumping Jacks – faster, push your limits 52 | - 50 Mountain Climbers – relentless pace 53 | - 60 High Knees – drive them up with fury 54 | - 2 Minute Plank – solid and unyielding 55 | `, 56 | }, 57 | { 58 | role: "user", 59 | content: `Generate a new David Goggins workout. Remember, only respond in the format specifed earlier. Nothing else`, 60 | }, 61 | ]; 62 | 63 | // Use OpenAI to generate work out 64 | const { 65 | data: { message, success }, 66 | } = await axios.post<{ message?: string; success: boolean }>( 67 | `${process.env.NEXT_PUBLIC_BASE_URL}/api/openai`, 68 | { 69 | messages, 70 | secret: process.env.APP_SECRET_KEY, 71 | } 72 | ); 73 | 74 | if (!message || !success) { 75 | return NextResponse.json( 76 | { 77 | success: false, 78 | message: "Something went wrong with generate openai response", 79 | }, 80 | { 81 | status: 500, 82 | } 83 | ); 84 | } 85 | 86 | console.log(message); 87 | 88 | // Grab all challenge preferences 89 | const challengePreferences = await prismadb.challengePreferences.findMany({ 90 | where: { 91 | challengeId, 92 | }, 93 | }); 94 | 95 | console.log("challengePreferences", challengePreferences); 96 | 97 | const userIds = challengePreferences.map((cp) => cp.userId); 98 | 99 | console.log("userIds", userIds); 100 | 101 | // Grab all user threads 102 | const userThreads = await prismadb.userThread.findMany({ 103 | where: { 104 | userId: { 105 | in: userIds, 106 | }, 107 | }, 108 | }); 109 | 110 | console.log("userThreads", userThreads); 111 | 112 | // Grab all user metadata 113 | const userMetas = await prismadb.userMeta.findMany({ 114 | where: { 115 | userId: { 116 | in: userIds, 117 | }, 118 | }, 119 | }); 120 | 121 | console.log("userMetas", userMetas); 122 | 123 | const userThreadMap: UserThreadMap = userThreads.reduce((map, thread) => { 124 | map[thread.userId] = thread; 125 | return map; 126 | }, {} as UserThreadMap); 127 | 128 | const userMetaMap = userMetas.reduce((map, meta) => { 129 | map[meta.userId] = meta; 130 | return map; 131 | }, {} as UserMetaMap); 132 | 133 | // Add messages to threads 134 | const threadAndNotificationsPromises: Promise[] = []; 135 | 136 | try { 137 | challengePreferences.forEach((cp) => { 138 | // FIND THE RESPECTIVE USER 139 | const userThread = userThreadMap[cp.userId]; 140 | 141 | // ADD MESSAGE TO THREAD 142 | if (userThread) { 143 | // Send Message 144 | threadAndNotificationsPromises.push( 145 | axios.post(`${process.env.NEXT_PUBLIC_BASE_URL}/api/message/create`, { 146 | message, 147 | threadId: userThread.threadId, 148 | fromUser: "false", 149 | }) 150 | ); 151 | 152 | // Send Notification 153 | if (cp.sendNotifications) { 154 | const correspondingUserMeta = userMetaMap[cp.userId]; 155 | threadAndNotificationsPromises.push( 156 | axios.post( 157 | `${process.env.NEXT_PUBLIC_BASE_URL}/api/send-notifications`, 158 | { 159 | subscription: { 160 | endpoint: correspondingUserMeta.endpoint, 161 | keys: { 162 | auth: correspondingUserMeta.auth, 163 | p256dh: correspondingUserMeta.p256dh, 164 | }, 165 | }, 166 | message, 167 | } 168 | ) 169 | ); 170 | } 171 | } 172 | }); 173 | 174 | await Promise.all(threadAndNotificationsPromises); 175 | 176 | return NextResponse.json({ message }, { status: 200 }); 177 | } catch (error) { 178 | console.error(error); 179 | return NextResponse.json( 180 | { success: false, message: "Something went wrong" }, 181 | { 182 | status: 500, 183 | } 184 | ); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /app/(app)/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { assistantAtom, userThreadAtom } from "@/atoms"; 4 | import axios from "axios"; 5 | import { useAtom } from "jotai"; 6 | import { Run, ThreadMessage } from "openai/resources/beta/threads/index.mjs"; 7 | import React, { useCallback, useEffect, useState } from "react"; 8 | import toast from "react-hot-toast"; 9 | 10 | const POLLING_FREQUENCY_MS = 1000; 11 | 12 | function ChatPage() { 13 | // Atom State 14 | const [userThread] = useAtom(userThreadAtom); 15 | const [assistant] = useAtom(assistantAtom); 16 | 17 | // State 18 | const [fetching, setFetching] = useState(false); 19 | const [messages, setMessages] = useState([]); 20 | const [message, setMessage] = useState(""); 21 | const [sending, setSending] = useState(false); 22 | const [pollingRun, setPollingRun] = useState(false); 23 | 24 | const fetchMessages = useCallback(async () => { 25 | if (!userThread) return; 26 | 27 | setFetching(true); 28 | 29 | try { 30 | const response = await axios.post<{ 31 | success: boolean; 32 | error?: string; 33 | messages?: ThreadMessage[]; 34 | }>("/api/message/list", { threadId: userThread.threadId }); 35 | 36 | // Validation 37 | if (!response.data.success || !response.data.messages) { 38 | console.error(response.data.error ?? "Unknown error."); 39 | return; 40 | } 41 | 42 | let newMessages = response.data.messages; 43 | 44 | // Sort in descending order 45 | newMessages = newMessages 46 | .sort((a, b) => { 47 | return ( 48 | new Date(a.created_at).getTime() - new Date(b.created_at).getTime() 49 | ); 50 | }) 51 | .filter( 52 | (message) => 53 | message.content[0].type === "text" && 54 | message.content[0].text.value.trim() !== "" 55 | ); 56 | 57 | setMessages(newMessages); 58 | } catch (error) { 59 | console.error(error); 60 | setMessages([]); 61 | } finally { 62 | setFetching(false); 63 | } 64 | }, [userThread]); 65 | 66 | useEffect(() => { 67 | const intervalId = setInterval(fetchMessages, POLLING_FREQUENCY_MS); 68 | 69 | // Clean up on unmount 70 | return () => clearInterval(intervalId); 71 | }, [fetchMessages]); 72 | 73 | const startRun = async ( 74 | threadId: string, 75 | assistantId: string 76 | ): Promise => { 77 | // api/run/create 78 | try { 79 | const { 80 | data: { success, run, error }, 81 | } = await axios.post<{ 82 | success: boolean; 83 | error?: string; 84 | run?: Run; 85 | }>("api/run/create", { 86 | threadId, 87 | assistantId, 88 | }); 89 | 90 | if (!success || !run) { 91 | console.error(error); 92 | toast.error("Failed to start run."); 93 | return ""; 94 | } 95 | 96 | return run.id; 97 | } catch (error) { 98 | console.error(error); 99 | toast.error("Failed to start run."); 100 | return ""; 101 | } 102 | }; 103 | 104 | const pollRunStatus = async (threadId: string, runId: string) => { 105 | // api/run/retrieve 106 | setPollingRun(true); 107 | 108 | const intervalId = setInterval(async () => { 109 | try { 110 | const { 111 | data: { run, success, error }, 112 | } = await axios.post<{ 113 | success: boolean; 114 | error?: string; 115 | run?: Run; 116 | }>("api/run/retrieve", { 117 | threadId, 118 | runId, 119 | }); 120 | 121 | if (!success || !run) { 122 | console.error(error); 123 | toast.error("Failed to poll run status."); 124 | return; 125 | } 126 | 127 | console.log("run", run); 128 | 129 | if (run.status === "completed") { 130 | clearInterval(intervalId); 131 | setPollingRun(false); 132 | fetchMessages(); 133 | return; 134 | } else if (run.status === "failed") { 135 | clearInterval(intervalId); 136 | setPollingRun(false); 137 | toast.error("Run failed."); 138 | return; 139 | } 140 | } catch (error) { 141 | console.error(error); 142 | toast.error("Failed to poll run status."); 143 | clearInterval(intervalId); 144 | } 145 | }, POLLING_FREQUENCY_MS); 146 | 147 | // Clean up on unmount 148 | return () => clearInterval(intervalId); 149 | }; 150 | 151 | const sendMessage = async () => { 152 | // Validation 153 | if (!userThread || sending || !assistant) { 154 | toast.error("Failed to send message. Invalid state."); 155 | return; 156 | } 157 | 158 | setSending(true); 159 | 160 | // Send message /api/message/create 161 | try { 162 | const { 163 | data: { message: newMessages }, 164 | } = await axios.post<{ 165 | success: boolean; 166 | message?: ThreadMessage; 167 | error?: string; 168 | }>("/api/message/create", { 169 | message, 170 | threadId: userThread.threadId, 171 | fromUser: "true", 172 | }); 173 | 174 | // Update ours messages with our new response 175 | if (!newMessages) { 176 | console.error("No message returned."); 177 | toast.error("Failed to send message. Please try again."); 178 | return; 179 | } 180 | 181 | setMessages((prev) => [...prev, newMessages]); 182 | setMessage(""); 183 | toast.success("Message sent."); 184 | // Start a run and then we are going to start polling. 185 | const runId = await startRun(userThread.threadId, assistant.assistantId); 186 | if (!runId) { 187 | toast.error("Failed to start run."); 188 | return; 189 | } 190 | pollRunStatus(userThread.threadId, runId); 191 | } catch (error) { 192 | console.error(error); 193 | toast.error("Failed to send message. Please try again."); 194 | } finally { 195 | setSending(false); 196 | } 197 | }; 198 | 199 | return ( 200 |
201 | {/* MESSAGES */} 202 |
203 | {/* 1. FETCHING MESSAGES */} 204 | {fetching && messages.length === 0 && ( 205 |
Fetching...
206 | )} 207 | {/* 2. NO MESSAGES */} 208 | {messages.length === 0 && !fetching && ( 209 |
No messages.
210 | )} 211 | {/* 3. LISTING OUT THE MESSAGES */} 212 | {messages.map((message) => ( 213 |
223 | {message.content[0].type === "text" 224 | ? message.content[0].text.value 225 | .split("\n") 226 | .map((text, index) =>

{text}

) 227 | : null} 228 |
229 | ))} 230 |
231 | 232 | {/* INPUT */} 233 |
234 |
235 | setMessage(e.target.value)} 241 | /> 242 | 251 |
252 |
253 |
254 | ); 255 | } 256 | 257 | export default ChatPage; 258 | --------------------------------------------------------------------------------