diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..fea7fc3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## 📋 What does this PR do? + + +## 🔗 Related Issue +Closes # + +## 🧪 Checklist +- [ ] Code works locally +- [ ] Acceptance criteria tested and passing +- [ ] No console errors +- [ ] Responsive on mobile and desktop +- [ ] No unnecessary files committed (node_modules, .env) + +## 📸 Screenshots (if UI changed) + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..117e6a9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI Pipeline + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + frontend: + name: Frontend Checks (React) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: | + cd frontend + npm install + + - name: Check for errors + run: | + cd frontend + npm run build + + backend: + name: Backend Checks (Django) + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: swappit + POSTGRES_PASSWORD: swappit + POSTGRES_DB: swappit_db + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install backend dependencies + run: | + cd backend + pip install -r requirements.txt + + - name: Run Django checks + env: + DATABASE_URL: postgresql://swappit:swappit@localhost:5432/swappit_db + SECRET_KEY: ci-secret-key-for-testing + DEBUG: True + run: | + cd backend + python manage.py check + + - name: Run migrations check + env: + DATABASE_URL: postgresql://swappit:swappit@localhost:5432/swappit_db + SECRET_KEY: ci-secret-key-for-testing + DEBUG: True + run: | + cd backend + python manage.py migrate diff --git a/App.jsx b/App.jsx new file mode 100644 index 0000000..30bb995 --- /dev/null +++ b/App.jsx @@ -0,0 +1,52 @@ +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { AppProvider } from './context/AppContext'; +import ProtectedRoute from './components/ProtectedRoute'; + +// Pages +import Landing from './pages/Landing'; +import Signup from './pages/Signup'; +import Signin from './pages/Signin'; +import Explorer from './pages/Explorer'; +import MySpace from './pages/MySpace'; +import ItemDetail from './pages/ItemDetail'; +import CreateItem from './pages/CreateItem'; +import About from './pages/About'; +import ProposerTroc from './pages/ProposerTroc'; + +export default function App() { + return ( + + {/* + AppProvider enveloppe toute l'application : + - currentUser disponible partout via useApp() + - login() / logout() accessibles dans Signin et Navbar + */} + + + {/* ── Pages publiques ── */} + } /> + } /> + } /> + } /> + + {/* ── Pages protégées ── */} + } /> + } /> + } /> + } /> + } /> + } /> + + {/* ── Fallback ── */} + } /> + + + + ); +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..439a002 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# 🔄 SWAPPIT + +> A community-driven item swap platform built for Cameroon. +> Trade what you have for what you need — no money required. + +--- + +## 📸 Screenshot + +image + +--- + +## 🚀 What is Swappit? +Swappit allows registered users to post items they no longer need +and propose swaps with other users — completely free, +based on item value in FCFA. + +--- + +## ✨ Features +- 📦 Post items with photo, description and value +- 🔍 Browse and search available items +- 🤝 Propose and accept swap offers +- ⭐ Trust scores and reviews after swaps +- 🗺️ Meeting point map for accepted swaps +- 🤖 AI value estimator and chat assistant + +--- + +## 🛠️ Tech Stack +| Layer | Technology | +|---|---| +| Frontend | React JS | +| Backend | Django | +| Database | PostgreSQL | +| Auth | Django Authentication | +| Hosting | Contabo | + +--- + +## ⚙️ How to Run Locally + +```bash +# Clone the repo +git clone https://github.com/ChatGPT486/SWAPPIT.git + +# Navigate into the project +cd SWAPPIT + +# Install dependencies +npm install + +# Start the development server +npm run dev +``` + +--- + +## 👥 Team +| Name | Role | GitHub | +|---|---|---| +| Tabi Paul Agwe | Full Stack Developer | [@ChatGPT486](https://github.com/ChatGPT486) | +| Takam Serge | Full Stack Developer | [@magmus2006](https://github.com/magmus2006) | +| Obam Banga Samuel | Frontend Developer & Database Engineer | [@obamX](https://github.com/obamX) | +| Ndongo Pamsy | Frontend Developer | [@ndongopamsy08-hue](https://github.com/ndongopamsy08-hue) | +|Nzeugang Daniel| Backend Developer | [@Derthgyu](https://github.com/Derthgyu) | + +--- +## 📅 Project Status +| Sprint | Theme | User Stories | Points | Status | +|---|---|---|---|---| +| Sprint 1 | Core Auth & Item Posting | US-01 User can create an account | 3pts | 🔨 In Progress | +| | | US-02 User can sign in and sign out | 2pts | 🔨 In Progress | +| | | US-03 User can post an item with photo | 5pts | 🔨 In Progress | +| | | US-04 User can view and edit their profile | 3pts | 🔨 In Progress | +| Sprint 2 | Marketplace & Swap Flow | US-05 User can browse all items in Explorer | 3pts | ⏳ Upcoming | +| | | US-06 User can search and filter items | 3pts | ⏳ Upcoming | +| | | US-07 User can propose a swap with fairness indicator | 8pts | ⏳ Upcoming | +| | | US-08 Owner can accept or reject a swap proposal | 5pts | ⏳ Upcoming | +| | | US-09 Contacts are shared when swap is accepted | 3pts | ⏳ Upcoming | +| Sprint 3 | Notifications & Reviews | US-10 User receives notifications for proposals | 5pts | ⏳ Upcoming | +| | | US-11 User can leave a star review after a swap | 5pts | ⏳ Upcoming | +| | | US-12 User trust score is visible on their items | 2pts | ⏳ Upcoming | +| | | US-13 Smart suggestions shown in Explorer | 5pts | ⏳ Upcoming | +| Sprint 4 | Map, AI & Polish | US-14 AI estimates item value automatically | 5pts | ⏳ Upcoming | +| | | US-15 AI chat assistant answers swap questions | 8pts | ⏳ Upcoming | +| | | US-16 Map shows meeting point for accepted swaps | 8pts | ⏳ Upcoming | +| | | US-17 About page shows team and mission | 2pts | ⏳ Upcoming | + +**Total: 74 story points across 4 sprints** +--- + +## 📄 License +This project is for educational purposes. + + diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/__pycache__/__init__.cpython-313.pyc b/backend/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..29e5ba0 Binary files /dev/null and b/backend/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/core/__pycache__/admin.cpython-313.pyc b/backend/core/__pycache__/admin.cpython-313.pyc new file mode 100644 index 0000000..f14733d Binary files /dev/null and b/backend/core/__pycache__/admin.cpython-313.pyc differ diff --git a/backend/core/__pycache__/apps.cpython-313.pyc b/backend/core/__pycache__/apps.cpython-313.pyc new file mode 100644 index 0000000..8175444 Binary files /dev/null and b/backend/core/__pycache__/apps.cpython-313.pyc differ diff --git a/backend/core/__pycache__/models.cpython-313.pyc b/backend/core/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..bfdf873 Binary files /dev/null and b/backend/core/__pycache__/models.cpython-313.pyc differ diff --git a/backend/core/__pycache__/serializers.cpython-313.pyc b/backend/core/__pycache__/serializers.cpython-313.pyc new file mode 100644 index 0000000..403e25b Binary files /dev/null and b/backend/core/__pycache__/serializers.cpython-313.pyc differ diff --git a/backend/core/__pycache__/views.cpython-313.pyc b/backend/core/__pycache__/views.cpython-313.pyc new file mode 100644 index 0000000..78bdfa6 Binary files /dev/null and b/backend/core/__pycache__/views.cpython-313.pyc differ diff --git a/backend/core/admin.py b/backend/core/admin.py new file mode 100644 index 0000000..6d31f94 --- /dev/null +++ b/backend/core/admin.py @@ -0,0 +1,27 @@ +from django.contrib import admin +from .models import User, Item, SwapExchange + +# 1. PERSONNALISATION DE L'AFFICHAGE DES UTILISATEURS +class UserAdmin(admin.ModelAdmin): + list_display = ('id', 'username', 'email', 'first_name', 'last_name', 'contact', 'created_at') + search_fields = ('username', 'email', 'first_name', 'last_name') + ordering = ('-created_at',) + +# 2. PERSONNALISATION DE L'AFFICHAGE DES ARTICLES (ITEMS) +class ItemAdmin(admin.ModelAdmin): + list_display = ('id', 'title', 'user', 'category', 'condition', 'value', 'created_at') + list_filter = ('category', 'condition', 'created_at') + search_fields = ('title', 'description', 'user__username') + # On affiche 'value' car on l'a sécurisé avec default=0, null=True, blank=True + fields = ('user', 'title', 'description', 'category', 'condition', 'value', 'emoji', 'image') + +# 3. PERSONNALISATION DE L'AFFICHAGE DES ÉCHANGES (SWAPS) +class SwapExchangeAdmin(admin.ModelAdmin): + list_display = ('id', 'sender', 'receiver', 'my_item', 'their_item', 'status', 'fairness', 'created_at') + list_filter = ('status', 'fairness', 'created_at') + search_fields = ('sender__username', 'receiver__username', 'my_item__title', 'their_item__title') + +# Enregistrement de tous les modèles avec leurs configurations respectives +admin.site.register(User, UserAdmin) +admin.site.register(Item, ItemAdmin) +admin.site.register(SwapExchange, SwapExchangeAdmin) \ No newline at end of file diff --git a/backend/core/apps.py b/backend/core/apps.py new file mode 100644 index 0000000..ae16c3e --- /dev/null +++ b/backend/core/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = 'core' diff --git a/backend/core/migrations/0001_initial.py b/backend/core/migrations/0001_initial.py new file mode 100644 index 0000000..c212591 --- /dev/null +++ b/backend/core/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 6.0.5 on 2026-06-02 09:31 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('contact', models.CharField(blank=True, max_length=20, null=True)), + ('bio', models.TextField(blank=True, null=True)), + ('avatar_color', models.CharField(default='#E8521F', max_length=7)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='Item', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('description', models.TextField()), + ('category', models.CharField(choices=[('Electronics', 'Electronics'), ('Clothing', 'Clothing'), ('Furniture', 'Furniture'), ('Books', 'Books'), ('Music', 'Music'), ('Sports', 'Sports'), ('Other', 'Other')], default='Other', max_length=50)), + ('condition', models.CharField(choices=[('NEW', 'Neuf'), ('LIKE_NEW', 'Très bon état'), ('USED', 'Utilisé')], default='USED', max_length=20)), + ('value', models.IntegerField(blank=True, default=0, null=True)), + ('emoji', models.CharField(default='📦', max_length=10)), + ('image', models.ImageField(blank=True, null=True, upload_to='items/')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='SwapExchange', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected')], default='pending', max_length=20)), + ('fairness', models.CharField(choices=[('fair', 'Fair'), ('unfair', 'Unfair'), ('skewed', 'Skewed')], default='fair', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('my_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='initiated_swaps', to='core.item')), + ('receiver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_swaps', to=settings.AUTH_USER_MODEL)), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_swaps', to=settings.AUTH_USER_MODEL)), + ('their_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='targeted_swaps', to='core.item')), + ], + ), + ] diff --git a/backend/core/migrations/0002_alter_item_image.py b/backend/core/migrations/0002_alter_item_image.py new file mode 100644 index 0000000..3e8c92e --- /dev/null +++ b/backend/core/migrations/0002_alter_item_image.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.5 on 2026-06-02 18:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='item', + name='image', + field=models.URLField(blank=True, null=True), + ), + ] diff --git a/backend/core/migrations/__init__.py b/backend/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/migrations/__pycache__/0001_initial.cpython-313.pyc b/backend/core/migrations/__pycache__/0001_initial.cpython-313.pyc new file mode 100644 index 0000000..a803d95 Binary files /dev/null and b/backend/core/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/backend/core/migrations/__pycache__/0002_alter_item_image.cpython-313.pyc b/backend/core/migrations/__pycache__/0002_alter_item_image.cpython-313.pyc new file mode 100644 index 0000000..9d042c2 Binary files /dev/null and b/backend/core/migrations/__pycache__/0002_alter_item_image.cpython-313.pyc differ diff --git a/backend/core/migrations/__pycache__/0002_alter_item_value.cpython-313.pyc b/backend/core/migrations/__pycache__/0002_alter_item_value.cpython-313.pyc new file mode 100644 index 0000000..fed7ab5 Binary files /dev/null and b/backend/core/migrations/__pycache__/0002_alter_item_value.cpython-313.pyc differ diff --git a/backend/core/migrations/__pycache__/0002_rename_name_item_title_item_condition_and_more.cpython-313.pyc b/backend/core/migrations/__pycache__/0002_rename_name_item_title_item_condition_and_more.cpython-313.pyc new file mode 100644 index 0000000..15546a2 Binary files /dev/null and b/backend/core/migrations/__pycache__/0002_rename_name_item_title_item_condition_and_more.cpython-313.pyc differ diff --git a/backend/core/migrations/__pycache__/__init__.cpython-313.pyc b/backend/core/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..c82f3f4 Binary files /dev/null and b/backend/core/migrations/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/core/models.py b/backend/core/models.py new file mode 100644 index 0000000..0c3d239 --- /dev/null +++ b/backend/core/models.py @@ -0,0 +1,75 @@ +from django.db import models +from django.contrib.auth.models import AbstractUser +from django.conf import settings + +# 1. UTILISATEUR PERSONNALISÉ +class User(AbstractUser): + contact = models.CharField(max_length=20, blank=True, null=True) + bio = models.TextField(blank=True, null=True) + avatar_color = models.CharField(max_length=7, default='#E8521F') + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.first_name} {self.last_name} ({self.username})" + + +# 2. ARTICLES A ÉCHANGER +class Item(models.Model): + CATEGORY_CHOICES = [ + ('Electronics', 'Electronics'), + ('Clothing', 'Clothing'), + ('Furniture', 'Furniture'), + ('Books', 'Books'), + ('Music', 'Music'), + ('Sports', 'Sports'), + ('Other', 'Other'), + ] + CONDITION_CHOICES = [ + ('NEW', 'Neuf'), + ('LIKE_NEW', 'Très bon état'), + ('USED', 'Utilisé'), + ] + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='items') + title = models.CharField(max_length=255) + description = models.TextField() + category = models.CharField(max_length=50, choices=CATEGORY_CHOICES, default='Other') + condition = models.CharField(max_length=20, choices=CONDITION_CHOICES, default='USED') + value = models.IntegerField(default=0, null=True, blank=True) + emoji = models.CharField(max_length=10, default='📦') + image = models.URLField( + blank=True, # Permet de laisser le champ vide dans un formulaire + null=True # Permet de stocker la valeur NULL en base de données si vide + ) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.title + + +# 3. PROPOSITIONS D'ÉCHANGES / SWAPS +class SwapExchange(models.Model): + STATUS_CHOICES = [ + ('pending', 'Pending'), + ('accepted', 'Accepted'), + ('rejected', 'Rejected'), + ] + + FAIRNESS_CHOICES = [ + ('fair', 'Fair'), + ('unfair', 'Unfair'), + ('skewed', 'Skewed'), + ] + + sender = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sent_swaps') + receiver = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='received_swaps') + + my_item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='initiated_swaps') + their_item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='targeted_swaps') + + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + fairness = models.CharField(max_length=20, choices=FAIRNESS_CHOICES, default='fair') + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Swap: {self.my_item.title} <-> {self.their_item.title} [{self.status}]" \ No newline at end of file diff --git a/backend/core/serializers.py b/backend/core/serializers.py new file mode 100644 index 0000000..89955b9 --- /dev/null +++ b/backend/core/serializers.py @@ -0,0 +1,57 @@ +from rest_framework import serializers +from django.contrib.auth import get_user_model +from core.models import Item, SwapExchange + +User = get_user_model() + +# 1. TRANSLATEUR UTILISATEUR +class UserSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'contact', 'bio', 'avatar_color', 'password'] + + def create(self, validated_data): + password = validated_data.pop('password') + user = User(**validated_data) + user.set_password(password) + user.save() + return user + + +# 2. TRANSLATEUR ARTICLES +class ItemSerializer(serializers.ModelSerializer): + user_details = UserSerializer(source='user', read_only=True) + # Permet de renvoyer item.owner__username directement pour ton composant React ! + owner__username = serializers.CharField(source='user.username', read_only=True) + + class Meta: + model = Item + fields = [ + 'id', 'user', 'user_details', 'owner__username', 'title', + 'description', 'category', 'condition', 'value', 'emoji', 'image', 'created_at' + ] + + +# 3. TRANSLATEUR ECHANGES (SWAPS) +class SwapExchangeSerializer(serializers.ModelSerializer): + sender_details = UserSerializer(source='sender', read_only=True) + receiver_details = UserSerializer(source='receiver', read_only=True) + + # CORRECTION : Double exposition (Singulier + Pluriel) pour matcher à 100% avec le Frontend + my_item_detail = ItemSerializer(source='my_item', read_only=True) + their_item_detail = ItemSerializer(source='their_item', read_only=True) + my_item_details = ItemSerializer(source='my_item', read_only=True) + their_item_details = ItemSerializer(source='their_item', read_only=True) + + sender_username = serializers.CharField(source='sender.username', read_only=True) + receiver_username = serializers.CharField(source='receiver.username', read_only=True) + + class Meta: + model = SwapExchange + fields = [ + 'id', 'sender', 'receiver', 'my_item', 'their_item', 'status', 'fairness', 'created_at', + 'sender_details', 'receiver_details', 'my_item_detail', 'their_item_detail', + 'my_item_details', 'their_item_details', 'sender_username', 'receiver_username' + ] \ No newline at end of file diff --git a/backend/core/tests.py b/backend/core/tests.py new file mode 100644 index 0000000..de8bdc0 --- /dev/null +++ b/backend/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/core/views.py b/backend/core/views.py new file mode 100644 index 0000000..844339f --- /dev/null +++ b/backend/core/views.py @@ -0,0 +1,270 @@ +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAuthenticated +from django.contrib.auth import authenticate, get_user_model +from .serializers import UserSerializer, ItemSerializer, SwapExchangeSerializer +from .models import Item, SwapExchange +from rest_framework.views import APIView +from django.db.models import Q + +User = get_user_model() + + +# ========================================== +# 1. INSCRIPTION (SIGNUP) +# ========================================== +@api_view(['POST']) +@permission_classes([AllowAny]) +def signup_view(request): + serializer = UserSerializer(data=request.data) + if serializer.is_valid(): + user = serializer.save() + return Response({ + "message": "Utilisateur créé avec succès !", + "user": UserSerializer(user).data + }, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +# ========================================== +# 2. CONNEXION (SIGNIN) — retourne user + tokens +# ========================================== +@api_view(['POST']) +@permission_classes([AllowAny]) +def signin_view(request): + username = request.data.get('username') + password = request.data.get('password') + + if not username or not password: + return Response({"error": "Veuillez fournir un nom d'utilisateur et un mot de passe."}, status=status.HTTP_400_BAD_REQUEST) + + user = authenticate(username=username, password=password) + + if user is not None: + # Génération des tokens JWT pour ce user + from rest_framework_simplejwt.tokens import RefreshToken + refresh = RefreshToken.for_user(user) + user_data = UserSerializer(user).data + user_data['id'] = user.id # Sécurité pare-feu + + return Response({ + "message": "Connexion réussie !", + "access": str(refresh.access_token), + "refresh": str(refresh), + "user": user_data + }, status=status.HTTP_200_OK) + + return Response({"error": "Identifiants invalides."}, status=status.HTTP_401_UNAUTHORIZED) + + +# ========================================== +# 3. LISTE ET CRÉATION DES ARTICLES +# ========================================== +@api_view(['GET', 'POST']) +@permission_classes([AllowAny]) +def item_list_create(request): + if request.method == 'GET': + items = Item.objects.filter( + Q(image__startswith='http://') | Q(image__startswith='https://') + ).order_by('-id') + serializer = ItemSerializer(items, many=True, context={'request': request}) + return Response(serializer.data, status=status.HTTP_200_OK) + + elif request.method == 'POST': + user_id = request.data.get('owner_id') or request.data.get('user') + try: + author = User.objects.get(id=user_id) + except User.DoesNotExist: + return Response({"error": "Utilisateur introuvable."}, status=status.HTTP_404_NOT_FOUND) + + item = Item.objects.create( + user=author, + title=request.data.get('title'), + description=request.data.get('description', ''), + category=request.data.get('category', 'Other'), + condition=request.data.get('condition', 'USED'), + value=request.data.get('value', 0), + emoji=request.data.get('emoji', '📦'), + image=request.data.get('image') or None + ) + serializer = ItemSerializer(item, context={'request': request}) + return Response({'message': 'Article créé avec succès !', 'item': serializer.data}, status=status.HTTP_201_CREATED) + + +# ========================================== +# 4. DÉTAIL D'UN ARTICLE +# ========================================== +@api_view(['GET', 'DELETE']) +@permission_classes([AllowAny]) +def item_detail_view(request, pk): + try: + item = Item.objects.get(pk=pk) + except Item.DoesNotExist: + return Response({"error": f"L'article {pk} n'existe pas."}, status=status.HTTP_404_NOT_FOUND) + + if request.method == 'GET': + serializer = ItemSerializer(item, context={'request': request}) + return Response(serializer.data, status=status.HTTP_200_OK) + + elif request.method == 'DELETE': + # Seul le propriétaire peut supprimer + if request.user.is_authenticated and item.user == request.user: + item.delete() + return Response({"message": "Article supprimé."}, status=status.HTTP_204_NO_CONTENT) + return Response({"error": "Non autorisé."}, status=status.HTTP_403_FORBIDDEN) + + +# ========================================== +# 5. MON ESPACE PERSONNEL +# ========================================== +class MySpaceView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + user_products = Item.objects.filter(user=user).order_by('-id') + products_serializer = ItemSerializer(user_products, many=True, context={'request': request}) + + all_swaps = SwapExchange.objects.filter(Q(sender=user) | Q(receiver=user)).order_by('-id') + swaps_pending = all_swaps.filter(status='pending') + swaps_completed = all_swaps.filter(status__in=['accepted', 'rejected']) + + return Response({ + "user": UserSerializer(user).data, + "items": products_serializer.data, + "transactions_pending": SwapExchangeSerializer(swaps_pending, many=True).data, + "transactions_completed": SwapExchangeSerializer(swaps_completed, many=True).data, + }) + + +# ========================================== +# 6. VITRINE PUBLIQUE +# ========================================== +@api_view(['GET']) +@permission_classes([AllowAny]) +def get_items(request): + items = Item.objects.filter( + Q(image__startswith='http://') | Q(image__startswith='https://') + ).order_by('-id') + serializer = ItemSerializer(items, many=True, context={'request': request}) + return Response(serializer.data, status=status.HTTP_200_OK) + + +# ========================================== +# 7. SYSTÈME DE TROC — CRÉATION D'UNE PROPOSITION +# ========================================== +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def create_swap(request): + """ + Crée une nouvelle proposition de troc. + Body attendu: { my_item: int, their_item: int, receiver: int } + """ + sender = request.user + my_item_id = request.data.get('my_item') + their_item_id = request.data.get('their_item') + receiver_id = request.data.get('receiver') + + # Validations + if not all([my_item_id, their_item_id, receiver_id]): + return Response({"error": "Champs manquants : my_item, their_item, receiver sont obligatoires."}, status=status.HTTP_400_BAD_REQUEST) + + try: + my_item = Item.objects.get(pk=my_item_id, user=sender) + except Item.DoesNotExist: + return Response({"error": "Votre article introuvable ou ne vous appartient pas."}, status=status.HTTP_404_NOT_FOUND) + + try: + their_item = Item.objects.get(pk=their_item_id) + except Item.DoesNotExist: + return Response({"error": "L'article cible est introuvable."}, status=status.HTTP_404_NOT_FOUND) + + try: + receiver = User.objects.get(pk=receiver_id) + except User.DoesNotExist: + return Response({"error": "Destinataire introuvable."}, status=status.HTTP_404_NOT_FOUND) + + # Pas de troc avec soi-même + if sender == receiver: + return Response({"error": "Vous ne pouvez pas proposer un troc à vous-même."}, status=status.HTTP_400_BAD_REQUEST) + + # Éviter les doublons en attente + existing = SwapExchange.objects.filter( + sender=sender, my_item=my_item, their_item=their_item, status='pending' + ).exists() + if existing: + return Response({"error": "Une proposition identique est déjà en attente."}, status=status.HTTP_400_BAD_REQUEST) + + # Calcul de la fairness automatique + my_val = my_item.value or 0 + their_val = their_item.value or 0 + if my_val == 0 and their_val == 0: + fairness = 'fair' + elif my_val == 0 or their_val == 0: + fairness = 'skewed' + else: + ratio = max(my_val, their_val) / min(my_val, their_val) + if ratio <= 1.2: + fairness = 'fair' + elif ratio <= 2.0: + fairness = 'skewed' + else: + fairness = 'unfair' + + swap = SwapExchange.objects.create( + sender=sender, + receiver=receiver, + my_item=my_item, + their_item=their_item, + fairness=fairness, + status='pending' + ) + + serializer = SwapExchangeSerializer(swap) + return Response({ + "message": "Proposition de troc envoyée !", + "swap": serializer.data + }, status=status.HTTP_201_CREATED) + + +# ========================================== +# 8. SYSTÈME DE TROC — RÉPONDRE (ACCEPTER/REFUSER) +# ========================================== +@api_view(['PATCH']) +@permission_classes([IsAuthenticated]) +def respond_swap(request, swap_id): + """ + Le receiver accepte ou refuse une proposition. + Body attendu: { action: 'accepted' | 'rejected' } + """ + try: + swap = SwapExchange.objects.get(pk=swap_id, receiver=request.user) + except SwapExchange.DoesNotExist: + return Response({"error": "Proposition introuvable ou vous n'êtes pas le destinataire."}, status=status.HTTP_404_NOT_FOUND) + + if swap.status != 'pending': + return Response({"error": f"Cette proposition a déjà été traitée ({swap.status})."}, status=status.HTTP_400_BAD_REQUEST) + + action = request.data.get('action') + if action not in ['accepted', 'rejected']: + return Response({"error": "Action invalide. Utilisez 'accepted' ou 'rejected'."}, status=status.HTTP_400_BAD_REQUEST) + + swap.status = action + swap.save() + + serializer = SwapExchangeSerializer(swap) + msg = "Troc accepté ! Les contacts seront partagés." if action == 'accepted' else "Proposition refusée." + return Response({"message": msg, "swap": serializer.data}, status=status.HTTP_200_OK) + + +# ========================================== +# 9. LISTE DES SWAPS (pour debugging) +# ========================================== +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def my_swaps(request): + user = request.user + swaps = SwapExchange.objects.filter(Q(sender=user) | Q(receiver=user)).order_by('-created_at') + serializer = SwapExchangeSerializer(swaps, many=True) + return Response(serializer.data) diff --git a/backend/items/397b4a4bcb85b5cfac36006b333241e1.jpg b/backend/items/397b4a4bcb85b5cfac36006b333241e1.jpg new file mode 100644 index 0000000..9498c7a Binary files /dev/null and b/backend/items/397b4a4bcb85b5cfac36006b333241e1.jpg differ diff --git a/backend/items/9a0f5b0764bfcee42372e74d4147897c.jpg b/backend/items/9a0f5b0764bfcee42372e74d4147897c.jpg new file mode 100644 index 0000000..289ce71 Binary files /dev/null and b/backend/items/9a0f5b0764bfcee42372e74d4147897c.jpg differ diff --git a/backend/items/9a0f5b0764bfcee42372e74d4147897c_RTDNM96.jpg b/backend/items/9a0f5b0764bfcee42372e74d4147897c_RTDNM96.jpg new file mode 100644 index 0000000..289ce71 Binary files /dev/null and b/backend/items/9a0f5b0764bfcee42372e74d4147897c_RTDNM96.jpg differ diff --git a/backend/items/duck.png b/backend/items/duck.png new file mode 100644 index 0000000..a77915c Binary files /dev/null and b/backend/items/duck.png differ diff --git a/backend/items/review.png b/backend/items/review.png new file mode 100644 index 0000000..6598db7 Binary files /dev/null and b/backend/items/review.png differ diff --git a/backend/items/screen_1.png b/backend/items/screen_1.png new file mode 100644 index 0000000..dc0f80f Binary files /dev/null and b/backend/items/screen_1.png differ diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..9fe92fa --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swappit_backend.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/python b/backend/python new file mode 100644 index 0000000..e69de29 diff --git a/backend/swappit_backend/__init__.py b/backend/swappit_backend/__init__.py new file mode 100644 index 0000000..bc6c8fb --- /dev/null +++ b/backend/swappit_backend/__init__.py @@ -0,0 +1,3 @@ +import pymysql + +pymysql.install_as_MySQLdb() \ No newline at end of file diff --git a/backend/swappit_backend/__pycache__/__init__.cpython-313.pyc b/backend/swappit_backend/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..3f5514c Binary files /dev/null and b/backend/swappit_backend/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/swappit_backend/__pycache__/settings.cpython-313.pyc b/backend/swappit_backend/__pycache__/settings.cpython-313.pyc new file mode 100644 index 0000000..b3e360c Binary files /dev/null and b/backend/swappit_backend/__pycache__/settings.cpython-313.pyc differ diff --git a/backend/swappit_backend/__pycache__/urls.cpython-313.pyc b/backend/swappit_backend/__pycache__/urls.cpython-313.pyc new file mode 100644 index 0000000..221b369 Binary files /dev/null and b/backend/swappit_backend/__pycache__/urls.cpython-313.pyc differ diff --git a/backend/swappit_backend/__pycache__/wsgi.cpython-313.pyc b/backend/swappit_backend/__pycache__/wsgi.cpython-313.pyc new file mode 100644 index 0000000..3e1f97c Binary files /dev/null and b/backend/swappit_backend/__pycache__/wsgi.cpython-313.pyc differ diff --git a/backend/swappit_backend/asgi.py b/backend/swappit_backend/asgi.py new file mode 100644 index 0000000..3769eef --- /dev/null +++ b/backend/swappit_backend/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for swappit_backend project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swappit_backend.settings') + +application = get_asgi_application() diff --git a/backend/swappit_backend/settings.py b/backend/swappit_backend/settings.py new file mode 100644 index 0000000..57914ce --- /dev/null +++ b/backend/swappit_backend/settings.py @@ -0,0 +1,127 @@ +""" +Django settings for swappit_backend project. +""" + +from pathlib import Path + +# Base directory +BASE_DIR = Path(__file__).resolve().parent.parent + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-v=0p-3!u(a7d3h6f#c_2_1k(g#h$q*5v_l@p4o9s0-a=)m66^+' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['*'] + +AUTH_USER_MODEL = 'core.User' + + +# Application definition + +INSTALLED_APPS = [ + 'corsheaders', + 'django.contrib.admin', + 'rest_framework', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'core', +] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'swappit_backend.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'swappit_backend.wsgi.application' + + +# Database Configuration (Connexion à MariaDB via XAMPP) + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': 'swappit_db', + 'USER': 'root', + 'PASSWORD': '', + 'HOST': '127.0.0.1', + 'PORT': '3306', + 'OPTIONS': { + 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", + }, + } +} + +REST_FRAMEWORK = { + 'DEFAULT_RENDERER_CLASSES': ( + 'rest_framework.renderers.JSONRenderer', + ), + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), +} + + + +# Password validation + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Static files (CSS, JavaScript, Images) + +STATIC_URL = 'static/' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +from django.db.backends.mysql.base import DatabaseWrapper +# 1. On force Django à croire qu'on est sur MySQL standard (pas MariaDB) +DatabaseWrapper.mysql_is_mariadb = False +DatabaseWrapper.mysql_version = (8, 0, 25) + +CORS_ALLOWED_ORIGINS = [ + "http://localhost:5173", + "http://127.0.0.1:5173", +] +CORS_ALLOW_ALL_ORIGINS = True \ No newline at end of file diff --git a/backend/swappit_backend/urls.py b/backend/swappit_backend/urls.py new file mode 100644 index 0000000..18a4806 --- /dev/null +++ b/backend/swappit_backend/urls.py @@ -0,0 +1,42 @@ +from django.contrib import admin +from django.urls import path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from core.views import ( + signup_view, signin_view, + item_list_create, item_detail_view, get_items, + MySpaceView, + create_swap, respond_swap, my_swaps +) +from django.conf import settings +from django.conf.urls.static import static + +admin.site.site_header = "Swappit Admin" +admin.site.site_title = "Swappit Admin Portal" + +urlpatterns = [ + path('admin/', admin.site.urls), + + # JWT standard (utile comme fallback) + path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + + # Auth custom (retourne user + tokens) + path('api/signup/', signup_view, name='api_signup'), + path('api/signin/', signin_view, name='api_signin'), # ← utilise cette route ! + + # Articles + path('api/items/', item_list_create, name='item-list-create'), + path('api/items//', item_detail_view, name='item-detail'), + path('api/get_items/', get_items, name='get-items'), + + # Espace personnel + path('api/myspace/', MySpaceView.as_view(), name='user-myspace'), + + # ============ SYSTÈME DE TROC ============ + path('api/swaps/', create_swap, name='swap-create'), # POST : proposer un troc + path('api/swaps//respond/', respond_swap, name='swap-respond'), # PATCH : accepter/refuser + path('api/swaps/mine/', my_swaps, name='my-swaps'), # GET : voir ses swaps +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/swappit_backend/wsgi.py b/backend/swappit_backend/wsgi.py new file mode 100644 index 0000000..356afc0 --- /dev/null +++ b/backend/swappit_backend/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for swappit_backend project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swappit_backend.settings') + +application = get_wsgi_application() diff --git a/index.css b/index.css new file mode 100644 index 0000000..21c24b4 --- /dev/null +++ b/index.css @@ -0,0 +1,159 @@ +:root { + /* Colors */ + --ink: #111318; + --ink-light: #3d4147; + --ink-muted: #7c8291; + --surface: #f6f7f9; + --surface-card: #ffffff; + --accent: #e8521f; + --accent-hover: #cc4318; + --accent-soft: rgba(232, 82, 31, 0.09); + --green: #16a34a; + --green-soft: rgba(22, 163, 74, 0.1); + --orange: #d97706; + --orange-soft: rgba(217, 119, 6, 0.1); + --red: #dc2626; + --red-soft: rgba(220, 38, 38, 0.1); + --border: #e4e6eb; + --border-strong: #c8ccd4; + + /* Shadows */ + --shadow-sm: 0 1px 4px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.04); + --shadow-md: 0 4px 16px rgba(0,0,0,0.08), 0 2px 6px rgba(0,0,0,0.05); + --shadow-lg: 0 16px 48px rgba(0,0,0,0.12), 0 4px 16px rgba(0,0,0,0.06); + --shadow-xl: 0 32px 80px rgba(0,0,0,0.16), 0 8px 24px rgba(0,0,0,0.08); + + /* Shape */ + --radius: 14px; + --radius-sm: 8px; + --radius-lg: 20px; + --radius-pill: 999px; + + /* Typography — Clash Display for headings, Plus Jakarta Sans for body */ + --font-display: 'Clash Display', 'SF Pro Display', system-ui, sans-serif; + --font-body: 'Plus Jakarta Sans', 'SF Pro Text', system-ui, sans-serif; + + /* Spacing scale */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 40px; + --space-2xl: 64px; + --space-3xl: 96px; + + /* Transitions */ + --transition: 0.18s ease; + --transition-slow: 0.35s ease; + + /* Max widths */ + --max-w: 1180px; + --max-w-narrow: 760px; +} + +/* ── Reset ──────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { scroll-behavior: smooth; font-size: 16px; text-size-adjust: 100%; } +body { + font-family: var(--font-body); + background: var(--surface); + color: var(--ink); + min-height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.6; +} +a { text-decoration: none; color: inherit; } +button { cursor: pointer; font-family: var(--font-body); border: none; background: none; line-height: 1; } +input, textarea, select { + font-family: var(--font-body); + font-size: 15px; + outline: none; + border: none; + line-height: 1.5; +} +img { display: block; max-width: 100%; height: auto; } +ul, ol { list-style: none; } + +/* ── Typography scale ───────────────────────────────────────── */ +.display-xl { + font-family: var(--font-display); + font-size: clamp(40px, 6vw, 80px); + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.0; +} +.display-lg { + font-family: var(--font-display); + font-size: clamp(30px, 4vw, 56px); + font-weight: 700; + letter-spacing: -0.025em; + line-height: 1.05; +} +.display-md { + font-family: var(--font-display); + font-size: clamp(22px, 2.5vw, 36px); + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.15; +} +.display-sm { + font-family: var(--font-display); + font-size: clamp(16px, 1.5vw, 22px); + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.2; +} +.body-lg { font-size: 17px; line-height: 1.7; } +.body-md { font-size: 15px; line-height: 1.65; } +.body-sm { font-size: 13px; line-height: 1.55; } +.body-xs { font-size: 11px; line-height: 1.5; } +.label { font-size: 12px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; } + +/* ── Scrollbar ──────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +/* ── Animations ─────────────────────────────────────────────── */ +@keyframes fadeUp { + from { opacity: 0; transform: translateY(18px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes scaleIn { + from { opacity: 0; transform: scale(0.96) translateY(6px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} +@keyframes spin { + to { transform: rotate(360deg); } +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* ── Responsive helpers ─────────────────────────────────────── */ +.hide-mobile { display: block; } +@media (max-width: 640px) { + .hide-mobile { display: none !important; } + :root { --space-xl: 28px; --space-2xl: 44px; --space-3xl: 64px; } +} + +/* ── Utility ────────────────────────────────────────────────── */ +.sr-only { + position: absolute; width: 1px; height: 1px; + overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; +} +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.clamp-2 { + display: -webkit-box; -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; +} diff --git a/main.jsx b/main.jsx new file mode 100644 index 0000000..e14d575 --- /dev/null +++ b/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + +) diff --git a/pages/About.jsx b/pages/About.jsx new file mode 100644 index 0000000..ea52f16 --- /dev/null +++ b/pages/About.jsx @@ -0,0 +1,147 @@ +import { Link } from 'react-router-dom'; + +const TEAM = [ + { id: 1, name: 'Membre 1', role: 'Fullstack Developer', bio: 'Passionné de tech et de commerce équitable. Architecte du système de troc.', emoji: '👨‍💻', color: '#E8521F' }, + { id: 2, name: 'Membre 2', role: 'UI/UX Designer', bio: 'Créatrice de l\'interface Swappit. Croit en un design centré sur l\'humain.', emoji: '🎨', color: '#3B82F6' }, + { id: 3, name: 'Membre 3', role: 'Backend Engineer', bio: 'Architecte Django. Garant de la performance et de la sécurité de la plateforme.', emoji: '⚙️', color: '#10B981' }, +]; + +export default function About() { + return ( +
+ {/* Navbar simple */} + + + {/* Hero */} +
+
+
+
+ ✦ The Team +
+

+ Built by people who believe in sharing. +

+

+ Swappit est né d'un projet universitaire au Cameroun, d'une observation simple : trop d'objets utiles sont gaspillés parce qu'il est difficile de trouver le bon échange. +

+
+
+ + {/* Mission */} +
+
+
+ {[ + { icon: '🌍', title: 'Notre Mission', text: "Réduire le gaspillage et aider les gens à accéder à ce dont ils ont besoin grâce à des échanges équitables et transparents." }, + { icon: '⚖️', title: 'Nos Valeurs', text: "L'équité avant tout. Chaque échange sur Swappit est évalué pour son équilibre, car un commerce honnête crée une vraie confiance." }, + { icon: '🚀', title: 'Notre Vision', text: "Partir du Cameroun, grandir à travers l'Afrique. Un continent où des millions d'objets utiles restent inutilisés — nous changeons ça." }, + ].map(v => ( +
{ e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = 'var(--shadow-md)'; }} + onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = 'none'; }} + > +
{v.icon}
+

{v.title}

+

{v.text}

+
+ ))} +
+
+
+ + {/* Team */} +
+
+
+
+ Meet the builders +
+

+ The people behind Swappit +

+
+
+ {TEAM.map((member, i) => ( + + ))} +
+
+
+ + {/* Stats */} +
+
+
+ {[ + { value: '8+', label: 'Articles sur la plateforme' }, + { value: '3', label: 'Utilisateurs beta actifs' }, + { value: '100%', label: 'Gratuit, sans frais' }, + { value: '♥', label: 'Made in Cameroon' }, + ].map(s => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+
+
+ + {/* CTA */} +
+
+

+ Ready to join us? +

+

+ Crée ton compte et commence à troquer. Sans frais, sans tracas. +

+
+ e.currentTarget.style.background = 'var(--accent)'} + onMouseLeave={e => e.currentTarget.style.background = 'var(--ink)'} + >Start Swapping → + + ← Retour + +
+
+
+ +
+
+ swappit + © 2026 Swappit · Made with ♥ in Cameroon +
+
+
+ ); +} + +function TeamCard({ member, delay }) { + return ( +
{ e.currentTarget.style.transform = 'translateY(-4px)'; e.currentTarget.style.boxShadow = 'var(--shadow-md)'; }} + onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = 'none'; }} + > +
+
+
{member.emoji}
+

{member.name}

+
{member.role}
+

{member.bio}

+
+
+ ); +} diff --git a/pages/CreateItem.jsx b/pages/CreateItem.jsx new file mode 100644 index 0000000..1926bf2 --- /dev/null +++ b/pages/CreateItem.jsx @@ -0,0 +1,241 @@ +import React, { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { createItem, getTokenUserId } from '../services/api'; + +// CORRECTION : Les catégories correspondent EXACTEMENT aux choix du backend Django +const CATEGORIES = [ + { value: 'Electronics', label: 'Électronique / Informatique' }, + { value: 'Clothing', label: 'Mode / Vêtements' }, + { value: 'Furniture', label: 'Meubles' }, + { value: 'Books', label: 'Livres' }, + { value: 'Music', label: 'Musique' }, + { value: 'Sports', label: 'Sports' }, + { value: 'Other', label: 'Autre' }, +]; + +// CORRECTION : Les conditions correspondent EXACTEMENT aux choix du backend Django +const CONDITIONS = [ + { value: 'NEW', label: 'Neuf' }, + { value: 'LIKE_NEW', label: 'Très bon état' }, + { value: 'USED', label: 'Utilisé' }, +]; + +const EMOJIS = ['📱', '👟', '📚', '🎵', '💻', '🎮', '👗', '🛋️', '📦', '🎸']; + +const CreateItem = () => { + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const [title, setTitle] = useState(''); + const [category, setCategory] = useState('Other'); + const [condition, setCondition] = useState('USED'); + const [description, setDescription] = useState(''); + const [image, setImage] = useState(''); + const [value, setValue] = useState(''); + const [emoji, setEmoji] = useState('📦'); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + if (!title.trim()) { + setError("Le nom de l'article est obligatoire."); + return; + } + + setLoading(true); + + // Récupération de l'ID utilisateur depuis le JWT + const ownerId = getTokenUserId(); + if (!ownerId) { + setError("Session expirée. Veuillez vous reconnecter."); + navigate('/signin'); + return; + } + + try { + const payload = { + title: title.trim(), + category, + condition, + description: description.trim(), + image: image.trim() || null, + value: value ? parseInt(value, 10) : 0, + emoji, + owner_id: ownerId, + }; + + await createItem(payload); + navigate('/myspace', { replace: true }); + } catch (err) { + console.error("Erreur publication :", err); + if (err.response?.data?.error) { + setError(err.response.data.error); + } else { + setError("Erreur lors de la mise en circulation. Vérifiez votre connexion."); + } + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+ ← Retour + Nouveau Troc +
+ +

+ Qu'est-ce que tu proposes ? 📦 +

+

+ Remplis les détails pour trouver le match parfait. +

+ + {error && ( +
+ {error} +
+ )} + +
+ + {/* NOM */} + + setTitle(e.target.value)} + style={inputStyle} + /> + + + {/* EMOJI */} + +
+ {EMOJIS.map(e => ( + + ))} +
+
+ + {/* CATÉGORIE */} + + + + + {/* ÉTAT */} + +
+ {CONDITIONS.map(c => ( + + ))} +
+
+ + {/* VALEUR ESTIMÉE */} + + setValue(e.target.value)} + min="0" + style={inputStyle} + /> + + + {/* URL PHOTO */} + + setImage(e.target.value)} + style={inputStyle} + /> + {image && ( + Aperçu e.target.style.display = 'none'} + /> + )} + + + {/* DESCRIPTION */} + +