diff --git a/.codex/config.toml b/.codex/config.toml index aaa379e..1dde217 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -37,6 +37,10 @@ url = "https://mcp.exa.ai/mcp" url = "https://mcp.supabase.com/mcp" startup_timeout_sec = 30 +[mcp_servers.mapbox-mcp] +url = "https://mcp.mapbox.com/mcp" +startup_timeout_sec = 30 + [mcp_servers.github] command = "npx" args = ["-y", "@modelcontextprotocol/server-github"] diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md index 43bc69c..6ee3980 100644 --- a/.gemini/GEMINI.md +++ b/.gemini/GEMINI.md @@ -8,6 +8,12 @@ Everything Claude Code (ECC) is a cross-harness coding system with 36 specialize Gemini support is currently focused on a strong project-local instruction layer via `.gemini/GEMINI.md`, plus the shared MCP catalog and package-manager setup assets shipped by the installer. +## Operational Scope + +- **Default Scope:** You are mandated to work **exclusively on the frontend** codebase. +- **Backend Changes:** Do NOT modify any files in the `api/` directory unless the user explicitly instructs you to perform backend changes for a specific task. +- **Research:** You may read backend files for context or verification, but implementation must remain frontend-only by default. + ## Core Workflow 1. Plan before editing large features. diff --git a/.gemini/antigravity-cli/mcp_config.json b/.gemini/antigravity-cli/mcp_config.json new file mode 100644 index 0000000..de28638 --- /dev/null +++ b/.gemini/antigravity-cli/mcp_config.json @@ -0,0 +1,84 @@ +{ + "mcp": { + "allowed": [ + "magic", + "mui-mcp", + "token-optimizer", + "playwright", + "firecrawl", + "memory", + "context7", + "github", + "mapbox-mcp" + ] + }, + "mcpServers": { + "magic": { + "command": "npx", + "args": [ + "-y", + "@magicuidesign/mcp@latest" + ] + }, + "mui-mcp": { + "command": "npx", + "args": [ + "-y", + "@mui/mcp@latest" + ] + }, + "token-optimizer": { + "command": "npx", + "args": [ + "-y", + "token-optimizer-mcp" + ] + }, + "playwright": { + "command": "npx", + "args": [ + "-y", + "@playwright/mcp", + "--browser", + "chrome" + ] + }, + "firecrawl": { + "command": "npx", + "args": [ + "-y", + "firecrawl-mcp" + ], + "env": { + "FIRECRAWL_API_KEY": "YOUR_FIRECRAWL_KEY_HERE" + } + }, + "memory": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-memory" + ] + }, + "context7": { + "command": "npx", + "args": [ + "-y", + "@upstash/context7-mcp@latest" + ] + }, + "github": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT_HERE" + } + }, + "mapbox-mcp": { + "serverUrl": "https://mcp.mapbox.com/mcp" + } + } +} \ No newline at end of file diff --git a/.gemini/settings.json b/.gemini/settings.json index 8c35f0c..3c798c7 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -1,29 +1,63 @@ { "mcp": { - "allowed": ["magic", "token-optimizer", "playwright", "firecrawl" ,"memory", "context7", "github"], - "excluded": ["experimental-server"] + "allowed": [ + "magic", + "mui-mcp", + "token-optimizer", + "playwright", + "firecrawl", + "memory", + "context7", + "github", + "mapbox-mcp" + ], + "excluded": [ + "experimental-server" + ] }, "mcpServers": { "magic": { "command": "npx", - "args": ["-y", "@magicuidesign/mcp@latest"], + "args": [ + "-y", + "@magicuidesign/mcp@latest" + ], "description": "Magic UI components", "timeout": 30000, "trust": true }, + "mui-mcp": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@mui/mcp@latest" + ] + }, "token-optimizer": { "command": "npx", - "args": ["-y", "token-optimizer-mcp"], + "args": [ + "-y", + "token-optimizer-mcp" + ], "description": "Token optimization for 95%+ context reduction via content deduplication and compression" }, "playwright": { "command": "npx", - "args": ["-y", "@playwright/mcp", "--browser", "chrome"], + "args": [ + "-y", + "@playwright/mcp", + "--browser", + "chrome" + ], "description": "Browser automation and testing via Playwright" }, "firecrawl": { "command": "npx", - "args": ["-y", "firecrawl-mcp"], + "args": [ + "-y", + "firecrawl-mcp" + ], "env": { "FIRECRAWL_API_KEY": "YOUR_FIRECRAWL_KEY_HERE" }, @@ -31,21 +65,35 @@ }, "memory": { "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-memory"], + "args": [ + "-y", + "@modelcontextprotocol/server-memory" + ], "description": "Persistent memory across sessions" }, "context7": { "command": "npx", - "args": ["-y", "@upstash/context7-mcp@latest"], + "args": [ + "-y", + "@upstash/context7-mcp@latest" + ], "description": "Live documentation lookup — use with /docs command and documentation-lookup skill (resolve-library-id, query-docs)." }, "github": { "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-github"], + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT_HERE" }, "description": "GitHub operations - PRs, issues, repos" + }, + "mapbox-mcp": { + "type": "http", + "url": "https://mcp.mapbox.com/mcp", + "description": "Mapbox geospatial MCP via hosted endpoint with OAuth - geocoding, POI search, routing, isochrones, and static maps" } } } \ No newline at end of file diff --git a/.gitignore b/.gitignore index 83972fa..620150d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ dist/ downloads/ eggs/ .eggs/ -lib/ +api/lib/ lib64/ parts/ sdist/ diff --git a/README.md b/README.md index cdef49e..abefdd7 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,156 @@ # LoopHarvest -## Local Supabase Workflow +> Next-generation hyper-local food surplus harvesting and community mutual-aid platform. -This repo now includes the Supabase CLI scaffold under [`supabase/`](/D:/LoopHarvest/supabase). Use the CLI through `npx` so contributors do not need a global install. +--- -Prerequisites: -- Docker Desktop running -- Node/npm available for `npx supabase` +![LoopHarvest Application Mockup](www/public/mockup.png) -Local commands: +## Overview -```bash -npx supabase start -npx supabase status -npx supabase db reset -npx supabase stop -``` +**LoopHarvest** is a modern, high-fidelity web and mobile-responsive application built to combat food waste and foster community resilience. It connects local businesses, growers, and individuals with surplus food to neighbors, non-profits, and mutual-aid groups who can utilize it. By transforming organic waste streams into community resources, LoopHarvest makes hyper-local food sharing seamless, secure, and visually interactive. + +--- + +## Key Features + +- **🌐 Interactive 3D Globe & Flat Map Navigation**: Discover available food resources in real-time. Built with Mapbox GL, featuring dynamic clusters, live resizing, and responsive projection changes. +- **🔄 Dual-Feed Marketplace**: Post available food surpluses (**Food Waste Listings**) or request support for urgent supplies (**Appeals**) with seamless step-by-step submission flows. +- **📊 Interactive Impact Dashboard**: Real-time tracking of community impact indicators, metrics, and matches mapped directly to UN Sustainable Development Goals (SDG). +- **🎨 Custom Design System & Personalization**: + - **Selected Color Themes**: Organic HSL Forest Green and minimalist Classic Charcoal-Dark modes. + - **Dynamic Density Scaling**: Fluid font-scaling to scale text, padding, and gaps proportionately (Spacious, Comfortable, Compact). + - **Accessibility Controls**: Native High-Contrast mode for enhanced visibility and Reduced Motion mode to toggle heavy translations/animations. + - **Smooth Animations**: Tailored GPU-composited Magic UI keyframe motion structures. +- **💬 Real-Time Client Synchronizations**: Immediate, server-side and client-side updates driven by Supabase. + +--- + +## Tech Stack + +### Frontend (`www/`) +- **Framework**: Next.js 16 (App Router) & React 19 (Client/Server Components) +- **Styling**: Tailwind CSS v4 (Natively compiled theme variables & utilities) +- **Visuals**: Lucide React Icons, Mapbox GL (`react-map-gl`), and Recharts +- **Components**: Customized, high-contrast Material-UI (MUI) components + +### Backend & Database (`api/`, `supabase/`) +- **Database & Auth**: Supabase PostgreSQL, row-level security (RLS), and JWT auth +- **Backend Service**: FastAPI (Python 3.11) with Uvicorn for asynchronous endpoints +- **Database Migrations**: Locally orchestrated Supabase CLI migrations + +--- + +## Getting Started + +Follow these steps to run LoopHarvest locally. + +### Prerequisites +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) (required for local Supabase) +- [Node.js v20+](https://nodejs.org/) & [npm v10+](https://www.npmjs.com/) +- [Python 3.11+](https://www.python.org/) & `pip` / `virtualenv` + +--- + +### Step 1: Spin up Local Supabase + +LoopHarvest uses the Supabase CLI to manage database structures and authentication locally. + +1. Ensure Docker Desktop is running. +2. In the repository root, start the local Supabase containers: + ```bash + npx supabase start + ``` +3. Retrieve your local configuration credentials by running: + ```bash + npx supabase status + ``` + +*Local URLs:* +- **Studio (Database GUI):** `http://127.0.0.1:54323` +- **PostgreSQL Connection URI:** `postgresql://postgres:postgres@127.0.0.1:54322/postgres` + +--- + +### Step 2: Set up the Python API Service (`api/`) + +The Python FastAPI backend manages complex workflows, optimization tasks, and analytics schedules. + +1. Navigate to the `api` directory: + ```bash + cd api + ``` +2. Create and activate a Python virtual environment: + ```bash + python -m venv .venv + # On Windows (PowerShell): + .venv\Scripts\Activate.ps1 + # On macOS/Linux: + source .venv/bin/activate + ``` +3. Install the backend dependencies: + ```bash + pip install -r pyproject.toml + # Or using poetry/pip: + pip install -e . + ``` +4. Configure environment variables in `.env` (use `.env.example` as a template). +5. Spin up the development server: + ```bash + uvicorn src.main:app --reload + ``` + +--- + +### Step 3: Run the Next.js Frontend (`www/`) + +1. Navigate to the `www` directory: + ```bash + cd www + ``` +2. Install the frontend dependencies: + ```bash + npm install + ``` +3. Configure the local environment file `.env.local` with the local Supabase credentials: + ```env + NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 + NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_from_supabase_status + ``` +4. Run the development server: + ```bash + npm run dev + ``` +5. Open your browser and navigate to `http://localhost:3000`. + +--- + +## UN Sustainable Development Goal (SDG) Integrations + +We believe that local actions lead to global impact. Our analytics are tied directly to the UN Sustainable Development Goals: + +| SDG Goal | Role in LoopHarvest | Local Realization | +| :--- | :--- | :--- | +| **Goal 2: Zero Hunger** | Reallocating organic surplus streams | Tracking pounds of fresh food saved and meals served to vulnerable communities. | +| **Goal 11: Sustainable Cities** | Decentralized, neighborhood-driven sharing networks | Building highly-localized networks, reducing urban trash loads and emissions. | +| **Goal 12: Responsible Consumption** | Combating food loss across distribution chains | Equipping businesses and growers with analytics to optimize yields and inventory. | +| **Goal 13: Climate Action** | Stripping organic waste from landfills to cut methane | Calculating greenhouse gas (CO₂ and methane equivalents) offsets in real time. | -The local stack uses: -- API: `http://127.0.0.1:54321` -- Studio: `http://127.0.0.1:54323` -- DB: `postgresql://postgres:postgres@127.0.0.1:54322/postgres` +--- -Remote project workflow: +## Project Structure -```bash -npx supabase login -npx supabase link --project-ref "$SUPABASE_PROJECT_REF" -npx supabase db pull remote_schema -npx supabase migration list +```text +├── .agents/ # ECC/Codex runtime loaded capabilities +├── api/ # Python FastAPI server +│ ├── src/ # Application routers, controllers, and services +│ └── tests/ # Backend Pytest suites +├── contexts/ # Core AI configuration policies and standards +├── supabase/ # Supabase configurations, schemas, and seeds +└── www/ # Next.js web client + ├── app/ # Page components and Next.js routes + ├── components/ # Reusable React UI blocks (cards, maps, navigation) + ├── lib/ # State stores, database clients, themes, and types + └── public/ # Visual assets, branding icons, and mockups ``` -Notes: -- `supabase/config.toml` is configured for the app running on `http://localhost:3000`. -- The existing migration files remain the source of truth for schema changes. -- `supabase/seed.sql` is intentionally present so local resets work before seed data is added. diff --git a/antigravity-sessions.md b/antigravity-sessions.md new file mode 100644 index 0000000..dc87a4b --- /dev/null +++ b/antigravity-sessions.md @@ -0,0 +1 @@ +agy --conversation=FFix \ No newline at end of file diff --git a/codex-session.md b/codex-sessions.md similarity index 100% rename from codex-session.md rename to codex-sessions.md diff --git a/mcp-configs/mcp-servers.json b/mcp-configs/mcp-servers.json index b2a3b75..a6e89c0 100644 --- a/mcp-configs/mcp-servers.json +++ b/mcp-configs/mcp-servers.json @@ -81,6 +81,11 @@ "url": "https://mcp.clickhouse.cloud/mcp", "description": "ClickHouse analytics queries" }, + "mapbox-mcp": { + "type": "http", + "url": "https://mcp.mapbox.com/mcp", + "description": "Mapbox geospatial MCP via hosted endpoint with OAuth - geocoding, POI search, routing, isochrones, and static maps" + }, "exa-web-search": { "command": "npx", "args": ["-y", "exa-mcp-server"], diff --git a/scripts.json b/scripts.json index 14ece6e..3199878 100644 --- a/scripts.json +++ b/scripts.json @@ -1,6 +1,9 @@ { "Obsidian": { - "description": "Obsidian vault ingestion on codex session", - "command": "codex --sandbox danger-full-access --add-dir D:/vault/personal/LoopHarvest" + "description": "Obsidian vault ingestion for ai sessions", + "command": { + "codex": "codex --sandbox danger-full-access --add-dir D:/vault/personal/LoopHarvest", + "antigravity": "agy --add-dir D:/vault/personal/LoopHarvest" + } } } \ No newline at end of file diff --git a/www/app/(app)/browse/page.tsx b/www/app/(app)/browse/page.tsx new file mode 100644 index 0000000..a8129d0 --- /dev/null +++ b/www/app/(app)/browse/page.tsx @@ -0,0 +1,403 @@ +'use client'; + +import * as React from 'react'; +import { Search, List as ListIcon, Map as MapIcon, Globe as GlobeIcon, ChevronLeft, ChevronRight, Apple, HeartHandshake } from 'lucide-react'; +import { mockStore, Listing, RequestItem } from '@/lib/mockStore'; +import { CATEGORIES, CategorySlug } from '@/lib/categories'; +import CategoryChip from '@/components/common/CategoryChip'; +import ListingCard from '@/components/cards/ListingCard'; +import RequestCard from '@/components/cards/RequestCard'; +import Globe from '@/components/Globe'; + +export default function BrowseMapPage() { + const [activeTab, setActiveTab] = React.useState<'listings' | 'requests'>('listings'); + const [searchQuery, setSearchQuery] = React.useState(''); + const [selectedCategory, setSelectedCategory] = React.useState(null); + const [listings, setListings] = React.useState(() => mockStore.getListings()); + const [requests, setRequests] = React.useState(() => mockStore.getRequests()); + const [selectedItem, setSelectedUserItem] = React.useState(null); + const [isLargeScreen, setIsLargeScreen] = React.useState(false); + const [showMapOnMobile, setShowMapOnMobile] = React.useState(false); + const [mapProjection, setMapProjection] = React.useState<'globe' | 'mercator'>('globe'); + + const categoriesRef = React.useRef(null); + const [showLeftArrow, setShowLeftArrow] = React.useState(false); + const [showRightArrow, setShowRightArrow] = React.useState(false); + + const checkScroll = React.useCallback(() => { + const el = categoriesRef.current; + if (!el) return; + const { scrollLeft, scrollWidth, clientWidth } = el; + setShowLeftArrow(scrollLeft > 2); + setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 2); + }, []); + + const setCategoriesRef = React.useCallback((node: HTMLDivElement | null) => { + categoriesRef.current = node; + if (node) { + setTimeout(() => { + const { scrollLeft, scrollWidth, clientWidth } = node; + setShowLeftArrow(scrollLeft > 2); + setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 2); + }, 100); + } + }, []); + + const scroll = React.useCallback((direction: 'left' | 'right') => { + const el = categoriesRef.current; + if (!el) return; + const scrollAmount = 200; + const target = el.scrollLeft + (direction === 'left' ? -scrollAmount : scrollAmount); + el.scrollTo({ + left: target, + behavior: 'smooth', + }); + }, []); + + React.useEffect(() => { + const handleResize = () => { + setIsLargeScreen(window.innerWidth >= 768); // md breakpoint is 768px + checkScroll(); + }; + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [checkScroll]); + + // Load store data + const refreshData = React.useCallback(() => { + setListings(mockStore.getListings()); + setRequests(mockStore.getRequests()); + }, []); + + // Filter lists based on inputs + const filteredListings = listings.filter((l) => { + const matchesSearch = l.title.toLowerCase().includes(searchQuery.toLowerCase()) || + l.description.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesCategory = selectedCategory ? l.category === selectedCategory : true; + return matchesSearch && matchesCategory; + }); + + const filteredRequests = requests.filter((r) => { + const matchesSearch = r.title.toLowerCase().includes(searchQuery.toLowerCase()) || + r.description.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesCategory = selectedCategory ? r.category === selectedCategory : true; + return matchesSearch && matchesCategory; + }); + + // Select item to fly to its position on the Globe + const flyToItem = (id: string, isListing: boolean) => { + const itemsList = isListing ? listings : requests; + const item = itemsList.find(i => i.id === id); + if (item) { + setSelectedUserItem(item); + setShowMapOnMobile(true); + } + }; + + const handleClaim = (id: string) => { + const success = mockStore.claimListing(id); + if (success) { + const event = new CustomEvent('post-created', { + detail: 'Listing claimed!' + }); + window.dispatchEvent(event); + refreshData(); + setSelectedUserItem(null); + } + }; + + const handleFulfill = (id: string) => { + const success = mockStore.fulfillRequest(id); + if (success) { + const event = new CustomEvent('post-created', { + detail: 'Offering to fulfill request!' + }); + window.dispatchEvent(event); + refreshData(); + setSelectedUserItem(null); + } + }; + + const renderHeaderFilters = () => { + const activeView = showMapOnMobile + ? (mapProjection === 'globe' ? 'globe' : 'flat') + : 'list'; + + return ( +
+ + {/* Search Input */} +
+
+ + + + setSearchQuery(e.target.value)} + className="w-full h-10 pl-10 pr-4 rounded-lg border border-white/10 bg-[#141414] text-sm text-[#E8EAD8] placeholder-[#5A5C50] focus:border-[#A8D97F] focus:outline-none" + /> +
+ + {/* Beautiful Segmented Selection Control with High-Fidelity Vector Icons */} +
+ + {/* List View Option (Mobile Only) */} + {!isLargeScreen && ( + + )} + + {/* Flat Map Option */} + + + {/* 3D Globe Option */} + +
+
+ + {/* Selector tabs */} +
+ + +
+ + {/* Horizontal Categories with Scroll Chevrons and Fade Masks */} +
+ {/* Left Fading Edge & Chevron */} +
+ +
+ + {/* Scrolling Categories List */} +
+ + {CATEGORIES.map((cat) => ( + setSelectedCategory(cat.slug)} + /> + ))} +
+ + {/* Right Fading Edge & Chevron */} +
+ +
+
+
+ ); + }; + + return ( +
+ {/* Mobile-only header (static, high z-index overlay, does not translate/slide) */} + {!isLargeScreen && ( +
+ {renderHeaderFilters()} +
+ )} + + {/* Main Grid: Split map/list on desktop, stacked sliding panel on mobile */} +
+ + {/* SIDEBAR: Search & Filters list */} +
+ + {/* Desktop-only header inside sidebar */} + {isLargeScreen && renderHeaderFilters()} + + {/* List Content */} +
+ {activeTab === 'listings' ? ( + filteredListings.length > 0 ? ( + filteredListings.map((listing, index) => ( +
flyToItem(listing.id, true)} + className="cursor-pointer animate-fade-in-up" + style={{ animationDelay: `${index * 40}ms`, animationFillMode: 'both' }} + > + +
+ )) + ) : ( +
+ No matching listings on the globe. +
+ ) + ) : ( + filteredRequests.length > 0 ? ( + filteredRequests.map((request, index) => ( +
flyToItem(request.id, false)} + className="cursor-pointer animate-fade-in-up" + style={{ animationDelay: `${index * 40}ms`, animationFillMode: 'both' }} + > + +
+ )) + ) : ( +
+ No matching appeals on the globe. +
+ ) + )} +
+
+ + {/* MAP PANEL: Interactive 3D Globe with dynamic flight and modal control */} +
+ {(isLargeScreen || showMapOnMobile) && ( + { + if (pin) { + const item = listings.find(l => l.id === pin.id) || requests.find(r => r.id === pin.id); + if (item) { + setSelectedUserItem(item); + } + } else { + setSelectedUserItem(null); + } + }} + /> + )} + {showMapOnMobile && ( + + )} +
+ +
+
+ ); +} diff --git a/www/app/(app)/home/page.tsx b/www/app/(app)/home/page.tsx new file mode 100644 index 0000000..89c3c35 --- /dev/null +++ b/www/app/(app)/home/page.tsx @@ -0,0 +1,404 @@ +"use client"; + +import * as React from "react"; +import { Search, Sparkles, Heart, ChevronLeft, ChevronRight } from "lucide-react"; +import { mockStore, Listing, RequestItem } from "@/lib/mockStore"; +import { CATEGORIES, CategorySlug } from "@/lib/categories"; +import CategoryChip from "@/components/common/CategoryChip"; +import ListingCard from "@/components/cards/ListingCard"; +import RequestCard from "@/components/cards/RequestCard"; + +export default function HomeFeed() { + const [activeTab, setActiveTab] = React.useState<"listings" | "requests">( + "listings", + ); + const [searchQuery, setSearchQuery] = React.useState(""); + const [selectedCategory, setSelectedCategory] = + React.useState(null); + const [listings, setListings] = React.useState(() => + mockStore.getListings(), + ); + const [requests, setRequests] = React.useState(() => + mockStore.getRequests(), + ); + const [stats, setStats] = React.useState(() => mockStore.getUserStats()); + + const categoriesRef = React.useRef(null); + const [showLeftArrow, setShowLeftArrow] = React.useState(false); + const [showRightArrow, setShowRightArrow] = React.useState(false); + + const checkScroll = React.useCallback(() => { + const el = categoriesRef.current; + if (!el) return; + const { scrollLeft, scrollWidth, clientWidth } = el; + setShowLeftArrow(scrollLeft > 2); + setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 2); + }, []); + + const setCategoriesRef = React.useCallback((node: HTMLDivElement | null) => { + categoriesRef.current = node; + if (node) { + setTimeout(() => { + const { scrollLeft, scrollWidth, clientWidth } = node; + setShowLeftArrow(scrollLeft > 2); + setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 2); + }, 100); + } + }, []); + + const scroll = React.useCallback((direction: "left" | "right") => { + const el = categoriesRef.current; + if (!el) return; + const scrollAmount = 200; + const target = + el.scrollLeft + (direction === "left" ? -scrollAmount : scrollAmount); + el.scrollTo({ + left: target, + behavior: "smooth", + }); + }, []); + + React.useEffect(() => { + const handleResize = () => { + checkScroll(); + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [checkScroll]); + + // Load initial store data + const refreshData = React.useCallback(() => { + setListings(mockStore.getListings()); + setRequests(mockStore.getRequests()); + setStats(mockStore.getUserStats()); + }, []); + + React.useEffect(() => { + // Add event listener to refresh data if a post is created elsewhere + const handleRefresh = () => { + refreshData(); + }; + window.addEventListener("post-created", handleRefresh); + return () => window.removeEventListener("post-created", handleRefresh); + }, [refreshData]); + + // Handle claiming a listing + const handleClaim = (id: string) => { + const success = mockStore.claimListing(id); + if (success) { + // Trigger toast through the CustomEvent that layout.tsx listens to + const event = new CustomEvent("post-created", { + detail: "Listing claimed! Points added to your profile.", + }); + window.dispatchEvent(event); + refreshData(); + } + }; + + // Handle fulfilling a request + const handleFulfill = (id: string) => { + const success = mockStore.fulfillRequest(id); + if (success) { + const event = new CustomEvent("post-created", { + detail: "Offering to fulfill request! Requester has been notified.", + }); + window.dispatchEvent(event); + refreshData(); + } + }; + + // Filter listings based on search and category selection + const filteredListings = listings.filter((l) => { + const matchesSearch = + l.title.toLowerCase().includes(searchQuery.toLowerCase()) || + l.description.toLowerCase().includes(searchQuery.toLowerCase()) || + l.city.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesCategory = selectedCategory + ? l.category === selectedCategory + : true; + return matchesSearch && matchesCategory; + }); + + // Filter requests based on search and category selection + const filteredRequests = requests.filter((r) => { + const matchesSearch = + r.title.toLowerCase().includes(searchQuery.toLowerCase()) || + r.description.toLowerCase().includes(searchQuery.toLowerCase()) || + r.city.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesCategory = selectedCategory + ? r.category === selectedCategory + : true; + return matchesSearch && matchesCategory; + }); + + // Matches carousel data (e.g. low-distance items or featured) + const matches = React.useMemo(() => { + if (activeTab === "listings") { + return listings.filter((l) => l.status === "open").slice(0, 3); + } else { + return requests.filter((r) => r.status === "open").slice(0, 3); + } + }, [listings, requests, activeTab]); + + return ( +
+
+ {/* Search & Filter Header */} +
+
+ + + + setSearchQuery(e.target.value)} + className="w-full h-12 pl-10 pr-4 rounded-xl border border-white/10 bg-[#141414] text-[#E8EAD8] placeholder-[#5A5C50] text-sm focus:border-[#A8D97F] focus:outline-none transition-all" + /> +
+ + {/* Quick Stats Bar */} +
+
+ + + Points: + + + {stats.loopPoints} XP + +
+
+
+ + + Diverted: + + + {stats.kgDiverted} kg + +
+
+
+ + {/* Categories Horizontal Scroll with Chevrons & Gradient Fade Masks */} +
+

+ Browse by Category +

+
+ {/* Left Fading Edge & Chevron */} +
+ +
+ + {/* Scrolling Categories List */} +
+ + {CATEGORIES.map((cat) => ( + setSelectedCategory(cat.slug)} + /> + ))} +
+ + {/* Right Fading Edge & Chevron */} +
+ +
+
+
+ + {/* Matched for You (Carousel) */} + {matches.length > 0 && !searchQuery && !selectedCategory && ( +
+
+
+ +

+ Highly Compatible Matches +

+
+ + Based on distance + +
+ +
+ {activeTab === "listings" + ? (matches as Listing[]).map((listing, index) => ( +
+ +
+ )) + : (matches as RequestItem[]).map((request, index) => ( +
+ +
+ ))} +
+
+ )} + + {/* Listings vs Requests Selector */} +
+
+ {/* Tab switch */} +
+ + +
+ + + Showing{" "} + {activeTab === "listings" + ? filteredListings.length + : filteredRequests.length}{" "} + items + +
+ + {/* Grid Layout of Items */} + {activeTab === "listings" ? ( + filteredListings.length > 0 ? ( +
+ {filteredListings.map((listing, index) => ( +
+ +
+ ))} +
+ ) : ( +
+ 🍂 +

+ No available waste found +

+

+ Try adjusting your search queries or selecting a different + material category. +

+
+ ) + ) : filteredRequests.length > 0 ? ( +
+ {filteredRequests.map((request, index) => ( +
+ +
+ ))} +
+ ) : ( +
+ 🙏 +

+ No active appeals found +

+

+ Nobody is asking for materials matching your criteria right now. + Check back later! +

+
+ )} +
+
+
+ ); +} diff --git a/www/app/(app)/impact/page.tsx b/www/app/(app)/impact/page.tsx new file mode 100644 index 0000000..b2d017d --- /dev/null +++ b/www/app/(app)/impact/page.tsx @@ -0,0 +1,259 @@ +'use client'; + +import * as React from 'react'; +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { Leaf, BarChart2, TrendingUp, Sparkles, Droplet, CloudLightning, Utensils, Building2, Recycle, Globe, type LucideIcon } from 'lucide-react'; +import { mockStore } from '@/lib/mockStore'; + +// Mock charts dataset representing daily carbon offset +const WEEKLY_DATA = [ + { day: 'Mon', kg: 4.2, co2: 2.1, water: 210 }, + { day: 'Tue', kg: 5.8, co2: 2.9, water: 290 }, + { day: 'Wed', kg: 8.5, co2: 4.2, water: 425 }, + { day: 'Thu', kg: 10.1, co2: 5.0, water: 505 }, + { day: 'Fri', kg: 12.4, co2: 6.2, water: 620 }, + { day: 'Sat', kg: 15.0, co2: 7.5, water: 750 }, + { day: 'Sun', kg: 17.4, co2: 8.7, water: 870 }, +]; + +interface SDGCard { + number: number; + title: string; + emoji: string; + icon: LucideIcon; + color: string; + description: string; +} + +const SDG_GOALS: SDGCard[] = [ + { + number: 2, + title: "Zero Hunger", + emoji: "🍲", + icon: Utensils, + color: "#E5A823", + description: "Routing surplus bakery items and fresh crops to community larders, preventing nutrition loss." + }, + { + number: 11, + title: "Sustainable Cities", + emoji: "🏙️", + icon: Building2, + color: "#F26E22", + description: "Forming localized organic recycling loops to reduce solid municipal waste entering city landfills." + }, + { + number: 12, + title: "Responsible Consumption", + emoji: "🔄", + icon: Recycle, + color: "#C98F1D", + description: "Fostering standard circular systems that prioritize material re-utilization over waste generation." + }, + { + number: 13, + title: "Climate Action", + emoji: "🍀", + icon: Globe, + color: "#3F7E44", + description: "Halting methane emissions by keeping organic matter out of anaerobic, compressed landfill zones." + } +]; + +export default function ImpactAnalyticsPage() { + const [stats] = React.useState(() => mockStore.getUserStats()); + const [hoveredCard, setHoveredCard] = React.useState(null); + + return ( +
+
+ + {/* Analytics Summary Banner */} +
+
+ +
+
+ + ESG Validation Verified +
+

Your Cumulative Offset

+

+ Your circular resource contributions save equivalent atmospheric carbon release and virtual water overheads. +

+
+ +
+ Loop Level Score + {stats.loopPoints} XP +
+
+ + {/* 3 Columns Metrics row */} +
+ + {/* Scraps Card */} +
+
+ +
+
+ Scraps Diverted + {stats.kgDiverted} kg +
+
+ + {/* Water Saved Card */} +
+
+ +
+
+ Water Conserved + {stats.waterSaved} L +
+
+ + {/* CO2 Mitigated Card */} +
+
+ +
+
+ CO2 Prevented + {stats.co2Saved} kg +
+
+ +
+ + {/* Recharts Graphical Line Area Chart */} +
+
+
+ +

Resource Routing Progress

+
+ Past 7 Days +
+ + {/* Recharts wrapper */} +
+ + + + + + + + + + + + + + + +
+
+ + {/* UN SDG Goals Section */} +
+
+
+ +

+ UN Sustainable Development Goals (SDG) Mapping +

+
+

+ Connecting localized actions to global sustainability standards +

+
+ +
+ {SDG_GOALS.map((sdg) => { + const isHovered = hoveredCard === sdg.number; + return ( +
setHoveredCard(sdg.number)} + onMouseLeave={() => setHoveredCard(null)} + style={{ + borderColor: isHovered ? sdg.color : `${sdg.color}25`, + backgroundColor: isHovered ? '#1B1B1B' : '#141414', + boxShadow: isHovered + ? `0 10px 25px -5px ${sdg.color}1A, 0 8px 10px -6px ${sdg.color}12` + : '0 4px 20px -2px rgba(0, 0, 0, 0.4)', + transform: isHovered ? 'translateY(-4px)' : 'none', + transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)' + }} + className="group rounded-2xl border p-5 flex flex-col justify-between min-h-[11rem] cursor-default" + > +
+
+ +
+ + Goal {sdg.number} + +
+ +
+

+ {sdg.title} +

+

+ {sdg.description} +

+
+
+ ); + })} +
+
+ +
+
+ ); +} diff --git a/www/app/(app)/layout.tsx b/www/app/(app)/layout.tsx new file mode 100644 index 0000000..64dcfca --- /dev/null +++ b/www/app/(app)/layout.tsx @@ -0,0 +1,234 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { X, Apple, MessageSquare, Check, LogOut } from 'lucide-react'; +import BottomNav from '@/components/navigation/BottomNav'; +import NavigationRail from '@/components/navigation/NavigationRail'; +import { logout } from '@/app/login/actions'; + +export default function AppLayout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const [mounted, setMounted] = React.useState(false); + const [isPostSheetOpen, setIsPostSheetOpen] = React.useState(false); + const [isSignOutConfirmOpen, setIsSignOutConfirmOpen] = React.useState(false); + const [toast, setToast] = React.useState<{ message: string; show: boolean }>({ message: '', show: false }); + + const triggerPostSheet = () => { + setIsPostSheetOpen(prev => !prev); + }; + + const navigateTo = (path: string) => { + setIsPostSheetOpen(false); + router.push(path); + }; + + const showToastNotification = (msg: string) => { + setToast({ message: msg, show: true }); + setTimeout(() => { + setToast(prev => ({ ...prev, show: false })); + }, 4000); + }; + + const [isSidebarCollapsed, setIsSidebarCollapsed] = React.useState(false); + + React.useEffect(() => { + setTimeout(() => { + setMounted(true); + }, 0); + try { + const stored = localStorage.getItem('fl_sidebar_collapsed'); + if (stored !== null) { + setTimeout(() => { + setIsSidebarCollapsed(stored === 'true'); + }, 0); + } + } catch (e) { + console.warn("Storage access failed (private browsing):", e); + } + }, []); + + const handleToggleCollapse = () => { + setIsSidebarCollapsed(prev => { + const next = !prev; + try { + localStorage.setItem('fl_sidebar_collapsed', String(next)); + window.dispatchEvent(new CustomEvent('sidebar-collapsed-change', { detail: next })); + } catch (e) { + console.warn("Writing to storage failed:", e); + } + return next; + }); + }; + + // Listen to custom posting events for feedback + React.useEffect(() => { + const handlePostCreated = (e: Event) => { + const customEvent = e as CustomEvent; + showToastNotification(customEvent.detail || 'Post created successfully!'); + }; + window.addEventListener('post-created', handlePostCreated); + return () => window.removeEventListener('post-created', handlePostCreated); + }, []); + + // Listen to custom signout events (for mobile settings triggers) + React.useEffect(() => { + const handleTriggerSignOut = () => { + setIsSignOutConfirmOpen(true); + }; + window.addEventListener('trigger-signout', handleTriggerSignOut); + return () => window.removeEventListener('trigger-signout', handleTriggerSignOut); + }, []); + + return ( +
+ {/* Navigation Rails & Drawers */} + setIsSignOutConfirmOpen(true)} + isCollapsed={isSidebarCollapsed} + onToggleCollapse={handleToggleCollapse} + /> + + {/* Main Content Area */} + {/* padding bottom is 80px on mobile to prevent navbar covering content, padding left adjusts dynamically with transition */} +
+ {mounted ? children : ( +
+
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + LoopHarvest +
+
+

LoopHarvest

+

Syncing Organic Cycles...

+
+
+
+ )} +
+ + {/* Bottom Nav (Mobile only) */} + + + {/* Post Bottom Sheet (M3 Modal overlay) */} + {isPostSheetOpen && ( +
setIsPostSheetOpen(false)} + > +
e.stopPropagation()} + > + {/* Drag Handle */} +
+ +
+

+ What do you want to post? +

+ +
+ +
+ + + +
+
+
+ )} + + {/* Sign Out Confirmation Modal (Premium Glassmorphic Dialog) */} + {isSignOutConfirmOpen && ( +
setIsSignOutConfirmOpen(false)} + > +
e.stopPropagation()} + > + {/* Warning Icon Badge */} +
+ +
+ +
+

+ Sign Out? +

+

+ Are you sure you want to sign out? You will need to log back in to manage your posts and active claims. +

+
+ +
+ + +
+
+
+ )} + + {/* Snackbar / Toast Notifications */} + {toast.show && ( +
+
+
+ +
+

{toast.message}

+
+
+ )} +
+ ); +} diff --git a/www/app/(app)/notifications/page.tsx b/www/app/(app)/notifications/page.tsx new file mode 100644 index 0000000..39272ff --- /dev/null +++ b/www/app/(app)/notifications/page.tsx @@ -0,0 +1,144 @@ +'use client'; + +import * as React from 'react'; +import { Bell, Trash2, CheckCircle2, Inbox, Calendar, MessageSquare, ArrowRight } from 'lucide-react'; +import { mockStore, NotificationItem } from '@/lib/mockStore'; +import { catMap } from '@/lib/categories'; + +export default function NotificationsPage() { + const [notifications, setNotifications] = React.useState(() => mockStore.getNotifications()); + + const refreshData = React.useCallback(() => { + setNotifications(mockStore.getNotifications()); + }, []); + + const handleMarkAllRead = () => { + mockStore.markAllNotificationsAsRead(); + refreshData(); + // Dispatch refresh event + window.dispatchEvent(new CustomEvent('post-created', { detail: 'All notifications marked as read!' })); + }; + + const handleDelete = (id: string) => { + mockStore.deleteNotification(id); + refreshData(); + }; + + const getIconForType = (type: NotificationItem['type']) => { + switch (type) { + case 'request_matched': + return ; + case 'listing_claimed': + return ; + case 'pickup_confirmed': + return ; + case 'match_found': + return ; + case 'review_received': + return ; + default: + return ; + } + }; + + return ( +
+
+
+

+ Notification Center +

+ {notifications.some(n => n.status === 'unread') ? ( + + ) : null} +
+ +
+

+ Recent Alerts +

+ + {notifications.filter(n => n.status === 'unread').length} unread + +
+ + {notifications.length > 0 ? ( +
+ {notifications.map((notif, idx) => { + const catColor = notif.category ? (catMap[notif.category]?.color || '#A8D97F') : '#A8AA98'; + const isUnread = notif.status === 'unread'; + + return ( +
+ {/* Category-Colored/Type Indicator Icon Container */} +
+ {getIconForType(notif.type)} +
+ + {/* Copy details */} +
+
+

+ {notif.title} +

+ + {notif.time} + +
+

+ {notif.body} +

+
+ + {/* Action delete floating button */} + + + {/* Unread dot indicator */} + {isUnread && ( + + )} +
+ ); + })} +
+ ) : ( +
+ +

Inbox is completely clean!

+

+ When neighbours post compatible food waste matches, list resources, or coordinate claims near you, alerts will populate here. +

+
+ )} + +
+
+ ); +} diff --git a/www/app/(app)/post/listing/page.tsx b/www/app/(app)/post/listing/page.tsx new file mode 100644 index 0000000..07ebb7d --- /dev/null +++ b/www/app/(app)/post/listing/page.tsx @@ -0,0 +1,365 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { ChevronRight, ChevronLeft, Send, Sparkles, Scale, Info } from 'lucide-react'; +import { mockStore } from '@/lib/mockStore'; +import { CATEGORIES, CategorySlug, catMap } from '@/lib/categories'; + +const PHOTO_PRESETS: Record = { + 'vegetable-scraps': [ + 'https://images.unsplash.com/photo-1598170845058-32b9d6a5da37?q=80&w=600&auto=format&fit=crop', + 'https://images.unsplash.com/photo-1557844352-761f2565b576?q=80&w=600&auto=format&fit=crop' + ], + 'coffee-grounds': [ + 'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?q=80&w=600&auto=format&fit=crop', + 'https://images.unsplash.com/photo-1509042239860-f550ce710b93?q=80&w=600&auto=format&fit=crop' + ], + 'fruit-waste': [ + 'https://images.unsplash.com/photo-1619546813926-a78fa6372cd2?q=80&w=600&auto=format&fit=crop', + 'https://images.unsplash.com/photo-1550258987-190a2d41a8ba?q=80&w=600&auto=format&fit=crop' + ], + 'spent-grain': [ + 'https://images.unsplash.com/photo-1574316071802-0d68497b05f5?q=80&w=600&auto=format&fit=crop', + 'https://images.unsplash.com/photo-1532634922-8fe0b757fb13?q=80&w=600&auto=format&fit=crop' + ], + 'bread-stale': [ + 'https://images.unsplash.com/photo-1549931319-a545dcf3bc73?q=80&w=600&auto=format&fit=crop', + 'https://images.unsplash.com/photo-1509440159596-0249088772ff?q=80&w=600&auto=format&fit=crop' + ], + 'fish-scraps': [ + 'https://images.unsplash.com/photo-1534604973900-c43ab4c2e0ab?q=80&w=600&auto=format&fit=crop' + ], + 'fruit-peels': [ + 'https://images.unsplash.com/photo-1611080626919-7cf5a9dbab5b?q=80&w=600&auto=format&fit=crop', + 'https://images.unsplash.com/photo-1590005354167-6da97870c913?q=80&w=600&auto=format&fit=crop' + ], + 'cooking-oil': [ + 'https://images.unsplash.com/photo-1474979266404-7eaacbcd87c5?q=80&w=600&auto=format&fit=crop' + ], + 'other': [ + 'https://images.unsplash.com/photo-1542601906990-b4d3fb778b09?q=80&w=600&auto=format&fit=crop' + ] +}; + +export default function PostListingPage() { + const router = useRouter(); + const [step, setStep] = React.useState(1); + + // Form Fields State + const [title, setTitle] = React.useState(''); + const [category, setCategory] = React.useState('vegetable-scraps'); + const [quantity, setQuantity] = React.useState(1.0); + const [unit, setUnit] = React.useState('kg'); + const [description, setDescription] = React.useState(''); + const [selectedPhoto, setSelectedPhoto] = React.useState(() => { + const presets = PHOTO_PRESETS['vegetable-scraps'] || PHOTO_PRESETS['other']; + return presets[0] || ''; + }); + + const activeCatPresets = PHOTO_PRESETS[category] || PHOTO_PRESETS['other']; + + const handleNext = () => { + if (step === 1 && !title.trim()) { + alert('Please fill in a descriptive title.'); + return; + } + if (step === 2 && quantity <= 0) { + alert('Quantity must be greater than zero.'); + return; + } + if (step === 3 && !description.trim()) { + alert('Please fill in a short details description.'); + return; + } + setStep(prev => prev + 1); + }; + + const handlePrev = () => { + setStep(prev => prev - 1); + }; + + const handlePublish = () => { + mockStore.addListing( + title, + category, + quantity, + unit, + description, + selectedPhoto + ); + + // Dispatch custom post created event to trigger Layout toasts + const event = new CustomEvent('post-created', { + detail: `Donation posted! "${title}" is now listed near you.` + }); + window.dispatchEvent(event); + + router.push('/home'); + }; + + return ( +
+
+ + Post Food Waste + +
+ + {/* Progress Stepper Bar */} +
+
+ {[1, 2, 3, 4].map((num) => ( +
+
num + ? 'bg-[#2A4A10] text-[#A8D97F]' + : 'bg-[#1B1B1B] text-[#5A5C50] border border-white/6' + }`} + > + {num} +
+ {num < 4 && ( +
num ? 'bg-[#2A4A10]' : 'bg-[#1B1B1B]' + }`} + /> + )} +
+ ))} +
+
+ + {/* Inner Form content container */} +
+ + {/* STEP 1: TITLE & CATEGORY */} + {step === 1 && ( +
+
+

Describe your organic material

+

Choose a precise title so composters can identify quality.

+
+ +
+
+ + setTitle(e.target.value)} + className="w-full h-12 px-4 rounded-xl border border-white/10 bg-[#141414] text-sm focus:border-[#A8D97F] focus:outline-none transition-all" + /> +
+ +
+ +
+ {CATEGORIES.map((cat) => ( + + ))} +
+
+
+
+ )} + + {/* STEP 2: QUANTITY & WEIGHT */} + {step === 2 && ( +
+
+

How much are you donating?

+

Accurate metrics enable carbon reduction claims validation.

+
+ +
+
+ + Handoff Weight +
+ +
+
+ + setQuantity(Math.max(0.1, parseFloat(e.target.value) || 0.1))} + className="w-full h-12 px-4 rounded-xl border border-white/10 bg-[#0A0A0A] text-center font-mono font-bold text-base text-[#E8EAD8] focus:border-[#A8D97F] focus:outline-none" + /> +
+ +
+ + +
+
+
+
+ )} + + {/* STEP 3: PHOTO & DETAILS */} + {step === 3 && ( +
+
+

Add Details & Visual Verification

+

Select preset stock images recommended for your category slug.

+
+ +
+
+ +