diff --git a/app/api/plus/analytics/route.ts b/app/api/plus/analytics/route.ts index 6a4dde2..f385632 100644 --- a/app/api/plus/analytics/route.ts +++ b/app/api/plus/analytics/route.ts @@ -1,4 +1,4 @@ -import { supabaseServer } from '@/lib/supabase-server' +import { supabaseServer } from '@/lib/supabase-server' import { NextRequest, NextResponse } from 'next/server' export async function GET(request: NextRequest) { @@ -9,7 +9,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'User ID required' }, { status: 400 }) } - // Verify user is Plus plan const { data: user, error: userError } = await supabaseServer .from('users') .select('subscription_plan') @@ -20,23 +19,19 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized - Plus plan required' }, { status: 403 }) } - // Get chat sessions for analytics const { data: chats } = await supabaseServer .from('chat_sessions') - .select('tool, created_at') + .select('tool, created_at, attachments') .eq('user_id', userId) - // Get file uploads - const { data: uploads } = await supabaseServer - .from('file_uploads') - .select('created_at') - .eq('user_id', userId) - - // Calculate stats const totalChats = chats?.length || 0 - const totalUploads = uploads?.length || 0 + const totalUploads = chats?.reduce((sum: number, chat: any) => { + if (Array.isArray(chat.attachments)) { + return sum + chat.attachments.length + } + return sum + }, 0) || 0 - // Group chats by tool const chatsByTool: { [key: string]: number } = {} chats?.forEach((chat: any) => { const tool = chat.tool || 'Universal' @@ -45,24 +40,22 @@ export async function GET(request: NextRequest) { const mostUsedTool = Object.entries(chatsByTool).sort((a, b) => b[1] - a[1])[0]?.[0] || 'Universal' - // Calculate daily activity (last 7 days) const dailyActivity: { date: string; chats: number }[] = [] for (let i = 6; i >= 0; i--) { const date = new Date() date.setDate(date.getDate() - i) const dateStr = date.toISOString().split('T')[0] - const count = chats?.filter((c: any) => + const count = chats?.filter((c: any) => c.created_at?.startsWith(dateStr) ).length || 0 dailyActivity.push({ date: dateStr, - chats: count + chats: count, }) } - // Average response time (mock for now) const avgResponseTime = 2.5 return NextResponse.json({ @@ -71,7 +64,7 @@ export async function GET(request: NextRequest) { mostUsedTool, avgResponseTime, chatsByTool, - dailyActivity + dailyActivity, }) } catch (error) { console.error('Analytics error:', error) diff --git a/app/history/page.tsx b/app/history/page.tsx index 9126943..b7968cf 100644 --- a/app/history/page.tsx +++ b/app/history/page.tsx @@ -14,6 +14,52 @@ interface ChatSession { tool?: string } +function escapeHtml(value: string) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function buildExportRows(conversations: ChatSession[]) { + return conversations.map((conversation) => ({ + sessionId: conversation.session_id, + title: conversation.title || 'Untitled chat', + message: conversation.last_message || '', + tool: conversation.tool || 'Chat', + date: new Date(conversation.created_at).toLocaleString(), + })) +} + +function buildExportTable(rows: ReturnType) { + return ` + + + + + + + + + + + + ${rows.map((row) => ` + + + + + + + + `).join('')} + +
SessionTitleToolMessageDate
${escapeHtml(row.sessionId)}${escapeHtml(row.title)}${escapeHtml(row.tool)}${escapeHtml(row.message)}${escapeHtml(row.date)}
+ ` +} + export default function HistoryPage() { const { user } = useAuth() const [conversations, setConversations] = useState([]) @@ -48,14 +94,8 @@ export default function HistoryPage() { fetchHistory() }, [fetchHistory]) - const handleExport = () => { - const data = conversations.map((conversation) => ({ - sessionId: conversation.session_id, - title: conversation.title, - message: conversation.last_message, - tool: conversation.tool, - date: conversation.created_at, - })) + const handleExportJson = () => { + const data = buildExportRows(conversations) const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) @@ -68,6 +108,76 @@ export default function HistoryPage() { URL.revokeObjectURL(url) } + const handleExportWord = () => { + const rows = buildExportRows(conversations) + const html = ` + + + + Tera history export + + + +

Tera chat history

+ ${buildExportTable(rows)} + + + ` + + const blob = new Blob([html], { type: 'application/msword' }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `tera-history-${new Date().toISOString().split('T')[0]}.doc` + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(url) + } + + const handleExportPdf = () => { + const rows = buildExportRows(conversations) + const printWindow = window.open('', '_blank', 'noopener,noreferrer,width=1100,height=900') + + if (!printWindow) { + alert('Popup blocked. Allow popups to export PDF.') + return + } + + const html = ` + + + + Tera history export + + + +

Tera chat history

+ ${buildExportTable(rows)} + + + ` + + printWindow.document.open() + printWindow.document.write(html) + printWindow.document.close() + printWindow.focus() + setTimeout(() => printWindow.print(), 300) + } + return (
@@ -75,7 +185,7 @@ export default function HistoryPage() {

Workspace

Chat history

-

Search recent sessions, reopen previous conversations, or export a clean JSON archive.

+

Search recent sessions, reopen previous conversations, or export JSON, Word, or PDF archives.

- + +
diff --git a/app/profile/page.tsx b/app/profile/page.tsx index f8c2cc2..0f3fe3a 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -4,32 +4,36 @@ import { useCallback, useEffect, useState } from 'react' import Link from 'next/link' import { useAuth } from '@/components/AuthProvider' import UsageMetricCard from '@/components/UsageMetricCard' -import { fetchUserProfile, fetchUserSessions, fetchUserUsageSummary, updateUserProfile } from '@/app/actions/user' -import type { UserProfile } from '@/lib/usage-tracking' -import type { ProfileUsageSummary } from '@/lib/profile-usage' +import { fetchCreditUsage, fetchUserProfile, fetchUserSessions, fetchUserUsageSummary, updateUserProfile } from '@/app/actions/user' +import { buildUsageMetricSummary, type ProfileUsageSummary } from '@/lib/profile-usage' import { TERA_USAGE_REFRESH_EVENT } from '@/lib/usage-events' +import type { UserProfile } from '@/lib/usage-tracking' + +type CreditUsageState = { + used: number + remaining: number + total: number + resetDate: string | null +} | null function formatMemberSince(createdAt: Date) { return createdAt.toLocaleDateString([], { month: 'long', day: 'numeric', year: 'numeric' }) } -import { checkLimitReset, fetchCreditUsage, fetchDailyTokenUsage, fetchUserProfile, fetchUserSessions, updateUserProfile } from '@/app/actions/user' -import { type UserProfile } from '@/lib/usage-tracking' -import { getPlanConfig, getRemainingChats, getRemainingFileUploads, getUsagePercentage, type PlanType } from '@/lib/plan-config' export default function ProfilePage() { const { user } = useAuth() const [profile, setProfile] = useState(null) const [usageSummary, setUsageSummary] = useState(null) + const [creditUsage, setCreditUsage] = useState(null) const [loading, setLoading] = useState(true) const [usageLoading, setUsageLoading] = useState(true) + const [creditsLoading, setCreditsLoading] = useState(true) const [editing, setEditing] = useState(false) const [saving, setSaving] = useState(false) const [formData, setFormData] = useState({ fullName: '', school: '', gradeLevels: [] as string[] }) const [recentSessions, setRecentSessions] = useState([]) const [sessionsLoading, setSessionsLoading] = useState(true) const [portalLoading, setPortalLoading] = useState(false) - const [creditUsage, setCreditUsage] = useState<{ used: number; remaining: number; total: number; resetDate: string | null } | null>(null) - const [dailyTokenUsage, setDailyTokenUsage] = useState(0) const loadUsageSummary = useCallback(async () => { if (!user) return @@ -40,49 +44,28 @@ export default function ProfilePage() { setUsageSummary(summary) } catch (error) { console.error('Error loading usage summary:', error) + setUsageSummary(null) } finally { setUsageLoading(false) } }, [user]) - const loadRecentSessions = useCallback(async () => { - useEffect(() => { - if (user) { - void loadProfile() - void loadRecentSessions() - void loadCreditUsage() - void loadDailyTokenUsage() - } - }, [user]) - - const loadCreditUsage = async () => { + const loadCreditUsage = useCallback(async () => { if (!user) return + + setCreditsLoading(true) try { const usage = await fetchCreditUsage(user.id) - if (!usage) return - setCreditUsage({ - used: usage.used, - remaining: usage.remaining, - total: usage.total, - resetDate: usage.resetDate, - }) + setCreditUsage(usage) } catch (error) { console.error('Error loading credit usage:', error) + setCreditUsage(null) + } finally { + setCreditsLoading(false) } - } - - const loadDailyTokenUsage = async () => { - if (!user) return - try { - const usage = await fetchDailyTokenUsage(user.id) - if (!usage) return - setDailyTokenUsage(usage.usedToday) - } catch (error) { - console.error('Error loading daily token usage:', error) - } - } + }, [user]) - const loadRecentSessions = async () => { + const loadRecentSessions = useCallback(async () => { if (!user) return setSessionsLoading(true) try { @@ -119,18 +102,19 @@ export default function ProfilePage() { void Promise.all([ loadProfile(), loadUsageSummary(), + loadCreditUsage(), loadRecentSessions(), ]) - }, [loadProfile, loadRecentSessions, loadUsageSummary, user]) + }, [loadCreditUsage, loadProfile, loadRecentSessions, loadUsageSummary, user]) useEffect(() => { const handleUsageRefresh = () => { - void loadUsageSummary() + void Promise.all([loadUsageSummary(), loadCreditUsage()]) } window.addEventListener(TERA_USAGE_REFRESH_EVENT, handleUsageRefresh) return () => window.removeEventListener(TERA_USAGE_REFRESH_EVENT, handleUsageRefresh) - }, [loadUsageSummary]) + }, [loadCreditUsage, loadUsageSummary]) const handleSave = async () => { if (!user || !profile) return @@ -174,16 +158,6 @@ export default function ProfilePage() { return
Unable to load profile.
} - const planConfig = getPlanConfig(profile.subscriptionPlan as PlanType) - const remainingChats = getRemainingChats(profile.subscriptionPlan as PlanType, profile.dailyChats) - const remainingUploads = getRemainingFileUploads(profile.subscriptionPlan as PlanType, profile.dailyFileUploads) - const uploadLimit = planConfig.limits.fileUploadsPerDay - const uploadPercentage = uploadLimit === 'unlimited' ? 0 : getUsagePercentage(uploadLimit as number, profile.dailyFileUploads) - const creditPercentage = creditUsage ? Math.min(100, Math.round((creditUsage.used / Math.max(1, creditUsage.total)) * 100)) : 0 - const creditResetLabel = creditUsage?.resetDate - ? new Date(creditUsage.resetDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) - : 'in 30 days' - const email = user.email || '' const displayName = formData.fullName || profile.fullName || (email ? email.split('@')[0] : '') || 'User' const initials = displayName @@ -193,6 +167,16 @@ export default function ProfilePage() { .toUpperCase() .slice(0, 2) + const creditMetric = creditUsage + ? buildUsageMetricSummary( + creditUsage.used, + creditUsage.total, + creditUsage.resetDate ? new Date(creditUsage.resetDate) : null, + ) + : null + + const usageCardsLoading = usageLoading || creditsLoading + return (
@@ -252,17 +236,6 @@ export default function ProfilePage() {

Member since

{formatMemberSince(profile.createdAt)}

Usage cards below refresh from the same tracked counters Tera uses while you work.

-

Plan

-

{planConfig.displayName}

-
- {profile.subscriptionPlan === 'free' ? ( - Upgrade - ) : ( - - )} -
@@ -270,7 +243,7 @@ export default function ProfilePage() {

Subscription

{usageSummary?.planDisplayName || 'Current plan'}

-

Upgrade to expand uploads and web search limits, or manage billing details from the same place you review activity.

+

Manage billing details, review your monthly credits, and keep subscription details close to the rest of the workspace.

{profile.subscriptionPlan === 'free' ? ( Upgrade @@ -279,8 +252,8 @@ export default function ProfilePage() { {portalLoading ? 'Loading...' : 'Manage'} )} -
@@ -291,80 +264,22 @@ export default function ProfilePage() {

Balance

Usage dashboard

-

A live view of the counters Tera updates when you send messages, upload files, or run web search.

+

A live view of the counters Tera updates when you send messages, upload files, run web search, or spend monthly credits.

- {usageSummary ? ( + {usageCardsLoading || !usageSummary || !creditMetric ? ( +
+

Loading usage summary...

+
+ ) : ( <> -
-
-
-

Account

-

{usageSummary.planDisplayName}

-

This card mirrors the plan limits that power the rest of the dashboard.

-
-
-
-
- Messages - {usageSummary.messages.isUnlimited ? 'Unlimited' : usageSummary.messages.limit} -
-
- Uploads / day - {usageSummary.uploads.isUnlimited ? 'Unlimited' : usageSummary.uploads.limit} -
-
- Web search / month - {usageSummary.webSearch.isUnlimited ? 'Unlimited' : usageSummary.webSearch.limit} -
-
-

Usage updates immediately after successful activity in this tab.

-

Remaining: {remainingChats === 'unlimited' ? 'Unlimited' : remainingChats}

-
-
-
-
-
-

Tokens used today

-

{dailyTokenUsage}

-
-

- Monthly remaining: {creditUsage ? `${creditUsage.remaining} / ${creditUsage.total}` : 'Loading...'} -

-
-
-
= 85 ? 'bg-red-400' : creditPercentage >= 60 ? 'bg-amber-400' : 'bg-tera-neon'}`} - style={{ width: `${creditPercentage}%` }} - /> -
-

- {creditPercentage >= 85 - ? 'You are close to your monthly credit limit. Upgrade for higher limits.' - : 'Credits are charged by tokens consumed as you chat with Tera.'} -

-

- Today: {dailyTokenUsage} tokens used · Credits reset on {creditResetLabel} -

-
-
-
-
-

File uploads today

-

{profile.dailyFileUploads}

-
-
-
+ - ) : ( -
-

Unable to load usage summary right now. Profile details are still available.

-
)}
@@ -390,6 +305,3 @@ export default function ProfilePage() {
) } - - - diff --git a/lib/web-search-usage.ts b/lib/web-search-usage.ts index 8bdfcb0..a3c056d 100644 --- a/lib/web-search-usage.ts +++ b/lib/web-search-usage.ts @@ -4,8 +4,6 @@ * - Free: 5/month * - Pro: 100/month * - Plus: unlimited - * Tracks and limits web searches based on subscription plan. - * Limits are sourced from centralized plan config to avoid drift. */ import { supabaseServer } from './supabase-server' @@ -15,10 +13,6 @@ const MONTHLY_WEB_SEARCH_LIMITS: Record = { free: 5, pro: 100, plus: Infinity, -const MONTHLY_WEB_SEARCH_LIMITS = { - free: getPlanConfig('free').limits.webSearchesPerMonth as number, - pro: getPlanConfig('pro').limits.webSearchesPerMonth as number, - plus: Infinity } const RESET_INTERVAL_DAYS = 30 @@ -217,5 +211,3 @@ export function getWebSearchLimitMessage(remaining: number, total: number): stri export const WEB_SEARCH_LIMITS = MONTHLY_WEB_SEARCH_LIMITS export const getDefaultLimit = (plan: PlanType = 'free') => MONTHLY_WEB_SEARCH_LIMITS[plan] - -