diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..b02fbf3 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "web-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "web", "run", "dev"], + "port": 5173 + } + ] +} diff --git a/api/.env.example b/api/.env.example index b3d2507..b884bc2 100644 --- a/api/.env.example +++ b/api/.env.example @@ -4,7 +4,13 @@ NODE_ENV=development JWT_SECRET=replace-with-a-long-random-string SENDGRID_API_KEY=API_KEY_HERE SENDGRID_FROM_EMAIL=mail@domain.com +# This API's own origin - used to build the verification link, which is a +# GET route this server handles directly (/api/auth/verify). APP_URL=http://localhost:5001 +# The frontend's origin (Vite dev server, or the deployed web app) - used to +# build the password reset link, which is a client-side route the React app +# handles (/reset-password). +WEB_APP_URL=http://localhost:5173 # comma-separated allowed web origins leave empty to allow all CORS_ORIGINS= AWS_REGION=us-east-1 diff --git a/api/package-lock.json b/api/package-lock.json index 9cb4bb2..8ce2724 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1118,9 +1118,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1138,9 +1135,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1158,9 +1152,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1178,9 +1169,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1198,9 +1186,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1218,9 +1203,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3078,9 +3060,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3102,9 +3081,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3126,9 +3102,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3150,9 +3123,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/api/src/confidence.ts b/api/src/confidence.ts index fef2a7a..4d1fe3e 100644 --- a/api/src/confidence.ts +++ b/api/src/confidence.ts @@ -16,6 +16,10 @@ export function applyVote(E: number, type: VoteType): number { return E + VOTE_WEIGHT[type]; } +export function reverseVote(E: number, type: VoteType): number { + return E - VOTE_WEIGHT[type]; +} + export function decayE(E: number, minutesElapsed: number): number { return E_PRIOR + (E - E_PRIOR) * Math.exp(-LAMBDA * minutesElapsed); } diff --git a/api/src/config/env.ts b/api/src/config/env.ts index a187a35..7e546b4 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -8,6 +8,7 @@ interface Config { sendgridApiKey: string; sendgridFromEmail: string; appUrl: string; + webAppUrl: string; corsOrigins: string[]; awsRegion: string; s3Bucket: string; @@ -28,6 +29,7 @@ const config: Config = { sendgridApiKey: required('SENDGRID_API_KEY'), sendgridFromEmail: required('SENDGRID_FROM_EMAIL'), appUrl: process.env.APP_URL ?? 'http://localhost:5001', + webAppUrl: process.env.WEB_APP_URL ?? 'http://localhost:5173', corsOrigins: (process.env.CORS_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean), awsRegion: process.env.AWS_REGION ?? 'us-east-1', s3Bucket: process.env.S3_BUCKET ?? '', diff --git a/api/src/services/email.ts b/api/src/services/email.ts index 302bc8e..8956496 100644 --- a/api/src/services/email.ts +++ b/api/src/services/email.ts @@ -16,7 +16,7 @@ export async function sendVerificationEmail(to: string, rawToken: string): Promi } export async function sendPasswordResetEmail(to: string, rawToken: string): Promise { - const link = `${config.appUrl}/reset-password?token=${rawToken}`; // needs frontend page + const link = `${config.webAppUrl}/reset-password?token=${rawToken}`; await sgMail.send({ to, diff --git a/api/src/services/posts.ts b/api/src/services/posts.ts index 855b099..2d88a61 100644 --- a/api/src/services/posts.ts +++ b/api/src/services/posts.ts @@ -1,5 +1,5 @@ import { Post, type IPost, type VoteType } from '../models/Post.js'; -import { applyVote, decayE, sigmoid, statusFromConfidence, expiryFromE, E_INITIAL } from '../confidence.js'; +import { applyVote, reverseVote, decayE, sigmoid, statusFromConfidence, expiryFromE, E_INITIAL } from '../confidence.js'; import { AppError } from '../errors.js'; type NewPost = Pick; @@ -27,15 +27,42 @@ export async function vote(postId: string, userId: string, type: VoteType) { throw new AppError(404, 'Post not found'); } - if (post.votes.some((v) => v.user.toString() === userId)) { - throw new AppError(409, 'You have already voted on this post'); + const existingVote = post.votes.find((v) => v.user.toString() === userId); + const minutes = (now.getTime() - post.lastUpdate.getTime()) / 60000; + const decayed = decayE(post.E, minutes); + + // Re-selecting the same option is a no-op - nothing to change. + if (existingVote && existingVote.type === type) { + const confidence = sigmoid(decayed); + return { confidence, status: statusFromConfidence(confidence), tallies: post.tallies }; } - const minutes = (now.getTime() - post.lastUpdate.getTime()) / 60000; - const E = applyVote(decayE(post.E, minutes), type); + const E = existingVote ? applyVote(reverseVote(decayed, existingVote.type), type) : applyVote(decayed, type); const confidence = sigmoid(E); const status = statusFromConfidence(confidence); const expiresAt = expiryFromE(E, now); + + if (existingVote) { + // Switching an existing vote to the other option: move the tally over. + const tallies = { + present: post.tallies.present + (type === 'present' ? 1 : 0) - (existingVote.type === 'present' ? 1 : 0), + gone: post.tallies.gone + (type === 'gone' ? 1 : 0) - (existingVote.type === 'gone' ? 1 : 0), + }; + + const res = await Post.updateOne( + { _id: postId, 'votes.user': userId, 'votes.type': existingVote.type }, + { + $set: { 'votes.$.type': type, 'votes.$.at': now, E, status, lastUpdate: now, expiresAt }, + $inc: { [`tallies.${existingVote.type}`]: -1, [`tallies.${type}`]: 1 }, + }, + ); + if (res.matchedCount === 0) { + throw new AppError(409, 'Your vote changed elsewhere, please try again'); + } + + return { confidence, status, tallies }; + } + const tallies = { present: post.tallies.present + (type === 'present' ? 1 : 0), gone: post.tallies.gone + (type === 'gone' ? 1 : 0), diff --git a/api/test/posts.test.ts b/api/test/posts.test.ts index a1ce577..e3fa9c4 100644 --- a/api/test/posts.test.ts +++ b/api/test/posts.test.ts @@ -123,12 +123,30 @@ describe('POST /api/posts/:id/vote', () => { expect(res.body.confidence).toBeGreaterThan(0.9); }); - it('rejects a second vote from the same user with 409', async () => { + it('lets a user switch their vote to the other option', async () => { const token = await authUser(); const { body } = await createPost(token); await vote(token, body.post._id, 'present').expect(200); const res = await vote(token, body.post._id, 'gone'); - expect(res.status).toBe(409); + expect(res.status).toBe(200); + expect(res.body.tallies).toEqual({ present: 0, gone: 1 }); + expect(res.body.status).toBe('fading'); + + const votes = (await Post.findById(body.post._id).select('+votes'))?.votes; + expect(votes).toHaveLength(1); + expect(votes?.[0]).toMatchObject({ type: 'gone' }); + }); + + it('re-casting the same vote is a no-op', async () => { + const token = await authUser(); + const { body } = await createPost(token); + await vote(token, body.post._id, 'present').expect(200); + const res = await vote(token, body.post._id, 'present'); + expect(res.status).toBe(200); + expect(res.body.tallies).toEqual({ present: 1, gone: 0 }); + + const votes = (await Post.findById(body.post._id).select('+votes'))?.votes; + expect(votes).toHaveLength(1); }); it('lets two different users each vote once', async () => { diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 9999eda..aea171c 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -21,26 +21,26 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.19.1" cupertino_icons: dependency: "direct main" description: @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" flutter: dependency: "direct main" description: flutter @@ -79,26 +79,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.7" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -111,34 +111,34 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.18.0" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" sky_engine: dependency: transitive description: flutter @@ -156,18 +156,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: @@ -188,18 +188,18 @@ packages: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.11" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -209,5 +209,5 @@ packages: source: hosted version: "14.3.0" sdks: - dart: ">=3.6.0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/web/.gitignore b/web/.gitignore index a547bf3..438657a 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/web/package-lock.json b/web/package-lock.json index 57c0926..85ffc87 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" }, "devDependencies": { "@types/node": "^24.13.2", @@ -738,6 +739,19 @@ } } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1187,6 +1201,44 @@ "react": "^19.2.7" } }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.2.tgz", @@ -1227,6 +1279,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/web/package.json b/web/package.json index b940de9..9eb80b5 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" }, "devDependencies": { "@types/node": "^24.13.2", diff --git a/web/src/App.tsx b/web/src/App.tsx index a66b5ef..421b835 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,122 +1,14 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import './App.css' +/* web/src/App.tsx */ -function App() { - const [count, setCount] = useState(0) +import { AuthProvider } from './context/AuthContext.js'; +import { AppRouter } from './router/AppRouter.js'; +function App() { return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) + + + + ); } -export default App +export default App; diff --git a/web/src/api/apiClient.ts b/web/src/api/apiClient.ts new file mode 100644 index 0000000..53f2b67 --- /dev/null +++ b/web/src/api/apiClient.ts @@ -0,0 +1,80 @@ +/* web/src/api/apiClient.ts */ + +// Determine API Base URL from environment variables, fallback to local dev port +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5001/api'; + +// Custom error class that holds API response payload +export class ApiError extends Error { + status: number; + payload: any; + + constructor(status: number, message: string, payload: any = null) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.payload = payload; + } +} + +interface RequestOptions extends RequestInit { + bodyData?: any; // Convenient payload helper +} + +/** + * Perform an authenticated API request using fetch. + */ +export async function apiRequest( + path: string, + options: RequestOptions = {} +): Promise { + const { bodyData, headers = {}, ...restOptions } = options; + + // Clean up path prefix if provided + const cleanPath = path.startsWith('/') ? path.slice(1) : path; + const url = `${API_BASE_URL}/${cleanPath}`; + + const requestHeaders = new Headers(headers); + + // Auto-inject JSON Content-Type if we're sending body data + if (bodyData && !requestHeaders.has('Content-Type')) { + requestHeaders.set('Content-Type', 'application/json'); + } + + // Auto-inject JWT token if saved in localStorage + const token = localStorage.getItem('auth_token'); + if (token && !requestHeaders.has('Authorization')) { + requestHeaders.set('Authorization', `Bearer ${token}`); + } + + const fetchOptions: RequestInit = { + ...restOptions, + headers: requestHeaders, + }; + + if (bodyData) { + fetchOptions.body = JSON.stringify(bodyData); + } + + const response = await fetch(url, fetchOptions); + + // Check if content is JSON + const contentType = response.headers.get('Content-Type'); + let data: any = null; + if (contentType && contentType.includes('application/json')) { + data = await response.json(); + } else { + data = { message: await response.text() }; + } + + if (!response.ok) { + // If we receive a 401 Unauthorized, dispatch a custom event to notify AuthContext + if (response.status === 401) { + window.dispatchEvent(new CustomEvent('auth-unauthorized')); + } + + const errorMessage = data?.error || data?.message || `Request failed with status ${response.status}`; + throw new ApiError(response.status, errorMessage, data); + } + + return data as T; +} diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts new file mode 100644 index 0000000..a979e80 --- /dev/null +++ b/web/src/api/auth.ts @@ -0,0 +1,80 @@ +/* web/src/api/auth.ts */ + +import { apiRequest } from './apiClient.js'; +import type { + AuthResponse, + MeResponse, + MessageResponse, + ChangePasswordResponse +} from './types.js'; + +export const authService = { + + // Log in user and return JWT + user profile. + + async login(body: Record): Promise { + return apiRequest('/auth/login', { + method: 'POST', + bodyData: body, + }); + }, + + /** + * Register a new account. Sends verification email. + */ + async register(body: Record): Promise { + return apiRequest('/auth/register', { + method: 'POST', + bodyData: body, + }); + }, + + + // Fetch current logged in user details. + + async getMe(): Promise { + return apiRequest('/auth/me', { + method: 'GET', + }); + }, + + + // Resend verification email to address. + + async resendVerification(email: string): Promise { + return apiRequest('/auth/resend-verification', { + method: 'POST', + bodyData: { email }, + }); + }, + + + // Request password reset token email. + + async forgotPassword(email: string): Promise { + return apiRequest('/auth/forgot-password', { + method: 'POST', + bodyData: { email }, + }); + }, + + + // Submit reset password token and new password. + + async resetPassword(body: Record): Promise { + return apiRequest('/auth/reset-password', { + method: 'POST', + bodyData: body, + }); + }, + + + // Change password when already logged in. Returns a fresh JWT. + + async changePassword(body: Record): Promise { + return apiRequest('/auth/change-password', { + method: 'POST', + bodyData: body, + }); + }, +}; diff --git a/web/src/api/locations.ts b/web/src/api/locations.ts new file mode 100644 index 0000000..f523f7a --- /dev/null +++ b/web/src/api/locations.ts @@ -0,0 +1,15 @@ +/* web/src/api/locations.ts */ + +import { apiRequest } from './apiClient.js'; +import type { PostLocation } from './types.js'; + +export const locationService = { + /** + * Fetch the fixed list of valid campus locations (public endpoint). + */ + async getLocations(): Promise<{ locations: PostLocation[] }> { + return apiRequest<{ locations: PostLocation[] }>('/locations', { + method: 'GET', + }); + }, +}; \ No newline at end of file diff --git a/web/src/api/posts.ts b/web/src/api/posts.ts new file mode 100644 index 0000000..2c86977 --- /dev/null +++ b/web/src/api/posts.ts @@ -0,0 +1,130 @@ +/* web/src/api/posts.ts */ + +import { apiRequest } from './apiClient.js'; +import type { + FeedResponse, + Post, + PostLocation, + PostType, + DietaryTag, + UploadUrlResponse, + VoteResponse, +} from './types.js'; + +// Shape the backend actually sends over the wire. +interface RawPost { + _id: string; + foodName: string; + type: PostType; + badges?: string[]; + dietaryTags?: DietaryTag[]; + location: string | PostLocation; + locationDetail?: string; + imageKey?: string; + author: string; + status: Post['status']; + confidence?: number; + tallies: Post['tallies']; + expiresAt: string; + createdAt: string; +} + +// Converts a raw backend post into the shape the rest of the app expects. +function normalizePost(raw: RawPost): Post { + return { + id: raw._id, + foodName: raw.foodName, + type: raw.type, + dietaryTags: (raw.badges || raw.dietaryTags || []) as DietaryTag[], + location: raw.location, + locationDetail: raw.locationDetail, + imageKey: raw.imageKey, + author: raw.author, + status: raw.status, + confidence: raw.confidence, + tallies: raw.tallies, + expiresAt: raw.expiresAt, + createdAt: raw.createdAt, + }; +} + +export const postService = { + /** + * Fetch active posts from the feed (public endpoint). + */ + async getFeed(): Promise { + const data = await apiRequest<{ posts: RawPost[] }>('/posts', { + method: 'GET', + }); + console.log(data); + return { posts: data.posts.map(normalizePost) }; + }, + + /** + * Create a new food post (requires auth). + */ + async createPost(body: { + foodName: string; + type: PostType; + dietaryTags?: DietaryTag[]; + location: string; + locationDetail?: string; + imageKey?: string; + }): Promise<{ post: Post }> { + const { dietaryTags, ...rest } = body; + const data = await apiRequest<{ post: RawPost }>('/posts', { + method: 'POST', + bodyData: { + ...rest, + badges: dietaryTags, + }, + }); + return { post: normalizePost(data.post) }; + }, + + /** + * Request a short-lived presigned S3 PUT URL for image uploading (requires auth). + */ + async getUploadUrl(): Promise { + return apiRequest('/posts/upload-url', { + method: 'GET', + }); + }, + + /** + * Vote "present" (still here) or "gone" (not there anymore) on a post (requires auth). + */ + async votePost(id: string, type: 'present' | 'gone'): Promise { + return apiRequest(`/posts/${id}/vote`, { + method: 'POST', + bodyData: { type }, + }); + }, + + /** + * Delete a post the current user authored (requires auth). + */ + async deletePost(id: string): Promise { + await apiRequest(`/posts/${id}`, { + method: 'DELETE', + }); + }, + + /** + * Upload binary data directly to an S3 presigned URL using PUT. + * This bypasses the Express backend. + */ + async uploadImageToS3(url: string, file: File): Promise { + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': file.type, + }, + body: file, + }); + + if (!response.ok) { + throw new Error(`S3 upload failed with status ${response.status}`); + } + }, +}; \ No newline at end of file diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..416e226 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,81 @@ +/* web/src/api/types.ts */ + +export interface User { + id: string; + displayName: string; + email: string; + avatarKey?: string; + verified: boolean; +} + +export interface AuthResponse { + token: string; + user: User; +} + +export interface MeResponse { + user: User; +} + +export type PostStatus = 'fresh' | 'likely' | 'fading' | 'gone'; + +export type PostType = 'pizza' | 'meal' | 'snacks' | 'baked-goods' | 'drinks' | 'other'; + +export const POST_TYPES: PostType[] = ['pizza', 'meal', 'snacks', 'baked-goods', 'drinks', 'other']; + +export type DietaryTag = 'vegetarian' | 'vegan' | 'halal' | 'kosher' | 'gluten-free'; + +export const DIETARY_TAGS: DietaryTag[] = ['vegetarian', 'vegan', 'halal', 'kosher', 'gluten-free']; + +export interface Tallies { + present: number; + gone: number; +} + +// Matches the CampusLocation +export interface PostLocation { + id: string; + name: string; + latitude: number; + longitude: number; +} + +export interface Post { + id: string; + foodName: string; + type: PostType; + dietaryTags: DietaryTag[]; + location: string | PostLocation; + locationDetail?: string; + imageKey?: string; + author: string; + status: PostStatus; + confidence?: number; + tallies: Tallies; + expiresAt: string; + createdAt: string; +} + +export interface MessageResponse { + message: string; +} + +export interface ChangePasswordResponse { + token: string; + message: string; +} + +export interface UploadUrlResponse { + url: string; + key: string; +} + +export interface VoteResponse { + confidence: number; + status: PostStatus; + tallies: Tallies; +} + +export interface FeedResponse { + posts: Post[]; +} \ No newline at end of file diff --git a/web/src/components/layout/Navbar.tsx b/web/src/components/layout/Navbar.tsx new file mode 100644 index 0000000..0ea391c --- /dev/null +++ b/web/src/components/layout/Navbar.tsx @@ -0,0 +1,173 @@ +/* web/src/components/layout/Navbar.tsx */ + +import React from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuth } from '../../context/AuthContext.js'; + +export const Navbar: React.FC = () => { + const { isAuthenticated, user, logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + return ( +
+
+ {/* Brand Logo */} + + 🍔 + FreeFood + + + {/* Navigation Links */} + +
+
+ ); +}; + +// Inline Layout Styles for high-fidelity scaffold +const headerStyle: React.CSSProperties = { + position: 'sticky', + top: 0, + zIndex: 100, + width: '100%', + height: '64px', + background: '#ffffff', + borderBottom: '1px solid var(--border-light)', + display: 'flex', + alignItems: 'center', +}; + +const navContainerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', +}; + +const logoStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: '8px', + textDecoration: 'none', +}; + +const logoEmojiStyle: React.CSSProperties = { + fontSize: '1.8rem', +}; + +const logoTextStyle: React.CSSProperties = { + fontFamily: 'var(--font-display)', + fontSize: '1.4rem', + fontWeight: 700, + color: 'var(--text-primary)', + letterSpacing: '-0.5px', +}; + +const navLinksStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: '24px', +}; + +const linkStyle: React.CSSProperties = { + color: 'var(--text-secondary)', + fontWeight: 500, + fontSize: '0.95rem', + transition: 'color var(--transition-fast)', + textDecoration: 'none', +}; + +const createPostButtonStyle: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: '6px', + background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))', + color: '#fff', + fontFamily: 'var(--font-display)', + fontWeight: 600, + fontSize: '0.9rem', + padding: '8px 16px', + borderRadius: 'var(--border-radius-pill)', + textDecoration: 'none', + boxShadow: 'var(--shadow-glow)', + transition: 'transform var(--transition-fast), filter var(--transition-fast)', +}; + +const profileLinkStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: '10px', + textDecoration: 'none', + color: 'var(--text-primary)', +}; + +const avatarPlaceholderStyle: React.CSSProperties = { + width: '32px', + height: '32px', + borderRadius: '50%', + background: 'linear-gradient(135deg, var(--color-secondary), var(--color-primary))', + color: '#fff', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 700, + fontSize: '0.9rem', + boxShadow: '0 0 10px rgba(240, 101, 63, 0.3)', +}; + +const emailTextStyle: React.CSSProperties = { + fontSize: '0.9rem', + fontWeight: 500, + maxWidth: '150px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}; + +const authButtonsContainerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: '16px', +}; + +const registerButtonStyle: React.CSSProperties = { + padding: '8px 18px', + fontSize: '0.9rem', + borderRadius: '99px', +}; + +const logoutButtonStyle: React.CSSProperties = { + padding: '6px 12px', + fontSize: '0.85rem', + borderRadius: 'var(--border-radius-sm)', +}; diff --git a/web/src/components/layout/ProtectedRoute.tsx b/web/src/components/layout/ProtectedRoute.tsx new file mode 100644 index 0000000..81d705b --- /dev/null +++ b/web/src/components/layout/ProtectedRoute.tsx @@ -0,0 +1,63 @@ +/* web/src/components/layout/ProtectedRoute.tsx */ + +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../../context/AuthContext.js'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export const ProtectedRoute: React.FC = ({ children }) => { + const { isAuthenticated, isLoading } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return ( +
+
+

Verifying session...

+
+ ); + } + + if (!isAuthenticated) { + // Redirect to the login page, but save the current location they were trying to access + return ; + } + + return <>{children}; +}; + +// Inline helper styles for the wireframe loader +const spinnerContainerStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '100vh', + width: '100vw', + backgroundColor: 'var(--bg-app)', +}; + +const spinnerStyle: React.CSSProperties = { + width: '40px', + height: '40px', + border: '4px solid var(--border-light)', + borderTop: '4px solid var(--color-primary)', + borderRadius: '50%', + animation: 'spin 1s linear infinite', +}; + +// Add keyframe styling injection or depend on global app CSS +const styleSheet = document.styleSheets[0]; +try { + styleSheet?.insertRule(` + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + `, styleSheet.cssRules.length); +} catch (e) { + // Silent fallback +} diff --git a/web/src/context/AuthContext.tsx b/web/src/context/AuthContext.tsx new file mode 100644 index 0000000..9e59285 --- /dev/null +++ b/web/src/context/AuthContext.tsx @@ -0,0 +1,118 @@ +/* web/src/context/AuthContext.tsx */ + +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { authService } from '../api/auth.js'; +import type { User } from '../api/types.js'; + +interface AuthContextType { + user: User | null; + token: string | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (email: string, password: string) => Promise; + register: (displayName: string, email: string, password: string) => Promise; + logout: () => void; + refreshUser: () => Promise; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Initialize: Check localStorage for token and load user profile + useEffect(() => { + async function loadUser() { + const storedToken = localStorage.getItem('auth_token'); + if (storedToken) { + try { + setToken(storedToken); + const response = await authService.getMe(); + setUser(response.user); + } catch (err) { + console.error('Failed to verify token on startup:', err); + // Token is invalid/expired + localStorage.removeItem('auth_token'); + setToken(null); + setUser(null); + } + } + setIsLoading(false); + } + + loadUser(); + + // Listen for global unauthorized events (401 errors from apiClient) + const handleUnauthorized = () => { + logout(); + }; + + window.addEventListener('auth-unauthorized', handleUnauthorized); + return () => { + window.removeEventListener('auth-unauthorized', handleUnauthorized); + }; + }, []); + + const login = async (email: string, password: string) => { + setIsLoading(true); + try { + const data = await authService.login({ email, password }); + localStorage.setItem('auth_token', data.token); + setToken(data.token); + setUser(data.user); + } finally { + setIsLoading(false); + } + }; + + const register = async (displayName: string, email: string, password: string) => { + setIsLoading(true); + try { + await authService.register({ displayName, email, password }); + } finally { + setIsLoading(false); + } + }; + + const logout = () => { + localStorage.removeItem('auth_token'); + setToken(null); + setUser(null); + }; + + const refreshUser = async () => { + try { + const response = await authService.getMe(); + setUser(response.user); + } catch (err) { + console.error('Failed to refresh user profile:', err); + } + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; \ No newline at end of file diff --git a/web/src/index.css b/web/src/index.css index 5fb3313..5e895f7 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,111 +1,118 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; +/* web/src/index.css */ +@import './styles/theme.css'; +@import url('https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600;700&family=Hanken+Grotesk:wght@400;500;600;700&display=swap'); + +/* Reset and Base Setup */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + width: 100%; + height: 100%; + background-color: var(--bg-app); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.5; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; +} - @media (max-width: 1024px) { - font-size: 16px; - } +body { + display: flex; + flex-direction: column; + min-height: 100vh; } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } +/* Custom Scrollbar for modern feel */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} - #social .button-icon { - filter: invert(1) brightness(2); - } +::-webkit-scrollbar-track { + background: var(--bg-app); +} + +::-webkit-scrollbar-thumb { + background: var(--border-input); + border-radius: var(--border-radius-sm); } +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* Layout Root */ #root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; display: flex; flex-direction: column; - box-sizing: border-box; -} - -body { - margin: 0; + min-height: 100vh; + width: 100%; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); +/* Typography Base */ +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-display); + font-weight: 600; + color: var(--text-primary); + line-height: 1.25; } h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } + font-size: 2.5rem; + letter-spacing: -0.03em; } + h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } + font-size: 1.75rem; + letter-spacing: -0.02em; } + +h3 { + font-size: 1.35rem; + letter-spacing: -0.01em; +} + p { - margin: 0; + color: var(--text-secondary); + font-size: 0.95rem; +} + +a { + color: var(--color-primary-text); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--color-primary-text-hover); } -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); +/* Basic Grid/Layout Helpers */ +.container { + width: 100%; + max-width: var(--max-width-content); + margin: 0 auto; + padding: 0 24px; +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +.fade-in { + animation: fadeIn var(--transition-normal) forwards; } diff --git a/web/src/pages/CreatePostPage.tsx b/web/src/pages/CreatePostPage.tsx new file mode 100644 index 0000000..3f433d7 --- /dev/null +++ b/web/src/pages/CreatePostPage.tsx @@ -0,0 +1,366 @@ +/* web/src/pages/CreatePostPage.tsx */ + +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { postService } from '../api/posts.js'; +import { POST_TYPES } from '../api/types.js'; +import type { PostType } from '../api/types.js'; + +export const CreatePostPage: React.FC = () => { + const navigate = useNavigate(); + + const [foodName, setFoodName] = useState(''); + const [type, setType] = useState(''); + const [location, setLocation] = useState(''); + const [badgeInput, setBadgeInput] = useState(''); + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [uploadStatus, setUploadStatus] = useState(null); + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setImageFile(file); + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!foodName || !type || !location) { + setError('Food Name, Type, and Location are required fields.'); + return; + } + + setError(null); + setLoading(true); + setUploadStatus(null); + + // Parse comma-separated tags into an array. The backend only accepts a + // fixed set of dietary tags, so anything else will 400 - that's fine, + // the backend error message will explain it. + const dietaryTags = badgeInput + .split(',') + .map(tag => tag.trim().toLowerCase()) + .filter(tag => tag.length > 0) as any; + + try { + let imageKey: string | undefined = undefined; + + // Handle Image upload if a file was selected + if (imageFile) { + setUploadStatus('Requesting secure upload authorization...'); + try { + // 1. Get S3 presigned URL + const { url, key } = await postService.getUploadUrl(); + imageKey = key; + + setUploadStatus('Uploading image directly to cloud storage...'); + // 2. PUT binary directly to S3 + await postService.uploadImageToS3(url, imageFile); + setUploadStatus('Upload complete!'); + } catch (uploadErr: any) { + console.error('S3 Upload failed:', uploadErr); + + if (uploadErr.status === 503 || uploadErr.message?.includes('configured')) { + // S3 not set up on backend (503 response) + const proceedWithoutImage = window.confirm( + 'Image uploads are not configured on this server. Would you like to post this without an image?' + ); + if (!proceedWithoutImage) { + setLoading(false); + setUploadStatus(null); + return; + } + } else { + const proceedWithoutImage = window.confirm( + 'Image upload failed. Would you like to post this without an image anyway?' + ); + if (!proceedWithoutImage) { + setLoading(false); + setUploadStatus(null); + return; + } + } + imageKey = undefined; // Don't submit key + } + } + + setUploadStatus('Recording post details...'); + // 3. Create the post + await postService.createPost({ + foodName, + type, + dietaryTags, + location, + imageKey, + }); + + navigate('/'); + } catch (err: any) { + console.error('Post creation failed:', err); + setError(err.message || 'Failed to create food post. Please verify fields.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

🍕 Share Free Food

+

Spot some leftovers? Spread the word to hungry students.

+
+ + {error && ( +
+ âš ī¸ + {error} +
+ )} + + {uploadStatus && ( +
+
+ {uploadStatus} +
+ )} + +
+
+ + setFoodName(e.target.value)} + disabled={loading} + required + /> +
+ +
+ + +
+ +
+ + setLocation(e.target.value)} + disabled={loading} + required + /> +
+ +
+ + setBadgeInput(e.target.value)} + disabled={loading} + /> +
+ +
+ +
+ {imagePreview ? ( +
+ Preview + +
+ ) : ( + + )} +
+
+ +
+ + +
+
+
+
+ ); +}; + +// Inline Layout Styles +const containerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '20px 0 40px', + flex: 1, +}; + +const cardStyle: React.CSSProperties = { + width: '100%', + maxWidth: 'var(--max-width-form)', + padding: '40px', +}; + +const formStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '20px', +}; + +const formGroupStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '8px', +}; + +const labelStyle: React.CSSProperties = { + fontSize: '0.85rem', + fontWeight: 600, + color: 'var(--text-secondary)', +}; + +const uploadBoxStyle: React.CSSProperties = { + border: '2px dashed var(--border-light)', + borderRadius: 'var(--border-radius-sm)', + background: 'var(--bg-input)', + overflow: 'hidden', + transition: 'border-color var(--transition-fast)', +}; + +const fileLabelStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '30px 20px', + cursor: 'pointer', + textAlign: 'center', + width: '100%', +}; + +const previewContainerStyle: React.CSSProperties = { + position: 'relative', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '16px', +}; + +const previewImageStyle: React.CSSProperties = { + maxWidth: '100%', + maxHeight: '200px', + borderRadius: '4px', + objectFit: 'cover', + marginBottom: '12px', +}; + +const removeImageButtonStyle: React.CSSProperties = { + background: 'var(--bg-chip)', + border: '1px solid var(--border-light)', + color: 'var(--text-primary)', + padding: '4px 12px', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '0.8rem', + fontWeight: 600, +}; + +const buttonRowStyle: React.CSSProperties = { + display: 'flex', + gap: '16px', + marginTop: '12px', +}; + +const errorBannerStyle: React.CSSProperties = { + background: 'var(--color-danger-bg)', + border: '1px solid var(--color-danger-border)', + color: 'var(--color-danger)', + padding: '12px 16px', + borderRadius: 'var(--border-radius-sm)', + marginBottom: '20px', + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '0.85rem', +}; + +const statusBannerStyle: React.CSSProperties = { + background: 'var(--bg-chip)', + border: '1px solid var(--border-light)', + color: 'var(--text-secondary)', + padding: '12px 16px', + borderRadius: 'var(--border-radius-sm)', + marginBottom: '20px', + display: 'flex', + alignItems: 'center', + gap: '12px', + fontSize: '0.85rem', +}; + +const smallSpinnerStyle: React.CSSProperties = { + width: '18px', + height: '18px', + border: '2px solid var(--border-light)', + borderTop: '2px solid var(--color-primary)', + borderRadius: '50%', + animation: 'spin 1s linear infinite', +}; \ No newline at end of file diff --git a/web/src/pages/FeedPage.tsx b/web/src/pages/FeedPage.tsx new file mode 100644 index 0000000..72587da --- /dev/null +++ b/web/src/pages/FeedPage.tsx @@ -0,0 +1,561 @@ +/* web/src/pages/FeedPage.tsx */ + +import React, { useState, useEffect } from 'react'; +import { postService } from '../api/posts.js'; +import { useAuth } from '../context/AuthContext.js'; +import type { Post } from '../api/types.js'; + +// Visual fallback mock data if backend connection fails +const MOCK_POSTS: Post[] = [ + { + id: 'mock-1', + foodName: 'Voodoo Doughnuts (Assorted)', + type: 'baked-goods', + dietaryTags: ['vegetarian'], + location: { id: 'student-union', name: 'Student Union', latitude: 28.6016695, longitude: -81.2005277 }, + author: 'mock-user-1', + status: 'fresh', + confidence: 0.94, + tallies: { present: 6, gone: 0 }, + expiresAt: new Date(Date.now() + 3600000 * 2).toISOString(), + createdAt: new Date().toISOString(), + }, + { + id: 'mock-2', + foodName: 'Leftover Panera Catering (Wraps & Salad)', + type: 'meal', + dietaryTags: ['vegetarian'], + location: { id: 'l3harris-engineering-center', name: 'L3Harris Engineering Center (HEC)', latitude: 28.6006421, longitude: -81.1977141 }, + author: 'mock-user-2', + status: 'likely', + confidence: 0.72, + tallies: { present: 4, gone: 1 }, + expiresAt: new Date(Date.now() + 3600000 * 1).toISOString(), + createdAt: new Date(Date.now() - 1800000).toISOString(), + }, + { + id: 'mock-3', + foodName: "Cold Pizza (Domino's Pepperoni)", + type: 'pizza', + dietaryTags: [], + location: { id: 'engineering-i', name: 'Engineering I', latitude: 28.6014069, longitude: -81.198508 }, + author: 'mock-user-3', + status: 'fading', + confidence: 0.38, + tallies: { present: 1, gone: 2 }, + expiresAt: new Date(Date.now() + 1800000).toISOString(), + createdAt: new Date(Date.now() - 7200000).toISOString(), + }, +]; + +export const FeedPage: React.FC = () => { + const { isAuthenticated } = useAuth(); + const [posts, setPosts] = useState([]); + const [loading, setLoading] = useState(true); + const [isFallback, setIsFallback] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [voteErrors, setVoteErrors] = useState>({}); + const [votingIds, setVotingIds] = useState>({}); + const [myVotes, setMyVotes] = useState>({}); + + useEffect(() => { + fetchFeed(); + }, []); + + const fetchFeed = async () => { + setLoading(true); + try { + const data = await postService.getFeed(); + setPosts(data.posts); + setIsFallback(false); + } catch (err) { + console.warn('Backend API connection failed, using high-fidelity mock data. Error:', err); + setPosts(MOCK_POSTS); + setIsFallback(true); + } finally { + setLoading(false); + } + }; + + const handleVote = async (postId: string, voteType: 'present' | 'gone') => { + if (!isAuthenticated) { + setVoteErrors(prev => ({ + ...prev, + [postId]: 'Please sign in to vote!' + })); + setTimeout(() => { + setVoteErrors(prev => { + const updated = { ...prev }; + delete updated[postId]; + return updated; + }); + }, 3000); + return; + } + + // Re-selecting the vote already cast is a no-op - nothing changes. + if (myVotes[postId] === voteType) { + return; + } + + setVotingIds(prev => ({ ...prev, [postId]: true })); + try { + if (isFallback) { + // Mock client vote update for demonstration + const previousVote = myVotes[postId]; + setPosts(prevPosts => + prevPosts.map(post => { + if (post.id === postId) { + const updatedTallies = { ...post.tallies }; + if (previousVote) { + updatedTallies[previousVote] -= 1; + } + updatedTallies[voteType] += 1; + const totalVotes = updatedTallies.present + updatedTallies.gone; + const newConfidence = totalVotes > 0 ? updatedTallies.present / totalVotes : 0.5; + + let newStatus: Post['status'] = 'gone'; + if (newConfidence >= 0.8) newStatus = 'fresh'; + else if (newConfidence >= 0.5) newStatus = 'likely'; + else if (newConfidence >= 0.1) newStatus = 'fading'; + + return { + ...post, + tallies: updatedTallies, + confidence: newConfidence, + status: newStatus, + }; + } + return post; + }) + ); + setMyVotes(prev => ({ ...prev, [postId]: voteType })); + } else { + // Real backend call + const response = await postService.votePost(postId, voteType); + setPosts(prevPosts => + prevPosts.map(post => { + if (post.id === postId) { + return { + ...post, + confidence: response.confidence, + status: response.status, + tallies: response.tallies, + }; + } + return post; + }) + ); + setMyVotes(prev => ({ ...prev, [postId]: voteType })); + } + } catch (err: any) { + setVoteErrors(prev => ({ + ...prev, + [postId]: err.message || 'Already voted or failed to record vote' + })); + setTimeout(() => { + setVoteErrors(prev => { + const updated = { ...prev }; + delete updated[postId]; + return updated; + }); + }, 3000); + } finally { + setVotingIds(prev => ({ ...prev, [postId]: false })); + } + }; + + // Filter posts based on search query. + const filteredPosts = posts.filter(post => { + const query = searchQuery.toLowerCase(); + const matchesFoodName = post.foodName.toLowerCase().includes(query); + const locationName = typeof post.location === 'string' ? post.location : (post.location?.name || ''); + const matchesLocation = locationName.toLowerCase().includes(query); + const matchesTag = (post.dietaryTags || []).some(tag => (tag || '').toLowerCase().includes(query)); + return matchesFoodName || matchesLocation || matchesTag; + }); + + const getStatusBadgeClass = (status: Post['status']) => { + switch (status) { + case 'fresh': return 'badge badge-fresh'; + case 'likely': return 'badge badge-likely'; + case 'fading': return 'badge badge-fading'; + case 'gone': return 'badge badge-gone'; + default: return 'badge'; + } + }; + + const getStatusColor = (status: Post['status']) => { + switch (status) { + case 'fresh': return 'var(--status-fresh-text)'; + case 'likely': return 'var(--status-likely-text)'; + case 'fading': return 'var(--status-fading-text)'; + case 'gone': return 'var(--status-gone-text)'; + default: return 'var(--text-secondary)'; + } + }; + + const getStatusText = (status: Post['status']) => { + switch (status) { + case 'fresh': return 'Fresh / Highly Active'; + case 'likely': return 'Likely Still There'; + case 'fading': return 'Fading / Might Be Gone'; + case 'gone': return 'All Gone / Expired'; + default: return status; + } + }; + + // Build the main body up front + let feedBody: React.ReactNode; + + if (loading) { + feedBody = ( +
+
+

Loading live feed...

+
+ ); + } else if (filteredPosts.length === 0) { + feedBody = ( +
+ 🍃 +

No Food Spotted

+

We couldn't find any active reports matching your search. Why not report some?

+
+ ); + } else { + feedBody = ( +
+ {filteredPosts.map(post => { + const confidencePercent = Math.round((post.confidence ?? 0.5) * 100); + const statusColor = getStatusColor(post.status); + + return ( +
+ {/* Badge Header */} +
+ + {post.status} + + + {new Date(post.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+ + {/* Title & Location */} +
+

{post.foodName}

+

📍 {typeof post.location === 'string' ? post.location : post.location?.name}

+
+ + {/* Dietary Tags */} +
+ {(post.dietaryTags || []).map((tag, idx) => ( + + #{tag} + + ))} +
+ + {/* Confidence Meter Section */} +
+
+ + Confidence Level + + + {confidencePercent}% + +
+ + {/* Meter Track */} +
+
+
+
+ {getStatusText(post.status)} + ({post.tallies.present} / {post.tallies.present + post.tallies.gone} votes) +
+
+ + {/* Card Action Buttons (Voting) */} +
+ + +
+ + {voteErrors[post.id] && ( +

{voteErrors[post.id]}

+ )} +
+ ); + })} +
+ ); + } + + return ( +
+ {/* Header section */} +
+
+

Active Food Feed

+

Confidence-ranked real-time reports of free grub on campus.

+
+ + {/* Search Bar */} +
+ setSearchQuery(e.target.value)} + style={{ paddingLeft: '40px' }} + /> + 🔍 +
+
+ + {isFallback && ( +
+ âš ī¸ + Offline Demo Mode: Backend server is not running or unreachable at port 5001. Showing interactive mock data. + +
+ )} + + {feedBody} +
+ ); +}; + +// Styles for components +const feedPageStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + width: '100%', +}; + +const feedHeaderContainerStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '32px', + flexWrap: 'wrap', + gap: '16px', +}; + +const searchWrapperStyle: React.CSSProperties = { + position: 'relative', + width: '100%', + maxWidth: '360px', +}; + +const searchIconStyle: React.CSSProperties = { + position: 'absolute', + left: '12px', + top: '50%', + transform: 'translateY(-50%)', + fontSize: '1rem', + pointerEvents: 'none', + opacity: 0.7, +}; + +const apiNoticeBannerStyle: React.CSSProperties = { + background: 'var(--color-warning-bg)', + border: '1px solid var(--color-warning-border)', + color: 'var(--color-warning-text)', + padding: '12px 20px', + borderRadius: 'var(--border-radius-sm)', + marginBottom: '24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: '12px', + fontSize: '0.9rem', +}; + +const retryButtonStyle: React.CSSProperties = { + background: 'var(--color-warning-icon)', + border: 'none', + color: '#fff', + padding: '4px 12px', + borderRadius: '4px', + fontWeight: '600', + cursor: 'pointer', + fontSize: '0.8rem', +}; + +const loadingContainerStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '80px 0', + gap: '16px', + color: 'var(--text-secondary)', +}; + +const spinnerStyle: React.CSSProperties = { + width: '32px', + height: '32px', + border: '3px solid var(--border-light)', + borderTop: '3px solid var(--color-primary)', + borderRadius: '50%', + animation: 'spin 1s linear infinite', +}; + +const emptyStateCardStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '64px', + textAlign: 'center', +}; + +const postsGridStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', + gap: '24px', + width: '100%', +}; + +const cardOverrideStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + padding: '24px', + height: '100%', + minHeight: '320px', +}; + +const cardHeaderStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '16px', +}; + +const timeStyle: React.CSSProperties = { + fontSize: '0.8rem', + color: 'var(--text-muted)', + fontWeight: 500, +}; + +const foodTitleStyle: React.CSSProperties = { + fontSize: '1.25rem', + fontWeight: 700, + color: 'var(--text-primary)', + lineHeight: 1.3, + marginBottom: '6px', +}; + +const locationStyle: React.CSSProperties = { + fontSize: '0.9rem', + color: 'var(--text-secondary)', +}; + +const tagsContainerStyle: React.CSSProperties = { + display: 'flex', + flexWrap: 'wrap', + gap: '8px', + margin: '12px 0 20px', +}; + +const tagStyle: React.CSSProperties = { + fontSize: '0.75rem', + fontWeight: 600, + color: 'var(--text-muted)', + background: 'var(--bg-chip)', + padding: '3px 8px', + borderRadius: '4px', + border: '1px solid var(--border-light)', +}; + +const confidenceSectionStyle: React.CSSProperties = { + marginBottom: '20px', + display: 'flex', + flexDirection: 'column', + gap: '6px', +}; + +const confidenceHeaderStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}; + +const meterTrackStyle: React.CSSProperties = { + height: '8px', + width: '100%', + backgroundColor: 'var(--bg-chip)', + borderRadius: '99px', + overflow: 'hidden', + border: '1px solid var(--border-light)', +}; + +const meterLabelsStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + fontSize: '0.75rem', + color: 'var(--text-muted)', +}; + +const actionRowStyle: React.CSSProperties = { + display: 'flex', + gap: '12px', + marginTop: 'auto', +}; + +const voteButtonStyle: React.CSSProperties = { + flex: 1, + padding: '10px', + fontSize: '0.85rem', + fontWeight: 600, + borderRadius: 'var(--border-radius-sm)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + gap: '6px', +}; + +const voteErrorStyle: React.CSSProperties = { + fontSize: '0.75rem', + color: 'var(--color-danger)', + textAlign: 'center', + marginTop: '8px', + fontWeight: 500, +}; \ No newline at end of file diff --git a/web/src/pages/ForgotPasswordPage.tsx b/web/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 0000000..7b053c1 --- /dev/null +++ b/web/src/pages/ForgotPasswordPage.tsx @@ -0,0 +1,160 @@ +/* web/src/pages/ForgotPasswordPage.tsx */ + +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { authService } from '../api/auth.js'; + +export const ForgotPasswordPage: React.FC = () => { + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email) { + setError('Please enter your email address.'); + return; + } + + setError(null); + setSuccessMessage(null); + setLoading(true); + + try { + const response = await authService.forgotPassword(email); + setSuccessMessage(response.message || 'If that account exists, a password reset link has been sent.'); + } catch (err: any) { + console.error(err); + setError(err.message || 'Something went wrong. Please try again.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ 🔑 +

Reset Password

+

Enter your email and we'll send you a password reset link.

+
+ + {error && ( +
+ âš ī¸ + {error} +
+ )} + + {successMessage ? ( +
+
+ ✓ + {successMessage} +
+

+ Be sure to check your spam folder if the email does not arrive in a few minutes. +

+ + Back to Sign In + +
+ ) : ( +
+
+ + setEmail(e.target.value)} + disabled={loading} + required + /> +
+ + +
+ )} + + {!successMessage && ( +
+

+ Remember your password? Sign In +

+
+ )} +
+
+ ); +}; + +// Inline Layout Styles +const containerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '40px 0', + flex: 1, +}; + +const cardStyle: React.CSSProperties = { + width: '100%', + maxWidth: 'var(--max-width-form)', + padding: '40px', +}; + +const formStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '20px', +}; + +const formGroupStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '8px', +}; + +const labelStyle: React.CSSProperties = { + fontSize: '0.85rem', + fontWeight: 600, + color: 'var(--text-secondary)', +}; + +const errorBannerStyle: React.CSSProperties = { + background: 'var(--color-danger-bg)', + border: '1px solid var(--color-danger-border)', + color: 'var(--color-danger)', + padding: '12px 16px', + borderRadius: 'var(--border-radius-sm)', + marginBottom: '20px', + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '0.85rem', +}; + +const successBannerStyle: React.CSSProperties = { + background: 'var(--color-success-bg)', + border: '1px solid var(--color-success-border)', + color: 'var(--color-success)', + padding: '12px 16px', + borderRadius: 'var(--border-radius-sm)', + marginBottom: '20px', + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '0.85rem', + textAlign: 'left', +}; + +const footerStyle: React.CSSProperties = { + marginTop: '28px', + textAlign: 'center', +}; diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..678e982 --- /dev/null +++ b/web/src/pages/LoginPage.tsx @@ -0,0 +1,157 @@ +/* web/src/pages/LoginPage.tsx */ + +import React, { useState } from 'react'; +import { Link, useNavigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext.js'; + +export const LoginPage: React.FC = () => { + const { login } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + // Get the redirect path from location state, default to Feed + const from = (location.state as any)?.from?.pathname || '/'; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email || !password) { + setError('Please fill in all fields.'); + return; + } + + setError(null); + setLoading(true); + + try { + await login(email, password); + navigate(from, { replace: true }); + } catch (err: any) { + console.error(err); + setError(err.message || 'Failed to sign in. Please verify your credentials.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ 🔐 +

Welcome Back

+

Sign in to vote or report free food spots.

+
+ + {error && ( +
+ âš ī¸ + {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + disabled={loading} + required + /> +
+ +
+
+ + Forgot? +
+ setPassword(e.target.value)} + disabled={loading} + required + /> +
+ + +
+ +
+

+ New to FreeFood? Create an account +

+
+
+
+ ); +}; + +// Inline Styles for high-fidelity scaffold +const containerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '40px 0', + flex: 1, +}; + +const cardStyle: React.CSSProperties = { + width: '100%', + maxWidth: 'var(--max-width-form)', + padding: '40px', +}; + +const formStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '20px', +}; + +const formGroupStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '8px', +}; + +const labelStyle: React.CSSProperties = { + fontSize: '0.85rem', + fontWeight: 600, + color: 'var(--text-secondary)', +}; + +const forgotPasswordLinkStyle: React.CSSProperties = { + fontSize: '0.8rem', + fontWeight: 500, + color: 'var(--color-primary-text)', +}; + +const errorBannerStyle: React.CSSProperties = { + background: 'var(--color-danger-bg)', + border: '1px solid var(--color-danger-border)', + color: 'var(--color-danger)', + padding: '12px 16px', + borderRadius: 'var(--border-radius-sm)', + marginBottom: '20px', + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '0.85rem', +}; + +const footerStyle: React.CSSProperties = { + marginTop: '28px', + textAlign: 'center', +}; diff --git a/web/src/pages/ProfilePage.tsx b/web/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..4c7a12f --- /dev/null +++ b/web/src/pages/ProfilePage.tsx @@ -0,0 +1,302 @@ +/* web/src/pages/ProfilePage.tsx */ + +import React, { useState } from 'react'; +import { useAuth } from '../context/AuthContext.js'; +import { authService } from '../api/auth.js'; + +export const ProfilePage: React.FC = () => { + const { user, refreshUser } = useAuth(); + + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const handlePasswordChange = async (e: React.FormEvent) => { + e.preventDefault(); + if (!currentPassword || !newPassword || !confirmPassword) { + setError('Please fill in all fields.'); + return; + } + + if (newPassword !== confirmPassword) { + setError('New passwords do not match.'); + return; + } + + if (newPassword.length < 6) { + setError('New password must be at least 6 characters long.'); + return; + } + + setError(null); + setSuccess(null); + setLoading(true); + + try { + const response = await authService.changePassword({ + currentPassword, + newPassword, + }); + + // Update stored token with the new one issued by the server + localStorage.setItem('auth_token', response.token); + + setSuccess('Password updated successfully! Other sessions have been signed out.'); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + + // Refresh context profile details + await refreshUser(); + } catch (err: any) { + console.error(err); + setError(err.message || 'Failed to update password. Please check your current password.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + {/* Left Side: Account Info Card */} +
+
+
+ {user?.email[0].toUpperCase()} +
+

My Account

+

Member of FreeFood Network

+
+ +
+
+ Email Address + {user?.email} +
+ +
+ Account ID + {user?.id} +
+ +
+ Status + + {user?.verified ? 'Verified Email' : 'Pending Verification'} + +
+
+
+ + {/* Right Side: Change Password Card */} +
+

Security Settings

+

Update your password to secure your account.

+ + {error && ( +
+ âš ī¸ + {error} +
+ )} + + {success && ( +
+ ✓ + {success} +
+ )} + +
+
+ + setCurrentPassword(e.target.value)} + disabled={loading} + required + /> +
+ +
+ + setNewPassword(e.target.value)} + disabled={loading} + required + /> +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={loading} + required + /> +
+ + +
+
+ +
+
+ ); +}; + +// Inline Layout Styles +const containerStyle: React.CSSProperties = { + padding: '10px 0 40px', + width: '100%', +}; + +const profileGridStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', + gap: '32px', + width: '100%', +}; + +const cardStyle: React.CSSProperties = { + padding: '40px', + display: 'flex', + flexDirection: 'column', +}; + +const profileHeaderStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + textAlign: 'center', + marginBottom: '32px', +}; + +const avatarStyle: React.CSSProperties = { + width: '80px', + height: '80px', + borderRadius: '50%', + background: 'linear-gradient(135deg, var(--color-secondary), var(--color-primary))', + color: '#fff', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 700, + fontSize: '2.5rem', + boxShadow: '0 8px 30px rgba(240, 101, 63, 0.35)', +}; + +const infoListStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '20px', +}; + +const infoRowStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + paddingBottom: '12px', + borderBottom: '1px solid var(--border-light)', +}; + +const infoLabelStyle: React.CSSProperties = { + fontSize: '0.85rem', + fontWeight: 600, + color: 'var(--text-secondary)', +}; + +const infoValueStyle: React.CSSProperties = { + fontSize: '0.9rem', + color: 'var(--text-primary)', + fontWeight: 500, +}; + +const infoValueStyleCode: React.CSSProperties = { + fontSize: '0.8rem', + fontFamily: 'ui-monospace, monospace', + color: 'var(--text-muted)', +}; + +const verifiedBadgeStyle: React.CSSProperties = { + fontSize: '0.75rem', + fontWeight: 700, + color: 'var(--color-success)', + background: 'var(--color-success-bg)', + border: '1px solid var(--color-success-border)', + padding: '3px 8px', + borderRadius: '4px', +}; + +const unverifiedBadgeStyle: React.CSSProperties = { + fontSize: '0.75rem', + fontWeight: 700, + color: 'var(--color-warning-text)', + background: 'var(--color-warning-bg)', + border: '1px solid var(--color-warning-border)', + padding: '3px 8px', + borderRadius: '4px', +}; + +const formStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '20px', +}; + +const formGroupStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '8px', +}; + +const labelStyle: React.CSSProperties = { + fontSize: '0.85rem', + fontWeight: 600, + color: 'var(--text-secondary)', +}; + +const errorBannerStyle: React.CSSProperties = { + background: 'var(--color-danger-bg)', + border: '1px solid var(--color-danger-border)', + color: 'var(--color-danger)', + padding: '12px 16px', + borderRadius: 'var(--border-radius-sm)', + marginBottom: '20px', + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '0.85rem', +}; + +const successBannerStyle: React.CSSProperties = { + background: 'var(--color-success-bg)', + border: '1px solid var(--color-success-border)', + color: 'var(--color-success)', + padding: '12px 16px', + borderRadius: 'var(--border-radius-sm)', + marginBottom: '20px', + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '0.85rem', +}; diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..796dd8f --- /dev/null +++ b/web/src/pages/RegisterPage.tsx @@ -0,0 +1,247 @@ +/* web/src/pages/RegisterPage.tsx */ + +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext.js'; +import { authService } from '../api/auth.js'; + +export const RegisterPage: React.FC = () => { + const { register } = useAuth(); + + const [displayName, setDisplayName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [registeredEmail, setRegisteredEmail] = useState(null); + const [resendStatus, setResendStatus] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!displayName || !email || !password || !confirmPassword) { + setError('Please fill in all fields.'); + return; + } + + if (password !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + + if (password.length < 8) { + setError('Password must be at least 8 characters long.'); + return; + } + + setError(null); + setLoading(true); + + try { + await register(displayName, email, password); + // Backend registers user as unverified and sends verification email + setRegisteredEmail(email); + } catch (err: any) { + console.error(err); + setError(err.message || 'Registration failed. The email may already be in use.'); + } finally { + setLoading(false); + } + }; + + const handleResend = async () => { + if (!registeredEmail) return; + setLoading(true); + setResendStatus(null); + try { + const response = await authService.resendVerification(registeredEmail); + setResendStatus(response.message || 'Verification email resent successfully.'); + } catch (err: any) { + setResendStatus(err.message || 'Failed to resend. Please try again.'); + } finally { + setLoading(false); + } + }; + + // If successfully registered, show the email check screen + if (registeredEmail) { + return ( +
+
+
+ âœ‰ī¸ +

Verify Your Email

+

+ We have sent a verification link to {registeredEmail}. + Please check your inbox (and spam folder) and verify your account to log in. +

+ + {resendStatus && ( +
+ â„šī¸ + {resendStatus} +
+ )} + +
+ + + Go to Login + +
+
+
+
+ ); + } + + return ( +
+
+
+ 🌱 +

Create Account

+

Join to share and vote on free food listings.

+
+ + {error && ( +
+ âš ī¸ + {error} +
+ )} + +
+
+ + setDisplayName(e.target.value)} + disabled={loading} + required + /> +
+ +
+ + setEmail(e.target.value)} + disabled={loading} + required + /> +
+ +
+ + setPassword(e.target.value)} + disabled={loading} + required + /> +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={loading} + required + /> +
+ + +
+ +
+

+ Already have an account? Sign In +

+
+
+
+ ); +}; + +// Inline Styles for high-fidelity scaffold +const containerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '40px 0', + flex: 1, +}; + +const cardStyle: React.CSSProperties = { + width: '100%', + maxWidth: 'var(--max-width-form)', + padding: '40px', +}; + +const formStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '20px', +}; + +const formGroupStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '8px', +}; + +const labelStyle: React.CSSProperties = { + fontSize: '0.85rem', + fontWeight: 600, + color: 'var(--text-secondary)', +}; + +const errorBannerStyle: React.CSSProperties = { + background: 'var(--color-danger-bg)', + border: '1px solid var(--color-danger-border)', + color: 'var(--color-danger)', + padding: '12px 16px', + borderRadius: 'var(--border-radius-sm)', + marginBottom: '20px', + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '0.85rem', +}; + +const infoBannerStyle: React.CSSProperties = { + background: 'var(--color-warning-bg)', + border: '1px solid var(--color-warning-border)', + color: 'var(--color-warning-text)', + padding: '12px 16px', + borderRadius: 'var(--border-radius-sm)', + marginBottom: '20px', + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '0.85rem', + textAlign: 'left', +}; + +const footerStyle: React.CSSProperties = { + marginTop: '28px', + textAlign: 'center', +}; \ No newline at end of file diff --git a/web/src/pages/ResetPasswordPage.tsx b/web/src/pages/ResetPasswordPage.tsx new file mode 100644 index 0000000..4278867 --- /dev/null +++ b/web/src/pages/ResetPasswordPage.tsx @@ -0,0 +1,185 @@ +/* web/src/pages/ResetPasswordPage.tsx */ + +import React, { useState, useEffect } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { authService } from '../api/auth.js'; + +export const ResetPasswordPage: React.FC = () => { + const [searchParams] = useSearchParams(); + const token = searchParams.get('token'); + + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (!token) { + setError('Invalid or missing password reset token. Please check your email link.'); + } + }, [token]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!token) { + setError('Missing reset token. Cannot perform reset.'); + return; + } + + if (!password || !confirmPassword) { + setError('Please fill in all fields.'); + return; + } + + if (password !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + + if (password.length < 6) { + setError('Password must be at least 6 characters long.'); + return; + } + + setError(null); + setLoading(true); + + try { + await authService.resetPassword({ token, password }); + setSuccess(true); + } catch (err: any) { + console.error(err); + setError(err.message || 'Failed to reset password. The link may have expired.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ 🔄 +

Create New Password

+

Type in a new secure password for your account.

+
+ + {error && ( +
+ âš ī¸ + {error} +
+ )} + + {success ? ( +
+
+ ✓ + Password successfully reset! You can now log in with your new password. +
+ + Go to Sign In + +
+ ) : ( +
+
+ + setPassword(e.target.value)} + disabled={loading || !token} + required + /> +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={loading || !token} + required + /> +
+ + +
+ )} +
+
+ ); +}; + +// Inline Layout Styles +const containerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '40px 0', + flex: 1, +}; + +const cardStyle: React.CSSProperties = { + width: '100%', + maxWidth: 'var(--max-width-form)', + padding: '40px', +}; + +const formStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '20px', +}; + +const formGroupStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '8px', +}; + +const labelStyle: React.CSSProperties = { + fontSize: '0.85rem', + fontWeight: 600, + color: 'var(--text-secondary)', +}; + +const errorBannerStyle: React.CSSProperties = { + background: 'var(--color-danger-bg)', + border: '1px solid var(--color-danger-border)', + color: 'var(--color-danger)', + padding: '12px 16px', + borderRadius: 'var(--border-radius-sm)', + marginBottom: '20px', + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '0.85rem', +}; + +const successBannerStyle: React.CSSProperties = { + background: 'var(--color-success-bg)', + border: '1px solid var(--color-success-border)', + color: 'var(--color-success)', + padding: '12px 16px', + borderRadius: 'var(--border-radius-sm)', + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '0.85rem', + textAlign: 'left', +}; diff --git a/web/src/router/AppRouter.tsx b/web/src/router/AppRouter.tsx new file mode 100644 index 0000000..17866cf --- /dev/null +++ b/web/src/router/AppRouter.tsx @@ -0,0 +1,111 @@ +/* web/src/router/AppRouter.tsx */ + +import React from 'react'; +import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'; +import { Navbar } from '../components/layout/Navbar.js'; +import { ProtectedRoute } from '../components/layout/ProtectedRoute.js'; + +// Page Imports +import { FeedPage } from '../pages/FeedPage.js'; +import { LoginPage } from '../pages/LoginPage.js'; +import { RegisterPage } from '../pages/RegisterPage.js'; +import { ForgotPasswordPage } from '../pages/ForgotPasswordPage.js'; +import { ResetPasswordPage } from '../pages/ResetPasswordPage.js'; +import { CreatePostPage } from '../pages/CreatePostPage.js'; +import { ProfilePage } from '../pages/ProfilePage.js'; + +// Common Layout Wrapper +const AppLayout: React.FC = () => { + return ( + <> + +
+ +
+
+
+

+ © {new Date().getFullYear()} FreeFood Network. COP4331 Project. +

+
+
+ + ); +}; + +export const AppRouter: React.FC = () => { + return ( + + + }> + {/* Public Routes */} + } /> + } /> + } /> + } /> + } /> + + {/* Protected Member Routes */} + + + + } + /> + + + + } + /> + + {/* Catch-all 404 Route */} + } /> + + + + ); +}; + +// Simple inline 404 View +const NotFound: React.FC = () => { + return ( +
+ 🔍 +

Page Not Found

+

We couldn't find the page you are looking for.

+ Go back to the Live Feed +
+ ); +}; + +import { Link } from 'react-router-dom'; + +const goHomeLinkStyle: React.CSSProperties = { + display: 'inline-block', + background: 'var(--color-primary)', + color: '#fff', + padding: '10px 20px', + borderRadius: 'var(--border-radius-sm)', + fontWeight: 600, + textDecoration: 'none', +}; + +const mainStyle: React.CSSProperties = { + flex: 1, + padding: '32px 24px 64px', + display: 'flex', + flexDirection: 'column', +}; + +const footerStyle: React.CSSProperties = { + width: '100%', + padding: '24px 0', + borderTop: '1px solid var(--border-light)', + textAlign: 'center', + background: 'var(--bg-surface)', +}; diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css new file mode 100644 index 0000000..3edc024 --- /dev/null +++ b/web/src/styles/theme.css @@ -0,0 +1,258 @@ +/* web/src/styles/theme.css */ + +:root { + /* Font Family */ + --font-sans: 'Hanken Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-display: 'Fredoka', var(--font-sans); + + /* Color Palette - Crumb (warm coral, campus food finder) */ + --bg-app: #fff3ec; + --bg-surface: #fff8f3; + --bg-surface-hover: #fff2ec; + --bg-card: #ffffff; + --bg-input: #ffffff; + --bg-chip: #f4ece3; + --coral-tint: #fff2ec; + + --border-light: #f2e5db; + --border-input: #f0dcd0; + --border-focus: rgba(240, 101, 63, 0.35); + + /* Brand Accents */ + --color-primary: #F0653F; /* Coral */ + --color-primary-hover: #d8542f; + --color-primary-glow: rgba(240, 101, 63, 0.28); + + --color-secondary: #F98E6E; /* Coral light */ + --color-secondary-hover: #f2794f; + + /* Coral is too light to hit 4.5:1 as small text on a light background - + used for links instead of --color-primary, which stays untouched for + buttons/badges/gradients where it's paired with a light background. */ + --color-primary-text: #CD3910; + --color-primary-text-hover: #B5320E; + + /* Text Colors */ + --text-primary: #4a352d; + --text-secondary: #95604F; + --text-muted: #996351; + --field-label: #c19585; + --placeholder: #c7a99e; + --ink: #3a2a24; + + /* Freshness status ramp (confidence bands) */ + --status-fresh: #4FB783; + --status-fresh-glow: #e7f6ee; + --status-fresh-text: #2f9d63; + + --status-likely: #8bc23f; + --status-likely-glow: #f1f7e4; + --status-likely-text: #5e8019; + + --status-fading: #e8943a; + --status-fading-glow: #fdefe0; + --status-fading-text: #b06a1c; + + --status-gone: #c7b8ab; + --status-gone-glow: #f4eee6; + --status-gone-text: #8a7a6c; + + /* System banners (form validation, uploads, expiry notices) */ + --color-danger: #c1432a; + --color-danger-bg: #fdeae5; + --color-danger-border: #f6c9ba; + + --color-success: var(--status-fresh-text); + --color-success-bg: var(--status-fresh-glow); + --color-success-border: rgba(79, 183, 131, 0.35); + + --color-warning-bg: #fff2e2; + --color-warning-border: #ffe0c2; + --color-warning-icon: #d98a2f; + --color-warning-text: #a8701f; + + /* Shadows and Effects */ + --shadow-sm: 0 2px 8px -2px rgba(196, 85, 59, 0.18); + --shadow-md: 0 14px 28px -18px rgba(196, 85, 59, 0.5); + --shadow-md-hover: 0 20px 34px -16px rgba(196, 85, 59, 0.55); + --shadow-glow: 0 14px 26px -10px rgba(240, 101, 63, 0.7); + --shadow-modal: 0 40px 80px -24px rgba(58, 26, 18, 0.6); + + --backdrop-blur: blur(16px); + --border-radius-sm: 14px; + --border-radius-md: 20px; + --border-radius-lg: 22px; + --border-radius-pill: 999px; + + /* Layout constraints */ + --max-width-content: 1200px; + --max-width-form: 480px; + + /* Animations */ + --transition-fast: 0.14s ease; + --transition-normal: 0.25s cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Card preset utility */ +.glass-panel { + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-md); + transition: transform var(--transition-normal), border-color var(--transition-normal), box-shadow var(--transition-normal); +} + +.glass-panel:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md-hover); + border-color: var(--border-input); +} + +/* Input presets */ +.glass-input { + width: 100%; + height: 52px; + padding: 0 16px; + background: var(--bg-input); + border: 1.5px solid var(--border-input); + border-radius: var(--border-radius-sm); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 0.95rem; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.glass-input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-glow); +} + +.glass-input::placeholder { + color: var(--placeholder); +} + +/* Primary Button Presets */ +.btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 14px 26px; + background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); + color: #fff; + font-family: var(--font-display); + font-weight: 600; + font-size: 0.95rem; + border: none; + border-radius: var(--border-radius-sm); + cursor: pointer; + box-shadow: var(--shadow-glow); + transition: transform var(--transition-fast), filter var(--transition-fast), box-shadow var(--transition-fast); +} + +.btn-primary:hover { + filter: brightness(1.05); + transform: translateY(-2px); + box-shadow: 0 18px 30px -12px var(--color-primary-glow); +} + +.btn-primary:active { + transform: translateY(1px); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* Secondary Button Presets */ +.btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 14px 26px; + background: #ffffff; + border: 1.5px solid var(--border-input); + color: var(--text-primary); + font-family: var(--font-display); + font-weight: 600; + font-size: 0.95rem; + border-radius: var(--border-radius-sm); + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--coral-tint); + border-color: var(--color-primary); + transform: translateY(-2px); +} + +.btn-secondary:active:not(:disabled) { + transform: translateY(1px); +} + +.btn-secondary:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Badge classes - pill with a status dot */ +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px 5px 9px; + border-radius: var(--border-radius-pill); + font-family: var(--font-display); + font-size: 0.72rem; + font-weight: 600; + text-transform: capitalize; + letter-spacing: 0.01em; +} + +.badge::before { + content: ''; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +.badge-fresh { + background: var(--status-fresh-glow); + color: var(--status-fresh-text); +} +.badge-fresh::before { + background: var(--status-fresh); +} + +.badge-likely { + background: var(--status-likely-glow); + color: var(--status-likely-text); +} +.badge-likely::before { + background: var(--status-likely); +} + +.badge-fading { + background: var(--status-fading-glow); + color: var(--status-fading-text); +} +.badge-fading::before { + background: var(--status-fading); +} + +.badge-gone { + background: var(--status-gone-glow); + color: var(--status-gone-text); +} +.badge-gone::before { + background: var(--status-gone); +}