diff --git a/client/src/App.module.css b/client/src/App.module.css new file mode 100644 index 0000000..c88fb8a --- /dev/null +++ b/client/src/App.module.css @@ -0,0 +1,296 @@ +/* Calculator App Module Styles */ + +.calculator { + @apply flex h-full max-w-6xl mx-auto bg-background; + gap: 1rem; +} + +.calculatorMain { + @apply flex-1 max-w-md mx-auto bg-card rounded-2xl shadow-lg border border-border p-6; + min-height: 600px; +} + +.calculatorHeader { + @apply flex justify-between items-center mb-4; +} + +.calculatorTitle { + @apply text-2xl font-bold text-foreground; +} + +.calculatorControls { + @apply flex gap-2; +} + +.historyToggle { + @apply p-2 rounded-lg transition-colors; +} + +.historyToggle.active { + @apply bg-primary text-primary-foreground; +} + +/* Display Styles */ +.display { + @apply bg-muted rounded-xl p-6 mb-6 text-right min-h-[120px] flex flex-col justify-end; + border: 1px solid hsl(var(--border)); +} + +.displayExpression { + @apply text-sm text-muted-foreground mb-2 min-h-[20px] break-all; + font-family: 'JetBrains Mono', 'Fira Code', monospace; +} + +.displayResult { + @apply text-3xl font-bold text-foreground break-all; + font-family: 'JetBrains Mono', 'Fira Code', monospace; +} + +.displayError .displayResult { + @apply text-destructive; +} + +/* Button Grid */ +.buttonGrid { + @apply grid grid-cols-4 gap-3; +} + +/* Calculator Button Styles */ +.calculatorButton { + @apply rounded-xl font-semibold text-lg transition-all duration-200 min-h-[60px] flex items-center justify-center; + @apply hover:scale-105 active:scale-95; + @apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2; + border: 1px solid hsl(var(--border)); +} + +.buttonContent { + @apply flex items-center justify-center; +} + +/* Button Variants */ +.button-default { + @apply bg-muted text-foreground; +} + +.button-default:hover { + @apply bg-muted; + background-color: hsl(var(--muted) / 0.8); +} + +.button-operator { + @apply bg-primary text-primary-foreground; +} + +.button-operator:hover { + @apply bg-primary; + background-color: hsl(var(--primary) / 0.9); +} + +.button-equals { + @apply bg-gradient-to-r from-primary to-blue-500 text-primary-foreground font-bold shadow-lg; +} + +.button-equals:hover { + background: linear-gradient(to right, hsl(var(--primary) / 0.9), hsl(217 91% 50%)); +} + +.button-clear { + @apply bg-destructive text-destructive-foreground; +} + +.button-clear:hover { + @apply bg-destructive; + background-color: hsl(var(--destructive) / 0.9); +} + +.button-function { + @apply bg-secondary text-secondary-foreground; + border: 1px solid hsl(var(--primary) / 0.3); +} + +.button-function:hover { + @apply bg-secondary; + background-color: hsl(var(--secondary) / 0.8); +} + +/* Button Sizes */ +.button-sm { + @apply min-h-[45px] text-base; +} + +.button-md { + @apply min-h-[60px] text-lg; +} + +.button-lg { + @apply min-h-[75px] text-xl; +} + +/* Span columns */ +.span-2 { + grid-column: span 2; +} + +.span-3 { + grid-column: span 3; +} + +.span-4 { + grid-column: span 4; +} + +/* Button States */ +.buttonDisabled { + @apply opacity-50 cursor-not-allowed hover:scale-100; +} + +/* History Panel */ +.history { + @apply w-80 bg-card rounded-2xl shadow-lg border border-border p-4 h-fit max-h-[600px] flex flex-col; +} + +.historyHeader { + @apply flex items-center justify-between mb-4 pb-3 border-b border-border; +} + +.historyTitle { + @apply flex items-center gap-2 text-lg font-semibold text-foreground; +} + +.clearHistoryButton { + @apply text-muted-foreground hover:text-destructive; +} + +.historyContent { + @apply flex-1; +} + +.historyEmpty { + @apply text-center py-8 text-muted-foreground; +} + +.historyList { + @apply space-y-2; +} + +.historyItem { + @apply p-3 rounded-lg transition-colors cursor-pointer border border-transparent; + background-color: hsl(var(--muted) / 0.5); +} + +.historyItem:hover { + @apply bg-muted border-border; +} + +.historyItemContent { + @apply space-y-1; +} + +.historyExpression { + @apply text-sm text-muted-foreground font-mono; +} + +.historyResult { + @apply text-base font-semibold text-foreground font-mono; +} + +.historyTime { + @apply text-xs text-muted-foreground mt-2 text-right; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .calculator { + @apply flex-col p-4 gap-4; + } + + .calculatorMain { + @apply max-w-full; + } + + .history { + @apply w-full max-h-[300px]; + } + + .buttonGrid { + @apply gap-2; + } + + .calculatorButton { + @apply min-h-[50px] text-base; + } + + .displayResult { + @apply text-2xl; + } +} + +@media (max-width: 480px) { + .calculatorButton { + @apply min-h-[45px] text-sm; + } + + .displayResult { + @apply text-xl; + } + + .display { + @apply p-4 min-h-[100px]; + } +} + +/* Dark mode specific adjustments */ +.dark .calculator { + @apply bg-background; +} + +.dark .calculatorButton { + @apply shadow-sm; +} + +.dark .button-default { + background-color: hsl(var(--muted) / 0.8); +} + +.dark .button-default:hover { + @apply bg-muted; +} + +.dark .historyItem { + background-color: hsl(var(--muted) / 0.3); +} + +.dark .historyItem:hover { + background-color: hsl(var(--muted) / 0.6); +} + +/* Animation classes */ +.fadeIn { + animation: fadeIn 0.3s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.scaleIn { + animation: scaleIn 0.2s ease-out; +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index a180f03..0e96217 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -15,6 +15,7 @@ import CreateListing from "@/pages/create-listing"; import Profile from "@/pages/profile"; import Profiles from "@/pages/profiles"; import ProfileDetail from "@/pages/profile-detail"; +import CalculatorPage from "@/pages/calculator"; import NotFound from "@/pages/not-found"; import { Bell, Search } from "lucide-react"; import { Input } from "@/components/ui/input"; @@ -60,6 +61,7 @@ function Router() { { path: "/private-market", component: PrivateMarket, title: "Private Market" }, { path: "/private-market/marketplace", component: Marketplace, title: "Marketplace" }, { path: "/private-market/create-listing", component: CreateListing, title: "Create Listing" }, + { path: "/calculator", component: CalculatorPage, title: "Calculator" }, { path: "/profiles", component: Profiles, title: "Network Profiles" }, { path: "/profile/:id", component: ProfileDetail, title: "Profile Details" }, { path: "/profile", component: Profile, title: "Profile & Settings" }, diff --git a/client/src/components/Calculator.tsx b/client/src/components/Calculator.tsx new file mode 100644 index 0000000..271df88 --- /dev/null +++ b/client/src/components/Calculator.tsx @@ -0,0 +1,430 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { History as HistoryIcon, RotateCcw, Divide, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { CalculatorButton } from './CalculatorButton'; +import { Display } from './Display'; +import { History } from './History'; +import { calculator, CalculationEntry } from '../utils/calculator'; +import { cn } from '@/lib/utils'; +import styles from '../App.module.css'; + +export interface CalculatorProps { + /** + * Additional CSS classes + */ + className?: string; +} + +/** + * Main calculator component with logic and layout + */ +export const Calculator: React.FC = ({ className }) => { + // Calculator state + const [expression, setExpression] = useState(''); + const [result, setResult] = useState('0'); + const [history, setHistory] = useState([]); + const [showHistory, setShowHistory] = useState(false); + const [error, setError] = useState(false); + const [lastResult, setLastResult] = useState(''); + + // Load history on component mount + useEffect(() => { + setHistory(calculator.getHistory()); + }, []); + + // Handle number input + const handleNumber = useCallback((num: string) => { + setError(false); + + // If we just finished a calculation, start fresh + if (lastResult && !expression) { + setExpression(num); + setResult(num); + setLastResult(''); + } else if (result === '0' && !expression) { + setExpression(num); + setResult(num); + } else { + // Check if the last character was an operator + const lastChar = expression.slice(-1); + if (['+', '-', '×', '÷', '^'].includes(lastChar)) { + setExpression(prev => prev + num); + setResult(num); + } else { + setExpression(prev => prev + num); + setResult(prev => prev + num); + } + } + }, [expression, result, lastResult]); + + // Handle operator input + const handleOperator = useCallback((op: string) => { + setError(false); + + if (!expression && result !== '0') { + // Start with current result + setExpression(result + op); + setResult(result); + } else { + const lastChar = expression.slice(-1); + + // Replace operator if last character is also an operator + if (['+', '-', '×', '÷', '^'].includes(lastChar)) { + setExpression(prev => prev.slice(0, -1) + op); + } else { + setExpression(prev => prev + op); + } + setResult(result); + } + }, [expression, result]); + + // Handle equals calculation + const handleEquals = useCallback(() => { + if (!expression) return; + + try { + const calculatedResult = calculator.evaluate(expression); + const formattedResult = calculator.formatNumber(calculatedResult); + + setResult(formattedResult); + setLastResult(formattedResult); + setExpression(''); + setError(false); + + // Update history + setHistory(calculator.getHistory()); + } catch (err) { + setError(true); + setResult('Error'); + } + }, [expression]); + + // Handle clear all + const handleClear = useCallback(() => { + setExpression(''); + setResult('0'); + setError(false); + setLastResult(''); + }, []); + + // Handle clear entry + const handleClearEntry = useCallback(() => { + if (expression) { + const newExpression = expression.slice(0, -1); + setExpression(newExpression); + setResult(newExpression || '0'); + } else { + setResult('0'); + } + setError(false); + }, [expression]); + + // Handle decimal point + const handleDecimal = useCallback(() => { + setError(false); + const currentNumber = result.split(/[+\-×÷^]/).pop() || ''; + if (!currentNumber.includes('.')) { + const newValue = result === '0' ? '0.' : result + '.'; + setResult(newValue); + if (expression) { + setExpression(prev => prev + '.'); + } + } + }, [expression, result]); + + // Handle percentage + const handlePercentage = useCallback(() => { + try { + if (result !== '0' && result !== '') { + const currentValue = parseFloat(result); + const percentValue = currentValue / 100; + const formatted = calculator.formatNumber(percentValue); + setResult(formatted); + if (expression) { + setExpression(prev => prev + '%'); + } + } + } catch (err) { + setError(true); + } + }, [result, expression]); + + // Handle square root + const handleSquareRoot = useCallback(() => { + try { + if (result !== '0' && result !== '') { + const currentValue = parseFloat(result); + const sqrtValue = calculator.sqrt(currentValue); + const formatted = calculator.formatNumber(sqrtValue); + setResult(formatted); + setExpression(`√${currentValue}`); + } + } catch (err) { + setError(true); + setResult('Error'); + } + }, [result]); + + // Handle history item click + const handleHistoryItemClick = useCallback((entry: CalculationEntry) => { + setResult(entry.result.toString()); + setExpression(''); + setError(false); + }, []); + + // Handle clear history + const handleClearHistory = useCallback(() => { + calculator.clearHistory(); + setHistory([]); + }, []); + + // Keyboard support + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const key = event.key; + + event.preventDefault(); + + if (key >= '0' && key <= '9') { + handleNumber(key); + } else if (key === '+') { + handleOperator('+'); + } else if (key === '-') { + handleOperator('-'); + } else if (key === '*') { + handleOperator('×'); + } else if (key === '/') { + handleOperator('÷'); + } else if (key === '^') { + handleOperator('^'); + } else if (key === 'Enter' || key === '=') { + handleEquals(); + } else if (key === 'Escape') { + handleClear(); + } else if (key === 'Backspace') { + handleClearEntry(); + } else if (key === '.') { + handleDecimal(); + } else if (key === '%') { + handlePercentage(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleNumber, handleOperator, handleEquals, handleClear, handleClearEntry, handleDecimal, handlePercentage]); + + return ( +
+
+ {/* Header with controls */} +
+

Calculator

+
+ +
+
+ + {/* Display */} + + + {/* Button Grid */} +
+ {/* Row 1 - Functions */} + + AC + + + CE + + + % + + handleOperator('÷')} + data-testid="btn-divide" + > + + + + {/* Row 2 */} + handleNumber('7')} + data-testid="btn-7" + > + 7 + + handleNumber('8')} + data-testid="btn-8" + > + 8 + + handleNumber('9')} + data-testid="btn-9" + > + 9 + + handleOperator('×')} + data-testid="btn-multiply" + > + + + + {/* Row 3 */} + handleNumber('4')} + data-testid="btn-4" + > + 4 + + handleNumber('5')} + data-testid="btn-5" + > + 5 + + handleNumber('6')} + data-testid="btn-6" + > + 6 + + handleOperator('-')} + data-testid="btn-subtract" + > + - + + + {/* Row 4 */} + handleNumber('1')} + data-testid="btn-1" + > + 1 + + handleNumber('2')} + data-testid="btn-2" + > + 2 + + handleNumber('3')} + data-testid="btn-3" + > + 3 + + handleOperator('+')} + data-testid="btn-add" + > + + + + + {/* Row 5 */} + handleNumber('0')} + span={2} + data-testid="btn-0" + > + 0 + + + . + + + = + + + {/* Scientific functions row */} + + √ + + handleOperator('^')} + data-testid="btn-power" + > + x² + + { + if (result !== '0') { + const value = parseFloat(result); + const reciprocal = 1 / value; + setResult(calculator.formatNumber(reciprocal)); + } + }} + data-testid="btn-reciprocal" + > + 1/x + + { + if (result !== '0') { + const value = parseFloat(result); + setResult(calculator.formatNumber(-value)); + } + }} + data-testid="btn-negate" + > + ± + +
+
+ + {/* History Panel */} + +
+ ); +}; \ No newline at end of file diff --git a/client/src/components/CalculatorButton.tsx b/client/src/components/CalculatorButton.tsx new file mode 100644 index 0000000..1a89dab --- /dev/null +++ b/client/src/components/CalculatorButton.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import styles from '../App.module.css'; + +export interface CalculatorButtonProps { + /** + * The text or symbol to display on the button + */ + children: React.ReactNode; + + /** + * Click handler for the button + */ + onClick: () => void; + + /** + * Button variant for different styling + */ + variant?: 'default' | 'operator' | 'equals' | 'clear' | 'function'; + + /** + * Button size + */ + size?: 'sm' | 'md' | 'lg'; + + /** + * Whether the button spans multiple columns + */ + span?: number; + + /** + * Whether the button is disabled + */ + disabled?: boolean; + + /** + * Additional CSS classes + */ + className?: string; + + /** + * Test identifier for automated testing + */ + 'data-testid'?: string; +} + +/** + * Reusable calculator button component with different variants and styles + */ +export const CalculatorButton: React.FC = ({ + children, + onClick, + variant = 'default', + size = 'md', + span = 1, + disabled = false, + className, + 'data-testid': testId, +}) => { + const buttonClass = cn( + styles.calculatorButton, + styles[`button-${variant}`], + styles[`button-${size}`], + { + [styles.buttonDisabled]: disabled, + [styles[`span-${span}`]]: span > 1, + }, + className + ); + + return ( + + ); +}; \ No newline at end of file diff --git a/client/src/components/Display.tsx b/client/src/components/Display.tsx new file mode 100644 index 0000000..a7d38ff --- /dev/null +++ b/client/src/components/Display.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import styles from '../App.module.css'; + +export interface DisplayProps { + /** + * Current expression being entered + */ + expression: string; + + /** + * Current result or value to display + */ + result: string; + + /** + * Whether there's an error state + */ + error?: boolean; + + /** + * Additional CSS classes + */ + className?: string; +} + +/** + * Calculator display component showing current input and result + */ +export const Display: React.FC = ({ + expression, + result, + error = false, + className, +}) => { + return ( +
+ {/* Expression line - shows the current input/calculation */} +
+ {expression || '0'} +
+ + {/* Result line - shows the calculated result */} +
+ {error ? 'Error' : result} +
+
+ ); +}; \ No newline at end of file diff --git a/client/src/components/History.tsx b/client/src/components/History.tsx new file mode 100644 index 0000000..f0fb76c --- /dev/null +++ b/client/src/components/History.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Trash2, Clock } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; +import { CalculationEntry } from '../utils/calculator'; +import styles from '../App.module.css'; + +export interface HistoryProps { + /** + * Array of calculation history entries + */ + history: CalculationEntry[]; + + /** + * Callback when a history item is clicked to reuse the result + */ + onHistoryItemClick?: (entry: CalculationEntry) => void; + + /** + * Callback when clear history is clicked + */ + onClearHistory?: () => void; + + /** + * Whether the history panel is visible + */ + visible: boolean; + + /** + * Additional CSS classes + */ + className?: string; +} + +/** + * Calculator history component showing past calculations + */ +export const History: React.FC = ({ + history, + onHistoryItemClick, + onClearHistory, + visible, + className, +}) => { + if (!visible) return null; + + const formatTime = (date: Date) => { + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + }); + }; + + return ( +
+ {/* History Header */} +
+
+ + History +
+ + {history.length > 0 && ( + + )} +
+ + {/* History Content */} + + {history.length === 0 ? ( +
+

No calculations yet

+

Your calculation history will appear here

+
+ ) : ( +
+ {history.map((entry, index) => ( +
onHistoryItemClick?.(entry)} + data-testid={`history-item-${index}`} + > +
+
+ {entry.expression} +
+
+ = {entry.result.toString()} +
+
+
+ {formatTime(entry.timestamp)} +
+
+ ))} +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/client/src/components/app-sidebar.tsx b/client/src/components/app-sidebar.tsx index 867cf37..e799966 100644 --- a/client/src/components/app-sidebar.tsx +++ b/client/src/components/app-sidebar.tsx @@ -1,4 +1,4 @@ -import { Home, Lightbulb, Globe, TrendingUp, User, Users, Plus, Building2 } from "lucide-react" +import { Home, Lightbulb, Globe, TrendingUp, User, Users, Plus, Building2, Calculator } from "lucide-react" import { Link, useLocation } from "wouter" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" @@ -11,6 +11,7 @@ const navigation = [ { name: "Public Explorer", href: "/explorer", icon: Globe }, { name: "Investment", href: "/investment", icon: TrendingUp }, { name: "Private Market", href: "/private-market", icon: Building2 }, + { name: "Calculator", href: "/calculator", icon: Calculator }, { name: "Network", href: "/profiles", icon: Users }, { name: "Profile", href: "/profile", icon: User }, ] diff --git a/client/src/pages/calculator.tsx b/client/src/pages/calculator.tsx new file mode 100644 index 0000000..01d9362 --- /dev/null +++ b/client/src/pages/calculator.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Calculator } from '@/components/Calculator'; + +/** + * Calculator page component - integrates Calculator into the app + */ +export default function CalculatorPage() { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/client/src/utils/calculator.ts b/client/src/utils/calculator.ts new file mode 100644 index 0000000..d3f06f1 --- /dev/null +++ b/client/src/utils/calculator.ts @@ -0,0 +1,153 @@ +/** + * Calculator utility functions for performing mathematical operations + */ + +export interface CalculationEntry { + expression: string; + result: number; + timestamp: Date; +} + +export class Calculator { + private static instance: Calculator; + private history: CalculationEntry[] = []; + + private constructor() {} + + public static getInstance(): Calculator { + if (!Calculator.instance) { + Calculator.instance = new Calculator(); + } + return Calculator.instance; + } + + /** + * Safely evaluate a mathematical expression + */ + public evaluate(expression: string): number { + try { + // Remove spaces and validate expression + const cleanExpression = expression.replace(/\s+/g, ''); + + // Security check - only allow numbers, operators, and specific functions + if (!/^[0-9+\-*/.()√^%]+$/.test(cleanExpression)) { + throw new Error('Invalid characters in expression'); + } + + // Replace calculator symbols with JavaScript equivalents + let jsExpression = cleanExpression + .replace(/×/g, '*') + .replace(/÷/g, '/') + .replace(/√(\d+(?:\.\d+)?)/g, 'Math.sqrt($1)') + .replace(/\^/g, '**'); + + // Handle percentage operations + jsExpression = this.handlePercentage(jsExpression); + + // Use Function constructor instead of eval for better security + const result = new Function(`"use strict"; return (${jsExpression})`)(); + + if (typeof result !== 'number' || !isFinite(result)) { + throw new Error('Invalid result'); + } + + // Add to history + this.addToHistory(expression, result); + + return result; + } catch (error) { + throw new Error('Invalid expression'); + } + } + + /** + * Handle percentage calculations + */ + private handlePercentage(expression: string): string { + // Convert percentage operations like "20%" to "20/100" + return expression.replace(/(\d+(?:\.\d+)?)%/g, '($1/100)'); + } + + /** + * Add calculation to history + */ + private addToHistory(expression: string, result: number): void { + const entry: CalculationEntry = { + expression, + result, + timestamp: new Date() + }; + + this.history.unshift(entry); + + // Keep only last 50 calculations + if (this.history.length > 50) { + this.history = this.history.slice(0, 50); + } + } + + /** + * Get calculation history + */ + public getHistory(): CalculationEntry[] { + return [...this.history]; + } + + /** + * Clear calculation history + */ + public clearHistory(): void { + this.history = []; + } + + /** + * Calculate square root + */ + public sqrt(value: number): number { + if (value < 0) { + throw new Error('Cannot calculate square root of negative number'); + } + return Math.sqrt(value); + } + + /** + * Calculate power + */ + public power(base: number, exponent: number): number { + return Math.pow(base, exponent); + } + + /** + * Calculate percentage + */ + public percentage(value: number, percentage: number): number { + return (value * percentage) / 100; + } + + /** + * Format number for display + */ + public formatNumber(num: number): string { + // Handle very large or very small numbers + if (Math.abs(num) > 1e10 || (Math.abs(num) < 1e-10 && num !== 0)) { + return num.toExponential(6); + } + + // Remove unnecessary decimal places + const formatted = num.toString(); + if (formatted.includes('.')) { + return parseFloat(formatted).toString(); + } + + return formatted; + } + + /** + * Validate if string is a valid number + */ + public isValidNumber(str: string): boolean { + return !isNaN(parseFloat(str)) && isFinite(parseFloat(str)); + } +} + +export const calculator = Calculator.getInstance(); \ No newline at end of file