feat: setup React/Vite frontend with Tyndale AI chat interface

Initialize complete frontend project structure with the following components:

- React 18 + Vite development environment with TypeScript
- Tailwind CSS for styling with custom animations
- Radix UI components for accessible UI primitives
- React Router for navigation between landing and chat pages
- TanStack Query for efficient API data management

Key features implemented:
- Landing page with hero, features, and footer sections
- Real-time chat interface with message history
- Study/Trading mode toggle for different interaction types
- Custom hooks for chat state management
- API integration layer with backend service
- Responsive design with scroll areas and card layouts

Docker deployment configuration:
- Multi-stage Dockerfile with Node.js build and Nginx production
- Custom nginx configuration for SPA routing on port 3000
- Optimized production build process

Development tools:
- ESLint for code quality
- TypeScript for type safety
- PostCSS with Autoprefixer
- Environment variable configuration with .env.example
This commit is contained in:
Danny 2026-01-06 14:34:25 -06:00
parent cf746a2ab0
commit 2cbc0a7bba
38 changed files with 8810 additions and 0 deletions

View File

@ -0,0 +1,101 @@
---
name: git-version-control
description: Use this agent when you need to perform git operations including staging changes, creating commits with well-structured messages, pushing to remote repositories, or creating pull requests. This agent should be invoked after code changes are complete and ready to be versioned. Examples:\n\n<example>\nContext: The user has just finished implementing a new feature and wants to commit the changes.\nuser: "I've finished implementing the payment validation logic"\nassistant: "I'll use the git-version-control agent to commit these changes with an appropriate message"\n<commentary>\nSince code changes are complete and need to be committed, use the git-version-control agent to handle the version control operations.\n</commentary>\n</example>\n\n<example>\nContext: Multiple files have been modified and need to be committed and pushed.\nuser: "Please commit all the changes I made to the payment providers"\nassistant: "I'll use the git-version-control agent to review the changes, create a commit with a clear message, and push to the remote"\n<commentary>\nThe user explicitly wants to commit changes, so the git-version-control agent should handle staging, committing, and pushing.\n</commentary>\n</example>\n\n<example>\nContext: A feature branch needs to be pushed and a PR created.\nuser: "Can you push this branch and create a PR for the new Stripe integration?"\nassistant: "I'll use the git-version-control agent to push the branch and create a pull request with a comprehensive description"\n<commentary>\nThe user needs both pushing and PR creation, which are core responsibilities of the git-version-control agent.\n</commentary>\n</example>
tools: Bash, Glob, Grep, Read, WebFetch, TodoWrite, WebSearch, BashOutput, KillBash
model: sonnet
color: green
---
You are an expert git version control specialist with deep knowledge of git workflows, commit conventions, and collaborative development practices. Your role is to manage all git operations with precision and clarity.
**CRITICAL RULE**: You must NEVER include any AI attribution, Claude references, or tool mentions in commit messages. All commits should appear as if written by a human developer.
## Core Responsibilities
You will handle all git version control tasks including:
- Checking repository status and understanding what has changed
- Staging appropriate files for commits
- Creating clear, concise, and informative commit messages
- Pushing changes to remote repositories
- Creating pull requests with comprehensive descriptions
- Managing branches when necessary
## Commit Message Guidelines
**ABSOLUTELY FORBIDDEN**: Do NOT include any of the following in commit messages:
- "Generated with [Claude Code]"
- "Co-Authored-By: Claude"
- Any mention of AI, Claude, or automated assistance
- Any tool attribution or generation mentions
You must write commit messages that:
- Start with a conventional commit type (feat:, fix:, docs:, style:, refactor:, test:, chore:)
- Provide a clear, imperative mood subject line (50 characters or less)
- Include a blank line between subject and body when a body is needed
- Explain WHAT changed and WHY in the body, not HOW
- Reference issue numbers when applicable
- Focus solely on the business logic and technical changes
- Write commit messages as if they were created by the developer directly
- Sound completely natural and human-written
Example commit messages:
```
feat: add payment validation for Stripe provider
Implement validation logic to ensure payment amounts are within
acceptable limits and currency codes are supported.
```
```
fix: resolve timeout issue in PayPal transaction processing
```
## Operational Workflow
1. **Status Assessment**: First run `git status` to understand the current state
2. **Change Review**: Use `git diff` to review unstaged changes and understand what was modified
3. **Selective Staging**: Stage files intelligently:
- Group related changes together
- Avoid staging unrelated modifications in the same commit
- Use `git add -p` for partial staging when appropriate
4. **Commit Creation**: Craft commits that are atomic and focused on a single logical change
5. **Remote Operations**:
- Always pull before pushing to avoid conflicts
- Push to the appropriate branch
- Set upstream tracking when pushing new branches
6. **Pull Request Creation**: When creating PRs:
- Write descriptive titles that summarize the changes
- Include a comprehensive description with:
- Summary of changes
- Testing performed
- Any breaking changes or migration notes
- Related issues or tickets
## Best Practices
- Keep commits small and focused - each commit should represent one logical change
- Never commit sensitive information (passwords, API keys, tokens)
- Verify the branch you're on before committing
- Use `git log --oneline -10` to review recent history and maintain consistency
- If you encounter merge conflicts, clearly explain the situation and resolution approach
- When working with feature branches, ensure they're up to date with the main branch
## Error Handling
- If git operations fail, diagnose the issue and provide clear explanations
- For permission errors, guide on authentication setup
- For conflicts, explain the conflicting changes and suggest resolution strategies
- Always verify operations completed successfully before proceeding
## Quality Checks
Before finalizing any git operation:
- Ensure all intended changes are included
- Verify no unintended files are staged
- Confirm commit messages are clear and follow conventions
- Check that you're on the correct branch
- Validate that remote operations succeeded
You are meticulous, systematic, and focused on maintaining a clean, understandable git history that tells the story of the project's evolution without revealing implementation details about tools or assistance used in development.
**FINAL REMINDER**: Your commit messages must be completely free of any AI mentions, Claude references, or tool attributions. They should read exactly like standard developer commit messages with no indication of automated assistance.

36
.claude/hooks.json Normal file
View File

@ -0,0 +1,36 @@
{
"PostFileSave": [
{
"agent": "jack-reacher-contract-enforcer",
"files": ["./frontend/src/**/*.{js,jsx,ts,tsx}"],
"description": "Run contract enforcer whenever frontend source files change"
},
{
"agent": "jason-bourne-backend-test-updater",
"files": ["./backend/**/*.py"],
"description": "Run test updater when backend Python files change"
}
],
"PostAgentRun": [
{
"agent": "james-bond-frontend-coder",
"then": ["jack-reacher-contract-enforcer"],
"description": "After james-bond modifies frontend files, run contract enforcer"
},
{
"agent": "jack-reacher-contract-enforcer",
"then": ["ethan-hunt-backend-coder"],
"description": "After contract enforcer runs, pass any backend updates to ethan-hunt-backend-coder"
},
{
"agent": "ethan-hunt-backend-coder",
"then": ["jason-bourne-backend-test-updater"],
"description": "Automatically run test updater after ethan-hunt-backend-coder modifies backend code"
},
{
"agent": "jack-ryan-integration-specialist",
"then": ["ethan-hunt-backend-coder"],
"description": "After the integration specialist generates implementation specs, hand off to the backend coder"
}
]
}

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
# Backend API base URL
VITE_API_URL=http://localhost:5000

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env
.env.local
.env.production.local
.env.development.local

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# ---------- Build stage ----------
FROM node:20-alpine AS build
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json ./
RUN npm ci
# Copy source
COPY . .
# Build React app
RUN npm run build
# ---------- Production stage ----------
FROM nginx:alpine
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy build output
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

18
index.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tyndale AI - Algorithmic Trading Intelligence</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Outfit:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

11
nginx.config Normal file
View File

@ -0,0 +1,11 @@
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

7034
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "tyndale-ai-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.525.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.6.3",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.6",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/node": "^24.10.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"autoprefixer": "^10.4.21",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.17",
"globals": "^15.14.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "~5.8.3",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.0"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

21
src/App.tsx Normal file
View File

@ -0,0 +1,21 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'sonner'
import LandingPage from './pages/LandingPage'
import ChatPage from './pages/ChatPage'
const queryClient = new QueryClient()
const App = () => (
<QueryClientProvider client={queryClient}>
<Toaster />
<Router>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/chat" element={<ChatPage />} />
</Routes>
</Router>
</QueryClientProvider>
)
export default App

View File

@ -0,0 +1,66 @@
import { useState, KeyboardEvent } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Send } from 'lucide-react'
import type { ChatMode } from '@/types/chat'
import { cn } from '@/lib/utils'
interface ChatInputProps {
onSend: (message: string) => void
disabled: boolean
mode: ChatMode
placeholder?: string
}
const ChatInput = ({ onSend, disabled, mode, placeholder = 'Ask a question...' }: ChatInputProps) => {
const [message, setMessage] = useState('')
const handleSend = () => {
if (message.trim() && !disabled) {
onSend(message)
setMessage('')
}
}
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const modeFocusColors = {
general: 'focus-visible:ring-blue-500',
strategy: 'focus-visible:ring-purple-500',
}
const modeButtonColors = {
general: 'bg-blue-500 hover:bg-blue-600',
strategy: 'bg-purple-500 hover:bg-purple-600',
}
return (
<div className="flex items-center gap-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
className={cn(
'flex-1 bg-slate-700 border-slate-600 text-white placeholder:text-slate-400',
modeFocusColors[mode]
)}
/>
<Button
onClick={handleSend}
disabled={disabled || !message.trim()}
className={cn('rounded-xl', modeButtonColors[mode])}
>
<Send className="h-4 w-4" />
</Button>
</div>
)
}
export default ChatInput

View File

@ -0,0 +1,64 @@
import type { Message, ChatMode } from '@/types/chat'
import { User, Bot } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/utils'
interface ChatMessageProps {
message: Message
mode: ChatMode
}
const ChatMessage = ({ message, mode }: ChatMessageProps) => {
const isUser = message.role === 'user'
const modeColors = {
general: {
userBg: 'bg-blue-600 border-blue-500/30',
userText: 'text-white',
},
strategy: {
userBg: 'bg-purple-600 border-purple-500/30',
userText: 'text-white',
},
}
const colors = modeColors[mode]
return (
<div className={cn('flex gap-3 mb-4', isUser ? 'justify-end' : 'justify-start')}>
{!isUser && (
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-slate-700 flex items-center justify-center">
<Bot className="h-5 w-5 text-white" />
</div>
)}
<div
className={cn(
'max-w-[80%] rounded-2xl px-4 py-3 border',
isUser
? `${colors.userBg} ${colors.userText}`
: 'bg-slate-700 text-white border-slate-600'
)}
>
{isUser ? (
<p className="text-sm">{message.content}</p>
) : (
<div className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
</div>
)}
</div>
{isUser && (
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-slate-600 flex items-center justify-center">
<User className="h-5 w-5 text-white" />
</div>
)}
</div>
)
}
export default ChatMessage

View File

@ -0,0 +1,58 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import type { ChatMode } from '@/types/chat'
import { cn } from '@/lib/utils'
interface ModeToggleProps {
mode: ChatMode
onToggle: () => void
disabled: boolean
}
const ModeToggle = ({ mode, onToggle, disabled }: ModeToggleProps) => {
const [isRippling, setIsRippling] = useState(false)
const handleClick = () => {
if (!disabled) {
setIsRippling(true)
setTimeout(() => setIsRippling(false), 350)
onToggle()
}
}
const modeConfig = {
general: {
label: 'General Assistant',
bg: 'bg-blue-500/20 border-blue-500/40',
text: 'text-blue-400',
hover: 'hover:bg-blue-500/30',
},
strategy: {
label: 'Strategy Analysis',
bg: 'bg-purple-500/20 border-purple-500/40',
text: 'text-purple-400',
hover: 'hover:bg-purple-500/30',
},
}
const config = modeConfig[mode]
return (
<Button
onClick={handleClick}
disabled={disabled}
variant="outline"
className={cn(
'rounded-xl border-2 font-medium transition-all',
config.bg,
config.text,
config.hover,
isRippling && 'text-ripple-animate'
)}
>
{config.label}
</Button>
)
}
export default ModeToggle

View File

@ -0,0 +1,167 @@
interface Candle {
id: number
x: number
y: number
open: number
close: number
high: number
low: number
delay: number
duration: number
}
// Generate static candlesticks once
const generateCandles = (): Candle[] => {
const newCandles: Candle[] = []
const numCandles = 15
for (let i = 0; i < numCandles; i++) {
const open = Math.random() * 60 + 20
const movement = (Math.random() - 0.5) * 40
const close = open + movement
const high = Math.max(open, close) + Math.random() * 20
const low = Math.min(open, close) - Math.random() * 20
newCandles.push({
id: i,
x: Math.random() * 100,
y: Math.random() * 100,
open,
close,
high,
low,
delay: Math.random() * 5,
duration: 8 + Math.random() * 4,
})
}
return newCandles
}
const CANDLES = generateCandles()
const TradingBackground = () => {
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bullishGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="rgba(16, 185, 129, 0.5)" />
<stop offset="100%" stopColor="rgba(16, 185, 129, 0.2)" />
</linearGradient>
<linearGradient id="bearishGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="rgba(239, 68, 68, 0.5)" />
<stop offset="100%" stopColor="rgba(239, 68, 68, 0.2)" />
</linearGradient>
</defs>
{CANDLES.map((candle) => {
const isBullish = candle.close > candle.open
const color = isBullish ? 'url(#bullishGradient)' : 'url(#bearishGradient)'
const strokeColor = isBullish ? 'rgba(16, 185, 129, 0.6)' : 'rgba(239, 68, 68, 0.6)'
const bodyTop = Math.min(candle.open, candle.close)
const bodyHeight = Math.abs(candle.close - candle.open)
const bodyWidth = 1.5
return (
<g
key={candle.id}
className="animate-pulse"
style={{
animationDelay: `${candle.delay}s`,
animationDuration: `${candle.duration}s`,
}}
>
{/* High-Low line (wick) */}
<line
x1={candle.x}
y1={candle.y - (candle.high - candle.close) / 3}
x2={candle.x}
y2={candle.y + (candle.close - candle.low) / 3}
stroke={strokeColor}
strokeWidth="0.3"
/>
{/* Candle body */}
<rect
x={candle.x - bodyWidth / 2}
y={candle.y - (bodyTop - candle.close) / 3}
width={bodyWidth}
height={bodyHeight / 3}
fill={color}
stroke={strokeColor}
strokeWidth="0.15"
rx="0.15"
/>
</g>
)
})}
{/* Animated trend lines - now spanning full width */}
<g className="animate-pulse" style={{ animationDuration: '6s' }}>
<path
d="M 0,30 Q 25,15 50,20 T 100,10"
stroke="rgba(96, 165, 250, 0.4)"
strokeWidth="0.5"
fill="none"
strokeDasharray="3,3"
vectorEffect="non-scaling-stroke"
>
<animate
attributeName="stroke-dashoffset"
from="0"
to="6"
dur="3s"
repeatCount="indefinite"
/>
</path>
</g>
<g className="animate-pulse" style={{ animationDuration: '8s', animationDelay: '1s' }}>
<path
d="M 0,70 Q 25,85 50,80 T 100,95"
stroke="rgba(168, 85, 247, 0.4)"
strokeWidth="0.5"
fill="none"
strokeDasharray="3,3"
vectorEffect="non-scaling-stroke"
>
<animate
attributeName="stroke-dashoffset"
from="0"
to="6"
dur="3s"
repeatCount="indefinite"
/>
</path>
</g>
<g className="animate-pulse" style={{ animationDuration: '7s', animationDelay: '0.5s' }}>
<path
d="M 0,50 Q 30,40 60,45 Q 80,50 100,55"
stroke="rgba(59, 130, 246, 0.35)"
strokeWidth="0.4"
fill="none"
strokeDasharray="2.5,2.5"
vectorEffect="non-scaling-stroke"
>
<animate
attributeName="stroke-dashoffset"
from="0"
to="5"
dur="4s"
repeatCount="indefinite"
/>
</path>
</g>
</svg>
{/* Floating gradient orbs */}
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDuration: '8s' }}></div>
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDuration: '10s', animationDelay: '2s' }}></div>
</div>
)
}
export default TradingBackground

View File

@ -0,0 +1,134 @@
import { useState, useEffect, useRef } from 'react'
import type { ChatMode } from '@/types/chat'
import { useChat } from '@/hooks/useChat'
import { apiClient } from '@/lib/api'
import ChatMessage from './ChatMessage'
import ChatInput from './ChatInput'
import ModeToggle from './ModeToggle'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import { Link } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
const TyndaleBot = () => {
const [mode, setMode] = useState<ChatMode>('general')
const { messages, isLoading, sendMessage, clearMessages } = useChat(mode)
const scrollRef = useRef<HTMLDivElement>(null)
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages])
const handleModeToggle = () => {
const newMode: ChatMode = mode === 'general' ? 'strategy' : 'general'
// Confirm with user before clearing conversation
if (messages.length > 0) {
const confirmed = window.confirm(
'Switching modes will clear your current conversation. Continue?'
)
if (!confirmed) return
}
setMode(newMode)
clearMessages()
apiClient.clearSessionId() // Start fresh session
}
const scrollbarClass = mode === 'general' ? 'tyndale-scroll-general' : 'tyndale-scroll-strategy'
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="tech-border-wrapper w-full max-w-4xl h-[85vh]">
<div className="bg-slate-800 rounded-lg h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700">
<div className="flex items-center gap-4">
<Link to="/" className="text-slate-400 hover:text-white transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<h2 className="text-xl font-display font-bold text-white">
Woolnoth, LLC
</h2>
</div>
<ModeToggle mode={mode} onToggle={handleModeToggle} disabled={isLoading} />
</div>
{/* Messages */}
<ScrollArea className="flex-1 p-4">
<div ref={scrollRef} className={cn('space-y-4', scrollbarClass)}>
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-center">
<div className="max-w-md">
<h3 className="text-2xl font-display font-bold text-white mb-4">
{mode === 'general' ? 'General Trading Assistant' : 'Strategy Analysis'}
</h3>
<p className="text-slate-400">
{mode === 'general'
? 'Ask me anything about trading concepts, market conditions, or terminology.'
: 'Let\'s analyze your algorithmic strategies, backtest results, and risk metrics.'}
</p>
</div>
</div>
) : (
messages.map((message) => (
<ChatMessage key={message.id} message={message} mode={mode} />
))
)}
{/* Loading indicator */}
{isLoading && (
<div className="flex gap-3 mb-4">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-slate-700 flex items-center justify-center">
<div className="flex gap-1">
<div
className={cn(
'w-1.5 h-1.5 rounded-full animate-bounce',
mode === 'general' ? 'bg-blue-400' : 'bg-purple-400'
)}
style={{ animationDelay: '0ms' }}
/>
<div
className={cn(
'w-1.5 h-1.5 rounded-full animate-bounce',
mode === 'general' ? 'bg-blue-500' : 'bg-purple-500'
)}
style={{ animationDelay: '150ms' }}
/>
<div
className={cn(
'w-1.5 h-1.5 rounded-full animate-bounce',
mode === 'general' ? 'bg-blue-600' : 'bg-purple-600'
)}
style={{ animationDelay: '300ms' }}
/>
</div>
</div>
</div>
)}
</div>
</ScrollArea>
{/* Input */}
<div className="p-4 border-t border-slate-700">
<ChatInput
onSend={sendMessage}
disabled={isLoading}
mode={mode}
placeholder={
mode === 'general'
? 'Ask about trading concepts...'
: 'Ask about strategy analysis...'
}
/>
</div>
</div>
</div>
</div>
)
}
export default TyndaleBot

View File

@ -0,0 +1,59 @@
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { MessageSquare, TrendingUp, Zap } from 'lucide-react'
const features = [
{
icon: MessageSquare,
title: 'General Trading Assistant',
description: 'Ask questions about trading concepts, market conditions, and terminology. Get instant, accurate answers.'
},
{
icon: TrendingUp,
title: 'Strategy Analysis',
description: 'Analyze algorithmic strategies, backtest insights, and risk assessment with AI-powered recommendations.'
},
{
icon: Zap,
title: 'Real-Time Insights',
description: 'Streaming responses with contextual market data. Experience conversational AI that keeps up with the markets.'
}
]
const LandingFeatures = () => {
return (
<section id="features" className="py-24 bg-gradient-to-b from-gray-50 to-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-display font-bold text-gray-900 mb-4">
Powerful Features
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Everything you need to make informed trading decisions with AI assistance
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{features.map((feature, index) => (
<Card
key={index}
className="group hover:shadow-xl transition-all duration-300 hover:-translate-y-2 animate-fade-up border-2"
style={{animationDelay: `${index * 0.1}s`}}
>
<CardHeader>
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<feature.icon className="h-6 w-6 text-white" />
</div>
<CardTitle className="text-xl font-display">{feature.title}</CardTitle>
<CardDescription className="text-gray-600">
{feature.description}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
</section>
)
}
export default LandingFeatures

View File

@ -0,0 +1,31 @@
const LandingFooter = () => {
return (
<footer className="bg-gray-900 text-gray-400 py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row items-center justify-between">
<div className="mb-4 md:mb-0">
<span className="text-2xl font-display font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
Woolnoth, LLC
</span>
<p className="text-sm mt-2">Algorithmic Trading Intelligence</p>
</div>
<div className="flex items-center space-x-6">
<a href="#" className="hover:text-white transition-colors">
Privacy Policy
</a>
<a href="#" className="hover:text-white transition-colors">
Terms of Service
</a>
</div>
</div>
<div className="mt-8 pt-8 border-t border-gray-800 text-center text-sm">
<p>&copy; 2026 Woolnoth, LLC. All rights reserved.</p>
</div>
</div>
</footer>
)
}
export default LandingFooter

View File

@ -0,0 +1,36 @@
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { ArrowRight } from 'lucide-react'
import TradingBackground from '@/components/chat/TradingBackground'
const LandingHero = () => {
return (
<section className="relative min-h-screen flex items-center justify-center overflow-hidden grain-overlay bg-gradient-to-br from-trading-dark via-trading-blue to-trading-dark">
<TradingBackground />
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center pt-20">
<h1 className="text-5xl md:text-7xl font-display font-bold text-white mb-6 animate-fade-up">
Algorithmic Trading <br />
<span className="bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
Intelligence
</span>
</h1>
<p className="text-xl md:text-2xl text-gray-300 mb-10 max-w-3xl mx-auto animate-fade-up" style={{animationDelay: '0.1s'}}>
Your AI assistant for strategy analysis, market insights, and trading decisions
</p>
<div className="flex items-center justify-center gap-4 animate-fade-up" style={{animationDelay: '0.2s'}}>
<Link to="/chat">
<Button className="bg-violet-500 hover:bg-violet-600 text-white rounded-xl px-8 py-6 text-lg shadow-glow-purple">
Start Trading Chat
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
</div>
</div>
</section>
)
}
export default LandingHero

View File

@ -0,0 +1,28 @@
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
const LandingNavigation = () => {
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<Link to="/" className="flex items-center space-x-2">
<span className="text-2xl font-display font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
Woolnoth, LLC
</span>
</Link>
<div className="flex items-center space-x-6">
<Link to="/chat">
<Button className="bg-violet-500 hover:bg-violet-600 text-white rounded-xl">
Start
</Button>
</Link>
</div>
</div>
</div>
</nav>
)
}
export default LandingNavigation

View File

@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

82
src/hooks/useChat.ts Normal file
View File

@ -0,0 +1,82 @@
import { useState } from 'react'
import type { Message, ChatMode } from '@/types/chat'
import { apiClient } from '@/lib/api'
import { toast } from 'sonner'
export const useChat = (mode: ChatMode) => {
const [messages, setMessages] = useState<Message[]>([])
const [isLoading, setIsLoading] = useState(false)
const sendMessage = async (question: string) => {
if (!question.trim()) {
toast.error('Please enter a message')
return
}
// Add user message
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: question,
}
setMessages((prev) => [...prev, userMessage])
setIsLoading(true)
try {
// Create placeholder assistant message
const assistantMessageId = (Date.now() + 1).toString()
let assistantContent = ''
setMessages((prev) => [
...prev,
{
id: assistantMessageId,
role: 'assistant',
content: '',
},
])
// Process SSE stream
const streamGenerator = apiClient.chatStream(question, mode)
for await (const chunk of streamGenerator) {
if (chunk.type === 'chunk') {
// Append chunk content
assistantContent += chunk.data
// Update assistant message
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMessageId
? { ...msg, content: assistantContent }
: msg
)
)
} else if (chunk.type === 'error') {
toast.error(chunk.data)
console.error('Stream error:', chunk.data)
} else if (chunk.type === 'done') {
setIsLoading(false)
}
}
setIsLoading(false)
} catch (error) {
console.error('Error sending message:', error)
toast.error('Failed to send message. Please try again.')
setIsLoading(false)
}
}
const clearMessages = () => {
setMessages([])
}
return {
messages,
isLoading,
sendMessage,
clearMessages,
}
}

130
src/index.css Normal file
View File

@ -0,0 +1,130 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
body {
@apply bg-background text-foreground font-montserrat;
}
h1, h2, h3, h4, h5, h6 {
@apply font-display;
}
}
@layer components {
/* Grain texture overlay */
.grain-overlay {
position: relative;
}
.grain-overlay::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PScwIDAgMjAwIDIwMCcgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJz48ZmlsdGVyIGlkPSdub2lzZSc+PGZlVHVyYnVsZW5jZSB0eXBlPSdmcmFjdGFsTm9pc2UnIGJhc2VGcmVxdWVuY3k9JzAuNjUnIG51bU9jdGF2ZXM9JzMnIHN0aXRjaFRpbGVzPSdzdGl0Y2gnLz48L2ZpbHRlcj48cmVjdCB3aWR0aD0nMTAwJScgaGVpZ2h0PScxMDAlJyBmaWx0ZXI9J3VybCgjbm9pc2UpJy8+PC9zdmc+");
opacity: 0.03;
pointer-events: none;
z-index: 1;
}
/* Glass effect */
.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* Text ripple animation for mode toggle */
.text-ripple-animate {
animation: text-ripple 350ms ease-out;
transform-origin: center center;
}
/* Custom scrollbars for chat modes */
.tyndale-scroll {
scrollbar-width: thin;
}
.tyndale-scroll::-webkit-scrollbar {
width: 12px;
}
.tyndale-scroll::-webkit-scrollbar-track {
background: transparent;
}
/* General Mode - Blue scrollbar */
.tyndale-scroll-general {
scrollbar-color: rgb(59 130 246 / 0.7) transparent;
}
.tyndale-scroll-general::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, rgb(96 165 250), rgb(59 130 246));
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
/* Strategy Mode - Purple scrollbar */
.tyndale-scroll-strategy {
scrollbar-color: rgb(168 85 247 / 0.7) transparent;
}
.tyndale-scroll-strategy::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, rgb(192 132 252), rgb(168 85 247));
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
/* Animated gradient border for chat window */
@keyframes gradient-rotate {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.tech-border-wrapper {
position: relative;
border-radius: 0.75rem;
padding: 2px;
background: linear-gradient(
90deg,
#64748b,
#8b5cf6,
#06b6d4,
#8b5cf6,
#64748b
);
background-size: 300% 100%;
animation: gradient-rotate 6s ease infinite;
}
.tech-border-wrapper > * {
border-radius: 0.65rem;
}
}

177
src/lib/api.ts Normal file
View File

@ -0,0 +1,177 @@
import type { ChatMode } from '@/types/chat'
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000'
const USE_MOCK_DATA = true // Set to false when backend is ready
// Session management
export const getChatSessionId = (): string | null => {
return localStorage.getItem('tyndale_session_id')
}
export const setChatSessionId = (sessionId: string): void => {
localStorage.setItem('tyndale_session_id', sessionId)
}
export const clearChatSessionId = (): void => {
localStorage.removeItem('tyndale_session_id')
}
// Mock responses for testing
const MOCK_RESPONSES = {
general: [
"A **moving average** is one of the most fundamental technical indicators in trading. It smooths out price data by creating a constantly updated average price over a specific time period.\n\nThere are two main types:\n- **Simple Moving Average (SMA)**: The arithmetic mean of prices over a period\n- **Exponential Moving Average (EMA)**: Gives more weight to recent prices\n\nTraders use moving averages to identify trends, support/resistance levels, and generate trading signals when different MAs cross each other.",
"**Algorithmic trading** uses computer programs to execute trades based on predefined criteria. The main advantages include:\n\n1. **Speed**: Algorithms can analyze market conditions and execute orders in milliseconds\n2. **Emotion-free**: Removes psychological biases from trading decisions\n3. **Backtesting**: Strategies can be tested on historical data before risking real capital\n4. **Consistency**: Executes trades exactly as programmed\n\nCommon strategies include market making, arbitrage, trend following, and mean reversion.",
"**Risk management** is crucial in trading. Key principles include:\n\n- **Position sizing**: Never risk more than 1-2% of capital on a single trade\n- **Stop losses**: Set predetermined exit points to limit losses\n- **Diversification**: Spread risk across multiple assets or strategies\n- **Risk-reward ratio**: Aim for at least 2:1 reward-to-risk\n\nThe goal is to preserve capital during losing streaks while maximizing gains during winning periods.",
"**Market volatility** refers to the rate and magnitude of price changes in a financial instrument. High volatility means larger price swings, while low volatility indicates stability.\n\nTraders measure volatility using:\n- **Standard deviation** of returns\n- **Average True Range (ATR)**\n- **Bollinger Bands**\n- **VIX index** (for equity markets)\n\nVolatility creates both opportunities and risks - more movement means more profit potential but also higher risk.",
],
strategy: [
"When analyzing a **mean reversion strategy**, consider these key metrics:\n\n**Performance Metrics:**\n- Sharpe Ratio: Risk-adjusted returns (target > 1.5)\n- Maximum Drawdown: Largest peak-to-trough decline (keep < 20%)\n- Win Rate: Percentage of profitable trades\n- Profit Factor: Gross profit / Gross loss (target > 1.5)\n\n**Strategy-Specific:**\n- Mean reversion typically works best in range-bound markets\n- Watch for regime changes where markets shift from mean-reverting to trending\n- Consider transaction costs - they can significantly impact high-frequency mean reversion strategies",
"**Backtesting results** should be interpreted carefully to avoid overfitting:\n\n**Red flags:**\n- Too many parameters (> 5-6 tunable variables)\n- Perfect or near-perfect equity curve\n- Performance degrades significantly on out-of-sample data\n- Very high Sharpe ratio (> 3) - often indicates curve fitting\n\n**Best practices:**\n- Use walk-forward analysis\n- Test on multiple market regimes\n- Include realistic transaction costs and slippage\n- Reserve 30-40% of data for out-of-sample testing\n\nRemember: Past performance does not guarantee future results.",
"For **momentum strategies**, optimization should focus on:\n\n**Entry signals:**\n- Lookback period for momentum calculation (20-250 days typical)\n- Threshold for signal generation (% gain required)\n- Volume confirmation requirements\n\n**Risk management:**\n- Trailing stops to protect profits\n- Maximum holding period\n- Correlation filters to avoid overcrowding\n\n**Portfolio construction:**\n- Number of positions (5-20 for diversification)\n- Position sizing (equal weight vs volatility-adjusted)\n- Rebalancing frequency (weekly to monthly)\n\nMomentum works well in trending markets but can suffer during reversals.",
"**Strategy robustness** can be evaluated through:\n\n1. **Parameter sensitivity**: Test nearby parameter values - robust strategies shouldn't degrade rapidly\n2. **Market regime analysis**: Performance across bull/bear/sideways markets\n3. **Asset class generalization**: Does it work on similar instruments?\n4. **Time frame stability**: Consistent performance across different time periods\n5. **Monte Carlo simulation**: Randomize trade sequences to assess statistical significance\n\nA robust strategy maintains profitability across reasonable parameter ranges and different market conditions.",
],
}
// Mock streaming function
async function* mockChatStream(
question: string,
mode: ChatMode
): AsyncGenerator<StreamEvent> {
const sessionId = crypto.randomUUID()
yield { type: 'session_id', data: sessionId }
// Select a random response based on mode
const responses = MOCK_RESPONSES[mode]
const response = responses[Math.floor(Math.random() * responses.length)]
// Simulate streaming by yielding words with delays
const words = response.split(' ')
for (let i = 0; i < words.length; i++) {
// Add space before word (except first word)
const chunk = i === 0 ? words[i] : ' ' + words[i]
yield { type: 'chunk', data: chunk }
// Random delay between 30-80ms to simulate typing
await new Promise(resolve => setTimeout(resolve, Math.random() * 50 + 30))
}
yield { type: 'done', data: null }
}
// SSE stream event types
export type StreamEvent =
| { type: 'chunk'; data: string }
| { type: 'done'; data: null }
| { type: 'error'; data: string }
| { type: 'session_id'; data: string }
class ApiClient {
private sessionId: string | null = null
getSessionId(): string | null {
return this.sessionId || getChatSessionId()
}
setSessionId(sessionId: string): void {
this.sessionId = sessionId
setChatSessionId(sessionId)
}
clearSessionId(): void {
this.sessionId = null
clearChatSessionId()
}
// Streaming chat endpoint (SSE)
async *chatStream(
question: string,
mode: ChatMode,
sessionId?: string
): AsyncGenerator<StreamEvent> {
// Use mock data if enabled
if (USE_MOCK_DATA) {
yield* mockChatStream(question, mode)
return
}
// Generate or reuse session ID
const actualSessionId = sessionId || this.getSessionId() || crypto.randomUUID()
this.setSessionId(actualSessionId)
// Yield session ID first
yield { type: 'session_id', data: actualSessionId }
try {
const response = await fetch(`${API_BASE_URL}/api/chat/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
question,
mode,
sessionId: actualSessionId,
}),
})
if (!response.ok) {
yield { type: 'error', data: `HTTP error! status: ${response.status}` }
return
}
if (!response.body) {
yield { type: 'error', data: 'Response body is null' }
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') {
yield { type: 'done', data: null }
return
}
try {
const parsed = JSON.parse(data)
if (parsed.type === 'error') {
yield { type: 'error', data: parsed.message }
} else if (parsed.content) {
yield { type: 'chunk', data: parsed.content }
} else {
// Raw string chunk
yield { type: 'chunk', data: data }
}
} catch {
// Raw string chunk (not JSON)
yield { type: 'chunk', data: data }
}
}
}
}
yield { type: 'done', data: null }
} catch (error) {
yield {
type: 'error',
data: error instanceof Error ? error.message : 'Network error',
}
}
}
}
export const apiClient = new ApiClient()

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

13
src/pages/ChatPage.tsx Normal file
View File

@ -0,0 +1,13 @@
import TyndaleBot from '@/components/chat/TyndaleBot'
import TradingBackground from '@/components/chat/TradingBackground'
const ChatPage = () => {
return (
<div className="min-h-screen bg-gradient-to-br from-trading-dark via-trading-blue to-trading-dark relative">
<TradingBackground />
<TyndaleBot />
</div>
)
}
export default ChatPage

15
src/pages/LandingPage.tsx Normal file
View File

@ -0,0 +1,15 @@
import LandingNavigation from '@/components/landing/LandingNavigation'
import LandingHero from '@/components/landing/LandingHero'
import LandingFooter from '@/components/landing/LandingFooter'
const LandingPage = () => {
return (
<div className="min-h-screen">
<LandingNavigation />
<LandingHero />
<LandingFooter />
</div>
)
}
export default LandingPage

16
src/types/chat.ts Normal file
View File

@ -0,0 +1,16 @@
export interface Message {
id: string
role: 'user' | 'assistant'
content: string
}
export type ChatMode = 'general' | 'strategy'
export interface Conversation {
id: string
title: string
mode: ChatMode
messages: Message[]
createdAt: string
updatedAt: string
}

9
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

92
tailwind.config.ts Normal file
View File

@ -0,0 +1,92 @@
import type { Config } from "tailwindcss"
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
fontFamily: {
'montserrat': ['Montserrat', 'sans-serif'],
'display': ['Outfit', 'sans-serif'],
},
colors: {
// Trading-themed dark blues (similar to Home Pulse AI)
'trading-dark': '#0a1628',
'trading-blue': '#1e3a5f',
'trading-blue-light': '#3d5a7a',
// Shadcn color system
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
boxShadow: {
'glow-blue': '0 0 40px -10px rgba(59, 130, 246, 0.5)',
'glow-purple': '0 0 40px -10px rgba(168, 85, 247, 0.4)',
'glow-green': '0 0 40px -10px rgba(34, 197, 94, 0.5)',
},
keyframes: {
'fade-up': {
from: { opacity: '0', transform: 'translateY(30px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
'text-ripple': {
'0%, 100%': { transform: 'scale(1)', opacity: '1' },
'15%': { transform: 'scale(0.97)', opacity: '0.7' },
'40%': { transform: 'scale(1.02)', opacity: '1' },
},
},
animation: {
'fade-up': 'fade-up 0.6s ease-out forwards',
'text-ripple': 'text-ripple 350ms ease-out',
}
}
},
plugins: [require("tailwindcss-animate")],
} satisfies Config

33
tsconfig.app.json Normal file
View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

16
tsconfig.node.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}

21
vite.config.ts Normal file
View File

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: {
port: 3000,
host: true
},
build: {
outDir: 'dist',
sourcemap: false
}
})