Akash-Dragon commited on
Commit
7e49c4d
·
1 Parent(s): cb2e99e

refactor: restructure project as a monorepo and implement backend API

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .editorconfig +15 -0
  2. .env.example +6 -0
  3. .gitignore +47 -47
  4. .prettierrc.json +7 -0
  5. App.js +0 -142
  6. CONTRIBUTING.md +77 -0
  7. LICENSE +21 -0
  8. Mobile/carbon_calculator/code.html +0 -357
  9. Mobile/challenges_gamification/code.html +0 -381
  10. Mobile/landing_page/code.html +0 -318
  11. README.md +360 -76
  12. SECURITY.md +52 -0
  13. Web-design/carbon_calculator_desktop/code.html +0 -424
  14. Web-design/challenges_gamification_desktop/code.html +0 -436
  15. Web-design/landing_page_desktop/code.html +0 -348
  16. app.json +0 -24
  17. backend/.env.example +18 -0
  18. backend/.eslintrc.json +26 -0
  19. backend/.gitignore +0 -6
  20. backend/Dockerfile +0 -24
  21. backend/README.md +0 -17
  22. backend/__tests__/routes.test.js +0 -153
  23. backend/firebase.js +0 -310
  24. backend/package-lock.json +0 -0
  25. backend/package.json +33 -15
  26. backend/prisma/migrations/20260616130754_init/migration.sql +89 -0
  27. backend/prisma/migrations/migration_lock.toml +3 -0
  28. backend/prisma/schema.prisma +85 -0
  29. backend/prisma/seed.js +166 -0
  30. backend/requirements.txt +0 -10
  31. backend/routes.js +0 -1008
  32. backend/server.js +0 -90
  33. backend/src/app.js +119 -0
  34. backend/src/controllers/assessmentController.js +196 -0
  35. backend/src/controllers/recommendationController.js +33 -0
  36. backend/src/controllers/simulationController.js +93 -0
  37. backend/src/controllers/userController.js +125 -0
  38. backend/src/index.js +39 -0
  39. backend/src/middleware/errorHandler.js +87 -0
  40. backend/src/middleware/rateLimiter.js +43 -0
  41. backend/src/middleware/validate.js +45 -0
  42. backend/src/routes/ai.js +78 -0
  43. backend/src/routes/assessments.js +42 -0
  44. backend/src/routes/recommendations.js +13 -0
  45. backend/src/routes/simulations.js +20 -0
  46. backend/src/routes/users.js +28 -0
  47. backend/src/services/aiService.js +291 -0
  48. backend/src/services/assessmentService.js +176 -0
  49. backend/src/services/carbonCalculator.js +121 -0
  50. 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
- # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2
-
3
- # dependencies
4
- node_modules/
5
-
6
- # Expo
7
- .expo/
8
- dist/
9
- web-build/
10
- expo-env.d.ts
11
-
12
- # Native
13
- .kotlin/
14
- *.orig.*
15
- *.jks
16
- *.p8
17
- *.p12
18
- *.key
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
- *.pem
32
-
33
- # local env files
34
- .env*.local
35
-
36
- # typescript
37
- *.tsbuildinfo
38
-
39
- # generated native folders
40
- /ios
41
- /android
42
-
43
- # backend specific ignores
44
- backend/node_modules/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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&amp;display=swap" rel="stylesheet"/>
9
- <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;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 &amp; 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&amp;display=swap" rel="stylesheet"/>
9
- <link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&amp;display=swap" rel="stylesheet"/>
10
- <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;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&amp;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&amp;display=swap" rel="stylesheet"/>
12
- <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;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
- # EcoTrack AI - Full-Spectrum Carbon Intelligence Platform
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- **Live Deployment (Vercel):** [https://eco-tracker-virid.vercel.app/](https://eco-tracker-virid.vercel.app/)
4
 
5
- EcoTrack AI is a modern, responsive, and secure carbon footprint intelligence platform designed for individuals and enterprises to understand, track, and reduce their environmental impact. Built with React Native (Expo) and Node.js, the platform offers dynamic calculation metrics, personalized AI coaching, green gamification leaderboards, and an offset marketplace.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  ---
8
 
9
- ## 🌟 Key Features
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- * **Full-Spectrum Carbon Calculator**: Calculates detailed monthly and yearly emissions across transportation, electricity usage, dietary habits, plastic waste, and shopping frequency.
12
- * **AI Sustainability Coach**: Generates real-time, personalized action plans to reduce your footprint based on category breakdown spikes.
13
- * **Missions & Leaderboard**: Complete green challenges (e.g., *No-Car Week*, *Vegan Weekend*), earn points, unlock levels, and rank on a global user leaderboard.
14
- * **Offset Marketplace**: Sponsor high-impact carbon offset initiatives (Amazon reforestation, wind infrastructure, coastal mangroves, solar panel programs) directly from your profile.
15
- * **Net-Zero Projects Ledger**: Launch and track timeline progress for custom carbon reduction projects (e.g., office lighting upgrades).
16
- * **Cross-Platform Accessibility**: Full screen-reader accessibility integration (`accessible`, roles, labels, hints) across all mobile and web layouts.
17
- * **Bank-Grade Security**: Shielded by `helmet` security headers, strict IP rate limit controls, and input validation/XSS sanitization.
 
18
 
19
  ---
20
 
21
- ## 🛠️ Tech Stack & Architecture
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
- ### Frontend (Mobile & Web)
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
- ### Backend (API Server)
29
- * **Runtime**: Node.js & Express.
30
- * **Database**: Google Cloud Firebase Firestore with a built-in JSON file mock fallback database (`database.json`) for zero-config offline developer setup.
31
- * **Testing**: Automated integration testing suite via Jest & Supertest.
32
- * **Security**: Helmet, Express-Rate-Limit, Regex validations, and parameter sanitization.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  ---
35
 
36
- ## 🚀 Getting Started
 
 
 
 
 
 
 
 
37
 
38
- ### 1. Prerequisites
39
- * [Node.js](https://nodejs.org/) (v16 or higher)
40
- * [npm](https://www.npmjs.com/) or [yarn](https://yarnpkg.com/)
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
- ### 2. Backend Setup
43
- 1. Navigate to the `backend` folder:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  ```bash
45
  cd backend
 
46
  ```
47
- 2. Install dependencies:
48
- ```bash
49
- npm install
50
- ```
51
- 3. *(Optional)* Setup Cloud Firestore:
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
- ### 3. Frontend Setup
62
- 1. Navigate back to the root folder:
63
  ```bash
64
- cd ..
 
65
  ```
66
- 2. Install dependencies:
67
- ```bash
68
- npm install
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
- ## 🧪 Testing
 
 
 
 
 
 
 
 
 
 
81
 
82
- The backend includes a comprehensive automated suite to test calculations, database inputs, validations, and leaderboard sorting.
83
 
84
- Run the tests inside the `backend` directory:
85
  ```bash
86
- npm run test
 
87
  ```
88
 
89
- Testing utilizes `database.test.json` to prevent polluting development environment databases.
90
 
91
  ---
92
 
93
- ## 📦 Deployment Guide
94
 
95
- ### Backend (Hugging Face Spaces)
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
- ### Frontend (Vercel / Expo Web)
103
- Deploy the built web bundle using Vercel overrides:
104
- * **Framework Preset**: Create React App (or Other)
105
- * **Build Command**: `npx expo export`
106
- * **Output Directory**: `dist`
107
- * **Environment Variable**: `EXPO_PUBLIC_API_BASE` pointing to your deployed Hugging Face Space URL.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
  ---
110
 
111
- ## 🔒 Security Policy
112
- * **Rate Limits**: Active throttle rules protect endpoints from DoS and script attacks.
113
- * **Helmet Headers**: Blocks iframe clickjacking, MIME injection, and restricts unsafe browser scripts.
114
- * **Input Validation**: Strictly inspects email domains and filters strings to protect data streams.
 
 
 
 
 
 
 
 
 
 
 
 
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: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./LICENSE)
16
+ [![Node.js](https://img.shields.io/badge/Node.js-18%2B-brightgreen)](https://nodejs.org/)
17
+ [![Database: Firestore](https://img.shields.io/badge/Database-Firebase_Firestore-orange.svg)](https://firebase.google.com/)
18
+ [![AI: Groq Cloud](https://img.shields.io/badge/AI-Groq_Llama3-blueviolet)](https://groq.com/)
19
+ [![Tests: Passing](https://img.shields.io/badge/Tests-167_Passing-brightgreen)](#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&amp;display=swap" rel="stylesheet"/>
9
- <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;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 &amp; 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&amp;display=swap" rel="stylesheet"/>
9
- <link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&amp;display=swap" rel="stylesheet"/>
10
- <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;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&amp;auto=format&amp;fit=crop&amp;w=100&amp;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&amp;auto=format&amp;fit=crop&amp;w=100&amp;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&amp;auto=format&amp;fit=crop&amp;w=100&amp;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&amp;display=swap" rel="stylesheet"/>
9
- <link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&amp;display=swap" rel="stylesheet"/>
10
- <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;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": "ecotrack-backend",
3
  "version": "1.0.0",
4
- "main": "server.js",
 
 
5
  "scripts": {
6
- "start": "node server.js",
7
- "dev": "nodemon server.js",
8
- "test": "jest --runInBand --detectOpenHandles --forceExit"
 
 
 
 
 
 
 
 
 
 
9
  },
10
  "dependencies": {
11
- "compression": "^1.8.1",
12
  "cors": "^2.8.5",
13
- "express": "^4.21.2",
14
- "express-rate-limit": "^8.5.2",
 
15
  "firebase-admin": "^14.0.0",
16
- "helmet": "^8.2.0",
17
- "hpp": "^0.2.3",
18
- "morgan": "^1.10.0"
 
19
  },
20
  "devDependencies": {
21
- "jest": "^30.4.2",
22
- "nodemon": "^3.1.9",
23
- "supertest": "^7.2.2"
 
 
24
  },
25
- "private": true
 
 
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
+ }