Spaces:
Runtime error
Runtime error
Commit ·
7e49c4d
1
Parent(s): cb2e99e
refactor: restructure project as a monorepo and implement backend API
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .editorconfig +15 -0
- .env.example +6 -0
- .gitignore +47 -47
- .prettierrc.json +7 -0
- App.js +0 -142
- CONTRIBUTING.md +77 -0
- LICENSE +21 -0
- Mobile/carbon_calculator/code.html +0 -357
- Mobile/challenges_gamification/code.html +0 -381
- Mobile/landing_page/code.html +0 -318
- README.md +360 -76
- SECURITY.md +52 -0
- Web-design/carbon_calculator_desktop/code.html +0 -424
- Web-design/challenges_gamification_desktop/code.html +0 -436
- Web-design/landing_page_desktop/code.html +0 -348
- app.json +0 -24
- backend/.env.example +18 -0
- backend/.eslintrc.json +26 -0
- backend/.gitignore +0 -6
- backend/Dockerfile +0 -24
- backend/README.md +0 -17
- backend/__tests__/routes.test.js +0 -153
- backend/firebase.js +0 -310
- backend/package-lock.json +0 -0
- backend/package.json +33 -15
- backend/prisma/migrations/20260616130754_init/migration.sql +89 -0
- backend/prisma/migrations/migration_lock.toml +3 -0
- backend/prisma/schema.prisma +85 -0
- backend/prisma/seed.js +166 -0
- backend/requirements.txt +0 -10
- backend/routes.js +0 -1008
- backend/server.js +0 -90
- backend/src/app.js +119 -0
- backend/src/controllers/assessmentController.js +196 -0
- backend/src/controllers/recommendationController.js +33 -0
- backend/src/controllers/simulationController.js +93 -0
- backend/src/controllers/userController.js +125 -0
- backend/src/index.js +39 -0
- backend/src/middleware/errorHandler.js +87 -0
- backend/src/middleware/rateLimiter.js +43 -0
- backend/src/middleware/validate.js +45 -0
- backend/src/routes/ai.js +78 -0
- backend/src/routes/assessments.js +42 -0
- backend/src/routes/recommendations.js +13 -0
- backend/src/routes/simulations.js +20 -0
- backend/src/routes/users.js +28 -0
- backend/src/services/aiService.js +291 -0
- backend/src/services/assessmentService.js +176 -0
- backend/src/services/carbonCalculator.js +121 -0
- backend/src/services/recommendationEngine.js +294 -0
.editorconfig
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
root = true
|
| 2 |
+
|
| 3 |
+
[*]
|
| 4 |
+
indent_style = space
|
| 5 |
+
indent_size = 2
|
| 6 |
+
end_of_line = lf
|
| 7 |
+
charset = utf-8
|
| 8 |
+
trim_trailing_whitespace = true
|
| 9 |
+
insert_final_newline = true
|
| 10 |
+
|
| 11 |
+
[*.md]
|
| 12 |
+
trim_trailing_whitespace = false
|
| 13 |
+
|
| 14 |
+
[*.json]
|
| 15 |
+
indent_size = 2
|
.env.example
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Database
|
| 2 |
+
DB_USER=ecoguide
|
| 3 |
+
DB_PASSWORD=ecoguide_secret
|
| 4 |
+
DB_NAME=ecoguide_db
|
| 5 |
+
DB_HOST=localhost
|
| 6 |
+
DB_PORT=5432
|
.gitignore
CHANGED
|
@@ -1,50 +1,50 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
.
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
.
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
*.mobileprovision
|
| 20 |
-
|
| 21 |
-
# Metro
|
| 22 |
-
.metro-health-check*
|
| 23 |
-
|
| 24 |
-
# debug
|
| 25 |
-
npm-debug.*
|
| 26 |
-
yarn-debug.*
|
| 27 |
-
yarn-error.*
|
| 28 |
-
|
| 29 |
-
# macOS
|
| 30 |
.DS_Store
|
| 31 |
-
*.
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
backend/firebase-key.json
|
| 46 |
-
backend/database.json
|
| 47 |
-
*adminsdk*.json
|
| 48 |
-
*.json.key
|
| 49 |
-
*.key.json
|
| 50 |
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
| 25 |
+
|
| 26 |
+
# Env files
|
| 27 |
+
.env
|
| 28 |
+
.env.local
|
| 29 |
+
.env.development
|
| 30 |
+
.env.development.local
|
| 31 |
+
.env.test
|
| 32 |
+
.env.test.local
|
| 33 |
+
.env.production
|
| 34 |
+
.env.production.local
|
| 35 |
+
|
| 36 |
+
# Frontend specific
|
| 37 |
+
/frontend/dist
|
| 38 |
+
|
| 39 |
+
# Backend specific
|
| 40 |
+
/backend/prisma/data.db
|
| 41 |
+
/backend/prisma/data.db-journal
|
| 42 |
+
|
| 43 |
+
# build and coverage
|
| 44 |
+
coverage
|
| 45 |
+
build
|
| 46 |
+
|
| 47 |
+
# Firebase keys
|
| 48 |
+
firebase-key.json
|
| 49 |
backend/firebase-key.json
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
.prettierrc.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"semi": true,
|
| 3 |
+
"singleQuote": true,
|
| 4 |
+
"tabWidth": 2,
|
| 5 |
+
"trailingComma": "es5",
|
| 6 |
+
"printWidth": 100
|
| 7 |
+
}
|
App.js
DELETED
|
@@ -1,142 +0,0 @@
|
|
| 1 |
-
import React, { useState, useEffect } from 'react';
|
| 2 |
-
import { StyleSheet, View, SafeAreaView, Platform } from 'react-native';
|
| 3 |
-
import { StatusBar } from 'expo-status-bar';
|
| 4 |
-
import { COLORS } from './src/theme/theme';
|
| 5 |
-
import { useResponsive } from './src/hooks/useResponsive';
|
| 6 |
-
import { TopNavBar } from './src/components/TopNavBar';
|
| 7 |
-
import { BottomTabBar } from './src/components/BottomTabBar';
|
| 8 |
-
import { getUserId, API_BASE, getHeaders } from './src/config';
|
| 9 |
-
|
| 10 |
-
// Import Screens
|
| 11 |
-
import { AuthScreen } from './src/screens/AuthScreen';
|
| 12 |
-
import { LandingPageScreen } from './src/screens/LandingPageScreen';
|
| 13 |
-
import { CarbonCalculatorScreen } from './src/screens/CarbonCalculatorScreen';
|
| 14 |
-
import { InsightsScreen } from './src/screens/InsightsScreen';
|
| 15 |
-
import { GamificationScreen } from './src/screens/GamificationScreen';
|
| 16 |
-
import { ProjectsScreen } from './src/screens/ProjectsScreen';
|
| 17 |
-
import { MarketplaceScreen } from './src/screens/MarketplaceScreen';
|
| 18 |
-
|
| 19 |
-
export default function App() {
|
| 20 |
-
const { isDesktop } = useResponsive();
|
| 21 |
-
const [activeTab, setActiveTab] = useState('home'); // home, calculate, analytics, projects, marketplace, impact
|
| 22 |
-
|
| 23 |
-
const currentUserId = getUserId();
|
| 24 |
-
|
| 25 |
-
const [cachedProfile, setCachedProfile] = useState(null);
|
| 26 |
-
const [cachedConfig, setCachedConfig] = useState(null);
|
| 27 |
-
const [cachedLeaderboard, setCachedLeaderboard] = useState(null);
|
| 28 |
-
|
| 29 |
-
// Preload and cache profile, config, and leaderboard on startup / user changes
|
| 30 |
-
useEffect(() => {
|
| 31 |
-
if (currentUserId) {
|
| 32 |
-
fetch(`${API_BASE}/config`, { headers: getHeaders() })
|
| 33 |
-
.then((res) => res.json())
|
| 34 |
-
.then((data) => {
|
| 35 |
-
if (data && !data.error) setCachedConfig(data);
|
| 36 |
-
})
|
| 37 |
-
.catch((err) => console.log('Error caching config:', err));
|
| 38 |
-
|
| 39 |
-
fetch(`${API_BASE}/profile`, { headers: getHeaders() })
|
| 40 |
-
.then((res) => res.json())
|
| 41 |
-
.then((data) => {
|
| 42 |
-
if (data && !data.error) setCachedProfile(data);
|
| 43 |
-
})
|
| 44 |
-
.catch((err) => console.log('Error caching profile:', err));
|
| 45 |
-
|
| 46 |
-
fetch(`${API_BASE}/leaderboard`, { headers: getHeaders() })
|
| 47 |
-
.then((res) => res.json())
|
| 48 |
-
.then((data) => {
|
| 49 |
-
if (data && !data.error) setCachedLeaderboard(data);
|
| 50 |
-
})
|
| 51 |
-
.catch((err) => console.log('Error caching leaderboard:', err));
|
| 52 |
-
}
|
| 53 |
-
}, [currentUserId]);
|
| 54 |
-
|
| 55 |
-
if (!currentUserId) {
|
| 56 |
-
return (
|
| 57 |
-
<SafeAreaView style={styles.appContainer}>
|
| 58 |
-
<StatusBar style="light" backgroundColor={COLORS.background} />
|
| 59 |
-
<AuthScreen onLoginSuccess={() => {
|
| 60 |
-
if (typeof window !== 'undefined') {
|
| 61 |
-
window.location.reload();
|
| 62 |
-
}
|
| 63 |
-
}} />
|
| 64 |
-
</SafeAreaView>
|
| 65 |
-
);
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
const renderActiveScreen = () => {
|
| 69 |
-
switch (activeTab) {
|
| 70 |
-
case 'home':
|
| 71 |
-
return <LandingPageScreen onStartCalculator={() => setActiveTab('calculate')} setActiveTab={setActiveTab} />;
|
| 72 |
-
case 'calculate':
|
| 73 |
-
return (
|
| 74 |
-
<CarbonCalculatorScreen
|
| 75 |
-
onBackHome={() => setActiveTab('home')}
|
| 76 |
-
setCachedProfile={setCachedProfile}
|
| 77 |
-
setCachedLeaderboard={setCachedLeaderboard}
|
| 78 |
-
/>
|
| 79 |
-
);
|
| 80 |
-
case 'analytics':
|
| 81 |
-
return <InsightsScreen setActiveTab={setActiveTab} />;
|
| 82 |
-
case 'projects':
|
| 83 |
-
return <ProjectsScreen setActiveTab={setActiveTab} />;
|
| 84 |
-
case 'marketplace':
|
| 85 |
-
return (
|
| 86 |
-
<MarketplaceScreen
|
| 87 |
-
setActiveTab={setActiveTab}
|
| 88 |
-
cachedProfile={cachedProfile}
|
| 89 |
-
setCachedProfile={setCachedProfile}
|
| 90 |
-
setCachedLeaderboard={setCachedLeaderboard}
|
| 91 |
-
/>
|
| 92 |
-
);
|
| 93 |
-
case 'impact':
|
| 94 |
-
return (
|
| 95 |
-
<GamificationScreen
|
| 96 |
-
setActiveTab={setActiveTab}
|
| 97 |
-
cachedProfile={cachedProfile}
|
| 98 |
-
setCachedProfile={setCachedProfile}
|
| 99 |
-
cachedLeaderboard={cachedLeaderboard}
|
| 100 |
-
setCachedLeaderboard={setCachedLeaderboard}
|
| 101 |
-
/>
|
| 102 |
-
);
|
| 103 |
-
default:
|
| 104 |
-
return <LandingPageScreen onStartCalculator={() => setActiveTab('calculate')} setActiveTab={setActiveTab} />;
|
| 105 |
-
}
|
| 106 |
-
};
|
| 107 |
-
|
| 108 |
-
return (
|
| 109 |
-
<SafeAreaView style={styles.appContainer}>
|
| 110 |
-
<StatusBar style="light" backgroundColor={COLORS.background} />
|
| 111 |
-
|
| 112 |
-
{/* Show top header navbar on desktop screens only */}
|
| 113 |
-
{isDesktop && (
|
| 114 |
-
<TopNavBar activeTab={activeTab} setActiveTab={setActiveTab} />
|
| 115 |
-
)}
|
| 116 |
-
|
| 117 |
-
{/* Screen Content Wrapper */}
|
| 118 |
-
<View style={[styles.contentContainer, isDesktop && styles.desktopPaddingTop]}>
|
| 119 |
-
{renderActiveScreen()}
|
| 120 |
-
</View>
|
| 121 |
-
|
| 122 |
-
{/* Show bottom tabbar navigation on mobile screens only */}
|
| 123 |
-
{!isDesktop && (
|
| 124 |
-
<BottomTabBar activeTab={activeTab} setActiveTab={setActiveTab} />
|
| 125 |
-
)}
|
| 126 |
-
</SafeAreaView>
|
| 127 |
-
);
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
const styles = StyleSheet.create({
|
| 131 |
-
appContainer: {
|
| 132 |
-
flex: 1,
|
| 133 |
-
backgroundColor: COLORS.background,
|
| 134 |
-
},
|
| 135 |
-
contentContainer: {
|
| 136 |
-
flex: 1,
|
| 137 |
-
backgroundColor: COLORS.background,
|
| 138 |
-
},
|
| 139 |
-
desktopPaddingTop: {
|
| 140 |
-
paddingTop: 64, // accounts for the fixed TopNavBar height
|
| 141 |
-
},
|
| 142 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to EcoGuide AI
|
| 2 |
+
|
| 3 |
+
Thank you for your interest in contributing! This document explains how to get started.
|
| 4 |
+
|
| 5 |
+
## Getting Started
|
| 6 |
+
|
| 7 |
+
1. **Fork** the repository and clone your fork
|
| 8 |
+
2. **Install** dependencies: `npm install` in both `backend/` and `frontend/`
|
| 9 |
+
3. **Copy** `.env.example` to `.env` in both directories and fill in values
|
| 10 |
+
4. **Run** migrations: `cd backend && npx prisma migrate deploy`
|
| 11 |
+
5. **Start** dev servers: `npm run dev` in each directory
|
| 12 |
+
|
| 13 |
+
## Development Workflow
|
| 14 |
+
|
| 15 |
+
- Branch from `main` with a descriptive name: `feat/my-feature` or `fix/my-bug`
|
| 16 |
+
- Keep commits focused and use conventional commit messages:
|
| 17 |
+
- `feat:` new feature
|
| 18 |
+
- `fix:` bug fix
|
| 19 |
+
- `security:` security improvement
|
| 20 |
+
- `perf:` performance improvement
|
| 21 |
+
- `test:` test addition or update
|
| 22 |
+
- `docs:` documentation update
|
| 23 |
+
- `chore:` tooling/config change
|
| 24 |
+
|
| 25 |
+
## Code Standards
|
| 26 |
+
|
| 27 |
+
- **No `var`** — use `const` / `let`
|
| 28 |
+
- **Strict equality** — use `===` everywhere
|
| 29 |
+
- **No console in production** — use `src/utils/logger.js` in frontend
|
| 30 |
+
- **Sanitize all inputs** — use `src/utils/sanitize.js` for numeric/text fields
|
| 31 |
+
- **JSDoc** — add JSDoc to all exported functions in `services/` and `utils/`
|
| 32 |
+
- Run `npm run lint` before opening a PR — zero warnings expected
|
| 33 |
+
|
| 34 |
+
## Testing
|
| 35 |
+
|
| 36 |
+
All new features must include tests:
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
cd backend && npm test
|
| 40 |
+
cd frontend && npm test
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
- **Backend**: add tests in `backend/src/tests/unit/`
|
| 44 |
+
- **Frontend utilities**: add tests in `frontend/src/tests/`
|
| 45 |
+
- Aim for >90% coverage on utility functions
|
| 46 |
+
|
| 47 |
+
## Pull Request Checklist
|
| 48 |
+
|
| 49 |
+
- [ ] Tests pass locally (`npm test` in both directories)
|
| 50 |
+
- [ ] No ESLint warnings (`npm run lint`)
|
| 51 |
+
- [ ] No hardcoded secrets or API keys
|
| 52 |
+
- [ ] JSDoc added to new functions
|
| 53 |
+
- [ ] README updated if adding new env vars or features
|
| 54 |
+
|
| 55 |
+
## Reporting Issues
|
| 56 |
+
|
| 57 |
+
Please open a GitHub Issue with:
|
| 58 |
+
|
| 59 |
+
- Steps to reproduce
|
| 60 |
+
- Expected vs. actual behaviour
|
| 61 |
+
- Browser/Node.js version
|
| 62 |
+
|
| 63 |
+
## Assumptions & Anonymous Sessions
|
| 64 |
+
|
| 65 |
+
EcoGuide AI operates without user accounts. This means:
|
| 66 |
+
|
| 67 |
+
- **Session identity** is an anonymous UUID generated on first visit and stored in `localStorage`. Clearing browser storage is equivalent to "logging out".
|
| 68 |
+
- **Cross-device**: There is no sync across devices. Each browser gets its own independent session UUID and history.
|
| 69 |
+
- **No authentication**: The backend accepts any UUID as a userId. The `POST /api/assessments` endpoint auto-creates a placeholder user record if the UUID is unknown (e.g. after a DB wipe or first use), so data is never silently lost.
|
| 70 |
+
- **Synthetic emails**: When the backend creates a placeholder user it generates `${uuid}@ecoguide.ai` as a unique DB key. This is never used for email communication — it exists only to satisfy the database unique constraint.
|
| 71 |
+
- **Privacy by design**: No names, real emails, or personally identifiable information are collected at any point. All data can be cleared by the user at any time through browser storage settings.
|
| 72 |
+
|
| 73 |
+
If you extend the platform to support real accounts (e.g. OAuth), update the `useUser` hook, the `/api/users` route, and the Prisma `User` model — and remove the synthetic email fallback in `assessmentService.js`.
|
| 74 |
+
|
| 75 |
+
## License
|
| 76 |
+
|
| 77 |
+
By contributing, you agree your contributions will be licensed under the [MIT License](./LICENSE).
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 EcoGuide AI
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
Mobile/carbon_calculator/code.html
DELETED
|
@@ -1,357 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
|
| 3 |
-
<html class="dark" lang="en"><head>
|
| 4 |
-
<meta charset="utf-8"/>
|
| 5 |
-
<meta content="width=device-width, initial-scale=1.0, viewport-fit=cover" name="viewport"/>
|
| 6 |
-
<title>EcoTrack AI - Carbon Calculator</title>
|
| 7 |
-
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
| 8 |
-
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 9 |
-
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 10 |
-
<style>
|
| 11 |
-
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap');
|
| 12 |
-
|
| 13 |
-
body {
|
| 14 |
-
font-family: 'Geist', sans-serif;
|
| 15 |
-
-webkit-font-smoothing: antialiased;
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
.glass-panel {
|
| 19 |
-
background: rgba(30, 41, 59, 0.5);
|
| 20 |
-
backdrop-filter: blur(12px);
|
| 21 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 22 |
-
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
.active-glow {
|
| 26 |
-
box-shadow: 0 0 15px rgba(78, 222, 163, 0.3);
|
| 27 |
-
border-color: #4edea3 !important;
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
.bioluminescent-btn {
|
| 31 |
-
box-shadow: 0 4px 14px 0 rgba(78, 222, 163, 0.3);
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
.custom-slider::-webkit-slider-thumb {
|
| 35 |
-
-webkit-appearance: none;
|
| 36 |
-
appearance: none;
|
| 37 |
-
width: 24px;
|
| 38 |
-
height: 24px;
|
| 39 |
-
background: #4edea3;
|
| 40 |
-
cursor: pointer;
|
| 41 |
-
border-radius: 50%;
|
| 42 |
-
box-shadow: 0 0 10px rgba(78, 222, 163, 0.5);
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
.step-indicator-active {
|
| 46 |
-
width: 2rem;
|
| 47 |
-
background: #4edea3;
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
.material-symbols-outlined {
|
| 51 |
-
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
| 52 |
-
}
|
| 53 |
-
</style>
|
| 54 |
-
<script id="tailwind-config">
|
| 55 |
-
tailwind.config = {
|
| 56 |
-
darkMode: "class",
|
| 57 |
-
theme: {
|
| 58 |
-
extend: {
|
| 59 |
-
"colors": {
|
| 60 |
-
"surface-container-lowest": "#060e20",
|
| 61 |
-
"inverse-surface": "#dae2fd",
|
| 62 |
-
"on-error-container": "#ffdad6",
|
| 63 |
-
"primary": "#4edea3",
|
| 64 |
-
"on-primary-container": "#00422b",
|
| 65 |
-
"surface": "#0b1326",
|
| 66 |
-
"surface-container-highest": "#2d3449",
|
| 67 |
-
"on-tertiary-container": "#523200",
|
| 68 |
-
"surface-container": "#171f33",
|
| 69 |
-
"on-surface": "#dae2fd",
|
| 70 |
-
"outline-variant": "#3c4a42",
|
| 71 |
-
"surface-variant": "#2d3449",
|
| 72 |
-
"on-tertiary-fixed": "#2a1700",
|
| 73 |
-
"on-tertiary-fixed-variant": "#653e00",
|
| 74 |
-
"primary-fixed": "#6ffbbe",
|
| 75 |
-
"on-secondary-container": "#00344e",
|
| 76 |
-
"inverse-on-surface": "#283044",
|
| 77 |
-
"tertiary-container": "#e29100",
|
| 78 |
-
"tertiary": "#ffb95f",
|
| 79 |
-
"surface-bright": "#31394d",
|
| 80 |
-
"on-secondary-fixed-variant": "#004c6e",
|
| 81 |
-
"error-container": "#93000a",
|
| 82 |
-
"inverse-primary": "#006c49",
|
| 83 |
-
"on-secondary": "#00344d",
|
| 84 |
-
"on-primary": "#003824",
|
| 85 |
-
"background": "#0b1326",
|
| 86 |
-
"error": "#ffb4ab",
|
| 87 |
-
"primary-fixed-dim": "#4edea3",
|
| 88 |
-
"on-primary-fixed": "#002113",
|
| 89 |
-
"on-tertiary": "#472a00",
|
| 90 |
-
"secondary-fixed-dim": "#89ceff",
|
| 91 |
-
"outline": "#86948a",
|
| 92 |
-
"primary-container": "#10b981",
|
| 93 |
-
"surface-tint": "#4edea3",
|
| 94 |
-
"surface-dim": "#0b1326",
|
| 95 |
-
"on-error": "#690005",
|
| 96 |
-
"secondary-fixed": "#c9e6ff",
|
| 97 |
-
"surface-container-low": "#131b2e",
|
| 98 |
-
"on-secondary-fixed": "#001e2f",
|
| 99 |
-
"on-surface-variant": "#bbcabf",
|
| 100 |
-
"tertiary-fixed-dim": "#ffb95f",
|
| 101 |
-
"secondary": "#89ceff",
|
| 102 |
-
"tertiary-fixed": "#ffddb8",
|
| 103 |
-
"surface-container-high": "#222a3d",
|
| 104 |
-
"secondary-container": "#00a2e6",
|
| 105 |
-
"on-primary-fixed-variant": "#005236",
|
| 106 |
-
"on-background": "#dae2fd"
|
| 107 |
-
},
|
| 108 |
-
"borderRadius": {
|
| 109 |
-
"DEFAULT": "0.25rem",
|
| 110 |
-
"lg": "0.5rem",
|
| 111 |
-
"xl": "0.75rem",
|
| 112 |
-
"full": "9999px"
|
| 113 |
-
},
|
| 114 |
-
"spacing": {
|
| 115 |
-
"md": "1.5rem",
|
| 116 |
-
"unit": "4px",
|
| 117 |
-
"xs": "0.5rem",
|
| 118 |
-
"xl": "4rem",
|
| 119 |
-
"margin": "2rem",
|
| 120 |
-
"sm": "1rem",
|
| 121 |
-
"gutter": "1.5rem",
|
| 122 |
-
"lg": "2.5rem"
|
| 123 |
-
},
|
| 124 |
-
"fontFamily": {
|
| 125 |
-
"caption": ["Geist"],
|
| 126 |
-
"title-sm": ["Geist"],
|
| 127 |
-
"display-lg": ["Geist"],
|
| 128 |
-
"display-lg-mobile": ["Geist"],
|
| 129 |
-
"headline-md": ["Geist"],
|
| 130 |
-
"body-md": ["Geist"],
|
| 131 |
-
"label-md": ["Geist"]
|
| 132 |
-
},
|
| 133 |
-
"fontSize": {
|
| 134 |
-
"caption": ["12px", {"lineHeight": "1.2", "fontWeight": "400"}],
|
| 135 |
-
"title-sm": ["18px", {"lineHeight": "1.4", "fontWeight": "600"}],
|
| 136 |
-
"display-lg": ["48px", {"lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
| 137 |
-
"display-lg-mobile": ["32px", {"lineHeight": "1.2", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
| 138 |
-
"headline-md": ["24px", {"lineHeight": "1.3", "fontWeight": "600"}],
|
| 139 |
-
"body-md": ["16px", {"lineHeight": "1.6", "fontWeight": "400"}],
|
| 140 |
-
"label-md": ["14px", {"lineHeight": "1.2", "letterSpacing": "0.01em", "fontWeight": "500"}]
|
| 141 |
-
}
|
| 142 |
-
},
|
| 143 |
-
},
|
| 144 |
-
}
|
| 145 |
-
</script>
|
| 146 |
-
<style>
|
| 147 |
-
body {
|
| 148 |
-
min-height: max(884px, 100dvh);
|
| 149 |
-
}
|
| 150 |
-
</style>
|
| 151 |
-
</head>
|
| 152 |
-
<body class="bg-surface text-on-surface min-h-screen selection:bg-primary/30">
|
| 153 |
-
<!-- Top App Bar -->
|
| 154 |
-
<header class="fixed top-0 w-full z-50 bg-surface/50 dark:bg-surface-container/50 backdrop-blur-xl border-b border-white/10 dark:border-white/5 shadow-sm backdrop-saturate-150">
|
| 155 |
-
<div class="flex justify-between items-center px-margin h-16 w-full max-w-7xl mx-auto">
|
| 156 |
-
<div class="flex items-center gap-3">
|
| 157 |
-
<div class="w-8 h-8 rounded-lg bg-primary-container flex items-center justify-center">
|
| 158 |
-
<span class="material-symbols-outlined text-on-primary-container" style="font-variation-settings: 'FILL' 1;">eco</span>
|
| 159 |
-
</div>
|
| 160 |
-
<h1 class="font-headline-md text-headline-md font-bold text-primary dark:text-primary-fixed tracking-tight">EcoTrack AI</h1>
|
| 161 |
-
</div>
|
| 162 |
-
<div class="flex items-center gap-2">
|
| 163 |
-
<button class="p-2 rounded-full hover:bg-white/10 transition-colors">
|
| 164 |
-
<span class="material-symbols-outlined text-on-surface-variant">notifications</span>
|
| 165 |
-
</button>
|
| 166 |
-
<div class="w-8 h-8 rounded-full overflow-hidden border border-primary/20">
|
| 167 |
-
<img alt="User Profile" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAfbSvIgrFI6x0lhKY96zmb1J-w-37aVQ4Opk2XSRClQ1YuoU4F3toj_yXGDr_W41cVRtRCmrJR0nZo_QkcRcc66tZWU7QESsDjZZe93gMSl-X-Wu9fhiwYenNusSXUGy2RZ4JxIm1u62BwcA7brNAeE8zPtcfmIPmGAKKnmxCKmxGCRxB24C0aey6PWi51LvyE3i1FspwQqPH52nZVA1Z3yn13mpWGbVpwArBWa76MvPjkXSdbJEUslRgH6vlUNX3INq4IumEAUesq"/>
|
| 168 |
-
</div>
|
| 169 |
-
</div>
|
| 170 |
-
</div>
|
| 171 |
-
</header>
|
| 172 |
-
<main class="pt-24 pb-48 px-margin max-w-lg mx-auto relative z-10">
|
| 173 |
-
<!-- Wizard Header -->
|
| 174 |
-
<div class="mb-lg">
|
| 175 |
-
<div class="flex items-center justify-between mb-xs">
|
| 176 |
-
<span class="font-label-md text-label-md text-primary uppercase tracking-widest">Step 02 of 05</span>
|
| 177 |
-
<span class="font-label-md text-label-md text-on-surface-variant">Transportation</span>
|
| 178 |
-
</div>
|
| 179 |
-
<div class="flex gap-2 h-1 w-full bg-surface-container-highest rounded-full overflow-hidden">
|
| 180 |
-
<div class="h-full bg-primary/40 w-1/5 rounded-full"></div>
|
| 181 |
-
<div class="h-full bg-primary w-1/5 rounded-full shadow-[0_0_8px_rgba(78,222,163,0.5)]"></div>
|
| 182 |
-
<div class="h-full bg-surface-container w-1/5 rounded-full"></div>
|
| 183 |
-
<div class="h-full bg-surface-container w-1/5 rounded-full"></div>
|
| 184 |
-
<div class="h-full bg-surface-container w-1/5 rounded-full"></div>
|
| 185 |
-
</div>
|
| 186 |
-
</div>
|
| 187 |
-
<!-- Animation/Atmosphere -->
|
| 188 |
-
<div class="absolute inset-0 -z-10 opacity-30 pointer-events-none">
|
| 189 |
-
|
| 190 |
-
</div>
|
| 191 |
-
<div class="space-y-lg">
|
| 192 |
-
<!-- Transport Type Selection -->
|
| 193 |
-
<section>
|
| 194 |
-
<h2 class="font-title-sm text-title-sm mb-md flex items-center gap-2">
|
| 195 |
-
<span class="material-symbols-outlined text-primary">directions_car</span>
|
| 196 |
-
How do you get around?
|
| 197 |
-
</h2>
|
| 198 |
-
<div class="grid grid-cols-4 gap-3">
|
| 199 |
-
<button class="glass-panel active-glow flex flex-col items-center justify-center p-md rounded-xl transition-all hover:bg-white/5 active:scale-95 group" id="btn-car" onclick="selectTransport('car')">
|
| 200 |
-
<span class="material-symbols-outlined text-primary mb-xs" data-icon="directions_car">directions_car</span>
|
| 201 |
-
<span class="font-caption text-caption text-on-surface-variant group-hover:text-primary transition-colors">Car</span>
|
| 202 |
-
</button>
|
| 203 |
-
<button class="glass-panel flex flex-col items-center justify-center p-md rounded-xl transition-all hover:bg-white/5 active:scale-95 group" id="btn-bus" onclick="selectTransport('bus')">
|
| 204 |
-
<span class="material-symbols-outlined text-on-surface-variant mb-xs" data-icon="directions_bus">directions_bus</span>
|
| 205 |
-
<span class="font-caption text-caption text-on-surface-variant group-hover:text-primary transition-colors">Bus</span>
|
| 206 |
-
</button>
|
| 207 |
-
<button class="glass-panel flex flex-col items-center justify-center p-md rounded-xl transition-all hover:bg-white/5 active:scale-95 group" id="btn-bike" onclick="selectTransport('bike')">
|
| 208 |
-
<span class="material-symbols-outlined text-on-surface-variant mb-xs" data-icon="directions_bike">directions_bike</span>
|
| 209 |
-
<span class="font-caption text-caption text-on-surface-variant group-hover:text-primary transition-colors">Bike</span>
|
| 210 |
-
</button>
|
| 211 |
-
<button class="glass-panel flex flex-col items-center justify-center p-md rounded-xl transition-all hover:bg-white/5 active:scale-95 group" id="btn-walk" onclick="selectTransport('walk')">
|
| 212 |
-
<span class="material-symbols-outlined text-on-surface-variant mb-xs" data-icon="directions_walk">directions_walk</span>
|
| 213 |
-
<span class="font-caption text-caption text-on-surface-variant group-hover:text-primary transition-colors">Walk</span>
|
| 214 |
-
</button>
|
| 215 |
-
</div>
|
| 216 |
-
</section>
|
| 217 |
-
<!-- Commute Distance -->
|
| 218 |
-
<section class="glass-panel p-md rounded-2xl">
|
| 219 |
-
<div class="flex justify-between items-center mb-md">
|
| 220 |
-
<h2 class="font-title-sm text-title-sm flex items-center gap-2">
|
| 221 |
-
<span class="material-symbols-outlined text-primary">distance</span>
|
| 222 |
-
Daily commute distance
|
| 223 |
-
</h2>
|
| 224 |
-
<span class="text-primary font-headline-md font-bold" id="distance-val">15<span class="text-label-md font-normal text-on-surface-variant ml-1">km</span></span>
|
| 225 |
-
</div>
|
| 226 |
-
<input class="w-full h-1.5 bg-surface-container-highest rounded-lg appearance-none cursor-pointer custom-slider" id="distance-slider" max="100" min="0" oninput="updateDistance(this.value)" step="1" type="range" value="15"/>
|
| 227 |
-
<div class="flex justify-between mt-xs px-1">
|
| 228 |
-
<span class="font-caption text-caption text-on-surface-variant">0km</span>
|
| 229 |
-
<span class="font-caption text-caption text-on-surface-variant">100km+</span>
|
| 230 |
-
</div>
|
| 231 |
-
</section>
|
| 232 |
-
<!-- Fuel Type Selection -->
|
| 233 |
-
<section>
|
| 234 |
-
<h2 class="font-title-sm text-title-sm mb-md flex items-center gap-2">
|
| 235 |
-
<span class="material-symbols-outlined text-primary">ev_station</span>
|
| 236 |
-
Vehicle fuel type
|
| 237 |
-
</h2>
|
| 238 |
-
<div class="relative group">
|
| 239 |
-
<select class="w-full bg-surface-container-low border border-white/10 rounded-xl px-md py-4 font-body-md text-on-surface appearance-none focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all">
|
| 240 |
-
<option value="electric">Electric (EV)</option>
|
| 241 |
-
<option value="hybrid">Hybrid</option>
|
| 242 |
-
<option selected="" value="gasoline">Gasoline</option>
|
| 243 |
-
<option value="diesel">Diesel</option>
|
| 244 |
-
<option value="lpg">LPG / Compressed Natural Gas</option>
|
| 245 |
-
</select>
|
| 246 |
-
<div class="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-on-surface-variant">
|
| 247 |
-
<span class="material-symbols-outlined">expand_more</span>
|
| 248 |
-
</div>
|
| 249 |
-
</div>
|
| 250 |
-
<p class="mt-xs font-caption text-caption text-on-surface-variant/70 flex items-center gap-1 px-1">
|
| 251 |
-
<span class="material-symbols-outlined text-[14px]">info</span>
|
| 252 |
-
Fuel type significantly impacts the final emission estimate.
|
| 253 |
-
</p>
|
| 254 |
-
</section>
|
| 255 |
-
</div>
|
| 256 |
-
</main>
|
| 257 |
-
<!-- Live Estimate Bottom Sheet -->
|
| 258 |
-
<div class="fixed bottom-0 left-0 w-full z-40 bg-surface/70 dark:bg-surface-container-low/70 backdrop-blur-lg border-t border-white/10 dark:border-white/5 shadow-[0_-4px_20px_rgba(0,0,0,0.1)] rounded-t-xl px-4 pb-safe pt-4">
|
| 259 |
-
<div class="max-w-7xl mx-auto">
|
| 260 |
-
<!-- Glow Counter -->
|
| 261 |
-
<div class="flex items-center justify-between mb-md glass-panel p-md rounded-xl border-primary/20">
|
| 262 |
-
<div>
|
| 263 |
-
<p class="font-label-md text-label-md text-on-surface-variant">Estimated Daily Impact</p>
|
| 264 |
-
<div class="flex items-baseline gap-2">
|
| 265 |
-
<span class="text-primary font-display-lg-mobile font-bold tracking-tight drop-shadow-[0_0_12px_rgba(78,222,163,0.4)]" id="live-carbon">2.4</span>
|
| 266 |
-
<span class="text-on-surface font-headline-md font-medium">kg CO2e</span>
|
| 267 |
-
</div>
|
| 268 |
-
</div>
|
| 269 |
-
<div class="h-12 w-12 rounded-full border-2 border-primary/20 flex items-center justify-center relative">
|
| 270 |
-
<div class="absolute inset-0 rounded-full border-2 border-primary border-t-transparent animate-spin duration-700"></div>
|
| 271 |
-
<span class="material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">analytics</span>
|
| 272 |
-
</div>
|
| 273 |
-
</div>
|
| 274 |
-
<!-- Footer Navigation -->
|
| 275 |
-
<div class="flex gap-4 pb-4">
|
| 276 |
-
<button class="flex-1 py-4 px-md rounded-xl font-label-md text-label-md font-bold text-on-surface-variant bg-surface-container hover:bg-surface-container-highest transition-all active:scale-95">
|
| 277 |
-
BACK
|
| 278 |
-
</button>
|
| 279 |
-
<button class="flex-[2] py-4 px-md rounded-xl font-label-md text-label-md font-bold text-on-primary bg-primary bioluminescent-btn hover:brightness-110 transition-all active:scale-95 flex items-center justify-center gap-2">
|
| 280 |
-
NEXT STEP
|
| 281 |
-
<span class="material-symbols-outlined text-[20px]">arrow_forward</span>
|
| 282 |
-
</button>
|
| 283 |
-
</div>
|
| 284 |
-
</div>
|
| 285 |
-
</div>
|
| 286 |
-
<!-- Hidden Bottom Nav for top-level context checking -->
|
| 287 |
-
<nav class="hidden md:flex fixed bottom-0 w-full z-50 rounded-t-xl bg-surface/70 dark:bg-surface-container-low/70 backdrop-blur-lg border-t border-white/10 dark:border-white/5 shadow-[0_-4px_20px_rgba(0,0,0,0.1)] justify-around items-center px-4 pb-safe pt-2">
|
| 288 |
-
<!-- Suppression logic active: Navigation hidden on task-focused wizard screen -->
|
| 289 |
-
</nav>
|
| 290 |
-
<script>
|
| 291 |
-
let currentDistance = 15;
|
| 292 |
-
let transportFactor = 0.16; // kg CO2 per km for car
|
| 293 |
-
|
| 294 |
-
function updateDistance(val) {
|
| 295 |
-
currentDistance = val;
|
| 296 |
-
document.getElementById('distance-val').innerHTML = `${val}<span class="text-label-md font-normal text-on-surface-variant ml-1">km</span>`;
|
| 297 |
-
calculateLiveEstimate();
|
| 298 |
-
}
|
| 299 |
-
|
| 300 |
-
function selectTransport(type) {
|
| 301 |
-
// Reset all buttons
|
| 302 |
-
const buttons = ['car', 'bus', 'bike', 'walk'];
|
| 303 |
-
buttons.forEach(t => {
|
| 304 |
-
const btn = document.getElementById(`btn-${t}`);
|
| 305 |
-
btn.classList.remove('active-glow');
|
| 306 |
-
btn.querySelector('.material-symbols-outlined').classList.remove('text-primary');
|
| 307 |
-
btn.querySelector('.material-symbols-outlined').classList.add('text-on-surface-variant');
|
| 308 |
-
});
|
| 309 |
-
|
| 310 |
-
// Set active
|
| 311 |
-
const activeBtn = document.getElementById(`btn-${type}`);
|
| 312 |
-
activeBtn.classList.add('active-glow');
|
| 313 |
-
activeBtn.querySelector('.material-symbols-outlined').classList.add('text-primary');
|
| 314 |
-
activeBtn.querySelector('.material-symbols-outlined').classList.remove('text-on-surface-variant');
|
| 315 |
-
|
| 316 |
-
// Update factors
|
| 317 |
-
switch(type) {
|
| 318 |
-
case 'car': transportFactor = 0.16; break;
|
| 319 |
-
case 'bus': transportFactor = 0.08; break;
|
| 320 |
-
case 'bike': transportFactor = 0.005; break;
|
| 321 |
-
case 'walk': transportFactor = 0; break;
|
| 322 |
-
}
|
| 323 |
-
calculateLiveEstimate();
|
| 324 |
-
}
|
| 325 |
-
|
| 326 |
-
function calculateLiveEstimate() {
|
| 327 |
-
const result = (currentDistance * transportFactor).toFixed(1);
|
| 328 |
-
const display = document.getElementById('live-carbon');
|
| 329 |
-
|
| 330 |
-
// Minimal animation for value change
|
| 331 |
-
let startValue = parseFloat(display.innerText);
|
| 332 |
-
let endValue = parseFloat(result);
|
| 333 |
-
let duration = 300;
|
| 334 |
-
let startTime = null;
|
| 335 |
-
|
| 336 |
-
function animate(currentTime) {
|
| 337 |
-
if (!startTime) startTime = currentTime;
|
| 338 |
-
const progress = Math.min((currentTime - startTime) / duration, 1);
|
| 339 |
-
const current = (startValue + (endValue - startValue) * progress).toFixed(1);
|
| 340 |
-
display.innerText = current;
|
| 341 |
-
if (progress < 1) requestAnimationFrame(animate);
|
| 342 |
-
}
|
| 343 |
-
requestAnimationFrame(animate);
|
| 344 |
-
}
|
| 345 |
-
|
| 346 |
-
// Add a subtle parallax effect to the glass cards
|
| 347 |
-
document.addEventListener('mousemove', (e) => {
|
| 348 |
-
const amount = 5;
|
| 349 |
-
const x = (e.clientX / window.innerWidth - 0.5) * amount;
|
| 350 |
-
const y = (e.clientY / window.innerHeight - 0.5) * amount;
|
| 351 |
-
const glassCards = document.querySelectorAll('.glass-panel');
|
| 352 |
-
glassCards.forEach(card => {
|
| 353 |
-
card.style.transform = `perspective(1000px) rotateX(${-y}deg) rotateY(${x}deg)`;
|
| 354 |
-
});
|
| 355 |
-
});
|
| 356 |
-
</script>
|
| 357 |
-
</body></html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Mobile/challenges_gamification/code.html
DELETED
|
@@ -1,381 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
|
| 3 |
-
<html class="dark" lang="en"><head>
|
| 4 |
-
<meta charset="utf-8"/>
|
| 5 |
-
<meta content="width=device-width, initial-scale=1.0, viewport-fit=cover" name="viewport"/>
|
| 6 |
-
<title>EcoTrack AI | Challenges & Gamification</title>
|
| 7 |
-
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
| 8 |
-
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 9 |
-
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" rel="stylesheet"/>
|
| 10 |
-
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 11 |
-
<script id="tailwind-config">
|
| 12 |
-
tailwind.config = {
|
| 13 |
-
darkMode: "class",
|
| 14 |
-
theme: {
|
| 15 |
-
extend: {
|
| 16 |
-
"colors": {
|
| 17 |
-
"surface-container-lowest": "#060e20",
|
| 18 |
-
"inverse-surface": "#dae2fd",
|
| 19 |
-
"on-error-container": "#ffdad6",
|
| 20 |
-
"primary": "#4edea3",
|
| 21 |
-
"on-primary-container": "#00422b",
|
| 22 |
-
"surface": "#0b1326",
|
| 23 |
-
"surface-container-highest": "#2d3449",
|
| 24 |
-
"on-tertiary-container": "#523200",
|
| 25 |
-
"surface-container": "#171f33",
|
| 26 |
-
"on-surface": "#dae2fd",
|
| 27 |
-
"outline-variant": "#3c4a42",
|
| 28 |
-
"surface-variant": "#2d3449",
|
| 29 |
-
"on-tertiary-fixed": "#2a1700",
|
| 30 |
-
"on-tertiary-fixed-variant": "#653e00",
|
| 31 |
-
"primary-fixed": "#6ffbbe",
|
| 32 |
-
"on-secondary-container": "#00344e",
|
| 33 |
-
"inverse-on-surface": "#283044",
|
| 34 |
-
"tertiary-container": "#e29100",
|
| 35 |
-
"tertiary": "#ffb95f",
|
| 36 |
-
"surface-bright": "#31394d",
|
| 37 |
-
"on-secondary-fixed-variant": "#004c6e",
|
| 38 |
-
"error-container": "#93000a",
|
| 39 |
-
"inverse-primary": "#006c49",
|
| 40 |
-
"on-secondary": "#00344d",
|
| 41 |
-
"on-primary": "#003824",
|
| 42 |
-
"background": "#0b1326",
|
| 43 |
-
"error": "#ffb4ab",
|
| 44 |
-
"primary-fixed-dim": "#4edea3",
|
| 45 |
-
"on-primary-fixed": "#002113",
|
| 46 |
-
"on-tertiary": "#472a00",
|
| 47 |
-
"secondary-fixed-dim": "#89ceff",
|
| 48 |
-
"outline": "#86948a",
|
| 49 |
-
"primary-container": "#10b981",
|
| 50 |
-
"surface-tint": "#4edea3",
|
| 51 |
-
"surface-dim": "#0b1326",
|
| 52 |
-
"on-error": "#690005",
|
| 53 |
-
"secondary-fixed": "#c9e6ff",
|
| 54 |
-
"surface-container-low": "#131b2e",
|
| 55 |
-
"on-secondary-fixed": "#001e2f",
|
| 56 |
-
"on-surface-variant": "#bbcabf",
|
| 57 |
-
"tertiary-fixed-dim": "#ffb95f",
|
| 58 |
-
"secondary": "#89ceff",
|
| 59 |
-
"tertiary-fixed": "#ffddb8",
|
| 60 |
-
"surface-container-high": "#222a3d",
|
| 61 |
-
"secondary-container": "#00a2e6",
|
| 62 |
-
"on-primary-fixed-variant": "#005236",
|
| 63 |
-
"on-background": "#dae2fd"
|
| 64 |
-
},
|
| 65 |
-
"borderRadius": {
|
| 66 |
-
"DEFAULT": "0.25rem",
|
| 67 |
-
"lg": "0.5rem",
|
| 68 |
-
"xl": "0.75rem",
|
| 69 |
-
"full": "9999px"
|
| 70 |
-
},
|
| 71 |
-
"spacing": {
|
| 72 |
-
"md": "1.5rem",
|
| 73 |
-
"unit": "4px",
|
| 74 |
-
"xs": "0.5rem",
|
| 75 |
-
"xl": "4rem",
|
| 76 |
-
"margin": "2rem",
|
| 77 |
-
"sm": "1rem",
|
| 78 |
-
"gutter": "1.5rem",
|
| 79 |
-
"lg": "2.5rem"
|
| 80 |
-
},
|
| 81 |
-
"fontFamily": {
|
| 82 |
-
"caption": ["Geist"],
|
| 83 |
-
"title-sm": ["Geist"],
|
| 84 |
-
"display-lg": ["Geist"],
|
| 85 |
-
"display-lg-mobile": ["Geist"],
|
| 86 |
-
"headline-md": ["Geist"],
|
| 87 |
-
"body-md": ["Geist"],
|
| 88 |
-
"label-md": ["Geist"]
|
| 89 |
-
},
|
| 90 |
-
"fontSize": {
|
| 91 |
-
"caption": ["12px", {"lineHeight": "1.2", "fontWeight": "400"}],
|
| 92 |
-
"title-sm": ["18px", {"lineHeight": "1.4", "fontWeight": "600"}],
|
| 93 |
-
"display-lg": ["48px", {"lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
| 94 |
-
"display-lg-mobile": ["32px", {"lineHeight": "1.2", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
| 95 |
-
"headline-md": ["24px", {"lineHeight": "1.3", "fontWeight": "600"}],
|
| 96 |
-
"body-md": ["16px", {"lineHeight": "1.6", "fontWeight": "400"}],
|
| 97 |
-
"label-md": ["14px", {"lineHeight": "1.2", "letterSpacing": "0.01em", "fontWeight": "500"}]
|
| 98 |
-
}
|
| 99 |
-
},
|
| 100 |
-
},
|
| 101 |
-
}
|
| 102 |
-
</script>
|
| 103 |
-
<style>
|
| 104 |
-
body {
|
| 105 |
-
background-color: #0b1326;
|
| 106 |
-
color: #dae2fd;
|
| 107 |
-
font-family: 'Geist', sans-serif;
|
| 108 |
-
overflow-x: hidden;
|
| 109 |
-
}
|
| 110 |
-
.glass-card {
|
| 111 |
-
background: rgba(30, 41, 59, 0.5);
|
| 112 |
-
backdrop-filter: blur(12px);
|
| 113 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 114 |
-
box-shadow: inset 0px 1px 0px 0px rgba(255, 255, 255, 0.05);
|
| 115 |
-
}
|
| 116 |
-
.bioluminescent-glow {
|
| 117 |
-
box-shadow: 0 0 15px rgba(78, 222, 163, 0.3);
|
| 118 |
-
}
|
| 119 |
-
.badge-glow {
|
| 120 |
-
filter: drop-shadow(0 0 8px rgba(78, 222, 163, 0.5));
|
| 121 |
-
}
|
| 122 |
-
.hide-scrollbar::-webkit-scrollbar {
|
| 123 |
-
display: none;
|
| 124 |
-
}
|
| 125 |
-
.hide-scrollbar {
|
| 126 |
-
-ms-overflow-style: none;
|
| 127 |
-
scrollbar-width: none;
|
| 128 |
-
}
|
| 129 |
-
.material-symbols-outlined {
|
| 130 |
-
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
| 131 |
-
}
|
| 132 |
-
</style>
|
| 133 |
-
<style>
|
| 134 |
-
body {
|
| 135 |
-
min-height: max(884px, 100dvh);
|
| 136 |
-
}
|
| 137 |
-
</style>
|
| 138 |
-
</head>
|
| 139 |
-
<body class="bg-surface text-on-surface antialiased">
|
| 140 |
-
<!-- Top App Bar -->
|
| 141 |
-
<header class="fixed top-0 w-full z-50 bg-surface/50 dark:bg-surface-container/50 backdrop-blur-xl border-b border-white/10 dark:border-white/5 shadow-sm backdrop-saturate-150 h-16">
|
| 142 |
-
<div class="flex justify-between items-center px-margin h-16 w-full max-w-7xl mx-auto">
|
| 143 |
-
<div class="flex items-center gap-3">
|
| 144 |
-
<div class="w-8 h-8 rounded-full overflow-hidden border border-primary/30">
|
| 145 |
-
<img alt="User" class="w-full h-full object-cover" data-alt="A high-quality 3D rendered avatar of a stylish user with modern glasses and a slight smile, set against a vibrant emerald green background with soft depth of field. The style is clean, eco-futuristic, and professional." src="https://lh3.googleusercontent.com/aida-public/AB6AXuDJZ3069M3x_gKg08zWvrkPlz9nBZLxTZ5bGL5s6k04Cj4GlVi3WGv00YsqhI7GjOfu368xRGOy7LwdoOoMQcAHgLUQMmZaU-Ka-CM0zfRivG5tbswzPbsW3dFDAGcbb_EPWF7UjgnCBY8hVtC_RAGuycag_HcuQLk3m341XC3lQOiMb_AtzbYKhICyxQA4v3J7Ohal_-31yyR2wcdLqD9FJu0zs3GCpMcp2zni6yjzPT5ILv6cVVE3N98f2Id678SvyquhcKq_MNnu"/>
|
| 146 |
-
</div>
|
| 147 |
-
<span class="font-headline-md text-headline-md font-bold text-primary dark:text-primary-fixed tracking-tight">EcoTrack AI</span>
|
| 148 |
-
</div>
|
| 149 |
-
<button class="material-symbols-outlined text-on-surface-variant hover:bg-white/10 transition-colors p-2 rounded-full">notifications</button>
|
| 150 |
-
</div>
|
| 151 |
-
</header>
|
| 152 |
-
<main class="pt-24 pb-32 px-4 space-y-8">
|
| 153 |
-
<!-- Active Challenges Horizontal Scroll -->
|
| 154 |
-
<section>
|
| 155 |
-
<div class="flex items-center justify-between mb-4">
|
| 156 |
-
<h2 class="font-headline-md text-headline-md text-on-surface">Active Challenges</h2>
|
| 157 |
-
<span class="font-label-md text-label-md text-primary">3 Active</span>
|
| 158 |
-
</div>
|
| 159 |
-
<div class="flex gap-4 overflow-x-auto hide-scrollbar snap-x -mx-4 px-4">
|
| 160 |
-
<!-- No-Car Week -->
|
| 161 |
-
<div class="snap-center shrink-0 w-[280px] glass-card rounded-xl p-md flex flex-col justify-between relative overflow-hidden">
|
| 162 |
-
<div class="absolute top-0 right-0 p-3">
|
| 163 |
-
<span class="bg-primary/20 text-primary text-caption font-caption px-2 py-1 rounded-lg">+500 pts</span>
|
| 164 |
-
</div>
|
| 165 |
-
<div>
|
| 166 |
-
<span class="material-symbols-outlined text-primary mb-2" style="font-variation-settings: 'FILL' 1;">directions_car</span>
|
| 167 |
-
<h3 class="font-title-sm text-title-sm text-on-surface mb-1">No-Car Week</h3>
|
| 168 |
-
<p class="font-label-md text-label-md text-on-surface-variant mb-4">4 days left</p>
|
| 169 |
-
</div>
|
| 170 |
-
<div class="space-y-2">
|
| 171 |
-
<div class="flex justify-between text-caption font-caption">
|
| 172 |
-
<span>Progress</span>
|
| 173 |
-
<span>60%</span>
|
| 174 |
-
</div>
|
| 175 |
-
<div class="w-full bg-surface-container-highest h-2 rounded-full overflow-hidden">
|
| 176 |
-
<div class="h-full bg-gradient-to-r from-secondary to-primary w-[60%]"></div>
|
| 177 |
-
</div>
|
| 178 |
-
</div>
|
| 179 |
-
</div>
|
| 180 |
-
<!-- Vegan Weekend -->
|
| 181 |
-
<div class="snap-center shrink-0 w-[280px] glass-card rounded-xl p-md flex flex-col justify-between relative overflow-hidden">
|
| 182 |
-
<div class="absolute top-0 right-0 p-3">
|
| 183 |
-
<span class="bg-tertiary/20 text-tertiary text-caption font-caption px-2 py-1 rounded-lg">+250 pts</span>
|
| 184 |
-
</div>
|
| 185 |
-
<div>
|
| 186 |
-
<span class="material-symbols-outlined text-tertiary mb-2" style="font-variation-settings: 'FILL' 1;">eco</span>
|
| 187 |
-
<h3 class="font-title-sm text-title-sm text-on-surface mb-1">Vegan Weekend</h3>
|
| 188 |
-
<p class="font-label-md text-label-md text-on-surface-variant mb-4">Starts in 12h</p>
|
| 189 |
-
</div>
|
| 190 |
-
<div class="space-y-2">
|
| 191 |
-
<div class="flex justify-between text-caption font-caption">
|
| 192 |
-
<span>Progress</span>
|
| 193 |
-
<span>0%</span>
|
| 194 |
-
</div>
|
| 195 |
-
<div class="w-full bg-surface-container-highest h-2 rounded-full overflow-hidden">
|
| 196 |
-
<div class="h-full bg-gradient-to-r from-secondary to-primary w-0"></div>
|
| 197 |
-
</div>
|
| 198 |
-
</div>
|
| 199 |
-
</div>
|
| 200 |
-
<!-- Plastic-Free Challenge -->
|
| 201 |
-
<div class="snap-center shrink-0 w-[280px] glass-card rounded-xl p-md flex flex-col justify-between relative overflow-hidden">
|
| 202 |
-
<div class="absolute top-0 right-0 p-3">
|
| 203 |
-
<span class="bg-primary/20 text-primary text-caption font-caption px-2 py-1 rounded-lg">+400 pts</span>
|
| 204 |
-
</div>
|
| 205 |
-
<div>
|
| 206 |
-
<span class="material-symbols-outlined text-primary mb-2" style="font-variation-settings: 'FILL' 1;">shopping_bag</span>
|
| 207 |
-
<h3 class="font-title-sm text-title-sm text-on-surface mb-1">Plastic-Free</h3>
|
| 208 |
-
<p class="font-label-md text-label-md text-on-surface-variant mb-4">2 days left</p>
|
| 209 |
-
</div>
|
| 210 |
-
<div class="space-y-2">
|
| 211 |
-
<div class="flex justify-between text-caption font-caption">
|
| 212 |
-
<span>Progress</span>
|
| 213 |
-
<span>85%</span>
|
| 214 |
-
</div>
|
| 215 |
-
<div class="w-full bg-surface-container-highest h-2 rounded-full overflow-hidden">
|
| 216 |
-
<div class="h-full bg-gradient-to-r from-secondary to-primary w-[85%]"></div>
|
| 217 |
-
</div>
|
| 218 |
-
</div>
|
| 219 |
-
</div>
|
| 220 |
-
</div>
|
| 221 |
-
</section>
|
| 222 |
-
<!-- Leaderboard Preview -->
|
| 223 |
-
<section class="glass-card rounded-2xl p-md">
|
| 224 |
-
<div class="flex items-center justify-between mb-6">
|
| 225 |
-
<h2 class="font-title-sm text-title-sm text-on-surface">Leaderboard</h2>
|
| 226 |
-
<button class="font-label-md text-label-md text-secondary">View All</button>
|
| 227 |
-
</div>
|
| 228 |
-
<div class="space-y-4">
|
| 229 |
-
<!-- Rank 1 -->
|
| 230 |
-
<div class="flex items-center justify-between p-3 rounded-xl bg-primary/10 border border-primary/20">
|
| 231 |
-
<div class="flex items-center gap-4">
|
| 232 |
-
<div class="relative">
|
| 233 |
-
<img class="w-12 h-12 rounded-full border-2 border-primary" data-alt="A professional close-up of a person with warm features and a confident look, rendered in a crisp digital vector style with soft ambient lighting and eco-friendly primary green accents in the background." src="https://lh3.googleusercontent.com/aida-public/AB6AXuAT1EPgeHbiCyiDKLGp868IabVBLWQJe-FbA4S09aQJipuS6tXAJnHYJnoD4VL-TBLlnzm4xoCEkS_WlOmVhbeXjuNHry4GZPGKfJ5_iQ8X-fVs2ZENwqa0MK2sTi6dgD_hmctOs2tY1U0dbsRFDclOP1Sy81nI9zy56ULwxCR3EAsmngeA71gDstnUUoOs0PVxPurXRdI32iJ5ScLE0CjWgOYh6n808_7lSn7PlX4m0EkobLUHN1beT5h43E4UGhZjiUdK2JjYPK9R"/>
|
| 234 |
-
<span class="absolute -top-1 -right-1 bg-tertiary text-on-tertiary text-[10px] w-5 h-5 flex items-center justify-center rounded-full font-bold">1</span>
|
| 235 |
-
</div>
|
| 236 |
-
<div>
|
| 237 |
-
<p class="font-label-md text-label-md text-on-surface">Sarah J.</p>
|
| 238 |
-
<p class="font-caption text-caption text-primary">Master Guardian</p>
|
| 239 |
-
</div>
|
| 240 |
-
</div>
|
| 241 |
-
<div class="text-right">
|
| 242 |
-
<p class="font-title-sm text-title-sm text-on-surface">12,450</p>
|
| 243 |
-
<p class="font-caption text-caption text-on-surface-variant">pts</p>
|
| 244 |
-
</div>
|
| 245 |
-
</div>
|
| 246 |
-
<!-- Rank 2 -->
|
| 247 |
-
<div class="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/10">
|
| 248 |
-
<div class="flex items-center gap-4">
|
| 249 |
-
<div class="relative">
|
| 250 |
-
<img class="w-12 h-12 rounded-full border-2 border-secondary" data-alt="A clean digital avatar of a young professional with a friendly smile, styled in a minimalist and modern eco-futuristic aesthetic with deep blue and slate tones in the lighting." src="https://lh3.googleusercontent.com/aida-public/AB6AXuC8P9QWmkdOOWXq6XrcEKt3wdPMsFJNegdG6UQznpU9ZMfLbsW_fKTfuIWlIHInf0YMPE4MLmRNL20P6VlcsCnd6KLkvc2A_RuBSwHsKvwumkpFZT8hQKxSFUve03mZ3vKJhUIOej633ww6102SdmOn6nfPmGNiLv7WvjtVfUwnGDsbyCIQyJ_B9mmnBqpiH6NUy4eYvDJfHlzbdZ2ZhVkzk8p547M03lRA615RwLExviMZ8p62ikv_uMg-K1FGpiL7YAMp8suTSGhZ"/>
|
| 251 |
-
<span class="absolute -top-1 -right-1 bg-secondary text-on-secondary text-[10px] w-5 h-5 flex items-center justify-center rounded-full font-bold">2</span>
|
| 252 |
-
</div>
|
| 253 |
-
<div>
|
| 254 |
-
<p class="font-label-md text-label-md text-on-surface">James W.</p>
|
| 255 |
-
<p class="font-caption text-caption text-on-surface-variant">Nature Enthusiast</p>
|
| 256 |
-
</div>
|
| 257 |
-
</div>
|
| 258 |
-
<div class="text-right">
|
| 259 |
-
<p class="font-title-sm text-title-sm text-on-surface">11,200</p>
|
| 260 |
-
<p class="font-caption text-caption text-on-surface-variant">pts</p>
|
| 261 |
-
</div>
|
| 262 |
-
</div>
|
| 263 |
-
<!-- Rank 3 -->
|
| 264 |
-
<div class="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/10">
|
| 265 |
-
<div class="flex items-center gap-4">
|
| 266 |
-
<div class="relative">
|
| 267 |
-
<img class="w-12 h-12 rounded-full border-2 border-surface-container-highest" data-alt="A high-end character illustration of a person with vibrant energy, rendered with smooth gradients and soft digital lighting, fitting an eco-futuristic tech brand identity with vibrant emerald and sky blue highlights." src="https://lh3.googleusercontent.com/aida-public/AB6AXuAYkzfO_UyJ2G530wb-ov-OwK5ff3WIT-F_PjNiklYBaBkSnYBGSr6Qup5sIOA0s5OdV_QDLc6gODbXBElL5UckyQ-9ktreb80hwwU5qos-sJXJIUnmCOsYy7tFKmczpTrMWwqioCB6SoLP3N7Du7iecb4edZ5a9O_cI7FgGiIxp29xjkaFWOVWfQ26O3L3nh-gswqV1XGraa-PB8EllrAXdrATbGiQycXKanj1dS3hpbO_kdffmXE-76_8Ol_flCoWuo5gdCpgSfkp"/>
|
| 268 |
-
<span class="absolute -top-1 -right-1 bg-surface-variant text-on-surface-variant text-[10px] w-5 h-5 flex items-center justify-center rounded-full font-bold">3</span>
|
| 269 |
-
</div>
|
| 270 |
-
<div>
|
| 271 |
-
<p class="font-label-md text-label-md text-on-surface">Mila K.</p>
|
| 272 |
-
<p class="font-caption text-caption text-on-surface-variant">Carbon Reducer</p>
|
| 273 |
-
</div>
|
| 274 |
-
</div>
|
| 275 |
-
<div class="text-right">
|
| 276 |
-
<p class="font-title-sm text-title-sm text-on-surface">10,890</p>
|
| 277 |
-
<p class="font-caption text-caption text-on-surface-variant">pts</p>
|
| 278 |
-
</div>
|
| 279 |
-
</div>
|
| 280 |
-
</div>
|
| 281 |
-
</section>
|
| 282 |
-
<!-- Earned Badges Grid -->
|
| 283 |
-
<section>
|
| 284 |
-
<h2 class="font-headline-md text-headline-md text-on-surface mb-6">Achievements</h2>
|
| 285 |
-
<div class="grid grid-cols-3 gap-6">
|
| 286 |
-
<!-- Tree Planter -->
|
| 287 |
-
<div class="flex flex-col items-center gap-2 group">
|
| 288 |
-
<div class="w-20 h-20 rounded-full glass-card flex items-center justify-center badge-glow border-primary/50 relative">
|
| 289 |
-
<span class="material-symbols-outlined text-4xl text-primary" style="font-variation-settings: 'FILL' 1;">forest</span>
|
| 290 |
-
<div class="absolute inset-0 bg-primary/10 rounded-full blur-xl opacity-50 group-hover:opacity-80 transition-opacity"></div>
|
| 291 |
-
</div>
|
| 292 |
-
<span class="font-label-md text-label-md text-center">Tree Planter</span>
|
| 293 |
-
</div>
|
| 294 |
-
<!-- Carbon Ninja -->
|
| 295 |
-
<div class="flex flex-col items-center gap-2 group">
|
| 296 |
-
<div class="w-20 h-20 rounded-full glass-card flex items-center justify-center badge-glow border-secondary/50 relative">
|
| 297 |
-
<span class="material-symbols-outlined text-4xl text-secondary" style="font-variation-settings: 'FILL' 1;">shutter_speed</span>
|
| 298 |
-
<div class="absolute inset-0 bg-secondary/10 rounded-full blur-xl opacity-50 group-hover:opacity-80 transition-opacity"></div>
|
| 299 |
-
</div>
|
| 300 |
-
<span class="font-label-md text-label-md text-center">Carbon Ninja</span>
|
| 301 |
-
</div>
|
| 302 |
-
<!-- Ocean Guard -->
|
| 303 |
-
<div class="flex flex-col items-center gap-2 group grayscale opacity-50">
|
| 304 |
-
<div class="w-20 h-20 rounded-full glass-card flex items-center justify-center border-white/10">
|
| 305 |
-
<span class="material-symbols-outlined text-4xl text-on-surface-variant" style="font-variation-settings: 'FILL' 0;">water_drop</span>
|
| 306 |
-
</div>
|
| 307 |
-
<span class="font-label-md text-label-md text-center">Ocean Guard</span>
|
| 308 |
-
</div>
|
| 309 |
-
<!-- Solar Pro -->
|
| 310 |
-
<div class="flex flex-col items-center gap-2 group grayscale opacity-50">
|
| 311 |
-
<div class="w-20 h-20 rounded-full glass-card flex items-center justify-center border-white/10">
|
| 312 |
-
<span class="material-symbols-outlined text-4xl text-on-surface-variant" style="font-variation-settings: 'FILL' 0;">wb_sunny</span>
|
| 313 |
-
</div>
|
| 314 |
-
<span class="font-label-md text-label-md text-center">Solar Pro</span>
|
| 315 |
-
</div>
|
| 316 |
-
<!-- Zero Waste -->
|
| 317 |
-
<div class="flex flex-col items-center gap-2 group">
|
| 318 |
-
<div class="w-20 h-20 rounded-full glass-card flex items-center justify-center badge-glow border-tertiary/50 relative">
|
| 319 |
-
<span class="material-symbols-outlined text-4xl text-tertiary" style="font-variation-settings: 'FILL' 1;">recycling</span>
|
| 320 |
-
<div class="absolute inset-0 bg-tertiary/10 rounded-full blur-xl opacity-50 group-hover:opacity-80 transition-opacity"></div>
|
| 321 |
-
</div>
|
| 322 |
-
<span class="font-label-md text-label-md text-center">Zero Waste</span>
|
| 323 |
-
</div>
|
| 324 |
-
<!-- Eco Voyager -->
|
| 325 |
-
<div class="flex flex-col items-center gap-2 group grayscale opacity-50">
|
| 326 |
-
<div class="w-20 h-20 rounded-full glass-card flex items-center justify-center border-white/10">
|
| 327 |
-
<span class="material-symbols-outlined text-4xl text-on-surface-variant" style="font-variation-settings: 'FILL' 0;">explore</span>
|
| 328 |
-
</div>
|
| 329 |
-
<span class="font-label-md text-label-md text-center">Eco Voyager</span>
|
| 330 |
-
</div>
|
| 331 |
-
</div>
|
| 332 |
-
</section>
|
| 333 |
-
<!-- CTA -->
|
| 334 |
-
<section class="pt-4">
|
| 335 |
-
<button class="w-full py-4 bg-primary text-on-primary font-headline-md rounded-2xl bioluminescent-glow hover:scale-[1.02] active:scale-95 transition-all">
|
| 336 |
-
Discover More Challenges
|
| 337 |
-
</button>
|
| 338 |
-
</section>
|
| 339 |
-
</main>
|
| 340 |
-
<!-- Bottom Navigation Bar -->
|
| 341 |
-
<nav class="fixed bottom-0 w-full z-50 rounded-t-xl bg-surface/70 dark:bg-surface-container-low/70 backdrop-blur-lg border-t border-white/10 dark:border-white/5 shadow-[0_-4px_20px_rgba(0,0,0,0.1)]">
|
| 342 |
-
<div class="flex justify-around items-center px-4 pb-safe pt-2 w-full h-20">
|
| 343 |
-
<div class="flex flex-col items-center justify-center text-on-surface-variant dark:text-on-surface-variant/70 hover:text-primary transition-all">
|
| 344 |
-
<span class="material-symbols-outlined" data-icon="home_app_logo">home_app_logo</span>
|
| 345 |
-
<span class="font-caption text-caption">Home</span>
|
| 346 |
-
</div>
|
| 347 |
-
<div class="flex flex-col items-center justify-center text-on-surface-variant dark:text-on-surface-variant/70 hover:text-primary transition-all">
|
| 348 |
-
<span class="material-symbols-outlined" data-icon="calculate">calculate</span>
|
| 349 |
-
<span class="font-caption text-caption">Calculate</span>
|
| 350 |
-
</div>
|
| 351 |
-
<div class="flex flex-col items-center justify-center text-on-surface-variant dark:text-on-surface-variant/70 hover:text-primary transition-all">
|
| 352 |
-
<span class="material-symbols-outlined" data-icon="analytics">analytics</span>
|
| 353 |
-
<span class="font-caption text-caption">Insights</span>
|
| 354 |
-
</div>
|
| 355 |
-
<div class="flex flex-col items-center justify-center text-primary dark:text-primary-fixed bg-primary/10 rounded-xl px-3 py-1">
|
| 356 |
-
<span class="material-symbols-outlined" data-icon="workspace_premium" style="font-variation-settings: 'FILL' 1;">workspace_premium</span>
|
| 357 |
-
<span class="font-caption text-caption">Impact</span>
|
| 358 |
-
</div>
|
| 359 |
-
</div>
|
| 360 |
-
</nav>
|
| 361 |
-
<script>
|
| 362 |
-
// Micro-interactions for horizontal scroll
|
| 363 |
-
const scrollContainer = document.querySelector('.hide-scrollbar');
|
| 364 |
-
scrollContainer.addEventListener('scroll', () => {
|
| 365 |
-
// Placeholder for potential scroll-based parallax or animations
|
| 366 |
-
});
|
| 367 |
-
|
| 368 |
-
// Simple button click ripple effect
|
| 369 |
-
document.querySelectorAll('button').forEach(button => {
|
| 370 |
-
button.addEventListener('mousedown', function() {
|
| 371 |
-
this.classList.add('scale-95');
|
| 372 |
-
});
|
| 373 |
-
button.addEventListener('mouseup', function() {
|
| 374 |
-
this.classList.remove('scale-95');
|
| 375 |
-
});
|
| 376 |
-
button.addEventListener('mouseleave', function() {
|
| 377 |
-
this.classList.remove('scale-95');
|
| 378 |
-
});
|
| 379 |
-
});
|
| 380 |
-
</script>
|
| 381 |
-
</body></html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Mobile/landing_page/code.html
DELETED
|
@@ -1,318 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
|
| 3 |
-
<html class="dark" lang="en"><head>
|
| 4 |
-
<meta charset="utf-8"/>
|
| 5 |
-
<meta content="width=device-width, initial-scale=1.0, viewport-fit=cover" name="viewport"/>
|
| 6 |
-
<title>EcoTrack AI | Track. Reduce. Restore.</title>
|
| 7 |
-
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
| 8 |
-
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 9 |
-
<link href="https://fonts.googleapis.com" rel="preconnect"/>
|
| 10 |
-
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
|
| 11 |
-
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" rel="stylesheet"/>
|
| 12 |
-
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 13 |
-
<style>
|
| 14 |
-
:root {
|
| 15 |
-
--glass-border: rgba(255, 255, 255, 0.1);
|
| 16 |
-
--glass-bg: rgba(30, 41, 59, 0.5);
|
| 17 |
-
--emerald-glow: rgba(78, 222, 163, 0.3);
|
| 18 |
-
}
|
| 19 |
-
body {
|
| 20 |
-
background-color: #0b1326;
|
| 21 |
-
color: #dae2fd;
|
| 22 |
-
font-family: 'Geist', sans-serif;
|
| 23 |
-
overflow-x: hidden;
|
| 24 |
-
}
|
| 25 |
-
.glass-card {
|
| 26 |
-
backdrop-filter: blur(12px);
|
| 27 |
-
background: var(--glass-bg);
|
| 28 |
-
border: 1px solid var(--glass-border);
|
| 29 |
-
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
| 30 |
-
}
|
| 31 |
-
.emerald-glow-btn {
|
| 32 |
-
box-shadow: 0 0 20px var(--emerald-glow);
|
| 33 |
-
transition: all 0.3s ease;
|
| 34 |
-
}
|
| 35 |
-
.emerald-glow-btn:active {
|
| 36 |
-
transform: scale(0.95);
|
| 37 |
-
box-shadow: 0 0 10px var(--emerald-glow);
|
| 38 |
-
}
|
| 39 |
-
.eco-gradient-text {
|
| 40 |
-
background: linear-gradient(135deg, #4edea3 0%, #89ceff 100%);
|
| 41 |
-
-webkit-background-clip: text;
|
| 42 |
-
-webkit-text-fill-color: transparent;
|
| 43 |
-
}
|
| 44 |
-
.floating {
|
| 45 |
-
animation: floating 6s ease-in-out infinite;
|
| 46 |
-
}
|
| 47 |
-
@keyframes floating {
|
| 48 |
-
0% { transform: translateY(0px); }
|
| 49 |
-
50% { transform: translateY(-20px); }
|
| 50 |
-
100% { transform: translateY(0px); }
|
| 51 |
-
}
|
| 52 |
-
.mesh-gradient {
|
| 53 |
-
background-color: #0b1326;
|
| 54 |
-
background-image:
|
| 55 |
-
radial-gradient(at 0% 0%, rgba(78, 222, 163, 0.15) 0, transparent 50%),
|
| 56 |
-
radial-gradient(at 100% 0%, rgba(0, 162, 230, 0.15) 0, transparent 50%);
|
| 57 |
-
}
|
| 58 |
-
</style>
|
| 59 |
-
<script id="tailwind-config">
|
| 60 |
-
tailwind.config = {
|
| 61 |
-
darkMode: "class",
|
| 62 |
-
theme: {
|
| 63 |
-
extend: {
|
| 64 |
-
"colors": {
|
| 65 |
-
"surface-container-lowest": "#060e20",
|
| 66 |
-
"inverse-surface": "#dae2fd",
|
| 67 |
-
"on-error-container": "#ffdad6",
|
| 68 |
-
"primary": "#4edea3",
|
| 69 |
-
"on-primary-container": "#00422b",
|
| 70 |
-
"surface": "#0b1326",
|
| 71 |
-
"surface-container-highest": "#2d3449",
|
| 72 |
-
"on-tertiary-container": "#523200",
|
| 73 |
-
"surface-container": "#171f33",
|
| 74 |
-
"on-surface": "#dae2fd",
|
| 75 |
-
"outline-variant": "#3c4a42",
|
| 76 |
-
"surface-variant": "#2d3449",
|
| 77 |
-
"on-tertiary-fixed": "#2a1700",
|
| 78 |
-
"on-tertiary-fixed-variant": "#653e00",
|
| 79 |
-
"primary-fixed": "#6ffbbe",
|
| 80 |
-
"on-secondary-container": "#00344e",
|
| 81 |
-
"inverse-on-surface": "#283044",
|
| 82 |
-
"tertiary-container": "#e29100",
|
| 83 |
-
"tertiary": "#ffb95f",
|
| 84 |
-
"surface-bright": "#31394d",
|
| 85 |
-
"on-secondary-fixed-variant": "#004c6e",
|
| 86 |
-
"error-container": "#93000a",
|
| 87 |
-
"inverse-primary": "#006c49",
|
| 88 |
-
"on-secondary": "#00344d",
|
| 89 |
-
"on-primary": "#003824",
|
| 90 |
-
"background": "#0b1326",
|
| 91 |
-
"error": "#ffb4ab",
|
| 92 |
-
"primary-fixed-dim": "#4edea3",
|
| 93 |
-
"on-primary-fixed": "#002113",
|
| 94 |
-
"on-tertiary": "#472a00",
|
| 95 |
-
"secondary-fixed-dim": "#89ceff",
|
| 96 |
-
"outline": "#86948a",
|
| 97 |
-
"primary-container": "#10b981",
|
| 98 |
-
"surface-tint": "#4edea3",
|
| 99 |
-
"surface-dim": "#0b1326",
|
| 100 |
-
"on-error": "#690005",
|
| 101 |
-
"secondary-fixed": "#c9e6ff",
|
| 102 |
-
"surface-container-low": "#131b2e",
|
| 103 |
-
"on-secondary-fixed": "#001e2f",
|
| 104 |
-
"on-surface-variant": "#bbcabf",
|
| 105 |
-
"tertiary-fixed-dim": "#ffb95f",
|
| 106 |
-
"secondary": "#89ceff",
|
| 107 |
-
"tertiary-fixed": "#ffddb8",
|
| 108 |
-
"surface-container-high": "#222a3d",
|
| 109 |
-
"secondary-container": "#00a2e6",
|
| 110 |
-
"on-primary-fixed-variant": "#005236",
|
| 111 |
-
"on-background": "#dae2fd"
|
| 112 |
-
},
|
| 113 |
-
"borderRadius": {
|
| 114 |
-
"DEFAULT": "0.25rem",
|
| 115 |
-
"lg": "0.5rem",
|
| 116 |
-
"xl": "0.75rem",
|
| 117 |
-
"full": "9999px"
|
| 118 |
-
},
|
| 119 |
-
"spacing": {
|
| 120 |
-
"md": "1.5rem",
|
| 121 |
-
"unit": "4px",
|
| 122 |
-
"xs": "0.5rem",
|
| 123 |
-
"xl": "4rem",
|
| 124 |
-
"margin": "2rem",
|
| 125 |
-
"sm": "1rem",
|
| 126 |
-
"gutter": "1.5rem",
|
| 127 |
-
"lg": "2.5rem"
|
| 128 |
-
},
|
| 129 |
-
"fontFamily": {
|
| 130 |
-
"caption": ["Geist"],
|
| 131 |
-
"title-sm": ["Geist"],
|
| 132 |
-
"display-lg": ["Geist"],
|
| 133 |
-
"display-lg-mobile": ["Geist"],
|
| 134 |
-
"headline-md": ["Geist"],
|
| 135 |
-
"body-md": ["Geist"],
|
| 136 |
-
"label-md": ["Geist"]
|
| 137 |
-
},
|
| 138 |
-
"fontSize": {
|
| 139 |
-
"caption": ["12px", {"lineHeight": "1.2", "fontWeight": "400"}],
|
| 140 |
-
"title-sm": ["18px", {"lineHeight": "1.4", "fontWeight": "600"}],
|
| 141 |
-
"display-lg": ["48px", {"lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
| 142 |
-
"display-lg-mobile": ["32px", {"lineHeight": "1.2", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
| 143 |
-
"headline-md": ["24px", {"lineHeight": "1.3", "fontWeight": "600"}],
|
| 144 |
-
"body-md": ["16px", {"lineHeight": "1.6", "fontWeight": "400"}],
|
| 145 |
-
"label-md": ["14px", {"lineHeight": "1.2", "letterSpacing": "0.01em", "fontWeight": "500"}]
|
| 146 |
-
}
|
| 147 |
-
},
|
| 148 |
-
},
|
| 149 |
-
}
|
| 150 |
-
</script>
|
| 151 |
-
<style>
|
| 152 |
-
body {
|
| 153 |
-
min-height: max(884px, 100dvh);
|
| 154 |
-
}
|
| 155 |
-
</style>
|
| 156 |
-
</head>
|
| 157 |
-
<body class="mesh-gradient min-h-screen flex flex-col items-center">
|
| 158 |
-
<!-- Top Navigation Bar (Shared Component) -->
|
| 159 |
-
<nav class="fixed top-0 w-full z-50 bg-surface/50 dark:bg-surface-container/50 backdrop-blur-xl border-b border-white/10 dark:border-white/5 shadow-sm backdrop-saturate-150">
|
| 160 |
-
<div class="flex justify-between items-center px-margin h-16 w-full max-w-7xl mx-auto">
|
| 161 |
-
<div class="flex items-center gap-3">
|
| 162 |
-
<div class="w-8 h-8 rounded-full overflow-hidden bg-primary-container flex items-center justify-center">
|
| 163 |
-
<span class="material-symbols-outlined text-on-primary-container text-[20px]">eco</span>
|
| 164 |
-
</div>
|
| 165 |
-
<span class="font-headline-md text-headline-md font-bold text-primary dark:text-primary-fixed tracking-tight">EcoTrack AI</span>
|
| 166 |
-
</div>
|
| 167 |
-
<button class="w-10 h-10 rounded-full flex items-center justify-center text-on-surface-variant dark:text-on-surface-variant hover:bg-white/10 transition-colors active:scale-95 duration-100">
|
| 168 |
-
<span class="material-symbols-outlined">notifications</span>
|
| 169 |
-
</button>
|
| 170 |
-
</div>
|
| 171 |
-
</nav>
|
| 172 |
-
<main class="w-full pt-24 pb-32 px-4 max-w-md">
|
| 173 |
-
<!-- Hero Section -->
|
| 174 |
-
<section class="flex flex-col items-center text-center space-y-8 mb-16">
|
| 175 |
-
<div class="relative w-full aspect-square max-w-[280px] flex items-center justify-center">
|
| 176 |
-
<!-- 3D Effect Globe Graphic (Placeholder for visualization) -->
|
| 177 |
-
<div class="absolute inset-0 bg-primary/10 rounded-full blur-[60px] animate-pulse"></div>
|
| 178 |
-
<div class="relative z-10 floating">
|
| 179 |
-
<img alt="Eco Globe Graphic" class="w-48 h-48 drop-shadow-[0_0_30px_rgba(78,222,163,0.5)]" data-alt="A futuristic, high-tech 3D render of a stylized digital Earth globe. The globe is translucent and glowing with neon emerald and sky blue pulse lines that represent carbon data streams. It is set against a deep navy blue atmospheric background with soft particle effects, reflecting an optimistic and visionary eco-technology aesthetic. Soft cinematic lighting highlights the smooth edges and glass-like texture of the digital continents." src="https://lh3.googleusercontent.com/aida-public/AB6AXuARn6LPgO5xyl1SYMESvfRFJ_QHX4-deMVJfFEy08qvNhxSsOptjhx-Iigx2GLU0wmocEvqJilo9lEz9WA235ci5zKt-rwHvD3Ljr_LWLBQn18zAHnjvCzaPQ0IWbBWa1mKgQM8tWpBcp9tfLDIK16fbSpto8P6iXJW8jnqwSv-OUg1iSBbL9nvTdwra8Ji6ZdTWJNu8Lnc6W7ubegZqVewLe59t5RHwRX-o2dSR3A6xOJKSNAhvxMNj5gProo2em903alVTcOr_3Ty"/>
|
| 180 |
-
</div>
|
| 181 |
-
</div>
|
| 182 |
-
<div class="space-y-4">
|
| 183 |
-
<h1 class="font-display-lg-mobile text-display-lg-mobile eco-gradient-text tracking-tight uppercase">
|
| 184 |
-
EcoTrack AI
|
| 185 |
-
</h1>
|
| 186 |
-
<p class="font-title-sm text-title-sm text-on-surface-variant">
|
| 187 |
-
Track. Reduce. Restore.
|
| 188 |
-
</p>
|
| 189 |
-
<p class="font-body-md text-body-md text-on-surface/70 px-4">
|
| 190 |
-
The world's most advanced AI-powered individual carbon accounting platform.
|
| 191 |
-
</p>
|
| 192 |
-
</div>
|
| 193 |
-
<button class="w-full py-4 bg-primary text-on-primary font-headline-md text-[18px] rounded-xl emerald-glow-btn font-bold">
|
| 194 |
-
Start Your Journey
|
| 195 |
-
</button>
|
| 196 |
-
</section>
|
| 197 |
-
<!-- Stats Grid -->
|
| 198 |
-
<section class="grid grid-cols-2 gap-4 mb-16">
|
| 199 |
-
<div class="glass-card rounded-xl p-4 text-center">
|
| 200 |
-
<span class="font-display-lg-mobile text-[24px] text-primary block">50k+</span>
|
| 201 |
-
<span class="font-caption text-caption text-on-surface-variant uppercase tracking-wider">Tons CO2 Saved</span>
|
| 202 |
-
</div>
|
| 203 |
-
<div class="glass-card rounded-xl p-4 text-center">
|
| 204 |
-
<span class="font-display-lg-mobile text-[24px] text-secondary block">100k+</span>
|
| 205 |
-
<span class="font-caption text-caption text-on-surface-variant uppercase tracking-wider">Eco Warriors</span>
|
| 206 |
-
</div>
|
| 207 |
-
</section>
|
| 208 |
-
<!-- Feature Cards Section -->
|
| 209 |
-
<section class="space-y-6">
|
| 210 |
-
<h2 class="font-headline-md text-headline-md text-primary px-2">Ecosystem Features</h2>
|
| 211 |
-
<!-- Real-time Tracking -->
|
| 212 |
-
<div class="glass-card rounded-2xl p-6 flex flex-col space-y-4">
|
| 213 |
-
<div class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
| 214 |
-
<span class="material-symbols-outlined text-primary text-[32px]">query_stats</span>
|
| 215 |
-
</div>
|
| 216 |
-
<div>
|
| 217 |
-
<h3 class="font-title-sm text-title-sm text-white mb-2">Real-time Tracking</h3>
|
| 218 |
-
<p class="font-body-md text-body-md text-on-surface-variant">Automated daily carbon footprint monitoring powered by IoT and bank integration.</p>
|
| 219 |
-
</div>
|
| 220 |
-
</div>
|
| 221 |
-
<!-- AI Coach -->
|
| 222 |
-
<div class="glass-card rounded-2xl p-6 flex flex-col space-y-4">
|
| 223 |
-
<div class="w-12 h-12 bg-secondary/10 rounded-lg flex items-center justify-center">
|
| 224 |
-
<span class="material-symbols-outlined text-secondary text-[32px]">smart_toy</span>
|
| 225 |
-
</div>
|
| 226 |
-
<div>
|
| 227 |
-
<h3 class="font-title-sm text-title-sm text-white mb-2">AI Coach</h3>
|
| 228 |
-
<p class="font-body-md text-body-md text-on-surface-variant">Personalized reduction strategies and smart suggestions based on your lifestyle data.</p>
|
| 229 |
-
</div>
|
| 230 |
-
</div>
|
| 231 |
-
<!-- Global Challenges -->
|
| 232 |
-
<div class="glass-card rounded-2xl p-6 flex flex-col space-y-4">
|
| 233 |
-
<div class="w-12 h-12 bg-tertiary/10 rounded-lg flex items-center justify-center">
|
| 234 |
-
<span class="material-symbols-outlined text-tertiary text-[32px]">public</span>
|
| 235 |
-
</div>
|
| 236 |
-
<div>
|
| 237 |
-
<h3 class="font-title-sm text-title-sm text-white mb-2">Global Challenges</h3>
|
| 238 |
-
<p class="font-body-md text-body-md text-on-surface-variant">Join worldwide missions to plant forests and restore coral reefs with your saved credits.</p>
|
| 239 |
-
</div>
|
| 240 |
-
</div>
|
| 241 |
-
</section>
|
| 242 |
-
<!-- Progress Visualizer (Visual extra) -->
|
| 243 |
-
<section class="mt-16 glass-card rounded-2xl p-6 overflow-hidden relative">
|
| 244 |
-
<div class="relative z-10">
|
| 245 |
-
<h3 class="font-title-sm text-title-sm mb-4">Global Sustainability Progress</h3>
|
| 246 |
-
<div class="h-3 w-full bg-surface-container-highest rounded-full overflow-hidden">
|
| 247 |
-
<div class="h-full bg-gradient-to-r from-secondary to-primary w-[65%] rounded-full relative">
|
| 248 |
-
<div class="absolute inset-0 bg-white/20 animate-[pulse_2s_infinite]"></div>
|
| 249 |
-
</div>
|
| 250 |
-
</div>
|
| 251 |
-
<div class="flex justify-between mt-2 font-caption text-caption text-on-surface-variant">
|
| 252 |
-
<span>Current: 65% Target</span>
|
| 253 |
-
<span>Goal: 100% Net Zero</span>
|
| 254 |
-
</div>
|
| 255 |
-
</div>
|
| 256 |
-
</section>
|
| 257 |
-
</main>
|
| 258 |
-
<!-- Bottom Navigation Bar (Shared Component) -->
|
| 259 |
-
<nav class="fixed bottom-0 w-full z-50 rounded-t-xl bg-surface/70 dark:bg-surface-container-low/70 backdrop-blur-lg border-t border-white/10 dark:border-white/5 shadow-[0_-4px_20px_rgba(0,0,0,0.1)]">
|
| 260 |
-
<div class="flex justify-around items-center px-4 pb-safe pt-2 w-full h-16">
|
| 261 |
-
<div class="flex flex-col items-center justify-center text-primary dark:text-primary-fixed bg-primary/10 rounded-xl px-3 py-1 scale-90 duration-200">
|
| 262 |
-
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">home_app_logo</span>
|
| 263 |
-
<span class="font-caption text-caption mt-0.5">Home</span>
|
| 264 |
-
</div>
|
| 265 |
-
<div class="flex flex-col items-center justify-center text-on-surface-variant dark:text-on-surface-variant/70 hover:text-primary transition-all">
|
| 266 |
-
<span class="material-symbols-outlined">calculate</span>
|
| 267 |
-
<span class="font-caption text-caption mt-0.5">Calculate</span>
|
| 268 |
-
</div>
|
| 269 |
-
<div class="flex flex-col items-center justify-center text-on-surface-variant dark:text-on-surface-variant/70 hover:text-primary transition-all">
|
| 270 |
-
<span class="material-symbols-outlined">analytics</span>
|
| 271 |
-
<span class="font-caption text-caption mt-0.5">Insights</span>
|
| 272 |
-
</div>
|
| 273 |
-
<div class="flex flex-col items-center justify-center text-on-surface-variant dark:text-on-surface-variant/70 hover:text-primary transition-all">
|
| 274 |
-
<span class="material-symbols-outlined">workspace_premium</span>
|
| 275 |
-
<span class="font-caption text-caption mt-0.5">Impact</span>
|
| 276 |
-
</div>
|
| 277 |
-
</div>
|
| 278 |
-
</nav>
|
| 279 |
-
<script>
|
| 280 |
-
// Simple counter animation for stats
|
| 281 |
-
const counters = document.querySelectorAll('.glass-card span.font-display-lg-mobile');
|
| 282 |
-
const speed = 200;
|
| 283 |
-
|
| 284 |
-
counters.forEach(counter => {
|
| 285 |
-
const animate = () => {
|
| 286 |
-
const targetText = counter.innerText;
|
| 287 |
-
const value = parseInt(targetText.replace(/[^\d]/g, ''));
|
| 288 |
-
const suffix = targetText.replace(/[\d]/g, '');
|
| 289 |
-
|
| 290 |
-
let current = 0;
|
| 291 |
-
const step = value / speed;
|
| 292 |
-
|
| 293 |
-
const update = () => {
|
| 294 |
-
if (current < value) {
|
| 295 |
-
current += step;
|
| 296 |
-
counter.innerText = Math.ceil(current) + suffix;
|
| 297 |
-
setTimeout(update, 1);
|
| 298 |
-
} else {
|
| 299 |
-
counter.innerText = targetText;
|
| 300 |
-
}
|
| 301 |
-
}
|
| 302 |
-
update();
|
| 303 |
-
}
|
| 304 |
-
animate();
|
| 305 |
-
});
|
| 306 |
-
|
| 307 |
-
// Add touch feedback to cards
|
| 308 |
-
document.querySelectorAll('.glass-card').forEach(card => {
|
| 309 |
-
card.addEventListener('touchstart', () => {
|
| 310 |
-
card.style.transform = 'scale(0.98)';
|
| 311 |
-
card.style.transition = 'transform 0.1s ease';
|
| 312 |
-
});
|
| 313 |
-
card.addEventListener('touchend', () => {
|
| 314 |
-
card.style.transform = 'scale(1)';
|
| 315 |
-
});
|
| 316 |
-
});
|
| 317 |
-
</script>
|
| 318 |
-
</body></html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -1,114 +1,398 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
EcoTrack AI
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
---
|
| 8 |
|
| 9 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
| 18 |
|
| 19 |
---
|
| 20 |
|
| 21 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
###
|
| 24 |
-
* **Framework**: React Native with Expo (Web + iOS/Android support).
|
| 25 |
-
* **Styling**: Vanilla React Native StyleSheet with custom HSL/RGB glassmorphism dark-theme palette.
|
| 26 |
-
* **Graphics**: Svg vector paths (`react-native-svg`) for charts and telemetry.
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
---
|
| 35 |
|
| 36 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
##
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
```bash
|
| 45 |
cd backend
|
|
|
|
| 46 |
```
|
| 47 |
-
2.
|
| 48 |
-
``
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
* Generate a private key JSON from the Firebase Console (Project Settings -> Service Accounts).
|
| 53 |
-
* Place the file in `backend/firebase-key.json`.
|
| 54 |
-
* *If no key is added, EcoTrack will automatically fallback to the local mock database file.*
|
| 55 |
-
4. Start the API server in developer mode:
|
| 56 |
-
```bash
|
| 57 |
-
npm run dev
|
| 58 |
-
```
|
| 59 |
-
The backend server runs locally on **`http://localhost:5000`** (or port `7860` if configured).
|
| 60 |
|
| 61 |
-
|
| 62 |
-
1. Navigate back to the root folder:
|
| 63 |
```bash
|
| 64 |
-
cd ..
|
|
|
|
| 65 |
```
|
| 66 |
-
2.
|
| 67 |
-
```
|
| 68 |
-
|
| 69 |
```
|
| 70 |
-
3. Set your environment variables:
|
| 71 |
-
* Create a `.env` or configuration mapping setting `EXPO_PUBLIC_API_BASE` pointing to your backend IP (e.g. `http://localhost:5000` for local run).
|
| 72 |
-
4. Run the Expo development web environment:
|
| 73 |
-
```bash
|
| 74 |
-
npx expo start --web
|
| 75 |
-
```
|
| 76 |
-
The app will compile and launch on **`http://localhost:8081`**.
|
| 77 |
|
| 78 |
-
|
|
|
|
|
|
|
| 79 |
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
-
|
| 83 |
|
| 84 |
-
Run the tests inside the `backend` directory:
|
| 85 |
```bash
|
| 86 |
-
|
|
|
|
| 87 |
```
|
| 88 |
|
| 89 |
-
|
| 90 |
|
| 91 |
---
|
| 92 |
|
| 93 |
-
##
|
| 94 |
|
| 95 |
-
|
| 96 |
-
The backend is packaged inside a custom Docker container optimized for port 7860:
|
| 97 |
-
1. Define space env variables:
|
| 98 |
-
* `FIREBASE_SERVICE_ACCOUNT`: Copy the raw JSON text from your service account key.
|
| 99 |
-
2. Build and trigger:
|
| 100 |
-
* Use the provided `backend/Dockerfile` to compile the image. Hugging Face Spaces will automatically run a health check on `/` and mark the container online.
|
| 101 |
|
| 102 |
-
###
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
---
|
| 110 |
|
| 111 |
-
##
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: EcoTracker II
|
| 3 |
+
emoji: 📉
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
short_description: Backend for the event Prompt Wars
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# EcoTrack AI — Advanced Carbon Footprint Awareness Platform
|
| 12 |
+
|
| 13 |
+
EcoTrack AI is a state-of-the-art web application designed to help users measure, understand, and reduce their carbon footprint. Powered by **Groq Cloud AI**, the platform offers a personalized AI sustainability coach, 30-day carbon reduction action plans, interactive "what-if" simulations, and a gamified leaderboard with streaks, points, and badges to drive user engagement.
|
| 14 |
+
|
| 15 |
+
[](./LICENSE)
|
| 16 |
+
[](https://nodejs.org/)
|
| 17 |
+
[](https://firebase.google.com/)
|
| 18 |
+
[](https://groq.com/)
|
| 19 |
+
[](#testing-and-verification)
|
| 20 |
+
|
| 21 |
+
**Live Demo:** [eco-tracker-ii-123.vercel.app](https://eco-tracker-ii-123.vercel.app/)
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## Table of Contents
|
| 26 |
+
|
| 27 |
+
1. [Architecture & Database Transition](#architecture--database-transition)
|
| 28 |
+
2. [Groq AI Integration & Usage](#groq-ai-integration--usage)
|
| 29 |
+
3. [Scientific Calculation & Emission Factors](#scientific-calculation--emission-factors)
|
| 30 |
+
4. [Scoring & Benchmark Interpolation](#scoring--benchmark-interpolation)
|
| 31 |
+
5. [Gamification & Achievements System](#gamification--achievements-system)
|
| 32 |
+
6. [Tech Stack](#tech-stack)
|
| 33 |
+
7. [Security Hardening Techniques](#security-hardening-techniques)
|
| 34 |
+
8. [Folder Structure](#folder-structure)
|
| 35 |
+
9. [Page Use Cases & Features](#page-use-cases--features)
|
| 36 |
+
10. [Installation & Setup](#installation--setup)
|
| 37 |
+
11. [Testing and Verification](#testing-and-verification)
|
| 38 |
+
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
+
## Architecture & Database Transition
|
| 42 |
+
|
| 43 |
+
Originally built on top of a relational schema using Prisma and PostgreSQL, EcoTrack AI was transitioned to **Firebase Firestore** to align with modern serverless and document-based practices.
|
| 44 |
+
|
| 45 |
+
```
|
| 46 |
+
┌─────────────────────────────────┐ ┌───────────────────────────────┐
|
| 47 |
+
│ React 18 Frontend │ ──────────────► │ Express 4 Backend │
|
| 48 |
+
│ (Vite, Tailwind, Recharts) │ HTTPS/REST │ (Helmet, Rate-Limiting, Zod)│
|
| 49 |
+
└─────────────────────────────────┘ └──────────────┬────────────────┘
|
| 50 |
+
│
|
| 51 |
+
firebase-admin│ groq-sdk
|
| 52 |
+
▼ ▼
|
| 53 |
+
┌───────────────────────────────┐
|
| 54 |
+
│ Firebase Firestore Database │
|
| 55 |
+
│ (Users, Assessments, Streaks)│
|
| 56 |
+
└───────────────────────────────┘
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### Why Firebase Firestore?
|
| 60 |
+
|
| 61 |
+
- **Document-Centric Model**: Carbon assessments and simulation states are inherently tree-like, hierarchical data. A document-based NoSQL database allows storing user metrics and footprints as direct, rich documents rather than performing heavy relational joins.
|
| 62 |
+
- **Schema Flexibility**: Enables future expansion of assessment categories (e.g., adding home heating types or detailed food options) without writing complex SQL schema migrations.
|
| 63 |
+
- **Serverless Scale**: Low-latency reads and writes that scale automatically, providing instantaneous calculations and ranking checks.
|
| 64 |
+
- **Data Schema Details**:
|
| 65 |
+
- `users`: Stores user credentials, streaks, ranks, points, and earned badges.
|
| 66 |
+
- `assessments`: Flat collection storing historical carbon footprints indexed by `userId`.
|
| 67 |
+
- `simulations`: Captures user configuration states for what-if scenarios.
|
| 68 |
+
|
| 69 |
+
### Hermetic Test Mocking
|
| 70 |
+
|
| 71 |
+
To ensure integration and unit tests run instantly and are completely insulated from external database network states, the backend uses a custom in-memory Firestore engine in [`firebaseClient.js`](./backend/src/utils/firebaseClient.js).
|
| 72 |
+
|
| 73 |
+
- When running in `test` environment, the Firebase Admin connection is bypassed.
|
| 74 |
+
- A robust, virtual mock store runs in memory to manage users and assessments, executing 160+ tests in under 5 seconds with zero server dependencies.
|
| 75 |
+
|
| 76 |
+
### Version 1 to Version 2 Database Migration
|
| 77 |
+
|
| 78 |
+
To preserve history, the backend includes a migration utility in [`migrateDb.js`](./backend/src/utils/migrateDb.js). This script reads historical footprint documents from the V1 subcollection structure (`users/{userId}/history`) and migrates them into flat V2 collections. It parses legacy breakdown objects (extracting transport, electricity, food, waste, and shopping emissions) and populates modern metadata variables automatically.
|
| 79 |
+
|
| 80 |
+
---
|
| 81 |
|
| 82 |
+
## Groq AI Integration & Usage
|
| 83 |
|
| 84 |
+
EcoTrack AI integrates the **Groq SDK** to provide lightning-fast, context-aware artificial intelligence. Powered by high-throughput Llama-3 inference models, the AI operates across five main areas:
|
| 85 |
+
|
| 86 |
+
1. **AI Sustainability Coach (`POST /api/ai/chat`)**
|
| 87 |
+
- An interactive chat assistant where users can ask for eco-friendly recommendations.
|
| 88 |
+
- Provides immediate, real-world advice based on the user's specific carbon footprint breakdown.
|
| 89 |
+
2. **30-Day Net-Zero Action Plan (`POST /api/ai/plan`)**
|
| 90 |
+
- Generates a step-by-step 30-day timeline designed to lower the user's carbon footprint.
|
| 91 |
+
- Customizes actions depending on whether the user's highest emissions originate from Transport, Energy, Food, or Shopping.
|
| 92 |
+
3. **Narrative Score Analysis (`POST /api/ai/score`)**
|
| 93 |
+
- Looks at a user's numerical sustainability score and writes a narrative assessment explaining what their rating signifies.
|
| 94 |
+
4. **Daily Eco Micro-Tip (`GET /api/ai/tip`)**
|
| 95 |
+
- Generates a daily tip centered on sustainable living.
|
| 96 |
+
- Caches tips on the server for 24 hours to minimize API hits.
|
| 97 |
+
5. **Narrative Comparison Analysis (`POST /api/ai/compare`)**
|
| 98 |
+
- Explains how the user's footprint compares to global averages and the Paris Agreement target (2,000 kg CO₂e/yr).
|
| 99 |
|
| 100 |
---
|
| 101 |
|
| 102 |
+
## Scientific Calculation & Emission Factors
|
| 103 |
+
|
| 104 |
+
EcoTrack AI calculates carbon footprints utilizing annualized kilograms of carbon dioxide equivalent ($kg\text{ CO}_2\text{e/year}$) based on coefficients compiled from peer-reviewed databases (including IPCC AR6, US EPA, and UK BEIS/DEFRA 2023).
|
| 105 |
+
|
| 106 |
+
### Reference Emission Factors
|
| 107 |
+
|
| 108 |
+
| Source Category | Asset/Habit Type | Carbon Coefficient | Unit |
|
| 109 |
+
| :-------------- | :---------------- | :----------------- | :----------------------------------------------------------------- |
|
| 110 |
+
| **Transport** | Petrol Car | `0.21` | $kg\text{ CO}_2\text{ / km}$ |
|
| 111 |
+
| | Diesel Car | `0.17` | $kg\text{ CO}_2\text{ / km}$ |
|
| 112 |
+
| | Hybrid Car | `0.105` | $kg\text{ CO}_2\text{ / km}$ |
|
| 113 |
+
| | Electric Car | `0.047` | $kg\text{ CO}_2\text{ / km}$ |
|
| 114 |
+
| | Public Transport | `0.089` | $kg\text{ CO}_2\text{ / km}$ (Bus average) |
|
| 115 |
+
| | Train | `0.041` | $kg\text{ CO}_2\text{ / km}$ |
|
| 116 |
+
| | Short-Haul Flight | `255.0` | $kg\text{ CO}_2\text{ / return flight } (<3\text{ hrs})$ |
|
| 117 |
+
| | Long-Haul Flight | `1620.0` | $kg\text{ CO}_2\text{ / return flight } (>3\text{ hrs})$ |
|
| 118 |
+
| **Energy** | Grid Electricity | `0.233` | $kg\text{ CO}_2\text{ / kWh}$ (UK Grid 2023) |
|
| 119 |
+
| **Food & Diet** | Heavy Meat Diet | `3300.0` | $kg\text{ CO}_2\text{ / year}$ |
|
| 120 |
+
| | Mixed Diet | `2500.0` | $kg\text{ CO}_2\text{ / year}$ |
|
| 121 |
+
| | Vegetarian Diet | `1700.0` | $kg\text{ CO}_2\text{ / year}$ |
|
| 122 |
+
| | Vegan Diet | `1500.0` | $kg\text{ CO}_2\text{ / year}$ |
|
| 123 |
+
| **Shopping** | Clothing Item | `33.0` | $kg\text{ CO}_2\text{ / item}$ (Manufacturing + Transport) |
|
| 124 |
+
| | Electronic Device | `300.0` | $kg\text{ CO}_2\text{ / device}$ (Lifecycle manufacturing average) |
|
| 125 |
|
| 126 |
+
### Formula Systems
|
| 127 |
+
|
| 128 |
+
- **Transport Emissions**:
|
| 129 |
+
$$\text{Emissions}_{\text{transport}} = (\text{Daily Car Km} \times \text{Factor}_{\text{car}} \times 365) + (\text{Public Transport Km/Week} \times 52 \times 0.089) + (\text{Short Flights} \times 255) + (\text{Long Flights} \times 1620)$$
|
| 130 |
+
- **Home Energy Emissions** (incorporating renewable energy tariff credits):
|
| 131 |
+
$$\text{Emissions}_{\text{energy}} = \text{Monthly Electricity (kWh)} \times 12 \times 0.233 \times \left(1 - \frac{\text{Renewable \%}}{100}\right)$$
|
| 132 |
+
- **Shopping Emissions**:
|
| 133 |
+
$$\text{Emissions}_{\text{shopping}} = (\text{Clothing/Year} \times 33) + (\text{Electronics/Year} \times 300)$$
|
| 134 |
|
| 135 |
---
|
| 136 |
|
| 137 |
+
## Scoring & Benchmark Interpolation
|
| 138 |
+
|
| 139 |
+
Sustainability scores range from **0 to 100** based on the total annualized carbon footprint. Instead of flat rating brackets, EcoTrack AI applies **piecewise linear interpolation** across five primary benchmark reference points:
|
| 140 |
+
|
| 141 |
+
- **Paris Agreement 2050 Target**: $2,000\text{ kg CO}_2\text{e/year}$ (Score: **100**)
|
| 142 |
+
- **Excellent Threshold**: $3,000\text{ kg CO}_2\text{e/year}$ (Score: **90**)
|
| 143 |
+
- **Good Threshold**: $5,000\text{ kg CO}_2\text{e/year}$ (Score: **70**)
|
| 144 |
+
- **Moderate Threshold**: $7,500\text{ kg CO}_2\text{e/year}$ (Score: **50**)
|
| 145 |
+
- **High/Poor Threshold**: $12,000\text{ kg CO}_2\text{e/year}$ (Score: **0**)
|
| 146 |
+
|
| 147 |
+
### Score Interpolation Logic
|
| 148 |
+
|
| 149 |
+
- **If emissions $\le 2000$**: Score is **100**.
|
| 150 |
+
- **If $2000 < \text{emissions} \le 3000$**:
|
| 151 |
+
$$\text{Score} = 90 + \left(\frac{3000 - \text{emissions}}{3000 - 2000}\right) \times 10$$
|
| 152 |
+
- **If $3000 < \text{emissions} \le 5000$**:
|
| 153 |
+
$$\text{Score} = 70 + \left(\frac{5000 - \text{emissions}}{5000 - 3000}\right) \times 20$$
|
| 154 |
+
- **If $5000 < \text{emissions} \le 7500$**:
|
| 155 |
+
$$\text{Score} = 50 + \left(\frac{7500 - \text{emissions}}{7500 - 5000}\right) \times 20$$
|
| 156 |
+
- **If $7500 < \text{emissions} < 12000$**:
|
| 157 |
+
$$\text{Score} = \left(\frac{12000 - \text{emissions}}{12000 - 7500}\right) \times 50$$
|
| 158 |
+
- **If emissions $\ge 12000$**: Score is **0**.
|
| 159 |
+
|
| 160 |
+
---
|
| 161 |
+
|
| 162 |
+
## Gamification & Achievements System
|
| 163 |
+
|
| 164 |
+
EcoTrack AI builds tracking habits by rewarding sustainable choices through a points, streaks, badges, and rank titles system.
|
| 165 |
|
| 166 |
+
### Points Allocation
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
+
- **Assessment Submission**: Base reward of **$100\text{ points}$** per unique assessment log.
|
| 169 |
+
- **Streak Milestone Bonuses**:
|
| 170 |
+
- **3-Day Streak**: Adds a **$+50\text{ points}$** bonus.
|
| 171 |
+
- **7-Day Streak**: Adds a **$+250\text{ points}$** bonus.
|
| 172 |
+
|
| 173 |
+
### Ranks & Levels
|
| 174 |
+
|
| 175 |
+
Based on total points accumulated, users level up through ranks:
|
| 176 |
+
|
| 177 |
+
| Points Required | Rank Title |
|
| 178 |
+
| :---------------- | :---------------------------------- |
|
| 179 |
+
| `0 - 999` | **Eco Novice** (Baseline tier) |
|
| 180 |
+
| `1,000 - 2,999` | **Green Sprout** |
|
| 181 |
+
| `3,000 - 5,999` | **Carbon Reducer** |
|
| 182 |
+
| `6,000 - 9,999` | **Nature Enthusiast** |
|
| 183 |
+
| `10,000 - 14,999` | **Ocean Guardian** |
|
| 184 |
+
| `15,000 - 24,999` | **Climate Champion** |
|
| 185 |
+
| `25,000+` | **Master Guardian** (Ultimate tier) |
|
| 186 |
+
|
| 187 |
+
### Achievements & Badges
|
| 188 |
+
|
| 189 |
+
Badges are analyzed during calculation routines and unlocked when meeting the following criteria:
|
| 190 |
+
|
| 191 |
+
| Badge Image/Title | Criteria for Unlocking |
|
| 192 |
+
| :---------------- | :-------------------------------------------------------------------------------------------- |
|
| 193 |
+
| **Tree Planter** | User reports a `vegan` or `vegetarian` dietType. |
|
| 194 |
+
| **Carbon Ninja** | User drives `0 km` daily OR drives a `hybrid`/`electric` vehicle OR has `none` for fuel type. |
|
| 195 |
+
| **Zero Waste** | User buys less than `5` clothes per year AND `0` electronics items per year. |
|
| 196 |
|
| 197 |
---
|
| 198 |
|
| 199 |
+
## Tech Stack
|
| 200 |
+
|
| 201 |
+
| Layer | Technologies Used |
|
| 202 |
+
| ------------ | ------------------------------------------------------------------ |
|
| 203 |
+
| **Core** | HTML5, Vanilla CSS, Javascript (ES Modules) |
|
| 204 |
+
| **Frontend** | React 18, Vite 5, Tailwind CSS 3, Recharts, Axios, React Router v6 |
|
| 205 |
+
| **Backend** | Node.js 18+, Express 4, Helmet, Zod, Groq SDK, Firebase Admin SDK |
|
| 206 |
+
| **Database** | Firebase Firestore (Real-time Document Store) |
|
| 207 |
+
| **Testing** | Vitest, JSDOM, React Testing Library, Supertest |
|
| 208 |
|
| 209 |
+
---
|
| 210 |
+
|
| 211 |
+
## Security Hardening Techniques
|
| 212 |
+
|
| 213 |
+
EcoTrack AI implements a multi-layer defense-in-depth security model:
|
| 214 |
+
|
| 215 |
+
- **Strict Content Security Policy (CSP)**: Managed through Helmet.js middleware, restriction rules block execution of inline script payloads. Approved connect boundaries (`connectSrc`) are configured for local development and production Vercel apps.
|
| 216 |
+
- **Rate Limiting**: Custom rate limits are established via `express-rate-limit`:
|
| 217 |
+
- **Default API Limit**: `100 requests / 15 minutes` across the app.
|
| 218 |
+
- **User Registration (`/api/users`)**: Restricted to `15 registrations / hour` to prevent identity spoofing and sybil attacks.
|
| 219 |
+
- **Assessment Submissions (`/api/assessments`)**: Capped at `30 submissions / hour` to mitigate database flooding.
|
| 220 |
+
- **Input Sanitization**: Built via custom XSS sanitizers (`sanitize.js`) in the backend route controllers. Any user text input (such as sustainability plan goals or coach chat messages) is parsed to strip out HTML tags and characters matching script patterns.
|
| 221 |
+
- **Zod Schema Parsing**: Validates matching data types, structures, and bounds for all API requests. Unrecognized schema keys in requests are rejected immediately.
|
| 222 |
+
- **Safe Key Isolation**: Firebase Service Account keys are read from `firebase-key.json` and are ignored in git commits by `.gitignore`.
|
| 223 |
+
|
| 224 |
+
---
|
| 225 |
|
| 226 |
+
## Folder Structure
|
| 227 |
+
|
| 228 |
+
```
|
| 229 |
+
carbon/
|
| 230 |
+
├── backend/
|
| 231 |
+
│ ├── src/
|
| 232 |
+
│ │ ├── controllers/ # Express route controllers (User, Assessment, AI, etc.)
|
| 233 |
+
│ │ ├── middleware/ # Rate limiters, error handling schemas
|
| 234 |
+
│ │ ├── routes/ # API route definitions (ai.js, users.js, assessments.js, etc.)
|
| 235 |
+
│ │ ├── services/ # Business logic (scoring, carbon math, Groq AI coach)
|
| 236 |
+
│ │ ├── tests/ # Unit and integration test suites
|
| 237 |
+
│ │ └── utils/ # firebaseClient, logger, sanitize helpers, migrateDb script
|
| 238 |
+
│ ├── .env.example
|
| 239 |
+
│ ├── firebase-key.json # Git-ignored Firebase credential file
|
| 240 |
+
│ └── package.json
|
| 241 |
+
├── frontend/
|
| 242 |
+
│ ├── src/
|
| 243 |
+
│ │ ├── components/ # Reusable UI widgets, charts, and layout components
|
| 244 |
+
│ │ ├── hooks/ # Custom React hooks (useUser, useAssessment)
|
| 245 |
+
│ │ ├── pages/ # Page components (Calculator, Dashboard, Coach, Leaderboard)
|
| 246 |
+
│ │ ├── services/ # API axios instances
|
| 247 |
+
│ │ ├── tests/ # Frontend component and integration tests
|
| 248 |
+
│ │ └── utils/ # Debouncing, formatting, and cache handlers
|
| 249 |
+
│ ├── .env.example
|
| 250 |
+
│ └── package.json
|
| 251 |
+
├── .gitignore
|
| 252 |
+
├── package.json # Monorepo management
|
| 253 |
+
└── README.md
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
---
|
| 257 |
+
|
| 258 |
+
## Page Use Cases & Features
|
| 259 |
+
|
| 260 |
+
### 1. Carbon Footprint Calculator
|
| 261 |
+
|
| 262 |
+
- **Use Case**: Allows users to input daily habits across 4 distinct categories (Transport, Energy, Food, Shopping).
|
| 263 |
+
- **Details**: Dynamic progress indicators, validation feedback, and instant emission calculations (annualized kg CO₂e).
|
| 264 |
+
|
| 265 |
+
### 2. Interactive Dashboard
|
| 266 |
+
|
| 267 |
+
- **Use Case**: Visual representation of the carbon footprint.
|
| 268 |
+
- **Details**: Powered by Recharts. Renders category emission pie charts, progress over time, and comparative benchmark sliders (comparing user footprint to global and Paris Agreement targets).
|
| 269 |
+
|
| 270 |
+
### 3. What-If Simulation Widget
|
| 271 |
+
|
| 272 |
+
- **Use Case**: Lets users simulate changes in their habits (e.g. eating less meat, walking more, reducing electricity usage).
|
| 273 |
+
- **Details**: Provides instant, dynamic reductions feedback showing how lifestyle changes impact their overall carbon footprint in real-time.
|
| 274 |
+
|
| 275 |
+
### 4. AI Coach Chat & Planner
|
| 276 |
+
|
| 277 |
+
- **Use Case**: Direct interactive messaging with the sustainability coach.
|
| 278 |
+
- **Details**: Users get custom 30-day timelines, daily tips, and structured advice for lowering their footprints.
|
| 279 |
+
|
| 280 |
+
### 5. Gamification & Leaderboards
|
| 281 |
+
|
| 282 |
+
- **Use Case**: Encourages continuous tracking and eco-friendly competition.
|
| 283 |
+
- **Details**: Shows user streaks, points earned for low footprints, ranks (e.g., "Eco Warrior"), and achievement badges (e.g., "Renewable Pioneer").
|
| 284 |
+
|
| 285 |
+
---
|
| 286 |
+
|
| 287 |
+
## Installation & Setup
|
| 288 |
+
|
| 289 |
+
### Prerequisites
|
| 290 |
+
|
| 291 |
+
- **Node.js** ≥ 18.0.0
|
| 292 |
+
- **npm** ≥ 9
|
| 293 |
+
- A **Firebase Firestore** project database (Free tier)
|
| 294 |
+
|
| 295 |
+
### 1. Configure the Backend
|
| 296 |
+
|
| 297 |
+
1. Navigate to `/backend` and create a `.env` file from the example:
|
| 298 |
```bash
|
| 299 |
cd backend
|
| 300 |
+
cp .env.example .env
|
| 301 |
```
|
| 302 |
+
2. Populate the `.env` file:
|
| 303 |
+
- Add your `GROQ_API_KEY` (obtained from [console.groq.com](https://console.groq.com)).
|
| 304 |
+
3. Save your Firebase Service Account JSON credentials file as `firebase-key.json` in the `/backend` folder. _(Alternatively, stringify the JSON key and paste it as the `FIREBASE_SERVICE_ACCOUNT` value in your `.env`)_.
|
| 305 |
+
|
| 306 |
+
### 2. Configure the Frontend
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
+
1. Navigate to `/frontend` and create a `.env` file:
|
|
|
|
| 309 |
```bash
|
| 310 |
+
cd ../frontend
|
| 311 |
+
cp .env.example .env
|
| 312 |
```
|
| 313 |
+
2. By default, it points to local development:
|
| 314 |
+
```env
|
| 315 |
+
VITE_API_URL=http://localhost:3001/api
|
| 316 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
+
### 3. Start the Development Servers
|
| 319 |
+
|
| 320 |
+
In the root directory, install all dependencies:
|
| 321 |
|
| 322 |
+
```bash
|
| 323 |
+
# Root directory
|
| 324 |
+
npm install
|
| 325 |
+
```
|
| 326 |
+
|
| 327 |
+
Start the backend API server:
|
| 328 |
+
|
| 329 |
+
```bash
|
| 330 |
+
# In backend/
|
| 331 |
+
npm run dev
|
| 332 |
+
```
|
| 333 |
|
| 334 |
+
Start the React development server:
|
| 335 |
|
|
|
|
| 336 |
```bash
|
| 337 |
+
# In frontend/
|
| 338 |
+
npm run dev
|
| 339 |
```
|
| 340 |
|
| 341 |
+
Open **http://localhost:5173** to run the application locally.
|
| 342 |
|
| 343 |
---
|
| 344 |
|
| 345 |
+
## Deployment (Free Tier Hosting)
|
| 346 |
|
| 347 |
+
The platform is designed to be hosted for free using a split server/client architecture:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
|
| 349 |
+
### 1. Backend: Hugging Face Spaces (Docker)
|
| 350 |
+
|
| 351 |
+
The Node.js/Express backend runs in a free Hugging Face Space using Docker.
|
| 352 |
+
|
| 353 |
+
- **Hardware:** CPU Basic (Free Tier - 2 vCPU · 16 GB).
|
| 354 |
+
- **Environment Variables & Secrets:**
|
| 355 |
+
- `PORT`: `7860` (assigned automatically by Hugging Face)
|
| 356 |
+
- `GROQ_API_KEY`: Your Groq Cloud API key.
|
| 357 |
+
- `CORS_ORIGIN`: Your deployed Vercel frontend URL (e.g., `https://eco-tracker-ii-123.vercel.app`).
|
| 358 |
+
- `FIREBASE_SERVICE_ACCOUNT`: The stringified JSON content of your `firebase-key.json` file.
|
| 359 |
+
- **Dockerfile:** Create this at the root directory:
|
| 360 |
+
```dockerfile
|
| 361 |
+
FROM node:18-alpine
|
| 362 |
+
WORKDIR /app
|
| 363 |
+
COPY package*.json ./
|
| 364 |
+
COPY backend/package*.json ./backend/
|
| 365 |
+
RUN npm install && npm install --prefix backend
|
| 366 |
+
COPY backend/ ./backend/
|
| 367 |
+
ENV NODE_ENV=production
|
| 368 |
+
ENV PORT=7860
|
| 369 |
+
EXPOSE 7860
|
| 370 |
+
WORKDIR /app/backend
|
| 371 |
+
CMD ["npm", "start"]
|
| 372 |
+
```
|
| 373 |
+
|
| 374 |
+
### 2. Frontend: Vercel (Static Web Hosting)
|
| 375 |
+
|
| 376 |
+
The React/Vite client is hosted on Vercel (Live at: `https://eco-tracker-ii-123.vercel.app`).
|
| 377 |
+
|
| 378 |
+
- **Root Directory:** `frontend`
|
| 379 |
+
- **Environment Variables:**
|
| 380 |
+
- `VITE_API_URL`: Your Hugging Face Space endpoint (e.g., `https://akash-dragon-ecotracker-ii.hf.space/api`).
|
| 381 |
|
| 382 |
---
|
| 383 |
|
| 384 |
+
## Testing and Verification
|
| 385 |
+
|
| 386 |
+
To run tests across both the backend and frontend, run the following command in the root folder:
|
| 387 |
+
|
| 388 |
+
```bash
|
| 389 |
+
# From the project root folder
|
| 390 |
+
npm test
|
| 391 |
+
```
|
| 392 |
+
|
| 393 |
+
### Coverage
|
| 394 |
+
|
| 395 |
+
- **Backend**: Includes tests for emissions calculation formulas, rate limits, Zod schema validation, and Express routes.
|
| 396 |
+
- **Frontend**: Includes tests for React hooks, cached calculations, custom debouncing utility, HTML sanitization, and the multi-step calculator workflow.
|
| 397 |
+
|
| 398 |
+
# EcoTracker-II
|
SECURITY.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security Policy — EcoGuide AI
|
| 2 |
+
|
| 3 |
+
## Supported Versions
|
| 4 |
+
|
| 5 |
+
Only the current version of EcoGuide AI is actively supported with security updates.
|
| 6 |
+
|
| 7 |
+
| Version | Supported |
|
| 8 |
+
| ------- | --------- |
|
| 9 |
+
| 1.0.x | Yes |
|
| 10 |
+
| < 1.0 | No |
|
| 11 |
+
|
| 12 |
+
## Reporting a Vulnerability
|
| 13 |
+
|
| 14 |
+
We take the security of EcoGuide AI seriously. If you find any security vulnerabilities, please do **not** report them via public GitHub issues. Instead, report them privately by emailing:
|
| 15 |
+
|
| 16 |
+
**[security@ecoguide.ai](mailto:security@ecoguide.ai)**
|
| 17 |
+
|
| 18 |
+
We will acknowledge receipt of your report within 48 hours and provide a timeline for mitigation.
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## Threat Model & Security Architecture
|
| 23 |
+
|
| 24 |
+
EcoGuide AI has been designed from the ground up with defensive security best practices to protect user data and maintain service availability.
|
| 25 |
+
|
| 26 |
+
### 1. Data Minimization & Privacy
|
| 27 |
+
|
| 28 |
+
- **Anonymous Sessions**: EcoGuide AI does **not** collect, process, or store any Personally Identifiable Information (PII) such as names, passwords, or emails.
|
| 29 |
+
- **UUID-Only Identifiers**: Users are identified solely by randomly generated client-side UUIDs (`crypto.randomUUID()`) saved in the browser's local storage.
|
| 30 |
+
- **Synthetic Email Generation**: The backend generates dummy emails (`${uuid}@ecoguide.ai`) purely to satisfy database unique constraints for relational user models. These are never used for communications or authentication.
|
| 31 |
+
|
| 32 |
+
### 2. SQL Injection Prevention
|
| 33 |
+
|
| 34 |
+
- **Prisma ORM Parameterization**: All database interactions use Prisma Client. Prisma parameterized query builders ensure input values are never interpolated directly into SQL queries, neutralizing SQL injection vectors by design.
|
| 35 |
+
|
| 36 |
+
### 3. API Security & Over-Posting Protections
|
| 37 |
+
|
| 38 |
+
- **Input Validation (Zod)**: All endpoints validate incoming request payloads against strict Zod schemas.
|
| 39 |
+
- **Unknown-Key Guard**: The assessment creation route rejects any request containing extra, undeclared JSON body keys. This prevents over-posting attacks where malicious users try to inject variables like roles or metadata.
|
| 40 |
+
- **Strict Size Limits**: Express request body parser sizes are limited to `100kb` to prevent memory exhaustion and Denial of Service (DoS) attacks via oversized payloads.
|
| 41 |
+
|
| 42 |
+
### 4. Client-Side Security (XSS / CSRF)
|
| 43 |
+
|
| 44 |
+
- **Input Sanitization**: Client-side inputs are programmatically sanitized and validated prior to transmission, stripping HTML elements and script tags.
|
| 45 |
+
- **DOM Injection Avoidance**: The frontend codebase avoids the use of `dangerouslySetInnerHTML`. Text content is rendered using secure DOM elements.
|
| 46 |
+
- **Strict CORS Policy**: Cross-Origin Resource Sharing is locked down to standard trusted domains, local developers, and subdomains of the hosting environment (`*.vercel.app`).
|
| 47 |
+
- **Content Security Policy (CSP)**: Secure CSP rules restrict script sources to `'self'`. Dynamic CSS styling uses `'unsafe-inline'` exclusively to accommodate Vite dev server hot reloading and Tailwind CSS class injection, with comments detailing this exception.
|
| 48 |
+
|
| 49 |
+
### 5. Rate Limiting & Denial of Service
|
| 50 |
+
|
| 51 |
+
- **IP-based Limits**: All `/api/` endpoints are rate-limited to 100 requests per 15 minutes per IP address using `express-rate-limit` to prevent brute force attacks and resource starvation.
|
| 52 |
+
- **Reverse Proxy Setup**: `trust proxy` is enabled to securely capture the correct client IP address behind API hosting gateways.
|
Web-design/carbon_calculator_desktop/code.html
DELETED
|
@@ -1,424 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
|
| 3 |
-
<html class="dark" lang="en"><head>
|
| 4 |
-
<meta charset="utf-8"/>
|
| 5 |
-
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
| 6 |
-
<title>EcoTrack AI | Carbon Calculator</title>
|
| 7 |
-
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
| 8 |
-
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 9 |
-
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 10 |
-
<style>
|
| 11 |
-
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap');
|
| 12 |
-
|
| 13 |
-
body {
|
| 14 |
-
font-family: 'Geist', sans-serif;
|
| 15 |
-
overflow-x: hidden;
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
.glass-card {
|
| 19 |
-
background: rgba(30, 41, 59, 0.5);
|
| 20 |
-
backdrop-filter: blur(12px);
|
| 21 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 22 |
-
box-shadow: inset 0px 1px 0px 0px rgba(255, 255, 255, 0.05);
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
.emerald-glow {
|
| 26 |
-
box-shadow: 0 0 15px rgba(78, 222, 163, 0.3);
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
.custom-slider::-webkit-slider-thumb {
|
| 30 |
-
-webkit-appearance: none;
|
| 31 |
-
appearance: none;
|
| 32 |
-
width: 18px;
|
| 33 |
-
height: 18px;
|
| 34 |
-
background: #4edea3;
|
| 35 |
-
cursor: pointer;
|
| 36 |
-
border-radius: 50%;
|
| 37 |
-
border: 2px solid #0b1326;
|
| 38 |
-
box-shadow: 0 0 10px rgba(78, 222, 163, 0.5);
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
.step-active {
|
| 42 |
-
@apply border-primary text-primary;
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
.step-inactive {
|
| 46 |
-
@apply border-white/10 text-on-surface-variant;
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
.material-symbols-outlined {
|
| 50 |
-
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
| 51 |
-
}
|
| 52 |
-
</style>
|
| 53 |
-
<script id="tailwind-config">
|
| 54 |
-
tailwind.config = {
|
| 55 |
-
darkMode: "class",
|
| 56 |
-
theme: {
|
| 57 |
-
extend: {
|
| 58 |
-
"colors": {
|
| 59 |
-
"tertiary-container": "#e29100",
|
| 60 |
-
"surface": "#0b1326",
|
| 61 |
-
"on-secondary": "#00344d",
|
| 62 |
-
"on-primary-fixed-variant": "#005236",
|
| 63 |
-
"primary-fixed": "#6ffbbe",
|
| 64 |
-
"on-surface": "#dae2fd",
|
| 65 |
-
"surface-container-lowest": "#060e20",
|
| 66 |
-
"on-tertiary-fixed-variant": "#653e00",
|
| 67 |
-
"on-secondary-fixed": "#001e2f",
|
| 68 |
-
"error-container": "#93000a",
|
| 69 |
-
"background": "#0b1326",
|
| 70 |
-
"surface-tint": "#4edea3",
|
| 71 |
-
"outline": "#86948a",
|
| 72 |
-
"primary-container": "#10b981",
|
| 73 |
-
"outline-variant": "#3c4a42",
|
| 74 |
-
"surface-dim": "#0b1326",
|
| 75 |
-
"secondary-container": "#00a2e6",
|
| 76 |
-
"surface-container-highest": "#2d3449",
|
| 77 |
-
"tertiary-fixed": "#ffddb8",
|
| 78 |
-
"surface-container-low": "#131b2e",
|
| 79 |
-
"surface-container": "#171f33",
|
| 80 |
-
"on-secondary-fixed-variant": "#004c6e",
|
| 81 |
-
"on-background": "#dae2fd",
|
| 82 |
-
"primary": "#4edea3",
|
| 83 |
-
"inverse-surface": "#dae2fd",
|
| 84 |
-
"secondary-fixed-dim": "#89ceff",
|
| 85 |
-
"surface-container-high": "#222a3d",
|
| 86 |
-
"on-error-container": "#ffdad6",
|
| 87 |
-
"secondary-fixed": "#c9e6ff",
|
| 88 |
-
"on-secondary-container": "#00344e",
|
| 89 |
-
"on-primary-container": "#00422b",
|
| 90 |
-
"on-primary": "#003824",
|
| 91 |
-
"tertiary-fixed-dim": "#ffb95f",
|
| 92 |
-
"on-surface-variant": "#bbcabf",
|
| 93 |
-
"on-tertiary-container": "#523200",
|
| 94 |
-
"primary-fixed-dim": "#4edea3",
|
| 95 |
-
"on-tertiary": "#472a00",
|
| 96 |
-
"secondary": "#89ceff",
|
| 97 |
-
"on-error": "#690005",
|
| 98 |
-
"inverse-primary": "#006c49",
|
| 99 |
-
"on-tertiary-fixed": "#2a1700",
|
| 100 |
-
"on-primary-fixed": "#002113",
|
| 101 |
-
"inverse-on-surface": "#283044",
|
| 102 |
-
"error": "#ffb4ab",
|
| 103 |
-
"tertiary": "#ffb95f",
|
| 104 |
-
"surface-bright": "#31394d",
|
| 105 |
-
"surface-variant": "#2d3449"
|
| 106 |
-
},
|
| 107 |
-
"borderRadius": {
|
| 108 |
-
"DEFAULT": "0.25rem",
|
| 109 |
-
"lg": "0.5rem",
|
| 110 |
-
"xl": "0.75rem",
|
| 111 |
-
"full": "9999px"
|
| 112 |
-
},
|
| 113 |
-
"spacing": {
|
| 114 |
-
"xl": "4rem",
|
| 115 |
-
"unit": "4px",
|
| 116 |
-
"gutter": "1.5rem",
|
| 117 |
-
"margin": "2rem",
|
| 118 |
-
"sm": "1rem",
|
| 119 |
-
"xs": "0.5rem",
|
| 120 |
-
"md": "1.5rem",
|
| 121 |
-
"lg": "2.5rem"
|
| 122 |
-
},
|
| 123 |
-
"fontFamily": {
|
| 124 |
-
"display-lg": ["Geist"],
|
| 125 |
-
"label-md": ["Geist"],
|
| 126 |
-
"headline-md": ["Geist"],
|
| 127 |
-
"body-md": ["Geist"],
|
| 128 |
-
"display-lg-mobile": ["Geist"],
|
| 129 |
-
"caption": ["Geist"],
|
| 130 |
-
"title-sm": ["Geist"]
|
| 131 |
-
},
|
| 132 |
-
"fontSize": {
|
| 133 |
-
"display-lg": ["48px", {"lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
| 134 |
-
"label-md": ["14px", {"lineHeight": "1.2", "letterSpacing": "0.01em", "fontWeight": "500"}],
|
| 135 |
-
"headline-md": ["24px", {"lineHeight": "1.3", "fontWeight": "600"}],
|
| 136 |
-
"body-md": ["16px", {"lineHeight": "1.6", "fontWeight": "400"}],
|
| 137 |
-
"display-lg-mobile": ["32px", {"lineHeight": "1.2", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
| 138 |
-
"caption": ["12px", {"lineHeight": "1.2", "fontWeight": "400"}],
|
| 139 |
-
"title-sm": ["18px", {"lineHeight": "1.4", "fontWeight": "600"}]
|
| 140 |
-
}
|
| 141 |
-
},
|
| 142 |
-
},
|
| 143 |
-
}
|
| 144 |
-
</script>
|
| 145 |
-
</head>
|
| 146 |
-
<body class="bg-surface text-on-surface min-h-screen flex">
|
| 147 |
-
<!-- Background Shader -->
|
| 148 |
-
|
| 149 |
-
<!-- SideNavBar (Authority: JSON) -->
|
| 150 |
-
<aside class="fixed left-0 top-0 h-full flex flex-col pt-20 pb-margin px-sm bg-surface-container/50 backdrop-blur-xl border-r border-white/10 shadow-lg w-64 z-40">
|
| 151 |
-
<div class="mb-lg px-sm">
|
| 152 |
-
<h1 class="font-headline-md text-headline-md font-bold text-primary">EcoTrack AI</h1>
|
| 153 |
-
<p class="font-label-md text-label-md text-on-surface-variant">Precision Sustainability</p>
|
| 154 |
-
</div>
|
| 155 |
-
<nav class="flex-1 space-y-xs">
|
| 156 |
-
<a class="flex items-center gap-xs text-on-surface-variant px-sm py-xs hover:bg-white/5 hover:text-primary transition-all cursor-pointer" href="#">
|
| 157 |
-
<span class="material-symbols-outlined">dashboard</span>
|
| 158 |
-
<span class="font-label-md text-label-md">Dashboard</span>
|
| 159 |
-
</a>
|
| 160 |
-
<a class="flex items-center gap-xs bg-primary/10 text-primary rounded-lg px-sm py-xs hover:bg-white/5 hover:text-primary transition-all cursor-pointer" href="#">
|
| 161 |
-
<span class="material-symbols-outlined">eco</span>
|
| 162 |
-
<span class="font-label-md text-label-md">Projects</span>
|
| 163 |
-
</a>
|
| 164 |
-
<a class="flex items-center gap-xs text-on-surface-variant px-sm py-xs hover:bg-white/5 hover:text-primary transition-all cursor-pointer" href="#">
|
| 165 |
-
<span class="material-symbols-outlined">bar_chart</span>
|
| 166 |
-
<span class="font-label-md text-label-md">Analytics</span>
|
| 167 |
-
</a>
|
| 168 |
-
<a class="flex items-center gap-xs text-on-surface-variant px-sm py-xs hover:bg-white/5 hover:text-primary transition-all cursor-pointer" href="#">
|
| 169 |
-
<span class="material-symbols-outlined">storefront</span>
|
| 170 |
-
<span class="font-label-md text-label-md">Marketplace</span>
|
| 171 |
-
</a>
|
| 172 |
-
<a class="flex items-center gap-xs text-on-surface-variant px-sm py-xs hover:bg-white/5 hover:text-primary transition-all cursor-pointer" href="#">
|
| 173 |
-
<span class="material-symbols-outlined">description</span>
|
| 174 |
-
<span class="font-label-md text-label-md">Reports</span>
|
| 175 |
-
</a>
|
| 176 |
-
</nav>
|
| 177 |
-
<div class="mt-auto px-sm pt-md border-t border-white/5">
|
| 178 |
-
<button class="w-full py-xs px-sm bg-primary text-on-primary font-label-md text-label-md rounded-lg emerald-glow hover:brightness-110 transition-all">
|
| 179 |
-
Upgrade to Pro
|
| 180 |
-
</button>
|
| 181 |
-
</div>
|
| 182 |
-
</aside>
|
| 183 |
-
<!-- Main Content Canvas -->
|
| 184 |
-
<main class="ml-64 flex-1 p-gutter min-h-screen relative flex gap-gutter overflow-hidden">
|
| 185 |
-
<!-- Wizard Content Area -->
|
| 186 |
-
<div class="flex-1 max-w-4xl space-y-md">
|
| 187 |
-
<!-- Step Progress Indicator -->
|
| 188 |
-
<header class="mb-lg pt-12">
|
| 189 |
-
<h2 class="font-display-lg text-display-lg text-primary mb-xs">Carbon Calculator</h2>
|
| 190 |
-
<div class="flex items-center gap-sm mt-md">
|
| 191 |
-
<div class="flex items-center gap-xs group">
|
| 192 |
-
<div class="w-8 h-8 rounded-full border-2 flex items-center justify-center font-bold text-caption border-primary text-primary">1</div>
|
| 193 |
-
<span class="font-label-md text-label-md text-primary">Housing</span>
|
| 194 |
-
</div>
|
| 195 |
-
<div class="w-12 h-px bg-white/10"></div>
|
| 196 |
-
<div class="flex items-center gap-xs">
|
| 197 |
-
<div class="w-8 h-8 rounded-full border-2 flex items-center justify-center font-bold text-caption border-primary bg-primary text-on-primary">2</div>
|
| 198 |
-
<span class="font-label-md text-label-md text-on-surface">Transportation</span>
|
| 199 |
-
</div>
|
| 200 |
-
<div class="w-12 h-px bg-white/10"></div>
|
| 201 |
-
<div class="flex items-center gap-xs">
|
| 202 |
-
<div class="w-8 h-8 rounded-full border-2 flex items-center justify-center font-bold text-caption border-white/10 text-on-surface-variant">3</div>
|
| 203 |
-
<span class="font-label-md text-label-md text-on-surface-variant">Diet</span>
|
| 204 |
-
</div>
|
| 205 |
-
<div class="w-12 h-px bg-white/10"></div>
|
| 206 |
-
<div class="flex items-center gap-xs">
|
| 207 |
-
<div class="w-8 h-8 rounded-full border-2 flex items-center justify-center font-bold text-caption border-white/10 text-on-surface-variant">4</div>
|
| 208 |
-
<span class="font-label-md text-label-md text-on-surface-variant">Results</span>
|
| 209 |
-
</div>
|
| 210 |
-
</div>
|
| 211 |
-
</header>
|
| 212 |
-
<!-- Main Form Card -->
|
| 213 |
-
<section class="glass-card rounded-xl p-lg space-y-lg relative overflow-hidden">
|
| 214 |
-
<div class="absolute top-0 left-0 w-1 h-full bg-gradient-to-b from-primary to-secondary"></div>
|
| 215 |
-
<div class="space-y-xs">
|
| 216 |
-
<h3 class="font-headline-md text-headline-md text-on-surface">Your Transit Profile</h3>
|
| 217 |
-
<p class="font-body-md text-body-md text-on-surface-variant">How do you move through the world? Select your primary modes of travel.</p>
|
| 218 |
-
</div>
|
| 219 |
-
<!-- Transport Selection Grid -->
|
| 220 |
-
<div class="grid grid-cols-2 md:grid-cols-4 gap-sm">
|
| 221 |
-
<!-- Car -->
|
| 222 |
-
<div class="glass-card p-md rounded-lg cursor-pointer border-primary bg-primary/5 transition-all group active:scale-95 border" onclick="toggleSelect(this)">
|
| 223 |
-
<div class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-sm group-hover:bg-primary/20 transition-colors">
|
| 224 |
-
<span class="material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">directions_car</span>
|
| 225 |
-
</div>
|
| 226 |
-
<span class="block font-title-sm text-title-sm text-on-surface">Car</span>
|
| 227 |
-
<span class="text-caption font-caption text-on-surface-variant">Personal use</span>
|
| 228 |
-
</div>
|
| 229 |
-
<!-- Bus -->
|
| 230 |
-
<div class="glass-card p-md rounded-lg cursor-pointer border-white/10 hover:border-primary/50 transition-all group active:scale-95 border" onclick="toggleSelect(this)">
|
| 231 |
-
<div class="w-12 h-12 rounded-lg bg-surface-container flex items-center justify-center mb-sm group-hover:bg-primary/10 transition-colors">
|
| 232 |
-
<span class="material-symbols-outlined text-on-surface-variant group-hover:text-primary">directions_bus</span>
|
| 233 |
-
</div>
|
| 234 |
-
<span class="block font-title-sm text-title-sm text-on-surface">Bus</span>
|
| 235 |
-
<span class="text-caption font-caption text-on-surface-variant">Public transit</span>
|
| 236 |
-
</div>
|
| 237 |
-
<!-- Bike -->
|
| 238 |
-
<div class="glass-card p-md rounded-lg cursor-pointer border-white/10 hover:border-primary/50 transition-all group active:scale-95 border" onclick="toggleSelect(this)">
|
| 239 |
-
<div class="w-12 h-12 rounded-lg bg-surface-container flex items-center justify-center mb-sm group-hover:bg-primary/10 transition-colors">
|
| 240 |
-
<span class="material-symbols-outlined text-on-surface-variant group-hover:text-primary">directions_bike</span>
|
| 241 |
-
</div>
|
| 242 |
-
<span class="block font-title-sm text-title-sm text-on-surface">Bike</span>
|
| 243 |
-
<span class="text-caption font-caption text-on-surface-variant">Manual/Electric</span>
|
| 244 |
-
</div>
|
| 245 |
-
<!-- Walk -->
|
| 246 |
-
<div class="glass-card p-md rounded-lg cursor-pointer border-white/10 hover:border-primary/50 transition-all group active:scale-95 border" onclick="toggleSelect(this)">
|
| 247 |
-
<div class="w-12 h-12 rounded-lg bg-surface-container flex items-center justify-center mb-sm group-hover:bg-primary/10 transition-colors">
|
| 248 |
-
<span class="material-symbols-outlined text-on-surface-variant group-hover:text-primary">directions_walk</span>
|
| 249 |
-
</div>
|
| 250 |
-
<span class="block font-title-sm text-title-sm text-on-surface">Walk</span>
|
| 251 |
-
<span class="text-caption font-caption text-on-surface-variant">Foot travel</span>
|
| 252 |
-
</div>
|
| 253 |
-
</div>
|
| 254 |
-
<!-- Car Configuration Details -->
|
| 255 |
-
<div class="pt-md border-t border-white/5 space-y-md">
|
| 256 |
-
<div class="grid grid-cols-1 md:grid-cols-2 gap-lg">
|
| 257 |
-
<!-- Distance Slider -->
|
| 258 |
-
<div class="space-y-sm">
|
| 259 |
-
<div class="flex justify-between items-center">
|
| 260 |
-
<label class="font-label-md text-label-md text-on-surface">Weekly Distance (km)</label>
|
| 261 |
-
<span class="text-primary font-bold" id="distance-val">120 km</span>
|
| 262 |
-
</div>
|
| 263 |
-
<input class="w-full h-1 bg-surface-container-highest rounded-lg appearance-none cursor-pointer custom-slider" max="1000" min="0" oninput="updateDistance(this.value)" type="range" value="120"/>
|
| 264 |
-
<div class="flex justify-between font-caption text-caption text-on-surface-variant">
|
| 265 |
-
<span>0 km</span>
|
| 266 |
-
<span>1000 km</span>
|
| 267 |
-
</div>
|
| 268 |
-
</div>
|
| 269 |
-
<!-- Fuel Dropdown -->
|
| 270 |
-
<div class="space-y-sm">
|
| 271 |
-
<label class="font-label-md text-label-md text-on-surface">Fuel or Energy Type</label>
|
| 272 |
-
<div class="relative">
|
| 273 |
-
<select class="w-full bg-surface-container-low border border-white/10 rounded-lg py-sm px-md font-body-md text-body-md text-on-surface focus:outline-none focus:border-secondary transition-colors appearance-none">
|
| 274 |
-
<option value="petrol">Unleaded Petrol</option>
|
| 275 |
-
<option value="diesel">Diesel</option>
|
| 276 |
-
<option value="hybrid">Hybrid (PHEV)</option>
|
| 277 |
-
<option selected="" value="electric">Electric (EV)</option>
|
| 278 |
-
<option value="hydrogen">Hydrogen Fuel Cell</option>
|
| 279 |
-
</select>
|
| 280 |
-
<span class="material-symbols-outlined absolute right-md top-1/2 -translate-y-1/2 pointer-events-none text-on-surface-variant">expand_more</span>
|
| 281 |
-
</div>
|
| 282 |
-
</div>
|
| 283 |
-
</div>
|
| 284 |
-
</div>
|
| 285 |
-
<!-- Navigation Buttons -->
|
| 286 |
-
<div class="flex justify-between items-center pt-lg border-t border-white/5">
|
| 287 |
-
<button class="px-lg py-sm rounded-lg font-label-md text-label-md text-on-surface hover:bg-white/5 transition-all flex items-center gap-xs">
|
| 288 |
-
<span class="material-symbols-outlined text-sm">arrow_back</span>
|
| 289 |
-
Back
|
| 290 |
-
</button>
|
| 291 |
-
<button class="px-xl py-sm bg-primary text-on-primary rounded-lg font-label-md text-label-md emerald-glow hover:brightness-110 transition-all flex items-center gap-xs">
|
| 292 |
-
Continue to Diet
|
| 293 |
-
<span class="material-symbols-outlined text-sm">arrow_forward</span>
|
| 294 |
-
</button>
|
| 295 |
-
</div>
|
| 296 |
-
</section>
|
| 297 |
-
<!-- Decorative Visual Section -->
|
| 298 |
-
<div class="grid grid-cols-2 gap-md">
|
| 299 |
-
<div class="glass-card rounded-xl p-md overflow-hidden relative h-40">
|
| 300 |
-
<div class="absolute inset-0 z-0">
|
| 301 |
-
<img class="w-full h-full object-cover opacity-30 grayscale mix-blend-screen" data-alt="A futuristic electric vehicle charging station at dusk, with soft cyan neon lights illuminating the sleek silver car bodies. The environment is clean and modern, representing sustainable urban mobility and high-tech infrastructure with a serene, optimistic atmosphere." src="https://lh3.googleusercontent.com/aida-public/AB6AXuA_v4Oqe2NJdMrpTVvD1C5lj7C6wtbNNmYfDIWjWY2ss72Zgvdqw7E746C0WdPckog-vtcnxLFejOTcr1RQyL867WIX5DQr2sxK7XCgL45FO8Ky2RgX26kB3BZeh7d21Q3qLhm3QjA6l86dac5Xy6a7CMtRYJswJhmyLDvG4obA3_ZrutBtcLfzS9TGaLEwe_-2Lcdcq7KJWKZm0p8z97AzmqOGL-XOQwC6Lsoc3MdkhYWGUmlFDwja8irxFoekQWxIrxL9SASpzVWW"/>
|
| 302 |
-
</div>
|
| 303 |
-
<div class="relative z-10">
|
| 304 |
-
<span class="px-xs py-1 bg-secondary/10 text-secondary border border-secondary/20 rounded-full font-caption text-caption mb-xs inline-block">Eco Tip</span>
|
| 305 |
-
<h4 class="font-title-sm text-title-sm text-on-surface mb-xs">EV Adoption</h4>
|
| 306 |
-
<p class="font-caption text-caption text-on-surface-variant line-clamp-2">Switching to an electric vehicle can reduce your transport footprint by up to 70% in high-renewable grids.</p>
|
| 307 |
-
</div>
|
| 308 |
-
</div>
|
| 309 |
-
<div class="glass-card rounded-xl p-md overflow-hidden relative h-40">
|
| 310 |
-
<div class="absolute inset-0 z-0">
|
| 311 |
-
<img class="w-full h-full object-cover opacity-30 grayscale mix-blend-screen" data-alt="A lush green urban park with modern bicycle paths weaving through mature trees and vibrant flower beds. People are seen commuting on bicycles under a bright, high-key sun, evoking a sense of community health and environmental harmony within a futuristic city landscape." src="https://lh3.googleusercontent.com/aida-public/AB6AXuBU1UimV8bEm_X2Si6vLLhZSgv80rmv_e1qrvoHlo2_Q04YvBa5cpZJfI6uVpGA6TLCr3xJsbemdHC0sHVmRNp1aGuCATut89V_vSIoQb-e0eIaVsd7ADCix5gJEP-hw5wBVLiZsSWfHEOo9hB55gri6XMO36k4vTFt0vREgLpW_MvDq0Dpjm8ed4hOuhWXACMFeEfYVg18fwLcw2ghgyIu0j1-j7st_kgd1Ca6Ps06D0n7yNaGdfo1sGBu4UdzsE3D614d-GGAltRk"/>
|
| 312 |
-
</div>
|
| 313 |
-
<div class="relative z-10">
|
| 314 |
-
<span class="px-xs py-1 bg-primary/10 text-primary border border-primary/20 rounded-full font-caption text-caption mb-xs inline-block">Impact Hub</span>
|
| 315 |
-
<h4 class="font-title-sm text-title-sm text-on-surface mb-xs">Micro-mobility</h4>
|
| 316 |
-
<p class="font-caption text-caption text-on-surface-variant line-clamp-2">Using a bicycle for distances under 5km saves an average of 1.2kg CO2e per trip.</p>
|
| 317 |
-
</div>
|
| 318 |
-
</div>
|
| 319 |
-
</div>
|
| 320 |
-
</div>
|
| 321 |
-
<!-- Right Side Live Estimate Panel -->
|
| 322 |
-
<aside class="w-80 h-[calc(100vh-3rem)] sticky top-gutter flex flex-col gap-gutter">
|
| 323 |
-
<div class="glass-card flex-1 rounded-xl p-lg flex flex-col border-secondary/20">
|
| 324 |
-
<div class="flex items-center gap-xs mb-lg">
|
| 325 |
-
<span class="material-symbols-outlined text-secondary" style="font-variation-settings: 'FILL' 1;">bolt</span>
|
| 326 |
-
<h3 class="font-title-sm text-title-sm text-on-surface">Live Estimate</h3>
|
| 327 |
-
</div>
|
| 328 |
-
<!-- Main Metric -->
|
| 329 |
-
<div class="text-center py-lg border-b border-white/5 mb-lg">
|
| 330 |
-
<span class="font-display-lg text-display-lg text-secondary block" id="co2-value">1.82</span>
|
| 331 |
-
<span class="font-label-md text-label-md text-on-surface-variant uppercase tracking-widest">Tons CO2e / Year</span>
|
| 332 |
-
</div>
|
| 333 |
-
<!-- Comparison Stats -->
|
| 334 |
-
<div class="space-y-md flex-1">
|
| 335 |
-
<div class="space-y-xs">
|
| 336 |
-
<div class="flex justify-between font-caption text-caption text-on-surface-variant">
|
| 337 |
-
<span>Your current impact</span>
|
| 338 |
-
<span class="text-on-surface">1.82t</span>
|
| 339 |
-
</div>
|
| 340 |
-
<div class="w-full h-2 bg-surface-container-highest rounded-full overflow-hidden">
|
| 341 |
-
<div class="h-full bg-gradient-to-r from-secondary to-primary w-[45%] rounded-full shadow-[0_0_8px_rgba(137,206,255,0.4)]"></div>
|
| 342 |
-
</div>
|
| 343 |
-
<div class="flex justify-between font-caption text-caption text-on-surface-variant">
|
| 344 |
-
<span>Global Avg (4.8t)</span>
|
| 345 |
-
<span class="text-error">Goal (0.5t)</span>
|
| 346 |
-
</div>
|
| 347 |
-
</div>
|
| 348 |
-
<div class="glass-card p-sm rounded-lg border-white/5 space-y-xs bg-white/5">
|
| 349 |
-
<p class="font-caption text-caption text-on-surface-variant">Current Step Delta:</p>
|
| 350 |
-
<div class="flex items-center gap-sm">
|
| 351 |
-
<span class="material-symbols-outlined text-primary text-sm">trending_down</span>
|
| 352 |
-
<span class="font-label-md text-label-md text-primary">-0.42t (Eco Driver)</span>
|
| 353 |
-
</div>
|
| 354 |
-
</div>
|
| 355 |
-
</div>
|
| 356 |
-
<!-- Visual Chart Placeholder -->
|
| 357 |
-
<div class="h-32 bg-surface-container-low rounded-lg relative overflow-hidden group">
|
| 358 |
-
<div class="absolute bottom-0 left-0 w-full h-full flex items-end px-xs gap-1 pb-1">
|
| 359 |
-
<div class="flex-1 bg-primary/20 rounded-t h-[30%] transition-all duration-700 group-hover:h-[45%]"></div>
|
| 360 |
-
<div class="flex-1 bg-primary/40 rounded-t h-[60%] transition-all duration-700 group-hover:h-[80%]"></div>
|
| 361 |
-
<div class="flex-1 bg-primary/30 rounded-t h-[45%] transition-all duration-700 group-hover:h-[55%]"></div>
|
| 362 |
-
<div class="flex-1 bg-secondary/50 rounded-t h-[85%] transition-all duration-700 group-hover:h-[95%]"></div>
|
| 363 |
-
<div class="flex-1 bg-primary/20 rounded-t h-[40%] transition-all duration-700 group-hover:h-[50%]"></div>
|
| 364 |
-
<div class="flex-1 bg-primary/50 rounded-t h-[65%] transition-all duration-700 group-hover:h-[75%]"></div>
|
| 365 |
-
</div>
|
| 366 |
-
</div>
|
| 367 |
-
<div class="mt-lg pt-md border-t border-white/5">
|
| 368 |
-
<p class="font-caption text-caption text-on-surface-variant text-center italic">Calculations updated 2s ago</p>
|
| 369 |
-
</div>
|
| 370 |
-
</div>
|
| 371 |
-
</aside>
|
| 372 |
-
</main>
|
| 373 |
-
<!-- Footer (Authority: JSON) -->
|
| 374 |
-
<footer class="fixed bottom-0 right-0 left-64 py-md px-gutter flex justify-between items-center bg-surface-container-lowest border-t border-white/10 z-40">
|
| 375 |
-
<div class="flex items-center gap-md">
|
| 376 |
-
<span class="font-title-sm text-title-sm text-on-surface">EcoTrack AI</span>
|
| 377 |
-
<span class="font-caption text-caption text-secondary">© 2024 EcoTrack AI. Engineering a Sustainable Future.</span>
|
| 378 |
-
</div>
|
| 379 |
-
<div class="flex gap-md">
|
| 380 |
-
<a class="font-caption text-caption text-on-surface-variant hover:text-secondary underline transition-all" href="#">Privacy Policy</a>
|
| 381 |
-
<a class="font-caption text-caption text-on-surface-variant hover:text-secondary underline transition-all" href="#">Terms of Service</a>
|
| 382 |
-
<a class="font-caption text-caption text-on-surface-variant hover:text-secondary underline transition-all" href="#">Carbon Methodology</a>
|
| 383 |
-
<a class="font-caption text-caption text-on-surface-variant hover:text-secondary underline transition-all" href="#">API Docs</a>
|
| 384 |
-
</div>
|
| 385 |
-
</footer>
|
| 386 |
-
<script>
|
| 387 |
-
function toggleSelect(el) {
|
| 388 |
-
const isActive = el.classList.contains('border-primary');
|
| 389 |
-
const icon = el.querySelector('.material-symbols-outlined');
|
| 390 |
-
|
| 391 |
-
if (isActive) {
|
| 392 |
-
el.classList.remove('border-primary', 'bg-primary/5');
|
| 393 |
-
el.classList.add('border-white/10');
|
| 394 |
-
icon.classList.remove('text-primary');
|
| 395 |
-
icon.classList.add('text-on-surface-variant');
|
| 396 |
-
icon.style.fontVariationSettings = "'FILL' 0";
|
| 397 |
-
} else {
|
| 398 |
-
el.classList.add('border-primary', 'bg-primary/5');
|
| 399 |
-
el.classList.remove('border-white/10');
|
| 400 |
-
icon.classList.add('text-primary');
|
| 401 |
-
icon.classList.remove('text-on-surface-variant');
|
| 402 |
-
icon.style.fontVariationSettings = "'FILL' 1";
|
| 403 |
-
}
|
| 404 |
-
simulateRealTimeUpdate();
|
| 405 |
-
}
|
| 406 |
-
|
| 407 |
-
function updateDistance(val) {
|
| 408 |
-
document.getElementById('distance-val').innerText = val + ' km';
|
| 409 |
-
simulateRealTimeUpdate();
|
| 410 |
-
}
|
| 411 |
-
|
| 412 |
-
function simulateRealTimeUpdate() {
|
| 413 |
-
const co2El = document.getElementById('co2-value');
|
| 414 |
-
const current = parseFloat(co2El.innerText);
|
| 415 |
-
// Just a visual simulation effect
|
| 416 |
-
co2El.classList.add('scale-110', 'text-white');
|
| 417 |
-
setTimeout(() => {
|
| 418 |
-
const noise = (Math.random() * 0.05 - 0.025);
|
| 419 |
-
co2El.innerText = (current + noise).toFixed(2);
|
| 420 |
-
co2El.classList.remove('scale-110', 'text-white');
|
| 421 |
-
}, 200);
|
| 422 |
-
}
|
| 423 |
-
</script>
|
| 424 |
-
</body></html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Web-design/challenges_gamification_desktop/code.html
DELETED
|
@@ -1,436 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
|
| 3 |
-
<html class="dark" lang="en"><head>
|
| 4 |
-
<meta charset="utf-8"/>
|
| 5 |
-
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
| 6 |
-
<title>EcoTrack AI | Challenges & Gamification</title>
|
| 7 |
-
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
| 8 |
-
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 9 |
-
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" rel="stylesheet"/>
|
| 10 |
-
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 11 |
-
<script id="tailwind-config">
|
| 12 |
-
tailwind.config = {
|
| 13 |
-
darkMode: "class",
|
| 14 |
-
theme: {
|
| 15 |
-
extend: {
|
| 16 |
-
"colors": {
|
| 17 |
-
"tertiary-container": "#e29100",
|
| 18 |
-
"surface": "#0b1326",
|
| 19 |
-
"on-secondary": "#00344d",
|
| 20 |
-
"on-primary-fixed-variant": "#005236",
|
| 21 |
-
"primary-fixed": "#6ffbbe",
|
| 22 |
-
"on-surface": "#dae2fd",
|
| 23 |
-
"surface-container-lowest": "#060e20",
|
| 24 |
-
"on-tertiary-fixed-variant": "#653e00",
|
| 25 |
-
"on-secondary-fixed": "#001e2f",
|
| 26 |
-
"error-container": "#93000a",
|
| 27 |
-
"background": "#0b1326",
|
| 28 |
-
"surface-tint": "#4edea3",
|
| 29 |
-
"outline": "#86948a",
|
| 30 |
-
"primary-container": "#10b981",
|
| 31 |
-
"outline-variant": "#3c4a42",
|
| 32 |
-
"surface-dim": "#0b1326",
|
| 33 |
-
"secondary-container": "#00a2e6",
|
| 34 |
-
"surface-container-highest": "#2d3449",
|
| 35 |
-
"tertiary-fixed": "#ffddb8",
|
| 36 |
-
"surface-container-low": "#131b2e",
|
| 37 |
-
"surface-container": "#171f33",
|
| 38 |
-
"on-secondary-fixed-variant": "#004c6e",
|
| 39 |
-
"on-background": "#dae2fd",
|
| 40 |
-
"primary": "#4edea3",
|
| 41 |
-
"inverse-surface": "#dae2fd",
|
| 42 |
-
"secondary-fixed-dim": "#89ceff",
|
| 43 |
-
"surface-container-high": "#222a3d",
|
| 44 |
-
"on-error-container": "#ffdad6",
|
| 45 |
-
"secondary-fixed": "#c9e6ff",
|
| 46 |
-
"on-secondary-container": "#00344e",
|
| 47 |
-
"on-primary-container": "#00422b",
|
| 48 |
-
"on-primary": "#003824",
|
| 49 |
-
"tertiary-fixed-dim": "#ffb95f",
|
| 50 |
-
"on-surface-variant": "#bbcabf",
|
| 51 |
-
"on-tertiary-container": "#523200",
|
| 52 |
-
"primary-fixed-dim": "#4edea3",
|
| 53 |
-
"on-tertiary": "#472a00",
|
| 54 |
-
"secondary": "#89ceff",
|
| 55 |
-
"on-error": "#690005",
|
| 56 |
-
"inverse-primary": "#006c49",
|
| 57 |
-
"on-tertiary-fixed": "#2a1700",
|
| 58 |
-
"on-primary-fixed": "#002113",
|
| 59 |
-
"inverse-on-surface": "#283044",
|
| 60 |
-
"error": "#ffb4ab",
|
| 61 |
-
"tertiary": "#ffb95f",
|
| 62 |
-
"surface-bright": "#31394d",
|
| 63 |
-
"surface-variant": "#2d3449"
|
| 64 |
-
},
|
| 65 |
-
"borderRadius": {
|
| 66 |
-
"DEFAULT": "0.25rem",
|
| 67 |
-
"lg": "0.5rem",
|
| 68 |
-
"xl": "0.75rem",
|
| 69 |
-
"full": "9999px"
|
| 70 |
-
},
|
| 71 |
-
"spacing": {
|
| 72 |
-
"xl": "4rem",
|
| 73 |
-
"unit": "4px",
|
| 74 |
-
"gutter": "1.5rem",
|
| 75 |
-
"margin": "2rem",
|
| 76 |
-
"sm": "1rem",
|
| 77 |
-
"xs": "0.5rem",
|
| 78 |
-
"md": "1.5rem",
|
| 79 |
-
"lg": "2.5rem"
|
| 80 |
-
},
|
| 81 |
-
"fontFamily": {
|
| 82 |
-
"display-lg": ["Geist"],
|
| 83 |
-
"label-md": ["Geist"],
|
| 84 |
-
"headline-md": ["Geist"],
|
| 85 |
-
"body-md": ["Geist"],
|
| 86 |
-
"display-lg-mobile": ["Geist"],
|
| 87 |
-
"caption": ["Geist"],
|
| 88 |
-
"title-sm": ["Geist"]
|
| 89 |
-
},
|
| 90 |
-
"fontSize": {
|
| 91 |
-
"display-lg": ["48px", {"lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
| 92 |
-
"label-md": ["14px", {"lineHeight": "1.2", "letterSpacing": "0.01em", "fontWeight": "500"}],
|
| 93 |
-
"headline-md": ["24px", {"lineHeight": "1.3", "fontWeight": "600"}],
|
| 94 |
-
"body-md": ["16px", {"lineHeight": "1.6", "fontWeight": "400"}],
|
| 95 |
-
"display-lg-mobile": ["32px", {"lineHeight": "1.2", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
| 96 |
-
"caption": ["12px", {"lineHeight": "1.2", "fontWeight": "400"}],
|
| 97 |
-
"title-sm": ["18px", {"lineHeight": "1.4", "fontWeight": "600"}]
|
| 98 |
-
}
|
| 99 |
-
},
|
| 100 |
-
},
|
| 101 |
-
}
|
| 102 |
-
</script>
|
| 103 |
-
<style>
|
| 104 |
-
body {
|
| 105 |
-
background-color: #0b1326;
|
| 106 |
-
color: #dae2fd;
|
| 107 |
-
font-family: 'Geist', sans-serif;
|
| 108 |
-
overflow-x: hidden;
|
| 109 |
-
}
|
| 110 |
-
.glass-card {
|
| 111 |
-
background: rgba(23, 31, 51, 0.5);
|
| 112 |
-
backdrop-filter: blur(12px);
|
| 113 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 114 |
-
box-shadow: inset 0px 1px 0px 0px rgba(255, 255, 255, 0.05);
|
| 115 |
-
}
|
| 116 |
-
.vibrant-emerald-glow {
|
| 117 |
-
box-shadow: 0 0 15px rgba(78, 222, 163, 0.3);
|
| 118 |
-
}
|
| 119 |
-
.custom-scrollbar::-webkit-scrollbar {
|
| 120 |
-
width: 4px;
|
| 121 |
-
}
|
| 122 |
-
.custom-scrollbar::-webkit-scrollbar-track {
|
| 123 |
-
background: rgba(255, 255, 255, 0.05);
|
| 124 |
-
}
|
| 125 |
-
.custom-scrollbar::-webkit-scrollbar-thumb {
|
| 126 |
-
background: #4edea3;
|
| 127 |
-
border-radius: 10px;
|
| 128 |
-
}
|
| 129 |
-
.material-symbols-outlined {
|
| 130 |
-
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
| 131 |
-
vertical-align: middle;
|
| 132 |
-
}
|
| 133 |
-
</style>
|
| 134 |
-
</head>
|
| 135 |
-
<body class="flex min-h-screen">
|
| 136 |
-
<!-- Sidebar Navigation -->
|
| 137 |
-
<aside class="fixed left-0 top-0 h-full flex flex-col pt-20 pb-margin px-sm bg-surface-container/50 backdrop-blur-xl border-r border-white/10 shadow-lg w-64 z-40">
|
| 138 |
-
<div class="mb-lg px-sm">
|
| 139 |
-
<div class="flex items-center gap-xs">
|
| 140 |
-
<span class="material-symbols-outlined text-primary text-headline-md">eco</span>
|
| 141 |
-
<span class="font-headline-md text-headline-md font-bold text-primary">EcoTrack AI</span>
|
| 142 |
-
</div>
|
| 143 |
-
<p class="font-label-md text-label-md text-on-surface-variant opacity-70">Precision Sustainability</p>
|
| 144 |
-
</div>
|
| 145 |
-
<nav class="flex flex-col gap-xs flex-grow">
|
| 146 |
-
<a class="flex items-center gap-xs text-on-surface-variant px-sm py-xs hover:bg-white/5 hover:text-primary transition-all hover:translate-x-1" href="#">
|
| 147 |
-
<span class="material-symbols-outlined" data-icon="dashboard">dashboard</span>
|
| 148 |
-
<span class="font-label-md text-label-md">Dashboard</span>
|
| 149 |
-
</a>
|
| 150 |
-
<a class="flex items-center gap-xs text-on-surface-variant px-sm py-xs hover:bg-white/5 hover:text-primary transition-all hover:translate-x-1" href="#">
|
| 151 |
-
<span class="material-symbols-outlined" data-icon="bar_chart">bar_chart</span>
|
| 152 |
-
<span class="font-label-md text-label-md">Analytics</span>
|
| 153 |
-
</a>
|
| 154 |
-
<a class="flex items-center gap-xs bg-primary/10 text-primary rounded-lg px-sm py-xs hover:translate-x-1 transition-transform" href="#">
|
| 155 |
-
<span class="material-symbols-outlined" data-icon="eco">eco</span>
|
| 156 |
-
<span class="font-label-md text-label-md">Projects</span>
|
| 157 |
-
</a>
|
| 158 |
-
<a class="flex items-center gap-xs text-on-surface-variant px-sm py-xs hover:bg-white/5 hover:text-primary transition-all hover:translate-x-1" href="#">
|
| 159 |
-
<span class="material-symbols-outlined" data-icon="storefront">storefront</span>
|
| 160 |
-
<span class="font-label-md text-label-md">Marketplace</span>
|
| 161 |
-
</a>
|
| 162 |
-
<a class="flex items-center gap-xs text-on-surface-variant px-sm py-xs hover:bg-white/5 hover:text-primary transition-all hover:translate-x-1" href="#">
|
| 163 |
-
<span class="material-symbols-outlined" data-icon="description">description</span>
|
| 164 |
-
<span class="font-label-md text-label-md">Reports</span>
|
| 165 |
-
</a>
|
| 166 |
-
</nav>
|
| 167 |
-
<div class="mt-auto px-sm">
|
| 168 |
-
<button class="w-full py-xs px-sm bg-primary text-on-primary font-label-md rounded-lg vibrant-emerald-glow active:scale-95 transition-all">
|
| 169 |
-
Upgrade to Pro
|
| 170 |
-
</button>
|
| 171 |
-
</div>
|
| 172 |
-
</aside>
|
| 173 |
-
<!-- Main Content Canvas -->
|
| 174 |
-
<main class="ml-64 flex-1 p-margin flex flex-col gap-lg min-h-screen">
|
| 175 |
-
<!-- Header Section -->
|
| 176 |
-
<header class="flex justify-between items-end">
|
| 177 |
-
<div>
|
| 178 |
-
<h1 class="font-display-lg text-display-lg text-primary">EcoHub Challenges</h1>
|
| 179 |
-
<p class="font-body-md text-body-md text-on-surface-variant">Gamified carbon reduction for a greener future.</p>
|
| 180 |
-
</div>
|
| 181 |
-
<div class="flex items-center gap-sm">
|
| 182 |
-
<div class="glass-card px-sm py-xs rounded-xl flex items-center gap-xs">
|
| 183 |
-
<span class="material-symbols-outlined text-tertiary">bolt</span>
|
| 184 |
-
<span class="font-label-md text-label-md">Streak: 12 Days</span>
|
| 185 |
-
</div>
|
| 186 |
-
<div class="glass-card px-sm py-xs rounded-xl flex items-center gap-xs">
|
| 187 |
-
<span class="material-symbols-outlined text-secondary" style="font-variation-settings: 'FILL' 1;">stars</span>
|
| 188 |
-
<span class="font-label-md text-label-md">2,450 XP</span>
|
| 189 |
-
</div>
|
| 190 |
-
</div>
|
| 191 |
-
</header>
|
| 192 |
-
<!-- Bento Grid for Missions and Achievements -->
|
| 193 |
-
<div class="grid grid-cols-12 gap-gutter">
|
| 194 |
-
<!-- Active Missions Grid (Col 1-8) -->
|
| 195 |
-
<section class="col-span-12 lg:col-span-8 space-y-md">
|
| 196 |
-
<div class="flex justify-between items-center">
|
| 197 |
-
<h2 class="font-headline-md text-headline-md text-on-surface flex items-center gap-xs">
|
| 198 |
-
<span class="material-symbols-outlined text-primary">rocket_launch</span>
|
| 199 |
-
Active Missions
|
| 200 |
-
</h2>
|
| 201 |
-
<button class="text-primary font-label-md hover:underline">View All</button>
|
| 202 |
-
</div>
|
| 203 |
-
<div class="grid grid-cols-1 md:grid-cols-2 gap-sm">
|
| 204 |
-
<!-- Mission Card 1 -->
|
| 205 |
-
<div class="glass-card p-md rounded-xl group hover:border-primary/50 transition-all cursor-pointer">
|
| 206 |
-
<div class="flex justify-between items-start mb-sm">
|
| 207 |
-
<div class="bg-primary/10 p-xs rounded-lg">
|
| 208 |
-
<span class="material-symbols-outlined text-primary">commute</span>
|
| 209 |
-
</div>
|
| 210 |
-
<span class="font-caption text-caption text-primary bg-primary/10 px-xs py-1 rounded-full">3 Days Left</span>
|
| 211 |
-
</div>
|
| 212 |
-
<h3 class="font-title-sm text-title-sm mb-xs">The Cycle Sprint</h3>
|
| 213 |
-
<p class="font-caption text-caption text-on-surface-variant mb-md">Commute by bicycle for 5 days this week to reduce your carbon footprint by 12kg.</p>
|
| 214 |
-
<div class="w-full h-1.5 bg-surface-container rounded-full overflow-hidden mb-xs">
|
| 215 |
-
<div class="h-full bg-gradient-to-r from-secondary to-primary w-[60%]"></div>
|
| 216 |
-
</div>
|
| 217 |
-
<div class="flex justify-between items-center">
|
| 218 |
-
<span class="font-caption text-caption text-on-surface-variant">3/5 days complete</span>
|
| 219 |
-
<span class="font-label-md text-label-md text-primary">+450 XP</span>
|
| 220 |
-
</div>
|
| 221 |
-
</div>
|
| 222 |
-
<!-- Mission Card 2 -->
|
| 223 |
-
<div class="glass-card p-md rounded-xl group hover:border-primary/50 transition-all cursor-pointer">
|
| 224 |
-
<div class="flex justify-between items-start mb-sm">
|
| 225 |
-
<div class="bg-tertiary/10 p-xs rounded-lg">
|
| 226 |
-
<span class="material-symbols-outlined text-tertiary">electric_bolt</span>
|
| 227 |
-
</div>
|
| 228 |
-
<span class="font-caption text-caption text-tertiary bg-tertiary/10 px-xs py-1 rounded-full">Weekly</span>
|
| 229 |
-
</div>
|
| 230 |
-
<h3 class="font-title-sm text-title-sm mb-xs">Phantom Power Hunter</h3>
|
| 231 |
-
<p class="font-caption text-caption text-on-surface-variant mb-md">Unplug all non-essential electronics before bed for 7 consecutive nights.</p>
|
| 232 |
-
<div class="w-full h-1.5 bg-surface-container rounded-full overflow-hidden mb-xs">
|
| 233 |
-
<div class="h-full bg-gradient-to-r from-secondary to-primary w-[85%]"></div>
|
| 234 |
-
</div>
|
| 235 |
-
<div class="flex justify-between items-center">
|
| 236 |
-
<span class="font-caption text-caption text-on-surface-variant">6/7 nights complete</span>
|
| 237 |
-
<span class="font-label-md text-label-md text-primary">+200 XP</span>
|
| 238 |
-
</div>
|
| 239 |
-
</div>
|
| 240 |
-
<!-- Mission Card 3 -->
|
| 241 |
-
<div class="glass-card p-md rounded-xl group hover:border-primary/50 transition-all cursor-pointer">
|
| 242 |
-
<div class="flex justify-between items-start mb-sm">
|
| 243 |
-
<div class="bg-secondary/10 p-xs rounded-lg">
|
| 244 |
-
<span class="material-symbols-outlined text-secondary">eco</span>
|
| 245 |
-
</div>
|
| 246 |
-
<span class="font-caption text-caption text-secondary bg-secondary/10 px-xs py-1 rounded-full">New</span>
|
| 247 |
-
</div>
|
| 248 |
-
<h3 class="font-title-sm text-title-sm mb-xs">Waste Warrior</h3>
|
| 249 |
-
<p class="font-caption text-caption text-on-surface-variant mb-md">Achieve a zero-plastic week by using reusable alternatives and reporting progress.</p>
|
| 250 |
-
<div class="w-full h-1.5 bg-surface-container rounded-full overflow-hidden mb-xs">
|
| 251 |
-
<div class="h-full bg-gradient-to-r from-secondary to-primary w-[10%]"></div>
|
| 252 |
-
</div>
|
| 253 |
-
<div class="flex justify-between items-center">
|
| 254 |
-
<span class="font-caption text-caption text-on-surface-variant">Started today</span>
|
| 255 |
-
<span class="font-label-md text-label-md text-primary">+600 XP</span>
|
| 256 |
-
</div>
|
| 257 |
-
</div>
|
| 258 |
-
<!-- Mission Card 4 -->
|
| 259 |
-
<div class="glass-card p-md rounded-xl group hover:border-primary/50 transition-all cursor-pointer border-dashed border-primary/30 bg-transparent flex flex-col items-center justify-center text-center">
|
| 260 |
-
<span class="material-symbols-outlined text-primary mb-xs">add_circle</span>
|
| 261 |
-
<h3 class="font-title-sm text-title-sm">Browse Challenges</h3>
|
| 262 |
-
<p class="font-caption text-caption text-on-surface-variant">Find more ways to impact the planet.</p>
|
| 263 |
-
</div>
|
| 264 |
-
</div>
|
| 265 |
-
<!-- Achievements Section -->
|
| 266 |
-
<div class="pt-lg">
|
| 267 |
-
<h2 class="font-headline-md text-headline-md text-on-surface mb-md flex items-center gap-xs">
|
| 268 |
-
<span class="material-symbols-outlined text-tertiary" style="font-variation-settings: 'FILL' 1;">emoji_events</span>
|
| 269 |
-
Achievements
|
| 270 |
-
</h2>
|
| 271 |
-
<div class="glass-card p-md rounded-xl flex gap-md overflow-x-auto custom-scrollbar pb-xs">
|
| 272 |
-
<!-- Badge 1 -->
|
| 273 |
-
<div class="flex flex-col items-center min-w-[100px] gap-xs text-center group">
|
| 274 |
-
<div class="w-16 h-16 rounded-full bg-surface-container-high border border-primary/20 flex items-center justify-center group-hover:scale-110 transition-transform bg-[url('https://images.unsplash.com/photo-1542601906990-b4d3fb778b09?ixlib=rb-4.0.3&auto=format&fit=crop&w=100&q=80')] bg-cover bg-center overflow-hidden" data-alt="A stylized glowing digital badge of a tiny seedling emerging from a digital soil grid, representing growth and sustainability.">
|
| 275 |
-
<div class="w-full h-full bg-primary/20 backdrop-blur-[2px] flex items-center justify-center">
|
| 276 |
-
<span class="material-symbols-outlined text-primary text-3xl" style="font-variation-settings: 'FILL' 1;">forest</span>
|
| 277 |
-
</div>
|
| 278 |
-
</div>
|
| 279 |
-
<span class="font-label-md text-label-md text-on-surface">Tree Planter</span>
|
| 280 |
-
</div>
|
| 281 |
-
<!-- Badge 2 -->
|
| 282 |
-
<div class="flex flex-col items-center min-w-[100px] gap-xs text-center group">
|
| 283 |
-
<div class="w-16 h-16 rounded-full bg-surface-container-high border border-secondary/20 flex items-center justify-center group-hover:scale-110 transition-transform bg-[url('https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?ixlib=rb-4.0.3&auto=format&fit=crop&w=100&q=80')] bg-cover bg-center overflow-hidden" data-alt="A futuristic badge design featuring clean cyan geometric shapes and a stylized electrical spark, representing energy efficiency and technological advancement.">
|
| 284 |
-
<div class="w-full h-full bg-secondary/20 backdrop-blur-[2px] flex items-center justify-center">
|
| 285 |
-
<span class="material-symbols-outlined text-secondary text-3xl" style="font-variation-settings: 'FILL' 1;">bolt</span>
|
| 286 |
-
</div>
|
| 287 |
-
</div>
|
| 288 |
-
<span class="font-label-md text-label-md text-on-surface">Kilowatt Killer</span>
|
| 289 |
-
</div>
|
| 290 |
-
<!-- Badge 3 -->
|
| 291 |
-
<div class="flex flex-col items-center min-w-[100px] gap-xs text-center group">
|
| 292 |
-
<div class="w-16 h-16 rounded-full bg-surface-container-high border border-tertiary/20 flex items-center justify-center group-hover:scale-110 transition-transform bg-[url('https://images.unsplash.com/photo-1518005020250-6eb5f3f2f669?ixlib=rb-4.0.3&auto=format&fit=crop&w=100&q=80')] bg-cover bg-center overflow-hidden" data-alt="A circular digital emblem depicting a stylized carbon atom being contained by a hexagonal mesh, using amber and dark blue tones for a professional scientific aesthetic.">
|
| 293 |
-
<div class="w-full h-full bg-tertiary/20 backdrop-blur-[2px] flex items-center justify-center">
|
| 294 |
-
<span class="material-symbols-outlined text-tertiary text-3xl" style="font-variation-settings: 'FILL' 1;">psychology</span>
|
| 295 |
-
</div>
|
| 296 |
-
</div>
|
| 297 |
-
<span class="font-label-md text-label-md text-on-surface">Carbon Ninja</span>
|
| 298 |
-
</div>
|
| 299 |
-
<!-- Badge 4 Locked -->
|
| 300 |
-
<div class="flex flex-col items-center min-w-[100px] gap-xs text-center group opacity-40 grayscale">
|
| 301 |
-
<div class="w-16 h-16 rounded-full bg-surface-container border border-white/5 flex items-center justify-center">
|
| 302 |
-
<span class="material-symbols-outlined text-on-surface-variant text-3xl">lock</span>
|
| 303 |
-
</div>
|
| 304 |
-
<span class="font-label-md text-label-md text-on-surface-variant">Global Hero</span>
|
| 305 |
-
</div>
|
| 306 |
-
<!-- Badge 5 Locked -->
|
| 307 |
-
<div class="flex flex-col items-center min-w-[100px] gap-xs text-center group opacity-40 grayscale">
|
| 308 |
-
<div class="w-16 h-16 rounded-full bg-surface-container border border-white/5 flex items-center justify-center">
|
| 309 |
-
<span class="material-symbols-outlined text-on-surface-variant text-3xl">lock</span>
|
| 310 |
-
</div>
|
| 311 |
-
<span class="font-label-md text-label-md text-on-surface-variant">Water Saver</span>
|
| 312 |
-
</div>
|
| 313 |
-
</div>
|
| 314 |
-
</div>
|
| 315 |
-
</section>
|
| 316 |
-
<!-- Leaderboard Sidebar (Col 9-12) -->
|
| 317 |
-
<section class="col-span-12 lg:col-span-4 flex flex-col">
|
| 318 |
-
<div class="glass-card rounded-xl flex-grow overflow-hidden flex flex-col sticky top-margin max-h-[870px]">
|
| 319 |
-
<div class="p-md border-b border-white/10 flex justify-between items-center">
|
| 320 |
-
<h2 class="font-headline-md text-headline-md text-on-surface">Global Leaderboard</h2>
|
| 321 |
-
<span class="material-symbols-outlined text-on-surface-variant cursor-pointer">filter_list</span>
|
| 322 |
-
</div>
|
| 323 |
-
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
| 324 |
-
<!-- Rank 1 -->
|
| 325 |
-
<div class="flex items-center gap-sm p-sm hover:bg-white/5 transition-colors border-b border-white/5">
|
| 326 |
-
<div class="flex flex-col items-center min-w-[30px]">
|
| 327 |
-
<span class="material-symbols-outlined text-tertiary text-lg" style="font-variation-settings: 'FILL' 1;">military_tech</span>
|
| 328 |
-
<span class="font-label-md text-label-md text-tertiary">1</span>
|
| 329 |
-
</div>
|
| 330 |
-
<img alt="Avatar 1" class="w-10 h-10 rounded-full border border-primary/30" data-alt="A professional headshot of a friendly-looking man with a modern haircut, set against a blurred background of a sustainable glass-walled office with indoor plants." src="https://lh3.googleusercontent.com/aida-public/AB6AXuAo0mxVMmgK0G8ytfb1rNX5JH6NqVRvROf7RIh5nMam96JfS5uTBz87S_rrFbUrgr0d3xA_kW6gcM3qxAy8m_V7_zZk0NBFNgm_2-UhJMw2tkDDS98w4qH5aMV1pAscVQ-F8dw0eoEAxfkX9huPjdipqj-5jQBxSYtzngDPCtQDyx_QO1leO0SfiEjboT49FL8HNHmkF9WYy0S9DhczQa-W0FawQnpsuG1_FIMsDZn0nUPtc_1lYqa5t_OCnAH6MXxMoGP6a-zdTEfY"/>
|
| 331 |
-
<div class="flex-1">
|
| 332 |
-
<p class="font-label-md text-label-md text-on-surface">Alex Rivera</p>
|
| 333 |
-
<p class="font-caption text-caption text-on-surface-variant">Palo Alto, CA</p>
|
| 334 |
-
</div>
|
| 335 |
-
<span class="font-title-sm text-title-sm text-primary">12.4k</span>
|
| 336 |
-
</div>
|
| 337 |
-
<!-- Rank 2 -->
|
| 338 |
-
<div class="flex items-center gap-sm p-sm hover:bg-white/5 transition-colors border-b border-white/5">
|
| 339 |
-
<div class="flex flex-col items-center min-w-[30px]">
|
| 340 |
-
<span class="font-label-md text-label-md text-on-surface-variant opacity-70">2</span>
|
| 341 |
-
</div>
|
| 342 |
-
<img alt="Avatar 2" class="w-10 h-10 rounded-full border border-white/10" data-alt="A portrait of a confident woman with short hair and glasses, wearing a smart casual outfit in a bright, modern studio with soft architectural lighting." src="https://lh3.googleusercontent.com/aida-public/AB6AXuBgxy9Vc1Og1X-KrYBrI1dS0qLIqG3debVeo6Uxdvx-4m2fZo93O988deugIgzBvIIixbJw-hSPADU8ioyM8b3ejzoJiMOp7H9KLSuUQEvwsNGek2JtY7Yc1sxnh-5lf5GB8zYGRt3lWpnVmlgGBhckgvQAXLHsblGdu6JG4rJSLgrVMFSoWb8jucEKx_4vMBPnbDYq5VGpT5JTaZ2MxYTh_0lh6rcv5g3ayoB1YbXFrEISZaUmszXbrRqgeREQj8l_hrMNmVLlQht9"/>
|
| 343 |
-
<div class="flex-1">
|
| 344 |
-
<p class="font-label-md text-label-md text-on-surface">Sarah Chen</p>
|
| 345 |
-
<p class="font-caption text-caption text-on-surface-variant">Berlin, GER</p>
|
| 346 |
-
</div>
|
| 347 |
-
<span class="font-title-sm text-title-sm text-primary">11.8k</span>
|
| 348 |
-
</div>
|
| 349 |
-
<!-- User Current Rank -->
|
| 350 |
-
<div class="flex items-center gap-sm p-sm bg-primary/10 border-y border-primary/20 my-xs">
|
| 351 |
-
<div class="flex flex-col items-center min-w-[30px]">
|
| 352 |
-
<span class="font-label-md text-label-md text-primary font-bold">142</span>
|
| 353 |
-
</div>
|
| 354 |
-
<div class="w-10 h-10 rounded-full bg-primary flex items-center justify-center text-on-primary font-bold">JD</div>
|
| 355 |
-
<div class="flex-1">
|
| 356 |
-
<p class="font-label-md text-label-md text-on-surface font-bold">You (Jane Doe)</p>
|
| 357 |
-
<p class="font-caption text-caption text-on-surface-variant">Portland, OR</p>
|
| 358 |
-
</div>
|
| 359 |
-
<span class="font-title-sm text-title-sm text-primary font-bold">2.4k</span>
|
| 360 |
-
</div>
|
| 361 |
-
<!-- Other ranks... -->
|
| 362 |
-
<div class="flex items-center gap-sm p-sm hover:bg-white/5 transition-colors border-b border-white/5">
|
| 363 |
-
<div class="flex flex-col items-center min-w-[30px]">
|
| 364 |
-
<span class="font-label-md text-label-md text-on-surface-variant opacity-70">3</span>
|
| 365 |
-
</div>
|
| 366 |
-
<img alt="Avatar 3" class="w-10 h-10 rounded-full border border-white/10" data-alt="A headshot of a middle-aged man with a neat beard and smiling eyes, photographed in a park with soft natural lighting and a green bokeh background." src="https://lh3.googleusercontent.com/aida-public/AB6AXuBBHaL6qaFLYyz3L7ppNpgusmPU6WlUwncEysvoU2c1bFbUSS5yv_TiRpJDZiZZv4KkNvqy_TjFlhfTMI1HNIUlF3M2dUjOJRsAX8yj_m16TOiam6tgUk9EDx1TfAuZ3tgp4W86UxQHwlvRgix25LXtJmAFPwoW3KnKGqRbTDzicvK10yBrBeaTfzz2_ERiq3lki4d5LYROb5yrShv4yZc_k0nAJh9VVBcaQiGUEz7tAMNpWuDqtpwv8RFUo13A8A-LhoK63wiq-yZz"/>
|
| 367 |
-
<div class="flex-1">
|
| 368 |
-
<p class="font-label-md text-label-md text-on-surface">Marcus Thorne</p>
|
| 369 |
-
<p class="font-caption text-caption text-on-surface-variant">London, UK</p>
|
| 370 |
-
</div>
|
| 371 |
-
<span class="font-title-sm text-title-sm text-primary">10.2k</span>
|
| 372 |
-
</div>
|
| 373 |
-
<!-- Repeat similar for padding -->
|
| 374 |
-
<div class="flex items-center gap-sm p-sm hover:bg-white/5 transition-colors border-b border-white/5">
|
| 375 |
-
<div class="flex flex-col items-center min-w-[30px]">
|
| 376 |
-
<span class="font-label-md text-label-md text-on-surface-variant opacity-70">4</span>
|
| 377 |
-
</div>
|
| 378 |
-
<img alt="Avatar 4" class="w-10 h-10 rounded-full border border-white/10" data-alt="A portrait of a young woman with a thoughtful expression, wearing a minimalist sustainable fashion piece, set against a clean concrete wall with industrial daylighting." src="https://lh3.googleusercontent.com/aida-public/AB6AXuAZ_n3n4igOlbHnlFirkwtv1GmJaXi996tq-ab6aqPU6DAxlCqvpFW-vMYS6cJoWpk4GyzE2zCaWVazrNAz5jpcfvmV-7dS5kfKiuE9KgiQUv7MyA63hHuwtoGkw9ASkXLa9v3pCJBLcn_bdengfzsgCroJNV7iqJDEcPoVPxug-y2J3KkHMaJe_vhEkhI4KQiBBJcukLV2Vhk1P8j3KUkpzxalPUtxBzl62DRLBqWcCUZvAnMzmnEzUURb0D8-brJtLKpIJDxazKhV"/>
|
| 379 |
-
<div class="flex-1">
|
| 380 |
-
<p class="font-label-md text-label-md text-on-surface">Elena Vogt</p>
|
| 381 |
-
<p class="font-caption text-caption text-on-surface-variant">Zurich, SUI</p>
|
| 382 |
-
</div>
|
| 383 |
-
<span class="font-title-sm text-title-sm text-primary">9.5k</span>
|
| 384 |
-
</div>
|
| 385 |
-
</div>
|
| 386 |
-
<div class="p-md mt-auto bg-surface-container-high border-t border-white/10">
|
| 387 |
-
<button class="w-full py-xs px-sm border border-primary text-primary font-label-md rounded-lg hover:bg-primary/10 transition-all">
|
| 388 |
-
Challenge a Friend
|
| 389 |
-
</button>
|
| 390 |
-
</div>
|
| 391 |
-
</div>
|
| 392 |
-
</section>
|
| 393 |
-
</div>
|
| 394 |
-
<!-- Atmospheric Background Element -->
|
| 395 |
-
<div class="fixed bottom-0 right-0 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] -z-10 pointer-events-none"></div>
|
| 396 |
-
<div class="fixed top-0 left-0 w-[300px] h-[300px] bg-secondary/5 rounded-full blur-[100px] -z-10 pointer-events-none"></div>
|
| 397 |
-
<!-- Footer -->
|
| 398 |
-
<footer class="w-full py-md flex justify-between items-center mt-auto border-t border-white/10">
|
| 399 |
-
<div class="flex flex-col">
|
| 400 |
-
<span class="font-title-sm text-title-sm text-on-surface">EcoTrack AI</span>
|
| 401 |
-
<span class="font-caption text-caption text-secondary opacity-70">© 2024 EcoTrack AI. Engineering a Sustainable Future.</span>
|
| 402 |
-
</div>
|
| 403 |
-
<div class="flex gap-md">
|
| 404 |
-
<a class="font-caption text-caption text-on-surface-variant hover:text-secondary underline transition-all" href="#">Privacy Policy</a>
|
| 405 |
-
<a class="font-caption text-caption text-on-surface-variant hover:text-secondary underline transition-all" href="#">Terms of Service</a>
|
| 406 |
-
<a class="font-caption text-caption text-on-surface-variant hover:text-secondary underline transition-all" href="#">Carbon Methodology</a>
|
| 407 |
-
<a class="font-caption text-caption text-on-surface-variant hover:text-secondary underline transition-all" href="#">API Docs</a>
|
| 408 |
-
</div>
|
| 409 |
-
</footer>
|
| 410 |
-
</main>
|
| 411 |
-
<!-- Micro-interaction Scripts -->
|
| 412 |
-
<script>
|
| 413 |
-
document.querySelectorAll('.glass-card').forEach(card => {
|
| 414 |
-
card.addEventListener('mouseenter', () => {
|
| 415 |
-
card.style.transform = 'translateY(-4px)';
|
| 416 |
-
card.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
| 417 |
-
});
|
| 418 |
-
card.addEventListener('mouseleave', () => {
|
| 419 |
-
card.style.transform = 'translateY(0)';
|
| 420 |
-
});
|
| 421 |
-
});
|
| 422 |
-
|
| 423 |
-
// Simple animation for progress bars on load
|
| 424 |
-
window.addEventListener('DOMContentLoaded', () => {
|
| 425 |
-
const bars = document.querySelectorAll('.h-full.bg-gradient-to-r');
|
| 426 |
-
bars.forEach(bar => {
|
| 427 |
-
const finalWidth = bar.style.width;
|
| 428 |
-
bar.style.width = '0%';
|
| 429 |
-
setTimeout(() => {
|
| 430 |
-
bar.style.transition = 'width 1.5s cubic-bezier(0.65, 0, 0.35, 1)';
|
| 431 |
-
bar.style.width = finalWidth;
|
| 432 |
-
}, 300);
|
| 433 |
-
});
|
| 434 |
-
});
|
| 435 |
-
</script>
|
| 436 |
-
</body></html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Web-design/landing_page_desktop/code.html
DELETED
|
@@ -1,348 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
|
| 3 |
-
<html class="dark" lang="en"><head>
|
| 4 |
-
<meta charset="utf-8"/>
|
| 5 |
-
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
| 6 |
-
<title>EcoTrack AI | Precision Sustainability</title>
|
| 7 |
-
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
| 8 |
-
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 9 |
-
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" rel="stylesheet"/>
|
| 10 |
-
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 11 |
-
<script id="tailwind-config">
|
| 12 |
-
tailwind.config = {
|
| 13 |
-
darkMode: "class",
|
| 14 |
-
theme: {
|
| 15 |
-
extend: {
|
| 16 |
-
"colors": {
|
| 17 |
-
"tertiary-container": "#e29100",
|
| 18 |
-
"surface": "#0b1326",
|
| 19 |
-
"on-secondary": "#00344d",
|
| 20 |
-
"on-primary-fixed-variant": "#005236",
|
| 21 |
-
"primary-fixed": "#6ffbbe",
|
| 22 |
-
"on-surface": "#dae2fd",
|
| 23 |
-
"surface-container-lowest": "#060e20",
|
| 24 |
-
"on-tertiary-fixed-variant": "#653e00",
|
| 25 |
-
"on-secondary-fixed": "#001e2f",
|
| 26 |
-
"error-container": "#93000a",
|
| 27 |
-
"background": "#0b1326",
|
| 28 |
-
"surface-tint": "#4edea3",
|
| 29 |
-
"outline": "#86948a",
|
| 30 |
-
"primary-container": "#10b981",
|
| 31 |
-
"outline-variant": "#3c4a42",
|
| 32 |
-
"surface-dim": "#0b1326",
|
| 33 |
-
"secondary-container": "#00a2e6",
|
| 34 |
-
"surface-container-highest": "#2d3449",
|
| 35 |
-
"tertiary-fixed": "#ffddb8",
|
| 36 |
-
"surface-container-low": "#131b2e",
|
| 37 |
-
"surface-container": "#171f33",
|
| 38 |
-
"on-secondary-fixed-variant": "#004c6e",
|
| 39 |
-
"on-background": "#dae2fd",
|
| 40 |
-
"primary": "#4edea3",
|
| 41 |
-
"inverse-surface": "#dae2fd",
|
| 42 |
-
"secondary-fixed-dim": "#89ceff",
|
| 43 |
-
"surface-container-high": "#222a3d",
|
| 44 |
-
"on-error-container": "#ffdad6",
|
| 45 |
-
"secondary-fixed": "#c9e6ff",
|
| 46 |
-
"on-secondary-container": "#00344e",
|
| 47 |
-
"on-primary-container": "#00422b",
|
| 48 |
-
"on-primary": "#003824",
|
| 49 |
-
"tertiary-fixed-dim": "#ffb95f",
|
| 50 |
-
"on-surface-variant": "#bbcabf",
|
| 51 |
-
"on-tertiary-container": "#523200",
|
| 52 |
-
"primary-fixed-dim": "#4edea3",
|
| 53 |
-
"on-tertiary": "#472a00",
|
| 54 |
-
"secondary": "#89ceff",
|
| 55 |
-
"on-error": "#690005",
|
| 56 |
-
"inverse-primary": "#006c49",
|
| 57 |
-
"on-tertiary-fixed": "#2a1700",
|
| 58 |
-
"on-primary-fixed": "#002113",
|
| 59 |
-
"inverse-on-surface": "#283044",
|
| 60 |
-
"error": "#ffb4ab",
|
| 61 |
-
"tertiary": "#ffb95f",
|
| 62 |
-
"surface-bright": "#31394d",
|
| 63 |
-
"surface-variant": "#2d3449"
|
| 64 |
-
},
|
| 65 |
-
"borderRadius": {
|
| 66 |
-
"DEFAULT": "0.25rem",
|
| 67 |
-
"lg": "0.5rem",
|
| 68 |
-
"xl": "0.75rem",
|
| 69 |
-
"full": "9999px"
|
| 70 |
-
},
|
| 71 |
-
"spacing": {
|
| 72 |
-
"xl": "4rem",
|
| 73 |
-
"unit": "4px",
|
| 74 |
-
"gutter": "1.5rem",
|
| 75 |
-
"margin": "2rem",
|
| 76 |
-
"sm": "1rem",
|
| 77 |
-
"xs": "0.5rem",
|
| 78 |
-
"md": "1.5rem",
|
| 79 |
-
"lg": "2.5rem"
|
| 80 |
-
},
|
| 81 |
-
"fontFamily": {
|
| 82 |
-
"display-lg": ["Geist"],
|
| 83 |
-
"label-md": ["Geist"],
|
| 84 |
-
"headline-md": ["Geist"],
|
| 85 |
-
"body-md": ["Geist"],
|
| 86 |
-
"display-lg-mobile": ["Geist"],
|
| 87 |
-
"caption": ["Geist"],
|
| 88 |
-
"title-sm": ["Geist"]
|
| 89 |
-
},
|
| 90 |
-
"fontSize": {
|
| 91 |
-
"display-lg": ["48px", {"lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
| 92 |
-
"label-md": ["14px", {"lineHeight": "1.2", "letterSpacing": "0.01em", "fontWeight": "500"}],
|
| 93 |
-
"headline-md": ["24px", {"lineHeight": "1.3", "fontWeight": "600"}],
|
| 94 |
-
"body-md": ["16px", {"lineHeight": "1.6", "fontWeight": "400"}],
|
| 95 |
-
"display-lg-mobile": ["32px", {"lineHeight": "1.2", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
| 96 |
-
"caption": ["12px", {"lineHeight": "1.2", "fontWeight": "400"}],
|
| 97 |
-
"title-sm": ["18px", {"lineHeight": "1.4", "fontWeight": "600"}]
|
| 98 |
-
}
|
| 99 |
-
},
|
| 100 |
-
},
|
| 101 |
-
}
|
| 102 |
-
</script>
|
| 103 |
-
<style>
|
| 104 |
-
body {
|
| 105 |
-
background-color: #0b1326;
|
| 106 |
-
color: #dae2fd;
|
| 107 |
-
font-family: 'Geist', sans-serif;
|
| 108 |
-
overflow-x: hidden;
|
| 109 |
-
}
|
| 110 |
-
.glass-card {
|
| 111 |
-
background: rgba(30, 41, 59, 0.5);
|
| 112 |
-
backdrop-filter: blur(12px);
|
| 113 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 114 |
-
box-shadow: inset 0px 1px 0px 0px rgba(255, 255, 255, 0.05);
|
| 115 |
-
}
|
| 116 |
-
.vibrant-emerald-glow {
|
| 117 |
-
box-shadow: 0 0 15px rgba(78, 222, 163, 0.3);
|
| 118 |
-
}
|
| 119 |
-
.transition-slow {
|
| 120 |
-
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
| 121 |
-
}
|
| 122 |
-
@keyframes slow-rotate {
|
| 123 |
-
from { transform: rotate(0deg); }
|
| 124 |
-
to { transform: rotate(360deg); }
|
| 125 |
-
}
|
| 126 |
-
.animate-slow-rotate {
|
| 127 |
-
animation: slow-rotate 120s linear infinite;
|
| 128 |
-
}
|
| 129 |
-
.emerald-gradient-text {
|
| 130 |
-
background: linear-gradient(135deg, #4edea3 0%, #89ceff 100%);
|
| 131 |
-
-webkit-background-clip: text;
|
| 132 |
-
-webkit-text-fill-color: transparent;
|
| 133 |
-
}
|
| 134 |
-
</style>
|
| 135 |
-
</head>
|
| 136 |
-
<body class="flex flex-col min-h-screen">
|
| 137 |
-
<!-- TopNavBar -->
|
| 138 |
-
<nav class="fixed top-0 left-0 w-full z-50 flex justify-between items-center px-gutter h-16 bg-surface/50 backdrop-blur-md border-b border-white/10 shadow-sm">
|
| 139 |
-
<div class="flex items-center gap-md">
|
| 140 |
-
<span class="font-display-lg text-display-lg font-bold text-primary tracking-tight">EcoTrack AI</span>
|
| 141 |
-
<div class="hidden md:flex items-center gap-gutter ml-lg">
|
| 142 |
-
<a class="text-primary border-b-2 border-primary pb-1 font-body-md text-body-md transition-colors" href="#">Dashboard</a>
|
| 143 |
-
<a class="text-on-surface-variant hover:text-primary transition-colors font-body-md text-body-md" href="#">Analytics</a>
|
| 144 |
-
<a class="text-on-surface-variant hover:text-primary transition-colors font-body-md text-body-md" href="#">Projects</a>
|
| 145 |
-
<a class="text-on-surface-variant hover:text-primary transition-colors font-body-md text-body-md" href="#">Marketplace</a>
|
| 146 |
-
</div>
|
| 147 |
-
</div>
|
| 148 |
-
<div class="flex items-center gap-sm">
|
| 149 |
-
<div class="relative group cursor-pointer active:scale-95 transition-transform">
|
| 150 |
-
<span class="material-symbols-outlined text-on-surface-variant hover:text-primary">notifications</span>
|
| 151 |
-
</div>
|
| 152 |
-
<div class="relative group cursor-pointer active:scale-95 transition-transform ml-xs">
|
| 153 |
-
<span class="material-symbols-outlined text-on-surface-variant hover:text-primary">settings</span>
|
| 154 |
-
</div>
|
| 155 |
-
<div class="w-8 h-8 rounded-full overflow-hidden border border-primary/30 ml-sm cursor-pointer active:scale-95 transition-transform">
|
| 156 |
-
<img alt="User profile" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuABbwKjMCtt2BIPzuY3HKRlGvLYpw0bYz6TOd7Fgb5wlf63ixv0-IvdSgVEb_IfLak8FGpkbmxVKGU8nZzutsiZ3Cgv27Z27iZ5lxgGGddbLZ6Sls52OtMW6nrVvIfNOyvpJpY2kJKuwG8oLHwbf6ESqzDFTWh6hDd_IWX-g43EHI9kHbTuTq1t2i0W_QYLBZ2ZiXCW3fskPXC5aYuEY0fUi_KpUOF52XvWRVnK1ytekhQsVSdrSgAVHabj7B0clky00xCcFyBb7FqV"/>
|
| 157 |
-
</div>
|
| 158 |
-
</div>
|
| 159 |
-
</nav>
|
| 160 |
-
<main class="flex-grow pt-16">
|
| 161 |
-
<!-- Hero Section -->
|
| 162 |
-
<section class="relative h-screen flex items-center justify-center overflow-hidden px-gutter">
|
| 163 |
-
|
| 164 |
-
<div class="relative z-10 max-w-6xl w-full grid grid-cols-1 lg:grid-cols-2 gap-xl items-center">
|
| 165 |
-
<div class="space-y-md text-center lg:text-left">
|
| 166 |
-
<div class="inline-flex items-center px-sm py-xs rounded-full bg-primary/10 border border-primary/20 backdrop-blur-md">
|
| 167 |
-
<span class="material-symbols-outlined text-primary mr-xs text-sm" style="font-variation-settings: 'FILL' 1;">eco</span>
|
| 168 |
-
<span class="font-label-md text-label-md text-primary uppercase tracking-widest">Next-Gen Carbon Intelligence</span>
|
| 169 |
-
</div>
|
| 170 |
-
<h1 class="font-display-lg text-display-lg text-on-surface leading-none">
|
| 171 |
-
Track. Reduce. <br/>
|
| 172 |
-
<span class="emerald-gradient-text">Restore.</span>
|
| 173 |
-
</h1>
|
| 174 |
-
<p class="font-body-md text-body-md text-on-surface-variant max-w-lg mx-auto lg:mx-0">
|
| 175 |
-
Harnessing the power of precision AI to quantify environmental impact in real-time. Join the global movement to engineer a carbon-negative future through data-driven clarity.
|
| 176 |
-
</p>
|
| 177 |
-
<div class="flex flex-col sm:flex-row gap-sm pt-md justify-center lg:justify-start">
|
| 178 |
-
<button class="px-lg py-sm bg-primary text-on-primary font-title-sm text-title-sm rounded-lg vibrant-emerald-glow hover:scale-105 active:scale-95 transition-all">
|
| 179 |
-
Start Your Journey
|
| 180 |
-
</button>
|
| 181 |
-
<button class="px-lg py-sm border border-primary/30 text-primary font-title-sm text-title-sm rounded-lg hover:bg-primary/10 transition-all flex items-center justify-center gap-xs">
|
| 182 |
-
View Global Map <span class="material-symbols-outlined">north_east</span>
|
| 183 |
-
</button>
|
| 184 |
-
</div>
|
| 185 |
-
</div>
|
| 186 |
-
<div class="relative hidden lg:flex justify-center items-center h-[500px]">
|
| 187 |
-
<!-- Floating Data Visualizations around a central globe glow -->
|
| 188 |
-
<div class="absolute w-96 h-96 bg-primary/20 blur-[100px] rounded-full animate-pulse"></div>
|
| 189 |
-
<div class="relative z-20 w-80 h-80 rounded-full glass-card border-primary/20 flex items-center justify-center overflow-hidden">
|
| 190 |
-
|
| 191 |
-
</div>
|
| 192 |
-
<!-- Stats Overlays -->
|
| 193 |
-
<div class="absolute top-0 right-0 glass-card p-sm rounded-xl border-secondary/20 -translate-y-1/4 animate-bounce-slow">
|
| 194 |
-
<div class="flex items-center gap-xs">
|
| 195 |
-
<span class="material-symbols-outlined text-secondary">trending_down</span>
|
| 196 |
-
<div>
|
| 197 |
-
<p class="text-[10px] uppercase tracking-tighter opacity-70">Live Reduction</p>
|
| 198 |
-
<p class="font-headline-md text-headline-md text-secondary">-14.2%</p>
|
| 199 |
-
</div>
|
| 200 |
-
</div>
|
| 201 |
-
</div>
|
| 202 |
-
</div>
|
| 203 |
-
</div>
|
| 204 |
-
</section>
|
| 205 |
-
<!-- Stats Ticker -->
|
| 206 |
-
<section class="py-md border-y border-white/5 bg-surface-container-lowest/50 backdrop-blur-sm">
|
| 207 |
-
<div class="max-w-7xl mx-auto px-gutter grid grid-cols-2 md:grid-cols-4 gap-lg text-center">
|
| 208 |
-
<div>
|
| 209 |
-
<p class="font-label-md text-label-md text-on-surface-variant uppercase">Global Carbon Saved</p>
|
| 210 |
-
<p class="font-headline-md text-headline-md text-primary mt-xs">2.4M Tons</p>
|
| 211 |
-
</div>
|
| 212 |
-
<div>
|
| 213 |
-
<p class="font-label-md text-label-md text-on-surface-variant uppercase">Active Projects</p>
|
| 214 |
-
<p class="font-headline-md text-headline-md text-primary mt-xs">1,842</p>
|
| 215 |
-
</div>
|
| 216 |
-
<div>
|
| 217 |
-
<p class="font-label-md text-label-md text-on-surface-variant uppercase">Trees Restored</p>
|
| 218 |
-
<p class="font-headline-md text-headline-md text-primary mt-xs">840K+</p>
|
| 219 |
-
</div>
|
| 220 |
-
<div>
|
| 221 |
-
<p class="font-label-md text-label-md text-on-surface-variant uppercase">AI Precision</p>
|
| 222 |
-
<p class="font-headline-md text-headline-md text-primary mt-xs">99.9%</p>
|
| 223 |
-
</div>
|
| 224 |
-
</div>
|
| 225 |
-
</section>
|
| 226 |
-
<!-- Bento Grid Features -->
|
| 227 |
-
<section class="py-xl px-gutter max-w-7xl mx-auto">
|
| 228 |
-
<div class="text-center mb-xl">
|
| 229 |
-
<h2 class="font-display-lg text-display-lg-mobile md:text-display-lg text-on-surface mb-xs">Data-Driven Restoration</h2>
|
| 230 |
-
<p class="font-body-md text-body-md text-on-surface-variant">Our intelligence layer integrates seamlessly with your supply chain to find every carbon leak.</p>
|
| 231 |
-
</div>
|
| 232 |
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-gutter">
|
| 233 |
-
<!-- Large Feature -->
|
| 234 |
-
<div class="md:col-span-7 glass-card rounded-2xl p-lg flex flex-col justify-between min-h-[400px] group transition-slow hover:border-primary/40">
|
| 235 |
-
<div class="space-y-sm">
|
| 236 |
-
<div class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
| 237 |
-
<span class="material-symbols-outlined text-primary">analytics</span>
|
| 238 |
-
</div>
|
| 239 |
-
<h3 class="font-headline-md text-headline-md text-on-surface">Predictive Emissions Analytics</h3>
|
| 240 |
-
<p class="font-body-md text-body-md text-on-surface-variant max-w-md">Utilize machine learning to forecast carbon output before it happens. Our algorithms identify inefficiencies and suggest remediation strategies automatically.</p>
|
| 241 |
-
</div>
|
| 242 |
-
<div class="mt-lg rounded-xl overflow-hidden h-48 border border-white/5">
|
| 243 |
-
<img class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" data-alt="A high-fidelity digital dashboard showing complex data visualizations and carbon footprint analytics. The UI is sleek and dark with neon emerald and sky blue graph lines glowing against a deep slate background. The image evokes a sense of high-tech precision and environmental control." src="https://lh3.googleusercontent.com/aida-public/AB6AXuAGLdYgb5qyoz6Nn-yVGKHN6LQoUrIbv-y3RzEerDMKGJOFdUL76Hnd7e1lMrp6FJHNqNri71zlBNnZGD9s8p5rXwKxOBJ-hGSdXIFD9UyXBRtdQXmoli7pcGWzFs1L08iQmQD5Q0QpShzEUyNLLcs1YM4mD1jrgoyKfpJbEUWE-niSyEel_c3xbnxpQEDSg3ucxlH_pzqYLZia7Vyc-8wiDdt72GMRZe0v0-Qg6Ai8vDSmqNXoHyj4PpoSWrQauu5jcvCao-fz2YZb"/>
|
| 244 |
-
</div>
|
| 245 |
-
</div>
|
| 246 |
-
<!-- Vertical Feature -->
|
| 247 |
-
<div class="md:col-span-5 glass-card rounded-2xl p-lg flex flex-col group transition-slow hover:border-secondary/40">
|
| 248 |
-
<div class="w-12 h-12 bg-secondary/10 rounded-lg flex items-center justify-center mb-sm">
|
| 249 |
-
<span class="material-symbols-outlined text-secondary">public</span>
|
| 250 |
-
</div>
|
| 251 |
-
<h3 class="font-headline-md text-headline-md text-on-surface">Satellite Integration</h3>
|
| 252 |
-
<p class="font-body-md text-body-md text-on-surface-variant mt-sm">Real-time terrestrial monitoring via high-resolution satellite arrays to verify restoration projects with zero physical footprint.</p>
|
| 253 |
-
<div class="mt-auto pt-lg">
|
| 254 |
-
<div class="w-full h-64 rounded-xl overflow-hidden border border-white/5">
|
| 255 |
-
<img class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" data-alt="A cinematic view of the earth from orbit, showing lush green continents and deep blue oceans under a thin atmosphere. High-tech digital data overlays and scanning grids trace across the surface, highlighting environmental restoration zones in glowing green. The style is hyper-realistic and futuristic." src="https://lh3.googleusercontent.com/aida-public/AB6AXuAwZnH8mSyWHbzsZFegkoS8ETJMHilmZRdk9UaRSZfC-LpscTz1az4Pc0iqxCbRtQezcalRHqBkByY6ONLvvTY8H8NJOS7nkH-31XaipdatDN-c5447NZnRRsskco49JUN230edLfDzcyDVKes-YqxSVOUk4VqbPHbT6A8IuDlK3jbzHHfVa173yTfbiCz5P4bfHPxiA2YusVDTyUWw9b_Cb5QbD9f4QSQVEIL7ryzMIsyZ8UXgzEbu9tgt8yY01l3gHqDebnqjeHrK"/>
|
| 256 |
-
</div>
|
| 257 |
-
</div>
|
| 258 |
-
</div>
|
| 259 |
-
<!-- Square Feature 1 -->
|
| 260 |
-
<div class="md:col-span-4 glass-card rounded-2xl p-lg group transition-slow hover:border-tertiary/40">
|
| 261 |
-
<span class="material-symbols-outlined text-tertiary text-4xl mb-sm" style="font-variation-settings: 'FILL' 1;">api</span>
|
| 262 |
-
<h4 class="font-title-sm text-title-sm text-on-surface">Seamless API Connect</h4>
|
| 263 |
-
<p class="font-caption text-caption text-on-surface-variant mt-xs">Integrate with 200+ ERP systems in minutes to begin ingestion of raw data streams.</p>
|
| 264 |
-
</div>
|
| 265 |
-
<!-- Square Feature 2 -->
|
| 266 |
-
<div class="md:col-span-4 glass-card rounded-2xl p-lg group transition-slow hover:border-primary/40">
|
| 267 |
-
<span class="material-symbols-outlined text-primary text-4xl mb-sm">verified_user</span>
|
| 268 |
-
<h4 class="font-title-sm text-title-sm text-on-surface">Immutable Ledger</h4>
|
| 269 |
-
<p class="font-caption text-caption text-on-surface-variant mt-xs">All reductions are verified on a private green-blockchain for audit-proof ESG reporting.</p>
|
| 270 |
-
</div>
|
| 271 |
-
<!-- Square Feature 3 -->
|
| 272 |
-
<div class="md:col-span-4 glass-card rounded-2xl p-lg group transition-slow hover:border-secondary/40">
|
| 273 |
-
<span class="material-symbols-outlined text-secondary text-4xl mb-sm">bolt</span>
|
| 274 |
-
<h4 class="font-title-sm text-title-sm text-on-surface">Real-time Offsets</h4>
|
| 275 |
-
<p class="font-caption text-caption text-on-surface-variant mt-xs">Auto-allocate funds to high-impact reforestation the moment emissions are tracked.</p>
|
| 276 |
-
</div>
|
| 277 |
-
</div>
|
| 278 |
-
</section>
|
| 279 |
-
<!-- CTA Section -->
|
| 280 |
-
<section class="py-xl px-gutter relative overflow-hidden">
|
| 281 |
-
<div class="absolute inset-0 bg-primary/5 -skew-y-3 origin-right"></div>
|
| 282 |
-
<div class="max-w-4xl mx-auto relative z-10 glass-card rounded-3xl p-xl border-primary/20 text-center space-y-md">
|
| 283 |
-
<h2 class="font-display-lg text-display-lg-mobile md:text-display-lg text-on-surface">Ready to lead the <span class="text-primary">Green Revolution</span>?</h2>
|
| 284 |
-
<p class="font-body-md text-body-md text-on-surface-variant max-w-2xl mx-auto">
|
| 285 |
-
Join the 1,500+ enterprises using EcoTrack AI to reach Net Zero ahead of schedule. Your transformation begins with a single data point.
|
| 286 |
-
</p>
|
| 287 |
-
<div class="pt-sm">
|
| 288 |
-
<button class="px-xl py-md bg-primary text-on-primary font-headline-md text-headline-md rounded-xl shadow-lg vibrant-emerald-glow hover:translate-y-[-4px] active:scale-95 transition-all">
|
| 289 |
-
Schedule a Demo
|
| 290 |
-
</button>
|
| 291 |
-
<p class="text-on-surface-variant font-caption text-caption mt-sm">No credit card required. Onboard in under 24 hours.</p>
|
| 292 |
-
</div>
|
| 293 |
-
</div>
|
| 294 |
-
</section>
|
| 295 |
-
</main>
|
| 296 |
-
<!-- Footer -->
|
| 297 |
-
<footer class="w-full py-md px-gutter flex flex-col md:flex-row justify-between items-center mt-auto bg-surface-container-lowest border-t border-white/10">
|
| 298 |
-
<div class="flex flex-col items-center md:items-start gap-xs mb-md md:mb-0">
|
| 299 |
-
<span class="font-title-sm text-title-sm text-on-surface">EcoTrack AI</span>
|
| 300 |
-
<p class="font-caption text-caption text-secondary">© 2024 EcoTrack AI. Engineering a Sustainable Future.</p>
|
| 301 |
-
</div>
|
| 302 |
-
<div class="flex flex-wrap justify-center gap-md">
|
| 303 |
-
<a class="font-caption text-caption text-on-surface-variant hover:text-secondary underline transition-all" href="#">Privacy Policy</a>
|
| 304 |
-
<a class="font-caption text-caption text-on-surface-variant hover:text-secondary underline transition-all" href="#">Terms of Service</a>
|
| 305 |
-
<a class="font-caption text-caption text-on-surface-variant hover:text-secondary underline transition-all" href="#">Carbon Methodology</a>
|
| 306 |
-
<a class="font-caption text-caption text-on-surface-variant hover:text-secondary underline transition-all" href="#">API Docs</a>
|
| 307 |
-
</div>
|
| 308 |
-
</footer>
|
| 309 |
-
<script>
|
| 310 |
-
// Micro-interactions and subtle effects
|
| 311 |
-
document.addEventListener('mousemove', (e) => {
|
| 312 |
-
const cards = document.querySelectorAll('.glass-card');
|
| 313 |
-
const x = e.clientX;
|
| 314 |
-
const y = e.clientY;
|
| 315 |
-
|
| 316 |
-
cards.forEach(card => {
|
| 317 |
-
const rect = card.getBoundingClientRect();
|
| 318 |
-
const cardX = x - rect.left;
|
| 319 |
-
const cardY = y - rect.top;
|
| 320 |
-
|
| 321 |
-
// Only update if mouse is relatively near
|
| 322 |
-
if (cardX > -100 && cardX < rect.width + 100 && cardY > -100 && cardY < rect.height + 100) {
|
| 323 |
-
card.style.setProperty('--mouse-x', `${cardX}px`);
|
| 324 |
-
card.style.setProperty('--mouse-y', `${cardY}px`);
|
| 325 |
-
}
|
| 326 |
-
});
|
| 327 |
-
});
|
| 328 |
-
|
| 329 |
-
// Simple intersection observer for entry animations
|
| 330 |
-
const observerOptions = {
|
| 331 |
-
threshold: 0.1
|
| 332 |
-
};
|
| 333 |
-
|
| 334 |
-
const observer = new IntersectionObserver((entries) => {
|
| 335 |
-
entries.forEach(entry => {
|
| 336 |
-
if (entry.isIntersecting) {
|
| 337 |
-
entry.target.classList.add('opacity-100', 'translate-y-0');
|
| 338 |
-
entry.target.classList.remove('opacity-0', 'translate-y-10');
|
| 339 |
-
}
|
| 340 |
-
});
|
| 341 |
-
}, observerOptions);
|
| 342 |
-
|
| 343 |
-
document.querySelectorAll('.glass-card').forEach(el => {
|
| 344 |
-
el.classList.add('opacity-0', 'translate-y-10', 'transition-all', 'duration-1000');
|
| 345 |
-
observer.observe(el);
|
| 346 |
-
});
|
| 347 |
-
</script>
|
| 348 |
-
</body></html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.json
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"expo": {
|
| 3 |
-
"name": "EcoTrack AI",
|
| 4 |
-
"slug": "ecotrack-ai",
|
| 5 |
-
"version": "1.0.0",
|
| 6 |
-
"orientation": "portrait",
|
| 7 |
-
"icon": "./assets/icon.png",
|
| 8 |
-
"userInterfaceStyle": "dark",
|
| 9 |
-
"ios": {
|
| 10 |
-
"supportsTablet": true
|
| 11 |
-
},
|
| 12 |
-
"android": {
|
| 13 |
-
"adaptiveIcon": {
|
| 14 |
-
"backgroundColor": "#E6F4FE",
|
| 15 |
-
"foregroundImage": "./assets/android-icon-foreground.png",
|
| 16 |
-
"backgroundImage": "./assets/android-icon-background.png",
|
| 17 |
-
"monochromeImage": "./assets/android-icon-monochrome.png"
|
| 18 |
-
}
|
| 19 |
-
},
|
| 20 |
-
"web": {
|
| 21 |
-
"favicon": "./assets/favicon.png"
|
| 22 |
-
}
|
| 23 |
-
}
|
| 24 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/.env.example
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Server Configuration
|
| 2 |
+
PORT=3001
|
| 3 |
+
NODE_ENV=development
|
| 4 |
+
|
| 5 |
+
# CORS Configuration
|
| 6 |
+
CORS_ORIGIN=http://localhost:5173
|
| 7 |
+
|
| 8 |
+
# CSP: public URL of this backend (used in connectSrc to allow frontend→API calls)
|
| 9 |
+
BACKEND_PUBLIC_URL=http://localhost:3001
|
| 10 |
+
|
| 11 |
+
# Groq AI (Required for AI eco-coach chatbot, plans, daily tips, and narrative score analysis)
|
| 12 |
+
# Get a free key at: https://console.groq.com
|
| 13 |
+
GROQ_API_KEY=""
|
| 14 |
+
|
| 15 |
+
# Firebase Service Account Credentials
|
| 16 |
+
# (Optional: Only if you want to pass the raw JSON service key string as an env var.
|
| 17 |
+
# Otherwise, simply save your Firebase key file as "firebase-key.json" inside the backend/ folder).
|
| 18 |
+
FIREBASE_SERVICE_ACCOUNT=""
|
backend/.eslintrc.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"env": {
|
| 3 |
+
"node": true,
|
| 4 |
+
"es2022": true
|
| 5 |
+
},
|
| 6 |
+
"extends": ["eslint:recommended"],
|
| 7 |
+
"parserOptions": {
|
| 8 |
+
"ecmaVersion": "latest",
|
| 9 |
+
"sourceType": "module"
|
| 10 |
+
},
|
| 11 |
+
"rules": {
|
| 12 |
+
"no-console": "warn",
|
| 13 |
+
"no-var": "error",
|
| 14 |
+
"prefer-const": "error",
|
| 15 |
+
"eqeqeq": ["error", "always"],
|
| 16 |
+
"no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }]
|
| 17 |
+
},
|
| 18 |
+
"overrides": [
|
| 19 |
+
{
|
| 20 |
+
"files": ["src/utils/logger.js"],
|
| 21 |
+
"rules": {
|
| 22 |
+
"no-console": "off"
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
]
|
| 26 |
+
}
|
backend/.gitignore
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
node_modules/
|
| 2 |
-
firebase-key.json
|
| 3 |
-
database.json
|
| 4 |
-
*adminsdk*.json
|
| 5 |
-
*.json.key
|
| 6 |
-
*.key.json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/Dockerfile
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 1 |
-
# Use official lightweight Node.js 18 image
|
| 2 |
-
FROM node:18-alpine
|
| 3 |
-
|
| 4 |
-
# Set working directory
|
| 5 |
-
WORKDIR /app
|
| 6 |
-
|
| 7 |
-
# Copy package files
|
| 8 |
-
COPY package*.json ./
|
| 9 |
-
|
| 10 |
-
# Install production dependencies
|
| 11 |
-
RUN npm ci --only=production
|
| 12 |
-
|
| 13 |
-
# Copy application source code
|
| 14 |
-
COPY . .
|
| 15 |
-
|
| 16 |
-
# Expose port (Hugging Face Spaces default port)
|
| 17 |
-
EXPOSE 7860
|
| 18 |
-
|
| 19 |
-
# Define environment variables
|
| 20 |
-
ENV PORT=7860
|
| 21 |
-
ENV NODE_ENV=production
|
| 22 |
-
|
| 23 |
-
# Start the application
|
| 24 |
-
CMD ["node", "server.js"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/README.md
DELETED
|
@@ -1,17 +0,0 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: EcoTracker Backend
|
| 3 |
-
emoji: 🌱
|
| 4 |
-
colorFrom: green
|
| 5 |
-
colorTo: blue
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
---
|
| 9 |
-
|
| 10 |
-
# EcoTracker Backend
|
| 11 |
-
|
| 12 |
-
This is the Express.js API server for **EcoTrack AI**, running as a Docker container on Hugging Face Spaces.
|
| 13 |
-
|
| 14 |
-
## Service Details
|
| 15 |
-
- **Port**: 7860
|
| 16 |
-
- **Database**: Cloud Firestore (Firebase)
|
| 17 |
-
- **Deployment Platform**: Hugging Face Spaces (Docker SDK)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/__tests__/routes.test.js
DELETED
|
@@ -1,153 +0,0 @@
|
|
| 1 |
-
// Set environment to test to route DB writes to database.test.json
|
| 2 |
-
process.env.NODE_ENV = 'test';
|
| 3 |
-
|
| 4 |
-
const request = require('supertest');
|
| 5 |
-
const fs = require('fs');
|
| 6 |
-
const path = require('path');
|
| 7 |
-
const app = require('../server');
|
| 8 |
-
|
| 9 |
-
describe('EcoTrack AI Backend API integration Tests', () => {
|
| 10 |
-
|
| 11 |
-
afterAll((done) => {
|
| 12 |
-
// Clean up temporary database.test.json after test suite completes
|
| 13 |
-
const testDbPath = path.join(__dirname, '../database.test.json');
|
| 14 |
-
if (fs.existsSync(testDbPath)) {
|
| 15 |
-
try {
|
| 16 |
-
fs.unlinkSync(testDbPath);
|
| 17 |
-
} catch (err) {
|
| 18 |
-
console.error('Error cleaning up test DB file:', err);
|
| 19 |
-
}
|
| 20 |
-
}
|
| 21 |
-
done();
|
| 22 |
-
});
|
| 23 |
-
|
| 24 |
-
// 1. Health check endpoint
|
| 25 |
-
describe('GET /', () => {
|
| 26 |
-
it('should return 200 OK with server status and local-mock database tag', async () => {
|
| 27 |
-
const res = await request(app).get('/');
|
| 28 |
-
expect(res.statusCode).toBe(200);
|
| 29 |
-
expect(res.body).toHaveProperty('status', 'online');
|
| 30 |
-
expect(res.body).toHaveProperty('service');
|
| 31 |
-
});
|
| 32 |
-
});
|
| 33 |
-
|
| 34 |
-
// 2. Preset configs loader
|
| 35 |
-
describe('GET /api/config', () => {
|
| 36 |
-
it('should return transport and electric multipliers presets', async () => {
|
| 37 |
-
const res = await request(app).get('/api/config');
|
| 38 |
-
expect(res.statusCode).toBe(200);
|
| 39 |
-
expect(res.body).toHaveProperty('factors');
|
| 40 |
-
expect(res.body).toHaveProperty('multipliers');
|
| 41 |
-
});
|
| 42 |
-
});
|
| 43 |
-
|
| 44 |
-
// 3. Carbon Calculator computations
|
| 45 |
-
describe('POST /api/carbon/calculate', () => {
|
| 46 |
-
it('should compute carbon metrics based on weights', async () => {
|
| 47 |
-
const calculationPayload = {
|
| 48 |
-
transport: { mode: 'car', distance: 100, fuelType: 'gasoline', daysPerWeek: 5 },
|
| 49 |
-
electricity: { units: 200 },
|
| 50 |
-
food: { type: 'vegetarian' },
|
| 51 |
-
waste: { plasticUsage: 5, recyclingHabits: 'partial' },
|
| 52 |
-
shopping: { clothes: 2, onlineOrders: 5, electronics: 0 }
|
| 53 |
-
};
|
| 54 |
-
|
| 55 |
-
const res = await request(app)
|
| 56 |
-
.post('/api/carbon/calculate')
|
| 57 |
-
.send(calculationPayload);
|
| 58 |
-
|
| 59 |
-
expect(res.statusCode).toBe(200);
|
| 60 |
-
expect(res.body).toHaveProperty('monthlyEmission');
|
| 61 |
-
expect(res.body).toHaveProperty('yearlyEmission');
|
| 62 |
-
expect(res.body.breakdown).toHaveProperty('transport');
|
| 63 |
-
expect(res.body.breakdown).toHaveProperty('electricity');
|
| 64 |
-
expect(res.body.breakdown).toHaveProperty('food');
|
| 65 |
-
expect(res.body.breakdown).toHaveProperty('waste');
|
| 66 |
-
expect(res.body.breakdown).toHaveProperty('shopping');
|
| 67 |
-
});
|
| 68 |
-
});
|
| 69 |
-
|
| 70 |
-
// 4. User registration & verification
|
| 71 |
-
describe('POST /api/users', () => {
|
| 72 |
-
it('should register a new valid user and generate profile document', async () => {
|
| 73 |
-
const payload = {
|
| 74 |
-
name: 'Testy Tester',
|
| 75 |
-
email: 'test@example.com'
|
| 76 |
-
};
|
| 77 |
-
|
| 78 |
-
const res = await request(app)
|
| 79 |
-
.post('/api/users')
|
| 80 |
-
.send(payload);
|
| 81 |
-
|
| 82 |
-
expect(res.statusCode).toBe(200);
|
| 83 |
-
expect(res.body).toHaveProperty('success', true);
|
| 84 |
-
expect(res.body).toHaveProperty('userId', 'testy_tester');
|
| 85 |
-
expect(res.body.profile).toHaveProperty('name', 'Testy Tester');
|
| 86 |
-
expect(res.body.profile).toHaveProperty('email', 'test@example.com');
|
| 87 |
-
expect(res.body.profile).toHaveProperty('points', 0);
|
| 88 |
-
});
|
| 89 |
-
|
| 90 |
-
it('should reject requests with empty name parameters', async () => {
|
| 91 |
-
const payload = {
|
| 92 |
-
name: ' ',
|
| 93 |
-
email: 'test@example.com'
|
| 94 |
-
};
|
| 95 |
-
|
| 96 |
-
const res = await request(app)
|
| 97 |
-
.post('/api/users')
|
| 98 |
-
.send(payload);
|
| 99 |
-
|
| 100 |
-
expect(res.statusCode).toBe(400);
|
| 101 |
-
expect(res.body).toHaveProperty('error');
|
| 102 |
-
});
|
| 103 |
-
|
| 104 |
-
it('should reject requests with malformed email strings', async () => {
|
| 105 |
-
const payload = {
|
| 106 |
-
name: 'Akash Test',
|
| 107 |
-
email: 'invalid-email-address'
|
| 108 |
-
};
|
| 109 |
-
|
| 110 |
-
const res = await request(app)
|
| 111 |
-
.post('/api/users')
|
| 112 |
-
.send(payload);
|
| 113 |
-
|
| 114 |
-
expect(res.statusCode).toBe(400);
|
| 115 |
-
expect(res.body).toHaveProperty('error');
|
| 116 |
-
});
|
| 117 |
-
|
| 118 |
-
it('should reject names longer than 50 characters', async () => {
|
| 119 |
-
const payload = {
|
| 120 |
-
name: 'A'.repeat(51),
|
| 121 |
-
email: 'long@example.com'
|
| 122 |
-
};
|
| 123 |
-
|
| 124 |
-
const res = await request(app)
|
| 125 |
-
.post('/api/users')
|
| 126 |
-
.send(payload);
|
| 127 |
-
|
| 128 |
-
expect(res.statusCode).toBe(400);
|
| 129 |
-
expect(res.body).toHaveProperty('error');
|
| 130 |
-
});
|
| 131 |
-
});
|
| 132 |
-
|
| 133 |
-
// 5. User retrieval and Leaderboard syncing
|
| 134 |
-
describe('GET /api/users & GET /api/leaderboard', () => {
|
| 135 |
-
it('should retrieve a list of all user profiles', async () => {
|
| 136 |
-
const res = await request(app).get('/api/users');
|
| 137 |
-
expect(res.statusCode).toBe(200);
|
| 138 |
-
expect(Array.isArray(res.body)).toBe(true);
|
| 139 |
-
expect(res.body.length).toBeGreaterThan(0);
|
| 140 |
-
});
|
| 141 |
-
|
| 142 |
-
it('should return a sorted ranked leaderboard list with ID parameters', async () => {
|
| 143 |
-
const res = await request(app).get('/api/leaderboard');
|
| 144 |
-
expect(res.statusCode).toBe(200);
|
| 145 |
-
expect(Array.isArray(res.body)).toBe(true);
|
| 146 |
-
expect(res.body.length).toBeGreaterThan(0);
|
| 147 |
-
expect(res.body[0]).toHaveProperty('id');
|
| 148 |
-
expect(res.body[0]).toHaveProperty('rank');
|
| 149 |
-
expect(res.body[0]).toHaveProperty('points');
|
| 150 |
-
});
|
| 151 |
-
});
|
| 152 |
-
|
| 153 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/firebase.js
DELETED
|
@@ -1,310 +0,0 @@
|
|
| 1 |
-
const { initializeApp, cert } = require('firebase-admin/app');
|
| 2 |
-
const { getFirestore } = require('firebase-admin/firestore');
|
| 3 |
-
const path = require('path');
|
| 4 |
-
const fs = require('fs');
|
| 5 |
-
|
| 6 |
-
// ==========================================
|
| 7 |
-
// Mock Firestore Class Definition
|
| 8 |
-
// ==========================================
|
| 9 |
-
class MockDocRef {
|
| 10 |
-
constructor(collectionPath, docId, mockDb) {
|
| 11 |
-
this.collectionPath = collectionPath;
|
| 12 |
-
this.id = docId;
|
| 13 |
-
this.mockDb = mockDb;
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
async get() {
|
| 17 |
-
const data = this.mockDb.readDoc(this.collectionPath, this.id);
|
| 18 |
-
return {
|
| 19 |
-
exists: !!data,
|
| 20 |
-
id: this.id,
|
| 21 |
-
data: () => data || null
|
| 22 |
-
};
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
async set(data, options = {}) {
|
| 26 |
-
this.mockDb.writeDoc(this.collectionPath, this.id, data, options.merge);
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
async update(data) {
|
| 30 |
-
this.mockDb.writeDoc(this.collectionPath, this.id, data, true);
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
collection(subCollectionName) {
|
| 34 |
-
return new MockCollectionRef(`${this.collectionPath}/${this.id}/${subCollectionName}`, this.mockDb);
|
| 35 |
-
}
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
class MockCollectionRef {
|
| 39 |
-
constructor(collectionPath, mockDb) {
|
| 40 |
-
this.collectionPath = collectionPath;
|
| 41 |
-
this.mockDb = mockDb;
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
doc(id) {
|
| 45 |
-
return new MockDocRef(this.collectionPath, id, this.mockDb);
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
async get() {
|
| 49 |
-
const docs = this.mockDb.readCollection(this.collectionPath);
|
| 50 |
-
return {
|
| 51 |
-
docs: docs.map(d => ({
|
| 52 |
-
id: d.id,
|
| 53 |
-
data: () => d.data
|
| 54 |
-
}))
|
| 55 |
-
};
|
| 56 |
-
}
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
class MockDb {
|
| 60 |
-
constructor() {
|
| 61 |
-
this.filePath = process.env.NODE_ENV === 'test'
|
| 62 |
-
? path.join(__dirname, 'database.test.json')
|
| 63 |
-
: path.join(__dirname, 'database.json');
|
| 64 |
-
if (!fs.existsSync(this.filePath)) {
|
| 65 |
-
// Seed initial users & historical records
|
| 66 |
-
const initialSeed = {
|
| 67 |
-
users: {
|
| 68 |
-
'sarah_j': {
|
| 69 |
-
email: 'sarah@example.com',
|
| 70 |
-
name: 'Sarah J.',
|
| 71 |
-
avatar: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAT1EPgeHbiCyiDKLGp868IabVBLWQJe-FbA4S09aQJipuS6tXAJnHYJnoD4VL-TBLlnzm4xoCEkS_WlOmVhbeXjuNHry4GZPGKfJ5_iQ8X-fVs2ZENwqa0MK2sTi6dgD_hmctOs2tY1U0dbsRFDclOP1Sy81nI9zy56ULwxCR3EAsmngeA71gDstnUUoOs0PVxPurXRdI32iJ5ScLE0CjWgOYh6n808_7lSn7PlX4m0EkobLUHN1beT5h43E4UGhZjiUdK2JjYPK9R',
|
| 72 |
-
title: 'Master Guardian',
|
| 73 |
-
points: 12450,
|
| 74 |
-
badges: ['Tree Planter', 'Carbon Ninja', 'Zero Waste'],
|
| 75 |
-
percentages: { transport: 45, electricity: 20, food: 20, waste: 10, shopping: 5 },
|
| 76 |
-
complianceRate: '94.2%',
|
| 77 |
-
totalMonthly: 182.4
|
| 78 |
-
},
|
| 79 |
-
'james_w': {
|
| 80 |
-
email: 'james@example.com',
|
| 81 |
-
name: 'James W.',
|
| 82 |
-
avatar: 'https://lh3.googleusercontent.com/aida-public/AB6AXuC8P9QWmkdOOWXq6XrcEKt3wdPMsFJNegdG6UQznpU9ZMfLbsW_fKTfuIWlIHInf0YMPE4MLmRNL20P6VlcsCnd6KLkvc2A_RuBSwHsKvwumkpFZT8hQKxSFUve03mZ3vKJhUIOej633ww6102SdmOn6nfPmGNiLv7WvjtVfUwnGDsbyCIQyJ_B9mmnBqpiH6NUy4eYvDJfHlzbdZ2ZhVkzk8p547M03lRA615RwLExviMZ8p62ikv_uMg-K1FGpiL7YAMp8suTSGhZ',
|
| 83 |
-
title: 'Nature Enthusiast',
|
| 84 |
-
points: 11200,
|
| 85 |
-
badges: ['Tree Planter'],
|
| 86 |
-
percentages: { transport: 35, electricity: 30, food: 15, waste: 10, shopping: 10 },
|
| 87 |
-
complianceRate: '88.5%',
|
| 88 |
-
totalMonthly: 210.0
|
| 89 |
-
},
|
| 90 |
-
'mila_k': {
|
| 91 |
-
email: 'mila@example.com',
|
| 92 |
-
name: 'Mila K.',
|
| 93 |
-
avatar: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAYkzfO_UyJ2G530wb-ov-OwK5ff3WIT-F_PjNiklYBaBkSnYBGSr6Qup5sIOA0s5OdV_QDLc6gODbXBElL5UckyQ-9ktreb80hwwU5qos-sJXJIUnmCOsYy7tFKmczpTrMWwqioCB6SoLP3N7Du7iecb4edZ5a9O_cI7FgGiIxp29xjkaFWOVWfQ26O3L3nh-gswqV1XGraa-PB8EllrAXdrATbGiQycXKanj1dS3hpbO_kdffmXE-76_8Ol_flCoWuo5gdCpgSfkp',
|
| 94 |
-
title: 'Carbon Reducer',
|
| 95 |
-
points: 10890,
|
| 96 |
-
badges: ['Carbon Ninja'],
|
| 97 |
-
percentages: { transport: 40, electricity: 25, food: 15, waste: 10, shopping: 10 },
|
| 98 |
-
complianceRate: '90.1%',
|
| 99 |
-
totalMonthly: 195.0
|
| 100 |
-
}
|
| 101 |
-
},
|
| 102 |
-
'users/sarah_j/history': {
|
| 103 |
-
'Jan': { month: 'Jan', amount: 155 },
|
| 104 |
-
'Feb': { month: 'Feb', amount: 145 },
|
| 105 |
-
'Mar': { month: 'Mar', amount: 130 },
|
| 106 |
-
'Apr': { month: 'Apr', amount: 125 },
|
| 107 |
-
'May': { month: 'May', amount: 112 },
|
| 108 |
-
'Jun': { month: 'Jun', amount: 98 }
|
| 109 |
-
},
|
| 110 |
-
'users/sarah_j/goals': {
|
| 111 |
-
'1': { id: '1', title: 'Reduce footprint by 20%', progress: 0.75, target: '20% Reduction' },
|
| 112 |
-
'2': { id: '2', title: 'Cycle 3 days/week', progress: 0.40, target: '3 Days/Week' }
|
| 113 |
-
},
|
| 114 |
-
'users/sarah_j/challenges': {
|
| 115 |
-
'1': { id: '1', title: 'No-Car Week', points: '+500 pts', pointsValue: 500, timeLeft: '4 days left', progress: 0.60, icon: 'directions-car', color: '#4edea3', bgColor: 'rgba(78, 222, 163, 0.15)', associatedBadge: 'Carbon Ninja' },
|
| 116 |
-
'2': { id: '2', title: 'Vegan Weekend', points: '+250 pts', pointsValue: 250, timeLeft: 'Starts in 12h', progress: 0.00, icon: 'eco', color: '#ffb95f', bgColor: 'rgba(255, 185, 95, 0.15)', associatedBadge: 'Tree Planter' },
|
| 117 |
-
'3': { id: '3', title: 'Plastic-Free', points: '+400 pts', pointsValue: 400, timeLeft: '2 days left', progress: 0.85, icon: 'shopping-bag', color: '#4edea3', bgColor: 'rgba(78, 222, 163, 0.15)', associatedBadge: 'Zero Waste' }
|
| 118 |
-
}
|
| 119 |
-
};
|
| 120 |
-
fs.writeFileSync(this.filePath, JSON.stringify(initialSeed, null, 2));
|
| 121 |
-
}
|
| 122 |
-
}
|
| 123 |
-
|
| 124 |
-
read() {
|
| 125 |
-
try {
|
| 126 |
-
return JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
|
| 127 |
-
} catch (e) {
|
| 128 |
-
return {};
|
| 129 |
-
}
|
| 130 |
-
}
|
| 131 |
-
|
| 132 |
-
write(data) {
|
| 133 |
-
fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2));
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
readDoc(collectionPath, docId) {
|
| 137 |
-
const data = this.read();
|
| 138 |
-
const collection = data[collectionPath] || {};
|
| 139 |
-
return collection[docId] || null;
|
| 140 |
-
}
|
| 141 |
-
|
| 142 |
-
writeDoc(collectionPath, docId, docData, merge = false) {
|
| 143 |
-
const data = this.read();
|
| 144 |
-
if (!data[collectionPath]) {
|
| 145 |
-
data[collectionPath] = {};
|
| 146 |
-
}
|
| 147 |
-
if (merge && data[collectionPath][docId]) {
|
| 148 |
-
data[collectionPath][docId] = { ...data[collectionPath][docId], ...docData };
|
| 149 |
-
} else {
|
| 150 |
-
data[collectionPath][docId] = docData;
|
| 151 |
-
}
|
| 152 |
-
this.write(data);
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
readCollection(collectionPath) {
|
| 156 |
-
const data = this.read();
|
| 157 |
-
const collection = data[collectionPath] || {};
|
| 158 |
-
return Object.keys(collection).map(key => ({
|
| 159 |
-
id: key,
|
| 160 |
-
data: collection[key]
|
| 161 |
-
}));
|
| 162 |
-
}
|
| 163 |
-
}
|
| 164 |
-
|
| 165 |
-
// Instantiate mock
|
| 166 |
-
const mockDbInstance = new MockDb();
|
| 167 |
-
const mockFirestore = {
|
| 168 |
-
collection: (path) => new MockCollectionRef(path, mockDbInstance)
|
| 169 |
-
};
|
| 170 |
-
|
| 171 |
-
// ==========================================
|
| 172 |
-
// Firebase Initialization Router
|
| 173 |
-
// ==========================================
|
| 174 |
-
let db;
|
| 175 |
-
|
| 176 |
-
async function seedRealFirestore(dbInstance) {
|
| 177 |
-
try {
|
| 178 |
-
const usersSnap = await dbInstance.collection('users').get();
|
| 179 |
-
if (usersSnap.empty) {
|
| 180 |
-
console.log("Seeding real Firestore with default users...");
|
| 181 |
-
const initialUsers = {
|
| 182 |
-
'sarah_j': {
|
| 183 |
-
email: 'sarah@example.com',
|
| 184 |
-
name: 'Sarah J.',
|
| 185 |
-
avatar: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAT1EPgeHbiCyiDKLGp868IabVBLWQJe-FbA4S09aQJipuS6tXAJnHYJnoD4VL-TBLlnzm4xoCEkS_WlOmVhbeXjuNHry4GZPGKfJ5_iQ8X-fVs2ZENwqa0MK2sTi6dgD_hmctOs2tY1U0dbsRFDclOP1Sy81nI9zy56ULwxCR3EAsmngeA71gDstnUUoOs0PVxPurXRdI32iJ5ScLE0CjWgOYh6n808_7lSn7PlX4m0EkobLUHN1beT5h43E4UGhZjiUdK2JjYPK9R',
|
| 186 |
-
title: 'Master Guardian',
|
| 187 |
-
points: 12450,
|
| 188 |
-
badges: ['Tree Planter', 'Carbon Ninja', 'Zero Waste'],
|
| 189 |
-
percentages: { transport: 45, electricity: 20, food: 20, waste: 10, shopping: 5 },
|
| 190 |
-
complianceRate: '94.2%',
|
| 191 |
-
totalMonthly: 182.4
|
| 192 |
-
},
|
| 193 |
-
'james_w': {
|
| 194 |
-
email: 'james@example.com',
|
| 195 |
-
name: 'James W.',
|
| 196 |
-
avatar: 'https://lh3.googleusercontent.com/aida-public/AB6AXuC8P9QWmkdOOWXq6XrcEKt3wdPMsFJNegdG6UQznpU9ZMfLbsW_fKTfuIWlIHInf0YMPE4MLmRNL20P6VlcsCnd6KLkvc2A_RuBSwHsKvwumkpFZT8hQKxSFUve03mZ3vKJhUIOej633ww6102SdmOn6nfPmGNiLv7WvjtVfUwnGDsbyCIQyJ_B9mmnBqpiH6NUy4eYvDJfHlzbdZ2ZhVkzk8p547M03lRA615RwLExviMZ8p62ikv_uMg-K1FGpiL7YAMp8suTSGhZ',
|
| 197 |
-
title: 'Nature Enthusiast',
|
| 198 |
-
points: 11200,
|
| 199 |
-
badges: ['Tree Planter'],
|
| 200 |
-
percentages: { transport: 35, electricity: 30, food: 15, waste: 10, shopping: 10 },
|
| 201 |
-
complianceRate: '88.5%',
|
| 202 |
-
totalMonthly: 210.0
|
| 203 |
-
},
|
| 204 |
-
'mila_k': {
|
| 205 |
-
email: 'mila@example.com',
|
| 206 |
-
name: 'Mila K.',
|
| 207 |
-
avatar: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAYkzfO_UyJ2G530wb-ov-OwK5ff3WIT-F_PjNiklYBaBkSnYBGSr6Qup5sIOA0s5OdV_QDLc6gODbXBElL5UckyQ-9ktreb80hwwU5qos-sJXJIUnmCOsYy7tFKmczpTrMWwqioCB6SoLP3N7Du7iecb4edZ5a9O_cI7FgGiIxp29xjkaFWOVWfQ26O3L3nh-gswqV1XGraa-PB8EllrAXdrATbGiQycXKanj1dS3hpbO_kdffmXE-76_8Ol_flCoWuo5gdCpgSfkp',
|
| 208 |
-
title: 'Carbon Reducer',
|
| 209 |
-
points: 10890,
|
| 210 |
-
badges: ['Carbon Ninja'],
|
| 211 |
-
percentages: { transport: 40, electricity: 25, food: 15, waste: 10, shopping: 10 },
|
| 212 |
-
complianceRate: '90.1%',
|
| 213 |
-
totalMonthly: 195.0
|
| 214 |
-
}
|
| 215 |
-
};
|
| 216 |
-
|
| 217 |
-
for (const [uid, userProfile] of Object.entries(initialUsers)) {
|
| 218 |
-
await dbInstance.collection('users').doc(uid).set(userProfile);
|
| 219 |
-
}
|
| 220 |
-
|
| 221 |
-
// Seed history for sarah_j
|
| 222 |
-
const historyItems = [
|
| 223 |
-
{ month: 'Jan', amount: 155 },
|
| 224 |
-
{ month: 'Feb', amount: 145 },
|
| 225 |
-
{ month: 'Mar', amount: 130 },
|
| 226 |
-
{ month: 'Apr', amount: 125 },
|
| 227 |
-
{ month: 'May', amount: 112 },
|
| 228 |
-
{ month: 'Jun', amount: 98 }
|
| 229 |
-
];
|
| 230 |
-
for (const item of historyItems) {
|
| 231 |
-
await dbInstance.collection('users').doc('sarah_j').collection('history').doc(item.month).set(item);
|
| 232 |
-
}
|
| 233 |
-
|
| 234 |
-
// Seed goals for sarah_j
|
| 235 |
-
const goalsItems = [
|
| 236 |
-
{ id: '1', title: 'Reduce footprint by 20%', progress: 0.75, target: '20% Reduction' },
|
| 237 |
-
{ id: '2', title: 'Cycle 3 days/week', progress: 0.40, target: '3 Days/Week' }
|
| 238 |
-
];
|
| 239 |
-
for (const item of goalsItems) {
|
| 240 |
-
await dbInstance.collection('users').doc('sarah_j').collection('goals').doc(item.id).set(item);
|
| 241 |
-
}
|
| 242 |
-
|
| 243 |
-
// Seed challenges for sarah_j
|
| 244 |
-
const challengesItems = [
|
| 245 |
-
{ id: '1', progress: 0.60, completed: false },
|
| 246 |
-
{ id: '2', progress: 0.00, completed: false },
|
| 247 |
-
{ id: '3', progress: 0.85, completed: false }
|
| 248 |
-
];
|
| 249 |
-
for (const item of challengesItems) {
|
| 250 |
-
await dbInstance.collection('users').doc('sarah_j').collection('challenges').doc(item.id).set(item);
|
| 251 |
-
}
|
| 252 |
-
console.log("Firestore seeding finished successfully.");
|
| 253 |
-
} else {
|
| 254 |
-
console.log("Firestore is not empty. Skipping seeding.");
|
| 255 |
-
}
|
| 256 |
-
} catch (err) {
|
| 257 |
-
console.error("Firestore seeding failed:", err);
|
| 258 |
-
}
|
| 259 |
-
}
|
| 260 |
-
|
| 261 |
-
// Load Firebase credentials (either from env variable or local json file)
|
| 262 |
-
let serviceAccount = null;
|
| 263 |
-
|
| 264 |
-
if (process.env.FIREBASE_SERVICE_ACCOUNT) {
|
| 265 |
-
try {
|
| 266 |
-
serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT);
|
| 267 |
-
console.log("Firebase service credentials loaded from FIREBASE_SERVICE_ACCOUNT environment variable.");
|
| 268 |
-
} catch (err) {
|
| 269 |
-
console.error("Failed to parse FIREBASE_SERVICE_ACCOUNT environment variable:", err);
|
| 270 |
-
}
|
| 271 |
-
}
|
| 272 |
-
|
| 273 |
-
const keyPath = path.join(__dirname, 'firebase-key.json');
|
| 274 |
-
if (!serviceAccount && fs.existsSync(keyPath)) {
|
| 275 |
-
try {
|
| 276 |
-
serviceAccount = require(keyPath);
|
| 277 |
-
console.log("Firebase service credentials loaded from firebase-key.json.");
|
| 278 |
-
} catch (err) {
|
| 279 |
-
console.error("Failed to load firebase-key.json:", err);
|
| 280 |
-
}
|
| 281 |
-
}
|
| 282 |
-
|
| 283 |
-
if (serviceAccount && process.env.NODE_ENV !== 'test') {
|
| 284 |
-
try {
|
| 285 |
-
initializeApp({
|
| 286 |
-
credential: cert(serviceAccount)
|
| 287 |
-
});
|
| 288 |
-
db = getFirestore();
|
| 289 |
-
console.log("=========================================");
|
| 290 |
-
console.log(" EcoTrack AI Connected to Firebase Firestore");
|
| 291 |
-
console.log("=========================================");
|
| 292 |
-
|
| 293 |
-
// Seed real Firestore asynchronously
|
| 294 |
-
seedRealFirestore(db);
|
| 295 |
-
} catch (err) {
|
| 296 |
-
console.error("Firebase init failed, falling back to local file db:", err);
|
| 297 |
-
}
|
| 298 |
-
}
|
| 299 |
-
|
| 300 |
-
if (!db) {
|
| 301 |
-
db = mockFirestore;
|
| 302 |
-
console.log("=========================================");
|
| 303 |
-
console.log(" EcoTrack AI Running with Local Mock Firestore");
|
| 304 |
-
console.log(" DB File: backend/database.json");
|
| 305 |
-
console.log(" Add firebase-key.json to backend/ to connect to real Firestore.");
|
| 306 |
-
console.log("=========================================");
|
| 307 |
-
}
|
| 308 |
-
|
| 309 |
-
module.exports = db;
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/package-lock.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/package.json
CHANGED
|
@@ -1,26 +1,44 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
"version": "1.0.0",
|
| 4 |
-
"
|
|
|
|
|
|
|
| 5 |
"scripts": {
|
| 6 |
-
"
|
| 7 |
-
"
|
| 8 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
},
|
| 10 |
"dependencies": {
|
| 11 |
-
"
|
| 12 |
"cors": "^2.8.5",
|
| 13 |
-
"
|
| 14 |
-
"express
|
|
|
|
| 15 |
"firebase-admin": "^14.0.0",
|
| 16 |
-
"
|
| 17 |
-
"
|
| 18 |
-
"morgan": "^1.10.0"
|
|
|
|
| 19 |
},
|
| 20 |
"devDependencies": {
|
| 21 |
-
"
|
| 22 |
-
"
|
| 23 |
-
"
|
|
|
|
|
|
|
| 24 |
},
|
| 25 |
-
"
|
|
|
|
|
|
|
| 26 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "ecoguide-backend",
|
| 3 |
"version": "1.0.0",
|
| 4 |
+
"description": "EcoGuide AI Backend API",
|
| 5 |
+
"main": "src/index.js",
|
| 6 |
+
"type": "module",
|
| 7 |
"scripts": {
|
| 8 |
+
"dev": "node --watch src/index.js",
|
| 9 |
+
"build": "prisma generate",
|
| 10 |
+
"start": "node src/index.js",
|
| 11 |
+
"test": "vitest run",
|
| 12 |
+
"test:watch": "vitest",
|
| 13 |
+
"test:coverage": "vitest run --coverage",
|
| 14 |
+
"lint": "eslint src --report-unused-disable-directives --max-warnings 0",
|
| 15 |
+
"db:generate": "prisma generate",
|
| 16 |
+
"db:migrate": "prisma migrate dev",
|
| 17 |
+
"db:migrate:prod": "prisma migrate deploy",
|
| 18 |
+
"db:seed": "node prisma/seed.js",
|
| 19 |
+
"db:studio": "prisma studio",
|
| 20 |
+
"db:reset": "prisma migrate reset --force"
|
| 21 |
},
|
| 22 |
"dependencies": {
|
| 23 |
+
"@prisma/client": "^5.9.1",
|
| 24 |
"cors": "^2.8.5",
|
| 25 |
+
"dotenv": "^16.4.1",
|
| 26 |
+
"express": "^4.18.2",
|
| 27 |
+
"express-rate-limit": "^7.1.5",
|
| 28 |
"firebase-admin": "^14.0.0",
|
| 29 |
+
"groq-sdk": "^1.2.1",
|
| 30 |
+
"helmet": "^7.1.0",
|
| 31 |
+
"morgan": "^1.10.0",
|
| 32 |
+
"zod": "^3.22.4"
|
| 33 |
},
|
| 34 |
"devDependencies": {
|
| 35 |
+
"@vitest/coverage-v8": "^1.2.1",
|
| 36 |
+
"eslint": "^8.57.0",
|
| 37 |
+
"prisma": "^5.9.1",
|
| 38 |
+
"supertest": "^6.3.4",
|
| 39 |
+
"vitest": "^1.2.1"
|
| 40 |
},
|
| 41 |
+
"engines": {
|
| 42 |
+
"node": ">=18.0.0"
|
| 43 |
+
}
|
| 44 |
}
|
backend/prisma/migrations/20260616130754_init/migration.sql
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- CreateTable
|
| 2 |
+
CREATE TABLE "users" (
|
| 3 |
+
"id" TEXT NOT NULL,
|
| 4 |
+
"name" TEXT NOT NULL,
|
| 5 |
+
"email" TEXT NOT NULL,
|
| 6 |
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 7 |
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
| 8 |
+
|
| 9 |
+
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
| 10 |
+
);
|
| 11 |
+
|
| 12 |
+
-- CreateTable
|
| 13 |
+
CREATE TABLE "assessments" (
|
| 14 |
+
"id" TEXT NOT NULL,
|
| 15 |
+
"user_id" TEXT NOT NULL,
|
| 16 |
+
"daily_car_km" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
| 17 |
+
"car_fuel_type" TEXT NOT NULL DEFAULT 'none',
|
| 18 |
+
"public_transport_km_per_week" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
| 19 |
+
"cycling_km_per_week" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
| 20 |
+
"short_flights_per_year" INTEGER NOT NULL DEFAULT 0,
|
| 21 |
+
"long_flights_per_year" INTEGER NOT NULL DEFAULT 0,
|
| 22 |
+
"monthly_electricity_kwh" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
| 23 |
+
"renewable_percentage" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
| 24 |
+
"diet_type" TEXT NOT NULL DEFAULT 'mixed',
|
| 25 |
+
"clothing_items_per_year" INTEGER NOT NULL DEFAULT 0,
|
| 26 |
+
"electronics_items_per_year" INTEGER NOT NULL DEFAULT 0,
|
| 27 |
+
"transport_emission" DOUBLE PRECISION NOT NULL,
|
| 28 |
+
"energy_emission" DOUBLE PRECISION NOT NULL,
|
| 29 |
+
"food_emission" DOUBLE PRECISION NOT NULL,
|
| 30 |
+
"shopping_emission" DOUBLE PRECISION NOT NULL,
|
| 31 |
+
"total_emission" DOUBLE PRECISION NOT NULL,
|
| 32 |
+
"sustainability_score" INTEGER NOT NULL,
|
| 33 |
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 34 |
+
|
| 35 |
+
CONSTRAINT "assessments_pkey" PRIMARY KEY ("id")
|
| 36 |
+
);
|
| 37 |
+
|
| 38 |
+
-- CreateTable
|
| 39 |
+
CREATE TABLE "recommendations" (
|
| 40 |
+
"id" TEXT NOT NULL,
|
| 41 |
+
"assessment_id" TEXT NOT NULL,
|
| 42 |
+
"title" TEXT NOT NULL,
|
| 43 |
+
"description" TEXT NOT NULL,
|
| 44 |
+
"estimated_savings" DOUBLE PRECISION NOT NULL,
|
| 45 |
+
"priority" TEXT NOT NULL,
|
| 46 |
+
"category" TEXT NOT NULL,
|
| 47 |
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 48 |
+
|
| 49 |
+
CONSTRAINT "recommendations_pkey" PRIMARY KEY ("id")
|
| 50 |
+
);
|
| 51 |
+
|
| 52 |
+
-- CreateTable
|
| 53 |
+
CREATE TABLE "simulations" (
|
| 54 |
+
"id" TEXT NOT NULL,
|
| 55 |
+
"assessment_id" TEXT NOT NULL,
|
| 56 |
+
"scenario_name" TEXT NOT NULL,
|
| 57 |
+
"scenario_params" JSONB NOT NULL,
|
| 58 |
+
"original_emission" DOUBLE PRECISION NOT NULL,
|
| 59 |
+
"projected_emission" DOUBLE PRECISION NOT NULL,
|
| 60 |
+
"reduction_percentage" DOUBLE PRECISION NOT NULL,
|
| 61 |
+
"annual_savings_kg" DOUBLE PRECISION NOT NULL,
|
| 62 |
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 63 |
+
|
| 64 |
+
CONSTRAINT "simulations_pkey" PRIMARY KEY ("id")
|
| 65 |
+
);
|
| 66 |
+
|
| 67 |
+
-- CreateIndex
|
| 68 |
+
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
| 69 |
+
|
| 70 |
+
-- CreateIndex
|
| 71 |
+
CREATE INDEX "assessments_user_id_idx" ON "assessments"("user_id");
|
| 72 |
+
|
| 73 |
+
-- CreateIndex
|
| 74 |
+
CREATE INDEX "assessments_created_at_idx" ON "assessments"("created_at");
|
| 75 |
+
|
| 76 |
+
-- CreateIndex
|
| 77 |
+
CREATE INDEX "recommendations_assessment_id_idx" ON "recommendations"("assessment_id");
|
| 78 |
+
|
| 79 |
+
-- CreateIndex
|
| 80 |
+
CREATE INDEX "simulations_assessment_id_idx" ON "simulations"("assessment_id");
|
| 81 |
+
|
| 82 |
+
-- AddForeignKey
|
| 83 |
+
ALTER TABLE "assessments" ADD CONSTRAINT "assessments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
| 84 |
+
|
| 85 |
+
-- AddForeignKey
|
| 86 |
+
ALTER TABLE "recommendations" ADD CONSTRAINT "recommendations_assessment_id_fkey" FOREIGN KEY ("assessment_id") REFERENCES "assessments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
| 87 |
+
|
| 88 |
+
-- AddForeignKey
|
| 89 |
+
ALTER TABLE "simulations" ADD CONSTRAINT "simulations_assessment_id_fkey" FOREIGN KEY ("assessment_id") REFERENCES "assessments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
backend/prisma/migrations/migration_lock.toml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Please do not edit this file manually
|
| 2 |
+
# It should be added in your version-control system (i.e. Git)
|
| 3 |
+
provider = "postgresql"
|
backend/prisma/schema.prisma
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This is your Prisma schema file,
|
| 2 |
+
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
| 3 |
+
|
| 4 |
+
generator client {
|
| 5 |
+
provider = "prisma-client-js"
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
datasource db {
|
| 9 |
+
provider = "postgresql"
|
| 10 |
+
url = env("DATABASE_URL")
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
model User {
|
| 14 |
+
id String @id @default(cuid())
|
| 15 |
+
name String
|
| 16 |
+
email String @unique
|
| 17 |
+
createdAt DateTime @default(now()) @map("created_at")
|
| 18 |
+
updatedAt DateTime @updatedAt @map("updated_at")
|
| 19 |
+
assessments Assessment[]
|
| 20 |
+
|
| 21 |
+
@@map("users")
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
model Assessment {
|
| 25 |
+
id String @id @default(cuid())
|
| 26 |
+
userId String @map("user_id")
|
| 27 |
+
// Input data
|
| 28 |
+
dailyCarKm Float @default(0) @map("daily_car_km")
|
| 29 |
+
carFuelType String @default("none") @map("car_fuel_type")
|
| 30 |
+
publicTransportKmPerWeek Float @default(0) @map("public_transport_km_per_week")
|
| 31 |
+
cyclingKmPerWeek Float @default(0) @map("cycling_km_per_week")
|
| 32 |
+
shortFlightsPerYear Int @default(0) @map("short_flights_per_year")
|
| 33 |
+
longFlightsPerYear Int @default(0) @map("long_flights_per_year")
|
| 34 |
+
monthlyElectricityKwh Float @default(0) @map("monthly_electricity_kwh")
|
| 35 |
+
renewablePercentage Float @default(0) @map("renewable_percentage")
|
| 36 |
+
dietType String @default("mixed") @map("diet_type")
|
| 37 |
+
clothingItemsPerYear Int @default(0) @map("clothing_items_per_year")
|
| 38 |
+
electronicsItemsPerYear Int @default(0) @map("electronics_items_per_year")
|
| 39 |
+
// Calculated emissions (kg CO2 per year)
|
| 40 |
+
transportEmission Float @map("transport_emission")
|
| 41 |
+
energyEmission Float @map("energy_emission")
|
| 42 |
+
foodEmission Float @map("food_emission")
|
| 43 |
+
shoppingEmission Float @map("shopping_emission")
|
| 44 |
+
totalEmission Float @map("total_emission")
|
| 45 |
+
sustainabilityScore Int @map("sustainability_score")
|
| 46 |
+
createdAt DateTime @default(now()) @map("created_at")
|
| 47 |
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
| 48 |
+
recommendations Recommendation[]
|
| 49 |
+
simulations Simulation[]
|
| 50 |
+
|
| 51 |
+
@@index([userId])
|
| 52 |
+
@@index([createdAt])
|
| 53 |
+
@@map("assessments")
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
model Recommendation {
|
| 57 |
+
id String @id @default(cuid())
|
| 58 |
+
assessmentId String @map("assessment_id")
|
| 59 |
+
title String
|
| 60 |
+
description String
|
| 61 |
+
estimatedSavings Float @map("estimated_savings")
|
| 62 |
+
priority String // HIGH | MEDIUM | LOW
|
| 63 |
+
category String // transport | energy | food | shopping
|
| 64 |
+
createdAt DateTime @default(now()) @map("created_at")
|
| 65 |
+
assessment Assessment @relation(fields: [assessmentId], references: [id], onDelete: Cascade)
|
| 66 |
+
|
| 67 |
+
@@index([assessmentId])
|
| 68 |
+
@@map("recommendations")
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
model Simulation {
|
| 72 |
+
id String @id @default(cuid())
|
| 73 |
+
assessmentId String @map("assessment_id")
|
| 74 |
+
scenarioName String @map("scenario_name")
|
| 75 |
+
scenarioParams Json @map("scenario_params")
|
| 76 |
+
originalEmission Float @map("original_emission")
|
| 77 |
+
projectedEmission Float @map("projected_emission")
|
| 78 |
+
reductionPercentage Float @map("reduction_percentage")
|
| 79 |
+
annualSavingsKg Float @map("annual_savings_kg")
|
| 80 |
+
createdAt DateTime @default(now()) @map("created_at")
|
| 81 |
+
assessment Assessment @relation(fields: [assessmentId], references: [id], onDelete: Cascade)
|
| 82 |
+
|
| 83 |
+
@@index([assessmentId])
|
| 84 |
+
@@map("simulations")
|
| 85 |
+
}
|
backend/prisma/seed.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import 'dotenv/config';
|
| 2 |
+
import { PrismaClient } from '@prisma/client';
|
| 3 |
+
|
| 4 |
+
const prisma = new PrismaClient();
|
| 5 |
+
|
| 6 |
+
const users = [
|
| 7 |
+
{ name: 'Alice Green', email: 'alice@ecoguide.ai' },
|
| 8 |
+
{ name: 'Bob Carbon', email: 'bob@ecoguide.ai' },
|
| 9 |
+
{ name: 'Carol Earth', email: 'carol@ecoguide.ai' },
|
| 10 |
+
];
|
| 11 |
+
|
| 12 |
+
const assessmentTemplates = [
|
| 13 |
+
{
|
| 14 |
+
// Low carbon user
|
| 15 |
+
dailyCarKm: 0,
|
| 16 |
+
carFuelType: 'none',
|
| 17 |
+
publicTransportKmPerWeek: 60,
|
| 18 |
+
cyclingKmPerWeek: 40,
|
| 19 |
+
shortFlightsPerYear: 0,
|
| 20 |
+
longFlightsPerYear: 0,
|
| 21 |
+
monthlyElectricityKwh: 120,
|
| 22 |
+
renewablePercentage: 80,
|
| 23 |
+
dietType: 'vegan',
|
| 24 |
+
clothingItemsPerYear: 5,
|
| 25 |
+
electronicsItemsPerYear: 0,
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
// Average user
|
| 29 |
+
dailyCarKm: 20,
|
| 30 |
+
carFuelType: 'petrol',
|
| 31 |
+
publicTransportKmPerWeek: 20,
|
| 32 |
+
cyclingKmPerWeek: 10,
|
| 33 |
+
shortFlightsPerYear: 2,
|
| 34 |
+
longFlightsPerYear: 1,
|
| 35 |
+
monthlyElectricityKwh: 250,
|
| 36 |
+
renewablePercentage: 10,
|
| 37 |
+
dietType: 'mixed',
|
| 38 |
+
clothingItemsPerYear: 12,
|
| 39 |
+
electronicsItemsPerYear: 1,
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
// High carbon user
|
| 43 |
+
dailyCarKm: 50,
|
| 44 |
+
carFuelType: 'petrol',
|
| 45 |
+
publicTransportKmPerWeek: 0,
|
| 46 |
+
cyclingKmPerWeek: 0,
|
| 47 |
+
shortFlightsPerYear: 6,
|
| 48 |
+
longFlightsPerYear: 3,
|
| 49 |
+
monthlyElectricityKwh: 500,
|
| 50 |
+
renewablePercentage: 0,
|
| 51 |
+
dietType: 'heavy_meat',
|
| 52 |
+
clothingItemsPerYear: 40,
|
| 53 |
+
electronicsItemsPerYear: 4,
|
| 54 |
+
},
|
| 55 |
+
];
|
| 56 |
+
|
| 57 |
+
// Calculate emissions inline for seeding
|
| 58 |
+
function calcTransport(d) {
|
| 59 |
+
const carFactors = { petrol: 0.21, diesel: 0.17, electric: 0.047, hybrid: 0.105, none: 0 };
|
| 60 |
+
const car = d.dailyCarKm * (carFactors[d.carFuelType] ?? 0) * 365;
|
| 61 |
+
const pt = d.publicTransportKmPerWeek * 52 * 0.089;
|
| 62 |
+
const flights = d.shortFlightsPerYear * 255 + d.longFlightsPerYear * 1620;
|
| 63 |
+
return Math.round(car + pt + flights);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function calcEnergy(d) {
|
| 67 |
+
return Math.round(d.monthlyElectricityKwh * 12 * 0.233 * (1 - d.renewablePercentage / 100));
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function calcFood(d) {
|
| 71 |
+
return { vegan: 1500, vegetarian: 1700, mixed: 2500, heavy_meat: 3300 }[d.dietType];
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function calcShopping(d) {
|
| 75 |
+
return Math.round(d.clothingItemsPerYear * 33 + d.electronicsItemsPerYear * 300);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function calcScore(total) {
|
| 79 |
+
if (total <= 2000) return 100;
|
| 80 |
+
if (total >= 12000) return 0;
|
| 81 |
+
if (total <= 3000) return Math.round(90 + ((3000 - total) / 1000) * 10);
|
| 82 |
+
if (total <= 5000) return Math.round(70 + ((5000 - total) / 2000) * 20);
|
| 83 |
+
if (total <= 7500) return Math.round(50 + ((7500 - total) / 2500) * 20);
|
| 84 |
+
return Math.round(((12000 - total) / 4500) * 50);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
async function seed() {
|
| 88 |
+
console.log('🌱 Starting seed...');
|
| 89 |
+
|
| 90 |
+
// Clean existing data
|
| 91 |
+
await prisma.simulation.deleteMany();
|
| 92 |
+
await prisma.recommendation.deleteMany();
|
| 93 |
+
await prisma.assessment.deleteMany();
|
| 94 |
+
await prisma.user.deleteMany({ where: { email: { in: users.map((u) => u.email) } } });
|
| 95 |
+
|
| 96 |
+
for (let i = 0; i < users.length; i++) {
|
| 97 |
+
const user = await prisma.user.create({ data: users[i] });
|
| 98 |
+
console.log(`✅ Created user: ${user.name}`);
|
| 99 |
+
|
| 100 |
+
// Create 2 assessments per user (at different dates)
|
| 101 |
+
for (let j = 0; j < 2; j++) {
|
| 102 |
+
const template = assessmentTemplates[i];
|
| 103 |
+
// Slightly vary the second assessment to simulate improvement
|
| 104 |
+
const assessData =
|
| 105 |
+
j === 0
|
| 106 |
+
? template
|
| 107 |
+
: {
|
| 108 |
+
...template,
|
| 109 |
+
dailyCarKm: Math.max(0, template.dailyCarKm - 5),
|
| 110 |
+
monthlyElectricityKwh: Math.max(0, template.monthlyElectricityKwh - 20),
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
const transport = calcTransport(assessData);
|
| 114 |
+
const energy = calcEnergy(assessData);
|
| 115 |
+
const food = calcFood(assessData);
|
| 116 |
+
const shopping = calcShopping(assessData);
|
| 117 |
+
const total = transport + energy + food + shopping;
|
| 118 |
+
const score = calcScore(total);
|
| 119 |
+
|
| 120 |
+
await prisma.assessment.create({
|
| 121 |
+
data: {
|
| 122 |
+
userId: user.id,
|
| 123 |
+
...assessData,
|
| 124 |
+
transportEmission: transport,
|
| 125 |
+
energyEmission: energy,
|
| 126 |
+
foodEmission: food,
|
| 127 |
+
shoppingEmission: shopping,
|
| 128 |
+
totalEmission: total,
|
| 129 |
+
sustainabilityScore: score,
|
| 130 |
+
createdAt: new Date(Date.now() - (j === 0 ? 30 : 0) * 24 * 60 * 60 * 1000),
|
| 131 |
+
recommendations: {
|
| 132 |
+
create: [
|
| 133 |
+
{
|
| 134 |
+
title: 'Example: Switch to Renewable Energy',
|
| 135 |
+
description:
|
| 136 |
+
'Sign up for a green energy tariff to reduce electricity emissions significantly.',
|
| 137 |
+
estimatedSavings: Math.round(energy * 0.7),
|
| 138 |
+
priority: 'HIGH',
|
| 139 |
+
category: 'energy',
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
title: 'Example: Reduce Meat Consumption',
|
| 143 |
+
description: 'Cutting red meat 3 days per week can save hundreds of kg CO₂/year.',
|
| 144 |
+
estimatedSavings: 500,
|
| 145 |
+
priority: 'MEDIUM',
|
| 146 |
+
category: 'food',
|
| 147 |
+
},
|
| 148 |
+
],
|
| 149 |
+
},
|
| 150 |
+
},
|
| 151 |
+
});
|
| 152 |
+
}
|
| 153 |
+
console.log(` ✅ Created assessments for ${user.name}`);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const count = await prisma.user.count();
|
| 157 |
+
const assessCount = await prisma.assessment.count();
|
| 158 |
+
console.log(`\n🎉 Seed complete! ${count} users, ${assessCount} assessments created.`);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
seed()
|
| 162 |
+
.catch((e) => {
|
| 163 |
+
console.error('❌ Seed failed:', e);
|
| 164 |
+
process.exit(1);
|
| 165 |
+
})
|
| 166 |
+
.finally(() => prisma.$disconnect());
|
backend/requirements.txt
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
# This is a Node.js (JavaScript) backend service.
|
| 2 |
-
# Node.js backend dependencies are defined and installed using "backend/package.json".
|
| 3 |
-
# To deploy this service in Hugging Face (HF) Spaces, use a "Docker" space template
|
| 4 |
-
# which will automatically build and run the backend using the provided "backend/Dockerfile".
|
| 5 |
-
|
| 6 |
-
# Listed below are the Node.js backend requirements:
|
| 7 |
-
cors==2.8.5
|
| 8 |
-
express==4.21.2
|
| 9 |
-
firebase-admin==14.0.0
|
| 10 |
-
morgan==1.10.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/routes.js
DELETED
|
@@ -1,1008 +0,0 @@
|
|
| 1 |
-
const express = require('express');
|
| 2 |
-
const router = express.Router();
|
| 3 |
-
const db = require('./firebase');
|
| 4 |
-
|
| 5 |
-
// Simple in-memory cache for Firestore queries to optimize efficiency
|
| 6 |
-
let usersCache = null;
|
| 7 |
-
let usersCacheTime = 0;
|
| 8 |
-
const CACHE_TTL = 10000; // 10 seconds
|
| 9 |
-
|
| 10 |
-
/**
|
| 11 |
-
* Retrieves all users from Firestore with a short-lived cache (10 seconds)
|
| 12 |
-
* to optimize read efficiency and limit database requests.
|
| 13 |
-
* @returns {Promise<Array<Object>>} A list of all user profiles.
|
| 14 |
-
*/
|
| 15 |
-
async function getAllUsersCached() {
|
| 16 |
-
const now = Date.now();
|
| 17 |
-
if (usersCache && (now - usersCacheTime < CACHE_TTL)) {
|
| 18 |
-
return usersCache;
|
| 19 |
-
}
|
| 20 |
-
const snapshot = await db.collection('users').get();
|
| 21 |
-
usersCache = snapshot.docs.map(doc => {
|
| 22 |
-
const data = doc.data();
|
| 23 |
-
return {
|
| 24 |
-
id: doc.id,
|
| 25 |
-
name: data.name || doc.id,
|
| 26 |
-
email: data.email || '',
|
| 27 |
-
avatar: data.avatar || 'https://lh3.googleusercontent.com/aida-public/AB6AXuAT1EPgeHbiCyiDKLGp868IabVBLWQJe-FbA4S09aQJipuS6tXAJnHYJnoD4VL-TBLlnzm4xoCEkS_WlOmVhbeXjuNHry4GZPGKfJ5_iQ8X-fVs2ZENwqa0MK2sTi6dgD_hmctOs2tY1U0dbsRFDclOP1Sy81nI9zy56ULwxCR3EAsmngeA71gDstnUUoOs0PVxPurXRdI32iJ5ScLE0CjWgOYh6n808_7lSn7PlX4m0EkobLUHN1beT5h43E4UGhZjiUdK2JjYPK9R',
|
| 28 |
-
points: data.points || 0,
|
| 29 |
-
title: data.title || 'Eco Novice',
|
| 30 |
-
percentages: data.percentages || { transport: 20, electricity: 20, food: 20, waste: 20, shopping: 20 },
|
| 31 |
-
complianceRate: data.complianceRate || '80.0%',
|
| 32 |
-
totalMonthly: data.totalMonthly || 0
|
| 33 |
-
};
|
| 34 |
-
});
|
| 35 |
-
usersCacheTime = now;
|
| 36 |
-
return usersCache;
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
// ==========================================
|
| 40 |
-
// Static Config Presets & Challenges Templates
|
| 41 |
-
// ==========================================
|
| 42 |
-
const staticConfig = {
|
| 43 |
-
factors: {
|
| 44 |
-
car: 0.16,
|
| 45 |
-
bus: 0.08,
|
| 46 |
-
bike: 0.005,
|
| 47 |
-
walk: 0.0,
|
| 48 |
-
train: 0.04,
|
| 49 |
-
ev: 0.02,
|
| 50 |
-
},
|
| 51 |
-
multipliers: {
|
| 52 |
-
electric: 0.3,
|
| 53 |
-
hybrid: 0.6,
|
| 54 |
-
gasoline: 1.0,
|
| 55 |
-
diesel: 1.2,
|
| 56 |
-
lpg: 0.8,
|
| 57 |
-
}
|
| 58 |
-
};
|
| 59 |
-
|
| 60 |
-
const defaultChallenges = [
|
| 61 |
-
{
|
| 62 |
-
id: '1',
|
| 63 |
-
title: 'No-Car Week',
|
| 64 |
-
points: '+500 pts',
|
| 65 |
-
pointsValue: 500,
|
| 66 |
-
timeLeft: '4 days left',
|
| 67 |
-
icon: 'directions-car',
|
| 68 |
-
color: '#4edea3',
|
| 69 |
-
bgColor: 'rgba(78, 222, 163, 0.15)',
|
| 70 |
-
associatedBadge: 'Carbon Ninja',
|
| 71 |
-
},
|
| 72 |
-
{
|
| 73 |
-
id: '2',
|
| 74 |
-
title: 'Vegan Weekend',
|
| 75 |
-
points: '+250 pts',
|
| 76 |
-
pointsValue: 250,
|
| 77 |
-
timeLeft: 'Starts in 12h',
|
| 78 |
-
icon: 'eco',
|
| 79 |
-
color: '#ffb95f',
|
| 80 |
-
bgColor: 'rgba(255, 185, 95, 0.15)',
|
| 81 |
-
associatedBadge: 'Tree Planter',
|
| 82 |
-
},
|
| 83 |
-
{
|
| 84 |
-
id: '3',
|
| 85 |
-
title: 'Plastic-Free',
|
| 86 |
-
points: '+400 pts',
|
| 87 |
-
pointsValue: 400,
|
| 88 |
-
timeLeft: '2 days left',
|
| 89 |
-
icon: 'shopping-bag',
|
| 90 |
-
color: '#4edea3',
|
| 91 |
-
bgColor: 'rgba(78, 222, 163, 0.15)',
|
| 92 |
-
associatedBadge: 'Zero Waste',
|
| 93 |
-
},
|
| 94 |
-
];
|
| 95 |
-
|
| 96 |
-
/**
|
| 97 |
-
* Helper to retrieve the active user ID from request headers or query params
|
| 98 |
-
* @param {express.Request} req - Express request object
|
| 99 |
-
* @returns {string} active user ID
|
| 100 |
-
*/
|
| 101 |
-
function getRequestUserId(req) {
|
| 102 |
-
return req.headers['x-user-id'] || req.query.userId || 'sarah_j';
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
// ==========================================
|
| 106 |
-
// API Endpoints (Firestore Powered)
|
| 107 |
-
// ==========================================
|
| 108 |
-
|
| 109 |
-
/**
|
| 110 |
-
* GET /api/config
|
| 111 |
-
* Retrieves carbon footprint factors and multipliers configuration presets.
|
| 112 |
-
* @route GET /api/config
|
| 113 |
-
* @returns {Object} 200 - Static config presets
|
| 114 |
-
*/
|
| 115 |
-
router.get('/config', (req, res) => {
|
| 116 |
-
res.json(staticConfig);
|
| 117 |
-
});
|
| 118 |
-
|
| 119 |
-
/**
|
| 120 |
-
* GET /api/profile
|
| 121 |
-
* Retrieves profile information for the active user, creating a default one if not found.
|
| 122 |
-
* @route GET /api/profile
|
| 123 |
-
* @returns {Object} 200 - User profile data object
|
| 124 |
-
* @returns {Object} 500 - Internal server error
|
| 125 |
-
*/
|
| 126 |
-
router.get('/profile', async (req, res, next) => {
|
| 127 |
-
const userId = getRequestUserId(req);
|
| 128 |
-
try {
|
| 129 |
-
const userDoc = await db.collection('users').doc(userId).get();
|
| 130 |
-
if (userDoc.exists) {
|
| 131 |
-
res.json(userDoc.data());
|
| 132 |
-
} else {
|
| 133 |
-
// Create a new user template if not exists
|
| 134 |
-
const defaultProfile = {
|
| 135 |
-
email: `${userId}@example.com`,
|
| 136 |
-
name: userId.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
| 137 |
-
avatar: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAT1EPgeHbiCyiDKLGp868IabVBLWQJe-FbA4S09aQJipuS6tXAJnHYJnoD4VL-TBLlnzm4xoCEkS_WlOmVhbeXjuNHry4GZPGKfJ5_iQ8X-fVs2ZENwqa0MK2sTi6dgD_hmctOs2tY1U0dbsRFDclOP1Sy81nI9zy56ULwxCR3EAsmngeA71gDstnUUoOs0PVxPurXRdI32iJ5ScLE0CjWgOYh6n808_7lSn7PlX4m0EkobLUHN1beT5h43E4UGhZjiUdK2JjYPK9R',
|
| 138 |
-
title: 'Eco Novice',
|
| 139 |
-
points: 0,
|
| 140 |
-
badges: [],
|
| 141 |
-
percentages: { transport: 20, electricity: 20, food: 20, waste: 20, shopping: 20 },
|
| 142 |
-
complianceRate: '80.0%',
|
| 143 |
-
totalMonthly: 0
|
| 144 |
-
};
|
| 145 |
-
await db.collection('users').doc(userId).set(defaultProfile);
|
| 146 |
-
res.json(defaultProfile);
|
| 147 |
-
}
|
| 148 |
-
} catch (err) {
|
| 149 |
-
next(err);
|
| 150 |
-
}
|
| 151 |
-
});
|
| 152 |
-
|
| 153 |
-
let statsCache = null;
|
| 154 |
-
let statsCacheTime = 0;
|
| 155 |
-
|
| 156 |
-
/**
|
| 157 |
-
* GET /api/stats
|
| 158 |
-
* Retrieves global platform usage/sustainability metrics for the landing page.
|
| 159 |
-
* Uses a short-lived cache (10 seconds).
|
| 160 |
-
* @route GET /api/stats
|
| 161 |
-
* @returns {Object} 200 - Global metrics payload
|
| 162 |
-
*/
|
| 163 |
-
router.get('/stats', async (req, res) => {
|
| 164 |
-
const now = Date.now();
|
| 165 |
-
if (statsCache && (now - statsCacheTime < 10000)) {
|
| 166 |
-
return res.json(statsCache);
|
| 167 |
-
}
|
| 168 |
-
try {
|
| 169 |
-
const users = await getAllUsersCached();
|
| 170 |
-
const userCount = users.length;
|
| 171 |
-
statsCache = {
|
| 172 |
-
globalReduction: '-14.2%',
|
| 173 |
-
globalSavedTons: '12 Tons',
|
| 174 |
-
activeProjectCount: `${Math.max(userCount * 12 + 50000, 50000).toLocaleString()}+`,
|
| 175 |
-
treesRestoredCount: `${Math.max(userCount, 8000).toLocaleString()}+`,
|
| 176 |
-
aiPrecision: '99.9%',
|
| 177 |
-
};
|
| 178 |
-
statsCacheTime = now;
|
| 179 |
-
res.json(statsCache);
|
| 180 |
-
} catch (err) {
|
| 181 |
-
res.json({
|
| 182 |
-
globalReduction: '-14.2%',
|
| 183 |
-
globalSavedTons: '12 Tons',
|
| 184 |
-
activeProjectCount: '50,000+',
|
| 185 |
-
treesRestoredCount: '8,000+',
|
| 186 |
-
aiPrecision: '99.9%',
|
| 187 |
-
});
|
| 188 |
-
}
|
| 189 |
-
});
|
| 190 |
-
|
| 191 |
-
/**
|
| 192 |
-
* GET /api/insights
|
| 193 |
-
* Retrieves current footprint total, compliance rate, carbon share percentages, and AI recommendations.
|
| 194 |
-
* @route GET /api/insights
|
| 195 |
-
* @returns {Object} 200 - User insights payload
|
| 196 |
-
* @returns {Object} 500 - Internal server error
|
| 197 |
-
*/
|
| 198 |
-
router.get('/insights', async (req, res, next) => {
|
| 199 |
-
const userId = getRequestUserId(req);
|
| 200 |
-
try {
|
| 201 |
-
const userDoc = await db.collection('users').doc(userId).get();
|
| 202 |
-
const userData = userDoc.exists ? userDoc.data() : {};
|
| 203 |
-
|
| 204 |
-
const totalMonthly = userData.totalMonthly || 0;
|
| 205 |
-
const percentages = userData.percentages || { transport: 20, electricity: 20, food: 20, waste: 20, shopping: 20 };
|
| 206 |
-
|
| 207 |
-
res.json({
|
| 208 |
-
totalMonthly: totalMonthly,
|
| 209 |
-
complianceRate: userData.complianceRate || '90.0%',
|
| 210 |
-
percentages: percentages,
|
| 211 |
-
recommendations: await generateRecommendations(totalMonthly, percentages)
|
| 212 |
-
});
|
| 213 |
-
} catch (err) {
|
| 214 |
-
next(err);
|
| 215 |
-
}
|
| 216 |
-
});
|
| 217 |
-
|
| 218 |
-
/**
|
| 219 |
-
* POST /api/carbon/calculate
|
| 220 |
-
* Calculates carbon footprint based on transport, electricity, food, waste, and shopping habits.
|
| 221 |
-
* @route POST /api/carbon/calculate
|
| 222 |
-
* @param {Object} req.body - Footprint details
|
| 223 |
-
* @returns {Object} 200 - Computation results with monthly/yearly emissions and category breakdowns
|
| 224 |
-
* @returns {Object} 400 - Validation error payload
|
| 225 |
-
*/
|
| 226 |
-
router.post('/carbon/calculate', (req, res) => {
|
| 227 |
-
const { transport, electricity, food, waste, shopping } = req.body;
|
| 228 |
-
|
| 229 |
-
// Validate transport inputs if provided
|
| 230 |
-
if (transport !== undefined) {
|
| 231 |
-
if (typeof transport !== 'object' || transport === null) {
|
| 232 |
-
return res.status(400).json({ error: 'Transport parameter must be an object' });
|
| 233 |
-
}
|
| 234 |
-
if (transport.distance !== undefined && (typeof transport.distance !== 'number' || transport.distance < 0 || isNaN(transport.distance))) {
|
| 235 |
-
return res.status(400).json({ error: 'Transport distance must be a valid positive number' });
|
| 236 |
-
}
|
| 237 |
-
if (transport.daysPerWeek !== undefined && (typeof transport.daysPerWeek !== 'number' || transport.daysPerWeek < 0 || transport.daysPerWeek > 7 || isNaN(transport.daysPerWeek))) {
|
| 238 |
-
return res.status(400).json({ error: 'Transport daysPerWeek must be a valid number between 0 and 7' });
|
| 239 |
-
}
|
| 240 |
-
}
|
| 241 |
-
// Validate electricity inputs if provided
|
| 242 |
-
if (electricity !== undefined) {
|
| 243 |
-
if (typeof electricity !== 'object' || electricity === null) {
|
| 244 |
-
return res.status(400).json({ error: 'Electricity parameter must be an object' });
|
| 245 |
-
}
|
| 246 |
-
if (electricity.units !== undefined && (typeof electricity.units !== 'number' || electricity.units < 0 || isNaN(electricity.units))) {
|
| 247 |
-
return res.status(400).json({ error: 'Electricity units must be a valid positive number' });
|
| 248 |
-
}
|
| 249 |
-
}
|
| 250 |
-
// Validate food inputs if provided
|
| 251 |
-
if (food !== undefined && (typeof food !== 'object' || food === null)) {
|
| 252 |
-
return res.status(400).json({ error: 'Food parameter must be an object' });
|
| 253 |
-
}
|
| 254 |
-
// Validate waste inputs if provided
|
| 255 |
-
if (waste !== undefined && (typeof waste !== 'object' || waste === null)) {
|
| 256 |
-
return res.status(400).json({ error: 'Waste parameter must be an object' });
|
| 257 |
-
}
|
| 258 |
-
// Validate shopping inputs if provided
|
| 259 |
-
if (shopping !== undefined && (typeof shopping !== 'object' || shopping === null)) {
|
| 260 |
-
return res.status(400).json({ error: 'Shopping parameter must be an object' });
|
| 261 |
-
}
|
| 262 |
-
|
| 263 |
-
// Compute Transport
|
| 264 |
-
const tMode = transport?.mode || 'car';
|
| 265 |
-
const tDist = transport?.distance || 0;
|
| 266 |
-
const tFuel = transport?.fuelType || 'gasoline';
|
| 267 |
-
const tDays = transport?.daysPerWeek || 5;
|
| 268 |
-
|
| 269 |
-
let tFactor = staticConfig.factors[tMode] || 0;
|
| 270 |
-
let tMultiplier = 1.0;
|
| 271 |
-
if (tMode === 'car' || tMode === 'bus') {
|
| 272 |
-
tMultiplier = staticConfig.multipliers[tFuel] || 1.0;
|
| 273 |
-
}
|
| 274 |
-
const transportMonthly = tFactor * tDist * tMultiplier * tDays * 4.3;
|
| 275 |
-
|
| 276 |
-
// Compute Electricity (monthly kWh units)
|
| 277 |
-
const eUnits = electricity?.units || 0;
|
| 278 |
-
const electricityMonthly = eUnits * 0.85;
|
| 279 |
-
|
| 280 |
-
// Compute Food
|
| 281 |
-
const fType = food?.type || 'vegetarian';
|
| 282 |
-
const fChicken = food?.chicken || 0;
|
| 283 |
-
const fMutton = food?.mutton || 0;
|
| 284 |
-
const fBeef = food?.beef || 0;
|
| 285 |
-
const fFish = food?.fish || 0;
|
| 286 |
-
|
| 287 |
-
let baseFood = 100;
|
| 288 |
-
if (fType === 'vegan') baseFood = 60;
|
| 289 |
-
if (fType === 'non-vegetarian') baseFood = 150;
|
| 290 |
-
const foodMonthly = baseFood + (fChicken * 4) + (fMutton * 10) + (fBeef * 25) + (fFish * 3);
|
| 291 |
-
|
| 292 |
-
// Compute Waste
|
| 293 |
-
const wPlastic = waste?.plasticUsage || 5;
|
| 294 |
-
const wRecycle = waste?.recyclingHabits || 'partial';
|
| 295 |
-
const recycleOffset = wRecycle === 'none' ? 12 : wRecycle === 'partial' ? 6 : 0;
|
| 296 |
-
const wasteMonthly = (wPlastic * 4) + recycleOffset;
|
| 297 |
-
|
| 298 |
-
// Compute Shopping
|
| 299 |
-
const sClothes = shopping?.clothes || 0;
|
| 300 |
-
const sOrders = shopping?.onlineOrders || 0;
|
| 301 |
-
const sElectronics = shopping?.electronics || 0;
|
| 302 |
-
const shoppingMonthly = (sClothes * 10) + (sOrders * 2) + (sElectronics * 80);
|
| 303 |
-
|
| 304 |
-
// Totals
|
| 305 |
-
const monthlyEmission = transportMonthly + electricityMonthly + foodMonthly + wasteMonthly + shoppingMonthly;
|
| 306 |
-
const yearlyEmission = monthlyEmission * 12;
|
| 307 |
-
|
| 308 |
-
const breakdown = {
|
| 309 |
-
transport: parseFloat(transportMonthly.toFixed(1)),
|
| 310 |
-
electricity: parseFloat(electricityMonthly.toFixed(1)),
|
| 311 |
-
food: parseFloat(foodMonthly.toFixed(1)),
|
| 312 |
-
waste: parseFloat(wasteMonthly.toFixed(1)),
|
| 313 |
-
shopping: parseFloat(shoppingMonthly.toFixed(1)),
|
| 314 |
-
};
|
| 315 |
-
|
| 316 |
-
res.json({
|
| 317 |
-
monthlyEmission: parseFloat(monthlyEmission.toFixed(1)),
|
| 318 |
-
yearlyEmission: parseFloat(yearlyEmission.toFixed(1)),
|
| 319 |
-
breakdown
|
| 320 |
-
});
|
| 321 |
-
});
|
| 322 |
-
|
| 323 |
-
/**
|
| 324 |
-
* POST /api/footprint/save
|
| 325 |
-
* Saves a footprint calculation to database history and updates user stats / points.
|
| 326 |
-
* @route POST /api/footprint/save
|
| 327 |
-
* @param {Object} req.body - Footprint computation values to store
|
| 328 |
-
* @returns {Object} 200 - Saved status, updated history, and updated profile
|
| 329 |
-
* @returns {Object} 400 - Validation error payload
|
| 330 |
-
* @returns {Object} 500 - Internal server error
|
| 331 |
-
*/
|
| 332 |
-
router.post('/footprint/save', async (req, res, next) => {
|
| 333 |
-
const userId = getRequestUserId(req);
|
| 334 |
-
const { monthlyEmission, breakdown } = req.body;
|
| 335 |
-
|
| 336 |
-
if (monthlyEmission === undefined || typeof monthlyEmission !== 'number' || monthlyEmission < 0 || isNaN(monthlyEmission)) {
|
| 337 |
-
return res.status(400).json({ error: 'monthlyEmission must be a valid positive number' });
|
| 338 |
-
}
|
| 339 |
-
if (breakdown !== undefined && (typeof breakdown !== 'object' || breakdown === null)) {
|
| 340 |
-
return res.status(400).json({ error: 'breakdown must be a valid object' });
|
| 341 |
-
}
|
| 342 |
-
|
| 343 |
-
try {
|
| 344 |
-
const currentMonthName = 'Jun';
|
| 345 |
-
|
| 346 |
-
// Save to subcollection history
|
| 347 |
-
await db.collection('users').doc(userId).collection('history').doc(currentMonthName).set({
|
| 348 |
-
month: currentMonthName,
|
| 349 |
-
amount: Math.round(monthlyEmission)
|
| 350 |
-
});
|
| 351 |
-
|
| 352 |
-
// Update user profile summary in firestore
|
| 353 |
-
const userDoc = await db.collection('users').doc(userId).get();
|
| 354 |
-
const profile = userDoc.exists ? userDoc.data() : {};
|
| 355 |
-
|
| 356 |
-
let newPercentages = profile.percentages || { transport: 20, electricity: 20, food: 20, waste: 20, shopping: 20 };
|
| 357 |
-
if (breakdown) {
|
| 358 |
-
const total = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
| 359 |
-
if (total > 0) {
|
| 360 |
-
newPercentages = {
|
| 361 |
-
transport: Math.round((breakdown.transport / total) * 100),
|
| 362 |
-
electricity: Math.round((breakdown.electricity / total) * 100),
|
| 363 |
-
food: Math.round((breakdown.food / total) * 100),
|
| 364 |
-
waste: Math.round((breakdown.waste / total) * 100),
|
| 365 |
-
shopping: Math.round((breakdown.shopping / total) * 100),
|
| 366 |
-
};
|
| 367 |
-
}
|
| 368 |
-
}
|
| 369 |
-
|
| 370 |
-
const updatedPoints = (profile.points || 0) + 100;
|
| 371 |
-
const currentTitle = updatedPoints >= 12000 ? 'Master Guardian' : updatedPoints >= 11000 ? 'Nature Enthusiast' : 'Eco Novice';
|
| 372 |
-
|
| 373 |
-
const updatedProfile = {
|
| 374 |
-
...profile,
|
| 375 |
-
points: updatedPoints,
|
| 376 |
-
title: currentTitle,
|
| 377 |
-
totalMonthly: Math.round(monthlyEmission),
|
| 378 |
-
percentages: newPercentages
|
| 379 |
-
};
|
| 380 |
-
|
| 381 |
-
await db.collection('users').doc(userId).set(updatedProfile);
|
| 382 |
-
usersCache = null; // Invalidate cache
|
| 383 |
-
|
| 384 |
-
// Fetch updated history list
|
| 385 |
-
const snapshot = await db.collection('users').doc(userId).collection('history').get();
|
| 386 |
-
const historyList = snapshot.docs.map(doc => doc.data());
|
| 387 |
-
|
| 388 |
-
res.json({
|
| 389 |
-
success: true,
|
| 390 |
-
history: historyList,
|
| 391 |
-
profile: updatedProfile
|
| 392 |
-
});
|
| 393 |
-
} catch (err) {
|
| 394 |
-
next(err);
|
| 395 |
-
}
|
| 396 |
-
});
|
| 397 |
-
|
| 398 |
-
/**
|
| 399 |
-
* GET /api/history
|
| 400 |
-
* Retrieves the historical timeline of carbon calculation submissions.
|
| 401 |
-
* @route GET /api/history
|
| 402 |
-
* @returns {Array<Object>} 200 - Array of historical emission data points
|
| 403 |
-
* @returns {Object} 500 - Internal server error
|
| 404 |
-
*/
|
| 405 |
-
router.get('/history', async (req, res, next) => {
|
| 406 |
-
const userId = getRequestUserId(req);
|
| 407 |
-
try {
|
| 408 |
-
const snapshot = await db.collection('users').doc(userId).collection('history').get();
|
| 409 |
-
const historyList = snapshot.docs.map(doc => doc.data());
|
| 410 |
-
|
| 411 |
-
const monthOrder = { Jan: 1, Feb: 2, Mar: 3, Apr: 4, May: 5, Jun: 6, Jul: 7, Aug: 8, Sep: 9, Oct: 10, Nov: 11, Dec: 12 };
|
| 412 |
-
historyList.sort((a, b) => (monthOrder[a.month] || 0) - (monthOrder[b.month] || 0));
|
| 413 |
-
|
| 414 |
-
// Seed default history data for Sarah J if database is fresh
|
| 415 |
-
if (historyList.length === 0 && userId === 'sarah_j') {
|
| 416 |
-
const defaultHistory = [
|
| 417 |
-
{ month: 'Jan', amount: 155 },
|
| 418 |
-
{ month: 'Feb', amount: 145 },
|
| 419 |
-
{ month: 'Mar', amount: 130 },
|
| 420 |
-
{ month: 'Apr', amount: 125 },
|
| 421 |
-
{ month: 'May', amount: 112 },
|
| 422 |
-
{ month: 'Jun', amount: 98 }
|
| 423 |
-
];
|
| 424 |
-
// Save it asynchronously
|
| 425 |
-
for (const record of defaultHistory) {
|
| 426 |
-
await db.collection('users').doc(userId).collection('history').doc(record.month).set(record);
|
| 427 |
-
}
|
| 428 |
-
return res.json(defaultHistory);
|
| 429 |
-
}
|
| 430 |
-
|
| 431 |
-
res.json(historyList);
|
| 432 |
-
} catch (err) {
|
| 433 |
-
next(err);
|
| 434 |
-
}
|
| 435 |
-
});
|
| 436 |
-
|
| 437 |
-
/**
|
| 438 |
-
* GET /api/goals
|
| 439 |
-
* Retrieves active carbon reduction goals for the current user.
|
| 440 |
-
* @route GET /api/goals
|
| 441 |
-
* @returns {Array<Object>} 200 - List of active goals
|
| 442 |
-
* @returns {Object} 500 - Internal server error
|
| 443 |
-
*/
|
| 444 |
-
router.get('/goals', async (req, res, next) => {
|
| 445 |
-
const userId = getRequestUserId(req);
|
| 446 |
-
try {
|
| 447 |
-
const snapshot = await db.collection('users').doc(userId).collection('goals').get();
|
| 448 |
-
const goalsList = snapshot.docs.map(doc => doc.data());
|
| 449 |
-
|
| 450 |
-
// Seed default goals for Sarah J if database is fresh
|
| 451 |
-
if (goalsList.length === 0 && userId === 'sarah_j') {
|
| 452 |
-
const defaultGoals = [
|
| 453 |
-
{ id: '1', title: 'Reduce footprint by 20%', progress: 0.75, target: '20% Reduction' },
|
| 454 |
-
{ id: '2', title: 'Cycle 3 days/week', progress: 0.40, target: '3 Days/Week' }
|
| 455 |
-
];
|
| 456 |
-
for (const goal of defaultGoals) {
|
| 457 |
-
await db.collection('users').doc(userId).collection('goals').doc(goal.id).set(goal);
|
| 458 |
-
}
|
| 459 |
-
return res.json(defaultGoals);
|
| 460 |
-
}
|
| 461 |
-
|
| 462 |
-
res.json(goalsList);
|
| 463 |
-
} catch (err) {
|
| 464 |
-
next(err);
|
| 465 |
-
}
|
| 466 |
-
});
|
| 467 |
-
|
| 468 |
-
/**
|
| 469 |
-
* POST /api/goals
|
| 470 |
-
* Adds a new carbon reduction goal for the current user and awards 50 points.
|
| 471 |
-
* @route POST /api/goals
|
| 472 |
-
* @param {Object} req.body - Goal metadata (title, target)
|
| 473 |
-
* @returns {Object} 200 - Goal creation success status, list of goals, and updated profile
|
| 474 |
-
* @returns {Object} 400 - Validation error payload
|
| 475 |
-
* @returns {Object} 500 - Internal server error
|
| 476 |
-
*/
|
| 477 |
-
router.post('/goals', async (req, res, next) => {
|
| 478 |
-
const userId = getRequestUserId(req);
|
| 479 |
-
const { title, target } = req.body;
|
| 480 |
-
|
| 481 |
-
if (!title || typeof title !== 'string' || !title.trim()) {
|
| 482 |
-
return res.status(400).json({ error: 'Goal title must be a valid non-empty string' });
|
| 483 |
-
}
|
| 484 |
-
if (title.length > 100) {
|
| 485 |
-
return res.status(400).json({ error: 'Goal title must be under 100 characters' });
|
| 486 |
-
}
|
| 487 |
-
if (target !== undefined && (typeof target !== 'string' || target.length > 50)) {
|
| 488 |
-
return res.status(400).json({ error: 'Goal target must be a valid string under 50 characters' });
|
| 489 |
-
}
|
| 490 |
-
|
| 491 |
-
const sanitizedTitle = title.replace(/<[^>]*>/g, '').trim();
|
| 492 |
-
const sanitizedTarget = (target || '').replace(/<[^>]*>/g, '').trim();
|
| 493 |
-
|
| 494 |
-
try {
|
| 495 |
-
const snapshot = await db.collection('users').doc(userId).collection('goals').get();
|
| 496 |
-
const newId = String(snapshot.docs.length + 1);
|
| 497 |
-
|
| 498 |
-
const newGoal = {
|
| 499 |
-
id: newId,
|
| 500 |
-
title: sanitizedTitle,
|
| 501 |
-
progress: 0.0,
|
| 502 |
-
target: sanitizedTarget || 'Active Target'
|
| 503 |
-
};
|
| 504 |
-
|
| 505 |
-
await db.collection('users').doc(userId).collection('goals').doc(newId).set(newGoal);
|
| 506 |
-
usersCache = null; // Invalidate cache
|
| 507 |
-
|
| 508 |
-
// Reward profile points
|
| 509 |
-
const userDoc = await db.collection('users').doc(userId).get();
|
| 510 |
-
const profile = userDoc.exists ? userDoc.data() : { points: 0 };
|
| 511 |
-
const updatedPoints = (profile.points || 0) + 50;
|
| 512 |
-
const currentTitle = updatedPoints >= 12000 ? 'Master Guardian' : updatedPoints >= 11000 ? 'Nature Enthusiast' : 'Eco Novice';
|
| 513 |
-
|
| 514 |
-
const updatedProfile = {
|
| 515 |
-
...profile,
|
| 516 |
-
points: updatedPoints,
|
| 517 |
-
title: currentTitle
|
| 518 |
-
};
|
| 519 |
-
await db.collection('users').doc(userId).set(updatedProfile);
|
| 520 |
-
|
| 521 |
-
const updatedSnapshot = await db.collection('users').doc(userId).collection('goals').get();
|
| 522 |
-
const goalsList = updatedSnapshot.docs.map(doc => doc.data());
|
| 523 |
-
|
| 524 |
-
res.json({ success: true, goal: newGoal, goals: goalsList, profile: updatedProfile });
|
| 525 |
-
} catch (err) {
|
| 526 |
-
next(err);
|
| 527 |
-
}
|
| 528 |
-
});
|
| 529 |
-
|
| 530 |
-
/**
|
| 531 |
-
* Helper to generate AI sustainability recommendations using Groq's Llama model or static fallbacks.
|
| 532 |
-
* @param {number} footprint - Monthly carbon footprint in kg CO2e
|
| 533 |
-
* @param {Object} breakdown - Breakdown percentages per carbon category
|
| 534 |
-
* @returns {Promise<Array<Object>>} A promise that resolves to exactly 3 recommendations
|
| 535 |
-
*/
|
| 536 |
-
async function generateRecommendations(footprint, breakdown) {
|
| 537 |
-
const apiKey = process.env.GROQ_API_KEY;
|
| 538 |
-
if (apiKey) {
|
| 539 |
-
try {
|
| 540 |
-
const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
| 541 |
-
method: 'POST',
|
| 542 |
-
headers: {
|
| 543 |
-
'Authorization': `Bearer ${apiKey}`,
|
| 544 |
-
'Content-Type': 'application/json'
|
| 545 |
-
},
|
| 546 |
-
body: JSON.stringify({
|
| 547 |
-
model: 'llama-3.1-8b-instant',
|
| 548 |
-
messages: [
|
| 549 |
-
{
|
| 550 |
-
role: 'system',
|
| 551 |
-
content: 'You are an expert AI Sustainability Coach. You analyze a user\'s carbon footprint (in kg CO2e) and category percentage breakdown (transport, electricity, food, waste, shopping). You must return exactly 3 highly actionable, personalized recommendations to help the user reduce their footprint. Return the recommendations as a JSON object containing a "recommendations" array. Each recommendation in the array must be an object with fields: "title" (under 40 characters), "desc" (1-2 sentences with concrete tips), "icon" (MaterialIcons icon name), and "color" (hex color code).'
|
| 552 |
-
},
|
| 553 |
-
{
|
| 554 |
-
role: 'user',
|
| 555 |
-
content: `Carbon Footprint: ${footprint} kg CO2e. Percentage breakdown: Transport: ${breakdown?.transport || 0}%, Electricity: ${breakdown?.electricity || 0}%, Food: ${breakdown?.food || 0}%, Waste: ${breakdown?.waste || 0}%, Shopping: ${breakdown?.shopping || 0}%.`
|
| 556 |
-
}
|
| 557 |
-
],
|
| 558 |
-
response_format: { type: 'json_object' }
|
| 559 |
-
})
|
| 560 |
-
});
|
| 561 |
-
if (response.ok) {
|
| 562 |
-
const data = await response.json();
|
| 563 |
-
const content = JSON.parse(data.choices[0].message.content);
|
| 564 |
-
if (content && Array.isArray(content.recommendations) && content.recommendations.length > 0) {
|
| 565 |
-
return content.recommendations;
|
| 566 |
-
}
|
| 567 |
-
} else {
|
| 568 |
-
console.error('Groq API responded with error:', response.status, await response.text());
|
| 569 |
-
}
|
| 570 |
-
} catch (err) {
|
| 571 |
-
console.error('Error fetching Groq recommendation:', err);
|
| 572 |
-
}
|
| 573 |
-
}
|
| 574 |
-
|
| 575 |
-
// Fallback to static recommendations
|
| 576 |
-
const transportPct = breakdown?.transport || 45;
|
| 577 |
-
const electricityPct = breakdown?.electricity || 20;
|
| 578 |
-
const foodPct = breakdown?.food || 20;
|
| 579 |
-
|
| 580 |
-
const recs = [];
|
| 581 |
-
|
| 582 |
-
if (transportPct >= 35) {
|
| 583 |
-
recs.push({
|
| 584 |
-
title: 'Transport contributes most emissions',
|
| 585 |
-
desc: `Your transport makes up ${transportPct}% of your footprint. Switching to public transport or walking twice a week could reduce your annual emissions by approximately 120 kg CO₂.`,
|
| 586 |
-
icon: 'directions-transit',
|
| 587 |
-
color: '#ffb95f'
|
| 588 |
-
});
|
| 589 |
-
} else {
|
| 590 |
-
recs.push({
|
| 591 |
-
title: 'Eco commute recommendation',
|
| 592 |
-
desc: 'You are doing great on travel! Consider joining the No-Car Week challenge to earn 500 extra points and claim a Carbon Ninja badge.',
|
| 593 |
-
icon: 'stars',
|
| 594 |
-
color: '#4edea3'
|
| 595 |
-
});
|
| 596 |
-
}
|
| 597 |
-
|
| 598 |
-
if (electricityPct >= 20) {
|
| 599 |
-
recs.push({
|
| 600 |
-
title: 'Optimize energy consumption',
|
| 601 |
-
desc: 'Your electricity is a key footprint contributor. Replacing current lighting with smart LEDs can save another 35 kg CO₂ yearly.',
|
| 602 |
-
icon: 'lightbulb',
|
| 603 |
-
color: '#4edea3'
|
| 604 |
-
});
|
| 605 |
-
} else {
|
| 606 |
-
recs.push({
|
| 607 |
-
title: 'Solar offset suggestion',
|
| 608 |
-
desc: 'Offset your remaining electricity emissions by investing in fractional green energy grids for an estimated 15% ROI.',
|
| 609 |
-
icon: 'wb-sunny',
|
| 610 |
-
color: '#89ceff'
|
| 611 |
-
});
|
| 612 |
-
}
|
| 613 |
-
|
| 614 |
-
if (foodPct >= 20) {
|
| 615 |
-
recs.push({
|
| 616 |
-
title: 'Plant-based diet shift',
|
| 617 |
-
desc: 'Meat-related meals add significant CO₂. Transitioning to a vegetarian or vegan diet 2 days a week cuts food emissions by up to 45%.',
|
| 618 |
-
icon: 'eco',
|
| 619 |
-
color: '#89ceff'
|
| 620 |
-
});
|
| 621 |
-
} else {
|
| 622 |
-
recs.push({
|
| 623 |
-
title: 'Zero Waste shopping habit',
|
| 624 |
-
desc: 'Plastic purchases add container waste. ordering in bulk or choosing local grocery refills mitigates shopping emissions.',
|
| 625 |
-
icon: 'shopping-bag',
|
| 626 |
-
color: '#ffb95f'
|
| 627 |
-
});
|
| 628 |
-
}
|
| 629 |
-
|
| 630 |
-
return recs;
|
| 631 |
-
}
|
| 632 |
-
|
| 633 |
-
/**
|
| 634 |
-
* POST /api/ai/recommend
|
| 635 |
-
* Returns AI sustainability recommendations given a footprint weight and breakdown.
|
| 636 |
-
* @route POST /api/ai/recommend
|
| 637 |
-
* @param {Object} req.body - Footprint details
|
| 638 |
-
* @returns {Object} 200 - Object containing recommendations array
|
| 639 |
-
* @returns {Object} 500 - Internal server error
|
| 640 |
-
*/
|
| 641 |
-
router.post('/ai/recommend', async (req, res, next) => {
|
| 642 |
-
try {
|
| 643 |
-
const { footprint, breakdown } = req.body;
|
| 644 |
-
const recs = await generateRecommendations(footprint, breakdown);
|
| 645 |
-
res.json({ recommendations: recs });
|
| 646 |
-
} catch (err) {
|
| 647 |
-
next(err);
|
| 648 |
-
}
|
| 649 |
-
});
|
| 650 |
-
|
| 651 |
-
/**
|
| 652 |
-
* GET /api/challenges
|
| 653 |
-
* Retrieves list of all challenges with user-specific progress.
|
| 654 |
-
* @route GET /api/challenges
|
| 655 |
-
* @returns {Array<Object>} 200 - Array of challenge templates with user progress values
|
| 656 |
-
* @returns {Object} 500 - Internal server error
|
| 657 |
-
*/
|
| 658 |
-
router.get('/challenges', async (req, res, next) => {
|
| 659 |
-
const userId = getRequestUserId(req);
|
| 660 |
-
try {
|
| 661 |
-
const snapshot = await db.collection('users').doc(userId).collection('challenges').get();
|
| 662 |
-
const progressMap = {};
|
| 663 |
-
snapshot.docs.forEach(doc => {
|
| 664 |
-
progressMap[doc.id] = doc.data();
|
| 665 |
-
});
|
| 666 |
-
|
| 667 |
-
const userChallenges = defaultChallenges.map(ch => {
|
| 668 |
-
const saved = progressMap[ch.id];
|
| 669 |
-
if (!saved && userId === 'sarah_j') {
|
| 670 |
-
if (ch.id === '1') return { ...ch, progress: 0.60, completed: false };
|
| 671 |
-
if (ch.id === '3') return { ...ch, progress: 0.85, completed: false };
|
| 672 |
-
}
|
| 673 |
-
return {
|
| 674 |
-
...ch,
|
| 675 |
-
progress: saved ? saved.progress : 0.0,
|
| 676 |
-
completed: saved ? saved.completed : false
|
| 677 |
-
};
|
| 678 |
-
});
|
| 679 |
-
|
| 680 |
-
res.json(userChallenges);
|
| 681 |
-
} catch (err) {
|
| 682 |
-
next(err);
|
| 683 |
-
}
|
| 684 |
-
});
|
| 685 |
-
|
| 686 |
-
/**
|
| 687 |
-
* POST /api/challenges/:id/join
|
| 688 |
-
* Joins a specific challenge for the current user, setting starting progress to 5%.
|
| 689 |
-
* @route POST /api/challenges/:id/join
|
| 690 |
-
* @param {string} req.params.id - Challenge ID ('1', '2', or '3')
|
| 691 |
-
* @returns {Object} 200 - Success status payload
|
| 692 |
-
* @returns {Object} 400 - Invalid challenge ID error
|
| 693 |
-
* @returns {Object} 500 - Internal server error
|
| 694 |
-
*/
|
| 695 |
-
router.post('/challenges/:id/join', async (req, res, next) => {
|
| 696 |
-
const userId = getRequestUserId(req);
|
| 697 |
-
const chId = req.params.id;
|
| 698 |
-
if (chId !== '1' && chId !== '2' && chId !== '3') {
|
| 699 |
-
return res.status(400).json({ error: 'Invalid challenge ID parameter' });
|
| 700 |
-
}
|
| 701 |
-
try {
|
| 702 |
-
await db.collection('users').doc(userId).collection('challenges').doc(chId).set({
|
| 703 |
-
progress: 0.05,
|
| 704 |
-
completed: false
|
| 705 |
-
});
|
| 706 |
-
usersCache = null; // Invalidate cache
|
| 707 |
-
res.json({ success: true });
|
| 708 |
-
} catch (err) {
|
| 709 |
-
next(err);
|
| 710 |
-
}
|
| 711 |
-
});
|
| 712 |
-
|
| 713 |
-
/**
|
| 714 |
-
* POST /api/challenges/:id/progress
|
| 715 |
-
* Increments progress of a joined challenge by 10%, rewarding points on completion.
|
| 716 |
-
* @route POST /api/challenges/:id/progress
|
| 717 |
-
* @param {string} req.params.id - Challenge ID ('1', '2', or '3')
|
| 718 |
-
* @returns {Object} 200 - Success status, updated challenge progress, and user profile details
|
| 719 |
-
* @returns {Object} 400 - Invalid challenge ID error
|
| 720 |
-
* @returns {Object} 500 - Internal server error
|
| 721 |
-
*/
|
| 722 |
-
router.post('/challenges/:id/progress', async (req, res, next) => {
|
| 723 |
-
const userId = getRequestUserId(req);
|
| 724 |
-
const chId = req.params.id;
|
| 725 |
-
if (chId !== '1' && chId !== '2' && chId !== '3') {
|
| 726 |
-
return res.status(400).json({ error: 'Invalid challenge ID parameter' });
|
| 727 |
-
}
|
| 728 |
-
|
| 729 |
-
try {
|
| 730 |
-
const chDoc = await db.collection('users').doc(userId).collection('challenges').doc(chId).get();
|
| 731 |
-
let progress = 0.0;
|
| 732 |
-
let completed = false;
|
| 733 |
-
|
| 734 |
-
if (!chDoc.exists && userId === 'sarah_j') {
|
| 735 |
-
if (chId === '1') progress = 0.60;
|
| 736 |
-
if (chId === '3') progress = 0.85;
|
| 737 |
-
} else if (chDoc.exists) {
|
| 738 |
-
progress = chDoc.data().progress || 0.0;
|
| 739 |
-
completed = chDoc.data().completed || false;
|
| 740 |
-
}
|
| 741 |
-
|
| 742 |
-
progress = Math.min(progress + 0.10, 1.0);
|
| 743 |
-
|
| 744 |
-
const challengeDetails = defaultChallenges.find(c => c.id === chId) || {};
|
| 745 |
-
let pointsAwarded = 0;
|
| 746 |
-
let badgeEarned = null;
|
| 747 |
-
|
| 748 |
-
const userDoc = await db.collection('users').doc(userId).get();
|
| 749 |
-
const profile = userDoc.exists ? userDoc.data() : { points: 0, badges: [] };
|
| 750 |
-
|
| 751 |
-
if (progress >= 1.0 && !completed) {
|
| 752 |
-
completed = true;
|
| 753 |
-
pointsAwarded = challengeDetails.pointsValue || 100;
|
| 754 |
-
badgeEarned = challengeDetails.associatedBadge;
|
| 755 |
-
}
|
| 756 |
-
|
| 757 |
-
await db.collection('users').doc(userId).collection('challenges').doc(chId).set({
|
| 758 |
-
progress,
|
| 759 |
-
completed
|
| 760 |
-
});
|
| 761 |
-
|
| 762 |
-
const updatedPoints = (profile.points || 0) + pointsAwarded;
|
| 763 |
-
usersCache = null; // Invalidate cache
|
| 764 |
-
const currentTitle = updatedPoints >= 12000 ? 'Master Guardian' : updatedPoints >= 11000 ? 'Nature Enthusiast' : 'Eco Novice';
|
| 765 |
-
const updatedBadges = [...(profile.badges || [])];
|
| 766 |
-
if (badgeEarned && !updatedBadges.includes(badgeEarned)) {
|
| 767 |
-
updatedBadges.push(badgeEarned);
|
| 768 |
-
}
|
| 769 |
-
|
| 770 |
-
const updatedProfile = {
|
| 771 |
-
...profile,
|
| 772 |
-
points: updatedPoints,
|
| 773 |
-
title: currentTitle,
|
| 774 |
-
badges: updatedBadges
|
| 775 |
-
};
|
| 776 |
-
|
| 777 |
-
await db.collection('users').doc(userId).set(updatedProfile);
|
| 778 |
-
|
| 779 |
-
res.json({
|
| 780 |
-
success: true,
|
| 781 |
-
challenge: {
|
| 782 |
-
id: chId,
|
| 783 |
-
progress,
|
| 784 |
-
completed
|
| 785 |
-
},
|
| 786 |
-
profile: updatedProfile
|
| 787 |
-
});
|
| 788 |
-
} catch (err) {
|
| 789 |
-
next(err);
|
| 790 |
-
}
|
| 791 |
-
});
|
| 792 |
-
|
| 793 |
-
/**
|
| 794 |
-
* GET /api/leaderboard
|
| 795 |
-
* Retrieves leaderboard list sorted by points.
|
| 796 |
-
* @route GET /api/leaderboard
|
| 797 |
-
* @returns {Array<Object>} 200 - List of ranked user objects
|
| 798 |
-
* @returns {Object} 500 - Internal server error
|
| 799 |
-
*/
|
| 800 |
-
router.get('/leaderboard', async (req, res, next) => {
|
| 801 |
-
try {
|
| 802 |
-
const users = await getAllUsersCached();
|
| 803 |
-
const formattedUsers = users.map(user => ({
|
| 804 |
-
id: user.id,
|
| 805 |
-
name: user.name,
|
| 806 |
-
points: user.points,
|
| 807 |
-
avatar: user.avatar,
|
| 808 |
-
title: user.title,
|
| 809 |
-
}));
|
| 810 |
-
|
| 811 |
-
// Sort leaderboard desc
|
| 812 |
-
formattedUsers.sort((a, b) => b.points - a.points);
|
| 813 |
-
|
| 814 |
-
// Format response
|
| 815 |
-
const rankedLeaderboard = formattedUsers.map((user, index) => {
|
| 816 |
-
const rank = index + 1;
|
| 817 |
-
let borderColor = 'rgba(255,255,255,0.08)';
|
| 818 |
-
let bg = 'rgba(255, 255, 255, 0.03)';
|
| 819 |
-
if (rank === 1) {
|
| 820 |
-
borderColor = '#4edea3';
|
| 821 |
-
bg = 'rgba(78, 222, 163, 0.08)';
|
| 822 |
-
} else if (rank === 2) {
|
| 823 |
-
borderColor = '#89ceff';
|
| 824 |
-
}
|
| 825 |
-
return {
|
| 826 |
-
id: user.id,
|
| 827 |
-
rank,
|
| 828 |
-
name: user.name,
|
| 829 |
-
title: user.title,
|
| 830 |
-
points: user.points,
|
| 831 |
-
avatar: user.avatar,
|
| 832 |
-
borderColor,
|
| 833 |
-
bg
|
| 834 |
-
};
|
| 835 |
-
});
|
| 836 |
-
|
| 837 |
-
res.json(rankedLeaderboard);
|
| 838 |
-
} catch (err) {
|
| 839 |
-
next(err);
|
| 840 |
-
}
|
| 841 |
-
});
|
| 842 |
-
|
| 843 |
-
/**
|
| 844 |
-
* GET /api/users
|
| 845 |
-
* Retrieves simple metadata objects for all registered platform users.
|
| 846 |
-
* @route GET /api/users
|
| 847 |
-
* @returns {Array<Object>} 200 - Array of user metadata items
|
| 848 |
-
* @returns {Object} 500 - Internal server error
|
| 849 |
-
*/
|
| 850 |
-
router.get('/users', async (req, res, next) => {
|
| 851 |
-
try {
|
| 852 |
-
const users = await getAllUsersCached();
|
| 853 |
-
const usersList = users.map(user => ({
|
| 854 |
-
id: user.id,
|
| 855 |
-
name: user.name,
|
| 856 |
-
email: user.email,
|
| 857 |
-
avatar: user.avatar,
|
| 858 |
-
points: user.points,
|
| 859 |
-
title: user.title,
|
| 860 |
-
}));
|
| 861 |
-
res.json(usersList);
|
| 862 |
-
} catch (err) {
|
| 863 |
-
next(err);
|
| 864 |
-
}
|
| 865 |
-
});
|
| 866 |
-
|
| 867 |
-
/**
|
| 868 |
-
* POST /api/users
|
| 869 |
-
* Creates / registers a new user profile on the platform, resolving unique slug IDs.
|
| 870 |
-
* @route POST /api/users
|
| 871 |
-
* @param {Object} req.body - Payload specifying 'name' and optional 'email'
|
| 872 |
-
* @returns {Object} 200 - Registration success payload containing the generated userId and profile
|
| 873 |
-
* @returns {Object} 400 - Validation error payload
|
| 874 |
-
* @returns {Object} 500 - Internal server error
|
| 875 |
-
*/
|
| 876 |
-
router.post('/users', async (req, res, next) => {
|
| 877 |
-
const { name, email } = req.body;
|
| 878 |
-
|
| 879 |
-
if (!name || typeof name !== 'string' || !name.trim()) {
|
| 880 |
-
return res.status(400).json({ error: 'Name is required and must be a valid string' });
|
| 881 |
-
}
|
| 882 |
-
|
| 883 |
-
if (name.length > 50) {
|
| 884 |
-
return res.status(400).json({ error: 'Name must be under 50 characters' });
|
| 885 |
-
}
|
| 886 |
-
|
| 887 |
-
// Sanitize name to prevent XSS
|
| 888 |
-
const sanitizedName = name.replace(/<[^>]*>/g, '').trim();
|
| 889 |
-
if (!sanitizedName) {
|
| 890 |
-
return res.status(400).json({ error: 'Invalid name characters' });
|
| 891 |
-
}
|
| 892 |
-
|
| 893 |
-
// If email is provided, validate its format
|
| 894 |
-
let validatedEmail = email || '';
|
| 895 |
-
if (validatedEmail) {
|
| 896 |
-
if (typeof validatedEmail !== 'string') {
|
| 897 |
-
return res.status(400).json({ error: 'Email must be a valid string' });
|
| 898 |
-
}
|
| 899 |
-
if (validatedEmail.length > 100) {
|
| 900 |
-
return res.status(400).json({ error: 'Email must be under 100 characters' });
|
| 901 |
-
}
|
| 902 |
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
| 903 |
-
if (!emailRegex.test(validatedEmail.trim())) {
|
| 904 |
-
return res.status(400).json({ error: 'Invalid email address format' });
|
| 905 |
-
}
|
| 906 |
-
validatedEmail = validatedEmail.trim();
|
| 907 |
-
}
|
| 908 |
-
|
| 909 |
-
try {
|
| 910 |
-
const trimmedName = sanitizedName;
|
| 911 |
-
|
| 912 |
-
// Generate unique slug ID
|
| 913 |
-
let baseId = trimmedName.toLowerCase()
|
| 914 |
-
.replace(/[^\w\s-]/g, '')
|
| 915 |
-
.replace(/[\s_-]+/g, '_')
|
| 916 |
-
.replace(/^-+|-+$/g, '');
|
| 917 |
-
|
| 918 |
-
if (!baseId) baseId = 'user';
|
| 919 |
-
|
| 920 |
-
let userId = baseId;
|
| 921 |
-
let counter = 1;
|
| 922 |
-
let docRef = db.collection('users').doc(userId);
|
| 923 |
-
let docSnap = await docRef.get();
|
| 924 |
-
|
| 925 |
-
while (docSnap.exists) {
|
| 926 |
-
userId = `${baseId}_${counter}`;
|
| 927 |
-
docRef = db.collection('users').doc(userId);
|
| 928 |
-
docSnap = await docRef.get();
|
| 929 |
-
counter++;
|
| 930 |
-
}
|
| 931 |
-
|
| 932 |
-
const avatarUrl = `https://api.dicebear.com/7.x/avataaars/png?seed=${encodeURIComponent(trimmedName)}`;
|
| 933 |
-
|
| 934 |
-
const newProfile = {
|
| 935 |
-
email: validatedEmail || `${userId}@example.com`,
|
| 936 |
-
name: trimmedName,
|
| 937 |
-
avatar: avatarUrl,
|
| 938 |
-
title: 'Eco Novice',
|
| 939 |
-
points: 0,
|
| 940 |
-
badges: [],
|
| 941 |
-
percentages: { transport: 20, electricity: 20, food: 20, waste: 20, shopping: 20 },
|
| 942 |
-
complianceRate: '80.0%',
|
| 943 |
-
totalMonthly: 0
|
| 944 |
-
};
|
| 945 |
-
|
| 946 |
-
await db.collection('users').doc(userId).set(newProfile);
|
| 947 |
-
usersCache = null; // Invalidate cache
|
| 948 |
-
|
| 949 |
-
res.json({
|
| 950 |
-
success: true,
|
| 951 |
-
userId: userId,
|
| 952 |
-
profile: newProfile
|
| 953 |
-
});
|
| 954 |
-
} catch (err) {
|
| 955 |
-
console.error('Error creating user:', err);
|
| 956 |
-
next(err);
|
| 957 |
-
}
|
| 958 |
-
});
|
| 959 |
-
|
| 960 |
-
/**
|
| 961 |
-
* POST /api/offsets/purchase
|
| 962 |
-
* Purchases a carbon offset package, rewarding points to the buyer.
|
| 963 |
-
* @route POST /api/offsets/purchase
|
| 964 |
-
* @param {Object} req.body - Details containing offsetId and cost
|
| 965 |
-
* @returns {Object} 200 - Purchase success payload and updated profile points details
|
| 966 |
-
* @returns {Object} 400 - Validation error payload
|
| 967 |
-
* @returns {Object} 500 - Internal server error
|
| 968 |
-
*/
|
| 969 |
-
router.post('/offsets/purchase', async (req, res, next) => {
|
| 970 |
-
const userId = getRequestUserId(req);
|
| 971 |
-
const { offsetId, cost } = req.body;
|
| 972 |
-
|
| 973 |
-
if (offsetId !== '1' && offsetId !== '2' && offsetId !== '3' && offsetId !== '4') {
|
| 974 |
-
return res.status(400).json({ error: 'Invalid offset ID parameter' });
|
| 975 |
-
}
|
| 976 |
-
if (typeof cost !== 'number' || cost <= 0 || isNaN(cost)) {
|
| 977 |
-
return res.status(400).json({ error: 'Cost must be a valid positive number' });
|
| 978 |
-
}
|
| 979 |
-
|
| 980 |
-
try {
|
| 981 |
-
const pointsReward = Math.max(Math.round((cost || 10) * 2.5), 50);
|
| 982 |
-
|
| 983 |
-
const userDoc = await db.collection('users').doc(userId).get();
|
| 984 |
-
const profile = userDoc.exists ? userDoc.data() : { points: 0 };
|
| 985 |
-
|
| 986 |
-
const updatedPoints = (profile.points || 0) + pointsReward;
|
| 987 |
-
const currentTitle = updatedPoints >= 12000 ? 'Master Guardian' : updatedPoints >= 11000 ? 'Nature Enthusiast' : 'Eco Novice';
|
| 988 |
-
|
| 989 |
-
const updatedProfile = {
|
| 990 |
-
...profile,
|
| 991 |
-
points: updatedPoints,
|
| 992 |
-
title: currentTitle
|
| 993 |
-
};
|
| 994 |
-
|
| 995 |
-
await db.collection('users').doc(userId).set(updatedProfile);
|
| 996 |
-
usersCache = null; // Invalidate cache
|
| 997 |
-
|
| 998 |
-
res.json({
|
| 999 |
-
success: true,
|
| 1000 |
-
pointsReward,
|
| 1001 |
-
profile: updatedProfile
|
| 1002 |
-
});
|
| 1003 |
-
} catch (err) {
|
| 1004 |
-
next(err);
|
| 1005 |
-
}
|
| 1006 |
-
});
|
| 1007 |
-
|
| 1008 |
-
module.exports = router;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/server.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
| 1 |
-
const express = require('express');
|
| 2 |
-
const cors = require('cors');
|
| 3 |
-
const morgan = require('morgan');
|
| 4 |
-
const helmet = require('helmet');
|
| 5 |
-
const rateLimit = require('express-rate-limit');
|
| 6 |
-
const hpp = require('hpp');
|
| 7 |
-
const compression = require('compression');
|
| 8 |
-
const routes = require('./routes');
|
| 9 |
-
|
| 10 |
-
const app = express();
|
| 11 |
-
const PORT = process.env.PORT || 5000;
|
| 12 |
-
|
| 13 |
-
// 1. Enable GZIP compression to reduce network payload size
|
| 14 |
-
app.use(compression());
|
| 15 |
-
|
| 16 |
-
// 2. Enable Helmet with forced HSTS and Referrer policy for secure headers
|
| 17 |
-
app.use(helmet({
|
| 18 |
-
crossOriginResourcePolicy: { policy: "cross-origin" },
|
| 19 |
-
hsts: {
|
| 20 |
-
maxAge: 31536000,
|
| 21 |
-
includeSubDomains: true,
|
| 22 |
-
preload: true
|
| 23 |
-
},
|
| 24 |
-
referrerPolicy: { policy: "no-referrer" }
|
| 25 |
-
}));
|
| 26 |
-
|
| 27 |
-
// 3. HTTP Parameter Pollution (HPP) protection
|
| 28 |
-
app.use(hpp());
|
| 29 |
-
|
| 30 |
-
// 4. Rate limiting to prevent brute force / DoS attacks
|
| 31 |
-
const limiter = rateLimit({
|
| 32 |
-
windowMs: 15 * 60 * 1000, // 15 minutes
|
| 33 |
-
max: 100, // Limit each IP to 100 requests per windowMs
|
| 34 |
-
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
| 35 |
-
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
| 36 |
-
message: { error: 'Too many requests from this IP, please try again after 15 minutes' }
|
| 37 |
-
});
|
| 38 |
-
app.use('/api', limiter);
|
| 39 |
-
|
| 40 |
-
// 5. Safe CORS options to prevent wildcard vulnerability exposure
|
| 41 |
-
const corsOptions = {
|
| 42 |
-
origin: '*',
|
| 43 |
-
methods: ['GET', 'POST', 'OPTIONS'],
|
| 44 |
-
allowedHeaders: ['Content-Type', 'Authorization', 'x-user-id'],
|
| 45 |
-
credentials: true,
|
| 46 |
-
maxAge: 86400
|
| 47 |
-
};
|
| 48 |
-
app.use(cors(corsOptions));
|
| 49 |
-
|
| 50 |
-
// 6. Limit request JSON body size to prevent payload overflow attacks
|
| 51 |
-
app.use(express.json({ limit: '10kb' }));
|
| 52 |
-
|
| 53 |
-
// Parse request bodies
|
| 54 |
-
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
|
| 55 |
-
|
| 56 |
-
// Log incoming request details for debugging
|
| 57 |
-
app.use(morgan('dev'));
|
| 58 |
-
|
| 59 |
-
// Root endpoint for health check & status checks
|
| 60 |
-
app.get('/', (req, res) => {
|
| 61 |
-
res.json({
|
| 62 |
-
status: 'online',
|
| 63 |
-
service: 'EcoTrack AI Carbon Accounting API',
|
| 64 |
-
version: '1.0.0',
|
| 65 |
-
database: process.env.FIREBASE_SERVICE_ACCOUNT ? 'firestore' : 'local-mock'
|
| 66 |
-
});
|
| 67 |
-
});
|
| 68 |
-
|
| 69 |
-
// Mount our router on /api
|
| 70 |
-
app.use('/api', routes);
|
| 71 |
-
|
| 72 |
-
// 7. Global Error Handling Middleware (prevents internal stack trace leakage)
|
| 73 |
-
app.use((err, req, res, next) => {
|
| 74 |
-
console.error('Unhandled Server Error:', err);
|
| 75 |
-
res.status(500).json({
|
| 76 |
-
error: 'An unexpected internal server error occurred. Please try again later.'
|
| 77 |
-
});
|
| 78 |
-
});
|
| 79 |
-
|
| 80 |
-
// Start server (only if not running inside a test runner)
|
| 81 |
-
if (process.env.NODE_ENV !== 'test') {
|
| 82 |
-
app.listen(PORT, '0.0.0.0', () => {
|
| 83 |
-
console.log(`=========================================`);
|
| 84 |
-
console.log(` EcoTrack AI Backend Service Running`);
|
| 85 |
-
console.log(` URL: http://localhost:${PORT}`);
|
| 86 |
-
console.log(`=========================================`);
|
| 87 |
-
});
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
module.exports = app;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/src/app.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express from 'express';
|
| 2 |
+
import cors from 'cors';
|
| 3 |
+
import morgan from 'morgan';
|
| 4 |
+
import helmet from 'helmet';
|
| 5 |
+
import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
|
| 6 |
+
import { apiLimiter } from './middleware/rateLimiter.js';
|
| 7 |
+
import userRoutes from './routes/users.js';
|
| 8 |
+
import assessmentRoutes from './routes/assessments.js';
|
| 9 |
+
import recommendationRoutes from './routes/recommendations.js';
|
| 10 |
+
import simulationRoutes from './routes/simulations.js';
|
| 11 |
+
import aiRoutes from './routes/ai.js';
|
| 12 |
+
|
| 13 |
+
const app = express();
|
| 14 |
+
|
| 15 |
+
// Trust Railway/Vercel reverse proxy for accurate IP and HTTPS
|
| 16 |
+
app.set('trust proxy', 1);
|
| 17 |
+
|
| 18 |
+
// ── Security Headers (Helmet) ─────────────────────────────────────────────────
|
| 19 |
+
// BACKEND_PUBLIC_URL: the public URL of this API server.
|
| 20 |
+
// Keeping it in an env var means the CSP isn't hard-coded to one Railway deployment.
|
| 21 |
+
// Falls back to the original Railway URL so existing deployments keep working.
|
| 22 |
+
const backendPublicUrl =
|
| 23 |
+
process.env.BACKEND_PUBLIC_URL || 'https://carbon-production-49fd.up.railway.app';
|
| 24 |
+
|
| 25 |
+
app.use(
|
| 26 |
+
helmet({
|
| 27 |
+
contentSecurityPolicy: {
|
| 28 |
+
directives: {
|
| 29 |
+
defaultSrc: ["'self'"],
|
| 30 |
+
scriptSrc: ["'self'"],
|
| 31 |
+
// NOTE: 'unsafe-inline' is required for styleSrc to allow:
|
| 32 |
+
// 1. Vite's dynamic style injection in development.
|
| 33 |
+
// 2. Tailwind's utility class injection at runtime.
|
| 34 |
+
// 3. Google Fonts dynamic CSS stylesheet generation.
|
| 35 |
+
styleSrc: ["'self'", 'https://fonts.googleapis.com', "'unsafe-inline'"],
|
| 36 |
+
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
| 37 |
+
connectSrc: ["'self'", backendPublicUrl, '*.vercel.app', 'http://localhost:*'],
|
| 38 |
+
imgSrc: ["'self'", 'data:'],
|
| 39 |
+
objectSrc: ["'none'"],
|
| 40 |
+
frameSrc: ["'none'"],
|
| 41 |
+
upgradeInsecureRequests: [],
|
| 42 |
+
},
|
| 43 |
+
},
|
| 44 |
+
crossOriginEmbedderPolicy: false,
|
| 45 |
+
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' },
|
| 46 |
+
crossOriginResourcePolicy: { policy: 'same-origin' },
|
| 47 |
+
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
|
| 48 |
+
noSniff: true,
|
| 49 |
+
// xssFilter removed — deprecated in Helmet 7 and no longer effective in modern browsers.
|
| 50 |
+
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
| 51 |
+
// permittedCrossDomainPolicies removed — was a no-op (false is not a valid option).
|
| 52 |
+
frameguard: { action: 'deny' },
|
| 53 |
+
})
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
// ── Permissions-Policy Header ───────────────────────────────────────────────
|
| 57 |
+
// Restrict access to sensitive browser APIs that this application does not use.
|
| 58 |
+
app.use((req, res, next) => {
|
| 59 |
+
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
|
| 60 |
+
next();
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
// ── CORS ─────────────────────────────────────────────────────────────────────
|
| 64 |
+
const corsOrigin = process.env.CORS_ORIGIN || 'http://localhost:5173';
|
| 65 |
+
const corsOrigins = corsOrigin
|
| 66 |
+
.split(',')
|
| 67 |
+
.map((o) => o.trim())
|
| 68 |
+
.filter(Boolean);
|
| 69 |
+
app.use(
|
| 70 |
+
cors({
|
| 71 |
+
origin: (origin, callback) => {
|
| 72 |
+
if (!origin) return callback(null, true);
|
| 73 |
+
if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')) {
|
| 74 |
+
return callback(null, true);
|
| 75 |
+
}
|
| 76 |
+
const isAllowed =
|
| 77 |
+
corsOrigins.some((allowed) => origin === allowed) || origin.endsWith('.vercel.app');
|
| 78 |
+
callback(null, isAllowed);
|
| 79 |
+
},
|
| 80 |
+
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
| 81 |
+
allowedHeaders: ['Content-Type', 'Authorization'],
|
| 82 |
+
credentials: true,
|
| 83 |
+
})
|
| 84 |
+
);
|
| 85 |
+
|
| 86 |
+
// ── Body Parsing ──────────────────────────────────────────────────────────────
|
| 87 |
+
app.use(express.json({ limit: '100kb' }));
|
| 88 |
+
app.use(express.urlencoded({ extended: true, limit: '100kb' }));
|
| 89 |
+
|
| 90 |
+
// ── Logging ───────────────────────────────────────────────────────────────────
|
| 91 |
+
if (process.env.NODE_ENV !== 'test') {
|
| 92 |
+
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// ── Rate Limiting ─────────────────────────────────────────────────────────────
|
| 96 |
+
app.use('/api/', apiLimiter);
|
| 97 |
+
|
| 98 |
+
// ── Health Check ──────────────────────────────────────────────────────────────
|
| 99 |
+
app.get('/health', (req, res) => {
|
| 100 |
+
res.json({
|
| 101 |
+
status: 'ok',
|
| 102 |
+
timestamp: new Date().toISOString(),
|
| 103 |
+
environment: process.env.NODE_ENV || 'development',
|
| 104 |
+
version: '1.0.0',
|
| 105 |
+
});
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
// ── API Routes ────────────────────────────────────────────────────────────────
|
| 109 |
+
app.use('/api/users', userRoutes);
|
| 110 |
+
app.use('/api/assessments', assessmentRoutes);
|
| 111 |
+
app.use('/api/recommendations', recommendationRoutes);
|
| 112 |
+
app.use('/api/simulations', simulationRoutes);
|
| 113 |
+
app.use('/api/ai', aiRoutes);
|
| 114 |
+
|
| 115 |
+
// ── 404 & Error Handling ──────────────────────────────────────────────────────
|
| 116 |
+
app.use(notFoundHandler);
|
| 117 |
+
app.use(errorHandler);
|
| 118 |
+
|
| 119 |
+
export default app;
|
backend/src/controllers/assessmentController.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from 'zod';
|
| 2 |
+
import db from '../utils/firebaseClient.js';
|
| 3 |
+
import { compareToAverages } from '../services/scoringService.js';
|
| 4 |
+
import { createAssessmentWithRecommendations } from '../services/assessmentService.js';
|
| 5 |
+
import { AppError } from '../middleware/errorHandler.js';
|
| 6 |
+
import { sanitizeText } from '../utils/sanitize.js';
|
| 7 |
+
|
| 8 |
+
const assessmentSchema = z
|
| 9 |
+
.object({
|
| 10 |
+
userId: z.string().min(1, 'userId is required'),
|
| 11 |
+
// Transportation
|
| 12 |
+
dailyCarKm: z.number().min(0).max(1000).default(0),
|
| 13 |
+
carFuelType: z.enum(['petrol', 'diesel', 'electric', 'hybrid', 'none']).default('none'),
|
| 14 |
+
publicTransportKmPerWeek: z.number().min(0).max(10000).default(0),
|
| 15 |
+
cyclingKmPerWeek: z.number().min(0).max(1000).default(0),
|
| 16 |
+
shortFlightsPerYear: z.number().int().min(0).max(100).default(0),
|
| 17 |
+
longFlightsPerYear: z.number().int().min(0).max(50).default(0),
|
| 18 |
+
// Energy
|
| 19 |
+
monthlyElectricityKwh: z.number().min(0).max(10000).default(0),
|
| 20 |
+
renewablePercentage: z.number().min(0).max(100).default(0),
|
| 21 |
+
// Food
|
| 22 |
+
dietType: z.enum(['vegan', 'vegetarian', 'mixed', 'heavy_meat']).default('mixed'),
|
| 23 |
+
// Shopping
|
| 24 |
+
clothingItemsPerYear: z.number().int().min(0).max(500).default(0),
|
| 25 |
+
electronicsItemsPerYear: z.number().int().min(0).max(50).default(0),
|
| 26 |
+
})
|
| 27 |
+
.strict();
|
| 28 |
+
|
| 29 |
+
const PERCENTAGE_MULTIPLIER = 100;
|
| 30 |
+
|
| 31 |
+
function buildUnknownKeysMessage(zodError, allowedKeys) {
|
| 32 |
+
const unknownKeyIssues = zodError.issues.filter((i) => i.code === 'unrecognized_keys');
|
| 33 |
+
if (unknownKeyIssues.length === 0) return zodError.message;
|
| 34 |
+
|
| 35 |
+
const unknownKeys = unknownKeyIssues.flatMap((i) => i.keys);
|
| 36 |
+
return (
|
| 37 |
+
`Unknown fields: ${unknownKeys.join(', ')}. ` +
|
| 38 |
+
`Only these fields are accepted: ${allowedKeys.join(', ')}`
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const ALLOWED_KEYS = Object.keys(assessmentSchema._def.shape());
|
| 43 |
+
|
| 44 |
+
export const createAssessment = async (req, res, next) => {
|
| 45 |
+
try {
|
| 46 |
+
const parseResult = assessmentSchema.safeParse(req.body);
|
| 47 |
+
if (!parseResult.success) {
|
| 48 |
+
const message = buildUnknownKeysMessage(parseResult.error, ALLOWED_KEYS);
|
| 49 |
+
throw new AppError(message, 400);
|
| 50 |
+
}
|
| 51 |
+
const data = parseResult.data;
|
| 52 |
+
data.userId = sanitizeText(data.userId);
|
| 53 |
+
|
| 54 |
+
const { assessment, emissions, score } = await createAssessmentWithRecommendations(data);
|
| 55 |
+
|
| 56 |
+
res.status(201).json({
|
| 57 |
+
success: true,
|
| 58 |
+
data: {
|
| 59 |
+
...assessment,
|
| 60 |
+
breakdown: emissions.breakdown,
|
| 61 |
+
scoreInfo: { score, ...emissions },
|
| 62 |
+
},
|
| 63 |
+
});
|
| 64 |
+
} catch (err) {
|
| 65 |
+
next(err);
|
| 66 |
+
}
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
export const getAssessmentById = async (req, res, next) => {
|
| 70 |
+
try {
|
| 71 |
+
const { id } = req.params;
|
| 72 |
+
const sanitizedId = sanitizeText(id);
|
| 73 |
+
|
| 74 |
+
const assessmentDoc = await db.collection('assessments').doc(sanitizedId).get();
|
| 75 |
+
if (!assessmentDoc.exists) throw new AppError('Assessment not found', 404);
|
| 76 |
+
|
| 77 |
+
const assessment = assessmentDoc.data();
|
| 78 |
+
|
| 79 |
+
// Fetch recommendations
|
| 80 |
+
const recsSnap = await db
|
| 81 |
+
.collection('recommendations')
|
| 82 |
+
.where('assessmentId', '==', sanitizedId)
|
| 83 |
+
.get();
|
| 84 |
+
const recommendations = recsSnap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
|
| 85 |
+
recommendations.sort((a, b) => (b.estimatedSavings || 0) - (a.estimatedSavings || 0));
|
| 86 |
+
|
| 87 |
+
// Fetch simulations
|
| 88 |
+
const simsSnap = await db
|
| 89 |
+
.collection('simulations')
|
| 90 |
+
.where('assessmentId', '==', sanitizedId)
|
| 91 |
+
.get();
|
| 92 |
+
const simulations = simsSnap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
|
| 93 |
+
simulations.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
| 94 |
+
|
| 95 |
+
// Fetch user
|
| 96 |
+
const userDoc = await db.collection('users').doc(assessment.userId).get();
|
| 97 |
+
const user = userDoc.exists
|
| 98 |
+
? { id: userDoc.id, name: userDoc.data().name, email: userDoc.data().email }
|
| 99 |
+
: null;
|
| 100 |
+
|
| 101 |
+
const total = assessment.totalEmission;
|
| 102 |
+
const safeTotal = total === 0 ? 1 : total;
|
| 103 |
+
|
| 104 |
+
res.json({
|
| 105 |
+
success: true,
|
| 106 |
+
data: {
|
| 107 |
+
...assessment,
|
| 108 |
+
id: sanitizedId,
|
| 109 |
+
recommendations,
|
| 110 |
+
simulations,
|
| 111 |
+
user,
|
| 112 |
+
breakdown: {
|
| 113 |
+
transport: parseFloat(
|
| 114 |
+
((assessment.transportEmission / safeTotal) * PERCENTAGE_MULTIPLIER).toFixed(1)
|
| 115 |
+
),
|
| 116 |
+
energy: parseFloat(
|
| 117 |
+
((assessment.energyEmission / safeTotal) * PERCENTAGE_MULTIPLIER).toFixed(1)
|
| 118 |
+
),
|
| 119 |
+
food: parseFloat(
|
| 120 |
+
((assessment.foodEmission / safeTotal) * PERCENTAGE_MULTIPLIER).toFixed(1)
|
| 121 |
+
),
|
| 122 |
+
shopping: parseFloat(
|
| 123 |
+
((assessment.shoppingEmission / safeTotal) * PERCENTAGE_MULTIPLIER).toFixed(1)
|
| 124 |
+
),
|
| 125 |
+
},
|
| 126 |
+
},
|
| 127 |
+
});
|
| 128 |
+
} catch (err) {
|
| 129 |
+
next(err);
|
| 130 |
+
}
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
export const getAssessmentsByUser = async (req, res, next) => {
|
| 134 |
+
try {
|
| 135 |
+
const { userId } = req.params;
|
| 136 |
+
const sanitizedUserId = sanitizeText(userId);
|
| 137 |
+
|
| 138 |
+
const userDoc = await db.collection('users').doc(sanitizedUserId).get();
|
| 139 |
+
if (!userDoc.exists) throw new AppError('User not found', 404);
|
| 140 |
+
|
| 141 |
+
const assessmentsSnap = await db
|
| 142 |
+
.collection('assessments')
|
| 143 |
+
.where('userId', '==', sanitizedUserId)
|
| 144 |
+
.get();
|
| 145 |
+
const assessments = assessmentsSnap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
|
| 146 |
+
|
| 147 |
+
assessments.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
| 148 |
+
|
| 149 |
+
for (const assessment of assessments) {
|
| 150 |
+
const recsSnap = await db
|
| 151 |
+
.collection('recommendations')
|
| 152 |
+
.where('assessmentId', '==', assessment.id)
|
| 153 |
+
.get();
|
| 154 |
+
const recommendations = recsSnap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
|
| 155 |
+
recommendations.sort((a, b) => (b.estimatedSavings || 0) - (a.estimatedSavings || 0));
|
| 156 |
+
assessment.recommendations = recommendations.slice(0, 3);
|
| 157 |
+
|
| 158 |
+
const simsSnap = await db
|
| 159 |
+
.collection('simulations')
|
| 160 |
+
.where('assessmentId', '==', assessment.id)
|
| 161 |
+
.get();
|
| 162 |
+
assessment._count = { simulations: simsSnap.docs.length };
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
res.json({ success: true, data: assessments, count: assessments.length });
|
| 166 |
+
} catch (err) {
|
| 167 |
+
next(err);
|
| 168 |
+
}
|
| 169 |
+
};
|
| 170 |
+
|
| 171 |
+
export const compareAssessment = async (req, res, next) => {
|
| 172 |
+
try {
|
| 173 |
+
const { id } = req.params;
|
| 174 |
+
const sanitizedId = sanitizeText(id);
|
| 175 |
+
|
| 176 |
+
const assessmentDoc = await db.collection('assessments').doc(sanitizedId).get();
|
| 177 |
+
if (!assessmentDoc.exists) throw new AppError('Assessment not found', 404);
|
| 178 |
+
|
| 179 |
+
const assessment = assessmentDoc.data();
|
| 180 |
+
const comparison = compareToAverages(assessment.totalEmission);
|
| 181 |
+
|
| 182 |
+
res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');
|
| 183 |
+
|
| 184 |
+
res.json({
|
| 185 |
+
success: true,
|
| 186 |
+
data: {
|
| 187 |
+
assessmentId: sanitizedId,
|
| 188 |
+
totalEmission: assessment.totalEmission,
|
| 189 |
+
sustainabilityScore: assessment.sustainabilityScore,
|
| 190 |
+
comparison,
|
| 191 |
+
},
|
| 192 |
+
});
|
| 193 |
+
} catch (err) {
|
| 194 |
+
next(err);
|
| 195 |
+
}
|
| 196 |
+
};
|
backend/src/controllers/recommendationController.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import db from '../utils/firebaseClient.js';
|
| 2 |
+
import { AppError } from '../middleware/errorHandler.js';
|
| 3 |
+
|
| 4 |
+
export const getRecommendations = async (req, res, next) => {
|
| 5 |
+
try {
|
| 6 |
+
const { assessmentId } = req.params;
|
| 7 |
+
|
| 8 |
+
const assessmentDoc = await db.collection('assessments').doc(assessmentId).get();
|
| 9 |
+
if (!assessmentDoc.exists) throw new AppError('Assessment not found', 404);
|
| 10 |
+
|
| 11 |
+
const recommendationsSnap = await db
|
| 12 |
+
.collection('recommendations')
|
| 13 |
+
.where('assessmentId', '==', assessmentId)
|
| 14 |
+
.get();
|
| 15 |
+
|
| 16 |
+
const recommendations = recommendationsSnap.docs.map((doc) => ({
|
| 17 |
+
id: doc.id,
|
| 18 |
+
...doc.data(),
|
| 19 |
+
}));
|
| 20 |
+
|
| 21 |
+
const priorityOrder = { HIGH: 1, MEDIUM: 2, LOW: 3 };
|
| 22 |
+
recommendations.sort((a, b) => {
|
| 23 |
+
const pA = priorityOrder[a.priority] || 99;
|
| 24 |
+
const pB = priorityOrder[b.priority] || 99;
|
| 25 |
+
if (pA !== pB) return pA - pB;
|
| 26 |
+
return (b.estimatedSavings || 0) - (a.estimatedSavings || 0);
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
res.json({ success: true, data: recommendations, count: recommendations.length });
|
| 30 |
+
} catch (err) {
|
| 31 |
+
next(err);
|
| 32 |
+
}
|
| 33 |
+
};
|
backend/src/controllers/simulationController.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from 'zod';
|
| 2 |
+
import db from '../utils/firebaseClient.js';
|
| 3 |
+
import { runSimulation } from '../services/simulationService.js';
|
| 4 |
+
import { AppError } from '../middleware/errorHandler.js';
|
| 5 |
+
import { sanitizeText } from '../utils/sanitize.js';
|
| 6 |
+
import crypto from 'crypto';
|
| 7 |
+
|
| 8 |
+
const simulationSchema = z.object({
|
| 9 |
+
assessmentId: z.string().min(1, 'assessmentId is required'),
|
| 10 |
+
scenarioName: z.string().min(1, 'scenarioName is required').max(100),
|
| 11 |
+
scenarioParams: z.record(z.union([z.string(), z.number(), z.boolean()])),
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
export const createSimulation = async (req, res, next) => {
|
| 15 |
+
try {
|
| 16 |
+
const data = simulationSchema.parse(req.body);
|
| 17 |
+
data.assessmentId = sanitizeText(data.assessmentId);
|
| 18 |
+
data.scenarioName = sanitizeText(data.scenarioName);
|
| 19 |
+
|
| 20 |
+
const assessmentDoc = await db.collection('assessments').doc(data.assessmentId).get();
|
| 21 |
+
if (!assessmentDoc.exists) throw new AppError('Assessment not found', 404);
|
| 22 |
+
|
| 23 |
+
const assessment = assessmentDoc.data();
|
| 24 |
+
|
| 25 |
+
const assessmentData = {
|
| 26 |
+
dailyCarKm: assessment.dailyCarKm,
|
| 27 |
+
carFuelType: assessment.carFuelType,
|
| 28 |
+
publicTransportKmPerWeek: assessment.publicTransportKmPerWeek,
|
| 29 |
+
cyclingKmPerWeek: assessment.cyclingKmPerWeek,
|
| 30 |
+
shortFlightsPerYear: assessment.shortFlightsPerYear,
|
| 31 |
+
longFlightsPerYear: assessment.longFlightsPerYear,
|
| 32 |
+
monthlyElectricityKwh: assessment.monthlyElectricityKwh,
|
| 33 |
+
renewablePercentage: assessment.renewablePercentage,
|
| 34 |
+
dietType: assessment.dietType,
|
| 35 |
+
clothingItemsPerYear: assessment.clothingItemsPerYear,
|
| 36 |
+
electronicsItemsPerYear: assessment.electronicsItemsPerYear,
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const result = runSimulation(assessmentData, data.scenarioParams, data.scenarioName);
|
| 40 |
+
|
| 41 |
+
const simId = crypto.randomUUID();
|
| 42 |
+
const simulation = {
|
| 43 |
+
id: simId,
|
| 44 |
+
assessmentId: data.assessmentId,
|
| 45 |
+
scenarioName: result.scenarioName,
|
| 46 |
+
scenarioParams: result.scenarioParams,
|
| 47 |
+
originalEmission: result.originalEmission,
|
| 48 |
+
projectedEmission: result.projectedEmission,
|
| 49 |
+
reductionPercentage: result.reductionPercentage,
|
| 50 |
+
annualSavingsKg: result.annualSavingsKg,
|
| 51 |
+
createdAt: new Date().toISOString(),
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
await db.collection('simulations').doc(simId).set(simulation);
|
| 55 |
+
|
| 56 |
+
res.status(201).json({ success: true, data: { ...simulation, ...result } });
|
| 57 |
+
} catch (err) {
|
| 58 |
+
next(err);
|
| 59 |
+
}
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
export const getSimulationById = async (req, res, next) => {
|
| 63 |
+
try {
|
| 64 |
+
const { id } = req.params;
|
| 65 |
+
const sanitizedId = sanitizeText(id);
|
| 66 |
+
|
| 67 |
+
const simulationDoc = await db.collection('simulations').doc(sanitizedId).get();
|
| 68 |
+
if (!simulationDoc.exists) throw new AppError('Simulation not found', 404);
|
| 69 |
+
|
| 70 |
+
const simulation = simulationDoc.data();
|
| 71 |
+
|
| 72 |
+
const assessmentDoc = await db.collection('assessments').doc(simulation.assessmentId).get();
|
| 73 |
+
const assessment = assessmentDoc.exists ? assessmentDoc.data() : null;
|
| 74 |
+
|
| 75 |
+
res.json({
|
| 76 |
+
success: true,
|
| 77 |
+
data: {
|
| 78 |
+
...simulation,
|
| 79 |
+
id: sanitizedId,
|
| 80 |
+
assessment: assessment
|
| 81 |
+
? {
|
| 82 |
+
id: assessment.id || simulation.assessmentId,
|
| 83 |
+
totalEmission: assessment.totalEmission,
|
| 84 |
+
sustainabilityScore: assessment.sustainabilityScore,
|
| 85 |
+
userId: assessment.userId,
|
| 86 |
+
}
|
| 87 |
+
: null,
|
| 88 |
+
},
|
| 89 |
+
});
|
| 90 |
+
} catch (err) {
|
| 91 |
+
next(err);
|
| 92 |
+
}
|
| 93 |
+
};
|
backend/src/controllers/userController.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from 'zod';
|
| 2 |
+
import db from '../utils/firebaseClient.js';
|
| 3 |
+
import { AppError } from '../middleware/errorHandler.js';
|
| 4 |
+
import { sanitizeText } from '../utils/sanitize.js';
|
| 5 |
+
import crypto from 'crypto';
|
| 6 |
+
|
| 7 |
+
const createUserSchema = z.object({
|
| 8 |
+
name: z.string().min(2, 'Name must be at least 2 characters').max(100),
|
| 9 |
+
email: z.string().email('Invalid email address'),
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
export const createUser = async (req, res, next) => {
|
| 13 |
+
try {
|
| 14 |
+
const { name, email } = createUserSchema.parse(req.body);
|
| 15 |
+
const sanitizedName = sanitizeText(name);
|
| 16 |
+
const sanitizedEmail = sanitizeText(email).toLowerCase();
|
| 17 |
+
|
| 18 |
+
const usersRef = db.collection('users');
|
| 19 |
+
const qSnapshot = await usersRef.where('email', '==', sanitizedEmail).get();
|
| 20 |
+
|
| 21 |
+
let userId;
|
| 22 |
+
let userData;
|
| 23 |
+
|
| 24 |
+
if (!qSnapshot.empty) {
|
| 25 |
+
const userDoc = qSnapshot.docs[0];
|
| 26 |
+
userId = userDoc.id;
|
| 27 |
+
userData = {
|
| 28 |
+
...userDoc.data(),
|
| 29 |
+
name: sanitizedName,
|
| 30 |
+
updatedAt: new Date().toISOString(),
|
| 31 |
+
};
|
| 32 |
+
await usersRef.doc(userId).set(userData);
|
| 33 |
+
} else {
|
| 34 |
+
userId = crypto.randomUUID();
|
| 35 |
+
userData = {
|
| 36 |
+
id: userId,
|
| 37 |
+
name: sanitizedName,
|
| 38 |
+
email: sanitizedEmail,
|
| 39 |
+
points: 0,
|
| 40 |
+
badges: [],
|
| 41 |
+
title: 'Eco Novice',
|
| 42 |
+
streak: { current: 0, longest: 0, lastSaved: null },
|
| 43 |
+
createdAt: new Date().toISOString(),
|
| 44 |
+
updatedAt: new Date().toISOString(),
|
| 45 |
+
};
|
| 46 |
+
await usersRef.doc(userId).set(userData);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const assessmentsSnapshot = await db
|
| 50 |
+
.collection('assessments')
|
| 51 |
+
.where('userId', '==', userId)
|
| 52 |
+
.get();
|
| 53 |
+
const assessmentsCount = assessmentsSnapshot.docs.length;
|
| 54 |
+
|
| 55 |
+
res.status(201).json({
|
| 56 |
+
success: true,
|
| 57 |
+
data: {
|
| 58 |
+
...userData,
|
| 59 |
+
id: userId,
|
| 60 |
+
_count: { assessments: assessmentsCount },
|
| 61 |
+
},
|
| 62 |
+
});
|
| 63 |
+
} catch (err) {
|
| 64 |
+
next(err);
|
| 65 |
+
}
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
export const getUserById = async (req, res, next) => {
|
| 69 |
+
try {
|
| 70 |
+
const { id } = req.params;
|
| 71 |
+
const sanitizedId = sanitizeText(id);
|
| 72 |
+
|
| 73 |
+
const userDoc = await db.collection('users').doc(sanitizedId).get();
|
| 74 |
+
if (!userDoc.exists) {
|
| 75 |
+
throw new AppError('User not found', 404);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const userData = userDoc.data();
|
| 79 |
+
const assessmentsSnapshot = await db
|
| 80 |
+
.collection('assessments')
|
| 81 |
+
.where('userId', '==', sanitizedId)
|
| 82 |
+
.get();
|
| 83 |
+
const assessmentsCount = assessmentsSnapshot.docs.length;
|
| 84 |
+
|
| 85 |
+
res.json({
|
| 86 |
+
success: true,
|
| 87 |
+
data: {
|
| 88 |
+
...userData,
|
| 89 |
+
id: sanitizedId,
|
| 90 |
+
_count: { assessments: assessmentsCount },
|
| 91 |
+
},
|
| 92 |
+
});
|
| 93 |
+
} catch (err) {
|
| 94 |
+
next(err);
|
| 95 |
+
}
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
export const getLeaderboard = async (req, res, next) => {
|
| 99 |
+
try {
|
| 100 |
+
const page = parseInt(req.query.page) || 1;
|
| 101 |
+
const limit = parseInt(req.query.limit) || 10;
|
| 102 |
+
const offset = (page - 1) * limit;
|
| 103 |
+
|
| 104 |
+
const usersSnap = await db.collection('users').get();
|
| 105 |
+
const usersList = usersSnap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
|
| 106 |
+
|
| 107 |
+
usersList.sort((a, b) => (b.points || 0) - (a.points || 0));
|
| 108 |
+
|
| 109 |
+
const total = usersList.length;
|
| 110 |
+
const paginatedUsers = usersList.slice(offset, offset + limit);
|
| 111 |
+
|
| 112 |
+
res.json({
|
| 113 |
+
success: true,
|
| 114 |
+
data: paginatedUsers,
|
| 115 |
+
pagination: {
|
| 116 |
+
page,
|
| 117 |
+
limit,
|
| 118 |
+
total,
|
| 119 |
+
totalPages: Math.ceil(total / limit),
|
| 120 |
+
},
|
| 121 |
+
});
|
| 122 |
+
} catch (err) {
|
| 123 |
+
next(err);
|
| 124 |
+
}
|
| 125 |
+
};
|
backend/src/index.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import 'dotenv/config';
|
| 2 |
+
import app from './app.js';
|
| 3 |
+
import logger from './utils/logger.js';
|
| 4 |
+
import db from './utils/firebaseClient.js';
|
| 5 |
+
|
| 6 |
+
const PORT = parseInt(process.env.PORT) || 3001;
|
| 7 |
+
|
| 8 |
+
async function main() {
|
| 9 |
+
// Test database connection
|
| 10 |
+
try {
|
| 11 |
+
// Check if Firestore connection works by retrieving a single document reference
|
| 12 |
+
await db.collection('users').limit(1).get();
|
| 13 |
+
logger.info('✅ Firebase Firestore connected successfully');
|
| 14 |
+
} catch (err) {
|
| 15 |
+
logger.error('❌ Firebase Firestore connection failed', { error: err.message });
|
| 16 |
+
process.exit(1);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const server = app.listen(PORT, () => {
|
| 20 |
+
logger.info(`🚀 EcoGuide AI API running on http://localhost:${PORT}`, {
|
| 21 |
+
environment: process.env.NODE_ENV || 'development',
|
| 22 |
+
port: PORT,
|
| 23 |
+
});
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
// Graceful shutdown
|
| 27 |
+
const shutdown = async (signal) => {
|
| 28 |
+
logger.info(`${signal} received. Shutting down gracefully...`);
|
| 29 |
+
server.close(() => {
|
| 30 |
+
logger.info('Server closed.');
|
| 31 |
+
process.exit(0);
|
| 32 |
+
});
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
| 36 |
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
main();
|
backend/src/middleware/errorHandler.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ZodError } from 'zod';
|
| 2 |
+
import logger from '../utils/logger.js';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Centralized error handling middleware
|
| 6 |
+
* Must be registered LAST in the Express middleware chain
|
| 7 |
+
*/
|
| 8 |
+
export const errorHandler = (err, req, res, _next) => {
|
| 9 |
+
// Log the error
|
| 10 |
+
logger.error('Unhandled error', {
|
| 11 |
+
message: err.message,
|
| 12 |
+
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
|
| 13 |
+
method: req.method,
|
| 14 |
+
url: req.url,
|
| 15 |
+
ip: req.ip,
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
// Handle Zod validation errors
|
| 19 |
+
if (err instanceof ZodError) {
|
| 20 |
+
return res.status(400).json({
|
| 21 |
+
success: false,
|
| 22 |
+
error: 'Validation failed',
|
| 23 |
+
details: err.errors.map((e) => ({
|
| 24 |
+
field: e.path.join('.'),
|
| 25 |
+
message: e.message,
|
| 26 |
+
})),
|
| 27 |
+
});
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// Prisma known errors
|
| 31 |
+
if (err.code === 'P2002') {
|
| 32 |
+
return res.status(409).json({
|
| 33 |
+
success: false,
|
| 34 |
+
error: 'A record with this information already exists.',
|
| 35 |
+
field: err.meta?.target?.[0],
|
| 36 |
+
});
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
if (err.code === 'P2025') {
|
| 40 |
+
return res.status(404).json({
|
| 41 |
+
success: false,
|
| 42 |
+
error: 'Record not found.',
|
| 43 |
+
});
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
if (err.code === 'P2003') {
|
| 47 |
+
return res.status(400).json({
|
| 48 |
+
success: false,
|
| 49 |
+
error: 'Invalid reference. The related record does not exist.',
|
| 50 |
+
});
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// Generic HTTP errors
|
| 54 |
+
const statusCode = err.statusCode || err.status || 500;
|
| 55 |
+
const message =
|
| 56 |
+
statusCode < 500 || process.env.NODE_ENV === 'development'
|
| 57 |
+
? err.message
|
| 58 |
+
: 'An internal server error occurred. Please try again later.';
|
| 59 |
+
|
| 60 |
+
res.status(statusCode).json({
|
| 61 |
+
success: false,
|
| 62 |
+
error: message,
|
| 63 |
+
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
|
| 64 |
+
});
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
/**
|
| 68 |
+
* 404 Not Found handler
|
| 69 |
+
*/
|
| 70 |
+
export const notFoundHandler = (req, res) => {
|
| 71 |
+
res.status(404).json({
|
| 72 |
+
success: false,
|
| 73 |
+
error: `Route not found: ${req.method} ${req.path}`,
|
| 74 |
+
});
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
/**
|
| 78 |
+
* Create a custom HTTP error
|
| 79 |
+
*/
|
| 80 |
+
export class AppError extends Error {
|
| 81 |
+
constructor(message, statusCode = 500) {
|
| 82 |
+
super(message);
|
| 83 |
+
this.statusCode = statusCode;
|
| 84 |
+
this.name = 'AppError';
|
| 85 |
+
Error.captureStackTrace(this, this.constructor);
|
| 86 |
+
}
|
| 87 |
+
}
|
backend/src/middleware/rateLimiter.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import rateLimit from 'express-rate-limit';
|
| 2 |
+
|
| 3 |
+
const windowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000;
|
| 4 |
+
const max = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100;
|
| 5 |
+
|
| 6 |
+
export const apiLimiter = rateLimit({
|
| 7 |
+
windowMs,
|
| 8 |
+
max,
|
| 9 |
+
standardHeaders: true,
|
| 10 |
+
legacyHeaders: false,
|
| 11 |
+
message: {
|
| 12 |
+
success: false,
|
| 13 |
+
error: 'Too many requests from this IP, please try again later.',
|
| 14 |
+
retryAfter: Math.ceil(windowMs / 1000 / 60),
|
| 15 |
+
},
|
| 16 |
+
skip: (_req) => process.env.NODE_ENV === 'test',
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
// Stricter limiter for user registration / lookup to prevent abuse
|
| 20 |
+
export const createLimiter = rateLimit({
|
| 21 |
+
windowMs: 60 * 60 * 1000, // 1 hour
|
| 22 |
+
max: 15,
|
| 23 |
+
standardHeaders: true,
|
| 24 |
+
legacyHeaders: false,
|
| 25 |
+
message: {
|
| 26 |
+
success: false,
|
| 27 |
+
error: 'Too many accounts created. Please try again in an hour.',
|
| 28 |
+
},
|
| 29 |
+
skip: (_req) => process.env.NODE_ENV === 'test',
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
// Stricter limiter for logging carbon assessments to prevent spamming
|
| 33 |
+
export const assessmentLimiter = rateLimit({
|
| 34 |
+
windowMs: 60 * 60 * 1000, // 1 hour
|
| 35 |
+
max: 30,
|
| 36 |
+
standardHeaders: true,
|
| 37 |
+
legacyHeaders: false,
|
| 38 |
+
message: {
|
| 39 |
+
success: false,
|
| 40 |
+
error: 'Too many carbon footprint assessments logged. Please try again in an hour.',
|
| 41 |
+
},
|
| 42 |
+
skip: (_req) => process.env.NODE_ENV === 'test',
|
| 43 |
+
});
|
backend/src/middleware/validate.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ZodError } from 'zod';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Validate request body against a Zod schema
|
| 5 |
+
* @param {import('zod').ZodSchema} schema
|
| 6 |
+
*/
|
| 7 |
+
export const validateBody = (schema) => (req, res, next) => {
|
| 8 |
+
try {
|
| 9 |
+
req.body = schema.parse(req.body);
|
| 10 |
+
next();
|
| 11 |
+
} catch (err) {
|
| 12 |
+
if (err instanceof ZodError) {
|
| 13 |
+
const errors = err.errors.map((e) => ({
|
| 14 |
+
field: e.path.join('.'),
|
| 15 |
+
message: e.message,
|
| 16 |
+
}));
|
| 17 |
+
return res.status(400).json({
|
| 18 |
+
success: false,
|
| 19 |
+
error: 'Validation failed',
|
| 20 |
+
details: errors,
|
| 21 |
+
});
|
| 22 |
+
}
|
| 23 |
+
next(err);
|
| 24 |
+
}
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* Validate request params against a Zod schema
|
| 29 |
+
* @param {import('zod').ZodSchema} schema
|
| 30 |
+
*/
|
| 31 |
+
export const validateParams = (schema) => (req, res, next) => {
|
| 32 |
+
try {
|
| 33 |
+
req.params = schema.parse(req.params);
|
| 34 |
+
next();
|
| 35 |
+
} catch (err) {
|
| 36 |
+
if (err instanceof ZodError) {
|
| 37 |
+
return res.status(400).json({
|
| 38 |
+
success: false,
|
| 39 |
+
error: 'Invalid parameters',
|
| 40 |
+
details: err.errors.map((e) => ({ field: e.path.join('.'), message: e.message })),
|
| 41 |
+
});
|
| 42 |
+
}
|
| 43 |
+
next(err);
|
| 44 |
+
}
|
| 45 |
+
};
|
backend/src/routes/ai.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Router } from 'express';
|
| 2 |
+
import { aiService } from '../services/aiService.js';
|
| 3 |
+
import { sanitizeText } from '../utils/sanitize.js';
|
| 4 |
+
|
| 5 |
+
const router = Router();
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* @route POST /api/ai/chat
|
| 9 |
+
* @desc Chat with the AI sustainability coach
|
| 10 |
+
*/
|
| 11 |
+
router.post('/chat', async (req, res, next) => {
|
| 12 |
+
try {
|
| 13 |
+
const { message, context } = req.body;
|
| 14 |
+
const sanitizedMsg = sanitizeText(message);
|
| 15 |
+
const result = await aiService.chat(sanitizedMsg, context);
|
| 16 |
+
res.json({ success: true, ...result });
|
| 17 |
+
} catch (err) {
|
| 18 |
+
next(err);
|
| 19 |
+
}
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* @route POST /api/ai/plan
|
| 24 |
+
* @desc Generate a 30-day net-zero action plan
|
| 25 |
+
*/
|
| 26 |
+
router.post('/plan', async (req, res, next) => {
|
| 27 |
+
try {
|
| 28 |
+
const { footprint, breakdown, goal } = req.body;
|
| 29 |
+
const sanitizedGoal = goal ? sanitizeText(goal) : '20% reduction';
|
| 30 |
+
const result = await aiService.generatePlan(footprint, breakdown, sanitizedGoal);
|
| 31 |
+
res.json({ success: true, ...result });
|
| 32 |
+
} catch (err) {
|
| 33 |
+
next(err);
|
| 34 |
+
}
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* @route GET /api/ai/tip
|
| 39 |
+
* @desc Get daily sustainability tip (cached 24h)
|
| 40 |
+
*/
|
| 41 |
+
router.get('/tip', async (req, res, next) => {
|
| 42 |
+
try {
|
| 43 |
+
const result = await aiService.getDailyTip();
|
| 44 |
+
res.json({ success: true, tip: result });
|
| 45 |
+
} catch (err) {
|
| 46 |
+
next(err);
|
| 47 |
+
}
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* @route POST /api/ai/score
|
| 52 |
+
* @desc Get carbon score evaluation feedback
|
| 53 |
+
*/
|
| 54 |
+
router.post('/score', async (req, res, next) => {
|
| 55 |
+
try {
|
| 56 |
+
const { score, breakdown } = req.body;
|
| 57 |
+
const result = await aiService.getScoreFeedback(score, breakdown);
|
| 58 |
+
res.json({ success: true, ...result });
|
| 59 |
+
} catch (err) {
|
| 60 |
+
next(err);
|
| 61 |
+
}
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* @route POST /api/ai/compare
|
| 66 |
+
* @desc Get narrative footprint average comparison feedback
|
| 67 |
+
*/
|
| 68 |
+
router.post('/compare', async (req, res, next) => {
|
| 69 |
+
try {
|
| 70 |
+
const { totalEmission, breakdown } = req.body;
|
| 71 |
+
const result = await aiService.getCompareNarrative(totalEmission, breakdown);
|
| 72 |
+
res.json({ success: true, ...result });
|
| 73 |
+
} catch (err) {
|
| 74 |
+
next(err);
|
| 75 |
+
}
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
export default router;
|
backend/src/routes/assessments.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Router } from 'express';
|
| 2 |
+
import {
|
| 3 |
+
createAssessment,
|
| 4 |
+
getAssessmentById,
|
| 5 |
+
getAssessmentsByUser,
|
| 6 |
+
compareAssessment,
|
| 7 |
+
} from '../controllers/assessmentController.js';
|
| 8 |
+
import { assessmentLimiter } from '../middleware/rateLimiter.js';
|
| 9 |
+
|
| 10 |
+
const router = Router();
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* @route POST /api/assessments
|
| 14 |
+
* @desc Create a new carbon footprint assessment
|
| 15 |
+
* @access Public
|
| 16 |
+
*/
|
| 17 |
+
router.post('/', assessmentLimiter, createAssessment);
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* @route GET /api/assessments/user/:userId
|
| 21 |
+
* @desc Get all assessments for a user
|
| 22 |
+
* @access Public
|
| 23 |
+
* NOTE: This route must come before /:id to avoid param conflicts
|
| 24 |
+
*/
|
| 25 |
+
router.get('/user/:userId', getAssessmentsByUser);
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* @route GET /api/assessments/:id/compare
|
| 29 |
+
* @desc Compare an assessment's footprint against global, UK, and Paris Agreement averages
|
| 30 |
+
* @access Public
|
| 31 |
+
* NOTE: This route must come before /:id to avoid param conflicts
|
| 32 |
+
*/
|
| 33 |
+
router.get('/:id/compare', compareAssessment);
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* @route GET /api/assessments/:id
|
| 37 |
+
* @desc Get a specific assessment by ID
|
| 38 |
+
* @access Public
|
| 39 |
+
*/
|
| 40 |
+
router.get('/:id', getAssessmentById);
|
| 41 |
+
|
| 42 |
+
export default router;
|
backend/src/routes/recommendations.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Router } from 'express';
|
| 2 |
+
import { getRecommendations } from '../controllers/recommendationController.js';
|
| 3 |
+
|
| 4 |
+
const router = Router();
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* @route GET /api/recommendations/:assessmentId
|
| 8 |
+
* @desc Get recommendations for an assessment
|
| 9 |
+
* @access Public
|
| 10 |
+
*/
|
| 11 |
+
router.get('/:assessmentId', getRecommendations);
|
| 12 |
+
|
| 13 |
+
export default router;
|
backend/src/routes/simulations.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Router } from 'express';
|
| 2 |
+
import { createSimulation, getSimulationById } from '../controllers/simulationController.js';
|
| 3 |
+
|
| 4 |
+
const router = Router();
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* @route POST /api/simulations
|
| 8 |
+
* @desc Run a carbon impact simulation scenario
|
| 9 |
+
* @access Public
|
| 10 |
+
*/
|
| 11 |
+
router.post('/', createSimulation);
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* @route GET /api/simulations/:id
|
| 15 |
+
* @desc Get a simulation result by ID
|
| 16 |
+
* @access Public
|
| 17 |
+
*/
|
| 18 |
+
router.get('/:id', getSimulationById);
|
| 19 |
+
|
| 20 |
+
export default router;
|
backend/src/routes/users.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Router } from 'express';
|
| 2 |
+
import { createUser, getUserById, getLeaderboard } from '../controllers/userController.js';
|
| 3 |
+
import { createLimiter } from '../middleware/rateLimiter.js';
|
| 4 |
+
|
| 5 |
+
const router = Router();
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* @route POST /api/users
|
| 9 |
+
* @desc Create or find a user by email (upsert)
|
| 10 |
+
* @access Public
|
| 11 |
+
*/
|
| 12 |
+
router.post('/', createLimiter, createUser);
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* @route GET /api/users/leaderboard
|
| 16 |
+
* @desc Get top users leaderboard (paginated)
|
| 17 |
+
* @access Public
|
| 18 |
+
*/
|
| 19 |
+
router.get('/leaderboard', getLeaderboard);
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* @route GET /api/users/:id
|
| 23 |
+
* @desc Get user by ID
|
| 24 |
+
* @access Public
|
| 25 |
+
*/
|
| 26 |
+
router.get('/:id', getUserById);
|
| 27 |
+
|
| 28 |
+
export default router;
|
backend/src/services/aiService.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Groq from 'groq-sdk';
|
| 2 |
+
import logger from '../utils/logger.js';
|
| 3 |
+
|
| 4 |
+
const apiKey = process.env.GROQ_API_KEY;
|
| 5 |
+
let groq = null;
|
| 6 |
+
|
| 7 |
+
if (apiKey) {
|
| 8 |
+
try {
|
| 9 |
+
groq = new Groq({ apiKey });
|
| 10 |
+
logger.info('✅ Groq SDK initialized successfully.');
|
| 11 |
+
} catch (err) {
|
| 12 |
+
logger.error('❌ Failed to initialize Groq SDK:', { error: err.message });
|
| 13 |
+
}
|
| 14 |
+
} else {
|
| 15 |
+
logger.warn('⚠️ No GROQ_API_KEY found. AI endpoints will run in Mock Fallback mode.');
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// Simple in-memory cache for the daily eco tip
|
| 19 |
+
let dailyTipCache = {
|
| 20 |
+
date: null,
|
| 21 |
+
tip: null,
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
export const aiService = {
|
| 25 |
+
/**
|
| 26 |
+
* Conversational Eco-Coach Chatbot
|
| 27 |
+
*/
|
| 28 |
+
chat: async (message, context = {}) => {
|
| 29 |
+
if (!groq) {
|
| 30 |
+
return {
|
| 31 |
+
reply: `Hi! I'm your CarbonSense Eco Coach. Currently, I'm running in offline demonstration mode. To unlock my full AI intelligence capabilities, please add a valid \`GROQ_API_KEY\` to your backend environment settings.\n\nBased on your message ("${message}"), my recommendation is to reduce daily car travel and review electricity usage!`,
|
| 32 |
+
suggestedActions: [
|
| 33 |
+
'Turn off unused appliances',
|
| 34 |
+
'Consider public transport',
|
| 35 |
+
'Explore green electricity tariffs',
|
| 36 |
+
],
|
| 37 |
+
};
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
try {
|
| 41 |
+
const systemPrompt = `You are CarbonSense Ocean Intelligence Coach, an expert sustainability advisor.
|
| 42 |
+
You are helping a user reduce their carbon footprint.
|
| 43 |
+
Be encouraging, professional, and practical. Use bullet points for recommendations.
|
| 44 |
+
Keep answers concise (max 3 short paragraphs).
|
| 45 |
+
|
| 46 |
+
${
|
| 47 |
+
context.footprint
|
| 48 |
+
? `Current User Footprint Context:
|
| 49 |
+
- Total Annual Footprint: ${context.footprint} kg CO2e
|
| 50 |
+
- Breakdown: Transport: ${context.breakdown?.transport || 0}%, Electricity: ${context.breakdown?.electricity || 0}%, Food: ${context.breakdown?.food || 0}%, Shopping: ${context.breakdown?.shopping || 0}%`
|
| 51 |
+
: ''
|
| 52 |
+
}`;
|
| 53 |
+
|
| 54 |
+
const chatCompletion = await groq.chat.completions.create({
|
| 55 |
+
messages: [
|
| 56 |
+
{ role: 'system', content: systemPrompt },
|
| 57 |
+
{ role: 'user', content: message },
|
| 58 |
+
],
|
| 59 |
+
model: 'llama-3.1-8b-instant',
|
| 60 |
+
temperature: 0.7,
|
| 61 |
+
max_tokens: 350,
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
const reply = chatCompletion.choices[0]?.message?.content || 'No response generated.';
|
| 65 |
+
return {
|
| 66 |
+
reply,
|
| 67 |
+
suggestedActions: [
|
| 68 |
+
'Calculate footprint again',
|
| 69 |
+
'Optimize my energy contract',
|
| 70 |
+
'Find cyclable commuting routes',
|
| 71 |
+
],
|
| 72 |
+
};
|
| 73 |
+
} catch (err) {
|
| 74 |
+
logger.error('Groq Chat completion failed:', { error: err.message || err });
|
| 75 |
+
return {
|
| 76 |
+
reply:
|
| 77 |
+
'I apologize, but my connection to the AI engine timed out. Please try again in a few moments!',
|
| 78 |
+
suggestedActions: [],
|
| 79 |
+
};
|
| 80 |
+
}
|
| 81 |
+
},
|
| 82 |
+
|
| 83 |
+
/**
|
| 84 |
+
* Personalized 30-Day Net Zero Action Plan
|
| 85 |
+
*/
|
| 86 |
+
generatePlan: async (footprint, breakdown, goal = '20% reduction') => {
|
| 87 |
+
if (!groq) {
|
| 88 |
+
return {
|
| 89 |
+
plan: [
|
| 90 |
+
{
|
| 91 |
+
week: 1,
|
| 92 |
+
actions: [
|
| 93 |
+
'Switch to 100% renewable electricity tariff',
|
| 94 |
+
'Swap one meat meal daily for a plant-based alternative',
|
| 95 |
+
],
|
| 96 |
+
expectedReduction: 'Approx. 50 kg',
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
week: 2,
|
| 100 |
+
actions: [
|
| 101 |
+
'Walk or cycle for all trips under 3 km',
|
| 102 |
+
'Unplug standby electronics when sleeping',
|
| 103 |
+
],
|
| 104 |
+
expectedReduction: 'Approx. 30 kg',
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
week: 3,
|
| 108 |
+
actions: [
|
| 109 |
+
'Wash clothes at 30°C and line-dry instead of tumble drying',
|
| 110 |
+
'Avoid purchasing fast-fashion clothing items',
|
| 111 |
+
],
|
| 112 |
+
expectedReduction: 'Approx. 40 kg',
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
week: 4,
|
| 116 |
+
actions: [
|
| 117 |
+
'Replace three short regional drives with public transit',
|
| 118 |
+
'Conduct a home energy audit',
|
| 119 |
+
],
|
| 120 |
+
expectedReduction: 'Approx. 60 kg',
|
| 121 |
+
},
|
| 122 |
+
],
|
| 123 |
+
goal,
|
| 124 |
+
};
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
try {
|
| 128 |
+
const prompt = `Generate a structured, personalized 30-day net-zero action plan for a user to achieve their goal of "${goal}".
|
| 129 |
+
Annual Footprint: ${footprint} kg CO2e.
|
| 130 |
+
Category breakdown: Transport: ${breakdown.transport || 0}%, Energy: ${breakdown.energy || 0}%, Food: ${breakdown.food || 0}%, Shopping: ${breakdown.shopping || 0}%.
|
| 131 |
+
|
| 132 |
+
Return ONLY a valid JSON object matching this structure:
|
| 133 |
+
{
|
| 134 |
+
"plan": [
|
| 135 |
+
{
|
| 136 |
+
"week": 1,
|
| 137 |
+
"actions": ["action 1", "action 2"],
|
| 138 |
+
"expectedReduction": "X kg"
|
| 139 |
+
},
|
| 140 |
+
...
|
| 141 |
+
]
|
| 142 |
+
}
|
| 143 |
+
Ensure it is valid parseable JSON. Do not include markdown code block syntax in your response, just raw JSON.`;
|
| 144 |
+
|
| 145 |
+
const chatCompletion = await groq.chat.completions.create({
|
| 146 |
+
messages: [{ role: 'user', content: prompt }],
|
| 147 |
+
model: 'llama-3.1-8b-instant',
|
| 148 |
+
response_format: { type: 'json_object' },
|
| 149 |
+
temperature: 0.5,
|
| 150 |
+
max_tokens: 500,
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
return JSON.parse(chatCompletion.choices[0]?.message?.content || '{}');
|
| 154 |
+
} catch (err) {
|
| 155 |
+
logger.error('Groq Plan generation failed:', { error: err.message || err });
|
| 156 |
+
return { plan: [], error: 'Failed to generate plan.' };
|
| 157 |
+
}
|
| 158 |
+
},
|
| 159 |
+
|
| 160 |
+
/**
|
| 161 |
+
* Daily Eco Tip (Cached 24h)
|
| 162 |
+
*/
|
| 163 |
+
getDailyTip: async () => {
|
| 164 |
+
const today = new Date().toISOString().split('T')[0];
|
| 165 |
+
if (dailyTipCache.date === today && dailyTipCache.tip) {
|
| 166 |
+
return dailyTipCache.tip;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
const defaultTip = {
|
| 170 |
+
tip: 'Wash laundry in cold water (30°C or below). This saves up to 75-90% of the energy used in a cycle, which goes solely toward heating the water!',
|
| 171 |
+
category: 'energy',
|
| 172 |
+
impact: 'Up to 75kg CO2/year saved',
|
| 173 |
+
icon: 'local-laundry-service',
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
if (!groq) {
|
| 177 |
+
dailyTipCache = { date: today, tip: defaultTip };
|
| 178 |
+
return defaultTip;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
try {
|
| 182 |
+
const prompt = `Generate a practical, fresh daily eco tip for carbon footprint reduction.
|
| 183 |
+
Return ONLY a valid JSON object matching this structure:
|
| 184 |
+
{
|
| 185 |
+
"tip": "Tip explanation goes here...",
|
| 186 |
+
"category": "energy" | "transport" | "food" | "shopping",
|
| 187 |
+
"impact": "X kg CO2 saved per year",
|
| 188 |
+
"icon": "icon-identifier-string"
|
| 189 |
+
}`;
|
| 190 |
+
|
| 191 |
+
const chatCompletion = await groq.chat.completions.create({
|
| 192 |
+
messages: [{ role: 'user', content: prompt }],
|
| 193 |
+
model: 'llama-3.1-8b-instant',
|
| 194 |
+
response_format: { type: 'json_object' },
|
| 195 |
+
temperature: 0.9, // Higher temperature for variety
|
| 196 |
+
max_tokens: 150,
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
const parsedTip = JSON.parse(chatCompletion.choices[0]?.message?.content || '{}');
|
| 200 |
+
dailyTipCache = { date: today, tip: parsedTip };
|
| 201 |
+
return parsedTip;
|
| 202 |
+
} catch (err) {
|
| 203 |
+
logger.error('Groq Daily Tip generation failed:', { error: err.message || err });
|
| 204 |
+
return defaultTip;
|
| 205 |
+
}
|
| 206 |
+
},
|
| 207 |
+
|
| 208 |
+
/**
|
| 209 |
+
* Carbon Intelligence Score & Feedback
|
| 210 |
+
*/
|
| 211 |
+
getScoreFeedback: async (score, breakdown) => {
|
| 212 |
+
if (!groq) {
|
| 213 |
+
return {
|
| 214 |
+
score,
|
| 215 |
+
grade: score >= 80 ? 'A' : score >= 60 ? 'B' : score >= 40 ? 'C' : 'D',
|
| 216 |
+
feedback: `You scored ${score}/100. Focus on your largest footprint contributor. Reducing meat intake and switching to green electricity tariffs are the highest-yield steps you can take.`,
|
| 217 |
+
};
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
try {
|
| 221 |
+
const prompt = `Provide carbon intelligence feedback on a sustainability score of ${score}/100.
|
| 222 |
+
Breakdown: Transport: ${breakdown.transport || 0}%, Energy: ${breakdown.energy || 0}%, Food: ${breakdown.food || 0}%, Shopping: ${breakdown.shopping || 0}%.
|
| 223 |
+
|
| 224 |
+
Return ONLY a valid JSON object matching this structure:
|
| 225 |
+
{
|
| 226 |
+
"score": ${score},
|
| 227 |
+
"grade": "A" | "B" | "C" | "D" | "F",
|
| 228 |
+
"feedback": "A short, actionable paragraph of feedback summarizing their emissions profiles."
|
| 229 |
+
}`;
|
| 230 |
+
|
| 231 |
+
const chatCompletion = await groq.chat.completions.create({
|
| 232 |
+
messages: [{ role: 'user', content: prompt }],
|
| 233 |
+
model: 'llama-3.1-8b-instant',
|
| 234 |
+
response_format: { type: 'json_object' },
|
| 235 |
+
temperature: 0.5,
|
| 236 |
+
max_tokens: 250,
|
| 237 |
+
});
|
| 238 |
+
|
| 239 |
+
return JSON.parse(chatCompletion.choices[0]?.message?.content || '{}');
|
| 240 |
+
} catch (err) {
|
| 241 |
+
logger.error('Groq Score Feedback failed:', { error: err.message || err });
|
| 242 |
+
return {
|
| 243 |
+
score,
|
| 244 |
+
grade: 'B',
|
| 245 |
+
feedback: 'Your emissions are moderate. Review transport and food habits.',
|
| 246 |
+
};
|
| 247 |
+
}
|
| 248 |
+
},
|
| 249 |
+
|
| 250 |
+
/**
|
| 251 |
+
* Narrative Comparison
|
| 252 |
+
*/
|
| 253 |
+
getCompareNarrative: async (totalEmission, breakdown) => {
|
| 254 |
+
if (!groq) {
|
| 255 |
+
return {
|
| 256 |
+
vsGlobal: totalEmission > 4700 ? 'higher' : 'lower',
|
| 257 |
+
standoutCategory: 'transport',
|
| 258 |
+
narrative: `Your annual footprint of ${(totalEmission / 1000).toFixed(1)} tonnes is ${totalEmission > 4700 ? 'above' : 'below'} the global average of 4.7 tonnes. Focus on choosing green modes of transport and reducing consumer electronics purchases to further lower your footprint.`,
|
| 259 |
+
};
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
try {
|
| 263 |
+
const prompt = `Compare a user's carbon footprint of ${totalEmission} kg CO2e against the global average (4,700 kg/year).
|
| 264 |
+
Breakdown: Transport: ${breakdown.transport || 0}%, Energy: ${breakdown.energy || 0}%, Food: ${breakdown.food || 0}%, Shopping: ${breakdown.shopping || 0}%.
|
| 265 |
+
|
| 266 |
+
Return ONLY a valid JSON object matching this structure:
|
| 267 |
+
{
|
| 268 |
+
"vsGlobal": "higher" | "lower",
|
| 269 |
+
"standoutCategory": "transport" | "energy" | "food" | "shopping",
|
| 270 |
+
"narrative": "A concise paragraph (2-3 sentences) evaluating their standing and highlighting which category has the highest relative impact."
|
| 271 |
+
}`;
|
| 272 |
+
|
| 273 |
+
const chatCompletion = await groq.chat.completions.create({
|
| 274 |
+
messages: [{ role: 'user', content: prompt }],
|
| 275 |
+
model: 'llama-3.1-8b-instant',
|
| 276 |
+
response_format: { type: 'json_object' },
|
| 277 |
+
temperature: 0.6,
|
| 278 |
+
max_tokens: 250,
|
| 279 |
+
});
|
| 280 |
+
|
| 281 |
+
return JSON.parse(chatCompletion.choices[0]?.message?.content || '{}');
|
| 282 |
+
} catch (err) {
|
| 283 |
+
logger.error('Groq Comparison Narrative failed:', { error: err.message || err });
|
| 284 |
+
return {
|
| 285 |
+
vsGlobal: totalEmission > 4700 ? 'higher' : 'lower',
|
| 286 |
+
standoutCategory: 'transport',
|
| 287 |
+
narrative: 'Comparison computed successfully.',
|
| 288 |
+
};
|
| 289 |
+
}
|
| 290 |
+
},
|
| 291 |
+
};
|
backend/src/services/assessmentService.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import db from '../utils/firebaseClient.js';
|
| 2 |
+
import { calculateAllEmissions } from './carbonCalculator.js';
|
| 3 |
+
import { calculateScore } from './scoringService.js';
|
| 4 |
+
import { generateRecommendations } from './recommendationEngine.js';
|
| 5 |
+
import crypto from 'crypto';
|
| 6 |
+
|
| 7 |
+
function computeStreak(userProfile) {
|
| 8 |
+
const today = new Date().toISOString().split('T')[0];
|
| 9 |
+
const streak = userProfile.streak || { current: 0, longest: 0, lastSaved: null };
|
| 10 |
+
|
| 11 |
+
if (streak.lastSaved === today) {
|
| 12 |
+
return streak;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const yesterday = new Date();
|
| 16 |
+
yesterday.setDate(yesterday.getDate() - 1);
|
| 17 |
+
const yStr = yesterday.toISOString().split('T')[0];
|
| 18 |
+
|
| 19 |
+
const newCurrent = streak.lastSaved === yStr ? streak.current + 1 : 1;
|
| 20 |
+
return {
|
| 21 |
+
current: newCurrent,
|
| 22 |
+
longest: Math.max(newCurrent, streak.longest || 0),
|
| 23 |
+
lastSaved: today,
|
| 24 |
+
};
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function computeTitle(points) {
|
| 28 |
+
if (points >= 25000) return 'Master Guardian';
|
| 29 |
+
if (points >= 15000) return 'Climate Champion';
|
| 30 |
+
if (points >= 10000) return 'Ocean Guardian';
|
| 31 |
+
if (points >= 6000) return 'Nature Enthusiast';
|
| 32 |
+
if (points >= 3000) return 'Carbon Reducer';
|
| 33 |
+
if (points >= 1000) return 'Green Sprout';
|
| 34 |
+
return 'Eco Novice';
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function computeBadges(data, existingBadges = []) {
|
| 38 |
+
const badges = new Set(existingBadges);
|
| 39 |
+
if (data.dietType === 'vegan' || data.dietType === 'vegetarian') {
|
| 40 |
+
badges.add('Tree Planter');
|
| 41 |
+
}
|
| 42 |
+
if (data.dailyCarKm === 0 || ['electric', 'hybrid', 'none'].includes(data.carFuelType)) {
|
| 43 |
+
badges.add('Carbon Ninja');
|
| 44 |
+
}
|
| 45 |
+
if (data.clothingItemsPerYear < 5 && data.electronicsItemsPerYear === 0) {
|
| 46 |
+
badges.add('Zero Waste');
|
| 47 |
+
}
|
| 48 |
+
return Array.from(badges);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* Create a new assessment with full emission analysis and recommendations.
|
| 53 |
+
*
|
| 54 |
+
* @param {Object} data - Validated assessment input (matches assessmentSchema).
|
| 55 |
+
* @returns {Promise<{assessment: Object, emissions: Object, score: number}>}
|
| 56 |
+
*/
|
| 57 |
+
export async function createAssessmentWithRecommendations(data) {
|
| 58 |
+
// ── Step 1: Resolve user ───────────────────────────────────────────────────
|
| 59 |
+
const userDoc = await db.collection('users').doc(data.userId).get();
|
| 60 |
+
let effectiveUserId = data.userId;
|
| 61 |
+
let user = userDoc.exists ? userDoc.data() : null;
|
| 62 |
+
|
| 63 |
+
if (!user) {
|
| 64 |
+
const email = `${data.userId}@ecoguide.ai`;
|
| 65 |
+
const qSnapshot = await db.collection('users').where('email', '==', email).get();
|
| 66 |
+
|
| 67 |
+
if (!qSnapshot.empty) {
|
| 68 |
+
const existingUserDoc = qSnapshot.docs[0];
|
| 69 |
+
effectiveUserId = existingUserDoc.id;
|
| 70 |
+
user = existingUserDoc.data();
|
| 71 |
+
} else {
|
| 72 |
+
const newUserId = data.userId;
|
| 73 |
+
user = {
|
| 74 |
+
id: newUserId,
|
| 75 |
+
name: 'Anonymous',
|
| 76 |
+
email: email,
|
| 77 |
+
points: 0,
|
| 78 |
+
badges: [],
|
| 79 |
+
title: 'Eco Novice',
|
| 80 |
+
streak: { current: 0, longest: 0, lastSaved: null },
|
| 81 |
+
createdAt: new Date().toISOString(),
|
| 82 |
+
updatedAt: new Date().toISOString(),
|
| 83 |
+
};
|
| 84 |
+
await db.collection('users').doc(newUserId).set(user);
|
| 85 |
+
effectiveUserId = newUserId;
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// ── Step 2: Calculate emissions ────────────────────────────────────────────
|
| 90 |
+
const emissions = calculateAllEmissions(data);
|
| 91 |
+
|
| 92 |
+
// ── Step 3: Score ──────────────────────────────────────────────────────────
|
| 93 |
+
const score = calculateScore(emissions.totalEmission);
|
| 94 |
+
|
| 95 |
+
// ── Step 4: Generate personalised recommendations ──────────────────────────
|
| 96 |
+
const recs = generateRecommendations(data, emissions);
|
| 97 |
+
|
| 98 |
+
// ── Step 5: Update User Profile (Points, Streak, Badges, Title) ────────────
|
| 99 |
+
const currentStreak = computeStreak(user);
|
| 100 |
+
const isStreakMilestone = currentStreak.current > 0;
|
| 101 |
+
const streakBonus =
|
| 102 |
+
isStreakMilestone && currentStreak.current % 7 === 0
|
| 103 |
+
? 250
|
| 104 |
+
: isStreakMilestone && currentStreak.current % 3 === 0
|
| 105 |
+
? 50
|
| 106 |
+
: 0;
|
| 107 |
+
|
| 108 |
+
const awardedPoints = 100 + streakBonus;
|
| 109 |
+
const newPoints = (user.points || 0) + awardedPoints;
|
| 110 |
+
const newBadges = computeBadges(data, user.badges || []);
|
| 111 |
+
const newTitle = computeTitle(newPoints);
|
| 112 |
+
|
| 113 |
+
const updatedUser = {
|
| 114 |
+
...user,
|
| 115 |
+
points: newPoints,
|
| 116 |
+
badges: newBadges,
|
| 117 |
+
title: newTitle,
|
| 118 |
+
streak: currentStreak,
|
| 119 |
+
updatedAt: new Date().toISOString(),
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
await db.collection('users').doc(effectiveUserId).set(updatedUser);
|
| 123 |
+
|
| 124 |
+
// ── Step 6: Persist Assessment ─────────────────────────────────────────────
|
| 125 |
+
const assessmentId = crypto.randomUUID();
|
| 126 |
+
const assessmentDocData = {
|
| 127 |
+
id: assessmentId,
|
| 128 |
+
userId: effectiveUserId,
|
| 129 |
+
dailyCarKm: data.dailyCarKm,
|
| 130 |
+
carFuelType: data.carFuelType,
|
| 131 |
+
publicTransportKmPerWeek: data.publicTransportKmPerWeek,
|
| 132 |
+
cyclingKmPerWeek: data.cyclingKmPerWeek,
|
| 133 |
+
shortFlightsPerYear: data.shortFlightsPerYear,
|
| 134 |
+
longFlightsPerYear: data.longFlightsPerYear,
|
| 135 |
+
monthlyElectricityKwh: data.monthlyElectricityKwh,
|
| 136 |
+
renewablePercentage: data.renewablePercentage,
|
| 137 |
+
dietType: data.dietType,
|
| 138 |
+
clothingItemsPerYear: data.clothingItemsPerYear,
|
| 139 |
+
electronicsItemsPerYear: data.electronicsItemsPerYear,
|
| 140 |
+
transportEmission: emissions.transportEmission,
|
| 141 |
+
energyEmission: emissions.energyEmission,
|
| 142 |
+
foodEmission: emissions.foodEmission,
|
| 143 |
+
shoppingEmission: emissions.shoppingEmission,
|
| 144 |
+
totalEmission: emissions.totalEmission,
|
| 145 |
+
sustainabilityScore: score,
|
| 146 |
+
createdAt: new Date().toISOString(),
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
await db.collection('assessments').doc(assessmentId).set(assessmentDocData);
|
| 150 |
+
|
| 151 |
+
const recommendationsList = [];
|
| 152 |
+
for (const r of recs) {
|
| 153 |
+
const recId = crypto.randomUUID();
|
| 154 |
+
const recDocData = {
|
| 155 |
+
id: recId,
|
| 156 |
+
assessmentId,
|
| 157 |
+
title: r.title,
|
| 158 |
+
description: r.description,
|
| 159 |
+
estimatedSavings: r.estimatedSavings,
|
| 160 |
+
priority: r.priority,
|
| 161 |
+
category: r.category,
|
| 162 |
+
createdAt: new Date().toISOString(),
|
| 163 |
+
};
|
| 164 |
+
await db.collection('recommendations').doc(recId).set(recDocData);
|
| 165 |
+
recommendationsList.push(recDocData);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
recommendationsList.sort((a, b) => (b.estimatedSavings || 0) - (a.estimatedSavings || 0));
|
| 169 |
+
|
| 170 |
+
const assessment = {
|
| 171 |
+
...assessmentDocData,
|
| 172 |
+
recommendations: recommendationsList,
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
return { assessment, emissions, score };
|
| 176 |
+
}
|
backend/src/services/carbonCalculator.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Carbon Calculator Service
|
| 3 |
+
* Implements real-world emission factors from IPCC, EPA, and UK BEIS 2023
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
// === Emission Factors ===
|
| 7 |
+
const EMISSION_FACTORS = {
|
| 8 |
+
car: {
|
| 9 |
+
petrol: 0.21, // kg CO₂ per km
|
| 10 |
+
diesel: 0.17,
|
| 11 |
+
electric: 0.047,
|
| 12 |
+
hybrid: 0.105,
|
| 13 |
+
none: 0,
|
| 14 |
+
},
|
| 15 |
+
publicTransport: 0.089, // kg CO₂ per km (bus average)
|
| 16 |
+
train: 0.041, // kg CO₂ per km
|
| 17 |
+
electricity: 0.233, // kg CO₂ per kWh (UK grid 2023)
|
| 18 |
+
food: {
|
| 19 |
+
vegan: 1500, // kg CO₂ per year
|
| 20 |
+
vegetarian: 1700,
|
| 21 |
+
mixed: 2500,
|
| 22 |
+
heavy_meat: 3300,
|
| 23 |
+
},
|
| 24 |
+
clothing: 33, // kg CO₂ per clothing item (manufacturing + transport)
|
| 25 |
+
electronics: 300, // kg CO₂ per device (avg smartphone/laptop lifecycle)
|
| 26 |
+
flight: {
|
| 27 |
+
short_haul: 255, // kg CO₂ per return flight (< 3 hours, incl. radiative forcing)
|
| 28 |
+
long_haul: 1620, // kg CO₂ per return flight (> 3 hours)
|
| 29 |
+
},
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Calculate transport-related CO₂ emissions
|
| 34 |
+
* @param {Object} data
|
| 35 |
+
* @returns {number} kg CO₂ per year
|
| 36 |
+
*/
|
| 37 |
+
export function calculateTransportEmission(data) {
|
| 38 |
+
const {
|
| 39 |
+
dailyCarKm = 0,
|
| 40 |
+
carFuelType = 'none',
|
| 41 |
+
publicTransportKmPerWeek = 0,
|
| 42 |
+
cyclingKmPerWeek: _cyclingKmPerWeek = 0,
|
| 43 |
+
shortFlightsPerYear = 0,
|
| 44 |
+
longFlightsPerYear = 0,
|
| 45 |
+
} = data;
|
| 46 |
+
|
| 47 |
+
const carFactor = EMISSION_FACTORS.car[carFuelType] ?? 0;
|
| 48 |
+
const carEmission = dailyCarKm * carFactor * 365;
|
| 49 |
+
const publicTransportEmission = publicTransportKmPerWeek * 52 * EMISSION_FACTORS.publicTransport;
|
| 50 |
+
const flightEmission =
|
| 51 |
+
shortFlightsPerYear * EMISSION_FACTORS.flight.short_haul +
|
| 52 |
+
longFlightsPerYear * EMISSION_FACTORS.flight.long_haul;
|
| 53 |
+
// Cycling produces 0 direct emissions
|
| 54 |
+
|
| 55 |
+
return carEmission + publicTransportEmission + flightEmission;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* Calculate home energy CO₂ emissions
|
| 60 |
+
* @param {Object} data
|
| 61 |
+
* @returns {number} kg CO₂ per year
|
| 62 |
+
*/
|
| 63 |
+
export function calculateEnergyEmission(data) {
|
| 64 |
+
const { monthlyElectricityKwh = 0, renewablePercentage = 0 } = data;
|
| 65 |
+
const nonRenewableFraction = 1 - Math.min(renewablePercentage, 100) / 100;
|
| 66 |
+
return monthlyElectricityKwh * 12 * EMISSION_FACTORS.electricity * nonRenewableFraction;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* Calculate food & diet CO₂ emissions
|
| 71 |
+
* @param {Object} data
|
| 72 |
+
* @returns {number} kg CO₂ per year
|
| 73 |
+
*/
|
| 74 |
+
export function calculateFoodEmission(data) {
|
| 75 |
+
const { dietType = 'mixed' } = data;
|
| 76 |
+
return EMISSION_FACTORS.food[dietType] ?? EMISSION_FACTORS.food.mixed;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* Calculate shopping CO₂ emissions
|
| 81 |
+
* @param {Object} data
|
| 82 |
+
* @returns {number} kg CO₂ per year
|
| 83 |
+
*/
|
| 84 |
+
export function calculateShoppingEmission(data) {
|
| 85 |
+
const { clothingItemsPerYear = 0, electronicsItemsPerYear = 0 } = data;
|
| 86 |
+
return (
|
| 87 |
+
clothingItemsPerYear * EMISSION_FACTORS.clothing +
|
| 88 |
+
electronicsItemsPerYear * EMISSION_FACTORS.electronics
|
| 89 |
+
);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* Calculate all emissions and return a full breakdown
|
| 94 |
+
* @param {Object} inputData
|
| 95 |
+
* @returns {Object} complete emission breakdown
|
| 96 |
+
*/
|
| 97 |
+
export function calculateAllEmissions(inputData) {
|
| 98 |
+
const transport = calculateTransportEmission(inputData);
|
| 99 |
+
const energy = calculateEnergyEmission(inputData);
|
| 100 |
+
const food = calculateFoodEmission(inputData);
|
| 101 |
+
const shopping = calculateShoppingEmission(inputData);
|
| 102 |
+
const total = transport + energy + food + shopping;
|
| 103 |
+
|
| 104 |
+
const safeTotal = total === 0 ? 1 : total;
|
| 105 |
+
|
| 106 |
+
return {
|
| 107 |
+
transportEmission: Math.round(transport),
|
| 108 |
+
energyEmission: Math.round(energy),
|
| 109 |
+
foodEmission: Math.round(food),
|
| 110 |
+
shoppingEmission: Math.round(shopping),
|
| 111 |
+
totalEmission: Math.round(total),
|
| 112 |
+
breakdown: {
|
| 113 |
+
transport: parseFloat(((transport / safeTotal) * 100).toFixed(1)),
|
| 114 |
+
energy: parseFloat(((energy / safeTotal) * 100).toFixed(1)),
|
| 115 |
+
food: parseFloat(((food / safeTotal) * 100).toFixed(1)),
|
| 116 |
+
shopping: parseFloat(((shopping / safeTotal) * 100).toFixed(1)),
|
| 117 |
+
},
|
| 118 |
+
};
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export { EMISSION_FACTORS };
|
backend/src/services/recommendationEngine.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* AI Recommendation Engine
|
| 3 |
+
* Analyzes user assessment data and generates personalized, prioritized recommendations
|
| 4 |
+
* using decision-tree logic based on real emission factors
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import { EMISSION_FACTORS } from './carbonCalculator.js';
|
| 8 |
+
|
| 9 |
+
const PRIORITY = { HIGH: 'HIGH', MEDIUM: 'MEDIUM', LOW: 'LOW' };
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Generate personalized recommendations from assessment data and calculated emissions
|
| 13 |
+
* @param {Object} assessmentData - user input data
|
| 14 |
+
* @param {Object} emissions - calculated emission breakdown
|
| 15 |
+
* @returns {Array} sorted array of recommendation objects
|
| 16 |
+
*/
|
| 17 |
+
export function generateRecommendations(assessmentData, emissions) {
|
| 18 |
+
const recommendations = [];
|
| 19 |
+
|
| 20 |
+
// === TRANSPORTATION RECOMMENDATIONS ===
|
| 21 |
+
_analyzeTransport(assessmentData, emissions, recommendations);
|
| 22 |
+
|
| 23 |
+
// === ENERGY RECOMMENDATIONS ===
|
| 24 |
+
_analyzeEnergy(assessmentData, emissions, recommendations);
|
| 25 |
+
|
| 26 |
+
// === FOOD RECOMMENDATIONS ===
|
| 27 |
+
_analyzeFood(assessmentData, emissions, recommendations);
|
| 28 |
+
|
| 29 |
+
// === SHOPPING RECOMMENDATIONS ===
|
| 30 |
+
_analyzeShopping(assessmentData, emissions, recommendations);
|
| 31 |
+
|
| 32 |
+
// Sort: HIGH priority first, then by estimated savings descending
|
| 33 |
+
return recommendations.sort((a, b) => {
|
| 34 |
+
const priorityOrder = { HIGH: 0, MEDIUM: 1, LOW: 2 };
|
| 35 |
+
const priorityDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
|
| 36 |
+
if (priorityDiff !== 0) return priorityDiff;
|
| 37 |
+
return b.estimatedSavings - a.estimatedSavings;
|
| 38 |
+
});
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function _analyzeTransport(data, emissions, recs) {
|
| 42 |
+
const {
|
| 43 |
+
dailyCarKm = 0,
|
| 44 |
+
carFuelType = 'none',
|
| 45 |
+
publicTransportKmPerWeek = 0,
|
| 46 |
+
shortFlightsPerYear = 0,
|
| 47 |
+
longFlightsPerYear = 0,
|
| 48 |
+
cyclingKmPerWeek: _cyclingKmPerWeek = 0,
|
| 49 |
+
} = data;
|
| 50 |
+
|
| 51 |
+
const totalFlights = shortFlightsPerYear + longFlightsPerYear;
|
| 52 |
+
const flightEmission =
|
| 53 |
+
shortFlightsPerYear * EMISSION_FACTORS.flight.short_haul +
|
| 54 |
+
longFlightsPerYear * EMISSION_FACTORS.flight.long_haul;
|
| 55 |
+
|
| 56 |
+
// 1. EV switch recommendation
|
| 57 |
+
if (dailyCarKm > 10 && carFuelType === 'petrol') {
|
| 58 |
+
const currentCarEmission = dailyCarKm * EMISSION_FACTORS.car.petrol * 365;
|
| 59 |
+
const evCarEmission = dailyCarKm * EMISSION_FACTORS.car.electric * 365;
|
| 60 |
+
const savings = Math.round(currentCarEmission - evCarEmission);
|
| 61 |
+
recs.push({
|
| 62 |
+
title: 'Switch to an Electric Vehicle',
|
| 63 |
+
description: `You drive ${dailyCarKm}km daily on petrol, generating ~${Math.round(currentCarEmission)}kg CO₂/year. An EV running on grid electricity would cut this by ~75%, saving ${savings}kg annually.`,
|
| 64 |
+
estimatedSavings: savings,
|
| 65 |
+
priority: savings > 1500 ? PRIORITY.HIGH : PRIORITY.MEDIUM,
|
| 66 |
+
category: 'transport',
|
| 67 |
+
});
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
if (dailyCarKm > 10 && carFuelType === 'diesel') {
|
| 71 |
+
const currentCarEmission = dailyCarKm * EMISSION_FACTORS.car.diesel * 365;
|
| 72 |
+
const evCarEmission = dailyCarKm * EMISSION_FACTORS.car.electric * 365;
|
| 73 |
+
const savings = Math.round(currentCarEmission - evCarEmission);
|
| 74 |
+
recs.push({
|
| 75 |
+
title: 'Switch to an Electric Vehicle',
|
| 76 |
+
description: `Your diesel vehicle contributes ~${Math.round(currentCarEmission)}kg CO₂/year. Switching to an EV could save ${savings}kg annually and reduce urban air pollution.`,
|
| 77 |
+
estimatedSavings: savings,
|
| 78 |
+
priority: savings > 1200 ? PRIORITY.HIGH : PRIORITY.MEDIUM,
|
| 79 |
+
category: 'transport',
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// 2. Cycling recommendation
|
| 84 |
+
if (dailyCarKm > 3 && carFuelType !== 'none') {
|
| 85 |
+
const carFactor = EMISSION_FACTORS.car[carFuelType] ?? 0.21;
|
| 86 |
+
// Assume 30% of short trips could be cycled
|
| 87 |
+
const cyclableKmPerDay = Math.min(dailyCarKm * 0.3, 5);
|
| 88 |
+
const savings = Math.round(cyclableKmPerDay * carFactor * 365);
|
| 89 |
+
recs.push({
|
| 90 |
+
title: 'Replace Short Trips with Cycling or Walking',
|
| 91 |
+
description: `Cycling or walking for journeys under 5km instead of driving could save ~${savings}kg CO₂/year while improving your health and reducing congestion.`,
|
| 92 |
+
estimatedSavings: savings,
|
| 93 |
+
priority: PRIORITY.MEDIUM,
|
| 94 |
+
category: 'transport',
|
| 95 |
+
});
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// 3. Public transport recommendation
|
| 99 |
+
if (dailyCarKm > 15 && publicTransportKmPerWeek < 20 && carFuelType !== 'electric') {
|
| 100 |
+
const carFactor = EMISSION_FACTORS.car[carFuelType] ?? 0.21;
|
| 101 |
+
const potentialPTKm = dailyCarKm * 5; // weekday trips
|
| 102 |
+
const savings = Math.round(potentialPTKm * 52 * (carFactor - EMISSION_FACTORS.publicTransport));
|
| 103 |
+
recs.push({
|
| 104 |
+
title: 'Use Public Transport for Commuting',
|
| 105 |
+
description: `Switching even 2 days/week to public transport for your ${dailyCarKm}km daily commute could save ~${savings}kg CO₂/year and reduce traffic stress.`,
|
| 106 |
+
estimatedSavings: Math.max(0, savings),
|
| 107 |
+
priority: savings > 800 ? PRIORITY.HIGH : PRIORITY.MEDIUM,
|
| 108 |
+
category: 'transport',
|
| 109 |
+
});
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// 4. Flight reduction
|
| 113 |
+
if (totalFlights >= 2) {
|
| 114 |
+
const savings = Math.round(flightEmission * 0.5);
|
| 115 |
+
recs.push({
|
| 116 |
+
title: 'Reduce Air Travel by 50%',
|
| 117 |
+
description: `Your ${totalFlights} annual flight${totalFlights > 1 ? 's' : ''} contribute ~${Math.round(flightEmission)}kg CO₂. Cutting flights in half through video calls, trains, or choosing fewer trips saves ${savings}kg annually.`,
|
| 118 |
+
estimatedSavings: savings,
|
| 119 |
+
priority: flightEmission > 2000 ? PRIORITY.HIGH : PRIORITY.MEDIUM,
|
| 120 |
+
category: 'transport',
|
| 121 |
+
});
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
if (longFlightsPerYear >= 1) {
|
| 125 |
+
recs.push({
|
| 126 |
+
title: 'Choose Train Over Short-Haul Flights',
|
| 127 |
+
description: `A long-haul flight emits ~1,620kg CO₂. For routes under 800km, trains emit 75–90% less CO₂ and are often as fast door-to-door.`,
|
| 128 |
+
estimatedSavings: Math.round(longFlightsPerYear * 1620 * 0.85),
|
| 129 |
+
priority: PRIORITY.HIGH,
|
| 130 |
+
category: 'transport',
|
| 131 |
+
});
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
// 5. Carpool suggestion
|
| 135 |
+
if (dailyCarKm > 20 && publicTransportKmPerWeek < 10 && carFuelType !== 'electric') {
|
| 136 |
+
const carFactor = EMISSION_FACTORS.car[carFuelType] ?? 0.21;
|
| 137 |
+
const savings = Math.round(dailyCarKm * carFactor * 365 * 0.35);
|
| 138 |
+
recs.push({
|
| 139 |
+
title: 'Start Carpooling',
|
| 140 |
+
description: `Sharing your commute with just one other person halves both your per-person emissions and fuel costs, saving an estimated ${savings}kg CO₂/year.`,
|
| 141 |
+
estimatedSavings: savings,
|
| 142 |
+
priority: PRIORITY.LOW,
|
| 143 |
+
category: 'transport',
|
| 144 |
+
});
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function _analyzeEnergy(data, emissions, recs) {
|
| 149 |
+
const { monthlyElectricityKwh = 0, renewablePercentage = 0 } = data;
|
| 150 |
+
const annualKwh = monthlyElectricityKwh * 12;
|
| 151 |
+
|
| 152 |
+
// 1. Renewable energy tariff
|
| 153 |
+
if (renewablePercentage < 50 && monthlyElectricityKwh > 100) {
|
| 154 |
+
const currentEmission = annualKwh * 0.233 * (1 - renewablePercentage / 100);
|
| 155 |
+
const renewableEmission = annualKwh * 0.233 * 0.1;
|
| 156 |
+
const savings = Math.round(currentEmission - renewableEmission);
|
| 157 |
+
recs.push({
|
| 158 |
+
title: 'Switch to a 100% Renewable Energy Tariff',
|
| 159 |
+
description: `Only ${renewablePercentage}% of your electricity comes from renewables. Switching to a green tariff costs little to nothing extra but can save ~${savings}kg CO₂/year immediately.`,
|
| 160 |
+
estimatedSavings: savings,
|
| 161 |
+
priority: savings > 600 ? PRIORITY.HIGH : PRIORITY.MEDIUM,
|
| 162 |
+
category: 'energy',
|
| 163 |
+
});
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// 2. Solar panels
|
| 167 |
+
if (renewablePercentage < 30 && monthlyElectricityKwh > 200) {
|
| 168 |
+
const savings = Math.round(emissions.energyEmission * 0.7);
|
| 169 |
+
recs.push({
|
| 170 |
+
title: 'Install Solar Panels',
|
| 171 |
+
description: `With your electricity consumption of ${monthlyElectricityKwh}kWh/month, rooftop solar panels could generate 70-80% of your electricity needs and save ~${savings}kg CO₂/year.`,
|
| 172 |
+
estimatedSavings: savings,
|
| 173 |
+
priority: PRIORITY.HIGH,
|
| 174 |
+
category: 'energy',
|
| 175 |
+
});
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// 3. Reduce consumption
|
| 179 |
+
if (monthlyElectricityKwh > 300) {
|
| 180 |
+
const savings = Math.round(
|
| 181 |
+
monthlyElectricityKwh * 0.25 * 12 * 0.233 * (1 - renewablePercentage / 100)
|
| 182 |
+
);
|
| 183 |
+
recs.push({
|
| 184 |
+
title: 'Reduce Home Electricity Consumption',
|
| 185 |
+
description: `Your usage of ${monthlyElectricityKwh}kWh/month is above average. Upgrading to LED lighting, smart thermostats, and A-rated appliances can reduce consumption by 20-30%, saving ~${savings}kg CO₂/year.`,
|
| 186 |
+
estimatedSavings: Math.max(0, savings),
|
| 187 |
+
priority: PRIORITY.MEDIUM,
|
| 188 |
+
category: 'energy',
|
| 189 |
+
});
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// 4. Smart heating
|
| 193 |
+
if (monthlyElectricityKwh > 150) {
|
| 194 |
+
recs.push({
|
| 195 |
+
title: 'Install a Smart Thermostat',
|
| 196 |
+
description:
|
| 197 |
+
'Smart thermostats learn your schedule and can reduce heating and cooling energy use by 10-15%, with typical savings of 150-400kg CO₂/year depending on home size.',
|
| 198 |
+
estimatedSavings: Math.round(emissions.energyEmission * 0.12),
|
| 199 |
+
priority: PRIORITY.LOW,
|
| 200 |
+
category: 'energy',
|
| 201 |
+
});
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
function _analyzeFood(data, emissions, recs) {
|
| 206 |
+
const { dietType = 'mixed' } = data;
|
| 207 |
+
|
| 208 |
+
if (dietType === 'heavy_meat') {
|
| 209 |
+
recs.push({
|
| 210 |
+
title: 'Reduce Red Meat Consumption',
|
| 211 |
+
description:
|
| 212 |
+
'A heavy meat diet generates ~3,300kg CO₂/year from food alone. Cutting beef and lamb to twice a week while keeping other meat saves ~800kg CO₂/year — one of the highest-impact individual changes.',
|
| 213 |
+
estimatedSavings: 800,
|
| 214 |
+
priority: PRIORITY.HIGH,
|
| 215 |
+
category: 'food',
|
| 216 |
+
});
|
| 217 |
+
recs.push({
|
| 218 |
+
title: 'Try Meat-Free Mondays',
|
| 219 |
+
description:
|
| 220 |
+
'Going vegetarian just one day per week saves ~150kg CO₂/year. Extending to 3 meat-free days reduces your diet footprint by over 600kg annually.',
|
| 221 |
+
estimatedSavings: 500,
|
| 222 |
+
priority: PRIORITY.MEDIUM,
|
| 223 |
+
category: 'food',
|
| 224 |
+
});
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
if (dietType === 'mixed') {
|
| 228 |
+
recs.push({
|
| 229 |
+
title: 'Adopt a Vegetarian Diet',
|
| 230 |
+
description:
|
| 231 |
+
'Switching from a mixed to vegetarian diet saves ~800kg CO₂/year. Plant proteins like beans, lentils and tofu have 10-20x lower carbon footprints than beef.',
|
| 232 |
+
estimatedSavings: 800,
|
| 233 |
+
priority: PRIORITY.MEDIUM,
|
| 234 |
+
category: 'food',
|
| 235 |
+
});
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
if (dietType !== 'vegan') {
|
| 239 |
+
recs.push({
|
| 240 |
+
title: 'Reduce Food Waste',
|
| 241 |
+
description:
|
| 242 |
+
'Around 30% of food is wasted globally. Meal planning, proper storage and composting can reduce your food footprint by 10-15%, saving ~150-300kg CO₂/year.',
|
| 243 |
+
estimatedSavings: Math.round(EMISSION_FACTORS.food[dietType] * 0.12),
|
| 244 |
+
priority: PRIORITY.LOW,
|
| 245 |
+
category: 'food',
|
| 246 |
+
});
|
| 247 |
+
|
| 248 |
+
recs.push({
|
| 249 |
+
title: 'Choose Local & Seasonal Produce',
|
| 250 |
+
description:
|
| 251 |
+
'Out-of-season produce shipped by air can have 50x higher emissions than local seasonal equivalents. Shopping at farmers markets and choosing seasonal food reduces transport emissions.',
|
| 252 |
+
estimatedSavings: Math.round(EMISSION_FACTORS.food[dietType] * 0.08),
|
| 253 |
+
priority: PRIORITY.LOW,
|
| 254 |
+
category: 'food',
|
| 255 |
+
});
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
function _analyzeShopping(data, emissions, recs) {
|
| 260 |
+
const { clothingItemsPerYear = 0, electronicsItemsPerYear = 0 } = data;
|
| 261 |
+
|
| 262 |
+
if (clothingItemsPerYear > 10) {
|
| 263 |
+
const savings = Math.round(clothingItemsPerYear * EMISSION_FACTORS.clothing * 0.6);
|
| 264 |
+
recs.push({
|
| 265 |
+
title: 'Buy Second-Hand Clothing',
|
| 266 |
+
description: `You buy ~${clothingItemsPerYear} clothing items per year. Sourcing 60% from charity shops, vintage stores or clothing swaps saves ~${savings}kg CO₂/year while reducing textile waste.`,
|
| 267 |
+
estimatedSavings: savings,
|
| 268 |
+
priority: clothingItemsPerYear > 20 ? PRIORITY.MEDIUM : PRIORITY.LOW,
|
| 269 |
+
category: 'shopping',
|
| 270 |
+
});
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
if (electronicsItemsPerYear >= 2) {
|
| 274 |
+
const savings = Math.round(electronicsItemsPerYear * EMISSION_FACTORS.electronics * 0.5);
|
| 275 |
+
recs.push({
|
| 276 |
+
title: 'Repair & Extend Electronics Lifespan',
|
| 277 |
+
description: `Manufacturing ${electronicsItemsPerYear} devices/year contributes ~${electronicsItemsPerYear * EMISSION_FACTORS.electronics}kg CO₂. Repairing devices and buying refurbished saves ~${savings}kg CO₂/year and significant money.`,
|
| 278 |
+
estimatedSavings: savings,
|
| 279 |
+
priority: PRIORITY.MEDIUM,
|
| 280 |
+
category: 'shopping',
|
| 281 |
+
});
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
if (clothingItemsPerYear > 5 || electronicsItemsPerYear >= 1) {
|
| 285 |
+
recs.push({
|
| 286 |
+
title: 'Choose Products with Lower Carbon Footprints',
|
| 287 |
+
description:
|
| 288 |
+
'Look for products with eco-certifications, made from recycled materials, or manufactured locally. Even small changes in purchasing habits compound over time.',
|
| 289 |
+
estimatedSavings: Math.round(emissions.shoppingEmission * 0.15),
|
| 290 |
+
priority: PRIORITY.LOW,
|
| 291 |
+
category: 'shopping',
|
| 292 |
+
});
|
| 293 |
+
}
|
| 294 |
+
}
|