diff --git a/.github/workflows/railway-preview.yml b/.github/workflows/railway-preview.yml new file mode 100644 index 000000000..c6dd50f9a --- /dev/null +++ b/.github/workflows/railway-preview.yml @@ -0,0 +1,149 @@ +name: Railway Preview Deploy + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + branches: [main] + +concurrency: + group: railway-preview-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + deploy-preview: + name: Deploy to Railway Preview + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository + + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Setup pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 + + - name: Setup Node.js + uses: actions/setup-node@v6.2.0 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build web dashboard + run: cd web && pnpm build + env: + NEXT_PUBLIC_BOT_API_URL: ${{ secrets.NEXT_PUBLIC_BOT_API_URL }} + NEXT_PUBLIC_DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }} + NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL_PREVIEW || 'https://preview.volvox.dev' }} + NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} + DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }} + DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }} + + - name: Install Railway CLI + run: npm install -g @railway/cli + + - name: Deploy to Railway Preview + id: railway-deploy + run: | + # Generate a unique preview environment name based on PR number + PREVIEW_ENV="pr-${{ github.event.number }}" + echo "Deploying to preview environment: $PREVIEW_ENV" + + # Deploy using Railway CLI + railway up --service=volvox-bot --environment=$PREVIEW_ENV --detach + + # Get the deployment URL + DEPLOY_URL=$(railway domain --service=volvox-bot --environment=$PREVIEW_ENV) + echo "deploy_url=$DEPLOY_URL" >> $GITHUB_OUTPUT + env: + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + + - name: Comment PR with preview URL + uses: actions/github-script@v7.0.1 + with: + script: | + const deployUrl = '${{ steps.railway-deploy.outputs.deploy_url }}'; + const prNumber = context.issue.number; + + // Find existing comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('๐Ÿš€ Railway Preview Deployment') + ); + + const body = `## ๐Ÿš€ Railway Preview Deployment + + Your PR has been deployed to a preview environment! + + **Preview URL:** ${deployUrl} + + **Environment:** \`pr-${prNumber}\` + + This deployment will be updated automatically when you push new commits. + + --- + *Last updated: ${new Date().toISOString()}*`; + + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body, + }); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: body, + }); + } + + cleanup-preview: + name: Cleanup Railway Preview + runs-on: ubuntu-latest + if: github.event.action == 'closed' + + steps: + - name: Install Railway CLI + run: npm install -g @railway/cli + + - name: Remove Preview Environment + run: | + PREVIEW_ENV="pr-${{ github.event.number }}" + echo "Removing preview environment: $PREVIEW_ENV" + railway environment delete $PREVIEW_ENV --yes || true + env: + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + + - name: Comment PR about cleanup + uses: actions/github-script@v7.0.1 + with: + script: | + const prNumber = context.issue.number; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `## ๐Ÿงน Preview Environment Cleaned Up + + The Railway preview environment for this PR has been removed. + + **Environment:** \`pr-${prNumber}\``, + }); diff --git a/.gitignore b/.gitignore index d9a3dd071..8ab5a75cc 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ web/.env.local web/.env.*.local web/tsconfig.tsbuildinfo -# Worktree marker +# OpenClaw +openclaw-studio/ diff --git a/docs/railway.toml b/docs/railway.toml index 8c5492404..db46e804d 100644 --- a/docs/railway.toml +++ b/docs/railway.toml @@ -1,6 +1,5 @@ [build] builder = "DOCKERFILE" -dockerfilePath = "Dockerfile" [deploy] restartPolicyType = "ON_FAILURE" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cee4d8562..b29c06a7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: diff: specifier: ^8.0.3 version: 8.0.3 + framer-motion: + specifier: ^12.34.5 + version: 12.34.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) lucide-react: specifier: ^0.525.0 version: 0.525.0(react@19.2.4) @@ -3273,6 +3276,20 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@12.34.5: + resolution: {integrity: sha512-Z2dQ+o7BsfpJI3+u0SQUNCrN+ajCKJen1blC4rCHx1Ta2EOHs+xKJegLT2aaD9iSMbU3OoX+WabQXkloUbZmJQ==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3950,6 +3967,12 @@ packages: moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + motion-dom@12.34.5: + resolution: {integrity: sha512-k33CsnxO2K3gBRMUZT+vPmc4Utlb5menKdG0RyVNLtlqRaaJPRWlE9fXl8NTtfZ5z3G8TDvqSu0MENLqSTaHZA==} + + motion-utils@12.29.2: + resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -8288,6 +8311,15 @@ snapshots: forwarded@0.2.0: {} + framer-motion@12.34.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.34.5 + motion-utils: 12.29.2 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + fresh@2.0.0: {} fs-constants@1.0.0: {} @@ -9022,6 +9054,12 @@ snapshots: moment@2.30.1: {} + motion-dom@12.34.5: + dependencies: + motion-utils: 12.29.2 + + motion-utils@12.29.2: {} + ms@2.1.3: {} mustache@4.2.0: {} diff --git a/web/next-env.d.ts b/web/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/web/next-env.d.ts +++ b/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web/package.json b/web/package.json index 11776e144..97a89f00d 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "diff": "^8.0.3", + "framer-motion": "^12.34.5", "lucide-react": "^0.525.0", "next": "^16.1.6", "next-auth": "^4.24.13", diff --git a/web/src/app/globals.css b/web/src/app/globals.css index b845b3169..561fe2608 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1,6 +1,24 @@ @import "tailwindcss"; +/* JetBrains Mono for headlines, Inter for body (loaded in layout.tsx) */ @theme { + --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + + /* Terminal Chic Colors */ + --color-terminal-green: #22c55e; + --color-terminal-cursor: #22c55e; + --color-terminal-bg: #0a0a0a; + --color-terminal-border: #1f1f1f; + --color-terminal-text: #e4e4e7; + --color-terminal-muted: #71717a; + + /* Light mode Terminal Chic */ + --color-terminal-light-bg: #fafafa; + --color-terminal-light-border: #e4e4e7; + --color-terminal-light-text: #18181b; + --color-terminal-light-muted: #71717a; + + /* Existing shadcn colors */ --color-border: hsl(var(--border)); --color-input: hsl(var(--input)); --color-ring: hsl(var(--ring)); @@ -25,8 +43,13 @@ --radius-lg: var(--radius); --radius-md: calc(var(--radius) - 2px); --radius-sm: calc(var(--radius) - 4px); + + /* Animations */ --animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out; + --animate-blink: blink 1s step-end infinite; + --animate-typewriter: typewriter 0.1s steps(1) forwards; + @keyframes accordion-down { from { height: 0; @@ -43,52 +66,88 @@ height: 0; } } + @keyframes blink { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0; + } + } } @layer base { :root { - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; + /* Terminal Chic Light Mode */ + --background: 0 0% 98%; + --foreground: 240 10% 10%; --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; + --card-foreground: 240 10% 10%; --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 240 5.9% 10%; - --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - --destructive: 0 84.2% 60.2%; + --popover-foreground: 240 10% 10%; + --primary: 142 76% 36%; + --primary-foreground: 0 0% 100%; + --secondary: 240 5% 96%; + --secondary-foreground: 240 10% 10%; + --muted: 240 5% 96%; + --muted-foreground: 240 4% 46%; + --accent: 240 5% 96%; + --accent-foreground: 240 10% 10%; + --destructive: 0 84% 60%; --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 240 5.9% 10%; + --border: 240 6% 90%; + --input: 240 6% 90%; + --ring: 142 76% 36%; --radius: 0.5rem; + + /* Semantic tokens for landing components */ + --bg-primary: hsl(var(--background)); + --bg-secondary: hsl(var(--muted)); + --bg-tertiary: hsl(var(--secondary)); + --text-primary: hsl(var(--foreground)); + --text-secondary: hsl(var(--muted-foreground)); + --text-muted: hsl(var(--muted-foreground)); + --accent-primary: hsl(var(--primary)); + --accent-success: #22c55e; + --accent-warning: #f59e0b; + --border-default: hsl(var(--border)); + --border-muted: hsl(var(--border)); } .dark { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 240 5.9% 10%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; + /* Terminal Chic Dark Mode */ + --background: 0 0% 4%; + --foreground: 0 0% 89%; + --card: 0 0% 6%; + --card-foreground: 0 0% 89%; + --popover: 0 0% 6%; + --popover-foreground: 0 0% 89%; + --primary: 142 76% 46%; + --primary-foreground: 0 0% 100%; + --secondary: 0 0% 12%; + --secondary-foreground: 0 0% 89%; + --muted: 0 0% 12%; + --muted-foreground: 0 0% 46%; + --accent: 0 0% 12%; + --accent-foreground: 0 0% 89%; + --destructive: 0 62% 30%; --destructive-foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 240 4.9% 83.9%; + --border: 0 0% 12%; + --input: 0 0% 12%; + --ring: 142 76% 46%; + + /* Semantic tokens for landing components (dark mode) */ + --bg-primary: hsl(var(--background)); + --bg-secondary: hsl(var(--muted)); + --bg-tertiary: hsl(var(--secondary)); + --text-primary: hsl(var(--foreground)); + --text-secondary: hsl(var(--muted-foreground)); + --text-muted: hsl(var(--muted-foreground)); + --accent-primary: hsl(var(--primary)); + --accent-success: #22c55e; + --accent-warning: #f59e0b; + --border-default: hsl(var(--border)); + --border-muted: hsl(var(--border)); } } @@ -97,6 +156,21 @@ @apply border-border; } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground antialiased; + font-feature-settings: "rlig" 1, "calt" 1; + } +} + +/* Terminal cursor blink animation */ +.terminal-cursor { + animation: blink 1s step-end infinite; +} + +@keyframes blink { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0; } } diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 389047e00..307fbca42 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,20 +1,32 @@ import type { Metadata } from 'next'; -import { Inter } from 'next/font/google'; +import { Inter, JetBrains_Mono } from 'next/font/google'; import { Providers } from '@/components/providers'; import './globals.css'; -const inter = Inter({ subsets: ['latin'] }); +const inter = Inter({ + subsets: ['latin'], + variable: '--font-inter', +}); + +const jetbrainsMono = JetBrains_Mono({ + subsets: ['latin'], + variable: '--font-mono', +}); export const metadata: Metadata = { - title: 'Bill Bot Dashboard', + title: 'Volvox Bot - AI-Powered Discord Bot', description: - 'Manage your Bill Bot Discord server โ€” moderation, AI chat, configuration, and more.', + 'The AI-powered Discord bot for modern communities. Moderation, AI chat, dynamic welcomes, spam detection, and a fully configurable web dashboard.', }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - + + {children} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 43838426b..946d35578 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,163 +1,78 @@ -import { Bot, MessageSquare, Shield, Sparkles, Users, Zap } from 'lucide-react'; +'use client'; + import Link from 'next/link'; +import { FeatureGrid, Footer, Hero, InviteButton, Pricing, Stats } from '@/components/landing'; +import { ThemeToggle } from '@/components/theme-toggle'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { getBotInviteUrl } from '@/lib/discord'; - -const features = [ - { - icon: MessageSquare, - title: 'AI Chat', - description: - 'Powered by Claude via the Anthropic Agent SDK โ€” natural conversations, context-aware responses, and intelligent triage-based model selection.', - }, - { - icon: Shield, - title: 'Moderation', - description: - 'Comprehensive moderation toolkit โ€” warns, kicks, bans, timeouts, tempbans with full case tracking and mod logs.', - }, - { - icon: Users, - title: 'Welcome Messages', - description: 'Dynamic, AI-generated welcome messages that make every new member feel special.', - }, - { - icon: Zap, - title: 'Spam Detection', - description: 'Automatic spam and scam detection to keep your community safe.', - }, - { - icon: Sparkles, - title: 'Runtime Config', - description: - 'Configure everything on the fly โ€” no restarts needed. Database-backed config with slash command management.', - }, - { - icon: Bot, - title: 'Web Dashboard', - description: - 'This dashboard โ€” manage your bot settings, view mod logs, and configure your server from any device.', - }, -]; - -/** Render an "Add to Server" button โ€” disabled/hidden when CLIENT_ID is unset. */ -function InviteButton({ size = 'sm', className }: { size?: 'sm' | 'lg'; className?: string }) { - const url = getBotInviteUrl(); - if (!url) return null; - return ( - - ); -} export default function LandingPage() { return (
{/* Navbar */} -
-
+
+
-
- B +
+ V
- Bill Bot + + volvox-bot +
-
-
- - {/* Hero */} -
-
- B -
-

Bill Bot

-

- The AI-powered Discord bot for the Volvox community. Moderation, AI chat, dynamic - welcomes, spam detection, and a fully configurable web dashboard. -

-
- - -
-
- - {/* Features */} -
-
-

Everything you need

-

- A full-featured Discord bot with a modern web dashboard. -

-
-
- {features.map((feature) => ( - - -
- -
- {feature.title} -
- - - {feature.description} - - -
- ))} -
-
- - {/* CTA */} -
-
-

Ready to get started?

-

- Add Bill Bot to your Discord server and manage everything from this dashboard. -

- -
-
- - {/* Footer */} - +
+ + {/* Hero Section */} + + + {/* Features Section */} +
+ +
+ + {/* Pricing Section */} +
+ +
+ + {/* Stats / Testimonials Section */} + + + {/* Footer CTA */} +
); } diff --git a/web/src/components/landing/FeatureGrid.tsx b/web/src/components/landing/FeatureGrid.tsx new file mode 100644 index 000000000..15b1131c1 --- /dev/null +++ b/web/src/components/landing/FeatureGrid.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { motion, useInView, useReducedMotion } from 'framer-motion'; +import { BarChart3, MessageSquare, Shield, Star } from 'lucide-react'; +import { useRef } from 'react'; + +const features = [ + { + icon: MessageSquare, + title: 'AI Chat', + description: + 'Mention @volvox to chat with Claude. Context-aware, helpful, and actually understands your community.', + command: '$ ai --model claude', + }, + { + icon: Shield, + title: 'Moderation', + description: + 'Auto-mod with Claude intelligence. No more spam, raids, or toxicity slipping through.', + command: '$ mod --auto-enable', + }, + { + icon: Star, + title: 'Starboard', + description: 'Highlight the best moments automatically. Your community curates itself.', + command: '$ starboard --threshold 5', + }, + { + icon: BarChart3, + title: 'Analytics', + description: 'Real-time dashboard with insights that matter. Know your community.', + command: '$ analytics --export', + }, +]; + +function TerminalCard({ feature, index }: { feature: (typeof features)[0]; index: number }) { + const ref = useRef(null); + const isInView = useInView(ref, { once: true, margin: '-50px' }); + const shouldReduceMotion = useReducedMotion(); + + return ( + + {/* Terminal Chrome */} +
+
+
+
+ {feature.command} +
+ + {/* Content */} +
+
+
+ +
+

+ {feature.title} +

+
+

{feature.description}

+
+ + {/* Hover Glow */} +
+
+
+ + ); +} + +export function FeatureGrid() { + const containerRef = useRef(null); + const isInView = useInView(containerRef, { once: true, margin: '-100px' }); + const shouldReduceMotion = useReducedMotion(); + + return ( +
+
+ +

+ > Features +

+

+ Everything you need, nothing you don't. Built by developers who actually use Discord. +

+
+ +
+ {features.map((feature, index) => ( + + ))} +
+
+
+ ); +} diff --git a/web/src/components/landing/Footer.tsx b/web/src/components/landing/Footer.tsx new file mode 100644 index 000000000..e76ba8a92 --- /dev/null +++ b/web/src/components/landing/Footer.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { BookOpen, Github, Heart, MessageCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { getBotInviteUrl } from '@/lib/discord'; + +export function Footer() { + const botInviteUrl = getBotInviteUrl(); + + return ( +
+
+ {/* CTA Section */} + +

+ Ready to upgrade your server? +

+

+ Join thousands of developers who've switched from MEE6, Dyno, and Carl-bot. Your + community deserves better. +

+ {botInviteUrl ? ( + + ) : ( + + )} +
+ + {/* Tagline */} + + Open source. Self-hostable. Free forever. + + + {/* Links */} + + + + Documentation + + + + GitHub + + + + Support Server + + + + {/* Copyright */} + +

+ Made with by developers, for + developers +

+

+ ยฉ {new Date().getFullYear()} Volvox. Not affiliated with Discord. +

+
+
+
+ ); +} diff --git a/web/src/components/landing/Hero.tsx b/web/src/components/landing/Hero.tsx new file mode 100644 index 000000000..015a4c75b --- /dev/null +++ b/web/src/components/landing/Hero.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { motion, useInView } from 'framer-motion'; +import { ArrowRight, Bot, MessageSquare, Sparkles, Terminal } from 'lucide-react'; +import Link from 'next/link'; +import { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { InviteButton } from './InviteButton'; + +/** Typewriter effect hook */ +function useTypewriter(text: string, speed = 100, delay = 500) { + const [displayText, setDisplayText] = useState(''); + const [isComplete, setIsComplete] = useState(false); + const intervalRef = useRef | null>(null); + + useEffect(() => { + setDisplayText(''); + setIsComplete(false); + + const timeout = setTimeout(() => { + let index = 0; + intervalRef.current = setInterval(() => { + if (index < text.length) { + setDisplayText(text.slice(0, index + 1)); + index++; + } else { + setIsComplete(true); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + } + }, speed); + }, delay); + + return () => { + clearTimeout(timeout); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [text, speed, delay]); + + return { displayText, isComplete }; +} + +/** Blinking cursor component */ +function BlinkingCursor() { + return ( +