├── .dockerignore ├── .env.example ├── .eslintrc.json ├── .gitignore ├── Dockerfile ├── README.md ├── next-env.d.ts ├── next-logger.config.js ├── next.config.js ├── openapitools.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── favicon.ico ├── logo.png └── og-image.png ├── src ├── app │ ├── (authenticated) │ │ ├── client_layout.tsx │ │ ├── client_page.tsx │ │ ├── layout.tsx │ │ ├── live │ │ │ ├── client_page.tsx │ │ │ ├── components │ │ │ │ ├── InputBar.tsx │ │ │ │ ├── Messages.tsx │ │ │ │ └── ProgressBar.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── score │ │ │ ├── client_page.tsx │ │ │ └── page.tsx │ ├── api │ │ ├── checkout │ │ │ └── route.ts │ │ ├── credits │ │ │ └── route.ts │ │ ├── score │ │ │ └── route.ts │ │ ├── sessions │ │ │ ├── [sessionId] │ │ │ │ ├── messages │ │ │ │ │ └── route.ts │ │ │ │ └── timeline │ │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ └── webhook │ │ │ └── route.ts │ ├── auth │ │ └── google │ │ │ ├── callback │ │ │ └── route.tsx │ │ │ ├── login │ │ │ └── route.tsx │ │ │ └── test │ │ │ └── route.ts │ ├── checkout │ │ ├── ClientSecretProvider.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── fonts │ │ ├── SF-Pro-Rounded-Black.otf │ │ ├── SF-Pro-Rounded-Bold.otf │ │ ├── SF-Pro-Rounded-Heavy.otf │ │ ├── SF-Pro-Rounded-Light.otf │ │ ├── SF-Pro-Rounded-Medium.otf │ │ ├── SF-Pro-Rounded-Regular.otf │ │ ├── SF-Pro-Rounded-Semibold.otf │ │ ├── SF-Pro-Rounded-Thin.otf │ │ ├── SF-Pro-Rounded-Ultralight.otf │ │ └── SF-Pro-Text-RegularItalic.otf │ ├── globals.css │ └── legal │ │ ├── layout.tsx │ │ ├── privacy │ │ └── page.tsx │ │ └── terms │ │ └── page.tsx ├── components │ ├── AgentAudioVisualizer.tsx │ ├── Analyze.tsx │ ├── AppStateProvider.tsx │ ├── AttributeCard.tsx │ ├── BorderButton.tsx │ ├── Button3D.tsx │ ├── Footer.tsx │ ├── GenderSelection.tsx │ ├── Header.tsx │ ├── PaywallPopup.tsx │ ├── PersonaButton.tsx │ ├── PersonasList.tsx │ ├── ScenariosList.tsx │ ├── Score.tsx │ ├── SelectedPersonaDetails.tsx │ ├── SessionDetail.tsx │ ├── SessionDetailModal.tsx │ ├── ShinyButton.tsx │ ├── SignupWallPopup.tsx │ ├── StatCard.tsx │ ├── analyze │ │ ├── MobileTabs.tsx │ │ ├── PastSessionsList.tsx │ │ └── StreakCard.tsx │ ├── common │ │ └── Card.tsx │ ├── icons │ │ └── Logo.tsx │ ├── legal │ │ ├── PrivacyPolicy.tsx │ │ └── TermsOfService.tsx │ ├── stats │ │ ├── RizzScore.tsx │ │ ├── StatCard.tsx │ │ └── Streak.tsx │ └── ui │ │ └── Button.tsx ├── generated │ ├── .gitignore │ ├── .npmignore │ ├── .openapi-generator-ignore │ ├── .openapi-generator │ │ ├── FILES │ │ └── VERSION │ ├── api.ts │ ├── base.ts │ ├── common.ts │ ├── configuration.ts │ ├── git_push.sh │ ├── index.ts │ └── openapi.ts ├── hooks │ └── useStreakData.ts └── lib │ ├── model │ ├── google_user.ts │ ├── score.ts │ └── stats.ts │ ├── server │ └── controller │ │ ├── credits.ts │ │ ├── score.ts │ │ ├── session.ts │ │ └── user.ts │ └── utils │ └── utils.ts ├── tailwind.config.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | *Dockerfile* 3 | node_modules 4 | .env.local -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | GABBER_API_KEY= 2 | GABBER_CREDIT_ID= 3 | 4 | STRIPE_SECRET_KEY= 5 | STRIPE_REDIRECT_HOST=http://localhost:3000 6 | 7 | GOOGLE_CLIENT_ID= 8 | GOOGLE_CLIENT_SECRET= 9 | GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback 10 | 11 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next"], 3 | "plugins": ["prettier", "import"], 4 | "rules": { 5 | "import/no-duplicates": ["error"], 6 | "import/newline-after-import": ["error", { "count": 1 }], 7 | "prettier/prettier": ["error"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.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 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker.io/docker/dockerfile:1 2 | 3 | FROM node:18-alpine AS base 4 | 5 | # Install dependencies only when needed 6 | FROM base AS deps 7 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 8 | RUN apk add --no-cache libc6-compat 9 | WORKDIR /app 10 | 11 | # Install dependencies based on the preferred package manager 12 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ 13 | RUN \ 14 | if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 15 | elif [ -f package-lock.json ]; then npm ci; \ 16 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ 17 | else echo "Lockfile not found." && exit 1; \ 18 | fi 19 | 20 | 21 | # Rebuild the source code only when needed 22 | FROM base AS builder 23 | WORKDIR /app 24 | COPY --from=deps /app/node_modules ./node_modules 25 | COPY . . 26 | 27 | # Next.js collects completely anonymous telemetry data about general usage. 28 | # Learn more here: https://nextjs.org/telemetry 29 | # Uncomment the following line in case you want to disable telemetry during the build. 30 | # ENV NEXT_TELEMETRY_DISABLED=1 31 | 32 | RUN \ 33 | if [ -f yarn.lock ]; then yarn run build; \ 34 | elif [ -f package-lock.json ]; then npm run build; \ 35 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ 36 | else echo "Lockfile not found." && exit 1; \ 37 | fi 38 | 39 | # Production image, copy all the files and run next 40 | FROM base AS runner 41 | WORKDIR /app 42 | 43 | ENV NODE_ENV=production 44 | # Uncomment the following line in case you want to disable telemetry during runtime. 45 | # ENV NEXT_TELEMETRY_DISABLED=1 46 | 47 | RUN addgroup --system --gid 1001 nodejs 48 | RUN adduser --system --uid 1001 nextjs 49 | 50 | COPY --from=builder /app/public ./public 51 | 52 | # Automatically leverage output traces to reduce image size 53 | # https://nextjs.org/docs/advanced-features/output-file-tracing 54 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 55 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 56 | 57 | USER nextjs 58 | 59 | EXPOSE 3000 60 | 61 | ENV PORT=3000 62 | 63 | # server.js is created by next build from the standalone output 64 | # https://nextjs.org/docs/pages/api-reference/next-config-js/output 65 | ENV HOSTNAME="0.0.0.0" 66 | CMD ["node", "server.js"] 67 | 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Note:** This app was built using the old usage token flow. That has been deprecated in favor of a new system using TTLs. See more details [here](https://docs.gabber.dev/authentication) and [here](https://docs.gabber.dev/reference/create-usage-token). 2 | 3 | # Rizz.AI Example App 4 | 5 | Rizz.AI is a gym for your social skills. Practice talking to various personas in different scenarios and get a score at the end. 6 | 7 | This app can be completely deployed on its own with no additional infrastrucure. We've written a [technical blog post](https://docs.gabber.dev/sample-app) that can be used as a guide when navigating this repo. 8 | 9 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fgabber-dev%2Fexample-app-rizz-ai&env=GABBER_API_KEY,GABBER_CREDIT_ID,STRIPE_SECRET_KEY,STRIPE_REDIRECT_HOST,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GOOGLE_REDIRECT_URI&project-name=rizz-ai-clone) 10 | 11 | ## Pre-Requisites 12 | 13 | Before you can run the app, you'll need a few things. 14 | 15 | ### Gabber 16 | 17 | Create a [Gabber](https://app.gabber.dev) project. 18 | 19 | Rizz.AI uses the following Gabber features: 20 | - Credits: Tracking end-user credit balances 21 | - Personas + Scenarios: The characters and situations the end users interact with 22 | - Webhook: Responding to usage events to adjust users' credit balances 23 | - Chat Completions: LLM completions endpoint for scoring the user's Rizz 24 | 25 | Before running the app you'll need to create a Credit object in the Gabber dashboard 26 | and a few personas and scenarios to populate the app. 27 | 28 | You will also need to set a webhook. For local development, use [ngrok](https://chatgpt.com/share/674aa232-bb14-800e-b0b5-35a5c8c787ca). 29 | 30 | After doing so set the following environment variable in .env.local 31 | 32 | ``` 33 | GABBER_API_KEY= 34 | GABBER_CREDIT_ID= 35 | ``` 36 | 37 | ### Google OAuth 38 | The app uses Google OAuth for user authentication. You'll need to set up a Google Cloud project to obtain OAuth 2.0 credentials. 39 | 40 | 1. **Create a Google Cloud Project**: 41 | - Go to the Google Cloud Console. 42 | - Create a new project or select an existing one. 43 | 2. **Enable OAuth Consent Screen**: 44 | - Navigate to APIs & Services > OAuth consent screen. 45 | - Select "external" if you plan to build an app for people outside of your organization. 46 | - Configure the consent screen by adding the necessary information. 47 | - Once you've added this info, select "publish app" 48 | 3. **Create OAuth Client ID**: 49 | - Go to APIs & Services > Credentials. 50 | - Click Create Credentials and select OAuth 2.0 client ID. 51 | - Choose Web Application as the application type. 52 | - Under Authorized JavaScript origins, add your app's URL (e.g., http://localhost:3000). 53 | - Under Authorized redirect URIs, add http://localhost:3000/auth/google/callback. 54 | - Click Create to obtain your Client ID and Client Secret. 55 | 4. **Set Environment Variables**: 56 | 57 | Add the following to your .env.local file: 58 | 59 | ```bash 60 | GOOGLE_CLIENT_ID= 61 | GOOGLE_CLIENT_SECRET= 62 | GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback 63 | ``` 64 | 65 | ### Stripe 66 | 67 | This project is configured to use Stripe for payments and checkout. It supports subscriptions as well as one-time purchases of Rizz credits. 68 | 69 | 1. **Create a Stripe Account**: 70 | - Sign up at Stripe Dashboard. 71 | 2. **Obtain API Keys**: 72 | - Navigate to Developers > API keys. 73 | - Copy the Publishable key and Secret key. 74 | 3. **Set Up Products and Pricing**: 75 | - Go to Products in the Stripe Dashboard. 76 | - Create products and pricing plans as needed. 77 | - IMPORTANT: For each product, set metadata `credit_amount=`. The app uses the metadata to determine how many Rizz credits to grant for each purchase or subscription. 78 | 4. **Set Environment Variables**: 79 | 80 | Add the following to your .env.local file: 81 | 82 | ```bash 83 | STRIPE_SECRET_KEY= 84 | STRIPE_REDIRECT_HOST=http://localhost:3000 85 | ``` 86 | ## Getting Started 87 | Follow these steps to run the app locally: 88 | 89 | 1. **Clone the Repository and install dependencies**: 90 | 91 | ```bash 92 | git clone https://github.com/yourusername/rizzai-example-app.git 93 | cd rizzai-example-app 94 | pnpm install 95 | ``` 96 | 97 | 2. **Set Up Environment Variables**: 98 | 99 | Create a file named `.env.local` in the root directory. 100 | Add all the environment variables as mentioned above. 101 | 102 | 3. **Run the Development Server**: 103 | 104 | ```bash 105 | npm run dev 106 | ``` 107 | 108 | 4. **Access the App**: 109 | 110 | Open your browser and navigate to http://localhost:3000. 111 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next-logger.config.js: -------------------------------------------------------------------------------- 1 | const pino = require("pino"); 2 | 3 | const logger = (defaultConfig) => { 4 | if (process.env.NODE_ENV === "development") { 5 | return pino({ 6 | ...defaultConfig, 7 | redact: { 8 | paths: ["_extra"], 9 | censor: "**REDACTED**" 10 | }, 11 | transport: { 12 | target: 'pino-pretty', 13 | options: { 14 | colorize: true 15 | } 16 | } 17 | }) 18 | } 19 | return pino({ 20 | ...defaultConfig, 21 | messageKey: "message", 22 | redact: { 23 | paths: ["_extra"], 24 | censor: "**REDACTED**" 25 | }, 26 | formatters: { 27 | level: (label) => { 28 | return { level: label }; 29 | }, 30 | } 31 | }); 32 | }; 33 | 34 | module.exports = { 35 | logger, 36 | }; 37 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | output: "standalone", 3 | images: { 4 | domains: ["imagedelivery.net"], 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /openapitools.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", 3 | "spaces": 2, 4 | "generator-cli": { 5 | "version": "7.10.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rizz-ai", 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 --fix", 10 | "tsc": "tsc", 11 | "generate": "openapi-generator-cli generate -i https://api.gabber.dev/openapi.yaml -g typescript-axios -o ./src/generated" 12 | }, 13 | "dependencies": { 14 | "@mui/icons-material": "^5.15.12", 15 | "@types/jsonwebtoken": "^9.0.6", 16 | "@types/react-modal": "^3.16.3", 17 | "@types/tinycolor2": "^1.4.6", 18 | "@types/uuid": "^9.0.8", 19 | "axios": "^1.7.7", 20 | "date-fns": "^4.1.0", 21 | "framer-motion": "^11.11.17", 22 | "gabber-client-react": "^0.11.2", 23 | "google-auth-library": "^9.6.3", 24 | "googleapis": "^133.0.0", 25 | "jsonwebtoken": "^9.0.2", 26 | "next": "14.2.9", 27 | "openai": "^4.73.1", 28 | "pino": "^9.5.0", 29 | "react": "^18.2.0", 30 | "react-dom": "^18.2.0", 31 | "react-hot-toast": "^2.4.1", 32 | "react-modal": "^3.16.1", 33 | "stripe": "^17.4.0", 34 | "tinycolor2": "^1.6.0", 35 | "uuid": "^9.0.1" 36 | }, 37 | "devDependencies": { 38 | "@openapitools/openapi-generator-cli": "^2.15.3", 39 | "@types/lodash": "^4.17.13", 40 | "@types/node": "^20.11.24", 41 | "@types/react": "^18.2.63", 42 | "@types/react-dom": "^18.2.20", 43 | "autoprefixer": "^10.4.18", 44 | "daisyui": "^4.7.2", 45 | "eslint": "^8.57.0", 46 | "eslint-config-next": "14.1.2", 47 | "eslint-plugin-prettier": "^5.2.1", 48 | "postcss": "^8.4.35", 49 | "tailwindcss": "^3.4.1", 50 | "typescript": "^5.3.3" 51 | } 52 | } -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabber-dev/example-app-rizz-ai/42e6c595c5925f241ae5e5cea536f4794516457a/public/favicon.ico -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabber-dev/example-app-rizz-ai/42e6c595c5925f241ae5e5cea536f4794516457a/public/logo.png -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabber-dev/example-app-rizz-ai/42e6c595c5925f241ae5e5cea536f4794516457a/public/og-image.png -------------------------------------------------------------------------------- /src/app/(authenticated)/client_layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AuthenticatedHeader } from "@/components/Header"; 4 | import { Footer } from "@/components/Footer"; 5 | import { Toaster } from "react-hot-toast"; 6 | import ReactModal from "react-modal"; 7 | import { PaywallPopup } from "@/components/PaywallPopup"; 8 | import { useAppState } from "@/components/AppStateProvider"; 9 | import { SignupWallPopup } from "@/components/SignupWallPopup"; 10 | 11 | export function ClientLayout({ children }: { children: React.ReactNode }) { 12 | const { showPaywall, setShowPaywall, showSignupWall, setShowSignupWall } = 13 | useAppState(); 14 | return ( 15 | <> 16 | 17 | { 20 | setShowPaywall(null); 21 | }} 22 | overlayClassName="fixed top-0 bottom-0 left-0 right-0 backdrop-blur-lg bg-blur flex justify-center items-center" 23 | className="w-3/4 h-1/2 max-h-[500px] max-w-[600px] bg-white rounded-lg shadow-lg outline-none" 24 | shouldCloseOnOverlayClick={true} 25 | > 26 |
27 | 28 |
29 |
30 | { 33 | setShowSignupWall(false); 34 | }} 35 | overlayClassName="fixed top-0 bottom-0 left-0 right-0 backdrop-blur-lg bg-blur flex justify-center items-center" 36 | className="w-3/4 h-1/2 max-h-[500px] max-w-[600px] bg-white rounded-lg shadow-lg outline-none" 37 | shouldCloseOnOverlayClick={true} 38 | > 39 |
40 | 41 |
42 |
43 |
44 |
45 | 46 |
47 |
48 | 49 |
50 | {children} 51 |
52 |
53 |
54 |
55 |
56 |
57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/app/(authenticated)/client_page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Card } from "@/components/common/Card"; 3 | import { useAppState } from "@/components/AppStateProvider"; 4 | import { ScenariosList } from "@/components/ScenariosList"; 5 | import { PersonasList } from "@/components/PersonasList"; 6 | import { SelectedPersonaDetails } from "@/components/SelectedPersonaDetails"; 7 | import { Analyze } from "@/components/Analyze"; 8 | import { StreakCard } from "@/components/analyze/StreakCard"; 9 | import { PastSessionsList } from "@/components/analyze/PastSessionsList"; 10 | import { useState, useMemo, useEffect } from "react"; 11 | import { SessionDetailModal } from "@/components/SessionDetailModal"; 12 | import { useStreakData } from "@/hooks/useStreakData"; 13 | import { RealtimeSession } from "@/generated/"; 14 | 15 | export default function ClientPage() { 16 | const { selectedPersona, sessions, setSelectedPersona } = useAppState(); 17 | const [selectedSessionId, setSelectedSessionId] = useState( 18 | null, 19 | ); 20 | const streakData = useStreakData(sessions); 21 | 22 | useEffect(() => { 23 | // Clear selected persona when component mounts 24 | setSelectedPersona(null); 25 | }, []); // Empty dependency array means this runs once on mount 26 | 27 | const recentSessions = useMemo(() => { 28 | if (!sessions.length) return []; 29 | return [...sessions] 30 | .sort((a, b) => { 31 | const dateA = new Date(a.created_at).getTime(); 32 | const dateB = new Date(b.created_at).getTime(); 33 | return dateB - dateA; 34 | }) 35 | .slice(0, 20); 36 | }, [sessions]); 37 | 38 | return ( 39 |
40 | {/* Mobile View */} 41 |
42 | 43 | 44 | 45 |
46 | 47 | {/* Desktop View */} 48 |
49 | 50 | 51 | 55 | 56 |
57 | 58 | {/* Right Column - Main Content (Desktop) */} 59 |
60 | {selectedPersona && ( 61 | 62 | 63 | 64 | )} 65 | 66 | {selectedPersona ? : } 67 | 68 |
69 | 70 | setSelectedSessionId(null)} 73 | /> 74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/app/(authenticated)/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import localFont from "next/font/local"; 3 | import { UserController } from "@/lib/server/controller/user"; 4 | import "../globals.css"; 5 | import { ClientLayout } from "./client_layout"; 6 | import { CreditsController } from "@/lib/server/controller/credits"; 7 | import Head from "next/head"; 8 | import Script from "next/script"; 9 | import { AppStateProvider } from "@/components/AppStateProvider"; 10 | import { 11 | Configuration, 12 | PersonaApi, 13 | RealtimeApi, 14 | RealtimeSession, 15 | ScenarioApi, 16 | } from "@/generated"; 17 | 18 | const sfProRounded = localFont({ 19 | src: [ 20 | { 21 | path: "../fonts/SF-Pro-Rounded-Regular.otf", 22 | weight: "400", 23 | style: "normal", 24 | }, 25 | { 26 | path: "../fonts/SF-Pro-Text-RegularItalic.otf", 27 | weight: "400", 28 | style: "italic", 29 | }, 30 | ], 31 | variable: "--sf-pro-rounded", 32 | }); 33 | 34 | export const metadata: Metadata = { 35 | title: "Rizz.AI - The Gym For Your Social Skills", 36 | description: 37 | "Practice dating, socializing, and speaking with confidence by having conversation with AI. Get tailored feedback to improve your skills.", 38 | openGraph: { 39 | title: "Rizz.AI - The Gym For Your Social Skills", 40 | description: 41 | "Practice dating, socializing, and speaking with confidence by having conversation with AI. Get tailored feedback to improve your skills.", 42 | images: [ 43 | { 44 | url: "https://rizz.ai/og-image.png", 45 | width: 1200, 46 | height: 630, 47 | alt: "Rizz.AI - Practice your dating skills with AI", 48 | }, 49 | ], 50 | }, 51 | twitter: { 52 | card: "summary_large_image", 53 | title: "Rizz.AI - The Gym For Your Social Skills", 54 | description: 55 | "Practice dating, socializing, and speaking with confidence by having conversation with AI. Get tailored feedback to improve your skills.", 56 | images: ["https://rizz.ai/og-image.png"], 57 | }, 58 | }; 59 | 60 | export default async function RootLayout({ 61 | children, 62 | }: { 63 | children: React.ReactNode; 64 | }) { 65 | const user = await UserController.getUserFromCookies(); 66 | 67 | const config = new Configuration({ apiKey: process.env.GABBER_API_KEY }); 68 | const personaApi = new PersonaApi(config); 69 | const scenarioApi = new ScenarioApi(config); 70 | 71 | const [personas, scenarios] = await Promise.all([ 72 | personaApi.listPersonas(), 73 | scenarioApi.listScenarios(), 74 | ]); 75 | 76 | let sessions: RealtimeSession[] = []; 77 | if (user) { 78 | const realtimeApi = new RealtimeApi(config); 79 | try { 80 | const response = await realtimeApi.listRealtimeSessions( 81 | user.stripe_customer, 82 | ); 83 | sessions = response.data.values; 84 | } catch (e) { 85 | console.error("Error fetching sessions", e); 86 | } 87 | } 88 | 89 | const [credits, hasPaid, products, usageToken] = await Promise.all([ 90 | user?.stripe_customer 91 | ? CreditsController.getCreditBalance(user.stripe_customer) 92 | : 0, 93 | user?.stripe_customer 94 | ? CreditsController.checkHasPaid(user.stripe_customer) 95 | : false, 96 | CreditsController.getProducts(), 97 | UserController.createUsageToken(user), 98 | ]); 99 | 100 | return ( 101 | 106 | 107 | 108 | 109 | 114 | 115 | 125 | {children} 126 | 127 | 128 | 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /src/app/(authenticated)/live/client_page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState, useEffect } from "react"; 4 | import { SessionProvider, useSession } from "gabber-client-react"; 5 | import { Persona, Scenario } from "@/generated"; 6 | import { AgentAudioVisualizer } from "@/components/AgentAudioVisualizer"; 7 | import { InputBar } from "./components/InputBar"; 8 | import Image from "next/image"; 9 | import { Messages } from "./components/Messages"; 10 | import { ProgressBar } from "./components/ProgressBar"; 11 | import { useAppState } from "@/components/AppStateProvider"; 12 | import { BorderButton } from "@/components/BorderButton"; 13 | import { useRouter } from "next/navigation"; 14 | import { toast } from "react-hot-toast"; 15 | import { set } from "lodash"; 16 | 17 | type Props = { 18 | persona: Persona; 19 | scenario: Scenario; 20 | }; 21 | 22 | const MESSAGES_BEFORE_RIZZ = 10; 23 | 24 | export function ClientPage({ persona, scenario }: Props) { 25 | const { credits, setShowPaywall, usageToken } = useAppState(); 26 | const [connectionOpts, setConnectionOpts] = useState(null); 30 | 31 | useEffect(() => { 32 | if (credits > 0) { 33 | setConnectionOpts({ 34 | token: usageToken, 35 | sessionConnectOptions: { persona: persona.id, scenario: scenario.id }, 36 | }); 37 | } else { 38 | setConnectionOpts(null); 39 | } 40 | }, [credits, persona.id, scenario.id, setShowPaywall, usageToken]); 41 | 42 | return ( 43 | 44 | 45 | 46 | ); 47 | } 48 | 49 | export function ClientSessionPageInner({ 50 | persona, 51 | scenario, 52 | }: Omit) { 53 | const { messages, id } = useSession(); 54 | const { credits, setShowPaywall, refreshCredits } = useAppState(); 55 | const router = useRouter(); 56 | 57 | // Refresh credits loop 58 | useEffect(() => { 59 | const interval = setInterval(() => { 60 | refreshCredits(); 61 | }, 5000); 62 | return () => clearInterval(interval); 63 | }, [refreshCredits]); 64 | 65 | useEffect(() => { 66 | if (credits <= 0) { 67 | setShowPaywall({ session: id }); 68 | } 69 | }, [credits, id, setShowPaywall]); 70 | 71 | return ( 72 |
73 |
74 |
75 | {persona.name} 81 |
88 | 89 |
90 |
91 |
92 |
{persona.name}
93 |
{scenario.name}
94 |
95 | Talk for at least 10 messages to get your rizz score 96 |
97 |
98 | {messages.length < MESSAGES_BEFORE_RIZZ ? ( 99 | 103 | ) : ( 104 | { 106 | if (credits <= 0) { 107 | console.error("No credits available"); 108 | setShowPaywall({ session: id }); 109 | return; 110 | } 111 | if (!id) { 112 | console.error("No session ID available"); 113 | toast.error("Session not ready yet, please try again"); 114 | return; 115 | } 116 | console.log("Navigating to score with session ID:", id); 117 | router.push(`/score?session=${id}`); 118 | }} 119 | className="font-bold px-2 hover:shadow-inner bg-primary rounded-lg text-primary hover:primary h-full" 120 | > 121 | Check your Rizz 122 | 123 | )} 124 |
125 |
126 |
127 |
128 | 129 |
130 |
131 |
132 |
133 |
134 |
135 | 136 |
137 |
138 |
139 |
140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /src/app/(authenticated)/live/components/InputBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useAppState } from "@/components/AppStateProvider"; 3 | import { BorderButton } from "@/components/BorderButton"; 4 | import { ArrowUpward, Mic, MicOff } from "@mui/icons-material"; 5 | import { useSession } from "gabber-client-react"; 6 | import { useState } from "react"; 7 | 8 | export function InputBar({}: {}) { 9 | const [text, setText] = useState(""); 10 | const { 11 | id, 12 | sendChatMessage, 13 | microphoneEnabled, 14 | setMicrophoneEnabled, 15 | userVolume, 16 | connectionState, 17 | } = useSession(); 18 | const { credits, setShowPaywall } = useAppState(); 19 | 20 | if (connectionState !== "connected") { 21 | if (credits > 0) { 22 | return null; 23 | } 24 | return ( 25 | { 27 | setShowPaywall({ session: id }); 28 | }} 29 | className="w-full h-full flex justify-center items-center text-primary" 30 | > 31 | Purchase Credits 32 | 33 | ); 34 | } 35 | 36 | return ( 37 |
38 |
{ 41 | e.preventDefault(); 42 | if (text == "") { 43 | return; 44 | } 45 | sendChatMessage({ text }); 46 | setText(""); 47 | }} 48 | > 49 | 68 | setText(e.target.value)} 72 | className="input input-bordered grow min-w-0 p-2" 73 | placeholder="Type a message..." 74 | /> 75 | 81 |
82 |
83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/app/(authenticated)/live/components/Messages.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSession } from "gabber-client-react"; 4 | import { useEffect, useRef, useState } from "react"; 5 | 6 | export function Messages({}: {}) { 7 | const { messages } = useSession(); // Assuming useSession provides messages 8 | const [isAtBottom, setIsAtBottom] = useState(true); // Track if user is at the bottom 9 | const containerRef = useRef(null); 10 | 11 | const scrollToBottom = () => { 12 | if (containerRef.current) { 13 | containerRef.current.scrollTo({ 14 | top: containerRef.current.scrollHeight, 15 | behavior: "smooth", 16 | }); 17 | } 18 | }; 19 | 20 | const handleScroll = () => { 21 | if (containerRef.current) { 22 | const isUserAtBottom = 23 | containerRef.current.scrollHeight - containerRef.current.scrollTop === 24 | containerRef.current.clientHeight; 25 | setIsAtBottom(isUserAtBottom); 26 | } 27 | }; 28 | 29 | useEffect(() => { 30 | if (isAtBottom) { 31 | scrollToBottom(); 32 | } 33 | }, [isAtBottom, messages]); // Scroll to bottom when messages change 34 | 35 | return ( 36 |
37 |
42 | {messages.map((message) => ( 43 |
50 | {message.text} 51 |
52 | ))} 53 |
54 | {!isAtBottom && ( 55 | 61 | )} 62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/app/(authenticated)/live/components/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { motion, AnimatePresence } from "framer-motion"; 3 | 4 | type Props = { 5 | numerator: number; 6 | denominator: number; 7 | }; 8 | 9 | export function ProgressBar({ numerator, denominator }: Props) { 10 | const [progress, setProgress] = useState(0); 11 | const [pulse, setPulse] = useState(false); 12 | 13 | useEffect(() => { 14 | const newProgress = (numerator / denominator) * 100; 15 | setProgress(newProgress); 16 | 17 | // Trigger the pulse animation 18 | setPulse(true); 19 | const timeout = setTimeout(() => setPulse(false), 300); // 300ms matches pulse duration 20 | return () => clearTimeout(timeout); 21 | }, [numerator, denominator]); 22 | 23 | return ( 24 |
25 | {/* Background */} 26 |
30 | 31 | {/* Progress Bar */} 32 | 39 | 40 | {/* Pulse Effect */} 41 | 42 | {pulse && ( 43 | 51 | )} 52 | 53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/app/(authenticated)/live/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { ClientPage } from "./client_page"; 3 | import { 4 | Configuration, 5 | PersonaApiFactory, 6 | ScenarioApiFactory, 7 | } from "@/generated"; 8 | import { UserController } from "@/lib/server/controller/user"; 9 | 10 | export default async function Page({ 11 | searchParams, 12 | }: { 13 | searchParams: { persona: string | null; scenario: string | null }; 14 | }) { 15 | const { persona, scenario } = searchParams; 16 | const user = await UserController.getUserFromCookies(); 17 | 18 | if (!persona || !scenario) { 19 | console.error("Missing persona or scenario"); 20 | return redirect("/"); 21 | } 22 | 23 | const config = new Configuration({ 24 | apiKey: process.env.GABBER_API_KEY, 25 | }); 26 | 27 | const personaApi = PersonaApiFactory(config); 28 | const scenarioApi = ScenarioApiFactory(config); 29 | 30 | const personaObj = (await personaApi.getPersona(persona)).data; 31 | const scenarioObj = (await scenarioApi.getScenario(scenario)).data; 32 | 33 | return ( 34 |
35 | 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/app/(authenticated)/page.tsx: -------------------------------------------------------------------------------- 1 | import ClientPage from "./client_page"; 2 | 3 | export default async function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/(authenticated)/score/client_page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button3D } from "@/components/Button3D"; 4 | import { Score as ScoreComponent } from "@/components/Score"; 5 | import { Score } from "@/lib/model/score"; 6 | import { useRouter } from "next/navigation"; 7 | 8 | type Props = { 9 | session: string; 10 | score: Score; 11 | }; 12 | 13 | export function ClientPage({ session, score }: Props) { 14 | const router = useRouter(); 15 | 16 | return ( 17 |
18 | 24 |
25 | 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/(authenticated)/score/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { ClientPage } from "./client_page"; 3 | import { UserController } from "@/lib/server/controller/user"; 4 | import { ScoreController } from "@/lib/server/controller/score"; 5 | 6 | export default async function Page({ 7 | params, 8 | searchParams, 9 | }: { 10 | params: { session: string }; 11 | searchParams: { session: string | null }; 12 | }) { 13 | const { session } = searchParams; 14 | const user = await UserController.getUserFromCookies(); 15 | 16 | if (!session) { 17 | console.error("Missing session"); 18 | return redirect("/"); 19 | } 20 | 21 | const score = await ScoreController.calculateScore(session); 22 | 23 | return ( 24 |
25 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/app/api/checkout/route.ts: -------------------------------------------------------------------------------- 1 | import { CreditsController } from "@/lib/server/controller/credits"; 2 | import { UserController } from "@/lib/server/controller/user"; 3 | import { NextRequest, NextResponse } from "next/server"; 4 | 5 | export async function POST(req: NextRequest) { 6 | const user = await UserController.getUserFromCookies(); 7 | 8 | if (!user) { 9 | return NextResponse.json({ error: "User not found" }, { status: 401 }); 10 | } 11 | 12 | const { price_id, gabber_session } = await req.json(); 13 | 14 | const checkoutSession = await CreditsController.createCheckoutSession({ 15 | customer: user.stripe_customer, 16 | price: price_id, 17 | gabberSession: gabber_session, 18 | }); 19 | 20 | return NextResponse.json(checkoutSession); 21 | } 22 | -------------------------------------------------------------------------------- /src/app/api/credits/route.ts: -------------------------------------------------------------------------------- 1 | import { CreditsController } from "@/lib/server/controller/credits"; 2 | import { UserController } from "@/lib/server/controller/user"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function GET() { 6 | const user = await UserController.getUserFromCookies(); 7 | if (!user) { 8 | return new NextResponse(null, { status: 401 }); 9 | } 10 | 11 | const { stripe_customer } = user; 12 | const balance = await CreditsController.getCreditBalance(stripe_customer); 13 | 14 | return NextResponse.json({ balance }); 15 | } 16 | -------------------------------------------------------------------------------- /src/app/api/score/route.ts: -------------------------------------------------------------------------------- 1 | import { ScoreController } from "@/lib/server/controller/score"; 2 | import { NextRequest } from "next/server"; 3 | 4 | export async function GET(req: NextRequest) { 5 | const session = req.nextUrl.searchParams.get("session"); 6 | if (!session) { 7 | return Response.json({ error: "Session is required" }, { status: 400 }); 8 | } 9 | 10 | const score = await ScoreController.calculateScore(session); 11 | return Response.json(score); 12 | } 13 | -------------------------------------------------------------------------------- /src/app/api/sessions/[sessionId]/messages/route.ts: -------------------------------------------------------------------------------- 1 | import { SessionController } from "@/lib/server/controller/session"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | 4 | export async function GET( 5 | request: NextRequest, 6 | { params }: { params: { sessionId: string } }, 7 | ) { 8 | const { sessionId } = params; 9 | 10 | try { 11 | const messages = await SessionController.getSessionMessages(sessionId); 12 | return NextResponse.json(messages); 13 | } catch (error) { 14 | console.error("Failed to fetch session messages:", error); 15 | return NextResponse.json( 16 | { error: "Failed to fetch session messages" }, 17 | { status: 500 }, 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/api/sessions/[sessionId]/timeline/route.ts: -------------------------------------------------------------------------------- 1 | import { SessionController } from "@/lib/server/controller/session"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | 4 | export async function GET( 5 | request: NextRequest, 6 | { params }: { params: { sessionId: string } }, 7 | ) { 8 | const { sessionId } = params; 9 | 10 | try { 11 | const timeline = await SessionController.getSessionTimeline(sessionId); 12 | return NextResponse.json(timeline); 13 | } catch (error) { 14 | console.error("Failed to fetch session timeline:", error); 15 | return NextResponse.json( 16 | { error: "Failed to fetch session timeline" }, 17 | { status: 500 }, 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/api/sessions/route.ts: -------------------------------------------------------------------------------- 1 | import { SessionController } from "@/lib/server/controller/session"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | 4 | export async function POST(request: NextRequest) { 5 | const formData = await request.formData(); 6 | const humanId = formData.get("human_id"); 7 | 8 | if (!humanId) { 9 | return NextResponse.json({ error: "Missing human_id" }, { status: 400 }); 10 | } 11 | 12 | try { 13 | const sessions = await SessionController.getSessions(humanId.toString()); 14 | return NextResponse.json(sessions); 15 | } catch (error) { 16 | console.error("Failed to fetch sessions:", error); 17 | return NextResponse.json( 18 | { error: "Failed to fetch sessions" }, 19 | { status: 500 }, 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/api/webhook/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | import crypto from "crypto"; 3 | import { CreditsController } from "@/lib/server/controller/credits"; 4 | import { UserController } from "@/lib/server/controller/user"; 5 | 6 | export async function POST(req: NextRequest) { 7 | const textBody = await req.text(); 8 | const webhookSignature = req.headers.get("X-Webhook-Signature"); 9 | const key = process.env.GABBER_API_KEY; 10 | 11 | if (!webhookSignature) { 12 | return new Response("No signature provided", { status: 400 }); 13 | } 14 | 15 | if (!key) { 16 | return new Response("Server misconfigured - missing API key"); 17 | } 18 | 19 | const computedSignature = crypto 20 | .createHmac("sha256", key) 21 | .update(textBody, "utf8") 22 | .digest("hex"); 23 | 24 | if (computedSignature !== webhookSignature) { 25 | // This could be from the wrong api key configured here or in the webhook gabber dashboard 26 | // or it could be from a malicious actor trying to send a request 27 | console.error("Signature mismatch"); 28 | return new Response("Invalid signature", { status: 403 }); 29 | } 30 | 31 | const parsedBody = JSON.parse(textBody); 32 | const { type, payload } = parsedBody; 33 | if (type === "usage.tracked") { 34 | const { human_id, type, value } = payload; 35 | if (type === "conversational_seconds") { 36 | // Use 1 credit per second 37 | const creditCost = value; 38 | const resp = await CreditsController.reportCreditUsage( 39 | human_id, 40 | creditCost * -1, 41 | ); 42 | const entry = resp.data; 43 | await UserController.updateLimits(human_id, entry.balance); 44 | } 45 | } 46 | return new Response(null, { status: 200 }); 47 | } 48 | -------------------------------------------------------------------------------- /src/app/auth/google/callback/route.tsx: -------------------------------------------------------------------------------- 1 | import { UserController } from "@/lib/server/controller/user"; 2 | import { OAuth2Client } from "google-auth-library"; 3 | import { cookies } from "next/headers"; 4 | import { redirect } from "next/navigation"; 5 | import { NextRequest } from "next/server"; 6 | 7 | export const runtime = "nodejs"; 8 | export const dynamic = "force-dynamic"; 9 | 10 | export async function GET(req: NextRequest) { 11 | const client = new OAuth2Client( 12 | process.env.GOOGLE_CLIENT_ID, 13 | process.env.GOOGLE_CLIENT_SECRET, 14 | process.env.GOOGLE_REDIRECT_URI, 15 | ); 16 | 17 | const searchParams = req.nextUrl.searchParams; 18 | const code = searchParams.get("code"); 19 | if (!code) { 20 | return Response.json({ message: "Missing code" }, { status: 400 }); 21 | } 22 | 23 | const { tokens } = await client.getToken(code); 24 | const { access_token } = tokens; 25 | if (!access_token) { 26 | return Response.json({ message: "Missing access token" }, { status: 500 }); 27 | } 28 | 29 | const { authToken } = await UserController.loginGoogleUser({ 30 | access_token, 31 | }); 32 | const cooks = await cookies(); 33 | cooks.set("auth_token", authToken, { 34 | expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 180), 35 | }); 36 | redirect("/"); 37 | } 38 | -------------------------------------------------------------------------------- /src/app/auth/google/login/route.tsx: -------------------------------------------------------------------------------- 1 | import { GenerateAuthUrlOpts, OAuth2Client } from "google-auth-library"; 2 | import { redirect } from "next/navigation"; 3 | 4 | export const runtime = "nodejs"; 5 | export const dynamic = "force-dynamic"; 6 | 7 | export async function GET() { 8 | const client = new OAuth2Client( 9 | process.env.GOOGLE_CLIENT_ID, 10 | process.env.GOOGLE_CLIENT_SECRET, 11 | process.env.GOOGLE_REDIRECT_URI, 12 | ); 13 | 14 | const opts: GenerateAuthUrlOpts = { 15 | access_type: "offline", 16 | scope: [ 17 | "https://www.googleapis.com/auth/userinfo.email", 18 | "https://www.googleapis.com/auth/userinfo.profile", 19 | ], 20 | }; 21 | 22 | const url = client.generateAuthUrl(opts); 23 | 24 | redirect(url); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/auth/google/test/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | 3 | export const runtime = "nodejs"; 4 | export const dynamic = "force-dynamic"; 5 | 6 | export async function GET(request: NextRequest) { 7 | return new Response("Hello, world!"); 8 | } 9 | -------------------------------------------------------------------------------- /src/app/checkout/ClientSecretProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | type ClientSecretContextData = { 6 | clientSecret: string; 7 | }; 8 | 9 | const ClientSecretContext = React.createContext< 10 | ClientSecretContextData | undefined 11 | >(undefined); 12 | 13 | export function ClientSecretProvider({ 14 | children, 15 | clientSecret, 16 | }: { 17 | children: React.ReactNode; 18 | clientSecret: string; 19 | }) { 20 | return ( 21 | 22 | {children} 23 | 24 | ); 25 | } 26 | 27 | export function useClientSecret() { 28 | const context = React.useContext(ClientSecretContext); 29 | if (context === undefined) { 30 | throw new Error( 31 | "useClientSecret must be used within a ClientSecretProvider", 32 | ); 33 | } 34 | return context; 35 | } 36 | -------------------------------------------------------------------------------- /src/app/checkout/layout.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import "../globals.css"; 3 | import { CreditsController } from "@/lib/server/controller/credits"; 4 | import { UserController } from "@/lib/server/controller/user"; 5 | import { redirect } from "next/navigation"; 6 | import { ClientSecretProvider } from "./ClientSecretProvider"; 7 | 8 | export const metadata = { 9 | title: "Rizz.AI - Go on AI Powered Dates", 10 | description: 11 | "Rizz.AI is a platform to help you practice your social skills with AI powered virtual dates.", 12 | }; 13 | 14 | export default async function RootLayout({ 15 | children, 16 | }: { 17 | children: React.ReactNode; 18 | }) { 19 | const user = await UserController.getUserFromCookies(); 20 | if (!user) { 21 | redirect("/"); 22 | } 23 | const clientSecret = await CreditsController.getClientSecret({ 24 | customer: user.stripe_customer, 25 | }); 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | {children} 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/app/checkout/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Script from "next/script"; 3 | import { useClientSecret } from "./ClientSecretProvider"; 4 | 5 | export default function Page() { 6 | const { clientSecret } = useClientSecret(); 7 | const html = ` 8 | 9 | 14 | 15 | `; 16 | 17 | return ( 18 | <> 19 | 24 |
25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/fonts/SF-Pro-Rounded-Black.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabber-dev/example-app-rizz-ai/42e6c595c5925f241ae5e5cea536f4794516457a/src/app/fonts/SF-Pro-Rounded-Black.otf -------------------------------------------------------------------------------- /src/app/fonts/SF-Pro-Rounded-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabber-dev/example-app-rizz-ai/42e6c595c5925f241ae5e5cea536f4794516457a/src/app/fonts/SF-Pro-Rounded-Bold.otf -------------------------------------------------------------------------------- /src/app/fonts/SF-Pro-Rounded-Heavy.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabber-dev/example-app-rizz-ai/42e6c595c5925f241ae5e5cea536f4794516457a/src/app/fonts/SF-Pro-Rounded-Heavy.otf -------------------------------------------------------------------------------- /src/app/fonts/SF-Pro-Rounded-Light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabber-dev/example-app-rizz-ai/42e6c595c5925f241ae5e5cea536f4794516457a/src/app/fonts/SF-Pro-Rounded-Light.otf -------------------------------------------------------------------------------- /src/app/fonts/SF-Pro-Rounded-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabber-dev/example-app-rizz-ai/42e6c595c5925f241ae5e5cea536f4794516457a/src/app/fonts/SF-Pro-Rounded-Medium.otf -------------------------------------------------------------------------------- /src/app/fonts/SF-Pro-Rounded-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabber-dev/example-app-rizz-ai/42e6c595c5925f241ae5e5cea536f4794516457a/src/app/fonts/SF-Pro-Rounded-Regular.otf -------------------------------------------------------------------------------- /src/app/fonts/SF-Pro-Rounded-Semibold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabber-dev/example-app-rizz-ai/42e6c595c5925f241ae5e5cea536f4794516457a/src/app/fonts/SF-Pro-Rounded-Semibold.otf -------------------------------------------------------------------------------- /src/app/fonts/SF-Pro-Rounded-Thin.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabber-dev/example-app-rizz-ai/42e6c595c5925f241ae5e5cea536f4794516457a/src/app/fonts/SF-Pro-Rounded-Thin.otf -------------------------------------------------------------------------------- /src/app/fonts/SF-Pro-Rounded-Ultralight.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabber-dev/example-app-rizz-ai/42e6c595c5925f241ae5e5cea536f4794516457a/src/app/fonts/SF-Pro-Rounded-Ultralight.otf -------------------------------------------------------------------------------- /src/app/fonts/SF-Pro-Text-RegularItalic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabber-dev/example-app-rizz-ai/42e6c595c5925f241ae5e5cea536f4794516457a/src/app/fonts/SF-Pro-Text-RegularItalic.otf -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /src/app/legal/layout.tsx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: "Next.js", 3 | description: "Generated by Next.js", 4 | }; 5 | 6 | export default function RootLayout({ 7 | children, 8 | }: { 9 | children: React.ReactNode; 10 | }) { 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/legal/privacy/page.tsx: -------------------------------------------------------------------------------- 1 | import PrivacyPolicy from "@/components/legal/PrivacyPolicy"; 2 | 3 | export default function PrivacyPolicyPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/legal/terms/page.tsx: -------------------------------------------------------------------------------- 1 | import TermsOfService from "@/components/legal/TermsOfService"; 2 | 3 | export default function TermsOfServicePage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/AgentAudioVisualizer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSession } from "gabber-client-react"; 4 | import { useEffect, useState } from "react"; 5 | 6 | export const AgentAudioVisualizer = () => { 7 | const { agentState, agentVolumeBands, agentVolume } = useSession(); 8 | const [ticker, setTicker] = useState(0); 9 | 10 | useEffect(() => { 11 | const itv = setInterval(() => { 12 | setTicker((t) => (t + 1) % 5); 13 | }, 200); 14 | 15 | return () => { 16 | clearInterval(itv); 17 | }; 18 | }, []); 19 | 20 | return ( 21 |
22 | {agentVolumeBands.map((frequency, index) => { 23 | const isCenter = index + 1 === Math.round(agentVolumeBands.length / 2); 24 | let bg = "bg-primary"; 25 | let animate = ""; 26 | if (agentVolume > 0.1) { 27 | bg = "bg-primary"; 28 | } else { 29 | if (agentState === "thinking") { 30 | bg = 31 | ticker % agentVolumeBands.length === index 32 | ? "bg-primary" 33 | : "bg-base-300"; 34 | } else if (agentState === "listening") { 35 | bg = isCenter ? "bg-primary" : "bg-base-300"; 36 | } 37 | } 38 | 39 | return ( 40 |
0.1 ? 5 + frequency * 100 : 5}%`, 45 | }} 46 | >
47 | ); 48 | })} 49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/Analyze.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useMemo } from "react"; 2 | import { useAppState } from "./AppStateProvider"; 3 | import { SessionDetailModal } from "./SessionDetailModal"; 4 | import { useRouter } from "next/navigation"; 5 | import toast from "react-hot-toast"; 6 | import { MobileTabs } from "./analyze/MobileTabs"; 7 | import { StreakCard } from "./analyze/StreakCard"; 8 | import { PastSessionsList } from "./analyze/PastSessionsList"; 9 | import { ScenariosList } from "./ScenariosList"; 10 | import { PersonasList } from "./PersonasList"; 11 | import { SelectedPersonaDetails } from "./SelectedPersonaDetails"; 12 | import { useStreakData } from "@/hooks/useStreakData"; 13 | 14 | export function Analyze() { 15 | const [activeTab, setActiveTab] = useState<"analyze" | "practice">( 16 | "practice", 17 | ); 18 | const [selectedSessionId, setSelectedSessionId] = useState( 19 | null, 20 | ); 21 | const { sessions, personas, scenarios, selectedPersona } = useAppState(); 22 | const router = useRouter(); 23 | const streakData = useStreakData(sessions); 24 | 25 | // Only show on mobile 26 | return ( 27 |
28 | 29 | 30 | {/* Analyze Tab Content */} 31 | {activeTab === "analyze" && ( 32 |
33 | 34 |
35 |
36 | 41 |
42 |
43 |
44 | )} 45 | 46 | {/* Practice Tab Content */} 47 | {activeTab === "practice" && ( 48 |
49 | {selectedPersona && } 50 |
51 | {selectedPersona ? : } 52 |
53 |
54 | )} 55 | 56 | setSelectedSessionId(null)} 59 | /> 60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/components/AppStateProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Configuration, 5 | Persona, 6 | PersonaApi, 7 | RealtimeApi, 8 | RealtimeSession, 9 | Scenario, 10 | ScenarioApi, 11 | LLMApi, 12 | } from "@/generated"; 13 | import { UserInfo } from "@/lib/server/controller/user"; 14 | import axios from "axios"; 15 | import { 16 | createContext, 17 | useCallback, 18 | useContext, 19 | useMemo, 20 | useRef, 21 | useState, 22 | } from "react"; 23 | import toast from "react-hot-toast"; 24 | import Stripe from "stripe"; 25 | import { shuffle } from "lodash"; 26 | 27 | type AppStateContextType = { 28 | userInfo: UserInfo | null; 29 | usageToken: string; 30 | 31 | credits: number; 32 | creditsLoading: boolean; 33 | refreshCredits: () => void; 34 | 35 | sessions: RealtimeSession[]; 36 | sessionsLoading: boolean; 37 | refreshSessions: () => void; 38 | 39 | personas: Persona[]; 40 | personasLoading: boolean; 41 | selectedPersona: Persona | null; 42 | setSelectedPersona: (p: Persona | null) => void; 43 | refreshPersonas: () => void; 44 | shufflePersonas: () => void; 45 | 46 | scenarios: Scenario[]; 47 | scenariosLoading: boolean; 48 | selectedScenario: Scenario | null; 49 | refreshScenarios: () => void; 50 | setSelectedScenario: (s: Scenario | null) => void; 51 | 52 | hasPaid: boolean; 53 | products: Stripe.Product[]; 54 | 55 | showPaywall: { session: string | null } | null; 56 | setShowPaywall: (paywall: { session: string | null } | null) => void; 57 | 58 | showSignupWall: boolean; 59 | setShowSignupWall: (show: boolean) => void; 60 | 61 | realtimeApi: RealtimeApi; 62 | personaApi: PersonaApi; 63 | scenarioApi: ScenarioApi; 64 | llmApi: LLMApi; 65 | gender: "men" | "women" | "all"; 66 | setGender: (g: "men" | "women" | "all") => void; 67 | 68 | user: UserInfo | null; 69 | }; 70 | 71 | export const CreditContext = createContext( 72 | undefined, 73 | ); 74 | 75 | export function AppStateProvider({ 76 | children, 77 | userInfo, 78 | usageToken, 79 | initialSessions, 80 | initialPersonas, 81 | initialScenarios, 82 | initialCredits, 83 | initialHasPaid, 84 | initialProducts, 85 | }: { 86 | children: React.ReactNode; 87 | userInfo: UserInfo | null; 88 | usageToken: string; 89 | initialSessions: RealtimeSession[]; 90 | initialPersonas: Persona[]; 91 | initialScenarios: Scenario[]; 92 | initialCredits: number; 93 | initialHasPaid: boolean; 94 | initialProducts: Stripe.Product[]; 95 | }) { 96 | const [gender, setGender] = useState<"men" | "women" | "all">("all"); 97 | const [credits, setCredits] = useState(initialCredits); 98 | const [creditsLoading, setCreditsLoading] = useState(true); 99 | 100 | const [showPaywall, setShowPaywall] = useState<{ 101 | session: string | null; 102 | } | null>(null); 103 | const [showSignupWall, setShowSignupWall] = useState(false); 104 | 105 | const [sessions, setSessions] = useState(initialSessions); 106 | const [sessionsLoading, setSessionsLoading] = useState(false); 107 | const sessionsLock = useRef(false); 108 | 109 | const [personas, setPersonas] = useState(initialPersonas); 110 | const [personasLoading, setPersonasLoading] = useState(false); 111 | const [selectedPersona, setSelectedPersona] = useState(null); 112 | const personasLock = useRef(false); 113 | 114 | const [scenarios, setScenarios] = useState(initialScenarios); 115 | const [scenariosLoading, setScenariosLoading] = useState(false); 116 | const [selectedScenario, setSelectedScenario] = useState( 117 | null, 118 | ); 119 | const scenariosLock = useRef(false); 120 | 121 | const realtimeApi = useMemo(() => { 122 | return new RealtimeApi(new Configuration({ accessToken: usageToken })); 123 | }, [usageToken]); 124 | 125 | const personaApi = useMemo(() => { 126 | return new PersonaApi(new Configuration({ accessToken: usageToken })); 127 | }, [usageToken]); 128 | 129 | const scenarioApi = useMemo(() => { 130 | return new ScenarioApi(new Configuration({ accessToken: usageToken })); 131 | }, [usageToken]); 132 | 133 | const llmApi = useMemo(() => { 134 | return new LLMApi(new Configuration({ accessToken: usageToken })); 135 | }, [usageToken]); 136 | 137 | const refreshPersonas = useCallback(async () => { 138 | if (personasLock.current) return; 139 | personasLock.current = true; 140 | setPersonasLoading(true); 141 | try { 142 | const { data } = await personaApi.listPersonas(); 143 | setPersonas(data.values || []); 144 | } catch (error) { 145 | toast.error("Failed to load personas"); 146 | } finally { 147 | setPersonasLoading(false); 148 | personasLock.current = false; 149 | } 150 | }, [personaApi]); 151 | 152 | const refreshScenarios = useCallback(async () => { 153 | if (scenariosLock.current) return; 154 | scenariosLock.current = true; 155 | setScenariosLoading(true); 156 | try { 157 | const { data } = await scenarioApi.listScenarios(); 158 | setScenarios(data.values || []); 159 | } catch (error) { 160 | toast.error("Failed to load scenarios"); 161 | } finally { 162 | setScenariosLoading(false); 163 | scenariosLock.current = false; 164 | } 165 | }, [scenarioApi]); 166 | 167 | const refreshSessions = useCallback(async () => { 168 | if (sessionsLock.current) return; 169 | sessionsLock.current = true; 170 | setSessionsLoading(true); 171 | try { 172 | const { data } = await realtimeApi.listRealtimeSessions(); 173 | setSessions(data.values || []); 174 | } catch (error) { 175 | toast.error("Failed to load sessions"); 176 | } finally { 177 | setSessionsLoading(false); 178 | sessionsLock.current = false; 179 | } 180 | }, [realtimeApi]); 181 | 182 | const refreshCredits = async () => { 183 | const { balance } = (await axios.get("/api/credits")).data; 184 | setCredits(balance); 185 | }; 186 | 187 | const shufflePersonas = () => { 188 | setPersonas(shuffle(personas)); 189 | }; 190 | 191 | const filteredPersonas = useMemo(() => { 192 | return personas.filter((p) => { 193 | if (gender === "all") return true; 194 | if (gender === "men") { 195 | return p.gender === "male"; 196 | } else if (gender === "women") { 197 | return p.gender === "female"; 198 | } 199 | return true; 200 | }); 201 | }, [gender, personas]); 202 | 203 | return ( 204 | 250 | {children} 251 | 252 | ); 253 | } 254 | 255 | export function useAppState() { 256 | const context = useContext(CreditContext); 257 | if (context === undefined) { 258 | throw new Error("useCredit must be used within a CreditProvider"); 259 | } 260 | return context; 261 | } 262 | -------------------------------------------------------------------------------- /src/components/AttributeCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | type AttributeRating = { 4 | name: string; 5 | score: "poor" | "fair" | "good"; 6 | summary: string; 7 | }; 8 | 9 | function getRatingColor(actual: string, rating: string): string { 10 | if (rating === actual) { 11 | return "bg-primary"; 12 | } 13 | return "bg-base-content opacity-20"; 14 | } 15 | 16 | function AttributeCard({ attr }: { attr: AttributeRating }) { 17 | const [isModalOpen, setIsModalOpen] = useState(false); 18 | 19 | return ( 20 | <> 21 |
setIsModalOpen(true)} 24 | > 25 |
26 | {attr.name} 27 |
28 |
29 | {["poor", "fair", "good"].map((rating) => ( 30 |
37 | ))} 38 |
39 |
40 |
41 |
42 | 43 | {/* Modal */} 44 | {isModalOpen && ( 45 |
46 |
47 |
{attr.name}
48 |
49 | {attr.summary} 50 |
51 | 60 |
61 |
62 | )} 63 | 64 | ); 65 | } 66 | 67 | export default AttributeCard; 68 | -------------------------------------------------------------------------------- /src/components/BorderButton.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes } from "react"; 2 | 3 | type Props = ButtonHTMLAttributes & { 4 | primary?: string; // Optional custom prop for primary color 5 | }; 6 | 7 | export function BorderButton(props: Props) { 8 | return ( 9 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Button3D.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | children: React.ReactNode; 3 | onClick?: () => void; 4 | enabled: boolean; 5 | className?: string; 6 | }; 7 | 8 | export function Button3D({ children, onClick, enabled, className }: Props) { 9 | return ( 10 |
24 | {children} 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | 5 | export function Footer() { 6 | return ( 7 |
8 |
9 |
powered by
10 | 14 | Gabber Logo 22 | 23 |
24 | 25 |
26 | 27 |
28 | 32 | 33 | Want to build an app like this? Check out the full codebase 34 | 35 | Build an app like this 36 | 37 |
38 | 39 |
40 | 41 |
42 | 43 | Privacy Policy 44 | 45 | 46 | Terms of Service 47 | 48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/components/GenderSelection.tsx: -------------------------------------------------------------------------------- 1 | import { useAppState } from "./AppStateProvider"; 2 | 3 | export function GenderSelection() { 4 | const { setGender, gender } = useAppState(); 5 | return ( 6 |
7 |
8 | 18 |
19 |
20 | 31 | 42 | 53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useRouter } from "next/navigation"; 3 | import { useAppState } from "./AppStateProvider"; 4 | import { Logo } from "./icons/Logo"; 5 | import { ShinyButton } from "@/components/ShinyButton"; 6 | 7 | export function AuthenticatedHeader() { 8 | const { credits, setShowPaywall, userInfo } = useAppState(); 9 | const router = useRouter(); 10 | return ( 11 |
12 | 13 | 14 | 15 |
16 | {userInfo ? ( 17 | { 19 | setShowPaywall({ session: null }); 20 | }} 21 | > 22 |
23 | Credits: {credits} 24 |
25 |
26 | ) : ( 27 | { 29 | router.push("/auth/google/login"); 30 | }} 31 | > 32 |
Login or Sign-Up
33 |
34 | )} 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/PaywallPopup.tsx: -------------------------------------------------------------------------------- 1 | import { useAppState } from "@/components/AppStateProvider"; 2 | import { useCallback, useMemo, useState } from "react"; 3 | import { useRouter } from "next/navigation"; 4 | import Stripe from "stripe"; 5 | import toast from "react-hot-toast"; 6 | 7 | const ProductCard = ({ 8 | product, 9 | onClick, 10 | }: { 11 | product: Stripe.Product; 12 | onClick: () => void; 13 | }) => { 14 | return ( 15 |
30 |
31 | {product.metadata.credit_amount} Credits 32 |
33 |
34 | ${((product.default_price as any).unit_amount / 100).toFixed(2)} 35 |
36 |
37 | ); 38 | }; 39 | 40 | export function PaywallPopup() { 41 | const { products, showPaywall, hasPaid } = useAppState(); 42 | const [activeTab, setActiveTab] = useState(hasPaid ? "oneTime" : "recurring"); 43 | const router = useRouter(); 44 | 45 | const recurringProducts = useMemo(() => { 46 | return products 47 | .filter((product) => (product.default_price as any).type === "recurring") 48 | .sort((a, b) => { 49 | const priceA = (a.default_price as any).unit_amount; 50 | const priceB = (b.default_price as any).unit_amount; 51 | return priceA - priceB; 52 | }); 53 | }, [products]); 54 | 55 | const oneTimeProducts = useMemo(() => { 56 | return products.filter( 57 | (product) => (product.default_price as any).type === "one_time", 58 | ); 59 | }, [products]); 60 | 61 | const getCheckoutSession = useCallback( 62 | async (priceId: string) => { 63 | const paywallSession = showPaywall?.session; 64 | const response = await fetch("/api/checkout", { 65 | method: "POST", 66 | headers: { 67 | "Content-Type": "application/json", 68 | }, 69 | body: JSON.stringify({ 70 | price_id: priceId, 71 | gabber_session: paywallSession, 72 | }), 73 | }); 74 | const data = await response.json(); 75 | const checkoutSession: Stripe.Checkout.Session = data; 76 | if (!checkoutSession.url) { 77 | toast.error("Error creating checkout session"); 78 | return; 79 | } 80 | router.push(checkoutSession.url); 81 | }, 82 | [router, showPaywall?.session], 83 | ); 84 | 85 | return ( 86 |
87 |
88 |
89 | 99 | 107 |
108 |
109 |
113 | {activeTab === "recurring" && ( 114 |
115 |

116 | Recurring Plans 117 |

118 |
119 |
120 | {recurringProducts.map((product) => ( 121 | { 125 | await getCheckoutSession( 126 | (product.default_price as any).id, 127 | ); 128 | }} 129 | /> 130 | ))} 131 |
132 |
133 |
134 | )} 135 | {activeTab === "oneTime" && ( 136 |
137 |

138 | One-time Token Grant 139 |

140 |
141 |
142 | {oneTimeProducts.map((product) => ( 143 | { 147 | getCheckoutSession((product.default_price as any).id); 148 | }} 149 | /> 150 | ))} 151 |
152 |
153 |
154 | )} 155 |
156 |
157 | ); 158 | } 159 | -------------------------------------------------------------------------------- /src/components/PersonaButton.tsx: -------------------------------------------------------------------------------- 1 | import { Persona } from "@/generated"; 2 | import Image from "next/image"; 3 | 4 | export function PersonaButton({ 5 | persona, 6 | onClick, 7 | }: { 8 | persona: Persona; 9 | onClick: () => void; 10 | }) { 11 | return ( 12 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/PersonasList.tsx: -------------------------------------------------------------------------------- 1 | import { useAppState } from "./AppStateProvider"; 2 | import { GenderSelection } from "./GenderSelection"; 3 | import { PersonaButton } from "./PersonaButton"; 4 | 5 | export function PersonasList() { 6 | const { personas, setSelectedPersona } = useAppState(); 7 | return ( 8 |
9 |
10 |

11 | Choose a Character 12 |

13 | 14 |
15 |
16 |
17 | {personas.map((persona) => ( 18 | setSelectedPersona(persona)} 22 | /> 23 | ))} 24 |
25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/ScenariosList.tsx: -------------------------------------------------------------------------------- 1 | import { useAppState } from "./AppStateProvider"; 2 | import { useRouter } from "next/navigation"; 3 | 4 | export function ScenariosList() { 5 | const { 6 | scenarios, 7 | selectedScenario, 8 | setSelectedPersona, 9 | setSelectedScenario, 10 | selectedPersona, 11 | userInfo, 12 | } = useAppState(); 13 | const router = useRouter(); 14 | 15 | return ( 16 |
17 |
18 |

19 | Select a Scenario To Practice 20 |

21 | {selectedScenario ? ( 22 | 36 | ) : ( 37 | 46 | )} 47 |
48 |
49 |
50 | {scenarios.map((scenario) => ( 51 | 66 | ))} 67 |
68 |
69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/components/Score.tsx: -------------------------------------------------------------------------------- 1 | import { Score as ScoreModel } from "@/lib/model/score"; 2 | import { useState } from "react"; 3 | import { Ring } from "@/components/stats/RizzScore"; 4 | import AttributeCard from "./AttributeCard"; 5 | import { motion } from "framer-motion"; 6 | 7 | type Props = { 8 | score: ScoreModel; 9 | }; 10 | 11 | type AttributeRating = { 12 | name: string; 13 | score: "poor" | "fair" | "good"; 14 | summary: string; 15 | }; 16 | 17 | export function Score({ score }: Props) { 18 | const [isSummaryModalOpen, setSummaryModalOpen] = useState(false); 19 | 20 | const handleSummaryClick = () => { 21 | setSummaryModalOpen(true); 22 | }; 23 | 24 | const handleCloseModal = () => { 25 | setSummaryModalOpen(false); 26 | }; 27 | 28 | const attributes: AttributeRating[] = [ 29 | { name: "Wit", score: score.wit, summary: score.wit_summary }, 30 | { name: "Humor", score: score.humor, summary: score.humor_summary }, 31 | { 32 | name: "Confidence", 33 | score: score.confidence, 34 | summary: score.confidence_summary, 35 | }, 36 | { 37 | name: "Seduction", 38 | score: score.seductiveness, 39 | summary: score.seductiveness_summary, 40 | }, 41 | { 42 | name: "Flow", 43 | score: score.flow, 44 | summary: score.flow_summary, 45 | }, 46 | { 47 | name: "Kindness", 48 | score: score.kindness, 49 | summary: score.kindness_summary, 50 | }, 51 | ]; 52 | 53 | const container = { 54 | hidden: { opacity: 0 }, 55 | show: { 56 | opacity: 1, 57 | transition: { 58 | staggerChildren: 0.1, 59 | }, 60 | }, 61 | }; 62 | 63 | const item = { 64 | hidden: { opacity: 0, y: 20 }, 65 | show: { opacity: 1, y: 0 }, 66 | }; 67 | 68 | return ( 69 | 75 | {/* Score Ring */} 76 | 77 | 78 |
79 |
Your Rizz Score
80 | 91 | {score.rizz_score} 92 | 93 |
94 |
95 | 96 | {/* Summary Button */} 97 | 104 | View Feedback 105 | 106 | 107 | {/* Attribute Ratings */} 108 | 112 | {attributes.map((attr) => ( 113 | 114 | 115 | 116 | ))} 117 | 118 | 119 | {/* Summary Modal */} 120 | {isSummaryModalOpen && ( 121 | 127 | 133 |
Summary
134 |
135 | {score.overall_summary} 136 |
137 | 143 |
144 |
145 | )} 146 |
147 | ); 148 | } 149 | 150 | function FeedbackSection({ title, items }: { title: string; items: string[] }) { 151 | return ( 152 |
153 |
{title}
154 |
    155 | {items.map((item, i) => ( 156 |
  • 157 |
    158 | {item} 159 |
  • 160 | ))} 161 |
162 |
163 | ); 164 | } 165 | 166 | function getRatingColor(actual: string, rating: string): string { 167 | if (rating === actual) { 168 | return "bg-primary"; 169 | } 170 | return "bg-base-content opacity-20"; 171 | } 172 | -------------------------------------------------------------------------------- /src/components/SelectedPersonaDetails.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { useAppState } from "./AppStateProvider"; 3 | 4 | export function SelectedPersonaDetails() { 5 | const { selectedPersona, setSelectedPersona } = useAppState(); 6 | return ( 7 |
8 |
9 | 16 |
17 | {selectedPersona?.name 25 |
26 |
27 |

28 | {selectedPersona?.name || ""} 29 |

30 |
31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/components/SessionDetail.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import axios from "axios"; 3 | import { formatDistanceToNow } from "date-fns"; 4 | import { useAppState } from "@/components/AppStateProvider"; 5 | import toast from "react-hot-toast"; 6 | import { Score } from "@/components/Score"; 7 | 8 | type Message = { 9 | agent: boolean; 10 | text: string; 11 | created_at: string; 12 | }; 13 | 14 | type TimelineItem = { 15 | type: "user" | "agent" | "silence"; 16 | seconds: number; 17 | }; 18 | 19 | type Props = { 20 | sessionId: string; 21 | }; 22 | 23 | export function SessionDetail({ sessionId }: Props) { 24 | const [messages, setMessages] = useState([]); 25 | const [timeline, setTimeline] = useState([]); 26 | const [loading, setLoading] = useState(true); 27 | // const { credits, setCredits } = useAppState(); 28 | // const [score, setScore] = useState(null); 29 | // const [isModalOpen, setIsModalOpen] = useState(false); 30 | const [isStatsCollapsed, setIsStatsCollapsed] = useState( 31 | window.innerWidth < 768, 32 | ); 33 | 34 | useEffect(() => { 35 | const handleResize = () => { 36 | if (window.innerWidth >= 768) { 37 | setIsStatsCollapsed(false); 38 | } 39 | }; 40 | 41 | window.addEventListener("resize", handleResize); 42 | return () => window.removeEventListener("resize", handleResize); 43 | }, []); 44 | 45 | useEffect(() => { 46 | const fetchSessionDetails = async () => { 47 | setLoading(true); 48 | try { 49 | const [messagesRes, timelineRes] = await Promise.all([ 50 | axios.get(`/api/sessions/${sessionId}/messages`), 51 | axios.get(`/api/sessions/${sessionId}/timeline`), 52 | ]); 53 | setMessages(messagesRes.data.values); 54 | setTimeline(timelineRes.data.values); 55 | } catch (e) { 56 | console.error("Failed to fetch session details:", e); 57 | } finally { 58 | setLoading(false); 59 | } 60 | }; 61 | 62 | fetchSessionDetails(); 63 | }, [sessionId]); 64 | 65 | if (loading) { 66 | return ( 67 |
68 |
69 |
70 |
71 | ); 72 | } 73 | 74 | const totalUserTime = timeline 75 | .filter((item) => item.type === "user") 76 | .reduce((acc, item) => acc + item.seconds, 0); 77 | 78 | const totalSilenceTime = timeline 79 | .filter((item) => item.type === "silence") 80 | .reduce((acc, item) => acc + item.seconds, 0); 81 | 82 | const totalAgentTime = timeline 83 | .filter((item) => item.type === "agent") 84 | .reduce((acc, item) => acc + item.seconds, 0); 85 | 86 | const totalTime = totalUserTime + totalSilenceTime + totalAgentTime; 87 | 88 | return ( 89 |
90 | 96 | {!isStatsCollapsed && ( 97 |
98 | 103 | 108 | 113 |
114 | )} 115 |
116 |
Transcript
117 |
118 | {messages.map((message, i) => ( 119 |
127 |
128 | {formatDistanceToNow(new Date(message.created_at))} ago 129 |
130 |
{message.text}
131 |
132 | ))} 133 |
134 |
135 |
136 | ); 137 | } 138 | 139 | function StatCard({ 140 | label, 141 | value, 142 | percentage, 143 | }: { 144 | label: string; 145 | value: string; 146 | percentage: number; 147 | }) { 148 | return ( 149 |
150 |
{label}
151 |
{value}
152 |
153 |
157 |
158 |
159 | ); 160 | } 161 | 162 | // function ScoreModal({ score, onClose }) { 163 | // return ( 164 | //
165 | //
166 | //
Rizz Score
167 | // 168 | // 174 | //
175 | //
176 | // ); 177 | // } 178 | -------------------------------------------------------------------------------- /src/components/SessionDetailModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import axios from "axios"; 3 | import { formatDistanceToNow } from "date-fns"; 4 | import { StatCard } from "@/components/StatCard"; 5 | import { useAppState } from "@/components/AppStateProvider"; 6 | import { ContextMessage } from "@/generated"; 7 | 8 | type TimelineItem = { 9 | type: "user" | "agent" | "silence"; 10 | seconds: number; 11 | }; 12 | 13 | type Props = { 14 | sessionId: string | null; 15 | onClose: () => void; 16 | }; 17 | 18 | export function SessionDetailModal({ sessionId, onClose }: Props) { 19 | const [messages, setMessages] = useState([]); 20 | const [timeline, setTimeline] = useState([]); 21 | const [loading, setLoading] = useState(true); 22 | const { llmApi, realtimeApi } = useAppState(); 23 | 24 | useEffect(() => { 25 | const fetchSessionDetails = async () => { 26 | if (!sessionId) return; 27 | setLoading(true); 28 | try { 29 | const [messagesRes, timelineRes] = await Promise.all([ 30 | realtimeApi.getRealtimeSessionMessages(sessionId), 31 | realtimeApi.getRealtimeSessionTimeline(sessionId), 32 | ]); 33 | setMessages(messagesRes.data.values); 34 | setTimeline( 35 | (timelineRes.data.values ?? []).map((item) => ({ 36 | type: item.type || "user", 37 | seconds: item.seconds || 0, 38 | })), 39 | ); 40 | } catch (e) { 41 | console.error("Failed to fetch session details:", e); 42 | } finally { 43 | setLoading(false); 44 | } 45 | }; 46 | 47 | // Reset states when sessionId changes 48 | setMessages([]); 49 | setTimeline([]); 50 | setLoading(true); 51 | 52 | fetchSessionDetails(); 53 | }, [sessionId, realtimeApi]); 54 | 55 | if (!sessionId) return null; 56 | 57 | const totalUserTime = timeline 58 | .filter((item) => item.type === "user") 59 | .reduce((acc, item) => acc + item.seconds, 0); 60 | 61 | const totalSilenceTime = timeline 62 | .filter((item) => item.type === "silence") 63 | .reduce((acc, item) => acc + item.seconds, 0); 64 | 65 | const totalAgentTime = timeline 66 | .filter((item) => item.type === "agent") 67 | .reduce((acc, item) => acc + item.seconds, 0); 68 | 69 | const totalTime = totalUserTime + totalSilenceTime + totalAgentTime; 70 | 71 | return ( 72 |
73 |
74 |
75 |

Session Details

76 | 79 |
80 | 81 |
82 | {loading ? ( 83 |
84 |
85 |
86 |
87 | ) : ( 88 | <> 89 |
90 | 95 | 100 | 105 |
106 | 107 |
Transcript
108 |
109 | {messages.map((message, i) => ( 110 |
118 |
119 | {formatDistanceToNow(new Date(message.created_at))} ago 120 |
121 |
{message.content}
122 |
123 | ))} 124 |
125 | 126 | )} 127 |
128 |
129 |
130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/components/ShinyButton.tsx: -------------------------------------------------------------------------------- 1 | import resolveConfig from "tailwindcss/resolveConfig"; 2 | import tailwindConfig from "../../tailwind.config"; // Adjust path to your config 3 | import tinycolor from "tinycolor2"; // Optional: For dynamic color manipulation 4 | import { ButtonHTMLAttributes } from "react"; 5 | 6 | const fullConfig = resolveConfig(tailwindConfig); 7 | 8 | // Get the primary color from the Tailwind theme 9 | const primaryColor = fullConfig.daisyui.themes[0].rizz.primary; 10 | const primaryLight = tinycolor(primaryColor).lighten(20).toString(); // 11 | 12 | type Props = ButtonHTMLAttributes & { 13 | primary?: string; // Optional custom prop for primary color 14 | }; 15 | 16 | export function ShinyButton(props: Props) { 17 | return ( 18 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/SignupWallPopup.tsx: -------------------------------------------------------------------------------- 1 | import { useAppState } from "@/components/AppStateProvider"; 2 | import { useRouter } from "next/navigation"; 3 | import { useMemo, useState } from "react"; 4 | import { ShinyButton } from "./ShinyButton"; 5 | 6 | export function SignupWallPopup() { 7 | const { products } = useAppState(); 8 | const router = useRouter(); 9 | 10 | const recurringProducts = useMemo(() => { 11 | return products.filter( 12 | (product) => (product.default_price as any).type === "recurring", 13 | ); 14 | }, [products]); 15 | 16 | const oneTimeProducts = useMemo(() => { 17 | return products.filter( 18 | (product) => (product.default_price as any).type === "one_time", 19 | ); 20 | }, [products]); 21 | 22 | return ( 23 |
24 | { 26 | router.push("/auth/google/login"); 27 | }} 28 | > 29 | Signup 30 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/StatCard.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | label: string; 3 | value: string; 4 | percentage: number; 5 | }; 6 | 7 | export function StatCard({ label, value, percentage }: Props) { 8 | return ( 9 |
10 |
{label}
11 |
{value}
12 |
13 |
17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/analyze/MobileTabs.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from "react"; 2 | 3 | type Props = { 4 | activeTab: "analyze" | "practice"; 5 | setActiveTab: Dispatch>; 6 | }; 7 | 8 | export function MobileTabs({ activeTab, setActiveTab }: Props) { 9 | return ( 10 |
11 | 21 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/analyze/PastSessionsList.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { formatDistanceToNow } from "date-fns"; 3 | import { useRouter } from "next/navigation"; 4 | import toast from "react-hot-toast"; 5 | import { RealtimeSession } from "@/generated"; 6 | 7 | type Props = { 8 | sessions: RealtimeSession[]; 9 | onSessionSelect: (sessionId: string) => void; 10 | className?: string; 11 | }; 12 | 13 | export function PastSessionsList({ 14 | sessions, 15 | onSessionSelect, 16 | className, 17 | }: Props) { 18 | const router = useRouter(); 19 | 20 | return ( 21 | <> 22 |

Past Sessions

23 |
26 | {sessions.length > 0 ? ( 27 | sessions.map((session) => ( 28 |
29 | 84 |
85 | )) 86 | ) : ( 87 |
88 |
89 | Start practicing to fill up your past sessions 90 |
91 |
92 | Your conversation history will appear here 93 |
94 |
95 | )} 96 |
97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /src/components/analyze/StreakCard.tsx: -------------------------------------------------------------------------------- 1 | type StreakData = { 2 | days: { 3 | label: string; 4 | completed: "hit" | "missed"; 5 | }[]; 6 | streak: number; 7 | }; 8 | 9 | type Props = { 10 | streakData: StreakData; 11 | }; 12 | 13 | export function StreakCard({ streakData }: Props) { 14 | return ( 15 |
16 |
17 |
Current Streak
18 |
{streakData.streak} days
19 |
20 |
21 | {streakData.days.map((day, i) => ( 22 |
23 |
{day.label}
24 |
31 |
32 | ))} 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/common/Card.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | children: React.ReactNode; 3 | className?: string; 4 | }; 5 | export function Card({ children, className }: Props) { 6 | return ( 7 |
17 | {children} 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/icons/Logo.tsx: -------------------------------------------------------------------------------- 1 | export function Logo( 2 | props: React.SVGProps & { color?: string }, 3 | ) { 4 | return ( 5 | 12 | 16 | 17 | 21 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/legal/PrivacyPolicy.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState } from "react"; 4 | import Button from "@/components/ui/Button"; 5 | 6 | export default function PrivacyPolicy({ isModal = false }) { 7 | const [activeSection, setActiveSection] = useState("updates"); 8 | 9 | const sections = [ 10 | { 11 | id: "introduction", 12 | title: "Introduction", 13 | content: ( 14 | <> 15 |

16 | At Rizz.AI ("Rizz," "we," 17 | "us," or "our"), we take your privacy seriously. 18 | This Privacy Policy explains how we collect, use, disclose, and 19 | protect your personal data when you access or use our website, 20 | website, website, website, products, services, and applications 21 | (collectively, the "Services"). 22 |

23 |

24 | By using or accessing the Services, you acknowledge that you have 25 | read, understood, and agreed to this Privacy Policy. If you do not 26 | agree, you must immediately discontinue use of the Services. 27 |

28 | 29 | ), 30 | }, 31 | { 32 | id: "updates", 33 | title: "Updates to this Privacy Policy", 34 | content: ( 35 | <> 36 |

37 | We may update this Privacy Policy from time to time as we improve 38 | our Services and comply with new regulations. If changes occur, we 39 | will notify you through appropriate means, such as an update on our 40 | website or an email notification. Your continued use of the Services 41 | after such changes constitutes acceptance of the updated Privacy 42 | Policy. 43 |

44 | 45 | ), 46 | }, 47 | { 48 | id: "age", 49 | title: "Age Restrictions and Protections", 50 | content: ( 51 | <> 52 |

53 | 54 | Rizz is strictly for users 18 years of age or older. 55 | 56 |

57 |
    58 |
  • 59 | We do not knowingly collect or store personal 60 | data from individuals under 18. 61 |
  • 62 |
  • 63 | If we become aware that we have collected information from a 64 | minor, we will delete it immediately. 65 |
  • 66 |
  • 67 | If you believe someone under 18 has accessed the Services, please 68 | contact us at{" "} 69 | 70 | support@rizz.ai 71 | {" "} 72 | to report it. 73 |
  • 74 |
75 | 76 | ), 77 | }, 78 | { 79 | id: "information", 80 | title: "Information We Collect", 81 | content: ( 82 | <> 83 |

84 | We collect the following types of data when you use the Services: 85 |

86 | 87 |

A. Personal Data You Provide

88 |
    89 |
  • 90 | Account Data: Name, phone number or email 91 | address, payment details (if applicable). 92 |
  • 93 |
  • 94 | User-Generated Content: Messages, images, 95 | interactions with AI characters, profile preferences. 96 |
  • 97 |
  • 98 | Support Requests: Communications with customer 99 | service, feedback, and reports. 100 |
  • 101 |
102 | 103 |

104 | B. Automatically Collected Data 105 |

106 |
    107 |
  • 108 | Device & IP Information: Browser type, operating 109 | system, IP address, location (approximate). 110 |
  • 111 |
  • 112 | Usage Data: Pages viewed, time spent in 113 | conversations, feature engagement metrics. 114 |
  • 115 |
  • 116 | Cookies & Tracking: See Section 7 for details. 117 |
  • 118 |
119 | 120 |

C. Sensitive Data Policy

121 |
    122 |
  • 123 | We do not request or store sensitive personal 124 | data such as financial account numbers, social security numbers, 125 | or medical records. 126 |
  • 127 |
  • 128 | Users are strictly prohibited from inputting{" "} 129 | personally identifiable or{" "} 130 | sensitive data into AI interactions. 131 |
  • 132 |
133 | 134 | ), 135 | }, 136 | { 137 | id: "use", 138 | title: "How We Use Your Information", 139 | content: ( 140 | <> 141 |

We use your data to:

142 |
    143 |
  1. 144 | Provide and Improve Services – Operate AI 145 | interactions, process payments, and enhance user experience. 146 |
  2. 147 |
  3. 148 | Ensure Security and Compliance – Prevent fraud, 149 | verify user eligibility, and enforce content policies. 150 |
  4. 151 |
  5. 152 | Analyze and Develop Features – Optimize AI 153 | performance, personalize content, and refine user engagement. 154 |
  6. 155 |
  7. 156 | Marketing & Communications – Send service 157 | updates, promotional offers, and engagement reminders. 158 |
  8. 159 |
160 | 161 | ), 162 | }, 163 | { 164 | id: "sharing", 165 | title: "How We Share Your Data", 166 | content: ( 167 | <> 168 |

169 | We do not sell your personal data. However, we may{" "} 170 | share your data under the following circumstances: 171 |

172 |
    173 |
  • 174 | With Service Providers – Payment processors, 175 | cloud storage, and analytics partners who assist in operating 176 | Rizz. 177 |
  • 178 |
  • 179 | For Legal Compliance – When required by law, such 180 | as responding to subpoenas or government requests. 181 |
  • 182 |
  • 183 | With Consent – If you explicitly authorize data 184 | sharing (e.g., linking accounts with third-party services). 185 |
  • 186 |
187 | 188 | ), 189 | }, 190 | { 191 | id: "retention", 192 | title: "Data Retention and Deletion", 193 | content: ( 194 | <> 195 |
    196 |
  • 197 | We retain your account data for as long as your account is{" "} 198 | active. 199 |
  • 200 |
  • 201 | Chat history and user interactions may be stored 202 | to enhance AI capabilities unless you request deletion. 203 |
  • 204 |
  • 205 | You may{" "} 206 | 207 | request deletion of your account and associated data 208 | {" "} 209 | at any time by emailing{" "} 210 | 211 | support@rizz.ai 212 | 213 | . 214 |
  • 215 |
  • 216 | If you violate our Terms of Service, we may 217 | retain data to comply with legal obligations or prevent fraud. 218 |
  • 219 |
220 | 221 | ), 222 | }, 223 | { 224 | id: "cookies", 225 | title: "Cookies, Tracking, and Opt-Out Options", 226 | content: ( 227 | <> 228 |

229 | Rizz uses cookies and tracking technologies to 230 | enhance user experience. These include: 231 |

232 |
    233 |
  • 234 | Essential Cookies – Required for authentication 235 | and security. 236 |
  • 237 |
  • 238 | Performance Cookies – Help analyze how users 239 | interact with the platform. 240 |
  • 241 |
  • 242 | Marketing Cookies – Used for promotional efforts 243 | (you can opt-out). 244 |
  • 245 |
246 | 247 |

Opt-Out Options

248 |
    249 |
  • 250 | You can disable cookies through browser settings. 251 |
  • 252 |
  • 253 | Marketing emails can be unsubscribed by selecting{" "} 254 | "Opt-Out" in any communication. 255 |
  • 256 |
  • 257 | Rizz does not honor Do Not Track (DNT) signals at 258 | this time. 259 |
  • 260 |
261 | 262 | ), 263 | }, 264 | { 265 | id: "security", 266 | title: "Data Security Measures", 267 | content: ( 268 | <> 269 |

270 | We implement strict{" "} 271 | 272 | technical, organizational, and administrative security 273 | {" "} 274 | measures to protect user data, including: 275 |

276 |
    277 |
  • 278 | Encryption – Sensitive data is encrypted in 279 | transit and at rest. 280 |
  • 281 |
  • 282 | Access Controls – User authentication and 283 | verification protocols. 284 |
  • 285 |
  • 286 | Anonymization – Aggregated and de-identified data 287 | for AI model improvements. 288 |
  • 289 |
290 |

291 | While we take reasonable precautions, no system is 292 | 100% secure. Users are responsible for{" "} 293 | keeping login credentials private and reporting any 294 | suspected security breaches. 295 |

296 | 297 | ), 298 | }, 299 | { 300 | id: "rights", 301 | title: "Your Rights and Choices", 302 | content: ( 303 | <> 304 |

305 | Depending on your location, you may have the right to: 306 |

307 |
    308 |
  • 309 | Access your personal data and request a copy. 310 |
  • 311 |
  • 312 | Correct inaccurate data stored by us. 313 |
  • 314 |
  • 315 | Delete your account and associated data. 316 |
  • 317 |
  • 318 | Opt-out of targeted marketing communications. 319 |
  • 320 |
321 |

322 | To exercise these rights, contact us at{" "} 323 | 324 | support@rizz.ai 325 | 326 | . 327 |

328 | 329 | ), 330 | }, 331 | { 332 | id: "compliance", 333 | title: "Compliance with Privacy Laws", 334 | content: ( 335 | <> 336 |

337 | A. California Consumer Privacy Act (CCPA) & California Privacy 338 | Rights Act (CPRA) 339 |

340 |

California residents have the right to:

341 |
    342 |
  • 343 | Request access to the{" "} 344 | categories of personal data collected in the past 345 | 12 months. 346 |
  • 347 |
  • 348 | Request deletion of personal data (subject to 349 | exemptions). 350 |
  • 351 |
  • 352 | Opt-out of data sharing with third parties for 353 | marketing. 354 |
  • 355 |
356 |

357 | To submit a CCPA request, email:{" "} 358 | 359 | support@rizz.ai 360 | 361 |

362 | 363 |

364 | B. General Data Protection Regulation (GDPR) (EU/EEA Users) 365 |

366 |

367 | If you are located in the European Union (EU) or{" "} 368 | European Economic Area (EEA), you have: 369 |

370 |
    371 |
  • 372 | The right to data portability and restriction of 373 | processing. 374 |
  • 375 |
  • 376 | The right to withdraw consent at any time. 377 |
  • 378 |
  • 379 | The right to lodge a complaint with a supervisory 380 | authority. 381 |
  • 382 |
383 | 384 |

385 | C. Children's Online Privacy Protection Act (COPPA) 386 |

387 |

388 | Rizz does not knowingly collect personal data from 389 | children under 18 years of age. 390 |

391 |

392 | If you believe a minor has used the Services, please email{" "} 393 | 394 | support@rizz.ai 395 | {" "} 396 | for immediate removal. 397 |

398 | 399 | ), 400 | }, 401 | { 402 | id: "enforcement", 403 | title: "Law Enforcement Requests", 404 | content: ( 405 | <> 406 |

407 | We may disclose data in response to{" "} 408 | 409 | court orders, legal requests, or government investigations 410 | {" "} 411 | if required by law. 412 |

413 |

414 | 415 | We do not voluntarily share data with law enforcement 416 | {" "} 417 | unless legally compelled. 418 |

419 | 420 | ), 421 | }, 422 | { 423 | id: "contact", 424 | title: "Contact Information", 425 | content: ( 426 | <> 427 |

428 | For questions regarding this Privacy Policy, contact: 429 |

430 | 444 |

445 | By using Rizz, you acknowledge that you have read, understood, and 446 | agreed to this Privacy Policy. 447 |

448 | 449 | ), 450 | }, 451 | ]; 452 | 453 | return ( 454 |
457 |
458 |
459 |

460 | Rizz Privacy Policy 461 |

462 |

463 | This Privacy Policy describes how we collect, use, and handle your 464 | personal information when you use our services. 465 |

466 |
467 | 468 |
471 | {/* Navigation Sidebar - Only show if not modal */} 472 | {!isModal && ( 473 |
474 |
475 |
476 |

Contents

477 |
478 | 496 |
497 |
498 | )} 499 | 500 | {/* Content Area - Adjust colspan based on modal state */} 501 |
502 | {sections.map((section) => ( 503 |
512 |

513 | 514 | {sections.findIndex((s) => s.id === section.id) + 1} 515 | 516 | {section.title} 517 |

518 |
519 | {section.content} 520 |
521 |
522 | ))} 523 | 524 | {isModal && ( 525 |
526 | 529 | 532 |
533 | )} 534 |
535 |
536 | 537 | {!isModal && ( 538 |
539 |

Effective Date: March 1, 2025

540 |
541 | )} 542 |
543 |
544 | ); 545 | } 546 | -------------------------------------------------------------------------------- /src/components/legal/TermsOfService.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState } from "react"; 4 | import Button from "@/components/ui/Button"; 5 | 6 | export default function TermsOfService({ isModal = false }) { 7 | const [activeSection, setActiveSection] = useState("acceptance"); 8 | 9 | const sections = [ 10 | { 11 | id: "acceptance", 12 | title: "Acceptance of Terms", 13 | content: ( 14 | <> 15 |

16 | Welcome to Rizz. Please read these Terms of Service 17 | ("Terms") carefully. They govern your access and use of 18 | Rizz's website, products, services, and applications 19 | (collectively, the "Services"). 20 |

21 |

22 | These Terms form a legally binding agreement between you 23 | ("User") and Rizz.AI ("Rizz," "we," 24 | "us," "our"). Your use of the Services 25 | constitutes acceptance of these Terms, including our Privacy Policy. 26 |

27 |

28 | If you do not agree to these Terms, you must stop using the Services 29 | immediately. 30 |

31 | 32 | ), 33 | }, 34 | { 35 | id: "changes", 36 | title: "Changes to These Terms", 37 | content: ( 38 | <> 39 |

40 | We may update these Terms as necessary. If we make changes, we will 41 | notify you by: 42 |

43 |
    44 |
  • Posting a notice on https://rizz.ai
  • 45 |
  • Sending you an email or other communication
  • 46 |
47 |

48 | If you continue using the Services after changes become effective, 49 | you agree to the revised Terms. If you disagree, you must stop using 50 | the Services immediately. 51 |

52 | 53 | ), 54 | }, 55 | { 56 | id: "privacy", 57 | title: "Privacy Policy", 58 | content: ( 59 | <> 60 |

61 | Your privacy is important to us. Please refer to our Privacy Policy 62 | for details on how we collect, use, and share your data. 63 |

64 | 65 | ), 66 | }, 67 | { 68 | id: "age", 69 | title: "Age Requirement", 70 | content: ( 71 | <> 72 |
    73 |
  • You must be at least 18 years old to use the Services.
  • 74 |
  • 75 | We do not knowingly collect personal data from users under 18. 76 |
  • 77 |
  • 78 | If you suspect an underage user is accessing the Services, report 79 | it to support@rizz.ai. 80 |
  • 81 |
82 | 83 | ), 84 | }, 85 | { 86 | id: "account", 87 | title: "User Accounts", 88 | content: ( 89 | <> 90 |

A. Account Creation

91 |
    92 |
  • You may need an account to access certain features.
  • 93 |
  • 94 | You must provide accurate, complete, and up-to-date registration 95 | information. 96 |
  • 97 |
98 | 99 |

B. Security & Responsibilities

100 |
    101 |
  • You are responsible for safeguarding your account.
  • 102 |
  • You must not share your account credentials.
  • 103 |
  • 104 | If you suspect unauthorized use, notify us immediately at 105 | support@rizz.ai. 106 |
  • 107 |
108 | 109 | ), 110 | }, 111 | { 112 | id: "acceptable-use", 113 | title: "Acceptable Use of Services", 114 | content: ( 115 | <> 116 |

By using Rizz, you agree NOT to:

117 |
    118 |
  • 119 | Use the Services for illegal, fraudulent, or harmful activities. 120 |
  • 121 |
  • Harass, exploit, or harm other users.
  • 122 |
  • 123 | Distribute hateful, violent, misleading, or defamatory content. 124 |
  • 125 |
  • Attempt unauthorized access to our systems.
  • 126 |
  • Use automated bots or scripts to manipulate interactions.
  • 127 |
128 |

129 | Violation of these rules may result in account termination and legal 130 | action. 131 |

132 | 133 | ), 134 | }, 135 | { 136 | id: "ai-interactions", 137 | title: "AI Interactions and Content", 138 | content: ( 139 | <> 140 |
    141 |
  • 142 | Rizz provides AI-powered interactions that may include suggestive 143 | or adult-oriented content. 144 |
  • 145 |
  • 146 | AI-generated content is for entertainment purposes only and should 147 | not be considered factual or professional advice. 148 |
  • 149 |
  • 150 | Rizz reserves the right to moderate or remove content that 151 | violates our policies. 152 |
  • 153 |
154 | 155 | ), 156 | }, 157 | { 158 | id: "prohibited", 159 | title: "Prohibited Content", 160 | content: ( 161 | <> 162 |

You must not create, share, or engage with:

163 |
    164 |
  • 165 | Child-related content, non-consensual explicit content, or 166 | animal-related content. 167 |
  • 168 |
  • Hate speech, violence, terrorism, or self-harm material.
  • 169 |
  • Fraudulent, defamatory, or misleading information.
  • 170 |
  • 171 | Intellectual property violations (e.g., copyrighted content 172 | without permission). 173 |
  • 174 |
175 |

176 | Violating these policies may result in permanent account termination 177 | and may be reported to legal authorities. 178 |

179 | 180 | ), 181 | }, 182 | { 183 | id: "messaging", 184 | title: "Messaging & Communications", 185 | content: ( 186 | <> 187 |

By signing up, you agree to receive:

188 |
    189 |
  • 190 | AI-driven conversations, feature updates, and promotional messages 191 | via the app or text/email. 192 |
  • 193 |
  • 194 | Message frequency may vary. Standard carrier data rates may apply. 195 |
  • 196 |
  • 197 | You can opt out anytime by replying STOP or contacting 198 | support@rizz.ai. 199 |
  • 200 |
201 | 202 | ), 203 | }, 204 | { 205 | id: "payments", 206 | title: "Payments & Subscriptions", 207 | content: ( 208 | <> 209 |

210 | Certain features require paid subscriptions or one-time payments. By 211 | subscribing, you agree to: 212 |

213 |
    214 |
  • Pay all applicable fees.
  • 215 |
  • 216 | Authorize Rizz to charge your payment method for recurring 217 | payments. 218 |
  • 219 |
  • 220 | Cancel before the next billing cycle to avoid automatic renewal. 221 |
  • 222 |
  • No refunds unless explicitly stated otherwise.
  • 223 |
224 | 225 | ), 226 | }, 227 | { 228 | id: "termination", 229 | title: "Termination of Service", 230 | content: ( 231 | <> 232 |

We may suspend or terminate your access if:

233 |
    234 |
  • You violate these Terms.
  • 235 |
  • Your actions pose a legal, security, or safety risk.
  • 236 |
  • You engage in prohibited conduct.
  • 237 |
238 |

239 | You may terminate your account at any time by contacting 240 | support@rizz.ai. 241 |

242 | 243 | ), 244 | }, 245 | { 246 | id: "intellectual", 247 | title: "Intellectual Property", 248 | content: ( 249 | <> 250 |
    251 |
  • 252 | All intellectual property related to Rizz, AI technology, 253 | branding, and content belongs exclusively to Rizz.AI. 254 |
  • 255 |
  • 256 | You may not copy, distribute, or commercially exploit any 257 | Rizz-generated content without written permission. 258 |
  • 259 |
260 | 261 | ), 262 | }, 263 | { 264 | id: "no-advice", 265 | title: "No Professional Advice", 266 | content: ( 267 | <> 268 |
    269 |
  • 270 | Rizz is NOT a substitute for medical, psychological, financial, or 271 | legal advice. 272 |
  • 273 |
  • AI-generated content is for entertainment purposes only.
  • 274 |
  • Do not rely on AI responses for critical decision-making.
  • 275 |
276 | 277 | ), 278 | }, 279 | { 280 | id: "disclaimers", 281 | title: "Disclaimer of Warranties", 282 | content: ( 283 | <> 284 |

285 | Rizz is provided "as-is", without warranties of any kind. 286 |

287 |

We do not guarantee:

288 |
    289 |
  • Uninterrupted or error-free operation.
  • 290 |
  • The accuracy or reliability of AI-generated responses.
  • 291 |
  • That content will always meet your expectations.
  • 292 |
293 | 294 | ), 295 | }, 296 | { 297 | id: "liability", 298 | title: "Limitation of Liability", 299 | content: ( 300 | <> 301 |

302 | To the maximum extent permitted by law, Rizz is not liable for: 303 |

304 |
    305 |
  • Indirect, incidental, or consequential damages.
  • 306 |
  • Loss of profits, data, or reputation.
  • 307 |
  • 308 | Any claim exceeding the amount paid for the Services in the last 309 | 12 months. 310 |
  • 311 |
312 | 313 | ), 314 | }, 315 | { 316 | id: "indemnification", 317 | title: "Indemnification", 318 | content: ( 319 | <> 320 |

321 | You agree to indemnify and hold harmless Rizz, its affiliates, and 322 | employees from any claims, damages, or expenses arising from: 323 |

324 |
    325 |
  • Your use or misuse of the Services.
  • 326 |
  • Your violation of these Terms.
  • 327 |
  • Any content you create or distribute using Rizz.
  • 328 |
329 | 330 | ), 331 | }, 332 | { 333 | id: "dispute", 334 | title: "Arbitration & Dispute Resolution", 335 | content: ( 336 | <> 337 |

A. Binding Arbitration

338 |

339 | All disputes must be resolved through final and binding arbitration 340 | instead of in court. 341 |

342 |
    343 |
  • 344 | Arbitration will be administered by the American Arbitration 345 | Association (AAA) under its Commercial Arbitration Rules. 346 |
  • 347 |
  • 348 | One arbitrator will be selected by mutual agreement or appointed 349 | by the AAA. 350 |
  • 351 |
  • 352 | The arbitration shall be held in San Francisco or Los Angeles, 353 | California, USA, unless agreed otherwise. 354 |
  • 355 |
  • 356 | Judgment on the arbitration award may be enforced in any court 357 | with jurisdiction. 358 |
  • 359 |
360 | 361 |

B. Confidentiality

362 |

363 | Neither party may disclose the arbitration existence, content, or 364 | results without written consent. 365 |

366 | 367 |

C. Exceptions to Arbitration

368 |

Either party may seek court action for:

369 |
    370 |
  • Temporary restraining orders or preliminary injunctions.
  • 371 |
  • Intellectual property disputes.
  • 372 |
  • Small claims court actions (if applicable).
  • 373 |
374 | 375 |

D. Waiver of Class Actions

376 |
    377 |
  • Claims must be brought individually.
  • 378 |
  • 379 | You and Rizz waive the right to class-action lawsuits or class 380 | arbitrations. 381 |
  • 382 |
383 | 384 | ), 385 | }, 386 | { 387 | id: "governing", 388 | title: "Governing Law", 389 | content: ( 390 | <> 391 |

392 | These Terms are governed by the laws of Delaware, USA. 393 |

394 |

395 | If arbitration is not applicable, all legal disputes must be 396 | litigated in Delaware state or federal courts. 397 |

398 | 399 | ), 400 | }, 401 | { 402 | id: "contact", 403 | title: "Contact Information", 404 | content: ( 405 | <> 406 |

407 | For any questions, concerns, or support, contact us at: 408 |

409 |
    410 |
  • 411 | Email: support@rizz.ai 412 |
  • 413 |
  • 414 | Website: https://rizz.ai 415 |
  • 416 |
417 |

418 | By using Rizz, you confirm that you have read, understood, and agree 419 | to these Terms. 420 |

421 | 422 | ), 423 | }, 424 | ]; 425 | 426 | return ( 427 |
430 |
431 |
432 |

433 | Rizz Terms of Service 434 |

435 |

436 | Please read these Terms of Service carefully before using our 437 | services. By using Rizz, you agree to be bound by these terms. 438 |

439 |
440 | 441 |
444 | {/* Navigation Sidebar - Only show if not modal */} 445 | {!isModal && ( 446 |
447 |
448 |
449 |

Contents

450 |
451 | 469 |
470 |
471 | )} 472 | 473 | {/* Content Area - Adjust colspan based on modal state */} 474 |
475 | {sections.map((section) => ( 476 |
485 |

486 | 487 | {sections.findIndex((s) => s.id === section.id) + 1} 488 | 489 | {section.title} 490 |

491 |
492 | {section.content} 493 |
494 |
495 | ))} 496 | 497 | {isModal && ( 498 |
499 | 502 | 505 |
506 | )} 507 |
508 |
509 | 510 | {!isModal && ( 511 |
512 |

Effective Date: March 1, 2025

513 |
514 | )} 515 |
516 |
517 | ); 518 | } 519 | -------------------------------------------------------------------------------- /src/components/stats/RizzScore.tsx: -------------------------------------------------------------------------------- 1 | import { Lock, Warning } from "@mui/icons-material"; 2 | import { motion } from "framer-motion"; 3 | 4 | export function RizzScore({ score, empty }: { score: number; empty: boolean }) { 5 | return ( 6 |
7 |
8 |
9 | 10 |
11 |
12 |
17 | {empty ? ( 18 |
19 | 20 |
21 | ) : ( 22 | score 23 | )} 24 |
25 |
26 |
27 |
28 | Your Rizz This Week 29 |
30 | {empty && ( 31 |
32 | 33 | Unlock score: go on at least 2 dates 34 | 35 |
36 | )} 37 |
38 | ); 39 | } 40 | 41 | export function Ring({ 42 | percentage, 43 | color, 44 | }: { 45 | percentage: number; 46 | color: string; 47 | }) { 48 | const fullSweep = (Math.PI * 4) / 3; 49 | const start = Math.PI / 2 + (Math.PI / 6) * 2; 50 | const pathFull = arcPath(50, 50, 45, 45, start, fullSweep); 51 | 52 | return ( 53 |
54 | 58 | 59 | 69 | 70 | 71 | 75 | 76 | 77 | 78 | 82 | 83 | 99 | 100 | 101 |
102 | ); 103 | } 104 | 105 | const arcPath = ( 106 | cx: number, 107 | cy: number, 108 | rx: number, 109 | ry: number, 110 | start_angle: number, 111 | sweep_angle: number, 112 | ) => { 113 | const cos = Math.cos; 114 | const sin = Math.sin; 115 | 116 | // @ts-ignore 117 | const f_matrix_times = ([[a, b], [c, d]], [x, y]) => [ 118 | a * x + b * y, 119 | c * x + d * y, 120 | ]; 121 | // @ts-ignore 122 | const f_rotate_matrix = (x) => [ 123 | [cos(x), -sin(x)], 124 | [sin(x), cos(x)], 125 | ]; 126 | // @ts-ignore 127 | const f_vec_add = ([a1, a2], [b1, b2]) => [a1 + b1, a2 + b2]; 128 | // @ts-ignore 129 | 130 | sweep_angle = sweep_angle % (2 * Math.PI); 131 | const rotMatrix = f_rotate_matrix(0); 132 | const [sX, sY] = f_vec_add( 133 | // @ts-ignore 134 | f_matrix_times(rotMatrix, [rx * cos(start_angle), ry * sin(start_angle)]), 135 | [cx, cy], 136 | ); 137 | const [eX, eY] = f_vec_add( 138 | // @ts-ignore 139 | f_matrix_times(rotMatrix, [ 140 | rx * cos(start_angle + sweep_angle), 141 | ry * sin(start_angle + sweep_angle), 142 | ]), 143 | [cx, cy], 144 | ); 145 | const fA = sweep_angle > Math.PI ? 1 : 0; 146 | const fS = sweep_angle > 0 ? 1 : 0; 147 | return ( 148 | "M " + 149 | sX + 150 | " " + 151 | sY + 152 | " A " + 153 | [rx, ry, (90 / (2 * Math.PI)) * 360, fA, fS, eX, eY].join(" ") 154 | ); 155 | }; 156 | -------------------------------------------------------------------------------- /src/components/stats/StatCard.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { Card } from "../common/Card"; 3 | 4 | export function StatCard({ 5 | stat, 6 | title, 7 | empty, 8 | }: { 9 | stat: string; 10 | title: string; 11 | empty?: boolean; 12 | }) { 13 | const textSize = useMemo(() => { 14 | if (stat.length < 3) { 15 | return "text-4xl"; 16 | } else if (stat.length < 8) { 17 | return "text-2xl"; 18 | } else if (stat.length < 12) { 19 | return "text-xl"; 20 | } 21 | return "text-lg"; 22 | }, [stat.length]); 23 | return ( 24 | 25 |
26 |
{title}
27 |
{stat}
28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/stats/Streak.tsx: -------------------------------------------------------------------------------- 1 | import { Stats } from "@/lib/model/stats"; 2 | import { Check } from "@mui/icons-material"; 3 | import { useMemo } from "react"; 4 | import { Card } from "../common/Card"; 5 | 6 | export function Streak({ stats }: { stats: Stats }) { 7 | const statLookup = useMemo(() => { 8 | const days = ["S", "M", "T", "W", "T", "F", "S"]; 9 | let lookup: ({ 10 | label: string; 11 | completed: "missed" | "hit" | "future"; 12 | } | null)[] = [null, null, null, null, null, null, null]; 13 | for (const day of stats.days) { 14 | const dayOfWeek = day.day.getDay(); 15 | lookup[dayOfWeek] = { 16 | label: days[dayOfWeek], 17 | completed: day.number_of_sessions > 0 ? "hit" : "missed", 18 | }; 19 | } 20 | 21 | lookup = lookup.map((day, i) => { 22 | return day ? day : { label: days[i], completed: "future" }; 23 | }); 24 | return lookup; 25 | }, [stats.days]); 26 | return ( 27 | 28 |
29 | {statLookup.map((stat, i) => ( 30 | 31 | ))} 32 |
33 |
34 | ); 35 | } 36 | 37 | function Day({ 38 | label, 39 | completed, 40 | }: { 41 | label: string; 42 | completed: "hit" | "missed" | "future"; 43 | }) { 44 | const icon = useMemo(() => { 45 | if (completed === "hit") { 46 | return ( 47 |
48 | 49 |
50 | ); 51 | } 52 | 53 | return
; 54 | }, [completed]); 55 | return ( 56 |
57 |
{icon}
58 |
{label}
59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes } from "react"; 2 | 3 | type Props = ButtonHTMLAttributes & { 4 | variant?: "primary" | "secondary"; 5 | }; 6 | 7 | export default function Button({ 8 | variant = "primary", 9 | className = "", 10 | ...props 11 | }: Props) { 12 | const baseClasses = "font-medium transition-colors duration-200"; 13 | const variantClasses = { 14 | primary: "bg-primary text-primary-content hover:bg-primary-focus", 15 | secondary: "bg-secondary text-secondary-content hover:bg-secondary-focus", 16 | }; 17 | 18 | return ( 19 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/generated/.gitignore: -------------------------------------------------------------------------------- 1 | wwwroot/*.js 2 | node_modules 3 | typings 4 | dist 5 | -------------------------------------------------------------------------------- /src/generated/.npmignore: -------------------------------------------------------------------------------- 1 | # empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm -------------------------------------------------------------------------------- /src/generated/.openapi-generator-ignore: -------------------------------------------------------------------------------- 1 | # OpenAPI Generator Ignore 2 | # Generated by openapi-generator https://github.com/openapitools/openapi-generator 3 | 4 | # Use this file to prevent files from being overwritten by the generator. 5 | # The patterns follow closely to .gitignore or .dockerignore. 6 | 7 | # As an example, the C# client generator defines ApiClient.cs. 8 | # You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: 9 | #ApiClient.cs 10 | 11 | # You can match any string of characters against a directory, file or extension with a single asterisk (*): 12 | #foo/*/qux 13 | # The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux 14 | 15 | # You can recursively match patterns against a directory, file or extension with a double asterisk (**): 16 | #foo/**/qux 17 | # This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux 18 | 19 | # You can also negate patterns with an exclamation (!). 20 | # For example, you can ignore all files in a docs folder with the file extension .md: 21 | #docs/*.md 22 | # Then explicitly reverse the ignore rule for a single file: 23 | #!docs/README.md 24 | -------------------------------------------------------------------------------- /src/generated/.openapi-generator/FILES: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .npmignore 3 | api.ts 4 | base.ts 5 | common.ts 6 | configuration.ts 7 | git_push.sh 8 | index.ts 9 | -------------------------------------------------------------------------------- /src/generated/.openapi-generator/VERSION: -------------------------------------------------------------------------------- 1 | 7.10.0 2 | -------------------------------------------------------------------------------- /src/generated/base.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Gabber API Reference 5 | * The Gabber API is a set of APIs that allow you to interact with the Gabber platform. 6 | * 7 | * The version of the OpenAPI document: 1.0.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | import type { Configuration } from './configuration'; 17 | // Some imports not used depending on template conditions 18 | // @ts-ignore 19 | import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; 20 | import globalAxios from 'axios'; 21 | 22 | export const BASE_PATH = "https://api.gabber.dev".replace(/\/+$/, ""); 23 | 24 | /** 25 | * 26 | * @export 27 | */ 28 | export const COLLECTION_FORMATS = { 29 | csv: ",", 30 | ssv: " ", 31 | tsv: "\t", 32 | pipes: "|", 33 | }; 34 | 35 | /** 36 | * 37 | * @export 38 | * @interface RequestArgs 39 | */ 40 | export interface RequestArgs { 41 | url: string; 42 | options: RawAxiosRequestConfig; 43 | } 44 | 45 | /** 46 | * 47 | * @export 48 | * @class BaseAPI 49 | */ 50 | export class BaseAPI { 51 | protected configuration: Configuration | undefined; 52 | 53 | constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) { 54 | if (configuration) { 55 | this.configuration = configuration; 56 | this.basePath = configuration.basePath ?? basePath; 57 | } 58 | } 59 | }; 60 | 61 | /** 62 | * 63 | * @export 64 | * @class RequiredError 65 | * @extends {Error} 66 | */ 67 | export class RequiredError extends Error { 68 | constructor(public field: string, msg?: string) { 69 | super(msg); 70 | this.name = "RequiredError" 71 | } 72 | } 73 | 74 | interface ServerMap { 75 | [key: string]: { 76 | url: string, 77 | description: string, 78 | }[]; 79 | } 80 | 81 | /** 82 | * 83 | * @export 84 | */ 85 | export const operationServerMap: ServerMap = { 86 | } 87 | -------------------------------------------------------------------------------- /src/generated/common.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Gabber API Reference 5 | * The Gabber API is a set of APIs that allow you to interact with the Gabber platform. 6 | * 7 | * The version of the OpenAPI document: 1.0.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | import type { Configuration } from "./configuration"; 17 | import type { RequestArgs } from "./base"; 18 | import type { AxiosInstance, AxiosResponse } from 'axios'; 19 | import { RequiredError } from "./base"; 20 | 21 | /** 22 | * 23 | * @export 24 | */ 25 | export const DUMMY_BASE_URL = 'https://example.com' 26 | 27 | /** 28 | * 29 | * @throws {RequiredError} 30 | * @export 31 | */ 32 | export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) { 33 | if (paramValue === null || paramValue === undefined) { 34 | throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`); 35 | } 36 | } 37 | 38 | /** 39 | * 40 | * @export 41 | */ 42 | export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) { 43 | if (configuration && configuration.apiKey) { 44 | const localVarApiKeyValue = typeof configuration.apiKey === 'function' 45 | ? await configuration.apiKey(keyParamName) 46 | : await configuration.apiKey; 47 | object[keyParamName] = localVarApiKeyValue; 48 | } 49 | } 50 | 51 | /** 52 | * 53 | * @export 54 | */ 55 | export const setBasicAuthToObject = function (object: any, configuration?: Configuration) { 56 | if (configuration && (configuration.username || configuration.password)) { 57 | object["auth"] = { username: configuration.username, password: configuration.password }; 58 | } 59 | } 60 | 61 | /** 62 | * 63 | * @export 64 | */ 65 | export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) { 66 | if (configuration && configuration.accessToken) { 67 | const accessToken = typeof configuration.accessToken === 'function' 68 | ? await configuration.accessToken() 69 | : await configuration.accessToken; 70 | object["Authorization"] = "Bearer " + accessToken; 71 | } 72 | } 73 | 74 | /** 75 | * 76 | * @export 77 | */ 78 | export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) { 79 | if (configuration && configuration.accessToken) { 80 | const localVarAccessTokenValue = typeof configuration.accessToken === 'function' 81 | ? await configuration.accessToken(name, scopes) 82 | : await configuration.accessToken; 83 | object["Authorization"] = "Bearer " + localVarAccessTokenValue; 84 | } 85 | } 86 | 87 | function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void { 88 | if (parameter == null) return; 89 | if (typeof parameter === "object") { 90 | if (Array.isArray(parameter)) { 91 | (parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key)); 92 | } 93 | else { 94 | Object.keys(parameter).forEach(currentKey => 95 | setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`) 96 | ); 97 | } 98 | } 99 | else { 100 | if (urlSearchParams.has(key)) { 101 | urlSearchParams.append(key, parameter); 102 | } 103 | else { 104 | urlSearchParams.set(key, parameter); 105 | } 106 | } 107 | } 108 | 109 | /** 110 | * 111 | * @export 112 | */ 113 | export const setSearchParams = function (url: URL, ...objects: any[]) { 114 | const searchParams = new URLSearchParams(url.search); 115 | setFlattenedQueryParams(searchParams, objects); 116 | url.search = searchParams.toString(); 117 | } 118 | 119 | /** 120 | * 121 | * @export 122 | */ 123 | export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) { 124 | const nonString = typeof value !== 'string'; 125 | const needsSerialization = nonString && configuration && configuration.isJsonMime 126 | ? configuration.isJsonMime(requestOptions.headers['Content-Type']) 127 | : nonString; 128 | return needsSerialization 129 | ? JSON.stringify(value !== undefined ? value : {}) 130 | : (value || ""); 131 | } 132 | 133 | /** 134 | * 135 | * @export 136 | */ 137 | export const toPathString = function (url: URL) { 138 | return url.pathname + url.search + url.hash 139 | } 140 | 141 | /** 142 | * 143 | * @export 144 | */ 145 | export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) { 146 | return >(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => { 147 | const axiosRequestArgs = {...axiosArgs.options, url: (axios.defaults.baseURL ? '' : configuration?.basePath ?? basePath) + axiosArgs.url}; 148 | return axios.request(axiosRequestArgs); 149 | }; 150 | } 151 | -------------------------------------------------------------------------------- /src/generated/configuration.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Gabber API Reference 5 | * The Gabber API is a set of APIs that allow you to interact with the Gabber platform. 6 | * 7 | * The version of the OpenAPI document: 1.0.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | export interface ConfigurationParameters { 17 | apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); 18 | username?: string; 19 | password?: string; 20 | accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); 21 | basePath?: string; 22 | serverIndex?: number; 23 | baseOptions?: any; 24 | formDataCtor?: new () => any; 25 | } 26 | 27 | export class Configuration { 28 | /** 29 | * parameter for apiKey security 30 | * @param name security name 31 | * @memberof Configuration 32 | */ 33 | apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); 34 | /** 35 | * parameter for basic security 36 | * 37 | * @type {string} 38 | * @memberof Configuration 39 | */ 40 | username?: string; 41 | /** 42 | * parameter for basic security 43 | * 44 | * @type {string} 45 | * @memberof Configuration 46 | */ 47 | password?: string; 48 | /** 49 | * parameter for oauth2 security 50 | * @param name security name 51 | * @param scopes oauth2 scope 52 | * @memberof Configuration 53 | */ 54 | accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); 55 | /** 56 | * override base path 57 | * 58 | * @type {string} 59 | * @memberof Configuration 60 | */ 61 | basePath?: string; 62 | /** 63 | * override server index 64 | * 65 | * @type {number} 66 | * @memberof Configuration 67 | */ 68 | serverIndex?: number; 69 | /** 70 | * base options for axios calls 71 | * 72 | * @type {any} 73 | * @memberof Configuration 74 | */ 75 | baseOptions?: any; 76 | /** 77 | * The FormData constructor that will be used to create multipart form data 78 | * requests. You can inject this here so that execution environments that 79 | * do not support the FormData class can still run the generated client. 80 | * 81 | * @type {new () => FormData} 82 | */ 83 | formDataCtor?: new () => any; 84 | 85 | constructor(param: ConfigurationParameters = {}) { 86 | this.apiKey = param.apiKey; 87 | this.username = param.username; 88 | this.password = param.password; 89 | this.accessToken = param.accessToken; 90 | this.basePath = param.basePath; 91 | this.serverIndex = param.serverIndex; 92 | this.baseOptions = param.baseOptions; 93 | this.formDataCtor = param.formDataCtor; 94 | } 95 | 96 | /** 97 | * Check if the given MIME is a JSON MIME. 98 | * JSON MIME examples: 99 | * application/json 100 | * application/json; charset=UTF8 101 | * APPLICATION/JSON 102 | * application/vnd.company+json 103 | * @param mime - MIME (Multipurpose Internet Mail Extensions) 104 | * @return True if the given MIME is JSON, false otherwise. 105 | */ 106 | public isJsonMime(mime: string): boolean { 107 | const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i'); 108 | return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json'); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/generated/git_push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/ 3 | # 4 | # Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com" 5 | 6 | git_user_id=$1 7 | git_repo_id=$2 8 | release_note=$3 9 | git_host=$4 10 | 11 | if [ "$git_host" = "" ]; then 12 | git_host="github.com" 13 | echo "[INFO] No command line input provided. Set \$git_host to $git_host" 14 | fi 15 | 16 | if [ "$git_user_id" = "" ]; then 17 | git_user_id="GIT_USER_ID" 18 | echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" 19 | fi 20 | 21 | if [ "$git_repo_id" = "" ]; then 22 | git_repo_id="GIT_REPO_ID" 23 | echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id" 24 | fi 25 | 26 | if [ "$release_note" = "" ]; then 27 | release_note="Minor update" 28 | echo "[INFO] No command line input provided. Set \$release_note to $release_note" 29 | fi 30 | 31 | # Initialize the local directory as a Git repository 32 | git init 33 | 34 | # Adds the files in the local repository and stages them for commit. 35 | git add . 36 | 37 | # Commits the tracked changes and prepares them to be pushed to a remote repository. 38 | git commit -m "$release_note" 39 | 40 | # Sets the new remote 41 | git_remote=$(git remote) 42 | if [ "$git_remote" = "" ]; then # git remote not defined 43 | 44 | if [ "$GIT_TOKEN" = "" ]; then 45 | echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment." 46 | git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git 47 | else 48 | git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git 49 | fi 50 | 51 | fi 52 | 53 | git pull origin master 54 | 55 | # Pushes (Forces) the changes in the local repository up to the remote repository 56 | echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git" 57 | git push origin master 2>&1 | grep -v 'To https' 58 | -------------------------------------------------------------------------------- /src/generated/index.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Gabber API Reference 5 | * The Gabber API is a set of APIs that allow you to interact with the Gabber platform. 6 | * 7 | * The version of the OpenAPI document: 1.0.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | export * from "./api"; 17 | export * from "./configuration"; 18 | 19 | -------------------------------------------------------------------------------- /src/generated/openapi.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabber-dev/example-app-rizz-ai/42e6c595c5925f241ae5e5cea536f4794516457a/src/generated/openapi.ts -------------------------------------------------------------------------------- /src/hooks/useStreakData.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { RealtimeSession } from "@/generated"; 3 | 4 | export function useStreakData(sessions: RealtimeSession[]) { 5 | return useMemo(() => { 6 | const today = new Date(); 7 | const last7Days = Array.from({ length: 7 }, (_, i) => { 8 | const date = new Date(); 9 | date.setDate(today.getDate() - i); 10 | return date; 11 | }).reverse(); 12 | 13 | const sessionsByDay = new Map(); 14 | sessions.forEach((session) => { 15 | const sessionDate = new Date(session.created_at); 16 | const dateKey = sessionDate.toDateString(); 17 | sessionsByDay.set(dateKey, (sessionsByDay.get(dateKey) || 0) + 1); 18 | }); 19 | 20 | let currentStreak = 0; 21 | for (let i = 0; i <= 30; i++) { 22 | const checkDate = new Date(today); 23 | checkDate.setDate(today.getDate() - i); 24 | const dateKey = checkDate.toDateString(); 25 | 26 | if (sessionsByDay.has(dateKey)) { 27 | currentStreak++; 28 | } else { 29 | break; 30 | } 31 | } 32 | 33 | return { 34 | days: last7Days.map((date) => ({ 35 | label: date.getDate().toString(), 36 | completed: sessionsByDay.has(date.toDateString()) 37 | ? ("hit" as const) 38 | : ("missed" as const), 39 | })), 40 | streak: currentStreak, 41 | }; 42 | }, [sessions]); 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/model/google_user.ts: -------------------------------------------------------------------------------- 1 | export type GoogleUser = { 2 | id: string; 3 | email: string; 4 | given_name?: string; 5 | family_name?: string; 6 | image_url?: string; 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/model/score.ts: -------------------------------------------------------------------------------- 1 | export type Score = { 2 | rizz_score: number; 3 | wit: "poor" | "fair" | "good"; 4 | humor: "poor" | "fair" | "good"; 5 | confidence: "poor" | "fair" | "good"; 6 | seductiveness: "poor" | "fair" | "good"; 7 | flow: "poor" | "fair" | "good"; 8 | kindness: "poor" | "fair" | "good"; 9 | wit_summary: string; 10 | humor_summary: string; 11 | confidence_summary: string; 12 | seductiveness_summary: string; 13 | flow_summary: string; 14 | kindness_summary: string; 15 | overall_summary: string; 16 | good_1: string; 17 | good_2: string; 18 | good_3: string; 19 | improve_1: string; 20 | improve_2: string; 21 | improve_3: string; 22 | notes: string; 23 | }; 24 | -------------------------------------------------------------------------------- /src/lib/model/stats.ts: -------------------------------------------------------------------------------- 1 | export type Stats = { 2 | weeks: WeekStats[]; 3 | days: DayStats[]; 4 | }; 5 | 6 | export type DayStats = { 7 | day: Date; 8 | user: string; 9 | spoken_seconds: number; 10 | total_score: number; 11 | average_score: number; 12 | number_of_sessions: number; 13 | total_session_seconds: number; 14 | silence_seconds: number; 15 | }; 16 | 17 | export type WeekStats = { 18 | start_day: Date; 19 | user: string; 20 | spoken_seconds: number; 21 | total_score: number; 22 | average_score: number; 23 | number_of_sessions: number; 24 | silence_seconds: number; 25 | }; 26 | -------------------------------------------------------------------------------- /src/lib/server/controller/credits.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Configuration, 3 | CreditApiFactory, 4 | CreditLedgerEntry, 5 | } from "@/generated"; 6 | import Stripe from "stripe"; 7 | import { v4 } from "uuid"; 8 | 9 | export class CreditsController { 10 | private static getCreditApi() { 11 | return CreditApiFactory( 12 | new Configuration({ 13 | apiKey: process.env.GABBER_API_KEY, 14 | }), 15 | ); 16 | } 17 | 18 | private static getStripeClient() { 19 | if (!process.env.STRIPE_SECRET_KEY) { 20 | throw new Error("Stripe secret key not found"); 21 | } 22 | return new Stripe(process.env.STRIPE_SECRET_KEY); 23 | } 24 | 25 | static async getCustomer(customer_id: string) { 26 | const client = await CreditsController.getStripeClient(); 27 | const customer = await client.customers.retrieve(customer_id); 28 | if (customer.deleted) { 29 | throw new Error("Customer deleted"); 30 | } 31 | return customer.id; 32 | } 33 | 34 | static async getCustomerByEmail(email: string) { 35 | const client = await CreditsController.getStripeClient(); 36 | const customers = await client.customers.list({ email }); 37 | if (customers.data.length === 0 || customers.data[0].deleted) { 38 | return null; 39 | } 40 | return customers.data[0]; 41 | } 42 | 43 | static async getClientSecret({ customer }: { customer: string }) { 44 | const client = await CreditsController.getStripeClient(); 45 | const session = await client.customerSessions.create({ 46 | customer, 47 | components: { 48 | pricing_table: { enabled: true }, 49 | }, 50 | }); 51 | 52 | return session.client_secret!; 53 | } 54 | 55 | static async createCheckoutSession({ 56 | customer, 57 | price, 58 | gabberSession, 59 | }: { 60 | customer: string; 61 | price: string; 62 | gabberSession?: string; 63 | }) { 64 | const client = await CreditsController.getStripeClient(); 65 | const priceObj = await client.prices.retrieve(price, { 66 | expand: ["product"], 67 | }); 68 | const metadata = (priceObj.product as Stripe.Product).metadata; 69 | const mode = priceObj.type === "recurring" ? "subscription" : "payment"; 70 | const redirectUrl = gabberSession 71 | ? process.env.STRIPE_REDIRECT_HOST + `/score?session=${gabberSession}` 72 | : process.env.STRIPE_REDIRECT_HOST; 73 | const session = await client.checkout.sessions.create({ 74 | customer, 75 | payment_method_types: ["card"], 76 | line_items: [ 77 | { 78 | price, // Replace with your Price ID 79 | quantity: 1, 80 | }, 81 | ], 82 | mode, 83 | success_url: redirectUrl, 84 | cancel_url: redirectUrl, 85 | metadata, 86 | subscription_data: mode === "subscription" ? { metadata } : undefined, 87 | payment_intent_data: 88 | mode === "payment" 89 | ? { 90 | metadata, 91 | } 92 | : undefined, 93 | }); 94 | return session; 95 | } 96 | 97 | static async createCustomer({ email }: { email: string }) { 98 | const client = await CreditsController.getStripeClient(); 99 | const customer = await client.customers.create({ email }); 100 | 101 | // Grant ~8 minutes (500 seconds) worth of free credits 102 | await CreditsController.grantFreeCredits(500, customer.id); 103 | 104 | return customer; 105 | } 106 | 107 | static async getProducts() { 108 | const client = await CreditsController.getStripeClient(); 109 | const products = await client.products.list({ 110 | expand: ["data.default_price"], 111 | }); 112 | return products.data; 113 | } 114 | 115 | static async grantFreeCredits(amountCents: number, customer: string) { 116 | // Free credits for first login 117 | const client = await CreditsController.getStripeClient(); 118 | await client.billing.creditGrants.create({ 119 | customer: customer, 120 | name: "Welcome Bonus Credit", 121 | applicability_config: { 122 | scope: { 123 | price_type: "metered", 124 | }, 125 | }, 126 | category: "promotional", 127 | amount: { 128 | type: "monetary", 129 | monetary: { 130 | value: amountCents, 131 | currency: "usd", 132 | }, 133 | }, 134 | }); 135 | 136 | // Report the credit grant to Gabber's backend 137 | await CreditsController.reportCreditUsage(customer, amountCents); 138 | } 139 | 140 | static async getCreditBalance(customer: string) { 141 | if (!process.env.GABBER_CREDIT_ID) { 142 | throw new Error("Server misconfigured - missing credit id"); 143 | } 144 | 145 | const creditApi = this.getCreditApi(); 146 | const client = await CreditsController.getStripeClient(); 147 | let latestCreditDate = new Date(0); 148 | try { 149 | const latestLedgerEntry = ( 150 | await creditApi.getLatestCreditLedgerEntry( 151 | process.env.GABBER_CREDIT_ID, 152 | customer, 153 | ) 154 | ).data; 155 | latestCreditDate = new Date(latestLedgerEntry.created_at); 156 | } catch (e: any) { 157 | console.error("Failed to get latest tracked charge", e.message); 158 | } 159 | 160 | // First apply any credits that have been paid for 161 | 162 | const charges = await client.charges.list({ 163 | customer, 164 | expand: ["data.invoice", "data.payment_intent"], 165 | created: { 166 | // Fetch stripe charges 1 minute before the last credit to account for server drift. 167 | // Duplicates will be handled by the idempotency_key when adding the ledger entry 168 | // via the Gabber API 169 | gte: Math.round(latestCreditDate.getTime() / 1000) - 60 * 1000, 170 | }, 171 | }); 172 | 173 | let latestLedgerEntry: CreditLedgerEntry | null = null; 174 | for (const charge of charges.data) { 175 | let amountStr = "0"; 176 | if (charge.invoice) { 177 | const invoice = charge.invoice as Stripe.Invoice; 178 | amountStr = 179 | invoice.subscription_details?.metadata?.credit_amount || "0"; 180 | } else if (charge.payment_intent) { 181 | const paymentIntent = charge.payment_intent as Stripe.PaymentIntent; 182 | amountStr = paymentIntent.metadata.credit_amount || "0"; 183 | } 184 | const amount = parseInt(amountStr); 185 | if (isNaN(amount)) { 186 | console.error( 187 | "Failed to parse metadata 'credit_amount' field. Make sure it exists on your product and is an integer", 188 | amount, 189 | ); 190 | continue; 191 | } 192 | console.log("Processing charge", amount); 193 | try { 194 | latestLedgerEntry = ( 195 | await creditApi.createCreditLedgerEntry( 196 | process.env.GABBER_CREDIT_ID, 197 | { 198 | amount, 199 | idempotency_key: charge.id, 200 | }, 201 | customer, 202 | ) 203 | ).data; 204 | } catch (e: any) { 205 | console.warn( 206 | "Failed to add ledger entry, this can be normal if idempotency_keys collide.\ 207 | Collisions are themselves normal because the gabber server clock and stripe server clock may have drifted apart.", 208 | e.message, 209 | ); 210 | } 211 | } 212 | 213 | try { 214 | const newLatestEntry = ( 215 | await creditApi.getLatestCreditLedgerEntry( 216 | process.env.GABBER_CREDIT_ID, 217 | customer, 218 | ) 219 | ).data; 220 | return newLatestEntry.balance; 221 | } catch (e: any) { 222 | return 0; 223 | } 224 | } 225 | 226 | static async checkHasPaid(customer: string) { 227 | const client = await CreditsController.getStripeClient(); 228 | const charges = await client.charges.list({ 229 | customer, 230 | limit: 1, 231 | }); 232 | return charges.data.length > 0; 233 | } 234 | 235 | static async reportCreditUsage(customer: string, amount: number) { 236 | if (!process.env.GABBER_CREDIT_ID) { 237 | throw new Error("Server misconfigured - missing credit id"); 238 | } 239 | const creditApi = this.getCreditApi(); 240 | try { 241 | return creditApi.createCreditLedgerEntry( 242 | process.env.GABBER_CREDIT_ID, 243 | { 244 | amount, 245 | idempotency_key: v4(), 246 | }, 247 | customer, 248 | ); 249 | } catch (e: any) { 250 | console.error("Failed to report credit usage:", e.message); 251 | throw e; 252 | } 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/lib/server/controller/score.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Configuration, 3 | LLMApiFactory, 4 | Persona, 5 | PersonaApiFactory, 6 | RealtimeApiFactory, 7 | Scenario, 8 | ScenarioApiFactory, 9 | } from "@/generated"; 10 | import OpenAI from "openai"; 11 | import { Score } from "@/lib/model/score"; 12 | 13 | export class ScoreController { 14 | private static getApis() { 15 | const config = new Configuration({ 16 | apiKey: process.env.GABBER_API_KEY, 17 | }); 18 | return { 19 | realtimeApi: RealtimeApiFactory(config), 20 | personaApi: PersonaApiFactory(config), 21 | scenarioApi: ScenarioApiFactory(config), 22 | llmAPI: LLMApiFactory(config), 23 | }; 24 | } 25 | static async calculateScore(session: string): Promise { 26 | const openai = new OpenAI({ 27 | apiKey: "", 28 | baseURL: "https://api.gabber.dev/v1", 29 | defaultHeaders: { "x-api-key": process.env.GABBER_API_KEY }, 30 | }); 31 | const llm = "66df3c9d-5d8c-4cfc-8b65-a805c1f8ab53"; // Gabber LLM from dashboard 32 | const { realtimeApi, llmAPI } = this.getApis(); 33 | const sessionObj = (await realtimeApi.getRealtimeSession(session)).data; 34 | if ( 35 | !sessionObj.config.generative.persona || 36 | !sessionObj.config.generative.scenario 37 | ) { 38 | throw new Error("Couldn't get persona or scenario"); 39 | } 40 | const scenarioObj = sessionObj.config.generative.scenario; 41 | const personaObj = sessionObj.config.generative.persona; 42 | 43 | const messages = ( 44 | await llmAPI.listContextMessages(sessionObj.config.generative.context.id) 45 | ).data; 46 | 47 | const history = messages.values 48 | .filter((msg) => msg.role !== "system") 49 | .map((msg) => { 50 | return { 51 | role: msg.role, 52 | content: msg.content, 53 | }; 54 | }); 55 | 56 | const systemMessage = generateSystemMessage(personaObj, scenarioObj); 57 | const scoreMessage = generateScoreMessage(); 58 | 59 | const llmMessages = [ 60 | { role: "system", content: systemMessage }, 61 | ...history, 62 | { role: "user", content: scoreMessage }, 63 | ] as { role: "user" | "assistant" | "system"; content: string }[]; 64 | const result = await openai.chat.completions.create({ 65 | model: llm, 66 | messages: llmMessages, 67 | }); 68 | 69 | const scoreJsonString = result.choices[0].message.content; 70 | if (!scoreJsonString) { 71 | throw new Error("Failed to generate score"); 72 | } 73 | try { 74 | const scoreObj = JSON.parse(scoreJsonString); 75 | const rizzScore = calculateRizzScore(scoreObj); 76 | return { 77 | ...scoreObj, 78 | rizz_score: rizzScore, 79 | }; 80 | } catch (e) { 81 | const jsonFixMesages = [ 82 | { 83 | role: "system", 84 | content: generateJSONSystemMessage(personaObj, scenarioObj), 85 | }, 86 | { role: "user", content: scoreJsonString }, 87 | ] as { role: "user" | "assistant" | "system"; content: string }[]; 88 | const jsonFixResult = await openai.chat.completions.create({ 89 | model: llm, 90 | messages: jsonFixMesages, 91 | }); 92 | const fixedJsonString = jsonFixResult.choices[0].message.content; 93 | if (!fixedJsonString) { 94 | throw new Error("Failed to fix JSON"); 95 | } 96 | const scoreObj = JSON.parse(fixedJsonString); 97 | const rizzScore = calculateRizzScore(scoreObj); 98 | return { 99 | ...scoreObj, 100 | rizz_score: rizzScore, 101 | }; 102 | } 103 | } 104 | } 105 | 106 | const generateJSONSystemMessage = (persona: Persona, scenario: Scenario) => { 107 | return `You generated a score for a simulated scenario in JSON format. 108 | The JSON format wasn't quite right. Can you fix this incorrect JSON? 109 | Make the output JSON parsable. Don't include any extra text or markdown.`; 110 | }; 111 | 112 | const generateSystemMessage = (persona: Persona, scenario: Scenario) => { 113 | return `Your name is ${persona?.name}. ${persona?.description}.I want you to pretend ${scenario?.prompt}. 114 | end of this exchange I will ask you to score me and return the score in JSON format.`; 115 | }; 116 | 117 | const generateScoreMessage = () => { 118 | const prompt = `Thanks for helping me with that simulated scenario. Now that it's over, I want to generate a score. \ 119 | Please be harsh on me with the score. 120 | 121 | For context on scoring 122 | 123 | Please rate my performance in the scenario based on the following attributes: 124 | - wit 125 | - humor 126 | - confidence 127 | - seductiveness 128 | - flow (lack of filler words, ability to progress conversation) 129 | - kindness 130 | 131 | each with scores: poor, fair, good 132 | 133 | For each attribute, include a short, casual and funny sentence of why you gave me the score I received for that attribute. For example, for flow you could say "You kept the conversation going, but used a lot of filler words like "um" and "like"" 134 | 135 | Also include an "overall_summary" field that summarizes my performance in the scenario. Make it funny and casual, using slang, but still useful 136 | 137 | Please provide your outputs in JSON format with the format: 138 | { 139 | "wit": "fair" | "good" | "poor", 140 | "wit_summary": "" 141 | "humor": "fair" | "good" | "poor", 142 | "humor_summary": "" 143 | "confidence": "fair" | "good" | "poor", 144 | "confidence_summary": "" 145 | "seductiveness": "fair" | "good" | "poor", 146 | "seductiveness_summary": "" 147 | "flow": "fair" | "good" | "poor" 148 | "flow_summary": "" 149 | "kindness": "fair" | "good" | "poor", 150 | "kindness_summary": "" 151 | "overall_summary": 152 | } 153 | 154 | Make the output JSON parsable. Don't include any extra text or markdown. 155 | `; 156 | 157 | return prompt; 158 | }; 159 | 160 | const calculateRizzScore = (scoreObj: Record): number => { 161 | const pointValues = { 162 | poor: 0, 163 | fair: 1, 164 | good: 2, 165 | }; 166 | 167 | const attributes = [ 168 | "wit", 169 | "humor", 170 | "confidence", 171 | "seductiveness", 172 | "flow", 173 | "kindness", 174 | ]; 175 | 176 | let totalPoints = 0; 177 | const maxPoints = attributes.length * 2; // 2 is max points per attribute 178 | 179 | for (const attr of attributes) { 180 | const score = scoreObj[attr] as keyof typeof pointValues; 181 | totalPoints += pointValues[score] || 0; 182 | } 183 | 184 | // Convert to score out of 100 and round to nearest integer 185 | return Math.round((totalPoints / maxPoints) * 100); 186 | }; 187 | -------------------------------------------------------------------------------- /src/lib/server/controller/session.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Configuration, 3 | RealtimeApiFactory, 4 | SessionTimelineItem, 5 | } from "@/generated"; 6 | 7 | export class SessionController { 8 | private static getConfig() { 9 | return new Configuration({ 10 | apiKey: process.env.GABBER_API_KEY, 11 | }); 12 | } 13 | 14 | static async getSessions(userId: string) { 15 | const config = this.getConfig(); 16 | const sessionApi = RealtimeApiFactory(config); 17 | const response = await sessionApi.listRealtimeSessions(userId); 18 | return response.data; 19 | } 20 | 21 | static async getSessionMessages(sessionId: string) { 22 | const config = this.getConfig(); 23 | const sessionApi = RealtimeApiFactory(config); 24 | const response = await sessionApi.getRealtimeSessionMessages(sessionId); 25 | return response.data; 26 | } 27 | 28 | static async getSessionTimeline(sessionId: string) { 29 | const config = this.getConfig(); 30 | const sessionApi = RealtimeApiFactory(config); 31 | const response = await sessionApi.getRealtimeSessionTimeline(sessionId); 32 | return response.data; 33 | } 34 | 35 | static async getSessionDetails(sessionId: string) { 36 | const config = new Configuration({ 37 | apiKey: process.env.GABBER_API_KEY, 38 | }); 39 | 40 | const sessionApi = RealtimeApiFactory(config); 41 | 42 | const [sessionData, messages, timeline] = await Promise.all([ 43 | sessionApi.getRealtimeSession(sessionId), 44 | sessionApi.getRealtimeSessionMessages(sessionId), 45 | sessionApi.getRealtimeSessionTimeline(sessionId), 46 | ]); 47 | 48 | const stats = this.calculateSessionStats(timeline.data.values); 49 | 50 | return { 51 | session: sessionData.data, 52 | messages: messages.data.values, 53 | timeline: timeline.data.values, 54 | stats, 55 | duration: this.calculateTotalDuration(timeline.data.values), 56 | }; 57 | } 58 | 59 | private static calculateSessionStats( 60 | timeline: Array = [], 61 | ) { 62 | const totalDuration = timeline.reduce( 63 | (acc, item) => acc + (item.seconds ?? 0), 64 | 0, 65 | ); 66 | 67 | const userTime = timeline 68 | .filter((item) => item.type === "user") 69 | .reduce((acc, item) => acc + (item.seconds ?? 0), 0); 70 | 71 | const silenceTime = timeline 72 | .filter((item) => item.type === "silence") 73 | .reduce((acc, item) => acc + (item.seconds ?? 0), 0); 74 | 75 | const agentTime = timeline 76 | .filter((item) => item.type === "agent") 77 | .reduce((acc, item) => acc + (item.seconds ?? 0), 0); 78 | 79 | return { 80 | totalDuration, 81 | userTime, 82 | silenceTime, 83 | agentTime, 84 | userPercentage: (userTime / totalDuration) * 100, 85 | silencePercentage: (silenceTime / totalDuration) * 100, 86 | agentPercentage: (agentTime / totalDuration) * 100, 87 | }; 88 | } 89 | 90 | private static calculateTotalDuration( 91 | timeline: Array = [], 92 | ) { 93 | return timeline.reduce((acc, item) => acc + (item.seconds ?? 0), 0); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/lib/server/controller/user.ts: -------------------------------------------------------------------------------- 1 | import { GoogleUser } from "../../model/google_user"; 2 | import jwt from "jsonwebtoken"; 3 | import { cookies } from "next/headers"; 4 | import { CreditsController } from "./credits"; 5 | import { Configuration, UsageApiFactory } from "@/generated"; 6 | 7 | export type UserInfo = { 8 | email: string; 9 | stripe_customer: string; 10 | }; 11 | 12 | export class UserController { 13 | static async getUserFromCookies(): Promise { 14 | const auth_token = cookies().get("auth_token"); 15 | if (!auth_token) return null; 16 | try { 17 | const user = await UserController.getUserFromToken(auth_token.value); 18 | return user; 19 | } catch (e) { 20 | console.error(e); 21 | return null; 22 | } 23 | } 24 | 25 | static async getUserFromToken(token: string) { 26 | const decoded = jwt.verify(token, process.env.GABBER_API_KEY || ""); 27 | const user: UserInfo = (decoded as any).data; 28 | return user; 29 | } 30 | 31 | static async createUsageToken(user: UserInfo | null) { 32 | const config = new Configuration({ 33 | apiKey: process.env.GABBER_API_KEY, 34 | }); 35 | const usageApi = UsageApiFactory(config); 36 | 37 | if (!user) { 38 | return ( 39 | await usageApi.createUsageToken({ 40 | human_id: "anonymous", 41 | limits: [{ type: "conversational_seconds", value: 0 }], //deprecated 42 | //ttl_seconds: 600, // this is the new way to set limits 43 | }) 44 | ).data.token; 45 | } 46 | 47 | return ( 48 | await usageApi.createUsageToken({ 49 | human_id: user.stripe_customer, 50 | limits: [{ type: "conversational_seconds", value: 1000 }], //deprecated 51 | //ttl_seconds: 3600, // this is the new way to set limits 52 | }) 53 | ).data.token; 54 | } 55 | 56 | static async updateLimits(human_id: string, limit: number) { 57 | const config = new Configuration({ 58 | apiKey: process.env.GABBER_API_KEY, 59 | }); 60 | const usageApi = UsageApiFactory(config); 61 | await usageApi.updateUsageToken({ 62 | human_id, 63 | limits: [{ type: "conversational_seconds", value: limit }], //deprecated 64 | //ttl_seconds: 3600, // this is the new way to set limits 65 | }); 66 | } 67 | 68 | static async loginGoogleUser({ 69 | access_token, 70 | }: { 71 | access_token: string; 72 | }): Promise<{ authToken: string }> { 73 | const userInfoReponse = await fetch( 74 | "https://www.googleapis.com/oauth2/v2/userinfo", 75 | { 76 | headers: { 77 | Authorization: `Bearer ${access_token}`, 78 | }, 79 | }, 80 | ); 81 | 82 | const userInfo: GoogleUser = await userInfoReponse.json(); 83 | userInfo.id = userInfo.id.toString(); 84 | let stripeCustomer = await CreditsController.getCustomerByEmail( 85 | userInfo.email, 86 | ); 87 | if (!stripeCustomer) { 88 | stripeCustomer = await CreditsController.createCustomer({ 89 | email: userInfo.email, 90 | }); 91 | } 92 | const authToken = UserController.generateJWT({ 93 | email: userInfo.email, 94 | stripe_customer: stripeCustomer?.id, 95 | }); 96 | return { authToken }; 97 | } 98 | 99 | static generateJWT(data: { email: string; stripe_customer: string }) { 100 | return jwt.sign( 101 | { 102 | exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365, 103 | data, 104 | }, 105 | process.env.GABBER_API_KEY || "", 106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/lib/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export const timeString = (seconds: number) => { 2 | if (seconds < 60) { 3 | return `${seconds}s`; 4 | } 5 | 6 | const minutes = Math.floor(seconds / 60); 7 | const remainingSeconds = seconds % 60; 8 | return `${minutes}m ${remainingSeconds}s`; 9 | }; 10 | 11 | export const colorFromString = (str: string) => { 12 | const hash = str.split("").reduce((acc, char) => { 13 | return char.charCodeAt(0) + acc; 14 | }, 0); 15 | 16 | const hue = hash % 360; 17 | return `hsl(${hue}, 50%, 50%)`; 18 | }; 19 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | colors: { 11 | "base-content-bold": "#c1c1c1", 12 | "accent-hover": "#00B4FF", 13 | "primary-light": "#FF7347", 14 | "error-base": "#820303", 15 | "error-base-content": "#fc4747", 16 | white: "#efefef", 17 | "base-hover": "#303030", 18 | }, 19 | fontFamily: { 20 | primary: ["var(--sf-pro-rounded)"], 21 | secondary: ["sans-serif"], 22 | }, 23 | }, 24 | daisyui: { 25 | themes: [ 26 | { 27 | rizz: { 28 | primary: "#FF5925", 29 | "base-100": "rgb(22, 22, 21)", 30 | "base-200": "#1b1b1b", 31 | "base-300": "#212121", 32 | "base-content": "#6f6e6a", 33 | accent: "#00A4FF", 34 | "accent-content": "#FFFFFF", 35 | }, 36 | }, 37 | ], 38 | }, 39 | plugins: [require("daisyui")], 40 | }; 41 | export default config; 42 | -------------------------------------------------------------------------------- /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 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------