diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..1bff170 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +github: Bond52 +ko_fi: bond52 + diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 01b5cc4..1c5a9f1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,21 +1,23 @@ --- -name: 🐛 Bug Report -about: Signaler un bug dans Sawaka +name: Bug report +about: Report a bug +title: "[BUG] " +labels: ["bug"] --- -# 🐛 Description du bug -Décrivez clairement le problème rencontré. +### Description +What is the bug? -## 🔁 Étapes pour reproduire -1. … -2. … -3. … +### Steps to Reproduce +1. ... +2. ... -## ✔ Comportement attendu -Décrivez ce qui aurait dû se produire. +### Expected Behavior +... -## 🖥️ Environnement -- OS : -- Navigateur : -- Version backend : -- Version frontend : +### Actual Behavior +... + +### Definition of Done +- [ ] Bug fixed +- [ ] Regression tested diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/requirement-business.yml b/.github/ISSUE_TEMPLATE/requirement-business.yml new file mode 100644 index 0000000..aa7e4a7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/requirement-business.yml @@ -0,0 +1,43 @@ +name: "Requirement — Business" +description: "Define a business-level requirement" +title: "BR — " +labels: ["requirement", "business"] +body: + - type: textarea + id: description + attributes: + label: Description + placeholder: Describe the business need + validations: + required: true + + - type: textarea + id: business_objective + attributes: + label: Business Objective + placeholder: What business goal does this support? + validations: + required: true + + - type: textarea + id: expected_outcome + attributes: + label: Expected Outcome + placeholder: What value should be delivered? + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: Priority + options: + - High + - Medium + - Low + + - type: textarea + id: relationships + attributes: + label: Relationships + placeholder: Parent / Derived from / Related to \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/requirement-functional.yml b/.github/ISSUE_TEMPLATE/requirement-functional.yml new file mode 100644 index 0000000..f5cae0d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/requirement-functional.yml @@ -0,0 +1,53 @@ +name: "Requirement — Functional" +description: "Define a functional requirement" +title: "FR — " +labels: ["requirement", "functional"] +body: + - type: textarea + id: description + attributes: + label: Description + placeholder: Describe the feature behavior + validations: + required: true + + - type: textarea + id: business_intent + attributes: + label: Business Intent + placeholder: Why is this needed? + validations: + required: true + + - type: textarea + id: expected_outcome + attributes: + label: Expected Outcome + placeholder: What should happen? + validations: + required: true + + - type: textarea + id: acceptance + attributes: + label: Acceptance Criteria + placeholder: | + - [ ] Criteria 1 + - [ ] Criteria 2 + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: Priority + options: + - High + - Medium + - Low + + - type: textarea + id: relationships + attributes: + label: Relationships + placeholder: Parent / Derived from / Related to \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/requirement-nfr.yml b/.github/ISSUE_TEMPLATE/requirement-nfr.yml new file mode 100644 index 0000000..73f4440 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/requirement-nfr.yml @@ -0,0 +1,43 @@ +name: "Requirement — Non-Functional" +description: "Define a non-functional requirement (performance, security, reliability...)" +title: "NFR - " +labels: ["requirement", "nfr"] +body: + - type: textarea + id: description + attributes: + label: Description + placeholder: Describe the requirement clearly + validations: + required: true + + - type: textarea + id: objective + attributes: + label: Objective + placeholder: What is the goal of this requirement? + validations: + required: true + + - type: textarea + id: metric + attributes: + label: Success Metric + placeholder: Example: ≥ 95% delivery success rate + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: Priority + options: + - High + - Medium + - Low + + - type: textarea + id: relationships + attributes: + label: Relationships + placeholder: Parent / Derived from / Related to \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..96da269 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.md @@ -0,0 +1,17 @@ +## Description +What needs to be done and why. + +## Scope +- [ ] Define scope item 1 +- [ ] Define scope item 2 + +## Technical Notes +Implementation details (UI / API / Backend / Infra) + +## Dependencies +- Related issues / requirements / blockers + +## Definition of Done +- [ ] Implementation completed +- [ ] Code reviewed +- [ ] Tested \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/test-case.yml b/.github/ISSUE_TEMPLATE/test-case.yml new file mode 100644 index 0000000..9ab954a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/test-case.yml @@ -0,0 +1,36 @@ +name: "Test Case" +description: "Define a test case for validation" +title: "TC - " +labels: ["test-case"] + +body: + - type: textarea + id: description + attributes: + label: Description + placeholder: Describe the test case clearly + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Test Steps + placeholder: Step-by-step execution + + - type: textarea + id: expected + attributes: + label: Expected Results + + - type: textarea + id: test_data + attributes: + label: Test Data + placeholder: Input values used in the test + + - type: textarea + id: notes + attributes: + label: Notes + placeholder: Optional remarks / edge cases \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/user_story.md b/.github/ISSUE_TEMPLATE/user_story.md new file mode 100644 index 0000000..bb45c47 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/user_story.md @@ -0,0 +1,21 @@ +--- +name: User Story +about: Create a user story +title: "[US] " +labels: ["user story"] +assignees: "" +--- + +### User Story +As a user, I want to ... so that ... + +### Acceptance Criteria +- Given ... +- When ... +- Then ... + +### Definition of Done +- [ ] Acceptance criteria met +- [ ] Tested in QA +- [ ] No critical bugs +- [ ] Documentation updated (if needed) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0c50bfc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,92 @@ +name: Sawaka CI + +on: + push: + branches: [main, qa] + pull_request: + branches: [main, qa] + +jobs: + ci: + runs-on: ubuntu-latest + + services: + mongo: + image: mongo:6 + ports: + - 27017:27017 + options: >- + --health-cmd "mongosh --eval 'db.runCommand({ ping: 1 })'" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + NODE_ENV: test + CI: "true" + MONGO_URI: mongodb://localhost:27017/sawaka_test + JWT_SECRET: ci-dummy-secret-do-not-use-in-prod + FRONTEND_URL: http://localhost:3000 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: "npm" + + - name: Install frontend dependencies + run: npm ci + + # ================================================== + # Backend — Jest + # ================================================== + - name: Install backend dependencies + working-directory: backend-api + run: npm ci + + - name: Run backend tests (Jest) + working-directory: backend-api + run: npm test + + # ================================================== + # API — Newman (server must be running) + # ================================================== + - name: Start API, wait, then run Newman + run: | + (cd backend-api && node index.js) & + npx wait-on -t 30000 http://localhost:5000/ + npx newman run tests/api/postman/sawaka.postman_collection.json \ + -e tests/api/postman/sawaka.postman_environment.json \ + --delay-request 100 + + # ================================================== + # Frontend — lint, build + # ================================================== + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + + # ================================================== + # Frontend — Playwright (app must be running) + # ================================================== + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Start Next.js, wait, then run Playwright + run: | + npm run start & + npx wait-on -t 60000 http://localhost:3000/ + npx playwright test + + # ================================================== + # Docs generation + # ================================================== + - name: Generate documentation + working-directory: backend-api + run: npm run docs:generate diff --git a/.github/workflows/docs-pages-qa.yml b/.github/workflows/docs-pages-qa.yml new file mode 100644 index 0000000..9011f61 --- /dev/null +++ b/.github/workflows/docs-pages-qa.yml @@ -0,0 +1,44 @@ +name: Docs (Context Analysis – QA) + +on: + push: + branches: [qa] + paths: + - "docs/**" + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install MkDocs + run: pip install mkdocs mkdocs-material + + - name: Build docs site + run: mkdocs build + + - uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + steps: + - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml new file mode 100644 index 0000000..bd34bea --- /dev/null +++ b/.github/workflows/docs-pages.yml @@ -0,0 +1,83 @@ +# Production documentation — GitHub Pages only from main. +# Publishes JSDoc and Swagger UI to GitHub Pages. +name: Docs Publish to GitHub Pages + +on: + push: + branches: + - main + - qa + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install backend dependencies + working-directory: backend-api + run: npm ci + + - name: Run JSDoc generation + working-directory: backend-api + run: npm run docs:generate + + - name: Run Swagger UI static generation + working-directory: backend-api + run: npm run docs:swagger-ui + + - name: Prepare site directory + run: | + mkdir -p site + cp -r backend-api/docs/jsdoc site/jsdoc + cp -r backend-api/docs/api-docs site/api-docs + + - name: Add root index page + run: | + cat > site/index.html << 'EOF' + + + + + + Sawaka Documentation + + +

Sawaka Documentation

+ + + + EOF + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site/ + + # Deploy job requires pages:write and id-token:write for Pages + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/docs-qa.yml b/.github/workflows/docs-qa.yml new file mode 100644 index 0000000..3536abe --- /dev/null +++ b/.github/workflows/docs-qa.yml @@ -0,0 +1,74 @@ +# QA documentation validation — no GitHub Pages deployment. +# Validates API docs (JSDoc + Swagger) and business documentation on qa. +name: Docs QA + +on: + push: + branches: [qa] + pull_request: + branches: [qa] + +jobs: + docs: + runs-on: ubuntu-latest + + steps: + # ---------------------------- + # Checkout + # ---------------------------- + - name: Checkout repository + uses: actions/checkout@v4 + + # ---------------------------- + # Node setup (API docs) + # ---------------------------- + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install backend dependencies + working-directory: backend-api + run: npm ci + + # ---------------------------- + # API documentation validation + # ---------------------------- + - name: Generate JSDoc + working-directory: backend-api + run: npm run docs:generate + + - name: Generate Swagger UI (static) + working-directory: backend-api + run: npm run docs:swagger-ui + + # ---------------------------- + # Business documentation checks + # ---------------------------- + - name: Validate business documentation structure + run: | + echo "Checking business documentation files..." + + test -f docs/context-analysis/pestle.md + test -f docs/context-analysis/market_analysis.md + test -f docs/context-analysis/README.md + + test -d docs/glossary + test -f docs/glossary/channel_model.md + test -f docs/glossary/monetization.md + test -f docs/glossary/type_of_governance.md + + echo "Business documentation structure OK" + + # ---------------------------- + # Artifact (manual download) + # ---------------------------- + - name: Upload QA documentation artifact + uses: actions/upload-artifact@v4 + with: + name: docs-qa + path: | + backend-api/docs/jsdoc + backend-api/docs/api-docs + docs/context-analysis + docs/glossary diff --git a/.gitignore b/.gitignore index 65230b1..3071f0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules .next .env.local +backend-api/docs # Ignorer les fichiers d'environnement .env diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..37e329c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project follows [Semantic Versioning](https://semver.org/). + +--- + +## [0.1.0] - 2025-12-13 + +### Added +- Artisan profiles management +- Product and service management +- JWT-based authentication +- Image uploads with Cloudinary + +### Technical Foundation +- Frontend built with Next.js +- Backend API built with Node.js and Express +- MongoDB integration +- Environment-based configuration (`.env`, `.env.local`) + +### Developer Experience +- Local development setup (frontend + backend) +- Nodemon support for backend development +- Clear project structure + +### Open Source Governance +- Branching strategy (`main`, `qa`, `feature/*`) +- Protected branches with mandatory Pull Requests +- Standard documentation: + - README + - CONTRIBUTING + - Code of Conduct + - Security policy diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 37994bb..563e9af 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -23,6 +23,6 @@ Project maintainers may take the following actions: ## 📩 Reporting an Issue If you observe a violation of this Code of Conduct, please contact: -**opensource@sawaka.org** +**contact@sawaka.org** All reports will be handled confidentially. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 667a1bc..aaca89c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ git checkout -b feature/my-feature ### 3. Develop Your Contribution - Follow coding best practices - Add comments where necessary -- Test locally +- Test locally (see [README](README.md#running-tests-locally)) ### 4. Clean Commit ```bash @@ -24,7 +24,7 @@ git commit -m "feat: short description of the feature" ``` ### 5. Create a Pull Request -Clearly describe what your PR does. +Clearly describe what your PR does. **CI must pass** (GitHub Actions). The workflow is the Definition of Done; see [README — CI and Definition of Done](README.md#ci-and-definition-of-done). --- @@ -41,4 +41,4 @@ Clearly describe what your PR does. --- ## 🙏 Thank You! -Your contribution helps support the Sawaka community. +Your contribution helps support artisans and the Sawaka community. diff --git a/README.md b/README.md index 2fc7d68..90d3e3f 100644 --- a/README.md +++ b/README.md @@ -39,92 +39,201 @@ git clone https://github.com/Bond52/sawaka.git cd sawaka ``` ---- - ### 2. Install dependencies -#### 🔹 Frontend (Next.js – root folder) +**Frontend (root):** ```bash npm install ``` -#### 🔹 Backend (Node.js / Express API) +**Backend:** ```bash cd backend-api npm install +cd .. ``` ---- +### 3. Environment variables -## 🛠️ Environment Variables - -To run the project locally or in production, create the following environment files. - ---- - -### 🔧 Backend (`backend-api/.env`) +Create `backend-api/.env`: ```ini PORT=5000 -MONGO_URI=... -JWT_SECRET=... +MONGO_URI=your_mongodb_connection_string +JWT_SECRET=your_secret CLOUDINARY_CLOUD_NAME=... CLOUDINARY_API_KEY=... CLOUDINARY_API_SECRET=... ``` ---- +Create `.env.local` in the project root (for the Next.js app): + +```ini +NEXT_PUBLIC_API_URL=https://sawaka-api-prod.onrender.com +``` + +For local development, use `http://localhost:5000` if the API runs locally. + +Do **not** commit `.env` or `.env.local`. -## 🌐 API Environments +### 4. Run the app + +**Terminal 1 — API:** + +```bash +cd backend-api && npm run dev +``` -- **Production**: https://sawaka-api-prod.onrender.com -- **QA**: https://sawaka-api-qa.onrender.com +**Terminal 2 — Frontend:** + +```bash +npm run dev +``` + +- Frontend: [http://localhost:3000](http://localhost:3000) +- API: [http://localhost:5000](http://localhost:5000) + +See [backend-api/README.md](backend-api/README.md) for API-specific scripts and layout. --- -### 🌐 Frontend (`.env.local`) +## Running tests locally -```ini -NEXT_PUBLIC_API_URL=https://sawaka-api-prod.onrender.com +### Backend (Jest) + +Requires a running MongoDB instance (local or cloud). Set `MONGO_URI` in `backend-api/.env`. + +```bash +cd backend-api +npm test +``` + +### Frontend — Lint & build + +```bash +npm run lint +npm run build +``` + +### Frontend — Playwright (E2E) + +Build the app, then run Playwright: + +```bash +npm run build +npm run start & +npx wait-on -t 60000 http://localhost:3000/ +npx playwright test ``` -⚠️ Never commit `.env` files to GitHub. +### API — Newman (Postman) + +Start the API first, then run the collection: + +```bash +cd backend-api && node index.js & +npx wait-on -t 30000 http://localhost:5000/ +npx newman run tests/api/postman/sawaka.postman_collection.json \ + -e tests/api/postman/sawaka.postman_environment.json +``` --- -## 📐 Diagrams & Architecture +## CI and Definition of Done + +**GitHub Actions is the executable Definition of Done (DoD).** The [Sawaka CI](.github/workflows/ci.yml) workflow runs on every push and pull request to `main` and `qa`. A pull request is considered **Done** only when all required checks pass. + +### Required checks -Architecture diagrams (system architecture, flows, data models) are created using **draw.io (diagrams.net)** -and stored in the `/docs/diagrams` directory. +All of the following must succeed: + +| Check | Description | +|-------|-------------| +| **Tests** | **Jest** (backend), **Newman** (API), **Playwright** (frontend E2E) | +| **Lint** | `npm run lint` (Next.js ESLint) | +| **Build** | `npm run build` (Next.js) | +| **Documentation** | `npm run docs:generate` (JSDoc in `backend-api`) | + +### Branch protection + +Branch protection rules enforce these quality gates: the configured branches (e.g. `main`, `qa`) require that CI succeeds before a PR can be merged. No merge without passing checks. + +### Definition of Done (summary) + +A task or PR is **Done** when: + +- Acceptance criteria are met +- Code is on the correct branch (`main` / `qa` / `feature/*` / `fix/*`) +- **All required CI checks pass** (tests, lint, build, documentation generation) +- No regressions on desktop or mobile +- Documentation is updated when relevant + +**Workflow:** [`.github/workflows/ci.yml`](.github/workflows/ci.yml). See [docs/CI_DIAGNOSIS.md](docs/CI_DIAGNOSIS.md) for CI design and troubleshooting. --- -## ✅ Definition of Done (DoD) +## Documentation -An issue, task, or user story is considered **Done** when all of the following conditions are met: +### JSDoc (backend) -- Code is implemented according to acceptance criteria -- Code is committed to the correct branch following the branching strategy -- No regression is introduced on desktop or mobile -- Responsive behavior is validated (mobile / tablet / desktop) -- Application builds successfully without errors -- Feature or fix is deployed to the QA environment -- No blocking or critical bug remains open -- Documentation is updated when applicable +The backend uses **JSDoc** for code-level documentation (routes, models, middleware). -This Definition of Done applies to all work items across the Sawaka project. +**Generate:** + +```bash +cd backend-api +npm run docs:generate +``` + +**Output:** `backend-api/docs/jsdoc/`. Open `backend-api/docs/jsdoc/index.html` in a browser. + +JSDoc is also generated in CI. The `backend-api/docs` directory is gitignored. + +### API environments + +- **Production:** [https://sawaka-api-prod.onrender.com](https://sawaka-api-prod.onrender.com) +- **QA:** [https://sawaka-api-qa.onrender.com](https://sawaka-api-qa.onrender.com) + +### Project Vision + +The project vision is documented in the GitHub Wiki: [Vision Document](https://github.com/Bond52/sawaka/wiki/Vision-Document). It serves as a reference for epics, backlog prioritization, and functional decisions. + +### Diagrams + +Architecture and flow diagrams (e.g. draw.io) can be stored in `docs/diagrams` when added. --- -## 🤝 Contributing +## Contributing + +We welcome contributions. Please read: + +- [CONTRIBUTING.md](CONTRIBUTING.md) — How to contribute, branch strategy, quality standards +- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) — Community guidelines + +**Onboarding path:** + +1. Fork the repo, clone, install dependencies, set up env (see Quick Start). +2. Create a branch: `feature/my-feature` or `fix/my-fix`. +3. Make changes, run tests and lint locally. +4. Open a pull request against `main` or `qa`. Ensure CI passes. +5. Follow review feedback and update as needed. + +Additional guidelines (Definition of Ready, coding standards, PR workflow) are in the [Wiki](https://github.com/Bond52/sawaka/wiki). + +--- -All contributions are welcome! -Please read first: +## Branching -- [CONTRIBUTING.md](CONTRIBUTING.md) -- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) +| Branch | Purpose | +|--------|---------| +| `main` | Production-ready code | +| `qa` | Integration and testing | +| `feature/*` | New features | +| `fix/*` | Bug fixes | +| `hotfix/*` | Urgent production fixes | Additional guidelines are available in the Wiki: - Definition of Ready @@ -133,10 +242,7 @@ Additional guidelines are available in the Wiki: --- -## 🌳 Branching Strategy +## License and links -- `main` → production-ready code -- `qa` → integration and testing -- `feature/*` → new features -- `fix/*` → bug fixes -- `hotfix/*` → urgent production fixes +- **License:** See [LICENSE](LICENSE) +- **Security:** [SECURITY.md](SECURITY.md) diff --git a/SECURITY.md b/SECURITY.md index a7ed403..1394b90 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,7 @@ Please **do not open a public issue** for security vulnerabilities. To report a vulnerability, contact: -**security@sawaka.org** +**contact@sawaka.org** We aim to respond within **48 hours**. diff --git a/app/acheteur/commandes/page.tsx b/app/acheteur/commandes/page.tsx index b510372..a388175 100644 --- a/app/acheteur/commandes/page.tsx +++ b/app/acheteur/commandes/page.tsx @@ -1,10 +1,11 @@ 'use client'; import { useEffect, useState } from "react"; +import { useTranslation } from "@/src/i18n/I18nProvider"; type Order = { _id: string; - orderNumber?: string; // ✅ Nouveau champ optionnel + orderNumber?: string; total: number; status: string; createdAt: string; @@ -17,6 +18,7 @@ type Order = { }; export default function MesCommandesPage() { + const { t } = useTranslation(); const [orders, setOrders] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); @@ -49,16 +51,16 @@ export default function MesCommandesPage() { const data = await res.json(); setOrders(data); } catch (err: any) { - setError("Impossible de charger vos commandes."); + setError(t("orders.loadError")); } finally { setLoading(false); } }; fetchOrders(); - }, []); + }, [t]); - if (loading) return

⏳ Chargement...

; + if (loading) return

⏳ {t("common.loading")}

; if (error) return

❌ {error}

; const commandesEnCours = orders.filter( @@ -74,10 +76,9 @@ export default function MesCommandesPage() { return (

- 📦 Mes commandes + 📦 {t("orders.title")}

- {/* Onglets */}
{commandesAffichees.length === 0 ? (

- Aucune commande {activeTab === "enCours" ? "en cours" : "terminée"}. + {activeTab === "enCours" ? t("orders.noneInProgress") : t("orders.noneCompleted")}

) : (
@@ -115,7 +116,7 @@ export default function MesCommandesPage() {

- Commande #{order.orderNumber || order._id} + {t("orders.orderNumber")}{order.orderNumber || order._id}

(
  • {it.title} × {it.quantity} - {it.price} FCFA + {it.price} {t("common.fcfa")}
  • ))}
    - Total : {order.total} FCFA - Créée le {new Date(order.createdAt).toLocaleDateString()} + {t("orders.totalLabel")} {order.total} {t("common.fcfa")} + {t("orders.createdOn")} {new Date(order.createdAt).toLocaleDateString()}
    diff --git a/app/acheteur/page.tsx b/app/acheteur/page.tsx index c8a57ed..c0eee15 100644 --- a/app/acheteur/page.tsx +++ b/app/acheteur/page.tsx @@ -1,19 +1,21 @@ 'use client'; import Link from "next/link"; +import { useTranslation } from "@/src/i18n/I18nProvider"; export default function AcheteurPage() { + const { t } = useTranslation(); + return (
    -

    Bienvenue acheteur

    -

    Vous êtes maintenant connecté en tant qu'acheteur.

    +

    {t("buyer.welcome")}

    +

    {t("buyer.connected")}

    ); } - diff --git a/app/activate/page.tsx b/app/activate/page.tsx new file mode 100644 index 0000000..5798794 --- /dev/null +++ b/app/activate/page.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useTranslation } from "@/src/i18n/I18nProvider"; + +const SUPPLIER_JWT_KEY = "supplierJwt"; + +function resolveApiBaseUrl(): string { + const fromEnv = process.env.NEXT_PUBLIC_API_BASE?.trim(); + if (fromEnv) return fromEnv.replace(/\/+$/, ""); + if ( + typeof window !== "undefined" && + window.location.hostname === "localhost" + ) { + return "http://localhost:5000"; + } + return ""; +} + +function mapActivationError( + raw: string, + status: number, + t: (key: string) => string +): string { + const lower = raw.toLowerCase(); + + if ( + lower.includes("already used") || + lower.includes("déjà utilisé") || + lower.includes("already been used") + ) { + return t("activation.alreadyUsed"); + } + + if ( + lower.includes("expired") || + lower.includes("expiré") || + lower.includes("expiration") + ) { + return t("activation.expiredLink"); + } + + if ( + lower.includes("invalid magic") || + lower.includes("invalid link") || + lower.includes("not found") || + status === 404 + ) { + return t("activation.invalidLink"); + } + + if ( + lower.includes("no longer invited") || + lower.includes("could not be activated") || + status === 409 + ) { + return t("activation.invalidLink"); + } + + if (status >= 500) { + return t("activation.serverError"); + } + + if (raw.trim()) return raw.trim(); + + return t("activation.invalidLink"); +} + +function ActivateContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { t } = useTranslation(); + const token = searchParams.get("token")?.trim() ?? ""; + + const [phase, setPhase] = useState<"loading" | "success" | "error">(() => + token ? "loading" : "error" + ); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + if (!token) { + setPhase("error"); + setErrorMessage(t("activation.invalidLink")); + return; + } + + let cancelled = false; + + setPhase("loading"); + setErrorMessage(null); + + const API_URL = resolveApiBaseUrl(); + + (async () => { + try { + if (!API_URL) { + if (!cancelled) { + setPhase("error"); + setErrorMessage(t("activation.misconfiguration")); + } + return; + } + + const res = await fetch( + `${API_URL}/api/suppliers/magic-link/${encodeURIComponent(token)}`, + { + method: "GET", + credentials: "include", + } + ); + + let data: { token?: unknown; error?: unknown; message?: unknown } = {}; + try { + data = await res.json(); + } catch { + /* non-JSON */ + } + + if (cancelled) return; + + if (!res.ok) { + const errText = + (typeof data.error === "string" && data.error) || + (typeof data.message === "string" && data.message) || + ""; + setPhase("error"); + setErrorMessage(mapActivationError(errText, res.status, t)); + return; + } + + const jwt = + typeof data.token === "string" && data.token.length > 0 + ? data.token + : null; + + if (!jwt) { + setPhase("error"); + setErrorMessage(t("activation.invalidLink")); + return; + } + + try { + localStorage.setItem(SUPPLIER_JWT_KEY, jwt); + } catch { + setPhase("error"); + setErrorMessage(t("activation.sessionSaveFailed")); + return; + } + + setPhase("success"); + } catch { + if (!cancelled) { + setPhase("error"); + setErrorMessage(t("activation.networkError")); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [token, t]); + + return ( +
    +
    + {phase === "loading" && ( + <> +
    +

    + {t("activation.activating")} +

    +

    + {t("activation.pleaseWait")} +

    + + )} + + {phase === "success" && ( +
    +
    + + + +
    +

    + {t("activation.success")} +

    + +
    + )} + + {phase === "error" && errorMessage && ( +
    +
    + + + +
    +

    + {errorMessage} +

    + +
    + )} +
    +
    + ); +} + +function ActivateFallback() { + const { t } = useTranslation(); + return ( +
    +
    +
    +

    + {t("activation.activating")} +

    +
    +
    + ); +} + +export default function ActivatePage() { + return ( +
    + }> + + +
    + ); +} diff --git a/app/add-supplier/components/SupplierForm.tsx b/app/add-supplier/components/SupplierForm.tsx new file mode 100644 index 0000000..18bdf31 --- /dev/null +++ b/app/add-supplier/components/SupplierForm.tsx @@ -0,0 +1,779 @@ +"use client"; + +import { Globe, Lock } from "lucide-react"; +import { useRef, useState } from "react"; +import { useTranslation } from "@/src/i18n/I18nProvider"; + +type Category = + | "construction_materials" + | "wood_lumber" + | "metal_steel" + | "electrical_supplies" + | "plumbing_supplies" + | "paints_finishes" + | "hardware_fasteners" + | "hand_tools" + | "power_tools" + | "industrial_machinery" + | "safety_equipment" + | "textiles_fabrics" + | "leather_accessories" + | "art_craft_materials" + | "agro_raw_materials" + | "food_processing_equipment" + | "packaging_containers" + | "equipment_rental" + | "transport_logistics" + | "import_wholesale_distribution"; + +export const SUPPLIER_CATEGORY_OPTIONS = [ + { value: "construction_materials" }, + { value: "wood_lumber" }, + { value: "metal_steel" }, + { value: "electrical_supplies" }, + { value: "plumbing_supplies" }, + { value: "paints_finishes" }, + { value: "hardware_fasteners" }, + { value: "hand_tools" }, + { value: "power_tools" }, + { value: "industrial_machinery" }, + { value: "safety_equipment" }, + { value: "textiles_fabrics" }, + { value: "leather_accessories" }, + { value: "art_craft_materials" }, + { value: "agro_raw_materials" }, + { value: "food_processing_equipment" }, + { value: "packaging_containers" }, + { value: "equipment_rental" }, + { value: "transport_logistics" }, + { value: "import_wholesale_distribution" }, +] as const satisfies ReadonlyArray<{ value: Category }>; + +const ALLOWED_CATEGORY_VALUES = new Set( + SUPPLIER_CATEGORY_OPTIONS.map((o) => o.value) +); + +type FormState = { + name: string; + country: string; + region: string; + city: string; + address: string; + postalCode: string; + accountEmail: string; + publicEmail: string; + phone: string; + website: string; +}; + +const initialForm: FormState = { + name: "", + country: "", + region: "", + city: "", + address: "", + postalCode: "", + accountEmail: "", + publicEmail: "", + phone: "", + website: "", +}; + +const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function isValidEmail(value: string): boolean { + return emailRe.test(value.trim()); +} + +function isValidOptionalUrl(value: string): boolean { + const v = value.trim(); + if (!v) return true; + try { + const withProto = /^https?:\/\//i.test(v) ? v : `https://${v}`; + new URL(withProto); + return true; + } catch { + return false; + } +} + +function normalizeCategoryValues(selected: string[]): Category[] { + const asArray = Array.isArray(selected) ? selected : []; + const filtered = asArray.filter( + (v): v is Category => + typeof v === "string" && ALLOWED_CATEGORY_VALUES.has(v as Category) + ); + return Array.from(new Set(filtered)); +} + +function buildPayload( + form: FormState, + categories: string[] +): Record { + const safeCategories = normalizeCategoryValues(categories); + const payload: Record = { + name: form.name.trim(), + categories: safeCategories, + country: form.country.trim(), + accountEmail: form.accountEmail.trim(), + phone: form.phone.trim(), + }; + + const region = form.region.trim(); + const city = form.city.trim(); + const address = form.address.trim(); + const postalCode = form.postalCode.trim(); + const publicEmail = form.publicEmail.trim(); + const website = form.website.trim(); + + if (region) payload.region = region; + if (city) payload.city = city; + if (address) payload.address = address; + if (postalCode) payload.postalCode = postalCode; + if (publicEmail) payload.publicEmail = publicEmail; + if (website) payload.website = website; + + return payload; +} + +const VALIDATION_FIELD_ORDER = [ + "name", + "categories", + "country", + "region", + "city", + "address", + "postalCode", + "accountEmail", + "publicEmail", + "phone", + "website", +] as const; + +const FIELD_SCROLL_ID: Record = { + name: "supplier-name", + categories: "supplier-categories", + country: "supplier-country", + region: "supplier-region", + city: "supplier-city", + address: "supplier-address", + postalCode: "supplier-postal", + accountEmail: "supplier-account-email", + publicEmail: "supplier-public-email", + phone: "supplier-phone", + website: "supplier-website", +}; + +function scrollToFirstFieldError(errors: Record) { + if (typeof document === "undefined") return; + for (const key of VALIDATION_FIELD_ORDER) { + if (!errors[key]) continue; + const id = FIELD_SCROLL_ID[key]; + if (!id) continue; + const el = document.getElementById(id); + if (!el) continue; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + if ( + el instanceof HTMLInputElement || + el instanceof HTMLTextAreaElement || + el instanceof HTMLSelectElement || + el instanceof HTMLButtonElement + ) { + el.focus({ preventScroll: true }); + } + break; + } +} + +function parseBackendErrorsObject(raw: unknown): Record { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof v === "string" && v.trim()) { + out[k] = v.trim(); + continue; + } + if (Array.isArray(v)) { + const first = v.find((x) => typeof x === "string" && String(x).trim()); + if (first) out[k] = String(first).trim(); + continue; + } + if (v && typeof v === "object" && "message" in v) { + const m = (v as { message?: unknown }).message; + if (typeof m === "string" && m.trim()) out[k] = m.trim(); + } + } + return out; +} + +function resolveApiBaseUrl(): string { + const fromEnv = process.env.NEXT_PUBLIC_API_BASE?.trim(); + if (fromEnv) return fromEnv.replace(/\/+$/, ""); + + if (typeof window !== "undefined" && window.location.hostname === "localhost") { + return "http://localhost:5000"; + } + + return "https://ecommerce-web-avec-tailwind.onrender.com"; +} + +export default function SupplierForm() { + const { t } = useTranslation(); + const API_URL = resolveApiBaseUrl(); + + const [form, setForm] = useState(initialForm); + const [categories, setCategories] = useState([]); + const [fieldErrors, setFieldErrors] = useState>({}); + const [serverMessage, setServerMessage] = useState(null); + const [success, setSuccess] = useState(false); + const [submitting, setSubmitting] = useState(false); + const submitLockRef = useRef(false); + + function validateForm( + values: FormState, + selectedCategories: Category[] + ): Record { + const errors: Record = {}; + const name = values.name.trim(); + const country = values.country.trim(); + const accountEmail = values.accountEmail.trim(); + const phone = values.phone.trim(); + const publicEmail = values.publicEmail.trim(); + const website = values.website.trim(); + const safeCategories = normalizeCategoryValues(selectedCategories); + + if (!name) { + errors.name = t("suppliers.validation.nameRequired"); + } else if (name.length < 2) { + errors.name = t("suppliers.validation.nameMin"); + } + + if (safeCategories.length === 0) { + errors.categories = t("suppliers.validation.categoriesRequired"); + } + + if (!country) { + errors.country = t("suppliers.validation.countryRequired"); + } + + if (!accountEmail) { + errors.accountEmail = t("suppliers.validation.accountEmailRequired"); + } else if (!isValidEmail(accountEmail)) { + errors.accountEmail = t("suppliers.validation.accountEmailInvalid"); + } + + if (publicEmail && !isValidEmail(publicEmail)) { + errors.publicEmail = t("suppliers.validation.publicEmailInvalid"); + } + + if (!phone) { + errors.phone = t("suppliers.validation.phoneRequired"); + } else if (phone.length < 6) { + errors.phone = t("suppliers.validation.phoneMin"); + } + + if (!isValidOptionalUrl(website)) { + errors.website = t("suppliers.validation.websiteInvalid"); + } + + return errors; + } + + const clearFieldError = (key: string) => { + setFieldErrors((prev) => { + if (!(key in prev)) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + }; + + const update = + (key: keyof FormState) => + (e: React.ChangeEvent) => { + setForm((prev) => ({ ...prev, [key]: e.target.value })); + clearFieldError(key); + setServerMessage(null); + setSuccess(false); + }; + + const toggleCategory = (value: Category) => { + if (!ALLOWED_CATEGORY_VALUES.has(value)) return; + setCategories((prev) => { + const safe = normalizeCategoryValues(prev); + if (safe.includes(value)) { + return safe.filter((v) => v !== value); + } + return Array.from(new Set([...safe, value])); + }); + clearFieldError("categories"); + setServerMessage(null); + setSuccess(false); + }; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (submitting || submitLockRef.current) return; + + setServerMessage(null); + setSuccess(false); + + const errors = validateForm(form, categories); + if (Object.keys(errors).length > 0) { + setFieldErrors(errors); + queueMicrotask(() => scrollToFirstFieldError(errors)); + return; + } + + setFieldErrors({}); + setSubmitting(true); + submitLockRef.current = true; + + try { + const res = await fetch(`${API_URL}/api/suppliers`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(buildPayload(form, categories)), + }); + + let data: { + message?: unknown; + error?: unknown; + errors?: unknown; + } = {}; + try { + data = await res.json(); + } catch { + /* corps absent ou non JSON */ + } + + if (res.ok) { + setSuccess(true); + setForm(initialForm); + setCategories([]); + setServerMessage(null); + return; + } + + const mapped = parseBackendErrorsObject(data.errors); + const errStr = + typeof data.error === "string" && data.error.trim() + ? data.error.trim() + : ""; + const msgStr = + typeof data.message === "string" && data.message.trim() + ? data.message.trim() + : ""; + + if (Object.keys(mapped).length > 0) { + setFieldErrors(mapped); + setServerMessage( + errStr || msgStr || t("suppliers.validation.fixFields") + ); + queueMicrotask(() => scrollToFirstFieldError(mapped)); + } else { + setServerMessage( + errStr || + msgStr || + (res.status >= 500 + ? t("suppliers.validation.serverError") + : res.status === 400 + ? t("suppliers.validation.invalidPayload") + : t("suppliers.validation.createFailed")) + ); + } + } catch { + setServerMessage(t("suppliers.validation.networkError")); + } finally { + setSubmitting(false); + submitLockRef.current = false; + } + } + + const labelClass = "block text-sm font-semibold text-slate-700 mb-2"; + const inputClass = + "w-full rounded-lg border border-slate-200 bg-white px-3.5 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 shadow-sm transition-shadow focus:border-sawaka-500 focus:outline-none focus:ring-2 focus:ring-sawaka-500/25"; + + return ( +
    +
    + {success && ( +
    + {t("suppliers.success")} +
    + )} + + {serverMessage && ( +
    + {serverMessage} +
    + )} + +
    +

    + {t("suppliers.formTitle")} +

    +
    + + EN | FR +
    +
    + +
    + + {/* General Information */} +
    +

    + {t("suppliers.sectionGeneral")} +

    + +
    + + + {fieldErrors.name && ( +

    + {fieldErrors.name} +

    + )} +
    + +
    + + {t("suppliers.categorySingle")}{" "} + * + +
    + {SUPPLIER_CATEGORY_OPTIONS.map((opt) => { + const selected = categories.includes(opt.value); + return ( + + ); + })} +
    + {fieldErrors.categories && ( +

    + {fieldErrors.categories} +

    + )} +
    +
    + +
    + + {/* Location */} +
    +

    {t("suppliers.sectionLocation")}

    + +
    +
    + + + {fieldErrors.country && ( +

    + {fieldErrors.country} +

    + )} +
    + +
    + + +
    + +
    + + +
    +
    + +
    +
    + +