diff --git a/app/actions/generate.ts b/app/actions/generate.ts index 5dc10dc..bc26957 100644 --- a/app/actions/generate.ts +++ b/app/actions/generate.ts @@ -7,7 +7,7 @@ import { generateTeacherResponse } from '@/lib/mistral' import type { AttachmentReference } from '@/lib/attachment' import { getUserProfileServer } from '@/lib/usage-tracking-server' import { incrementChatsServer } from '@/lib/usage-tracking-server' -import { canStartChat, canUploadFile, getPlanConfig } from '@/lib/plan-config' +import { canUploadFile, getPlanConfig } from '@/lib/plan-config' import { getWebSearchRemaining, incrementWebSearchCount } from '@/lib/web-search-usage' import { getUserCreditsRemaining, incrementUserCredits, getPlanCreditCap } from '@/lib/free-plan-credits' @@ -78,20 +78,6 @@ export async function generateAnswer({ prompt, tool, authorId, authorEmail, atta } } - // Check if user has reached their chat limit - if (!canStartChat(userProfile.subscriptionPlan, userProfile.dailyChats)) { - const planConfig = getPlanConfig(userProfile.subscriptionPlan) - const limit = planConfig.limits.messagesPerDay - const errorMessage = `You've reached your daily limit of ${limit} messages. Upgrade to Pro for unlimited access.` - console.error('Chat limit reached:', errorMessage) - return { - answer: errorMessage, - sessionId: sessionId, - chatId: chatId, - error: errorMessage - } - } - // Token-based monthly credit cap gate const { remaining: creditsRemaining, resetDate } = await getUserCreditsRemaining(authorId) if (creditsRemaining <= 0) { diff --git a/backend-server/src/services/mistral.ts b/backend-server/src/services/mistral.ts index 1648aa0..acd32bf 100644 --- a/backend-server/src/services/mistral.ts +++ b/backend-server/src/services/mistral.ts @@ -25,7 +25,7 @@ After explaining a concept, you MUST always include these questions: 3. "Did you learn something new?" 4. "Would you like a visual explanation (like a flowchart, diagram, or chart) to see how this works?" -If the user says "Yes" to a visual explanation, generate the appropriate chart, graph, or diagram immediately. +If the user says "Yes" to a visual explanation, generate the appropriate chart, graph, or diagram immediately. If the user directly asks for a visual, always generate one immediately in the required visual format. WEB SEARCH CAPABILITY: You have access to a \`web_search\` tool that searches the live internet via a privacy-respecting meta search engine (SearXNG). Use it whenever the user asks about current events, time-sensitive information, or facts you are not confident about. Return answers with clear, concise explanations and mention relevant URLs from the results.`; diff --git a/components/LimitModal.tsx b/components/LimitModal.tsx index 8872c1a..bdb2c80 100644 --- a/components/LimitModal.tsx +++ b/components/LimitModal.tsx @@ -1,7 +1,7 @@ 'use client' import { useRouter } from 'next/navigation' -import { useState, useEffect } from 'react' +import { useEffect, useState } from 'react' interface LimitModalProps { isOpen: boolean @@ -11,248 +11,127 @@ interface LimitModalProps { unlocksAt?: Date } -export default function LimitModal({ isOpen, limitType, currentPlan, onClose, unlocksAt }: LimitModalProps) { +const LIMIT_INFO = { + chats: { + title: 'Something Went Wrong', + message: 'There was an issue processing your message. Chats themselves are unlimited on every plan.', + upgrade: 'Try again, or contact support if this keeps happening.', + }, + 'file-uploads': { + title: 'File Upload Limit Reached', + message: 'You have reached your daily file upload limit.', + upgrade: 'Upgrade to Pro for 25 uploads or Plus for unlimited uploads.', + }, + 'web-search': { + title: 'Monthly Web Search Limit Reached', + message: 'You have reached your monthly web search limit.', + upgrade: 'Upgrade to Pro for 100 searches or Plus for unlimited searches.', + }, + 'research-mode': { + title: 'Deep Research Mode', + message: 'Deep Research is available on Pro and Plus plans.', + upgrade: 'Upgrade to unlock deeper multi-source research.', + }, + credits: { + title: 'Monthly Credit Cap Reached', + message: 'Chats are free and unlimited, but monthly credits power AI responses. You have used your current monthly credit allowance.', + upgrade: 'Upgrade for more monthly credits, or wait for your reset date.', + }, +} as const + +export default function LimitModal({ isOpen, limitType, currentPlan: _currentPlan, onClose, unlocksAt }: LimitModalProps) { const router = useRouter() const [isClosing, setIsClosing] = useState(false) - const [timeRemaining, setTimeRemaining] = useState('') + const [timeRemaining, setTimeRemaining] = useState('') useEffect(() => { - if (!unlocksAt) return + if (!unlocksAt) { + setTimeRemaining('') + return + } const updateTime = () => { - const now = new Date() - const diff = unlocksAt.getTime() - now.getTime() + const diff = unlocksAt.getTime() - Date.now() if (diff <= 0) { setTimeRemaining('Available now') - } else { - const hours = Math.floor(diff / (60 * 60 * 1000)) - const minutes = Math.floor((diff % (60 * 60 * 1000)) / (60 * 1000)) - - if (hours > 0) { - setTimeRemaining(`${hours}h ${minutes}m remaining`) - } else { - setTimeRemaining(`${minutes}m remaining`) - } + return } + + const hours = Math.floor(diff / (60 * 60 * 1000)) + const minutes = Math.floor((diff % (60 * 60 * 1000)) / (60 * 1000)) + setTimeRemaining(hours > 0 ? `${hours}h ${minutes}m remaining` : `${minutes}m remaining`) } updateTime() - const interval = setInterval(updateTime, 60000) // Update every minute - + const interval = setInterval(updateTime, 60000) return () => clearInterval(interval) }, [unlocksAt]) if (!isOpen || !limitType) return null - const limitInfo: Record = { - 'chats': { - title: 'Something Went Wrong', - message: 'There was an issue processing your message. Please try again.', - current: 0, - upgrade: 'Contact support if this persists', - }, - 'file-uploads': { - title: 'File Upload Limit Reached', - message: 'You\'ve reached your daily file upload limit.', - current: 3, - upgrade: 'Pro for 25 uploads, Plus for unlimited', - }, - 'web-search': { - title: 'Monthly Web Search Limit Reached', - message: 'You\'ve reached your monthly web search limit.', - current: 5, - upgrade: 'Pro for 100 searches, Plus for unlimited', - }, - 'research-mode': { - title: 'Deep Research Mode', - message: 'Deep Research is a Pro/Plus feature for comprehensive multi-source research.', - current: 0, - upgrade: 'Pro or Plus', - }, - 'credits': { - title: 'Free Plan Credit Cap Reached', - message: 'You’ve used your monthly Free credits for advanced usage. Upgrade now or wait for monthly reset.', - current: 150, - upgrade: 'Pro or Plus', - }, - } + const info = LIMIT_INFO[limitType] - const info = limitInfo[limitType] + const closeModal = () => { + setIsClosing(true) + setTimeout(() => { + setIsClosing(false) + onClose() + }, 200) + } const handleUpgrade = () => { setIsClosing(true) setTimeout(() => { router.push('/pricing') + setIsClosing(false) onClose() - }, 300) + }, 200) } return (
e.stopPropagation()} + className={`relative w-full max-w-md rounded-2xl border border-tera-border bg-tera-panel p-6 shadow-2xl transition-all duration-200 ${isClosing ? 'scale-95 opacity-0' : 'scale-100 opacity-100'}`} + onClick={(event) => event.stopPropagation()} > - {/* Close Button */} - {/* Icon */} -
-
- - - -
-
- - {/* Title */} -

{info.title}

+

Usage limit

+

{info.title}

+

{info.message}

+

{info.upgrade}

- {/* Message */} -

{info.message}

- - {/* Unlock Time Info */} {timeRemaining && ( -
-

- Access Unlocks In: - {timeRemaining} -

+
+ {timeRemaining}
)} - {/* Limit Info */} -
-
- Your Free Plan: - - {limitType === 'chats' && 'Unlimited conversations'} - {limitType === 'file-uploads' && `${info.current} uploads/day`} - {limitType === 'web-search' && `${info.current} searches/month`} - {limitType === 'research-mode' && 'Not available'} - {limitType === 'credits' && `${info.current} credits/month`} - -
-
- - {/* Upgrade Info */} -
-

- Upgrade to {info.upgrade} - for higher limits and more features -

-
- - {/* Plan Comparison */} -
- {limitType === 'chats' && ( - <> -
- All Plans: - Unlimited conversations -
- - )} - {limitType === 'file-uploads' && ( - <> -
- Free: - 3 uploads/day (10MB) -
-
- Pro: - 25 uploads/day (500MB) -
-
- Plus: - Unlimited (2GB) -
- - )} - {limitType === 'web-search' && ( - <> -
- Free: - 5 searches/month -
-
- Pro: - 100 searches/month -
-
- Plus: - Unlimited searches -
- - )} - {limitType === 'research-mode' && ( - <> -
- Free: - Not available -
-
- Pro: - ✓ Deep Research Mode -
-
- Plus: - ✓ Deep Research Mode -
- - )} - {limitType === 'credits' && ( - <> -
- Free: - 150 credits/month -
-
- Pro: - Higher limits -
-
- Plus: - Highest limits -
- - )} -
- - {/* Buttons - Only Upgrade button (forced) */} -
+
+
diff --git a/components/PromptShell.tsx b/components/PromptShell.tsx index 805c69f..e043e18 100644 --- a/components/PromptShell.tsx +++ b/components/PromptShell.tsx @@ -582,10 +582,7 @@ export default function PromptShell({ const now = new Date() const unlocksAt = new Date(now.getTime() + 24 * 60 * 60 * 1000) - if (limitError.includes('messages')) { - setLimitModalType('chats') - setLimitUnlocksAt(unlocksAt) - } else if (limitError.includes('file uploads')) { + if (limitError.includes('file uploads')) { setLimitModalType('file-uploads') setLimitUnlocksAt(unlocksAt) } else if (limitError.includes('web search')) { @@ -664,10 +661,7 @@ export default function PromptShell({ const now = new Date() const unlocksAt = new Date(now.getTime() + 24 * 60 * 60 * 1000) - if (message.includes('limit') && (message.includes('chats') || message.includes('messages'))) { - setLimitModalType('chats') - setLimitUnlocksAt(unlocksAt) - } else if (message.includes('limit') && message.includes('file uploads')) { + if (message.includes('limit') && message.includes('file uploads')) { setLimitModalType('file-uploads') setLimitUnlocksAt(unlocksAt) } else if (message.includes('limit') && message.includes('web-search')) { diff --git a/components/UpgradePrompt.tsx b/components/UpgradePrompt.tsx index 8a9a369..edd1ac6 100644 --- a/components/UpgradePrompt.tsx +++ b/components/UpgradePrompt.tsx @@ -8,71 +8,66 @@ interface UpgradePromptProps { inline?: boolean } -export default function UpgradePrompt({ type, onClose, inline = false }: UpgradePromptProps) { - const messages = { - 'lesson-plans': { - title: 'Lesson Plan Limit Reached', - description: "You've reached your monthly limit of 5 lesson plans on the Free plan.", - benefit: 'Upgrade to Pro for unlimited lesson plans and advanced features.', - icon: '📚' - }, - 'chats': { - title: 'Something Went Wrong', - description: "There was an issue processing your message. All plans include unlimited conversations.", - benefit: 'Please try again or contact support if this persists.', - icon: '💬' - }, - 'file-uploads': { - title: 'File Upload Limit Reached', - description: "You've reached your daily limit of 3 file uploads on the Free plan.", - benefit: 'Upgrade to Pro for 25 uploads/day or Plus for unlimited.', - icon: '📎' - }, - 'web-search': { - title: 'Web Search Limit Reached', - description: "You've reached your monthly limit of 5 web searches on the Free plan.", - benefit: 'Upgrade to Pro (100/month) or Plus (unlimited) to unlock more searches.', - icon: '🔍' - }, - 'research-mode': { - title: 'Deep Research Mode', - description: "Deep Research mode (comprehensive multi-source research) is only available on Pro and Plus plans.", - benefit: 'Upgrade to access Deep Research and other advanced features.', - icon: '🔭' - }, - 'credits': { - title: 'Free Credit Cap Reached', - description: "You've reached your monthly Free credit cap for advanced usage.", - benefit: 'Upgrade to Pro or Plus for higher usage limits, or wait for your monthly credit reset.', - icon: '⚡' - } - } +const PROMPTS = { + 'lesson-plans': { + title: 'Lesson Plan Limit Reached', + description: 'You have reached your monthly lesson plan limit.', + benefit: 'Upgrade to Pro for more lesson plans and advanced features.', + icon: 'LP', + }, + chats: { + title: 'Something Went Wrong', + description: 'There was an issue processing your message. Chats themselves are unlimited on every plan.', + benefit: 'Please try again or contact support if this persists.', + icon: 'AI', + }, + 'file-uploads': { + title: 'File Upload Limit Reached', + description: 'You have reached your daily file upload limit.', + benefit: 'Upgrade to Pro for more uploads or Plus for unlimited uploads.', + icon: 'UP', + }, + 'web-search': { + title: 'Web Search Limit Reached', + description: 'You have reached your monthly web search limit.', + benefit: 'Upgrade to Pro or Plus to unlock more searches.', + icon: 'WS', + }, + 'research-mode': { + title: 'Deep Research Mode', + description: 'Deep Research mode is available on Pro and Plus plans.', + benefit: 'Upgrade to unlock deeper multi-source research.', + icon: 'DR', + }, + credits: { + title: 'Monthly Credit Cap Reached', + description: 'Chats are free and unlimited, but monthly credits power AI responses.', + benefit: 'Upgrade to Pro or Plus for more monthly credits, or wait for your reset date.', + icon: 'CR', + }, +} as const - const message = messages[type] +export default function UpgradePrompt({ type, onClose, inline = false }: UpgradePromptProps) { + const message = PROMPTS[type] if (inline) { return ( -
+
- {message.icon} + + {message.icon} +
-

{message.title}

-

{message.description}

-

{message.benefit}

- +

{message.title}

+

{message.description}

+

{message.benefit}

+ View Plans
{onClose && ( - )}
@@ -82,36 +77,28 @@ export default function UpgradePrompt({ type, onClose, inline = false }: Upgrade return (
-
+
{onClose && ( - )}
-
{message.icon}
-

{message.title}

-

{message.description}

-

{message.benefit}

+
+ {message.icon} +
+

{message.title}

+

{message.description}

+

{message.benefit}

{onClose && ( - )} - + View Plans
diff --git a/components/visuals/ChartRenderer.tsx b/components/visuals/ChartRenderer.tsx index 8d66b36..d5b48dc 100644 --- a/components/visuals/ChartRenderer.tsx +++ b/components/visuals/ChartRenderer.tsx @@ -1,45 +1,58 @@ "use client" -import React, { useRef } from 'react' +import React, { useEffect, useMemo, useRef } from 'react' import { - LineChart, - Line, - BarChart, - Bar, - AreaChart, - Area, - PieChart, - Pie, - RadarChart, - Radar, - PolarGrid, - PolarAngleAxis, - PolarRadiusAxis, - ScatterChart, - Scatter, - ComposedChart, - XAxis, - YAxis, - CartesianGrid, + Chart, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + ArcElement, + RadialLinearScale, Tooltip, Legend, - ResponsiveContainer, - Cell, - ZAxis -} from 'recharts' + Filler, + ScatterController, + BubbleController, + LineController, + BarController, + PieController, + RadarController, + type ChartType +} from 'chart.js' + +Chart.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + ArcElement, + RadialLinearScale, + Tooltip, + Legend, + Filler, + ScatterController, + BubbleController, + LineController, + BarController, + PieController, + RadarController +) interface ChartData { - type: 'line' | 'bar' | 'area' | 'pie' | 'radar' | 'scatter' | 'composed' + type: 'line' | 'bar' | 'area' | 'pie' | 'radar' | 'scatter' | 'composed' | string title?: string xAxisKey?: string yAxisKey?: string - zAxisKey?: string // For bubble charts or scatter weights + zAxisKey?: string data: any[] series: Array<{ key: string color: string name?: string - type?: 'line' | 'bar' | 'area' | 'scatter' // For composed charts + type?: 'line' | 'bar' | 'area' | 'scatter' data?: any[] }> } @@ -50,327 +63,251 @@ interface ChartRendererProps { const COLORS = ['#00FFA3', '#00B8D9', '#FF5630', '#FFAB00', '#36B37E', '#6554C0', '#FF00E6', '#2979FF'] +const normalizeType = (rawType?: string): ChartType => { + const type = (rawType || '').toLowerCase() + if (type === 'line' || type === 'linechart' || type === 'area' || type === 'areachart' || type === 'composed' || type === 'composedchart' || type === 'combo') return 'line' + if (type === 'bar' || type === 'barchart') return 'bar' + if (type === 'pie' || type === 'piechart') return 'pie' + if (type === 'radar' || type === 'radarchart') return 'radar' + if (type === 'scatter' || type === 'scatterplot') return 'scatter' + return 'bar' +} + export default function ChartRenderer({ config }: ChartRendererProps) { - // Helper to check data availability - const hasRootData = Array.isArray(config?.data) && config.data.length > 0 - const hasSeries = Array.isArray(config?.series) && config.series.length > 0 - const firstSeries = config?.series?.[0] as any - const hasNestedData = hasSeries && Array.isArray(firstSeries?.data) - - // Auto-generate series from data keys if series is missing but data is present - let effectiveSeries = config?.series - let effectiveData = config?.data - - if (hasRootData && !hasSeries) { - // Infer series from data keys (exclude common axis keys) - const axisKeys = ['name', 'label', 'category', 'date', 'year', 'month', 'x', 'axis', config?.xAxisKey].filter(Boolean) as string[] - const firstDataItem = effectiveData?.[0] || {} - const inferredSeriesKeys = Object.keys(firstDataItem).filter(k => { - const val = firstDataItem[k] - const keyLower = k.toLowerCase() - if (axisKeys.includes(keyLower)) return false - // Accept numbers OR numeric strings - return typeof val === 'number' || (typeof val === 'string' && !isNaN(parseFloat(val)) && val.trim() !== '') - }) + const canvasRef = useRef(null) + const chartInstanceRef = useRef(null) + + const processed = useMemo(() => { + const hasRootData = Array.isArray(config?.data) && config.data.length > 0 + const hasSeries = Array.isArray(config?.series) && config.series.length > 0 + const firstSeries = config?.series?.[0] as any + const hasNestedData = hasSeries && Array.isArray(firstSeries?.data) + + let effectiveSeries = config?.series + let effectiveData = config?.data - if (inferredSeriesKeys.length > 0) { - effectiveSeries = inferredSeriesKeys.map((key, i) => ({ - key, - color: COLORS[i % COLORS.length], - name: key.charAt(0).toUpperCase() + key.slice(1) - })) + if (hasRootData && !hasSeries) { + const axisKeys = ['name', 'label', 'category', 'date', 'year', 'month', 'x', 'axis', config?.xAxisKey].filter(Boolean) as string[] + const firstDataItem = effectiveData?.[0] || {} + const inferredSeriesKeys = Object.keys(firstDataItem).filter(k => { + const val = firstDataItem[k] + const keyLower = k.toLowerCase() + if (axisKeys.includes(keyLower)) return false + return typeof val === 'number' || (typeof val === 'string' && !isNaN(parseFloat(val)) && val.trim() !== '') + }) + + if (inferredSeriesKeys.length > 0) { + effectiveSeries = inferredSeriesKeys.map((key, i) => ({ + key, + color: COLORS[i % COLORS.length], + name: key.charAt(0).toUpperCase() + key.slice(1) + })) + } } - } - const hasEffectiveSeries = Array.isArray(effectiveSeries) && effectiveSeries.length > 0 + const hasEffectiveSeries = Array.isArray(effectiveSeries) && effectiveSeries.length > 0 - // Validate config - require either root data or nested series data - if (!config || (!hasRootData && !hasNestedData) || !hasEffectiveSeries) { - return ( -
-

Chart Configuration Error

-

Missing: {!hasRootData && !hasNestedData ? 'data array' : ''} {!hasEffectiveSeries ? 'series (could not infer from data)' : ''}

-
{JSON.stringify(config, null, 2)}
-
- ) - } + if (!config || (!hasRootData && !hasNestedData) || !hasEffectiveSeries) { + return { isValid: false, type: 'bar' as ChartType, xAxisKey: 'name', yAxisKey: undefined as string | undefined, zAxisKey: undefined as string | undefined, title: config?.title, data: [], series: [] } + } + + let { type, xAxisKey = 'name', yAxisKey, zAxisKey, title } = config + let data = effectiveData + const series = effectiveSeries || [] - // Initialize variables with defaults, using the potentially auto-generated series - let { type, xAxisKey = 'name', yAxisKey, zAxisKey, title } = config - let data = effectiveData - let series = effectiveSeries - - // Normalize data: convert string numbers to actual numbers for Recharts - if (Array.isArray(data)) { - data = data.map(item => { - const normalized: any = {} - for (const [key, val] of Object.entries(item)) { - if (typeof val === 'string' && !isNaN(parseFloat(val)) && val.trim() !== '' && key !== xAxisKey) { - normalized[key] = parseFloat(val) - } else { - normalized[key] = val + if (Array.isArray(data)) { + data = data.map(item => { + const normalized: any = {} + for (const [key, val] of Object.entries(item)) { + if (typeof val === 'string' && !isNaN(parseFloat(val)) && val.trim() !== '' && key !== xAxisKey) { + normalized[key] = parseFloat(val) + } else { + normalized[key] = val + } } + return normalized + }) + } + + if (!hasRootData && hasNestedData && firstSeries?.data) { + const dataMap = new Map() + let detectedAxisKey = xAxisKey + + if (xAxisKey === 'name') { + const firstItem = firstSeries.data[0] || {} + const candidate = Object.keys(firstItem).find(k => k !== 'value') + if (candidate) detectedAxisKey = candidate + xAxisKey = detectedAxisKey } - return normalized - }) - } - // Auto-transform nested series data to flat data (Recharts format) if root data is missing - if (!hasRootData && hasNestedData) { - const dataMap = new Map() - let detectedAxisKey = xAxisKey - - // First pass: Detect axis key from first item if default 'name' isn't found - if (xAxisKey === 'name') { - const firstItem = firstSeries.data[0] || {} - // Find a key that is likely the axis (e.g. 'axis', 'category', 'year', 'date') - // or just the first key that isn't 'value' - const candidate = Object.keys(firstItem).find(k => k !== 'value') - if (candidate) detectedAxisKey = candidate - xAxisKey = detectedAxisKey + series.forEach((s: any) => { + if (Array.isArray(s.data)) { + s.data.forEach((item: any) => { + const axisValue = item[detectedAxisKey] + if (axisValue !== undefined) { + if (!dataMap.has(axisValue)) { + dataMap.set(axisValue, { [detectedAxisKey]: axisValue }) + } + let val = item.value !== undefined ? item.value : item + if (typeof val === 'object' && val !== null) { + if (Array.isArray(val)) val = val[1] + else val = val.y ?? val.value ?? val.count ?? val.score ?? val.amount ?? 0 + } + dataMap.get(axisValue)[s.key] = val + } + }) + } + }) + data = Array.from(dataMap.values()) } - series.forEach((s: any) => { - if (Array.isArray(s.data)) { - s.data.forEach((item: any) => { - const axisValue = item[detectedAxisKey] - if (axisValue !== undefined) { - if (!dataMap.has(axisValue)) { - dataMap.set(axisValue, { [detectedAxisKey]: axisValue }) - } - // Use the series key as the value key in the flat object - // If item has 'value', use it. Otherwise assume item is number/value? - let val = item.value !== undefined ? item.value : item - - // Fix for "Objects are not valid as a React child": - // If val is still an object (e.g. {x, y} from scatter data), extract a primitive. - if (typeof val === 'object' && val !== null) { - if (Array.isArray(val)) val = val[1] // Handle [x, y] format - else val = val.y ?? val.value ?? val.count ?? val.score ?? val.amount ?? 0 - } + return { + isValid: true, + type: normalizeType(type), + xAxisKey, + yAxisKey, + zAxisKey, + title, + data: Array.isArray(data) ? data : [], + series + } + }, [config]) - dataMap.get(axisValue)[s.key] = val - } - }) + useEffect(() => { + if (!canvasRef.current) return + + if (chartInstanceRef.current) { + chartInstanceRef.current.destroy() + chartInstanceRef.current = null + } + + if (!processed.isValid || processed.series.length === 0) return + + const isScatter = processed.type === 'scatter' + const isPie = processed.type === 'pie' + const labels = isScatter ? [] : processed.data.map((item: any) => item[processed.xAxisKey] ?? '') + + const datasets = processed.series.map((s: any, i: number) => { + const color = s.color || COLORS[i % COLORS.length] + const isArea = (config?.type || '').toLowerCase().includes('area') || s.type === 'area' + + if (isScatter) { + const scatterData = (Array.isArray(s.data) && s.data.length > 0 ? s.data : processed.data).map((point: any) => ({ + x: Number(point[processed.xAxisKey || 'x']) || 0, + y: Number(point[processed.yAxisKey || 'y']) || Number(point[s.key]) || 0, + ...(processed.zAxisKey ? { r: Number(point[processed.zAxisKey]) || 4 } : {}) + })) + + return { + label: s.name || s.key, + data: scatterData, + borderColor: color, + backgroundColor: `${color}99`, + pointRadius: processed.zAxisKey ? undefined : 5 + } + } + + if (isPie) { + return { + label: s.name || s.key, + data: processed.data.map((item: any) => Number(item[s.key]) || 0), + backgroundColor: processed.data.map((_: any, idx: number) => COLORS[idx % COLORS.length]), + borderColor: '#0A0A0A', + borderWidth: 2 + } + } + + return { + label: s.name || s.key, + data: processed.data.map((item: any) => Number(item[s.key]) || 0), + borderColor: color, + backgroundColor: isArea ? `${color}55` : `${color}CC`, + fill: isArea, + tension: 0.35 } }) - data = Array.from(dataMap.values()) - } - const chartRef = useRef(null) - const handleDownload = () => { - if (!chartRef.current) return - - const svg = chartRef.current.querySelector('svg') - if (!svg) return - - // Create a canvas to draw the SVG - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') - const svgData = new XMLSerializer().serializeToString(svg) - - // Add minimal styling to ensure it looks good on white background if transparent - const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }) - const url = URL.createObjectURL(svgBlob) - - const img = new Image() - img.onload = () => { - canvas.width = svg.clientWidth || 600 - canvas.height = svg.clientHeight || 400 - if (ctx) { - ctx.fillStyle = '#0A0A0A' // Match container bg - ctx.fillRect(0, 0, canvas.width, canvas.height) - ctx.drawImage(img, 0, 0) - - const pngUrl = canvas.toDataURL('image/png') - const downloadLink = document.createElement('a') - downloadLink.href = pngUrl - downloadLink.download = `${title ? title.replace(/\s+/g, '_') : 'tera_chart'}.png` - document.body.appendChild(downloadLink) - downloadLink.click() - document.body.removeChild(downloadLink) - URL.revokeObjectURL(url) + chartInstanceRef.current = new Chart(canvasRef.current, { + type: processed.type, + data: { + labels, + datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + labels: { color: '#d1d5db' } + }, + tooltip: { + backgroundColor: '#111827', + titleColor: '#f9fafb', + bodyColor: '#f9fafb' + } + }, + scales: isPie || isScatter + ? undefined + : { + x: { + ticks: { color: '#9ca3af' }, + grid: { color: '#1f2937' } + }, + y: { + ticks: { color: '#9ca3af' }, + grid: { color: '#1f2937' } + } + } } + }) + + return () => { + chartInstanceRef.current?.destroy() + chartInstanceRef.current = null } - img.src = url + }, [processed, config?.type]) + + const handleDownload = () => { + const chart = chartInstanceRef.current + if (!chart) return + + const downloadLink = document.createElement('a') + downloadLink.href = chart.toBase64Image('image/png', 1) + downloadLink.download = `${(processed.title || 'tera_chart').replace(/\s+/g, '_')}.png` + document.body.appendChild(downloadLink) + downloadLink.click() + document.body.removeChild(downloadLink) } - const normalizedType = (type || '').toLowerCase() - - const renderChart = () => { - switch (normalizedType) { - case 'line': - case 'linechart': - return ( - - - - - - - {series.map((s, i) => ( - - ))} - - ) - case 'bar': - case 'barchart': - return ( - - - - - - - {series.map((s, i) => ( - - ))} - - ) - case 'area': - case 'areachart': - return ( - - - - - - - {series.map((s, i) => ( - - ))} - - ) - case 'radar': - case 'radarchart': - return ( - - - - - - - {series.map((s, i) => ( - - ))} - - ) - case 'scatter': - case 'scatterplot': - return ( - - - - - {zAxisKey && } - - - {series.map((s, i) => ( - - ))} - - ) - case 'composed': - case 'composedchart': - case 'combo': - return ( - - - - - - - {series.map((s, i) => { - const color = s.color || COLORS[i % COLORS.length] - if (s.type === 'bar') return - if (s.type === 'area') return - return - })} - - ) - case 'pie': - case 'piechart': - return ( - - - {data.map((entry, index) => ( - - ))} - - - - - ) - default: - return null - } + if (!processed.isValid) { + return ( +
+

Chart Configuration Error

+

Missing chart data or plottable series.

+
{JSON.stringify(config, null, 2)}
+
+ ) } return (
- {/* Code Section */}

- 📝 Chart Configuration (JSON) + 📝 Chart.js Configuration (JSON)

-                    {JSON.stringify({ type, data, series, xAxisKey, yAxisKey }, null, 2)}
+                    {JSON.stringify({ type: processed.type, data: processed.data, series: processed.series, xAxisKey: processed.xAxisKey, yAxisKey: processed.yAxisKey }, null, 2)}
                 
- {/* Chart Rendering Section */} -
- {title && ( +
+ {processed.title && (

- {title} + {processed.title}

)} -
- - {renderChart() || ( -
- Invalid Chart Config - - Type received: {String(type || 'undefined')} - -
- )} -
+
+
diff --git a/lib/mistral.ts b/lib/mistral.ts index d5f921e..6a43cb2 100644 --- a/lib/mistral.ts +++ b/lib/mistral.ts @@ -27,7 +27,7 @@ After explaining a concept, you MUST always include these questions: 3. "Did you learn something new?" 4. "Would you like a visual explanation (like a flowchart, diagram, or chart) to help you visualize this concept?" -If the user says "Yes" to a visual explanation, generate the appropriate chart, graph, or diagram immediately using the blocks below. +If the user says "Yes" to a visual explanation, generate the appropriate chart, graph, or diagram immediately. If the user directly asks for a visual, always generate one immediately in the required visual format using the blocks below. ABSOLUTE FORMATTING RULE: - NEVER use asterisks (*) for bold or emphasis. Use hyphens (-) for lists. diff --git a/lib/usage-tracking.ts b/lib/usage-tracking.ts index b30b6eb..ea5cdfd 100644 --- a/lib/usage-tracking.ts +++ b/lib/usage-tracking.ts @@ -173,32 +173,7 @@ export async function canUserStartChat(userId: string): Promise<{ allowed: boole const stats = await getUsageStats(userId) if (!stats) return { allowed: false, remaining: 0, reason: 'Could not fetch usage stats' } - const allowed = canStartChat(profile.subscriptionPlan, stats.dailyChats) - const remaining = getRemainingChats(profile.subscriptionPlan, stats.dailyChats) - - if (!allowed) { - const limit = PLAN_CONFIGS[profile.subscriptionPlan].limits.messagesPerDay - - // Calculate unlock time (24 hours from when limit was first hit) - let unlocksAt: Date | undefined - if (profile.limitHitChatAt) { - unlocksAt = new Date(profile.limitHitChatAt.getTime() + 24 * 60 * 60 * 1000) - } else { - // If not yet recorded, record it now - const now = new Date() - await recordChatLimitHit(userId) - unlocksAt = new Date(now.getTime() + 24 * 60 * 60 * 1000) - } - - return { - allowed: false, - remaining: 0, - reason: `You've reached your daily limit of ${limit} messages. Access unlocks in 24 hours.`, - unlocksAt - } - } - - return { allowed: true, remaining } + return { allowed: true, remaining: 'unlimited' } } /** diff --git a/package.json b/package.json index 989df67..fe291f0 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@mistralai/mistralai": "^1.10.0", "@supabase/supabase-js": "^2.84.0", "@vercel/analytics": "^1.5.0", + "chart.js": "^4.5.1", "googleapis": "^167.0.0", "katex": "^0.16.27", "mammoth": "^1.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2397565..00f75b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@vercel/analytics': specifier: ^1.5.0 version: 1.6.1(next@16.0.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) + chart.js: + specifier: ^4.5.1 + version: 4.5.1 googleapis: specifier: ^167.0.0 version: 167.0.0 @@ -487,6 +490,9 @@ packages: peerDependencies: react: ^19.2.3 + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} @@ -1282,6 +1288,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + chevrotain-allstar@0.3.1: resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} peerDependencies: @@ -3400,6 +3410,8 @@ snapshots: transitivePeerDependencies: - zod + '@kurkle/color@0.3.4': {} + '@mermaid-js/parser@0.6.3': dependencies: langium: 3.3.1 @@ -4266,6 +4278,10 @@ snapshots: character-reference-invalid@2.0.1: {} + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + chevrotain-allstar@0.3.1(chevrotain@11.0.3): dependencies: chevrotain: 11.0.3