From 2e9b00e83d940303925904f749e42e70b2b5dc94 Mon Sep 17 00:00:00 2001 From: magmus2006 Date: Tue, 26 May 2026 00:41:47 +0100 Subject: [PATCH 1/7] Add files via upload --- src/.env.example | 16 ++++ src/README.md | 203 +++++++++++++++++++++++++++++++++++++++++++ src/manage.py | 15 ++++ src/requirements.txt | 6 ++ 4 files changed, 240 insertions(+) create mode 100644 src/.env.example create mode 100644 src/README.md create mode 100644 src/manage.py create mode 100644 src/requirements.txt diff --git a/src/.env.example b/src/.env.example new file mode 100644 index 0000000..be96d3a --- /dev/null +++ b/src/.env.example @@ -0,0 +1,16 @@ +# ── Django Backend .env ──────────────────────────────────────────────────────── +# Copy to .env and fill in your values + +DJANGO_SECRET_KEY=change-this-to-a-long-random-string-in-production +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1 + +# PostgreSQL (leave empty to use SQLite for development) +# DB_NAME=swappit +# DB_USER=postgres +# DB_PASSWORD=yourpassword +# DB_HOST=localhost +# DB_PORT=5432 + +# CORS — allow the Vite dev server +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..1d25005 --- /dev/null +++ b/src/README.md @@ -0,0 +1,203 @@ +# SWAPPIT — Django REST API + +Backend API for the SWAPPIT item-swap platform. +Connects the React/Vite frontend to a real database. + +--- + +## Architecture + +``` +Frontend (React/Vite) Backend (Django REST) +────────────────────── ────────────────────────────── +src/services/api.js ←───→ /api/v1/auth/ (JWT auth) +src/context/AppContext ←───→ /api/v1/items/ (items CRUD) + ←───→ /api/v1/exchanges/ (swap proposals) + ←───→ /api/v1/reviews/ (ratings) + ←───→ /api/v1/notifications/ +``` + +--- + +## Quick Start + +### 1. Install dependencies +```bash +cd swappit_api +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt +``` + +### 2. Configure environment +```bash +cp .env.example .env +# Edit .env — set DJANGO_SECRET_KEY at minimum +``` + +### 3. Run migrations +```bash +python manage.py migrate +python manage.py createsuperuser # optional: for /admin/ +``` + +### 4. Start the server +```bash +python manage.py runserver +# API available at http://localhost:8000/api/v1/ +``` + +### 5. Connect the frontend +``` +# In your React project, copy: +# frontend_service/api.js → src/services/api.js +# frontend_service/AppContext.jsx → src/context/AppContext.jsx + +# Add to your .env: +VITE_API_URL=http://localhost:8000/api/v1 +``` + +--- + +## API Reference + +### Auth +| Method | Endpoint | Description | Auth | +|--------|----------|-------------|------| +| POST | `/auth/signup/` | Create account | ❌ | +| POST | `/auth/signin/` | Login → JWT tokens | ❌ | +| POST | `/auth/signout/` | Blacklist refresh token | ✅ | +| GET | `/auth/me/` | Get own profile | ✅ | +| PATCH | `/auth/me/` | Update profile + photo | ✅ | +| POST | `/auth/token/refresh/` | Refresh access token | ❌ | +| GET | `/auth/users//` | Public user profile | ✅ | + +**Signup body:** +```json +{ + "firstName": "Armel", + "lastName": "Kamga", + "email": "armel@example.com", + "password": "pass123", + "contact": "+237 6 71 23 45 67", + "bio": "Gadget collector" +} +``` +**Response:** +```json +{ + "ok": true, + "tokens": { "access": "...", "refresh": "..." }, + "user": { "id": 1, "firstName": "Armel", ... } +} +``` + +--- + +### Items +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/items/` | All available items (search/filter/sort) | +| POST | `/items/` | Create item (multipart for image) | +| GET | `/items//` | Item detail | +| DELETE | `/items//` | Delete own item | +| GET | `/items/mine/` | My items | +| GET | `/items/suggestions/` | Smart swap suggestions | + +**Query params for GET /items/:** +- `?search=iphone` — text search +- `?category=Electronics` — filter by category +- `?sort=recent|value_asc|value_desc` + +**Create item body:** +```json +{ + "name": "iPhone 13 Pro", + "category": "Electronics", + "description": "Excellent condition...", + "condition": "Excellent", + "value": 180000, + "emoji": "📱" +} +``` + +--- + +### Exchanges +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/exchanges/` | My exchanges (as proposer or owner) | +| POST | `/exchanges/` | Propose a swap | +| POST | `/exchanges//respond/` | Accept or reject | + +**Propose body:** +```json +{ "offeredItemId": 1, "requestedItemId": 5 } +``` +**Respond body:** +```json +{ "accepted": true } +``` + +--- + +### Reviews +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/reviews/?userId=` | Reviews for a user | +| POST | `/reviews/` | Submit a review | + +**Review body:** +```json +{ + "targetUserId": 2, + "exchangeId": 10, + "stars": 5, + "comment": "Smooth exchange!" +} +``` + +--- + +### Notifications +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/notifications/` | My notifications + unreadCount | +| POST | `/notifications//read/` | Mark one as read | +| POST | `/notifications/read-all/` | Mark all as read | + +--- + +## Fairness Calculation + +The API mirrors the AppContext fairness logic exactly: + +| Ratio | Label | Meaning | +|-------|-------|---------| +| 0.92 – 1.08 | **Balanced** | Nearly equal value | +| 0.72 – 1.39 | **Acceptable** | Slight imbalance | +| Outside | **Unfair** | Large value gap | + +--- + +## Project Structure + +``` +swappit_api/ +├── manage.py +├── requirements.txt +├── .env.example +├── swappit_api/ +│ ├── settings.py ← JWT, CORS, DRF config +│ └── urls.py ← Root URL routing +└── apps/ + ├── users/ ← Custom User model + JWT auth + ├── items/ ← Item CRUD + suggestions + ├── exchanges/ ← Swap proposal flow + ├── reviews/ ← Star ratings + avg recalc + └── notifications/ ← In-app notification system + +frontend_service/ + ├── api.js ← Copy to src/services/api.js + └── AppContext.jsx ← Copy to src/context/AppContext.jsx +``` diff --git a/src/manage.py b/src/manage.py new file mode 100644 index 0000000..815309b --- /dev/null +++ b/src/manage.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swappit_api.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError("Couldn't import Django.") from exc + execute_from_command_line(sys.argv) + +if __name__ == '__main__': + main() diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..36db191 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,6 @@ +Django==5.0.4 +djangorestframework==3.15.1 +djangorestframework-simplejwt==5.3.1 +django-cors-headers==4.3.1 +Pillow==10.3.0 +python-dotenv==1.0.1 From e32078698c8a62566e013fa65825771aee36ed68 Mon Sep 17 00:00:00 2001 From: magmus2006 Date: Tue, 26 May 2026 00:43:03 +0100 Subject: [PATCH 2/7] Add files via upload --- src/context/AppContext-1.jsx | 238 +++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 src/context/AppContext-1.jsx diff --git a/src/context/AppContext-1.jsx b/src/context/AppContext-1.jsx new file mode 100644 index 0000000..c6ebcb7 --- /dev/null +++ b/src/context/AppContext-1.jsx @@ -0,0 +1,238 @@ +/** + * src/context/AppContext.jsx — API-connected version + */ +import { createContext, useContext, useState, useCallback } from 'react' +import * as api from '../services/api' + +const AppContext = createContext(null) + +export function AppProvider({ children }) { + const [currentUser, setCurrentUser] = useState(null) + const [items, setItems] = useState([]) + const [myItems, setMyItems] = useState([]) // items de l'utilisateur connecté + const [exchanges, setExchanges] = useState([]) + const [reviews, setReviews] = useState({}) + const [notifications, setNotifications] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + const [loading, setLoading] = useState(false) + + // ── Auth ──────────────────────────────────────────────────────────────────── + const signup = async (formData) => { + const res = await api.signup(formData) + if (res.ok) setCurrentUser(res.user) + return res + } + + const signin = async (email, password) => { + const res = await api.signin(email, password) + if (res.ok) setCurrentUser(res.user) + return res + } + + const signout = async () => { + await api.signout() + setCurrentUser(null) + setItems([]); setMyItems([]); setExchanges([]) + setReviews({}); setNotifications([]) + } + + const updateProfile = async (fields) => { + const res = await api.updateProfile(fields) + if (res.ok) setCurrentUser(res.user) + return res + } + + // ── Items ─────────────────────────────────────────────────────────────────── + // Charge TOUS les items disponibles (Explorer) + const loadItems = useCallback(async (filters = {}) => { + setLoading(true) + try { + const data = await api.getItems(filters) + setItems(Array.isArray(data) ? data : []) + } catch (e) { + console.error('loadItems error:', e) + } finally { + setLoading(false) + } + }, []) + + // Charge les items de l'utilisateur connecté (MySpace) + const loadMyItems = useCallback(async () => { + try { + const data = await api.getMyItems() + setMyItems(Array.isArray(data) ? data : []) + } catch (e) { + console.error('loadMyItems error:', e) + } + }, []) + + const addItem = async (itemData) => { + const res = await api.addItem(itemData) + if (res.ok) { + setMyItems(prev => [res.item, ...prev]) + // Ajoute aussi dans la liste globale si disponible + setItems(prev => [res.item, ...prev]) + } + return res + } + + const deleteItem = async (id) => { + await api.deleteItem(id) + setMyItems(prev => prev.filter(i => i.id !== id)) + setItems(prev => prev.filter(i => i.id !== id)) + } + + const getItemById = (id) => { + const found = [...items, ...myItems].find(i => i.id === id) + return found || api.getItemById(id) + } + + // Retourne les items de l'utilisateur connecté depuis le state dédié + const getMyItems = () => myItems + + // ── Exchanges ─────────────────────────────────────────────────────────────── + const loadExchanges = useCallback(async () => { + try { + const data = await api.getMyExchanges() + setExchanges(Array.isArray(data) ? data : []) + } catch (e) { + console.error('loadExchanges error:', e) + } + }, []) + + const proposeExchange = async ({ offeredItemId, requestedItemId }) => { + const res = await api.proposeExchange({ offeredItemId, requestedItemId }) + if (res.ok) setExchanges(prev => [res.exchange, ...prev]) + return res + } + + const respondExchange = async (exchangeId, accepted) => { + const res = await api.respondExchange(exchangeId, accepted) + if (res.ok) { + setExchanges(prev => prev.map(e => e.id === exchangeId ? res.exchange : e)) + if (accepted) { + const ex = res.exchange + setItems(prev => prev.map(i => + i.id === ex.offeredItemId || i.id === ex.requestedItemId + ? { ...i, available: false } : i + )) + setMyItems(prev => prev.map(i => + i.id === ex.offeredItemId || i.id === ex.requestedItemId + ? { ...i, available: false } : i + )) + } + } + return res + } + + const getMyExchanges = () => exchanges + + const canReviewExchange = (exchangeId) => { + const ex = exchanges.find(e => e.id === exchangeId) + if (!ex || ex.status !== 'accepted') return false + if (ex.proposerId === currentUser?.id && !ex.reviewedByProposer) return true + if (ex.ownerId === currentUser?.id && !ex.reviewedByOwner) return true + return false + } + + const getReviewPartner = (exchangeId) => { + const ex = exchanges.find(e => e.id === exchangeId) + if (!ex) return null + return ex.proposerId === currentUser?.id ? ex.owner : ex.proposer + } + + // ── Reviews ───────────────────────────────────────────────────────────────── + const getUserReviews = useCallback(async (userId) => { + if (reviews[userId]) return reviews[userId] + try { + const data = await api.getUserReviews(userId) + const list = Array.isArray(data) ? data : [] + setReviews(prev => ({ ...prev, [userId]: list })) + return list + } catch (e) { + console.error('getUserReviews error:', e) + return [] + } + }, [reviews]) + + const addReview = async (data) => { + const res = await api.addReview(data) + if (res.ok) { + setReviews(prev => { const next = { ...prev }; delete next[data.targetUserId]; return next }) + } + return res + } + + // ── Notifications ─────────────────────────────────────────────────────────── + const loadNotifications = useCallback(async () => { + try { + const data = await api.getMyNotifications() + setNotifications(Array.isArray(data.notifications) ? data.notifications : []) + setUnreadCount(data.unreadCount || 0) + } catch (e) { + console.error('loadNotifications error:', e) + } + }, []) + + const markNotifRead = async (id) => { + await api.markNotifRead(id) + setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n)) + setUnreadCount(c => Math.max(0, c - 1)) + } + + const markAllNotifsRead = async () => { + await api.markAllNotifsRead() + setNotifications(prev => prev.map(n => ({ ...n, read: true }))) + setUnreadCount(0) + } + + const getMyNotifications = () => notifications + const getUnreadCount = () => unreadCount + + // ── Suggestions ───────────────────────────────────────────────────────────── + const getSuggestions = useCallback(() => api.getSuggestions(), []) + + // ── Fairness (calcul local) ────────────────────────────────────────────────── + const getFairness = api.getFairness + + // ── Helpers ────────────────────────────────────────────────────────────────── + const getUserById = async (id) => { + try { return await api.getUserById(id) } catch { return null } + } + + const getTeam = () => SEED_TEAM + const getAllUsers = () => [] + + return ( + + {children} + + ) +} + +export const useApp = () => useContext(AppContext) + +const SEED_TEAM = [ + { id: 't1', name: 'Jean-Baptiste Fouda', role: 'Project Lead & Full-Stack Dev', bio: 'Passionate about building tools that create real impact in African communities.', emoji: '🚀', color: '#e8521f' }, + { id: 't2', name: 'Armel Kamga', role: 'Backend Developer', bio: 'Django wizard. Loves clean APIs and well-structured databases.', emoji: '⚙️', color: '#7c3aed' }, + { id: 't3', name: 'Diane Mbarga', role: 'UI/UX Designer', bio: 'Believes great design should be invisible.', emoji: '🎨', color: '#0891b2' }, + { id: 't4', name: 'Patrick Nkeng', role: 'Frontend Developer', bio: 'Turns Figma mockups into pixel-perfect components.', emoji: '💻', color: '#16a34a' }, + { id: 't5', name: 'Serge Biyong', role: 'Product & Marketing', bio: 'Bridges the gap between what we build and who needs it.', emoji: '📣', color: '#d97706' }, + { id: 't6', name: 'Chloe Ngo Bum', role: 'QA & Community Manager', bio: 'The last line of defence before a bug reaches users.', emoji: '🛡️', color: '#db2777' }, +] From fdb22ffd879c42d6e5564d8897b8332a114e8be1 Mon Sep 17 00:00:00 2001 From: magmus2006 Date: Tue, 26 May 2026 00:44:31 +0100 Subject: [PATCH 3/7] Add files via upload --- src/components/ItemCard.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ItemCard.jsx b/src/components/ItemCard.jsx index e7c02c3..7baa4ab 100644 --- a/src/components/ItemCard.jsx +++ b/src/components/ItemCard.jsx @@ -77,7 +77,7 @@ export default function ItemCard({ item, showActions = false, onSwap, compact = }}> {owner.photo ? - : `${owner.firstName[0]}${owner.lastName[0]}` + : `${owner?.firstName?.[0]}${owner?.lastName?.[0]}` }
From f3ee95c0ddf6648e5b9fc4e10316075d82f92d53 Mon Sep 17 00:00:00 2001 From: magmus2006 Date: Tue, 26 May 2026 00:45:29 +0100 Subject: [PATCH 4/7] Add files via upload --- src/pages/MySpace-1.jsx | 579 ++++++++++++++++++++++++++++++++++++++++ src/pages/Signin.jsx | 4 +- 2 files changed, 581 insertions(+), 2 deletions(-) create mode 100644 src/pages/MySpace-1.jsx diff --git a/src/pages/MySpace-1.jsx b/src/pages/MySpace-1.jsx new file mode 100644 index 0000000..b692ec5 --- /dev/null +++ b/src/pages/MySpace-1.jsx @@ -0,0 +1,579 @@ +import { useState, useEffect, useRef } from 'react' +import { useLocation } from 'react-router-dom' +import { useApp } from '../context/AppContext' +import Navbar from '../components/Navbar' +import ItemCard from '../components/ItemCard' +import Toast from '../components/Toast' +import StarRating from '../components/StarRating' +import ReviewModal from '../components/ReviewModal' +import { AIValueEstimator } from '../components/AIAssistant' + +const TABS = [ + { id: 'profile', label: 'Profile', icon: '👤' }, + { id: 'products', label: 'My Items', icon: '📦' }, + { id: 'exchanges', label: 'Exchanges', icon: '🔁' }, + { id: 'notifications', label: 'Notifications', icon: '🔔' }, +] + +const EMOJIS = { Electronics:'📱', Clothing:'👕', Furniture:'🪑', Books:'📚', Music:'🎸', Sports:'⚽', Other:'📦' } + +export default function MySpace() { + const location = useLocation() + const { + currentUser, updateProfile, + getMyItems, getMyExchanges, getMyNotifications, + loadItems, loadExchanges, loadNotifications, + addItem, deleteItem, respondExchange, + markNotifRead, markAllNotifsRead, + getFairness, getUserById, getItemById, getUnreadCount, + getUserReviews, addReview, canReviewExchange, getReviewPartner, + } = useApp() + + const tabFromUrl = new URLSearchParams(location.search).get('tab') + const [tab, setTab] = useState(tabFromUrl || 'profile') + const [toast, setToast] = useState(null) + const [reviewTarget, setReviewTarget] = useState(null) + + // ── Charge toutes les données depuis l'API au montage ────────────────────── + useEffect(() => { + loadItems() + loadExchanges() + loadNotifications() + }, []) + + useEffect(() => { if (tabFromUrl) setTab(tabFromUrl) }, [tabFromUrl]) + + const myItems = getMyItems() + const myExchanges = getMyExchanges() + const myNotifs = getMyNotifications() + const unread = getUnreadCount() + + const handleReviewSubmit = async (data) => { + await addReview(data) + setToast({ message: 'Review submitted! Thank you.', type: 'success' }) + setReviewTarget(null) + } + + if (!currentUser) return null + + return ( +
+ + + {/* Header */} +
+
+
+
+ {currentUser.photo + ? + : `${currentUser.firstName[0]}${currentUser.lastName[0]}` + } +
+
+
+ {currentUser.firstName} {currentUser.lastName} +
+
+ + + {currentUser.stars > 0 + ? `${currentUser.stars} · ${currentUser.reviewCount} review${currentUser.reviewCount !== 1 ? 's' : ''}` + : 'No reviews yet'} + +
+
+
+ + {/* Tabs */} +
+ {TABS.map(t => ( + + ))} +
+
+
+ +
+ {tab === 'profile' && } + {tab === 'products' && } + {tab === 'exchanges' && setReviewTarget({ partner, exchangeId })} />} + {tab === 'notifications' && } +
+ + {reviewTarget && ( + setReviewTarget(null)} + /> + )} + + {toast && setToast(null)} />} +
+ ) +} + +// ── Profile Tab ──────────────────────────────────────────────────────────────── +function ProfileTab({ user, onUpdate, onToast, getUserReviews, getUserById }) { + const [editing, setEditing] = useState(false) + const [form, setForm] = useState({ + firstName: user.firstName, + lastName: user.lastName, + bio: user.bio || '', + contact: user.contact || '', + }) + const [myReviews, setMyReviews] = useState([]) + const photoRef = useRef() + + // Charge les reviews de manière async correctement + useEffect(() => { + getUserReviews(user.id).then(setMyReviews).catch(() => setMyReviews([])) + }, [user.id]) + + const handleSave = async () => { + await onUpdate(form) + setEditing(false) + onToast({ message: 'Profile updated!', type: 'success' }) + } + + const handlePhotoChange = (e) => { + const file = e.target.files[0] + if (file) onUpdate({ photo: file }) + } + + return ( +
+ {/* Left: info */} +
+ + {/* Avatar */} +
+
+
+ {user.photo + ? + : `${user.firstName[0]}${user.lastName[0]}` + } +
+ + +
+
+ {user.firstName} {user.lastName} +
+
+ Member since {new Date(user.joinedAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} +
+
+ + {/* Stats */} +
+ {[ + { label: 'Swaps', value: user.swapCount || 0 }, + { label: 'Reviews', value: user.reviewCount || 0 }, + { label: 'Rating', value: user.stars > 0 ? `${user.stars}★` : '—' }, + ].map(s => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ + {!editing ? ( +
+ {user.email} + {user.contact || '—'} + {user.bio || '—'} + setEditing(true)}>Edit Profile +
+ ) : ( +
+ setForm(f => ({ ...f, firstName: v }))} /> + setForm(f => ({ ...f, lastName: v }))} /> + setForm(f => ({ ...f, contact: v }))} /> + setForm(f => ({ ...f, bio: v }))} multiline /> +
+ Save + setEditing(false)}>Cancel +
+
+ )} +
+
+ + {/* Right: reviews */} + + {myReviews.length === 0 ? ( + + ) : ( +
+ {myReviews.map(r => ( +
+
+ + {r.author?.firstName} {r.author?.lastName} + + +
+ {r.comment &&

{r.comment}

} +
+ {new Date(r.createdAt).toLocaleDateString()} +
+
+ ))} +
+ )} +
+
+ ) +} + +// ── Products Tab ─────────────────────────────────────────────────────────────── +function ProductsTab({ items, onAdd, onDelete, onToast }) { + const [showForm, setShowForm] = useState(false) + const [form, setForm] = useState({ name: '', category: 'Electronics', description: '', condition: 'Good', value: '', emoji: '📦' }) + const [loading, setLoading] = useState(false) + + const handleAdd = async (e) => { + e.preventDefault() + setLoading(true) + const res = await onAdd({ ...form, value: parseInt(form.value) || 0}) + if (!res) return + setLoading(false) + if (res?.ok) { + setShowForm(false) + setForm({ name: '', category: 'Electronics', description: '', condition: 'Good', value: '', emoji: '📦' }) + onToast({ message: 'Item added!', type: 'success' }) + } + } + + return ( +
+
+

My Items

+ setShowForm(v => !v)}>{showForm ? 'Cancel' : '+ Add Item'} +
+ + {showForm && ( +
+

New Item

+
+ setForm(f => ({ ...f, name: e.target.value }))} /> +
+
+ + setForm(f => ({...f, image:e.target.files[0]}))} style={inp}/> + +
+
+ + +
+
+ + +
+
+ setForm(f => ({ ...f, value: e.target.value }))} /> + setForm(f => ({ ...f, description: v }))} multiline /> + setForm(f => ({ ...f, value: String(v) }))} /> + {loading ? 'Adding…' : 'Add Item'} + +
+ )} + + {items.length === 0 ? ( + + ) : ( +
+ {items.map(item => ( +
+ + +
+ ))} +
+ )} +
+ ) +} + +// ── Exchanges Tab ────────────────────────────────────────────────────────────── +function ExchangesTab({ exchanges, currentUser, onRespond, getFairness, getUserById, getItemById, onToast, canReviewExchange, getReviewPartner, onReview }) { + const [responding, setResponding] = useState(null) + + const handleRespond = async (exchangeId, accepted) => { + setResponding(exchangeId) + await onRespond(exchangeId, accepted) + setResponding(null) + onToast({ message: accepted ? 'Swap accepted! 🎉' : 'Swap declined.', type: accepted ? 'success' : 'info' }) + } + + return ( +
+

Exchanges

+ {exchanges.length === 0 ? ( + + ) : ( +
+ {exchanges.map(ex => { + const isProposer = ex.proposerId === currentUser.id + const fairness = getFairness(ex.offeredItem?.value, ex.requestedItem?.value) + const canReview = canReviewExchange(ex.id) + const partner = getReviewPartner(ex.id) + + return ( +
+
+
+ + {isProposer ? 'You proposed' : 'Received from'} {isProposer ? ex.owner?.firstName : ex.proposer?.firstName} + +
+ {new Date(ex.createdAt).toLocaleDateString()} +
+
+
+ {fairness && ( + + {fairness.icon} {fairness.label} + + )} + + {ex.status === 'pending' ? '⏳ Pending' : ex.status === 'accepted' ? '✅ Accepted' : '❌ Rejected'} + +
+
+ +
+ +
+ +
+ + {ex.status === 'pending' && !isProposer && ( +
+ + +
+ )} + + {canReview && partner && ( + + )} +
+ ) + })} +
+ )} +
+ ) +} + +function ExChip({ item, label }) { + return ( +
+
{label}
+ {item?.image + ? {item?.name} + :
{item?.emoji || '📦'}
+ } +
{item?.name || 'Unknown'}
+
{item?.value?.toLocaleString()} FCFA
+
+ ) +} + +// ── Notifications Tab ────────────────────────────────────────────────────────── +function NotificationsTab({ notifs, onRead, onReadAll, setTab }) { + const cfg = { + proposal: { icon: '🔁', color: 'var(--orange)' }, + accepted: { icon: '✅', color: 'var(--green)' }, + rejected: { icon: '❌', color: 'var(--red)' }, + review: { icon: '⭐', color: '#f59e0b' }, + default: { icon: '🔔', color: 'var(--ink-muted)' }, + } + + return ( +
+
+

Notifications

+ {notifs.some(n => !n.read) && ( + + )} +
+ + {notifs.length === 0 ? ( + + ) : ( +
+ {notifs.map(n => { + const c = cfg[n.type] || cfg.default + return ( +
{ onRead(n.id); if (n.type === 'proposal') setTab('exchanges') }} style={{ + padding: '14px 16px', borderRadius: 12, + background: n.read ? '#fff' : 'var(--accent-soft)', + border: `1px solid ${n.read ? 'var(--border)' : 'rgba(232,82,31,0.18)'}`, + display: 'flex', gap: 12, alignItems: 'flex-start', cursor: 'pointer', + transition: 'transform var(--transition)', + }} + onMouseEnter={e => e.currentTarget.style.transform = 'translateX(4px)'} + onMouseLeave={e => e.currentTarget.style.transform = 'translateX(0)'} + > +
{c.icon}
+
+

{n.message}

+ {n.contact &&
📞 {n.contact}
} +
{new Date(n.createdAt).toLocaleString()}
+
+ {!n.read &&
} +
+ ) + })} +
+ )} +
+ ) +} + +// ── Shared micro-components ──────────────────────────────────────────────────── +function Card({ title, subtitle, children }) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {children} +
+ ) +} + +function InfoRow({ label, children }) { + return ( +
+
{label}
+
{children}
+
+ ) +} + +function PField({ label, value, onChange, multiline }) { + return ( +
+ + {multiline + ?