diff --git a/.Jules/palette.md b/.Jules/palette.md new file mode 100644 index 0000000..a4c3077 --- /dev/null +++ b/.Jules/palette.md @@ -0,0 +1,3 @@ +## 2025-02-20 - Password Visibility Toggle +**Learning:** Adding a password visibility toggle is a high-value micro-UX improvement for login forms, especially on mobile. Using MUI's `InputAdornment` and `IconButton` with `Visibility/VisibilityOff` icons makes this easy and consistent with the design system. Crucially, `onMouseDown={e => e.preventDefault()}` is needed to prevent focus loss from the input field when clicking the toggle. +**Action:** Always verify keyboard accessibility and focus management when adding interactive elements inside form fields. diff --git a/.github/workflows/weekly-backup.yml b/.github/workflows/weekly-backup.yml new file mode 100644 index 0000000..b247674 --- /dev/null +++ b/.github/workflows/weekly-backup.yml @@ -0,0 +1,15 @@ +name: Weekly Database Backup +on: + schedule: + - cron: '0 0 * * 0' # Every Sunday at midnight UTC + workflow_dispatch: # Allow manual trigger from GitHub Actions tab + +jobs: + run-backup: + runs-on: ubuntu-latest + steps: + - name: Trigger Backup API + run: | + curl -X POST ${{ secrets.APP_URL }}/api/admin/backup \ + -H "x-backup-secret: ${{ secrets.BACKUP_SECRET }}" \ + -H "Content-Type: application/json" diff --git a/.gitignore b/.gitignore index 5ef6a52..c926d3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,41 +1,48 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies +# Dependencies /node_modules /.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing +.pnp.js + +# Testing /coverage -# next.js +# Next.js /.next/ /out/ -# production +# Production /build -# misc +# Environment Variables +.env +.env.local +.env.production +.env.development +.env.test +.env*.local + +# Firebase +.firebase/ +firebase-debug.log +firebase-debug.log* + +# Vercel +.vercel/ + +# System & Editor .DS_Store +.vscode/ +.idea/ *.pem -# debug +# TypeScript +*.tsbuildinfo + +# Debug Logs npm-debug.log* yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# env files (can opt-in for committing if needed) -.env* +pnpm-debug.log* -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts +# Local Security/Keys (Preserved from previous configuration) +certificates/ +serviceAccountKey.json diff --git a/ADMIN_TIER_GUIDE.md b/ADMIN_TIER_GUIDE.md new file mode 100644 index 0000000..63df9f2 --- /dev/null +++ b/ADMIN_TIER_GUIDE.md @@ -0,0 +1,71 @@ +# Admin Tier Management Guide + +## ✅ Functionality Already Exists! + +Admin can change any user's tier and it saves to Firebase automatically. + +## How to Change User Tiers + +### Step 1: Access User Management +Navigate to: `/admin/users` + +### Step 2: Find the User +- **Users Tab**: Regular users +- **Admins Tab**: Admin users + +### Step 3: Edit User +Click the **Edit** button (pencil icon) on any user row + +### Step 4: Select New Tier +In the dialog, choose from the dropdown: +- **Free** - Limited features (5 scans/day, basic AI) +- **Plus** - Enhanced features (20 scans/day, global search, 10 AI chats/day) +- **Pro** - Full features (unlimited scans, meal planner, PC builder, visual search) +- **Ultimate** - All Pro + white label, beta access, unlimited compare + +### Step 5: Save +Click **"Save Changes"** button + +## What Happens When You Change a Tier + +1. **Immediate Firebase Update**: Tier is saved to `users/{userId}/subscription/tier` +2. **Timestamp Added**: `updatedAt` field is automatically updated +3. **Features Update**: User immediately gets access to new tier features +4. **Real-time Effect**: Next time user refreshes or navigates, new features appear + +## Backend Details + +**API Endpoint**: `/api/admin/action` +**Action**: `updateTier` +**Firebase Path**: `users/{userId}/subscription` + +```typescript +// Backend saves as: +{ + subscription: { + tier: 'pro', // or free, plus, ultimate + updatedAt: new Date() + } +} +``` + +## Feature Access by Tier + +| Feature | Free | Plus | Pro | Ultimate | +|---------|:----:|:----:|:---:|:--------:| +| Daily Scans | 5 | 20 | ∞ | ∞ | +| History | 10 | 50 | ∞ | ∞ | +| AI Chat | ❌ | 10/day | ∞ | ∞ | +| Global Search | ❌ | ✅ | ✅ | ✅ | +| Visual Search | ❌ | ❌ | ✅ | ✅ | +| Meal Planner | ❌ | ❌ | ✅ | ✅ | +| PC Builder | ❌ | ❌ | ✅ | ✅ | +| Product Compare | 0 | 2 | 5 | ∞ | +| Beta Access | ❌ | ❌ | ❌ | ✅ | + +## Notes + +- **Admin Role**: Admins automatically get Ultimate-level features regardless of tier +- **Instant Effect**: Changes take effect immediately upon save +- **Safe Operation**: Uses Firebase `merge: true` to prevent data loss +- **Audit Trail**: `updatedAt` timestamp tracks when tier was last changed diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8899c0d --- /dev/null +++ b/LICENSE @@ -0,0 +1,10 @@ +Copyright (c) 2024 Youssef Boubli (TRADMSS) + +All rights reserved. + +This software and its documentation are the property of Youssef Boubli. +Unauthorized copying, modification, distribution, or use of this software, +via any medium, is strictly prohibited without the express written permission +of the copyright holder. + +This software is NOT open source. It is proprietary software. diff --git a/PROJECT_DOCS.md b/PROJECT_DOCS.md new file mode 100644 index 0000000..0b0e321 --- /dev/null +++ b/PROJECT_DOCS.md @@ -0,0 +1,246 @@ +# TruthLens - Private Project Documentation +> **Repository**: Private (not public, not free) +> **Owner**: Youssef Boubli +> **Stack**: Next.js 16, Firebase, TypeScript, Material-UI + +--- + +## 🏗️ Architecture Overview + +``` +TruthLens/ +├── app/ # Next.js App Router pages +│ ├── (admin)/ # Admin dashboard routes +│ ├── api/ # API routes (serverless) +│ ├── pc-builder/ # PC Geek Builder (NEW) +│ ├── scan/ # Barcode scanner +│ └── ... # User-facing pages +├── components/ # React components +├── services/ # Business logic services +├── types/ # TypeScript types +├── lib/ # Firebase & utilities +├── context/ # React Context (Auth, etc.) +├── hooks/ # Custom React hooks +└── public/ # Static assets & icons +``` + +--- + +## 💎 Subscription Tiers + +| Tier | Price | Target Audience | +|------|-------|-----------------| +| **Free** | $0 | Trial users, limited features | +| **Plus** | TBD | Casual users, basic premium | +| **Pro** | TBD | Power users, full features | +| **Ultimate** | TBD | Enterprise, white-label | + +### Feature Matrix + +| Feature | Free | Plus | Pro | Ultimate | +|---------|:----:|:----:|:---:|:--------:| +| Daily Scans | 5 | 20 | ∞ | ∞ | +| Scan History | 10 | 50 | ∞ | ∞ | +| Multi-Scan | 1 | 3 | 10 | 99 | +| AI Analysis | Basic | Advanced | Premium | Premium | +| Smart Grading | ❌ | ✅ | ✅ | ✅ | +| AI Truth Detector | ❌ | ❌ | ✅ | ✅ | +| Advertisements | ✅ | ❌ | ❌ | ❌ | +| Global Search | ❌ | ✅ | ✅ | ✅ | +| Visual Search (Photo) | ❌ | ❌ | ✅ | ✅ | +| AI Meal Planning | ❌ | ❌ | ✅ | ✅ | +| AI Chat Assistant | ❌ | 10/day | ∞ | ∞ | +| Product Compare | ❌ | 2 | 5 | ∞ | +| Eco Score Analysis | ❌ | ❌ | ✅ | ✅ | +| Gamification (Quests) | ✅ | ✅ | ✅ | ✅ | +| **PC Geek Builder** | ❌ | ❌ | ✅ | ✅ | +| Export (PDF/CSV/Excel) | - | CSV | All | All | +| Priority Support | ❌ | ❌ | ✅ | ✅ | +| White Label Branding | ❌ | ❌ | ❌ | ✅ | +| Beta Access | ❌ | ❌ | ❌ | ✅ | + +--- + +## 📱 All Application Routes + +### User-Facing Pages +| Route | Page | Access | +|-------|------|--------| +| `/` | Home Dashboard | All users | +| `/scan` | Barcode Scanner | All users | +| `/product/[id]` | Product Details | All users | +| `/global-search` | Global Product Search | Plus+ | +| `/search/photo` | Visual Search (Photo) | Pro+ | +| `/ai-chat` | AI Assistant | Plus+ | +| `/compare` | Product Compare | Plus+ | +| `/favorites` | Saved Favorites | All users | +| `/history` | Scan History | All users | +| `/recommendations` | Smart Picks | All users | +| `/meal-planner` | AI Meal Planning | Pro+ | +| `/quest` | Gamification | All users | +| `/pc-builder` | **PC Geek Builder** | Pro+ | +| `/upgrade` | Subscription Upgrade | All users | +| `/profile` | User Profile & Settings | Logged in | +| `/support` | Support Chat | All users | +| `/install-guide` | PWA Install Guide | All users | + +### Auth Routes +| Route | Purpose | +|-------|---------| +| `/login` | User login | +| `/signup` | New user registration | +| `/reset-password` | Password recovery | +| `/request-access` | Access request form | +| `/set-admin` | Admin account recovery | + +### Admin Routes (`/admin/*`) +| Route | Purpose | +|-------|---------| +| `/admin` | Admin Dashboard | +| `/admin/users` | User Management | +| `/admin/settings` | System Configuration | +| `/admin/events` | Seasonal Events Manager | +| `/admin/support` | Support Tickets | +| `/admin/chat` | Chat History | +| `/admin/tiers` | Tier Configuration | +| `/admin/payments` | Payment History | +| `/admin/access-codes` | Access Code Generator | +| `/admin/access-requests` | Access Requests | +| `/admin/cancellations` | Cancellation Requests | +| `/admin/themes` | Theme Customization | +| `/admin/settings/recovery` | Admin Recovery | + +--- + +## 🔌 API Endpoints + +### Product APIs +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/api/product/scan` | POST | Process barcode scan | +| `/api/search-by-image` | POST | Visual search | + +### PC Builder APIs +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/api/pc-builder/generate` | POST | Generate AI PC build | +| `/api/pc-builder/save` | POST | Save build to profile | + +### Admin APIs +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/api/admin/users` | GET/POST | User management | +| `/api/admin/keys` | POST | Secure API key storage | +| `/api/admin/ai-test` | POST | Test AI provider | +| `/api/admin/ollama/models` | GET | Fetch Ollama models | +| `/api/admin/backup` | POST | Database backup | +| `/api/admin/upload` | POST | File upload | +| `/api/admin/delete-file` | POST | File deletion | +| `/api/admin/send-notification` | POST | Push notifications | +| `/api/admin/action` | POST | Admin actions | + +### Other APIs +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/api/ollama` | POST | Ollama AI proxy | +| `/api/access/submit` | POST | Submit access request | +| `/api/access/validate` | POST | Validate access code | +| `/api/notifications/subscribe` | POST | Push notification subscribe | +| `/api/notifications/send-chat-push` | POST | Send chat push | +| `/api/v1/event_config` | GET | Event configuration | + +--- + +## 🤖 AI Integrations + +### Providers Supported +| Provider | Usage | Config Location | +|----------|-------|-----------------| +| **Groq** | Fast inference (Llama 3.3) | Admin Settings | +| **Gemini** | Google's AI | Admin Settings | +| **OpenAI** | GPT models | Admin Settings | +| **DeepSeek** | Alternative AI | Admin Settings | +| **Ollama** | Self-hosted AI | Azure VM | +| **GitHub Models** | PC Builder AI | Admin Settings | + +### AI Features +- **Smart Grading**: AI-powered product health scoring +- **Truth Detector**: Marketing claim verification +- **AI Chat**: Conversational assistant +- **Meal Planning**: Diet-based meal suggestions +- **PC Builder**: Hardware recommendations & bottleneck analysis + +--- + +## 🔧 External Services + +| Service | Purpose | Config | +|---------|---------|--------| +| **Firebase** | Auth, Firestore, Storage | `.env.local` | +| **SearXNG** | Real-time price search | Admin Settings | +| **Open Food Facts** | Product database | Auto | +| **Appwrite** | File storage (music) | Admin upload | +| **GitHub Models** | AI inference | Admin Settings | + +--- + +## 🎨 Special Features + +### Seasonal Events +- ❄️ Snow Effect +- 🌧️ Rain Effect +- 🍂 Falling Leaves +- 🎊 Confetti +- 🎄 Christmas Theme + +### Multi-Language Support +- 🇺🇸 English (en) +- 🇫🇷 French (fr) +- 🇪🇸 Spanish (es) +- 🇵🇹 Portuguese (pt) +- 🇲🇦 Moroccan Darija (ar_MA) + +### PWA Features +- Installable as native app +- Offline support (Service Worker) +- Push notifications +- Background sync + +--- + +## 🔐 Security + +- **Authentication**: Firebase Auth (Email, Google, etc.) +- **Authorization**: Role-based (user, admin) +- **API Protection**: JWT Bearer tokens +- **Secure Keys**: Server-side vault (`_system_secrets`) +- **Admin Recovery**: TOTP-based 2FA + +--- + +## 📂 Key Configuration Files + +| File | Purpose | +|------|---------| +| `types/user.ts` | Tier features & config | +| `types/system.ts` | System settings types | +| `types/pcBuilder.ts` | PC Builder types | +| `lib/firebase.ts` | Firebase client config | +| `lib/firebaseAdmin.ts` | Firebase Admin SDK | +| `context/AuthContext.tsx` | Auth state management | +| `next.config.ts` | Next.js configuration | +| `.env.local` | Environment variables | + +--- + +## 🚀 Deployment + +- **Platform**: Vercel +- **Database**: Firebase Firestore +- **AI Server**: Azure VM (Ollama) +- **Search Engine**: Azure VM (SearXNG) +- **Repository**: GitHub (Private) + +--- + +*Last Updated: December 12, 2025* diff --git a/README-PUSH-NOTIFICATIONS.md b/README-PUSH-NOTIFICATIONS.md new file mode 100644 index 0000000..42f9e23 --- /dev/null +++ b/README-PUSH-NOTIFICATIONS.md @@ -0,0 +1,48 @@ +# Web Push Implementation Guide + +## 1. Generate VAPID Keys +To secure your push notifications, you need VAPID keys. Run this command in your terminal: + +```bash +npx web-push generate-vapid-keys +``` + +This will output a **Public Key** and a **Private Key**. + +## 2. Environment Variables +Add the following to your `.env.local` file (create it if it doesn't exist): + +```properties +NEXT_PUBLIC_VAPID_PUBLIC_KEY="" +VAPID_PRIVATE_KEY="" +VAPID_SUBJECT="mailto:support@yourdomain.com" +``` + +## 3. Database Integration (Completed) +- **`app/api/notifications/subscribe/route.ts`**: Handles saving subscriptions to Firestore (`users/{userId}`). +- **`app/api/notifications/send-chat-push/route.ts`**: Handles retrieving subscriptions from Firestore. + +## 4. Frontend Integration +You must add the `` component to your **User Chat Page** or **Layout** so users can enable notifications. + +Example (`app/chat/layout.tsx` or `app/chat/page.tsx`): +```tsx +import PushNotificationSetup from '@/components/PushNotificationSetup'; +// ... inside your component + +``` + +## 4. Testing +1. Reload your application. +2. Click the new **"Enable Chat Notifications"** button (add `` to your chat page). +3. Check the console for "Subscribed!" +4. Test the push API manually (e.g., with Postman): + + **POST** `http://localhost:3000/api/notifications/send-chat-push` + ```json + { + "userId": "current-user-id", + "messageText": "Hello from the server!", + "senderName": "Test Bot" + } + ``` diff --git a/README.md b/README.md index e215bc4..34d093d 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,157 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). -## Getting Started +

+ TruthLens Logo +

-First, run the development server: +

TruthLens 🔍🥗

-```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +

+ The Ultimate AI-Powered Nutrition Assistant +

+ +

+ + Live Demo + +

+ +

+ Next.js + TypeScript + Firebase + AI Powered + License +

+ +--- + +**TruthLens** isn't just a calorie counter. It's an intelligent nutrition companion that helps you "see the truth" behind the food you eat. + +Built as a modern **Progressive Web App (PWA)**, it combines the power of cloud AI (Groq, Gemini) with self-hosted privacy (Ollama, SearXNG) to instantly analyze products, decode ingredients, and generate personalized health quests. + +## 🌟 Key Features + +### 🥗 Smart Food Analysis +* **🔎 Instant Grading**: Our proprietary **Smart Grade** algorithm evaluates products from **A (Excellent)** to **E (Avoid)** based on ingredients, processing, and nutrition. +* **🧪 Ingredient Decoder**: Instantly identifies harmful additives, allergens, and hidden sugars. +* **📷 Scanner v2**: Built on **Native Barcode Detection API** (Android/iOS) for instant, 60fps scanning. Features zero-lag redirection, continuous focus, and offline-first detection. + +### 🤖 "AI Swarm v2" Intelligence +TruthLens uses a unique **AI Swarm** architecture to race multiple AI providers: +* **Self-Hosted Power**: Defaults to your own **Azure VM (Llama 3.2)** for fast, private, and unlimited inference. +* **Cloud Fallback**: Seamlessly switches to **Groq (Llama 3)** or **Gemini 1.5** for complex reasoning. +* **Context-Aware**: The `/ai-chat` remembers your dietary preferences and health goals. + +### 💎 Flexible & Lifetime Access +* **Fair Pricing**: Monthly subscriptions or a unique **Lifetime Deal**. +* **Tiered Features**: + * **Explorer (Free)**: + - Basic AI (Azure Ollama - completely free, no key needed) + - **AI Chat**: Available with **Bring Your Own Key (BYOK)** for Groq/Gemini + - 10 messages/day with your own API key + - Basic product scanner (5 scans/day) + - Limited history (10 items) + * **Plus ($4.99/mo)**: + - Everything in Free + - **Platform AI keys included** (no BYOK needed) + - 20 scans/day + - Global search enabled + * **Pro ($9.99/mo)**: + - Unlimited scans & AI chat + - All premium AI models included + - Visual search & meal planning + - PC Builder access + - Priority support + * **Ultimate ($19.99/mo)**: + - All Pro features + - White label reports + - Beta access to new features + +> **💡 Tip for Free Users**: You can use AI Chat by adding your own free API keys from [Groq](https://console.groq.com/keys) or [Google AI Studio](https://aistudio.google.com/app/apikey). Or upgrade to Plus to get platform keys included! + +### 🛠️ Powerful Admin Dashboard +* **Control Center**: Manage users, subscriptions, and app config in real-time. +* **Dynamic Logic**: Toggle maintenance mode or adjust pricing without redeploying. +* **Infrastructure Config**: Configure your self-hosted AI endpoints directly from the UI. + +## 🏠 Self-Hosted Infrastructure + +TruthLens is designed to run its own AI infrastructure for **zero API costs** and **unlimited usage**. + +| Service | Purpose | Model/Engine | Status | +|---------|---------|--------------|--------| +| **Ollama** | LLM Inference | `tinyllama` (1.1B) | ✅ Integrated | +| **SearXNG** | Web Search | Metasearch | ✅ Integrated | + +* 🔒 **Privacy First**: Your queries stay on your server when using self-hosted models. +* 💰 **Zero Recurring Costs**: No per-token billing. +* 🚀 **Configurable**: Update URLs in `Admin > Settings`. + +## 💻 Developer API Guide + +TruthLens exposes a modular service architecture. Here's how to interact with the core systems: + +### 1. AI Service (`services/aiService.ts`) +The central hub for all intelligence. +```typescript +import { getSwarmResponse } from '@/services/aiService'; + +// Races Ollama, Groq, and Gemini to answer +const response = await getSwarmResponse("Is Red Dye 40 bad for kids?"); +``` + +### 2. Product Search (`app/actions.ts`) +Unified interface for OpenFoodFacts + External Search. +```typescript +import { getProductAction } from '@/app/actions'; + +// Fetches from DB or scrapes web via SearXNG +const product = await getProductAction("barcode_or_name"); ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +### 3. System Config (`services/systemService.ts`) +Dynamic configuration engine backed by Firestore. +```typescript +import { getSystemSettings } from '@/services/systemService'; + +// Get dynamic API keys (including self-hosted URLs) +const settings = await getSystemSettings(); +console.log(settings.apiKeys.ollamaUrl); +``` -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## 🛠️ Getting Started -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +1. **Clone the Repo**: + ```bash + git clone https://github.com/yourusername/truthlens.git + cd truthlens + ``` -## Learn More +2. **Install Dependencies**: + ```bash + npm install + ``` -To learn more about Next.js, take a look at the following resources: +3. **Environment Setup**: + Create `.env.local`: + ```env + NEXT_PUBLIC_FIREBASE_API_KEY=... + NEXT_PUBLIC_GEMINI_API_KEY=... + NEXT_PUBLIC_GROQ_API_KEY=... + ``` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +4. **Run Development Server**: + ```bash + npm run dev + ``` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## 📄 License -## Deploy on Vercel +Copyright © 2024 Youssef Boubli via TRADMSS. All Rights Reserved. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +This software is proprietary and confidential. Unauthorized copying, distribution, or use of this file, via any medium, is strictly prohibited. -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +--- +

+ Empowering healthier choices, one scan at a time. +

diff --git a/actions/ai-actions.ts b/actions/ai-actions.ts new file mode 100644 index 0000000..bd82126 --- /dev/null +++ b/actions/ai-actions.ts @@ -0,0 +1,66 @@ +'use server'; + +import { adminDb } from '@/lib/firebaseAdmin'; +import Groq from 'groq-sdk'; +import { GoogleGenerativeAI } from '@google/generative-ai'; + +// Helper to fetch keys securely from Firestore (Admin-only collection) +async function getSecureKeys() { + try { + if (!adminDb) { + console.warn('Admin SDK not initialized. Using env fallback.'); + return { + groq: process.env.GROQ_API_KEY, + gemini: process.env.GEMINI_API_KEY + }; + } + + const doc = await adminDb.collection('_system_secrets').doc('ai_config').get(); + if (!doc.exists) { + console.warn('System secrets not found. Using fallback env vars.'); + return { + groq: process.env.GROQ_API_KEY, + gemini: process.env.GEMINI_API_KEY + }; + } + const data = doc.data(); + return { + groq: data?.groq || process.env.GROQ_API_KEY, + gemini: data?.gemini || process.env.GEMINI_API_KEY + }; + } catch (error) { + console.error('Failed to fetch secure keys:', error); + return { groq: process.env.GROQ_API_KEY, gemini: process.env.GEMINI_API_KEY }; + } +} + +export async function generateAIResponse(prompt: string, provider: 'groq' | 'gemini' = 'groq') { + const keys = await getSecureKeys(); + + try { + if (provider === 'groq') { + if (!keys.groq) throw new Error('Groq API Key missing in _system_secrets'); + const groq = new Groq({ apiKey: keys.groq }); + + const completion = await groq.chat.completions.create({ + messages: [{ role: 'user', content: prompt }], + model: 'llama-3.3-70b-versatile', + temperature: 0.7, + }); + return completion.choices[0]?.message?.content || 'No response'; + } + else if (provider === 'gemini') { + if (!keys.gemini) throw new Error('Gemini API Key missing in _system_secrets'); + const genAI = new GoogleGenerativeAI(keys.gemini); + const model = genAI.getGenerativeModel({ model: 'gemini-1.5-flash' }); + + const result = await model.generateContent(prompt); + return result.response.text(); + } + } catch (error: unknown) { + console.error('AI Generation Error:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return `Error: ${message}`; + } + return ''; +} diff --git a/app/(admin)/admin/access-codes/page.tsx b/app/(admin)/admin/access-codes/page.tsx new file mode 100644 index 0000000..58d1f9e --- /dev/null +++ b/app/(admin)/admin/access-codes/page.tsx @@ -0,0 +1,357 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, Typography, Paper, TextField, Button, Table, TableBody, + TableCell, TableContainer, TableHead, TableRow, Chip, IconButton, + Dialog, DialogTitle, DialogContent, DialogActions, FormControl, + InputLabel, Select, MenuItem, Switch, CircularProgress, Alert +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import DeleteIcon from '@mui/icons-material/Delete'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { useAuth } from '@/context/AuthContext'; +import { useRouter } from 'next/navigation'; +import { createAccessCode, getAllAccessCodes, toggleAccessCodeStatus, deleteAccessCode } from '@/services/accessRequestService'; +import { AccessCode, AccessCodeTier } from '@/types/accessRequest'; + +export default function AdminAccessCodesPage() { + const { userProfile, loading: authLoading } = useAuth(); + const router = useRouter(); + + const [codes, setCodes] = useState([]); + const [loading, setLoading] = useState(true); + const [openDialog, setOpenDialog] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + // New code form + const [newCode, setNewCode] = useState(''); + const [newTier, setNewTier] = useState('plus'); + const [newType, setNewType] = useState<'student' | 'general'>('general'); + const [newLimit, setNewLimit] = useState(100); + const [newExpiry, setNewExpiry] = useState(''); + const [creating, setCreating] = useState(false); + + // Delete dialog state + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [codeToDelete, setCodeToDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + + + // Auth check + useEffect(() => { + if (!authLoading && userProfile?.role !== 'admin') { + router.push('/'); + } + }, [authLoading, userProfile, router]); + + // Load codes + const loadCodes = useCallback(async () => { + setLoading(true); + try { + const data = await getAllAccessCodes(); + setCodes(data); + } catch (err) { + console.error('Error loading codes:', err); + } + setLoading(false); + }, []); + + useEffect(() => { + if (userProfile?.role === 'admin') { + loadCodes(); + } + }, [userProfile, loadCodes]); + + const generateRandomCode = () => { + const prefix = newType === 'student' ? 'TL-STU' : 'TL-ACC'; + const random = Math.random().toString(36).substring(2, 8).toUpperCase(); + setNewCode(`${prefix}-${random}`); + }; + + const handleCreateCode = async () => { + if (!newCode.trim()) { + setError('Code is required'); + return; + } + + setCreating(true); + setError(''); + + try { + await createAccessCode( + newCode, + newTier, + newType, + newLimit, + newExpiry ? new Date(newExpiry) : null, + userProfile?.uid || '' + ); + setSuccess('Code created successfully!'); + setOpenDialog(false); + setNewCode(''); + loadCodes(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to create code'; + setError(message); + } + + setCreating(false); + }; + + const handleToggleActive = async (codeId: string, currentActive: boolean) => { + try { + await toggleAccessCodeStatus(codeId, !currentActive); + loadCodes(); + } catch (err) { + console.error('Error toggling code:', err); + } + }; + + const openDeleteDialog = (code: AccessCode) => { + setCodeToDelete(code); + setDeleteDialogOpen(true); + }; + + const handleDelete = async () => { + if (!codeToDelete) return; + + setDeleting(true); + setError(''); + + try { + await deleteAccessCode(codeToDelete.id); + setSuccess(`Access code "${codeToDelete.code}" deleted successfully!`); + setDeleteDialogOpen(false); + setCodeToDelete(null); + loadCodes(); + setTimeout(() => setSuccess(''), 3000); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete access code'; + setError(message); + console.error('Error deleting code:', err); + } + + setDeleting(false); + }; + + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + setSuccess('Code copied to clipboard!'); + setTimeout(() => setSuccess(''), 2000); + }; + + const getTierColor = (tier: string) => { + switch (tier) { + case 'plus': return 'primary'; + case 'pro': return 'secondary'; + case 'ultimate': return 'warning'; + default: return 'default'; + } + }; + + if (authLoading || loading) { + return ( + + + + ); + } + + return ( + + + + 🔑 Access Codes + + + + + + + + {success && {success}} + + + + + + Code + Tier + Type + Usage + Expires + Active + Actions + + + + {codes.map((code) => ( + + + + + {code.code} + + copyToClipboard(code.code)}> + + + + + + + + + + + + {code.usedCount} / {code.usageLimit === 0 ? '∞' : code.usageLimit} + + + {code.expiresAt + ? new Date(code.expiresAt).toLocaleDateString() + : 'Never'} + + + handleToggleActive(code.id, code.active)} + color="success" + /> + + + openDeleteDialog(code)} + > + + + + + ))} + {codes.length === 0 && ( + + + No access codes yet + + + )} + +
+
+ + {/* Create Code Dialog */} + setOpenDialog(false)} maxWidth="sm" fullWidth> + Generate Access Code + + {error && {error}} + + + setNewCode(e.target.value.toUpperCase())} + placeholder="TL-ACC-XXXXXX" + /> + + + + + Tier to Grant + + + + + Code Type + + + + setNewLimit(parseInt(e.target.value) || 0)} + sx={{ mb: 2 }} + /> + + setNewExpiry(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + + + + + + {/* Delete Confirmation Dialog */} + !deleting && setDeleteDialogOpen(false)} + maxWidth="xs" + fullWidth + > + + + + Delete Access Code? + + + + {error && {error}} + + Are you sure you want to permanently delete the access code{' '} + "{codeToDelete?.code}"? + + + This action cannot be undone. The code will be removed from Firebase and users will no longer be able to use it. + + + + + + + +
+ ); +} diff --git a/app/(admin)/admin/access-requests/page.tsx b/app/(admin)/admin/access-requests/page.tsx new file mode 100644 index 0000000..42c93f7 --- /dev/null +++ b/app/(admin)/admin/access-requests/page.tsx @@ -0,0 +1,331 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, Typography, Paper, Tabs, Tab, Table, TableBody, TableCell, + TableContainer, TableHead, TableRow, Chip, IconButton, Button, + Dialog, DialogTitle, DialogContent, DialogActions, TextField, + CircularProgress, Alert, Avatar +} from '@mui/material'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import CancelIcon from '@mui/icons-material/Cancel'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { useAuth } from '@/context/AuthContext'; +import { useRouter } from 'next/navigation'; +import { getAllRequests, approveRequest, denyRequest } from '@/services/accessRequestService'; +import { FreeAccessRequest } from '@/types/accessRequest'; + +export default function AdminAccessRequestsPage() { + const { userProfile, loading: authLoading } = useAuth(); + const router = useRouter(); + + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState(0); + const [success, setSuccess] = useState(''); + const [error, setError] = useState(''); + + // Deny dialog + const [denyDialogOpen, setDenyDialogOpen] = useState(false); + const [denyReason, setDenyReason] = useState(''); + const [selectedRequest, setSelectedRequest] = useState(null); + const [processing, setProcessing] = useState(false); + + // Detail dialog + const [detailDialogOpen, setDetailDialogOpen] = useState(false); + + // Auth check + useEffect(() => { + if (!authLoading && userProfile?.role !== 'admin') { + router.push('/'); + } + }, [authLoading, userProfile, router]); + + // Load requests + const loadRequests = useCallback(async () => { + setLoading(true); + try { + const data = await getAllRequests(); + setRequests(data); + } catch (error) { + console.error('Error loading requests:', error); + } + setLoading(false); + }, []); + + useEffect(() => { + if (userProfile?.role === 'admin') { + loadRequests(); + } + }, [userProfile, loadRequests]); + + const filteredRequests = requests.filter(r => { + if (tab === 0) return r.status === 'pending'; + if (tab === 1) return r.status === 'approved'; + return r.status === 'denied'; + }); + + const handleApprove = async (request: FreeAccessRequest) => { + setProcessing(true); + try { + const success = await approveRequest(request.id, userProfile?.uid || ''); + if (success) { + setSuccess(`Approved request for ${request.fullName}`); + loadRequests(); + } + } catch (error) { + console.error('Error approving request:', error); + setError('Failed to approve request'); + } + setProcessing(false); + }; + + const handleDenyClick = (request: FreeAccessRequest) => { + setSelectedRequest(request); + setDenyReason(''); + setDenyDialogOpen(true); + }; + + const handleDenyConfirm = async () => { + if (!selectedRequest || !denyReason.trim()) return; + + setProcessing(true); + try { + const success = await denyRequest(selectedRequest.id, userProfile?.uid || '', denyReason); + if (success) { + setSuccess(`Denied request for ${selectedRequest.fullName}`); + setDenyDialogOpen(false); + loadRequests(); + } + } catch (error) { + console.error('Error denying request:', error); + setError('Failed to deny request'); + } + setProcessing(false); + }; + + const handleViewDetails = (request: FreeAccessRequest) => { + setSelectedRequest(request); + setDetailDialogOpen(true); + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'pending': return 'warning'; + case 'approved': return 'success'; + case 'denied': return 'error'; + default: return 'default'; + } + }; + + const getTierColor = (tier: string) => { + switch (tier) { + case 'plus': return 'primary'; + case 'pro': return 'secondary'; + case 'ultimate': return 'warning'; + default: return 'default'; + } + }; + + if (authLoading || loading) { + return ( + + + + ); + } + + return ( + + + + 📋 Access Requests + + + + + {success && setSuccess('')}>{success}} + {error && setError('')}>{error}} + + + setTab(v)}> + r.status === 'pending').length})`} /> + r.status === 'approved').length})`} /> + r.status === 'denied').length})`} /> + + + + + + + + User + Email + Phone + Tier + Student + Date + Status + Actions + + + + {filteredRequests.map((req) => ( + + + + {req.fullName[0]} + + {req.fullName} + @{req.username} + + + + {req.email} + {req.phone} + + + + + {req.isStudent ? ( + + ) : '-'} + + + {new Date(req.createdAt).toLocaleDateString()} + + + + + + handleViewDetails(req)}> + + + {req.status === 'pending' && ( + <> + handleApprove(req)} + disabled={processing} + > + + + handleDenyClick(req)} + > + + + + )} + + + ))} + {filteredRequests.length === 0 && ( + + + No requests in this category + + + )} + +
+
+ + {/* Deny Dialog */} + setDenyDialogOpen(false)} maxWidth="sm" fullWidth> + Deny Request + + + You are denying the request from {selectedRequest?.fullName}. + + setDenyReason(e.target.value)} + placeholder="This reason will be shown to the user" + required + /> + + + + + + + + {/* Detail Dialog */} + setDetailDialogOpen(false)} maxWidth="sm" fullWidth> + Request Details + + {selectedRequest && ( + + + Full Name + {selectedRequest.fullName} + + + Username + @{selectedRequest.username} + + + Email + {selectedRequest.email} + + + Phone + {selectedRequest.phone} + + + Reason + + {selectedRequest.reason} + + + + Code Used + {selectedRequest.codeUsed} + + {selectedRequest.isStudent && selectedRequest.studentProofUrl && ( + + Student Proof + + + )} + {selectedRequest.status === 'denied' && selectedRequest.denialReason && ( + + Denial Reason: + {selectedRequest.denialReason} + + )} + + )} + + + + + +
+ ); +} diff --git a/app/(admin)/admin/ai-models/page.tsx b/app/(admin)/admin/ai-models/page.tsx new file mode 100644 index 0000000..cdc892e --- /dev/null +++ b/app/(admin)/admin/ai-models/page.tsx @@ -0,0 +1,419 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { + Box, + Typography, + Paper, + Grid, + Chip, + Button, + Alert, + CircularProgress, + List, + ListItem, + ListItemIcon, + ListItemText, + ListItemSecondaryAction, + Switch, + Tooltip, + IconButton, + Card, + CardContent, + Divider, + Snackbar +} from '@mui/material'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import WarningIcon from '@mui/icons-material/Warning'; +import SmartToyIcon from '@mui/icons-material/SmartToy'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import InfoIcon from '@mui/icons-material/Info'; +import CloudIcon from '@mui/icons-material/Cloud'; +import { useAuth } from '@/context/AuthContext'; +import { getSystemSettings, updateSystemSettings } from '@/services/systemService'; +import axios from 'axios'; + +interface Model { + name: string; + displayName: string; + size: string; + purpose: string; + enabled: boolean; + status: 'active' | 'inactive' | 'testing'; +} + +export default function AIModelsPage() { + const { user } = useAuth(); + const [loading, setLoading] = useState(true); + const [serverStatus, setServerStatus] = useState<'online' | 'offline' | 'checking'>('checking'); + const [models, setModels] = useState([]); + const [serverUrl, setServerUrl] = useState('http://localhost:11435'); + const [testingModel, setTestingModel] = useState(null); + const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' as 'success' | 'error' | 'info' }); + + useEffect(() => { + fetchModels(); + }, []); + + const fetchModels = async () => { + setLoading(true); + setServerStatus('checking'); + + try { + // Get settings from Firebase + const settings = await getSystemSettings(); + const ollamaUrl = settings.apiKeys?.ollamaUrl || 'http://localhost:11435'; + setServerUrl(ollamaUrl); + + // Fetch models from Ollama server + const token = await user?.getIdToken(); + console.log('[AI Models] Fetching from:', ollamaUrl, 'Auth:', !!token); + + const response = await axios.get('/api/admin/ollama/models', { + params: { url: ollamaUrl }, + headers: { Authorization: `Bearer ${token}` }, + timeout: 30000 // Increased timeout to 30s for slow connections + }); + + console.log('[AI Models] Response:', response.data); + const modelsList = response.data.models || []; + const enabledModels = settings.apiKeys?.ollamaModels || {}; + + // Map models with display info + const mappedModels: Model[] = modelsList.map((m: any) => ({ + name: m.name, + displayName: getDisplayName(m.name), + size: formatSize(m.size), + purpose: getPurpose(m.name), + enabled: enabledModels[m.name] !== false, // Default to enabled + status: 'active' as const + })); + + setModels(mappedModels); + setServerStatus('online'); + setSnackbar({ + open: true, + message: `✅ Connected! Found ${mappedModels.length} models`, + severity: 'success' + }); + + } catch (error: any) { + console.error('[AI Models] Error:', error); + const errorMsg = error.response?.data?.error || error.message || 'Connection failed'; + setServerStatus('offline'); + setSnackbar({ + open: true, + message: `❌ ${errorMsg}`, + severity: 'error' + }); + } finally { + setLoading(false); + } + }; + + const getDisplayName = (modelName: string): string => { + const nameMap: Record = { + 'llama3.1:8b': 'Llama 3.1 (8B)', + 'mistral:7b': 'Mistral (7B)', + 'gemma:2b': 'Gemma (2B)', + 'phi': 'Phi', + 'phi:latest': 'Phi', + 'qwen:1.8b': 'Qwen (1.8B)', + 'stablelm2:1.6b': 'StableLM2 (1.6B)', + 'tinyllama': 'TinyLlama', + 'deepseek-coder:6.7b': 'DeepSeek Coder', + 'deepseek-llm:7b': 'DeepSeek LLM' + }; + return nameMap[modelName] || modelName; + }; + + const getPurpose = (modelName: string): string => { + if (modelName.includes('llama3')) return 'Advanced tasks & reasoning'; + if (modelName.includes('mistral')) return 'General purpose & analysis'; + if (modelName.includes('gemma')) return 'Fast general tasks'; + if (modelName.includes('phi')) return 'Product analysis & reasoning'; + if (modelName.includes('qwen')) return 'Multilingual (Arabic/French)'; + if (modelName.includes('stablelm')) return 'Natural conversation'; + if (modelName.includes('tinyllama')) return 'Quick responses'; + if (modelName.includes('deepseek-coder')) return 'Code generation'; + if (modelName.includes('deepseek-llm')) return 'Advanced reasoning'; + return 'General AI tasks'; + }; + + const formatSize = (bytes: number): string => { + if (!bytes) return 'Unknown'; + const gb = bytes / (1024 * 1024 * 1024); + return `${gb.toFixed(1)}GB`; + }; + + const handleToggleModel = async (modelName: string, enabled: boolean) => { + try { + // Update local state immediately + setModels(prev => prev.map(m => + m.name === modelName ? { ...m, enabled } : m + )); + + // Save to Firebase + const settings = await getSystemSettings(); + const updatedModels = { + ...settings.apiKeys?.ollamaModels, + [modelName]: enabled + }; + + await updateSystemSettings({ + apiKeys: { + ...settings.apiKeys, + ollamaModels: updatedModels + } + }); + + setSnackbar({ + open: true, + message: `${getDisplayName(modelName)} ${enabled ? 'enabled' : 'disabled'}`, + severity: 'success' + }); + } catch (error) { + console.error('Failed to update model:', error); + // Revert local state + setModels(prev => prev.map(m => + m.name === modelName ? { ...m, enabled: !enabled } : m + )); + setSnackbar({ open: true, message: 'Failed to update model', severity: 'error' }); + } + }; + + const handleTestModel = async (modelName: string) => { + setTestingModel(modelName); + try { + const token = await user?.getIdToken(); + const response = await axios.post( + '/api/admin/ollama/test', + { + url: serverUrl, + model: modelName, + prompt: 'Hello! Please respond with a single word: OK' + }, + { headers: { Authorization: `Bearer ${token}` } } + ); + + if (response.data.success) { + setSnackbar({ + open: true, + message: `✅ ${getDisplayName(modelName)} working perfectly!`, + severity: 'success' + }); + } else { + throw new Error('Test failed'); + } + } catch (error) { + console.error('Test failed:', error); + setSnackbar({ + open: true, + message: `❌ ${getDisplayName(modelName)} test failed`, + severity: 'error' + }); + } finally { + setTestingModel(null); + } + }; + + if (loading) { + return ( + + + + ); + } + + return ( + + {/* Header */} + + + 🤖 AI Models + + + Manage your self-hosted AI models running on Azure VM + + + + {/* Server Status Card */} + + + + + + + + AI Server Status + + + {serverUrl} + + + + + {serverStatus === 'online' ? ( + <> + } + label="ONLINE" + sx={{ bgcolor: 'rgba(255,255,255,0.3)', color: 'white', fontWeight: 'bold' }} + /> + + {models.length} models ready + + + ) : ( + } + label="OFFLINE" + sx={{ bgcolor: 'rgba(255,255,255,0.3)', color: 'white', fontWeight: 'bold' }} + /> + )} + + + + + + {/* Models List */} + + + + Available Models + + + + + {models.length === 0 ? ( + }> + No models found. Please check your server connection. + + ) : ( + + {models.map((model, index) => ( + + + + + + {model.enabled && ( + + )} + + + + + {model.displayName} + + + + } + secondary={ + + + {model.purpose} + + + Model ID: {model.name} + + + } + /> + + + + handleToggleModel(model.name, e.target.checked)} + color="success" + /> + + + + handleTestModel(model.name)} + disabled={!model.enabled || testingModel === model.name} + > + {testingModel === model.name ? ( + + ) : ( + + )} + + + + + + + {index < models.length - 1 && } + + ))} + + )} + + + {/* Info Section */} + } sx={{ mt: 3 }}> + + 💡 How it works: + + + • Enable/Disable: Toggle models on or off. Disabled models won't be used by the app. +
+ • Test: Send a quick test to verify the model is working correctly. +
+ • Auto-Save: All changes are instantly saved to Firebase. +
+
+ + {/* Snackbar for notifications */} + setSnackbar({ ...snackbar, open: false })} + > + + {snackbar.message} + + + + ); +} diff --git a/app/(admin)/admin/cancellations/page.tsx b/app/(admin)/admin/cancellations/page.tsx new file mode 100644 index 0000000..7197e83 --- /dev/null +++ b/app/(admin)/admin/cancellations/page.tsx @@ -0,0 +1,194 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { + Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, + Chip, IconButton, Tooltip, CircularProgress, Button +} from '@mui/material'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import CancelIcon from '@mui/icons-material/Cancel'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { getCancellationRequests, updateUserTier } from '@/services/subscriptionService'; +import { doc, updateDoc, deleteDoc } from 'firebase/firestore'; +import { db } from '@/lib/firebase'; + +interface CancellationRequest { + id: string; + userId: string; + email: string; + reason: string; + status: 'pending' | 'approved' | 'rejected' | 'completed'; + createdAt: Date | { toDate: () => Date }; +} + +export default function CancellationRequestsPage() { + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + + useEffect(() => { + fetchRequests(); + }, []); + + const fetchRequests = async () => { + setLoading(true); + const data = await getCancellationRequests(); + setRequests(data as CancellationRequest[]); + setLoading(false); + }; + + const handleApprove = async (request: CancellationRequest) => { + if (!confirm('Are you sure you want to approve this cancellation? This will downgrade the user to FREE tier immediately.')) return; + + setActionLoading(request.id); + try { + // 1. Update User Tier to Free + await updateUserTier(request.userId, 'free'); + + // 2. Update Request Status + const requestRef = doc(db, 'cancellationRequests', request.id); + await updateDoc(requestRef, { status: 'completed' }); + + // 3. Refresh List + await fetchRequests(); + } catch (error) { + console.error('Error approving cancellation:', error); + alert('Failed to approve cancellation.'); + } finally { + setActionLoading(null); + } + }; + + const handleReject = async (request: CancellationRequest) => { + if (!confirm('Are you sure you want to reject this cancellation?')) return; + + setActionLoading(request.id); + try { + const requestRef = doc(db, 'cancellationRequests', request.id); + await updateDoc(requestRef, { status: 'rejected' }); + await fetchRequests(); + } catch (error) { + console.error('Error rejecting cancellation:', error); + alert('Failed to reject cancellation.'); + } finally { + setActionLoading(null); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('Are you sure you want to delete this record?')) return; + + try { + await deleteDoc(doc(db, 'cancellationRequests', id)); + setRequests(prev => prev.filter(r => r.id !== id)); + } catch (error) { + console.error('Error deleting request:', error); + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'pending': return 'warning'; + case 'approved': + case 'completed': return 'success'; + case 'rejected': return 'error'; + default: return 'default'; + } + }; + + if (loading) { + return ( + + + + ); + } + + return ( + + + Membership Cancellations + + + + + + + + User Email + Reason + Date + Status + Actions + + + + {requests.length === 0 ? ( + + + No cancellation requests found. + + + ) : ( + requests.map((request) => ( + + {request.email} + + + {request.reason} + + + + {request.createdAt instanceof Date + ? request.createdAt.toLocaleDateString() + : 'toDate' in request.createdAt + ? request.createdAt.toDate().toLocaleDateString() + : 'N/A'} + + + + + + {request.status === 'pending' && ( + + + handleReject(request)} + disabled={actionLoading === request.id} + > + + + + )} + {request.status !== 'pending' && ( + handleDelete(request.id)} color="error" size="small"> + + + )} + + + )) + )} + +
+
+
+
+ ); +} diff --git a/app/(admin)/admin/chat/page.tsx b/app/(admin)/admin/chat/page.tsx new file mode 100644 index 0000000..7d2f087 --- /dev/null +++ b/app/(admin)/admin/chat/page.tsx @@ -0,0 +1,230 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { listenForAdminChatList, clearAllChats, deleteChat } from '@/services/supportService'; +import AdminChatWindow from '@/components/admin/AdminChatWindow'; +import UserAvatarWithStatus from '@/components/admin/UserAvatarWithStatus'; +import { useAuth } from '@/context/AuthContext'; +import { Chat } from '@/types/chat'; +import { Trash2, Search, MessageSquare } from 'lucide-react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Box, useTheme, alpha } from '@mui/material'; + +export default function AdminChatPage() { + const { user } = useAuth(); + const theme = useTheme(); // MUST be called before any conditional returns + const [chats, setChats] = useState([]); + const [selectedChatId, setSelectedChatId] = useState(null); + const [isClearing, setIsClearing] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + useEffect(() => { + const unsubscribe = listenForAdminChatList((newChats) => { + setChats(newChats); + }); + return () => unsubscribe(); + }, []); + + const handleDeleteChat = async (chatId: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (!confirm("Are you sure you want to delete this conversation?")) return; + + try { + if (selectedChatId === chatId) setSelectedChatId(null); + await deleteChat(chatId); + } catch (error) { + console.error("Failed to delete chat:", error); + alert("Failed to delete chat."); + } + }; + + const handleClearAll = async () => { + if (!confirm("Are you sure you want to DELETE ALL chats and messages? This cannot be undone.")) return; + + setIsClearing(true); + try { + await clearAllChats(); + setSelectedChatId(null); + } catch (error) { + console.error("Failed to clear chats:", error); + alert("Failed to clear chats."); + } finally { + setIsClearing(false); + } + }; + + const selectedChat = chats.find(c => c.id === selectedChatId); + + if (!user) return null; + + // View A: The Chat List + const ChatListView = ( + + {/* Header - Search Only (Title Removed) */} + +
+ + setSearchQuery(e.target.value)} + className="w-full bg-transparent border border-gray-200 dark:border-gray-700 rounded-xl py-2 pl-10 pr-4 focus:outline-none focus:border-blue-500 transition-all font-medium text-sm" + style={{ color: theme.palette.text.primary }} + /> +
+
+ Recent Chats + +
+
+ + {/* List */} +
+ {chats.filter(c => + (c.userName?.toLowerCase().includes(searchQuery.toLowerCase())) || + (c.userEmail?.toLowerCase().includes(searchQuery.toLowerCase())) || + c.id.includes(searchQuery) + ).length === 0 ? ( +
+

No conversations found

+
+ ) : ( + chats + .filter(c => + (c.userName?.toLowerCase().includes(searchQuery.toLowerCase())) || + (c.userEmail?.toLowerCase().includes(searchQuery.toLowerCase())) || + c.id.includes(searchQuery) + ) + .map(chat => ( + setSelectedChatId(chat.id)} + onKeyDown={(e) => e.key === 'Enter' && setSelectedChatId(chat.id)} + className={`w-full text-left p-3 rounded-xl transition-all flex items-center gap-3 group cursor-pointer relative`} + style={{ + backgroundColor: selectedChatId === chat.id ? alpha(theme.palette.primary.main, 0.1) : 'transparent', + }} + > + + +
+
+ + {chat.userName || chat.userEmail || "Anonymous User"} + + + {chat.updatedAt?.toDate?.().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+

+ {chat.lastMessage?.text || "Started a new conversation"} +

+
+ + {/* Delete Hover Action */} + +
+ )) + )} +
+
+ ); + + return ( + // Full screen negative margin container to override AdminLayout padding + + {/* Desktop: Sidebar (List) */} + + {ChatListView} + + + {/* Desktop & Mobile: Main Content (Conversation) */} +
+ + {selectedChatId ? ( + + setSelectedChatId(null)} + /> + + ) : ( +
+
+
+ +
+
+

Select a Conversation

+

+ Choose a chat from the sidebar to start messaging your users. +

+
+ )} +
+
+
+ ); +} + diff --git a/app/(admin)/admin/events/page.tsx b/app/(admin)/admin/events/page.tsx new file mode 100644 index 0000000..bffce59 --- /dev/null +++ b/app/(admin)/admin/events/page.tsx @@ -0,0 +1,103 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Box, Typography, CircularProgress, Snackbar, Alert, Paper } from '@mui/material'; +import EventIcon from '@mui/icons-material/Event'; +import { getSystemSettings, updateSystemSettings } from '@/services/systemService'; +import { useAuth } from '@/context/AuthContext'; +import EventSettingsTab from '@/components/admin/settings/EventSettingsTab'; +import { EventManagerConfig, ExtendedSystemSettings } from '@/types/system'; + +export default function AdminEventsPage() { + const { userProfile, loading: authLoading } = useAuth(); + const [loading, setLoading] = useState(true); + const [msg, setMsg] = useState({ type: 'success', text: '' }); + const [eventSchedule, setEventSchedule] = useState([]); + const [saving, setSaving] = useState(false); + const [settings, setSettings] = useState({} as ExtendedSystemSettings); + + useEffect(() => { + if (!authLoading) { + fetchSettings(); + } + }, [authLoading]); + + const fetchSettings = async () => { + try { + setLoading(true); + const data = await getSystemSettings(); + if (data) { + setSettings(data); + // Migrate legacy single event to schedule array if needed + let schedule = data.eventSchedule || []; + if (schedule.length === 0 && data.eventManager) { + schedule = [data.eventManager]; + } + setEventSchedule(schedule); + } + } catch (error) { + console.error('Failed to fetch settings:', error); + setMsg({ type: 'error', text: 'Failed to load event settings' }); + } finally { + setLoading(false); + } + }; + + const handleUpdateSettings = async (newSettings: ExtendedSystemSettings) => { + setSaving(true); + try { + await updateSystemSettings(newSettings); + setEventSchedule(newSettings.eventSchedule || []); + setSettings(newSettings); // Update local full state + setMsg({ type: 'success', text: 'Events updated successfully!' }); + } catch (error) { + console.error('Failed to update events:', error); + setMsg({ type: 'error', text: 'Failed to save changes' }); + } finally { + setSaving(false); + } + }; + + if (authLoading || loading) { + return ( + + + + ); + } + + if (userProfile?.role !== 'admin') { + return Access Denied; + } + + return ( + + + + + + Event Management + + + + + + + + setMsg({ type: 'success', text: '' })} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + > + setMsg({ type: 'success', text: '' })}> + {msg.text} + + + + ); +} diff --git a/app/(admin)/admin/layout.tsx b/app/(admin)/admin/layout.tsx new file mode 100644 index 0000000..36d1b09 --- /dev/null +++ b/app/(admin)/admin/layout.tsx @@ -0,0 +1,170 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Box, AppBar, Toolbar, Typography, IconButton, useTheme, useMediaQuery, alpha } from '@mui/material'; +import { usePathname } from 'next/navigation'; +import { AnimatePresence } from 'framer-motion'; +import PageTransition from '@/components/animation/PageTransition'; +import AdminSidebar from '@/components/admin/AdminSidebar'; +import Footer from '@/components/layout/Footer'; +import MenuIcon from '@mui/icons-material/MenuRounded'; +import CloseIcon from '@mui/icons-material/CloseRounded'; +import NotificationsIcon from '@mui/icons-material/NotificationsNoneRounded'; + +import { Badge } from '@mui/material'; +import { listenForAdminUnreadCount } from '@/services/supportService'; + +import AdminNotificationsMenu from '@/components/admin/AdminNotificationsMenu'; +import { getPendingRequests } from '@/services/accessRequestService'; + +const SIDEBAR_WIDTH = 280; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const [mobileOpen, setMobileOpen] = useState(false); + const [desktopOpen, setDesktopOpen] = useState(true); + const [unreadCount, setUnreadCount] = useState(0); + const [notificationAnchor, setNotificationAnchor] = useState(null); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + useEffect(() => { + // Listen for chat messages + const unsubscribe = listenForAdminUnreadCount((count) => { + // Also fetch pending requests count to update total badge + getPendingRequests().then(requests => { + setUnreadCount(count + requests.length); + }).catch(() => { + setUnreadCount(count); + }); + }); + return () => unsubscribe(); + }, []); + + const handleDrawerToggle = () => { + if (isMobile) { + setMobileOpen(!mobileOpen); + } else { + setDesktopOpen(!desktopOpen); + } + }; + + const handleNotificationClick = (event: React.MouseEvent) => { + setNotificationAnchor(event.currentTarget); + }; + + const handleNotificationClose = () => { + setNotificationAnchor(null); + }; + + const getPageTitle = (path: string) => { + const parts = path.split('/').filter(Boolean); + if (parts.length === 1 && parts[0] === 'admin') return 'Dashboard'; + const lastPart = parts[parts.length - 1]; + return lastPart.charAt(0).toUpperCase() + lastPart.slice(1); + }; + + return ( + + {/* Sidebar Navigation */} + + + {/* Main Content Area */} + + {/* Glassmorphic AppBar */} + + + {/* Hamburger Menu (Visible on both now) */} + + {(isMobile ? mobileOpen : desktopOpen) ? : } + + + + {getPageTitle(pathname)} + + + {/* Right Side Actions */} + + + + + + + + + + + {/* Page Content Container */} + + + + {children} + + + + + {/* Footer */} + +