Repository: TelesCoop/iarbre
Pull request #96: add source code link + feedback pop-in
Author: login: ludovicdmt
Created: 2025-03-04T08:50:31Z | Merged: 2025-03-14T08:08:52Z

En utilisant ce [wireframe](https://www.notion.so/erasme/Wireframes-interface-254c6f02e40c401994cd44b6875b2317?pvs=4#1ab44e49a3ad8066b4acc32d6a296be4) : 
- [x] Ajout d'un lien code source en bas à droite
- [x] Popin pour envoyer son feedback en base depuis la navbar


Diff:
diff --git a/back/api/urls.py b/back/api/urls.py
index 7e8c35a0..18443200 100644
--- a/back/api/urls.py
+++ b/back/api/urls.py
@@ -7,4 +7,5 @@
         "tiles/<str:geolevel>/<str:datatype>/<int:zoom>/<int:x>/<int:y>.mvt",
         views.tile_view,
     ),
+    path("feedback/", views.receive_feedback),
 ]
diff --git a/back/api/views.py b/back/api/views.py
index f8782944..0d5f399a 100644
--- a/back/api/views.py
+++ b/back/api/views.py
@@ -1,10 +1,15 @@
 import time
-
+import json
 from django.http import HttpResponse
 from django.views.decorators.cache import cache_page
-from django.views.decorators.http import require_GET
+from django.views.decorators.http import require_GET, require_POST
+from django.views.decorators.csrf import csrf_exempt
 
 from api.map import load_tiles
+from typing import Any, Dict
+
+from django.http import JsonResponse
+from iarbre_data.models import Feedback
 
 
 @require_GET
@@ -36,3 +41,38 @@ def tile_view(
     response = load_tiles(geolevel, datatype, x, y, zoom)
     print(f"Request duration: {time.time() - start_time} seconds")
     return response
+
+
+@csrf_exempt  # Mandatory but not really satisfactory
+@require_POST
+def receive_feedback(request) -> JsonResponse:
+    """
+    Handles feedback submission.
+
+    This view accepts POST requests with JSON data containing feedback.
+    The feedback is stored in the database, and a response is returned indicating
+    the success or failure of the operation.
+
+    Request JSON format:
+    {
+        "email": "user@example.com",  # Optional
+        "feedback": "User feedback text"
+    }
+    Returns:
+        JsonResponse: A JSON response indicating the result of the feedback submission.
+            - On success: {"message": "Feedback saved!", "id": <feedback_id>}
+            - On failure: {"error": <error_message>}
+    """
+    try:
+        data: Dict[str, Any] = json.loads(request.body)
+        if not data.get("feedback"):  # Email is not mandatory
+            return JsonResponse({"error": "Feedback is required."}, status=400)
+        feedback = Feedback.objects.create(
+            email=data.get("email"),
+            feedback=data.get("feedback"),
+        )
+        return JsonResponse(
+            {"message": "Feedback saved!", "id": feedback.id}, status=201
+        )
+    except Exception as e:
+        return JsonResponse({"error": str(e)}, status=400)
diff --git a/back/iarbre_data/migrations/0014_feedback.py b/back/iarbre_data/migrations/0014_feedback.py
new file mode 100644
index 00000000..dd9876b7
--- /dev/null
+++ b/back/iarbre_data/migrations/0014_feedback.py
@@ -0,0 +1,30 @@
+# Generated by Django 5.1.5 on 2025-03-04 15:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("iarbre_data", "0013_rename_indice_tile_plantability_indice_and_more"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="Feedback",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("email", models.EmailField(blank=True, max_length=254, null=True)),
+                ("feedback", models.TextField()),
+                ("created_at", models.DateTimeField(auto_now_add=True)),
+            ],
+        ),
+    ]
diff --git a/back/iarbre_data/migrations/0019_merge_20250310_1025.py b/back/iarbre_data/migrations/0019_merge_20250310_1025.py
new file mode 100644
index 00000000..2605256c
--- /dev/null
+++ b/back/iarbre_data/migrations/0019_merge_20250310_1025.py
@@ -0,0 +1,13 @@
+# Generated by Django 5.1.5 on 2025-03-10 10:25
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("iarbre_data", "0014_feedback"),
+        ("iarbre_data", "0018_alter_mvttile_datatype_alter_mvttile_geolevel"),
+    ]
+
+    operations = []
diff --git a/back/iarbre_data/models.py b/back/iarbre_data/models.py
index c54594ee..149d1990 100644
--- a/back/iarbre_data/models.py
+++ b/back/iarbre_data/models.py
@@ -197,3 +197,14 @@ def before_save_lcz(sender, instance, **kwargs):
     """Transform the geometry to the map geometry."""
     if instance.map_geometry is None:
         instance.map_geometry = instance.geometry.transform(3857, clone=True)
+
+
+class Feedback(models.Model):
+    """Store feedbacks from carte.iarbre.fr"""
+
+    email = models.EmailField(blank=True, null=True)
+    feedback = models.TextField()
+    created_at = models.DateTimeField(auto_now_add=True)
+
+    def __str__(self):
+        return f"Feedback from {self.email or 'Anonymous'}"
diff --git a/back/iarbre_data/tests/test_feedback.py b/back/iarbre_data/tests/test_feedback.py
new file mode 100644
index 00000000..8eb682b1
--- /dev/null
+++ b/back/iarbre_data/tests/test_feedback.py
@@ -0,0 +1,64 @@
+import json
+from django.test import TestCase, Client
+from iarbre_data.models import Feedback
+
+
+class ReceiveFeedbackViewTest(TestCase):
+    def setUp(self):
+        self.client = Client()
+        self.url = "/api/feedback/"
+
+    def test_valid_feedback_submission(self):
+        data = {
+            "email": "molly.maguires@test.fr",
+            "feedback": "Raise the floor not the ceiling.",
+        }
+        response = self.client.post(
+            self.url, json.dumps(data), content_type="application/json"
+        )
+
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(response.json()["message"], "Feedback saved!")
+        self.assertTrue(
+            Feedback.objects.filter(
+                feedback="Raise the floor not the ceiling."
+            ).exists()
+        )
+        self.assertTrue(
+            Feedback.objects.filter(email="molly.maguires@test.fr").exists()
+        )
+
+    def test_missing_feedback_field(self):
+        data = {"email": "molly.maguires@test.fr"}  # No feedback text
+        response = self.client.post(
+            self.url, json.dumps(data), content_type="application/json"
+        )
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json()["error"], "Feedback is required.")
+
+    def test_missing_email_field(self):
+        data = {
+            "feedback": "Raise the floor not the ceiling."
+        }  # Email is not mandatory
+        response = self.client.post(
+            self.url, json.dumps(data), content_type="application/json"
+        )
+
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(response.json()["message"], "Feedback saved!")
+        self.assertTrue(
+            Feedback.objects.filter(
+                feedback="Raise the floor not the ceiling."
+            ).exists()
+        )
+
+    def test_invalid_json_format(self):
+        response = self.client.post(
+            self.url, "{invalid_json}", content_type="application/json"
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json()["error"],
+            "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)",
+        )
diff --git a/front/cypress/components/FeedbackPopin.cy.ts b/front/cypress/components/FeedbackPopin.cy.ts
new file mode 100644
index 00000000..5e79ecc1
--- /dev/null
+++ b/front/cypress/components/FeedbackPopin.cy.ts
@@ -0,0 +1,44 @@
+import FeedbackPopin from "@/components/FeedbackPopin.vue"
+
+describe("Component: FeedbackPopin", () => {
+  it("renders correctly", () => {
+    cy.mount(FeedbackPopin)
+    cy.getBySel("submit-feedback-button").should("exist")
+    cy.getBySel("close-feedback-button").should("exist")
+  })
+
+  it("correctly send feedback", () => {
+    cy.mount(FeedbackPopin, {
+      props: {
+        allowedExtensions: []
+      },
+      emits: {
+        "submit-feedback": cy.spy().as("submit-feedback")
+      }
+    })
+    const testEmail = "molly.maguire@test.fr"
+    const testFeedback = "Raise the floor, not just the ceiling."
+
+    cy.getBySel("submit-feedback-button").click()
+
+    // Should still be on the same page
+    cy.get("@submit-feedback").should("not.have.been.called")
+
+    // Only email, missing feedback
+    cy.get('input[type="email"]').type(testEmail)
+    cy.getBySel("submit-feedback-button").click()
+    cy.get("@submit-feedback").should("not.have.been.called")
+
+    // Should be able to submit without email
+    // cy.get('input[type="email"]').clear()
+    cy.get("textarea").type(testFeedback)
+    cy.getBySel("submit-feedback-button").click()
+    cy.get("@submit-feedback").should("have.been.called")
+
+    // Check if the event was passed with the correct values
+    cy.get("@submit-feedback").should("have.been.calledWith", {
+      email: testEmail,
+      feedback: testFeedback
+    })
+  })
+})
diff --git a/front/cypress/components/NavbarComponent.cy.ts b/front/cypress/components/NavbarComponent.cy.ts
new file mode 100644
index 00000000..cb2c3780
--- /dev/null
+++ b/front/cypress/components/NavbarComponent.cy.ts
@@ -0,0 +1,30 @@
+import Navbar from "@/components/NavbarComponent.vue"
+
+describe("Component:Navbar", () => {
+  beforeEach(() => {
+    cy.mount(Navbar)
+  })
+  it("renders correctly", () => {
+    cy.getBySel("open-feedback-button").should("exist")
+    cy.getBySel("open-savoir-href").should("exist")
+  })
+  it("Fill and submit the feedback form", () => {
+    cy.getBySel("open-feedback-button").click()
+
+    const testEmail = "molly.maguire@test.fr"
+    const testFeedback = "Raise the floor, not just the ceiling."
+
+    cy.intercept("POST", "**/feedback/", {
+      statusCode: 200,
+      body: { message: "Merci pour votre retour !" }
+    }).as("submitFeedback")
+
+    cy.get('input[type="email"]').type(testEmail)
+    cy.get("textarea").type(testFeedback)
+    cy.getBySel("submit-feedback-button").click()
+
+    cy.wait("@submitFeedback").its("response.statusCode").should("eq", 200)
+
+    cy.getBySel("feedback-popin").should("not.exist")
+  })
+})
diff --git a/front/cypress/e2e/navigation.cy.ts b/front/cypress/e2e/navigation.cy.ts
index 1a0ffce6..1f329e1e 100644
--- a/front/cypress/e2e/navigation.cy.ts
+++ b/front/cypress/e2e/navigation.cy.ts
@@ -4,5 +4,7 @@ describe("Navigation", () => {
   it("loads map on root url", () => {
     cy.visit("/")
     cy.getBySel("map-component").should("exist")
+    cy.getBySel("navbar").should("exist")
+    cy.getBySel("feedback-popin").should("not.exist")
   })
 })
diff --git a/front/cypress/e2e/popininteractivity.cy.ts b/front/cypress/e2e/popininteractivity.cy.ts
new file mode 100644
index 00000000..187a5eec
--- /dev/null
+++ b/front/cypress/e2e/popininteractivity.cy.ts
@@ -0,0 +1,22 @@
+// https://on.cypress.io/api
+
+describe("Feedback Popin interactivity", () => {
+  beforeEach(() => {
+    cy.visit("/")
+  })
+
+  it("Open Popin on click", () => {
+    cy.getBySel("open-feedback-button").click()
+    cy.getBySel("feedback-popin").should("exist")
+  })
+
+  it("Open, close and reopen feedback popin", () => {
+    cy.getBySel("open-feedback-button").click()
+
+    cy.getBySel("close-feedback-button").click()
+    cy.getBySel("feedback-popin").should("not.exist")
+
+    cy.getBySel("open-feedback-button").click()
+    cy.getBySel("feedback-popin").should("exist")
+  })
+})
diff --git a/front/package-lock.json b/front/package-lock.json
index 942fa34b..0ca68f58 100644
--- a/front/package-lock.json
+++ b/front/package-lock.json
@@ -39,7 +39,7 @@
         "postcss-for": "^2.1.1",
         "prettier": "^3.4.2",
         "start-server-and-test": "^2.0.4",
-        "tailwindcss": "^4.0.11",
+        "tailwindcss": "^4.0.12",
         "typescript": "5.6.3",
         "vite": "^5.3.1",
         "vite-plugin-vue-devtools": "^7.3.1",
@@ -1418,6 +1418,11 @@
         "tailwindcss": "4.0.11"
       }
     },
+    "node_modules/@tailwindcss/node/node_modules/tailwindcss": {
+      "version": "4.0.11",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.11.tgz",
+      "integrity": "sha512-GZ6+tNwieqvpFLZfx2tkZpfOMAK7iumbOJOLmd6v8AcYuHbjUb+cmDRu6l+rFkIqarh5FfLbCSRJhegcVdoPng=="
+    },
     "node_modules/@tailwindcss/oxide": {
       "version": "4.0.11",
       "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.11.tgz",
@@ -1630,6 +1635,11 @@
         "tailwindcss": "4.0.11"
       }
     },
+    "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": {
+      "version": "4.0.11",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.11.tgz",
+      "integrity": "sha512-GZ6+tNwieqvpFLZfx2tkZpfOMAK7iumbOJOLmd6v8AcYuHbjUb+cmDRu6l+rFkIqarh5FfLbCSRJhegcVdoPng=="
+    },
     "node_modules/@tsconfig/node20": {
       "version": "20.1.4",
       "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz",
@@ -2896,18 +2906,27 @@
         "node": ">=6"
       }
     },
-    "node_modules/call-bind": {
-      "version": "1.0.7",
-      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
-      "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
-        "es-define-property": "^1.0.0",
         "es-errors": "^1.3.0",
-        "function-bind": "^1.1.2",
-        "get-intrinsic": "^1.2.4",
-        "set-function-length": "^1.2.1"
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "dev": true,
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
       },
       "engines": {
         "node": ">= 0.4"
@@ -3482,24 +3501,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/define-data-property": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
-      "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "es-define-property": "^1.0.0",
-        "es-errors": "^1.3.0",
-        "gopd": "^1.0.1"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
     "node_modules/define-lazy-prop": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
@@ -3558,6 +3559,20 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "dev": true,
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/duplexer": {
       "version": "0.1.2",
       "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
@@ -3707,14 +3722,10 @@
       }
     },
     "node_modules/es-define-property": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
-      "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
       "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "get-intrinsic": "^1.2.4"
-      },
       "engines": {
         "node": ">= 0.4"
       }
@@ -3735,6 +3746,18 @@
       "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
       "dev": true
     },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "dev": true,
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/esbuild": {
       "version": "0.21.5",
       "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -4651,17 +4674,21 @@
       "license": "ISC"
     },
     "node_modules/get-intrinsic": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
-      "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
         "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
         "function-bind": "^1.1.2",
-        "has-proto": "^1.0.1",
-        "has-symbols": "^1.0.3",
-        "hasown": "^2.0.0"
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
       },
       "engines": {
         "node": ">= 0.4"
@@ -4670,6 +4697,19 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "dev": true,
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/get-stream": {
       "version": "5.2.0",
       "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@@ -4842,13 +4882,12 @@
       }
     },
     "node_modules/gopd": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
-      "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
       "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "get-intrinsic": "^1.1.3"
+      "engines": {
+        "node": ">= 0.4"
       },
       "funding": {
         "url": "https://github.com/sponsors/ljharb"
@@ -4900,38 +4939,11 @@
         "node": ">=8"
       }
     },
-    "node_modules/has-property-descriptors": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
-      "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "es-define-property": "^1.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/has-proto": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
-      "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
     "node_modules/has-symbols": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
-      "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
       "dev": true,
-      "license": "MIT",
       "engines": {
         "node": ">= 0.4"
       },
@@ -6100,6 +6112,15 @@
         "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
       }
     },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/memorystream": {
       "version": "0.3.1",
       "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
@@ -6374,11 +6395,10 @@
       "license": "MIT"
     },
     "node_modules/object-inspect": {
-      "version": "1.13.2",
-      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
-      "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==",
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
       "dev": true,
-      "license": "MIT",
       "engines": {
         "node": ">= 0.4"
       },
@@ -7515,24 +7535,6 @@
         "node": ">=10"
       }
     },
-    "node_modules/set-function-length": {
-      "version": "1.2.2",
-      "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
-      "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "define-data-property": "^1.1.4",
-        "es-errors": "^1.3.0",
-        "function-bind": "^1.1.2",
-        "get-intrinsic": "^1.2.4",
-        "gopd": "^1.0.1",
-        "has-property-descriptors": "^1.0.2"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
     "node_modules/shebang-command": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -7567,16 +7569,69 @@
       }
     },
     "node_modules/side-channel": {
-      "version": "1.0.6",
-      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz",
-      "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==",
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
-        "call-bind": "^1.0.7",
         "es-errors": "^1.3.0",
-        "get-intrinsic": "^1.2.4",
-        "object-inspect": "^1.13.1"
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+      "dev": true,
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "dev": true,
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "dev": true,
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
       },
       "engines": {
         "node": ">= 0.4"
@@ -7947,10 +8002,10 @@
       }
     },
     "node_modules/tailwindcss": {
-      "version": "4.0.11",
-      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.11.tgz",
-      "integrity": "sha512-GZ6+tNwieqvpFLZfx2tkZpfOMAK7iumbOJOLmd6v8AcYuHbjUb+cmDRu6l+rFkIqarh5FfLbCSRJhegcVdoPng==",
-      "license": "MIT"
+      "version": "4.0.12",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz",
+      "integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==",
+      "dev": true
     },
     "node_modules/tapable": {
       "version": "2.2.1",
@@ -9886,6 +9941,13 @@
         "enhanced-resolve": "^5.18.1",
         "jiti": "^2.4.2",
         "tailwindcss": "4.0.11"
+      },
+      "dependencies": {
+        "tailwindcss": {
+          "version": "4.0.11",
+          "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.11.tgz",
+          "integrity": "sha512-GZ6+tNwieqvpFLZfx2tkZpfOMAK7iumbOJOLmd6v8AcYuHbjUb+cmDRu6l+rFkIqarh5FfLbCSRJhegcVdoPng=="
+        }
       }
     },
     "@tailwindcss/oxide": {
@@ -9983,6 +10045,13 @@
         "lightningcss": "^1.29.1",
         "postcss": "^8.4.41",
         "tailwindcss": "4.0.11"
+      },
+      "dependencies": {
+        "tailwindcss": {
+          "version": "4.0.11",
+          "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.11.tgz",
+          "integrity": "sha512-GZ6+tNwieqvpFLZfx2tkZpfOMAK7iumbOJOLmd6v8AcYuHbjUb+cmDRu6l+rFkIqarh5FfLbCSRJhegcVdoPng=="
+        }
       }
     },
     "@tsconfig/node20": {
@@ -10876,17 +10945,24 @@
       "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==",
       "dev": true
     },
-    "call-bind": {
-      "version": "1.0.7",
-      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
-      "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
+    "call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
       "dev": true,
       "requires": {
-        "es-define-property": "^1.0.0",
         "es-errors": "^1.3.0",
-        "function-bind": "^1.1.2",
-        "get-intrinsic": "^1.2.4",
-        "set-function-length": "^1.2.1"
+        "function-bind": "^1.1.2"
+      }
+    },
+    "call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "dev": true,
+      "requires": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
       }
     },
     "callsites": {
@@ -11275,17 +11351,6 @@
       "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==",
       "dev": true
     },
-    "define-data-property": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
-      "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
-      "dev": true,
-      "requires": {
-        "es-define-property": "^1.0.0",
-        "es-errors": "^1.3.0",
-        "gopd": "^1.0.1"
-      }
-    },
     "define-lazy-prop": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
@@ -11321,6 +11386,17 @@
         "esutils": "^2.0.2"
       }
     },
+    "dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "dev": true,
+      "requires": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      }
+    },
     "duplexer": {
       "version": "0.1.2",
       "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
@@ -11429,13 +11505,10 @@
       "dev": true
     },
     "es-define-property": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
-      "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
-      "dev": true,
-      "requires": {
-        "get-intrinsic": "^1.2.4"
-      }
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "dev": true
     },
     "es-errors": {
       "version": "1.3.0",
@@ -11449,6 +11522,15 @@
       "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
       "dev": true
     },
+    "es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "dev": true,
+      "requires": {
+        "es-errors": "^1.3.0"
+      }
+    },
     "esbuild": {
       "version": "0.21.5",
       "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -12052,16 +12134,31 @@
       "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A=="
     },
     "get-intrinsic": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
-      "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
       "dev": true,
       "requires": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
         "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
         "function-bind": "^1.1.2",
-        "has-proto": "^1.0.1",
-        "has-symbols": "^1.0.3",
-        "hasown": "^2.0.0"
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      }
+    },
+    "get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "dev": true,
+      "requires": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
       }
     },
     "get-stream": {
@@ -12182,13 +12279,10 @@
       }
     },
     "gopd": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
-      "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
-      "dev": true,
-      "requires": {
-        "get-intrinsic": "^1.1.3"
-      }
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "dev": true
     },
     "graceful-fs": {
       "version": "4.2.11",
@@ -12224,25 +12318,10 @@
       "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
       "dev": true
     },
-    "has-property-descriptors": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
-      "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
-      "dev": true,
-      "requires": {
-        "es-define-property": "^1.0.0"
-      }
-    },
-    "has-proto": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
-      "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
-      "dev": true
-    },
     "has-symbols": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
-      "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
       "dev": true
     },
     "hasown": {
@@ -12950,6 +13029,12 @@
         "vt-pbf": "^3.1.3"
       }
     },
+    "math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "dev": true
+    },
     "memorystream": {
       "version": "0.3.1",
       "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
@@ -13127,9 +13212,9 @@
       "dev": true
     },
     "object-inspect": {
-      "version": "1.13.2",
-      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
-      "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==",
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
       "dev": true
     },
     "once": {
@@ -13875,20 +13960,6 @@
       "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
       "dev": true
     },
-    "set-function-length": {
-      "version": "1.2.2",
-      "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
-      "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
-      "dev": true,
-      "requires": {
-        "define-data-property": "^1.1.4",
-        "es-errors": "^1.3.0",
-        "function-bind": "^1.1.2",
-        "get-intrinsic": "^1.2.4",
-        "gopd": "^1.0.1",
-        "has-property-descriptors": "^1.0.2"
-      }
-    },
     "shebang-command": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -13911,15 +13982,51 @@
       "dev": true
     },
     "side-channel": {
-      "version": "1.0.6",
-      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz",
-      "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==",
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "dev": true,
+      "requires": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      }
+    },
+    "side-channel-list": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+      "dev": true,
+      "requires": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3"
+      }
+    },
+    "side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
       "dev": true,
       "requires": {
-        "call-bind": "^1.0.7",
+        "call-bound": "^1.0.2",
         "es-errors": "^1.3.0",
-        "get-intrinsic": "^1.2.4",
-        "object-inspect": "^1.13.1"
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      }
+    },
+    "side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "dev": true,
+      "requires": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
       }
     },
     "siginfo": {
@@ -14172,9 +14279,10 @@
       }
     },
     "tailwindcss": {
-      "version": "4.0.11",
-      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.11.tgz",
-      "integrity": "sha512-GZ6+tNwieqvpFLZfx2tkZpfOMAK7iumbOJOLmd6v8AcYuHbjUb+cmDRu6l+rFkIqarh5FfLbCSRJhegcVdoPng=="
+      "version": "4.0.12",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz",
+      "integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==",
+      "dev": true
     },
     "tapable": {
       "version": "2.2.1",
diff --git a/front/package.json b/front/package.json
index eeac2832..e173c1ee 100644
--- a/front/package.json
+++ b/front/package.json
@@ -48,11 +48,11 @@
     "postcss-for": "^2.1.1",
     "prettier": "^3.4.2",
     "start-server-and-test": "^2.0.4",
-    "tailwindcss": "^4.0.11",
+    "tailwindcss": "^4.0.12",
     "typescript": "5.6.3",
     "vite": "^5.3.1",
     "vite-plugin-vue-devtools": "^7.3.1",
     "vitest": "^3.0.5",
     "vue-tsc": "^2.0.21"
   }
-}
\ No newline at end of file
+}
diff --git a/front/src/components/FeedbackPopin.vue b/front/src/components/FeedbackPopin.vue
new file mode 100644
index 00000000..9b495f0a
--- /dev/null
+++ b/front/src/components/FeedbackPopin.vue
@@ -0,0 +1,43 @@
+<script setup lang="ts">
+import { ref, defineEmits } from "vue"
+
+const email = ref("")
+const feedback = ref("")
+const message = ref("")
+
+const emit = defineEmits(["submit-feedback", "close"])
+
+const sendFeedback = (event: Event) => {
+  event.preventDefault()
+
+  if (feedback.value) {
+    emit("submit-feedback", { email: email.value, feedback: feedback.value })
+    message.value = "Merci pour votre retour !"
+    email.value = ""
+    feedback.value = ""
+    setTimeout(() => {
+      emit("close")
+    }, 1500)
+  } else {
+    message.value = "Veuillez remplir un avis."
+  }
+}
+</script>
+
+<template>
+  <div class="popin" data-cy="feedback-popin">
+    <button class="popin-close-button" data-cy="close-feedback-button" @click="emit('close')">
+      x
+    </button>
+    <h3 class="popin-heading">Votre avis compte !</h3>
+    <p class="popin-text">Partagez-nous vos impressions pour nous aider à améliorer le site :</p>
+    <form class="popin-form" @submit="sendFeedback">
+      <input v-model="email" type="email" placeholder="Votre email" class="feedback-input" />
+      <textarea v-model="feedback" placeholder="Votre message" class="feedback-textarea"></textarea>
+      <button class="feedback-submit-button" type="submit" data-cy="submit-feedback-button">
+        J'envoie mon avis
+      </button>
+      <p v-if="message" class="message">{{ message }}</p>
+    </form>
+  </div>
+</template>
diff --git a/front/src/components/NavbarComponent.vue b/front/src/components/NavbarComponent.vue
new file mode 100644
index 00000000..f1d3f6a4
--- /dev/null
+++ b/front/src/components/NavbarComponent.vue
@@ -0,0 +1,46 @@
+<script setup lang="ts">
+import { ref } from "vue"
+import { FULL_BASE_API_URL } from "@/utils/constants"
+import FeedbackPopin from "@/components/FeedbackPopin.vue"
+
+const isVisible = ref(false)
+
+const sendFeedbackToAPI = async (data: { email: string; feedback: string }) => {
+  try {
+    const response = await fetch(`${FULL_BASE_API_URL}/feedback/`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ feedback: data.feedback, email: data.email })
+    })
+    if (!response.ok) {
+      throw new Error("Erreur lors de l'envoi du feedback")
+    }
+    isVisible.value = false
+  } catch (error) {
+    console.error("Erreur lors de l'envoi du feedback:", error)
+  }
+}
+</script>
+
+<template>
+  <div class="header">
+    <div class="header-logo">
+      <a href="/">
+        <img class="h-10 w-auto" src="/images/logo-iarbre.png" alt="Logo I-Arbre" />
+      </a>
+    </div>
+    <nav class="header-nav">
+      <ul class="nav-list">
+        <li>
+          <button class="button" data-cy="open-feedback-button" @click.prevent="isVisible = true">
+            ✉️ Nous envoyer votre retour
+          </button>
+        </li>
+        <li>
+          <a href="https://iarbre.fr" class="link" data-cy="open-savoir-href"> ⓘ En savoir plus </a>
+        </li>
+      </ul>
+    </nav>
+  </div>
+  <FeedbackPopin v-if="isVisible" @submit-feedback="sendFeedbackToAPI" @close="isVisible = false" />
+</template>
diff --git a/front/src/layouts/DefaultLayout.vue b/front/src/layouts/DefaultLayout.vue
index ee6edfe0..e9e70c9d 100644
--- a/front/src/layouts/DefaultLayout.vue
+++ b/front/src/layouts/DefaultLayout.vue
@@ -1,26 +1,14 @@
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import Navbar from "@/components/NavbarComponent.vue"
+</script>
 
 <template>
   <div class="flex flex-col min-h-screen">
     <header
       class="navbar fixed w-full top-0 z-10 flex items-center justify-between px-10 py-2.5 bg-[var(--color-off-white)]"
+      data-cy="navbar"
     >
-      <div>
-        <a href="/">
-          <img class="h-10 w-auto" src="/images/logo-iarbre.png" alt="Logo I-Arbre" />
-        </a>
-      </div>
-      <nav>
-        <ul class="flex">
-          <li>
-            <a
-              href="https://iarbre.fr"
-              class="font-[var(--font-accent)] text-[var(--color-brown)] no-underline hover:text-[var(--color-light-green)] transition-colors duration-300"
-              >ⓘ En savoir plus</a
-            >
-          </li>
-        </ul>
-      </nav>
+      <Navbar />
     </header>
     <main class="flex-1 mt-20">
       <slot></slot>
diff --git a/front/src/styles/main.css b/front/src/styles/main.css
index 84ec3f49..6a28bd5a 100644
--- a/front/src/styles/main.css
+++ b/front/src/styles/main.css
@@ -21,6 +21,7 @@
   --color-light-green: #92a48d;
   --color-brown: #32312d;
   --color-white: #ffffff;
+  --color-red: red;
 
   /* Scale colors */
   --color-scale-0: #dfdfdf;
@@ -54,27 +55,63 @@ body {
 }
 
 /* Custom utility classes */
-.max-full-height {
-  max-height: var(--content-height);
+
+.button {
+  @apply font-[var(--font-accent)] text-[var(--color-brown)] text-base bg-[var(--color-off-white)] border-none outline-none transition duration-300 hover:text-[var(--color-light-green)] cursor-pointer;
 }
 
-.font-accent {
-  font-family: var(--font-accent);
+.header {
+  @apply fixed top-0 w-full bg-[var(--color-off-white)] h-[var(--header-height)] z-10 flex items-center justify-between overflow-hidden box-border;
 }
 
-/* Button styles */
-.button {
-  @apply font-[var(--font-accent)] no-underline border-none py-2.5 px-5 rounded-[50px];
+.header-logo {
+  @apply pl-4 md:pl-0;
+}
+
+.header-nav {
+  @apply pr-4 md:pr-20;
+}
+
+.nav-list {
+  @apply flex gap-4 list-none;
+}
+
+.link {
+  @apply font-[var(--font-accent)] text-[var(--color-brown)] text-base no-underline transition duration-300 hover:text-[var(--color-light-green)];
+}
+
+.popin {
+  @apply fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 max-w-sm mx-auto p-5 border border-gray-300 rounded-lg bg-[var(--color-white)] text-[var(--color-dark-green)] text-left flex flex-col text-sm box-border;
+}
+
+.popin-close-button {
+  @apply absolute top-2 right-2 text-lg text-[var(--color-dark-green)] cursor-pointer transition-colors duration-300 hover:text-[var(--color-red)];
+}
+
+.popin-heading {
+  @apply font-medium;
+}
+
+.popin-text {
+  @apply mb-2;
+}
+
+.popin-form {
+  @apply w-full;
+}
+
+.feedback-input {
+  @apply w-full mt-2 p-2 rounded-full border border-gray-300 outline-none;
 }
 
-.button.primary {
-  @apply text-[var(--color-white)] bg-[var(--color-dark-green)];
+.feedback-textarea {
+  @apply w-full h-36 mt-2 p-2 rounded-3xl border border-gray-300 resize-none outline-none;
 }
 
-.button.small {
-  @apply py-[7px] px-[10px] text-[0.8rem];
+.feedback-submit-button {
+  @apply w-full mt-2 bg-[var(--color-light-green)] text-[var(--color-white)] font-bold cursor-pointer transition-colors duration-300 hover:text-[var(--color-white)] text-center rounded-full py-2;
 }
 
-.button.text {
-  @apply bg-transparent rounded-none p-[3px];
+.message {
+  @apply mt-2 text-[var(--color-dark-green)];
 }
diff --git a/front/src/views/MapView.vue b/front/src/views/MapView.vue
index 407da376..fa11e291 100644
--- a/front/src/views/MapView.vue
+++ b/front/src/views/MapView.vue
@@ -5,6 +5,13 @@ import MapComponent from "@/components/map/MapComponent.vue"
 <template>
   <div class="map-container max-w-screen overflow-hidden relative">
     <map-component map-id="default" class="h-full" />
+    <a
+      href="https://github.com/TelesCoop/iarbre"
+      target="_blank"
+      class="absolute bottom-[0.38rem] left-[1.1rem] text-[var(--color-brown)] bg-[var(--color-off-white)] px-2 py-1 rounded-[10px] underline text-[0.8rem]"
+    >
+      code source</a
+    >
   </div>
 </template>
 


Reviews:
user: login: Marc-AntoineA
body: C'est une MR en apparence assez simple et qui oui ne devrait pas prendre longtemps à relire mais elle serait bien plus simple à relire si :
* tu avais mis une capture d'écran du résultat : là je suis obligé de lancer un iarbre en local (donc si je suis en train de dev, de switch de branch, ou sinon de lancer le setup) : ça prend du temps 
* tu avais mis un lien direct vers le wireframe en question ou davantage explicité ce que tu as fait "Ajout d'un lien vers le code source en bas à gauche, cf [ce wireframe](https://www.notion.so/erasme/Wireframes-interface-254c6f02e40c401994cd44b6875b2317?pvs=4#1ab44e49a3ad8066b4acc32d6a296be4))"

(:information_source: Sur notion, tu peux sélectionner un bloc, faire clic droit puis ouvrir le menu puis "copy link to block")

Par ailleurs, il y a toujours un temps non négligeable de relire une MR, même toute petite. Du coup, je trouve que c'est discutable de faire "juste une petite modif" comme celle là et de ne pas la grouper avec d'autres.

----

Sur la MR en elle même, quelques ajustements pour mieux correspondre au wireframe selon moi mais sinon ok. Tu peux faire les modifs puis merger.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T09:16:57Z
user: login: ludovicdmt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T09:32:20Z
user: login: ludovicdmt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T11:08:12Z
user: login: Marc-AntoineA
body: :clap: :clap: Bravo pour avoir mis les mains dans le cambouis et avoir réussi à faire qqch qui tourne !

J’ai fait une review assez exhaustive pour l’exercice. Voici les grandes lignes :
* back : on devrait implémenter une "vraie API" avec DjangoRestFramework très vite. Je préfèrerais qu'on le fasse pour cette MR. Pour éviter de faire grossir cette MR, cela peut être fait dans une autre MR qui sera mergée avant celle-ci ;
* back : il faut ajouter des tests pour cette route. Faire une requête et vérifier qu’un objet `Feedback` a bien été ajouté ;
* front : 
  * mutualiser le style ;
  * créer des composants ;
  * tester les composants individuellement ;
  * tester l’ensemble ;

state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T12:39:42Z
user: login: Marc-AntoineA
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T15:07:26Z
user: login: Marc-AntoineA
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T13:51:49Z
user: login: Marc-AntoineA
body: Juste des commentaires sur tailwind. Je n’ai pas revu le reste du code.

Me faire signe quand ça sera relisable :)
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T13:55:36Z
user: login: Marc-AntoineA
body: Revue partiell, uniquement de la partie tests. Je fais une pause pour voir un truc avec Maxime.
Je n’ai pas regardé le reste encore.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T10:11:54Z
user: login: ludovicdmt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T11:11:49Z
user: login: ludovicdmt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T11:12:08Z
user: login: ludovicdmt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T11:12:12Z
user: login: ludovicdmt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T11:14:10Z
user: login: ludovicdmt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T11:14:31Z
user: login: ludovicdmt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T11:15:00Z
user: login: ludovicdmt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T11:15:27Z
user: login: ludovicdmt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T11:16:49Z
user: login: Marc-AntoineA
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T11:39:05Z
user: login: Marc-AntoineA
body: J’ai relu le reste, globalement bon pour moi. Essentiellement la question de pourquoi avoir installé `tailwind`. Mais sinon pas de gros soucis.

Par ailleurs, j’ai apprécié que tu aies utilisé des descriptions de commits assez précises, c’est plus simple pour cibler la review
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T12:58:08Z
user: login: ludovicdmt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T13:07:08Z
user: login: ludovicdmt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T13:21:10Z

Review comments:
diff_hunk: @@ -5,6 +5,9 @@ import MapComponent from "@/components/map/MapComponent.vue"
 <template>
   <div class="map-container max-full-height">
     <map-component map-id="default" class="h-100" />
+    <a href="https://github.com/TelesCoop/iarbre" target="_blank" class="code-source-link"
+      >code_source</a
path: front/src/views/MapView.vue
user: login: Marc-AntoineA
body: Typo, pour moi c'est "code source" sur le wireframe.
created_at: 2025-03-04T09:05:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5,6 +5,9 @@ import MapComponent from "@/components/map/MapComponent.vue"
 <template>
   <div class="map-container max-full-height">
     <map-component map-id="default" class="h-100" />
+    <a href="https://github.com/TelesCoop/iarbre" target="_blank" class="code-source-link"
path: front/src/views/MapView.vue
user: login: Marc-AntoineA
body: J'aurais mis ce lien dans un `div` mais je n'ai pas d'avis fort sur la question.
created_at: 2025-03-04T09:07:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,4 +17,16 @@ import MapComponent from "@/components/map/MapComponent.vue"
   height:  $content-height
   overflow: hidden
   position: relative
+
+.code-source-link
+  position: absolute
+  bottom: 1rem
+  left: 1.1rem
+  color: black
path: front/src/views/MapView.vue
user: login: Marc-AntoineA
body: Pour moi c'est pas la couleur du wireframe.
created_at: 2025-03-04T09:10:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,4 +17,16 @@ import MapComponent from "@/components/map/MapComponent.vue"
   height:  $content-height
   overflow: hidden
   position: relative
+
+.code-source-link
+  position: absolute
+  bottom: 1rem
+  left: 1.1rem
+  color: black
+  text-decoration: underline
+  font-size: 1.2rem
path: front/src/views/MapView.vue
user: login: Marc-AntoineA
body: Sur le wireframe, je vois ça beaucoup plus petit, et pas en gras.  Même ordre de grandeur de taille que le "OpenStreetMap Contributors" ? 
created_at: 2025-03-04T09:11:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,4 +17,16 @@ import MapComponent from "@/components/map/MapComponent.vue"
   height:  $content-height
   overflow: hidden
   position: relative
+
+.code-source-link
+  position: absolute
+  bottom: 1rem
+  left: 1.1rem
+  color: black
+  text-decoration: underline
+  font-size: 1.2rem
+  font-weight: bold
+  background-color: rgba(239, 239, 237, 0.6)  // Semi-transparent $off-white
+  padding: 0.2rem 0.5rem
+  border-radius: 5px
path: front/src/views/MapView.vue
user: login: Marc-AntoineA
body: Je ne vois pas d'exemple de wireframe avec un fond de carte et je ne sais donc pas comment Geoffrey aurait voulu gérer le background-color + le border-radius. Mais globalement, il y a une règle de ne pas avoir des border-radius différents sur une même page.

En testant rapidement, je ne trouve pas ça dégueu sans `background-color`, ça a l'avantage d'être assez discret.

created_at: 2025-03-04T09:15:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,4 +17,16 @@ import MapComponent from "@/components/map/MapComponent.vue"
   height:  $content-height
   overflow: hidden
   position: relative
+
+.code-source-link
+  position: absolute
+  bottom: 1rem
+  left: 1.1rem
+  color: black
+  text-decoration: underline
+  font-size: 1.2rem
+  font-weight: bold
+  background-color: rgba(239, 239, 237, 0.6)  // Semi-transparent $off-white
+  padding: 0.2rem 0.5rem
+  border-radius: 5px
path: front/src/views/MapView.vue
user: login: ludovicdmt
body: C'est ce que j'avais fait au départ mais je trouve ça invisible

![image](https://github.com/user-attachments/assets/a080a9a7-7c7a-4c37-ae08-2021868c33e7)

created_at: 2025-03-04T09:32:19Z
author_association: MEMBER
diff_hunk: @@ -14,4 +17,16 @@ import MapComponent from "@/components/map/MapComponent.vue"
   height:  $content-height
   overflow: hidden
   position: relative
+
+.code-source-link
+  position: absolute
+  bottom: 1rem
+  left: 1.1rem
+  color: black
+  text-decoration: underline
+  font-size: 1.2rem
path: front/src/views/MapView.vue
user: login: ludovicdmt
body: 
![image](https://github.com/user-attachments/assets/96639dec-2f6a-4851-99e2-0e3a3d3ed29c)


Après consultation de Geoffrey et lui avoir montré différentes options, il m'a demandé de mettre le background blanc et le plus similaire possible au (i) de OSM à droite
created_at: 2025-03-04T11:08:12Z
author_association: MEMBER
diff_hunk: @@ -10,13 +47,30 @@
       </div>
       <nav class="navbar-right">
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: Suggestion : tu peux créer un composant `Navbar` pour maintenir le `DefaultLayout` lisible.
created_at: 2025-03-05T10:43:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,13 +47,30 @@
       </div>
       <nav class="navbar-right">
         <ul>
+          <li>
+            <a href="#" class="navbar-link" @click.prevent="showFeedback">
+              ✉️ Nous envoyer votre retour
+            </a>
+          </li>
           <li><a href="https://iarbre.fr" class="navbar-link">ⓘ En savoir plus</a></li>
         </ul>
       </nav>
     </header>
+
     <main class="default-content">
       <slot></slot>
     </main>
+
+    <!-- Feedback -->
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: J'aime bien ce principe "Un code commenté est un mauvais code". En l'occurrence, tu peux créer un composant générique `FeedbackPopup` et ici avoir :
```
<feedback-popup v-if="isVisible"  @on-send="sendFeedback" @on-close="hideFeedback" />
```

Faire cela te permettra de manipuler les composants en Vue, ce qui est le cœur de la bête (et sa force).

Par ailleurs, ce composant devra être testé :
* individuellement, avec un fichier `front/cypress/components/FeedbackPopup.cy.ts` où tu testeras : 
  * que le composant se charge bien ;
  * que le clic sur le bouton `sendFeedback` déclenche bien l'événement "on-send"
  * que le clic sur la croix déclenche bien l'événement "on-close"
* avec un test `e2e` où tu vérifies qu'en cliquant sur le bouton dans la navbar, ça affiche bien ta popup et qu'en cliquant sur "envoyer" ça envoie bien la requête (tu peux l'intercepter côté cypress pour ne rien mettre en base de données si tu veux).

C'est peut-être un peu discutable de créer ces tests à chaque fois pour chaque composant mais en tout cas c'est un très bon exercice pour les prendre en main !
created_at: 2025-03-05T10:52:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,13 +47,30 @@
       </div>
       <nav class="navbar-right">
         <ul>
+          <li>
+            <a href="#" class="navbar-link" @click.prevent="showFeedback">
+              ✉️ Nous envoyer votre retour
+            </a>
+          </li>
           <li><a href="https://iarbre.fr" class="navbar-link">ⓘ En savoir plus</a></li>
         </ul>
       </nav>
     </header>
+
     <main class="default-content">
       <slot></slot>
     </main>
+
+    <!-- Feedback -->
+    <div v-if="isVisible" class="feedback-container">
+      <span class="close-btn" @click="hideFeedback">x</span>
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: Essaie d'avoir du HTML correct sémantiquement. Si ton élément est un bouton, cela doit être un `<button>`.

Note : pour pouvoir accéder à cet élément via cypress, on ajoute l'attribut `data-cy="ce-que-je-veux"` puis on fait un `cy.getBySel("ce-que-je-veux")` dans cypress.
created_at: 2025-03-05T10:54:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,13 +47,30 @@
       </div>
       <nav class="navbar-right">
         <ul>
+          <li>
+            <a href="#" class="navbar-link" @click.prevent="showFeedback">
+              ✉️ Nous envoyer votre retour
+            </a>
+          </li>
           <li><a href="https://iarbre.fr" class="navbar-link">ⓘ En savoir plus</a></li>
         </ul>
       </nav>
     </header>
+
     <main class="default-content">
       <slot></slot>
     </main>
+
+    <!-- Feedback -->
+    <div v-if="isVisible" class="feedback-container">
+      <span class="close-btn" @click="hideFeedback">x</span>
+      <p><strong>Votre avis compte !</strong></p>
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: Pour la sémantique & l'accessibilité, c'est très bien d'utiliser `<strong>` pour renforcer une partie du texte. Il ne faut par contre pas l'utiliser pour du style et "mettre en gras".

Voir ici des éléments sur [Stack overflow](https://stackoverflow.com/questions/271743/whats-the-difference-between-b-and-strong-i-and-em).

Dans ton cas précis, ici le "votre avis compte" a plutôt un rôle de titre et tu devrais plutôt mettre
```html
<h3>Votre avis compte !</h3>
```
created_at: 2025-03-05T10:58:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,4 +121,72 @@
   min-height: $content-height
   margin-top: $header-height
   flex: 1
+
+.feedback-container
+  position: fixed
+  top: 50%
+  left: 50%
+  transform: translate(-50%, -50%)
+  z-index: 1000
+  max-width: 400px
+  margin: auto
+  padding: 20px
+  border: 1px solid #ccc
+  border-radius: 5px
+  background: $white
+  color : $dark-green
+  text-align: left
+  display: flex
+  flex-direction: column
+  font-size: 0.9rem
+  box-sizing: border-box
+
+.close-btn
+  position: absolute
+  top: 10px
+  right: 10px
+  font-size: 18px
+  color: $dark-green
+  cursor: pointer
+  transition: 0.3s
+  &:hover
+    color: red
+
+textarea
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: Certes, le style est `scoped` donc ici tu appliques ton style `textarea` uniquement ici.

Néanmoins, c'est plus lisible d'utiliser la force du `scss` de pouvoir "encapsuler des styles" en ajoutant des tabulations.

```sass
.feedback-container
   display: flex //etc
   
   textarea // tu cibles les textarea à l'intérieur d'un élément qui a la classe `.feedback-container`
     padding-top: 10px
``` 
 
created_at: 2025-03-05T11:54:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,4 +121,72 @@
   min-height: $content-height
   margin-top: $header-height
   flex: 1
+
+.feedback-container
+  position: fixed
+  top: 50%
+  left: 50%
+  transform: translate(-50%, -50%)
+  z-index: 1000
+  max-width: 400px
+  margin: auto
+  padding: 20px
+  border: 1px solid #ccc
+  border-radius: 5px
+  background: $white
+  color : $dark-green
+  text-align: left
+  display: flex
+  flex-direction: column
+  font-size: 0.9rem
+  box-sizing: border-box
+
+.close-btn
+  position: absolute
+  top: 10px
+  right: 10px
+  font-size: 18px
+  color: $dark-green
+  cursor: pointer
+  transition: 0.3s
+  &:hover
+    color: red
+
+textarea
+  width: calc(100% - 20px)
+  height: 150px
+  margin-top: 10px
+  padding: 10px
+  border-radius: 20px
+  border: 1px solid #ccc
+  resize: none
+  outline: none
+
+input
+  width: calc(100% - 10px)
+  margin-top: 10px
+  padding: 5px
+  border-radius: 20px
+  border: 1px solid #ccc
+  outline: none
+
+button
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: C'est important de distinguer le style propre à ton élément de celui qui est réutilisé à de nombreux endroits.

En l'occurrence, un bouton primaire est _a priori_ un bouton qu'on va mettre un peu partout. Voire même aussi sur d'autres sites (dont le site vitrine !).

Il y a déjà un style `.button` défini dans `front/src/assets/styles/_iarbre.scss` (le bouton aurait dû être dans la popup de la carte). Pour l’utiliser `<button class="button primary"/>.

N’hésite pas à adapter le style !

created_at: 2025-03-05T11:59:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,4 +121,72 @@
   min-height: $content-height
   margin-top: $header-height
   flex: 1
+
+.feedback-container
+  position: fixed
+  top: 50%
+  left: 50%
+  transform: translate(-50%, -50%)
+  z-index: 1000
+  max-width: 400px
+  margin: auto
+  padding: 20px
+  border: 1px solid #ccc
+  border-radius: 5px
+  background: $white
+  color : $dark-green
+  text-align: left
+  display: flex
+  flex-direction: column
+  font-size: 0.9rem
+  box-sizing: border-box
+
+.close-btn
+  position: absolute
+  top: 10px
+  right: 10px
+  font-size: 18px
+  color: $dark-green
+  cursor: pointer
+  transition: 0.3s
+  &:hover
+    color: red
+
+textarea
+  width: calc(100% - 20px)
+  height: 150px
+  margin-top: 10px
+  padding: 10px
+  border-radius: 20px
+  border: 1px solid #ccc
+  resize: none
+  outline: none
+
+input
+  width: calc(100% - 10px)
+  margin-top: 10px
+  padding: 5px
+  border-radius: 20px
+  border: 1px solid #ccc
+  outline: none
+
+button
+  margin-top: 10px
+  padding: 10px 26px
+  border: none
+  background-color: $light-green
+  color: $white
+  cursor: pointer
+  border-radius: 20px
+  width: 100%
+  font-weight: bold
+  transition: background 0.3s
+  text-align: center
+  font-family: 'Sligoil', sans-serif
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: Il y a une variable `$accent-font` pour `Sligoil`.
created_at: 2025-03-05T11:59:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,4 +121,72 @@
   min-height: $content-height
   margin-top: $header-height
   flex: 1
+
+.feedback-container
+  position: fixed
+  top: 50%
+  left: 50%
+  transform: translate(-50%, -50%)
+  z-index: 1000
+  max-width: 400px
+  margin: auto
+  padding: 20px
+  border: 1px solid #ccc
+  border-radius: 5px
+  background: $white
+  color : $dark-green
+  text-align: left
+  display: flex
+  flex-direction: column
+  font-size: 0.9rem
+  box-sizing: border-box
+
+.close-btn
+  position: absolute
+  top: 10px
+  right: 10px
+  font-size: 18px
+  color: $dark-green
+  cursor: pointer
+  transition: 0.3s
+  &:hover
+    color: red
+
+textarea
+  width: calc(100% - 20px)
+  height: 150px
+  margin-top: 10px
+  padding: 10px
+  border-radius: 20px
+  border: 1px solid #ccc
+  resize: none
+  outline: none
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: C’est sympa pour l’interactivité je trouve d’avoir un `outline`.
created_at: 2025-03-05T12:01:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,4 +121,72 @@
   min-height: $content-height
   margin-top: $header-height
   flex: 1
+
+.feedback-container
+  position: fixed
+  top: 50%
+  left: 50%
+  transform: translate(-50%, -50%)
+  z-index: 1000
+  max-width: 400px
+  margin: auto
+  padding: 20px
+  border: 1px solid #ccc
+  border-radius: 5px
+  background: $white
+  color : $dark-green
+  text-align: left
+  display: flex
+  flex-direction: column
+  font-size: 0.9rem
+  box-sizing: border-box
+
+.close-btn
+  position: absolute
+  top: 10px
+  right: 10px
+  font-size: 18px
+  color: $dark-green
+  cursor: pointer
+  transition: 0.3s
+  &:hover
+    color: red
+
+textarea
+  width: calc(100% - 20px)
+  height: 150px
+  margin-top: 10px
+  padding: 10px
+  border-radius: 20px
+  border: 1px solid #ccc
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: Ajoute une variable CSS pour que ces couleurs soient uniformément utilisées (un `$light-gray: #ccc` dans `_iarbre.scss`) 
created_at: 2025-03-05T12:02:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,4 +121,72 @@
   min-height: $content-height
   margin-top: $header-height
   flex: 1
+
+.feedback-container
+  position: fixed
+  top: 50%
+  left: 50%
+  transform: translate(-50%, -50%)
+  z-index: 1000
+  max-width: 400px
+  margin: auto
+  padding: 20px
+  border: 1px solid #ccc
+  border-radius: 5px
+  background: $white
+  color : $dark-green
+  text-align: left
+  display: flex
+  flex-direction: column
+  font-size: 0.9rem
+  box-sizing: border-box
+
+.close-btn
+  position: absolute
+  top: 10px
+  right: 10px
+  font-size: 18px
+  color: $dark-green
+  cursor: pointer
+  transition: 0.3s
+  &:hover
+    color: red
+
+textarea
+  width: calc(100% - 20px)
+  height: 150px
+  margin-top: 10px
+  padding: 10px
+  border-radius: 20px
+  border: 1px solid #ccc
+  resize: none
+  outline: none
+
+input
+  width: calc(100% - 10px)
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: Globalement, c’est assez rare qu’on ait besoin d’utiliser du `calc` de cette manière. Ici, je n’en vois pas l’intérêt. Tu dois régler cette largeur en ajoutant du padding à ton container.
created_at: 2025-03-05T12:05:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,4 +121,72 @@
   min-height: $content-height
   margin-top: $header-height
   flex: 1
+
+.feedback-container
+  position: fixed
+  top: 50%
+  left: 50%
+  transform: translate(-50%, -50%)
+  z-index: 1000
+  max-width: 400px
+  margin: auto
+  padding: 20px
+  border: 1px solid #ccc
+  border-radius: 5px
+  background: $white
+  color : $dark-green
+  text-align: left
+  display: flex
+  flex-direction: column
+  font-size: 0.9rem
+  box-sizing: border-box
+
+.close-btn
+  position: absolute
+  top: 10px
+  right: 10px
+  font-size: 18px
+  color: $dark-green
+  cursor: pointer
+  transition: 0.3s
+  &:hover
+    color: red
+
+textarea
+  width: calc(100% - 20px)
+  height: 150px
+  margin-top: 10px
+  padding: 10px
+  border-radius: 20px
+  border: 1px solid #ccc
+  resize: none
+  outline: none
+
+input
+  width: calc(100% - 10px)
+  margin-top: 10px
+  padding: 5px
+  border-radius: 20px
+  border: 1px solid #ccc
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: N’hésite pas à mutualiser du style entre plusieurs éléments.

```sass
textarea, input
  border: 1px solid #ccc
textearea
  // trucs spécifiques
```
created_at: 2025-03-05T12:07:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,4 +121,72 @@
   min-height: $content-height
   margin-top: $header-height
   flex: 1
+
+.feedback-container
+  position: fixed
+  top: 50%
+  left: 50%
+  transform: translate(-50%, -50%)
+  z-index: 1000
+  max-width: 400px
+  margin: auto
+  padding: 20px
+  border: 1px solid #ccc
+  border-radius: 5px
+  background: $white
+  color : $dark-green
+  text-align: left
+  display: flex
+  flex-direction: column
+  font-size: 0.9rem
+  box-sizing: border-box
+
+.close-btn
+  position: absolute
+  top: 10px
+  right: 10px
+  font-size: 18px
+  color: $dark-green
+  cursor: pointer
+  transition: 0.3s
+  &:hover
+    color: red
+
+textarea
+  width: calc(100% - 20px)
+  height: 150px
+  margin-top: 10px
+  padding: 10px
+  border-radius: 20px
+  border: 1px solid #ccc
+  resize: none
+  outline: none
+
+input
+  width: calc(100% - 10px)
+  margin-top: 10px
+  padding: 5px
+  border-radius: 20px
+  border: 1px solid #ccc
+  outline: none
+
+button
+  margin-top: 10px
+  padding: 10px 26px
+  border: none
+  background-color: $light-green
+  color: $white
+  cursor: pointer
+  border-radius: 20px
+  width: 100%
+  font-weight: bold
+  transition: background 0.3s
+  text-align: center
+  font-family: 'Sligoil', sans-serif
+
+
+
+.message
+  margin-top: 10px
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: Tu aurais pu appliquer le `margin-top` à tous les enfants d’un container parent. 

Une autre solution très courante est d’utiliser des classe utiltaires, en l’occurence ici une classe qu’on appellerait `mt-10` (convention de tailwindcss) et qui ferait :
```
.mt-10
  margin-top: 10px
```

Comme ça, tu as juste besoin d’ajouter la classe `mt-10` à tes élémetns.
created_at: 2025-03-05T12:09:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,13 +47,30 @@
       </div>
       <nav class="navbar-right">
         <ul>
+          <li>
+            <a href="#" class="navbar-link" @click.prevent="showFeedback">
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: Naturellement j’aurais mis un `button`, je réserve les liens pour des éléments externes :man_shrugging: 

Il y a un problème d’alignement horizontal par rapport à `En savoir plus` 

![image](https://github.com/user-attachments/assets/ec0a6817-7dcb-4ffc-a38e-c7b89248164b)

created_at: 2025-03-05T12:15:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,4 +121,72 @@
   min-height: $content-height
   margin-top: $header-height
   flex: 1
+
+.feedback-container
+  position: fixed
+  top: 50%
+  left: 50%
+  transform: translate(-50%, -50%)
+  z-index: 1000
+  max-width: 400px
+  margin: auto
+  padding: 20px
+  border: 1px solid #ccc
+  border-radius: 5px
+  background: $white
+  color : $dark-green
+  text-align: left
+  display: flex
+  flex-direction: column
+  font-size: 0.9rem
+  box-sizing: border-box
+
+.close-btn
+  position: absolute
+  top: 10px
+  right: 10px
+  font-size: 18px
+  color: $dark-green
+  cursor: pointer
+  transition: 0.3s
+  &:hover
+    color: red
+
+textarea
+  width: calc(100% - 20px)
+  height: 150px
+  margin-top: 10px
+  padding: 10px
+  border-radius: 20px
+  border: 1px solid #ccc
+  resize: none
+  outline: none
+
+input
+  width: calc(100% - 10px)
+  margin-top: 10px
+  padding: 5px
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: le padding horizontal est pas assez importante. Les lettres sont collées à gauche et à droite.
created_at: 2025-03-05T12:17:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,4 +121,72 @@
   min-height: $content-height
   margin-top: $header-height
   flex: 1
+
+.feedback-container
+  position: fixed
+  top: 50%
+  left: 50%
+  transform: translate(-50%, -50%)
+  z-index: 1000
+  max-width: 400px
+  margin: auto
+  padding: 20px
+  border: 1px solid #ccc
+  border-radius: 5px
+  background: $white
+  color : $dark-green
+  text-align: left
+  display: flex
+  flex-direction: column
+  font-size: 0.9rem
+  box-sizing: border-box
+
+.close-btn
+  position: absolute
+  top: 10px
+  right: 10px
+  font-size: 18px
+  color: $dark-green
+  cursor: pointer
+  transition: 0.3s
+  &:hover
+    color: red
+
+textarea
+  width: calc(100% - 20px)
+  height: 150px
+  margin-top: 10px
+  padding: 10px
+  border-radius: 20px
+  border: 1px solid #ccc
+  resize: none
+  outline: none
+
+input
+  width: calc(100% - 10px)
+  margin-top: 10px
+  padding: 5px
+  border-radius: 20px
+  border: 1px solid #ccc
+  outline: none
+
+button
+  margin-top: 10px
+  padding: 10px 26px
+  border: none
+  background-color: $light-green
+  color: $white
+  cursor: pointer
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: Manque un effet de `hover`
created_at: 2025-03-05T12:21:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,4 +1,41 @@
-<script setup lang="ts"></script>
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: cf. commentaire plus loin, tout ce code devrait être dans des composants `navbar` et ou `feedback` pour ne pas polluer `DefaultLayout`.
created_at: 2025-03-05T12:22:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,4 +1,41 @@
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { ref } from "vue"
+import axios from "axios"
+import { FULL_BASE_API_URL } from "@/utils/constants"
+
+const isVisible = ref(false)
+const feedback = ref<string>("")
+const message = ref<string>("")
+const email = ref<string>("")
+
+const showFeedback = () => {
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: Tu peux directement faire dans ton code html `@click="isVisible=false"` pour éviter de devoir créer des méthodes creuses.
created_at: 2025-03-05T12:23:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,4 +1,41 @@
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { ref } from "vue"
+import axios from "axios"
+import { FULL_BASE_API_URL } from "@/utils/constants"
+
+const isVisible = ref(false)
+const feedback = ref<string>("")
+const message = ref<string>("")
+const email = ref<string>("")
+
+const showFeedback = () => {
+  isVisible.value = true
+}
+
+const hideFeedback = () => {
+  isVisible.value = false
+}
+
+const sendFeedback = async () => {
+  if (!feedback.value.trim()) {
+    message.value = "Veuillez écrire un avis avant d'envoyer."
+    return
+  }
+  try {
+    await axios.post(
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: On préfère déporter les fonctions d'utilisation de l'api dans un fichier dédié `api.ts` qui contient des méthodes génériques pour faire les différents types de requêtes, voir par ex. pour [biophonia](https://github.com/TelesCoop/biophonia/blob/dev/front/src/api.ts).

J’ai plus tendance à utiliser `fetch` que `axios` mais bon.
created_at: 2025-03-05T12:26:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,4 +121,72 @@
   min-height: $content-height
   margin-top: $header-height
   flex: 1
+
+.feedback-container
+  position: fixed
+  top: 50%
+  left: 50%
+  transform: translate(-50%, -50%)
+  z-index: 1000
+  max-width: 400px
+  margin: auto
+  padding: 20px
+  border: 1px solid #ccc
+  border-radius: 5px
+  background: $white
+  color : $dark-green
+  text-align: left
+  display: flex
+  flex-direction: column
+  font-size: 0.9rem
+  box-sizing: border-box
+
+.close-btn
+  position: absolute
+  top: 10px
+  right: 10px
+  font-size: 18px
+  color: $dark-green
+  cursor: pointer
+  transition: 0.3s
+  &:hover
+    color: red
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: Utiliser une variable css dédiée, éventuellement pour l’instant une variable idiote `$red: red` mais qui risque d’être amenée à évoluer car on devrait vouloir un rouge un peu spécial.
created_at: 2025-03-05T12:27:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,4 +1,41 @@
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { ref } from "vue"
+import axios from "axios"
+import { FULL_BASE_API_URL } from "@/utils/constants"
+
+const isVisible = ref(false)
+const feedback = ref<string>("")
+const message = ref<string>("")
+const email = ref<string>("")
+
+const showFeedback = () => {
+  isVisible.value = true
+}
+
+const hideFeedback = () => {
+  isVisible.value = false
+}
+
+const sendFeedback = async () => {
+  if (!feedback.value.trim()) {
+    message.value = "Veuillez écrire un avis avant d'envoyer."
+    return
+  }
+  try {
+    await axios.post(
+      `${FULL_BASE_API_URL}/feedback/`,
+      { feedback: feedback.value, email: email.value },
+      { headers: { "Content-Type": "application/json" } }
+    )
+    message.value = "Merci pour votre avis !"
+    feedback.value = ""
+    email.value = ""
+  } catch (error) {
+    console.error("Erreur lors de l'envoi du feedback:", error)
+    message.value = "Une erreur s'est produite, veuillez réessayer plus tard."
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: Si tu affiches un message, ça peut être bien de distinguer le succès de l’erreur.
created_at: 2025-03-05T12:27:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,4 +1,41 @@
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { ref } from "vue"
+import axios from "axios"
+import { FULL_BASE_API_URL } from "@/utils/constants"
+
+const isVisible = ref(false)
+const feedback = ref<string>("")
+const message = ref<string>("")
+const email = ref<string>("")
+
+const showFeedback = () => {
+  isVisible.value = true
+}
+
+const hideFeedback = () => {
+  isVisible.value = false
+}
+
+const sendFeedback = async () => {
+  if (!feedback.value.trim()) {
+    message.value = "Veuillez écrire un avis avant d'envoyer."
+    return
+  }
+  try {
+    await axios.post(
+      `${FULL_BASE_API_URL}/feedback/`,
+      { feedback: feedback.value, email: email.value },
+      { headers: { "Content-Type": "application/json" } }
+    )
+    message.value = "Merci pour votre avis !"
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: En terme d’ux, je m’attends à ce que la popup se ferme et que le message apparaisse ailleurs, via un toast par ex.
created_at: 2025-03-05T12:29:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -28,3 +33,38 @@ def tile_view(request, model_type, zoom, x, y):
     response = load_tiles(model, x, y, zoom)
     print(f"Request duration: {time.time() - start_time} seconds")
     return response
+
+
+@csrf_exempt  # Mandatory but not really satisfactory
path: back/api/views.py
user: login: Marc-AntoineA
body: Clairement insatisfaisant. Globalement, ce que tu as fait m’a l’air correct mais je pense qu’on devrait implémenter une api complète dans le back avec la gestion des `csrf` :shrug: de si on le fait maintenant ou plus tard ?

 
created_at: 2025-03-05T12:34:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,13 +47,30 @@
       </div>
       <nav class="navbar-right">
         <ul>
+          <li>
+            <a href="#" class="navbar-link" @click.prevent="showFeedback">
+              ✉️ Nous envoyer votre retour
+            </a>
+          </li>
           <li><a href="https://iarbre.fr" class="navbar-link">ⓘ En savoir plus</a></li>
         </ul>
       </nav>
     </header>
+
     <main class="default-content">
       <slot></slot>
     </main>
+
+    <!-- Feedback -->
+    <div v-if="isVisible" class="feedback-container">
+      <span class="close-btn" @click="hideFeedback">x</span>
+      <p><strong>Votre avis compte !</strong></p>
+      <p>Partagez-nous vos impressions pour nous aider à améliorer le site :</p>
+      <input v-model="email" type="email" placeholder="Votre email" />
+      <textarea v-model="feedback" placeholder="Votre message"></textarea>
+      <button @click="sendFeedback">J'envoie mon avis</button>
+      <p v-if="message" class="message">{{ message }}</p>
path: front/src/layouts/DefaultLayout.vue
user: login: Marc-AntoineA
body: utiliser un `<form>` en container.
created_at: 2025-03-05T15:07:26Z
author_association: CONTRIBUTOR
path: front/yarn.lock
user: login: Marc-AntoineA
body: On utilise `npm` sur ce projet.
created_at: 2025-03-10T13:51:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,63 +23,34 @@ const sendFeedbackToAPI = async (data: { email: string; feedback: string }) => {
 </script>
 
 <template>
-  <div class="navbar">
-    <div class="navbar-left">
+  <div
+    class="fixed top-0 w-full bg-[var(--color-off-white)] h-[var(--header-height)] z-10 flex items-center justify-between overflow-hidden box-border"
+  >
+    <div class="pl-4 md:pl-0">
       <a href="/">
-        <img class="navbar-logo" src="/images/logo-iarbre.png" alt="Logo I-Arbre" />
+        <img class="h-10 w-auto" src="/images/logo-iarbre.png" alt="Logo I-Arbre" />
       </a>
     </div>
-    <nav class="navbar-right">
-      <ul>
+    <nav class="pr-4 md:pr-20">
+      <ul class="flex gap-4 list-none">
         <li>
-          <button class="navbar-link" @click.prevent="isVisible = true">
+          <button
+            class="font-[var(--font-accent)] text-[var(--color-brown)] text-base bg-[var(--color-off-white)] border-none outline-none transition duration-300 hover:text-[var(--color-light-green)] cursor-pointer"
path: front/src/components/NavbarComponent.vue
user: login: Marc-AntoineA
body: Tu peux définir une classe `css` pour ne pas utiliser autant de classes `tailwind`. 

Le problème là c’est que t’as globalement deux fois la même chose (lignes `38` et `47`) et ça va être compliqué de maintenir qu’est-ce qui doit être identifique ou non.

Pour appliquer du tailwind, tu vas faire un truc du genre :
```
.my-class {
  @apply font-[var(--font-accent)];
  @apply ...;
}
```
created_at: 2025-03-10T13:54:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,89 +21,38 @@ const sendFeedback = (event: Event) => {
 </script>
 
 <template>
-  <div class="feedback-container">
-    <button class="button text close-btn" @click="emit('close')">x</button>
-    <h3>Votre avis compte !</h3>
-    <p>Partagez-nous vos impressions pour nous aider à améliorer le site :</p>
-
-    <form @submit="sendFeedback">
-      <input v-model="email" type="email" placeholder="Votre email" required />
-      <textarea v-model="feedback" placeholder="Votre message" required></textarea>
-      <button class="button send-btn" type="submit">J'envoie mon avis</button>
-      <p v-if="message" class="message">{{ message }}</p>
+  <div
+    class="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 max-w-sm mx-auto p-5 border border-gray-300 rounded-lg bg-[var(--color-white)] text-[var(--color-dark-green)] text-left flex flex-col text-sm box-border"
+  >
+    <button
+      class="button text absolute top-2 right-2 text-lg text-[var(--color-dark-green)] cursor-pointer transition-colors duration-300 hover:text-[var(--color-red)]"
+      @click="emit('close')"
+    >
+      x
+    </button>
+    <h3 class="font-medium">Votre avis compte !</h3>
+    <p class="mb-2">Partagez-nous vos impressions pour nous aider à améliorer le site :</p>
+    <form class="w-full" @submit="sendFeedback">
+      <input
+        v-model="email"
+        type="email"
+        placeholder="Votre email"
+        required
+        class="w-full mt-2 p-2 rounded-full border border-gray-300 outline-none"
path: front/src/components/FeedbackPopin.vue
user: login: Marc-AntoineA
body: Idem que plus bas, c’est un peu illisible niveau `tailwind` ici je trouve.
created_at: 2025-03-10T13:54:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,35 @@
+import json
+from django.test import TestCase, Client
+from iarbre_data.models import Feedback
+
+
+class ReceiveFeedbackViewTest(TestCase):
+    def setUp(self):
+        self.client = Client()
+        self.url = "/api/feedback/"
+
+    def test_valid_feedback_submission(self):
+        data = {"email": "molly.maguires@test.fr", "feedback": "Jolie carte!"}
+        response = self.client.post(
+            self.url, json.dumps(data), content_type="application/json"
+        )
+
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(response.json()["message"], "Feedback saved!")
+        self.assertTrue(Feedback.objects.filter(feedback="Jolie carte!").exists())
+
+    def test_missing_feedback_field(self):
+        data = {"email": "molly.maguires@test.fr"}  # No feedback text
+        response = self.client.post(
+            self.url, json.dumps(data), content_type="application/json"
+        )
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json()["error"], "Feedback is required.")
+
+    def test_invalid_json_format(self):
+        response = self.client.post(
+            self.url, "{invalid_json}", content_type="application/json"
+        )
+
+        self.assertEqual(response.status_code, 400)
path: back/iarbre_data/tests/test_feedback.py
user: login: Marc-AntoineA
body: Il n'y a pas de message d'erreur à tester dans ce cas ? 
created_at: 2025-03-13T09:56:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,35 @@
+import json
+from django.test import TestCase, Client
+from iarbre_data.models import Feedback
+
+
+class ReceiveFeedbackViewTest(TestCase):
+    def setUp(self):
+        self.client = Client()
+        self.url = "/api/feedback/"
+
+    def test_valid_feedback_submission(self):
+        data = {"email": "molly.maguires@test.fr", "feedback": "Jolie carte!"}
+        response = self.client.post(
+            self.url, json.dumps(data), content_type="application/json"
+        )
+
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(response.json()["message"], "Feedback saved!")
+        self.assertTrue(Feedback.objects.filter(feedback="Jolie carte!").exists())
path: back/iarbre_data/tests/test_feedback.py
user: login: Marc-AntoineA
body: Tu peux tester l'adresse email aussi
created_at: 2025-03-13T09:56:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,15 @@
+import FeedbackPopin from "@/components/FeedbackPopin.vue"
+
+describe("Component: FeedbackPopin", () => {
+  it("renders correctly", () => {
+    cy.mount(FeedbackPopin)
+
+    cy.contains("Votre avis compte !")
path: front/cypress/components/FeedbackPopin.cy.ts
user: login: Marc-AntoineA
body: Écrire des tests d'intégration, c'est vraiment une histoire de compromis à trouver entre suffisamment tester tout en n'ayant pas un truc trop spécifique, qui cassera  à la moindre modification de ton code.

Ici, tu peux te contenter d'un seul test `cy.contains()`.
created_at: 2025-03-13T09:58:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,66 @@
+// https://on.cypress.io/api
+
+describe("Feedback Popin interactivity", () => {
+  beforeEach(() => {
+    cy.visit("/")
+    cy.get("@consoleInfo").should("have.been.calledWith", "cypress: map data loaded")
path: front/cypress/e2e/popininteractivity.cy.ts
user: login: Marc-AntoineA
body: Tu n'as pas besoin d'attendre ça ici non ?
created_at: 2025-03-13T10:00:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,66 @@
+// https://on.cypress.io/api
+
+describe("Feedback Popin interactivity", () => {
+  beforeEach(() => {
+    cy.visit("/")
+    cy.get("@consoleInfo").should("have.been.calledWith", "cypress: map data loaded")
+  })
+
+  it("Open Popin on click", () => {
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
path: front/cypress/e2e/popininteractivity.cy.ts
user: login: Marc-AntoineA
body: :x: Non, mauvaise manière de faire. Là tu es dépendant du texte écrit dans ton bouton. 

Pour avoir plus de robustesse, tu ajoutes un identifiant à ton bouton `<button data-cy="open-feedback-button"/>`
Puis dans ton code :
```
cy.getBySel("open-feedback-button").click()
```

À changer.
created_at: 2025-03-13T10:02:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,66 @@
+// https://on.cypress.io/api
+
+describe("Feedback Popin interactivity", () => {
+  beforeEach(() => {
+    cy.visit("/")
+    cy.get("@consoleInfo").should("have.been.calledWith", "cypress: map data loaded")
+  })
+
+  it("Open Popin on click", () => {
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+    cy.getBySel("feedback-popin").should("exist")
+  })
+
+  it("Open, close and reopen feedback popin", () => {
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+
+    cy.get("button").contains("x").click()
+    cy.getBySel("feedback-popin").should("not.exist")
+
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+    cy.getBySel("feedback-popin").should("exist")
+  })
+
+  it("Fill and submit the feedback form", () => {
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+
+    const testEmail = "molly.maguire@test.fr"
+    const testFeedback = "Raise the floor, not just the ceiling."
+
+    cy.get('input[type="email"]').type(testEmail)
+    cy.get("textarea").type(testFeedback)
+
+    cy.window().then((win) => {
path: front/cypress/e2e/popininteractivity.cy.ts
user: login: Marc-AntoineA
body: Je ne connais pas cette syntaxe, j'utilise `intercept`. C'est plus élégant, notamment parce que tu 
```
cy.intercept("GET", "/ma route")
```

Grace à ça tu peux :
* modifier la requête ;
* modifier la réponse ;
* vérifier que ça a été appelé ;
* effectuer une opération quelconque à chaque appel ;


created_at: 2025-03-13T10:06:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,66 @@
+// https://on.cypress.io/api
+
+describe("Feedback Popin interactivity", () => {
+  beforeEach(() => {
+    cy.visit("/")
+    cy.get("@consoleInfo").should("have.been.calledWith", "cypress: map data loaded")
+  })
+
+  it("Open Popin on click", () => {
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+    cy.getBySel("feedback-popin").should("exist")
+  })
+
+  it("Open, close and reopen feedback popin", () => {
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+
+    cy.get("button").contains("x").click()
+    cy.getBySel("feedback-popin").should("not.exist")
+
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+    cy.getBySel("feedback-popin").should("exist")
+  })
+
+  it("Fill and submit the feedback form", () => {
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+
+    const testEmail = "molly.maguire@test.fr"
+    const testFeedback = "Raise the floor, not just the ceiling."
+
+    cy.get('input[type="email"]').type(testEmail)
+    cy.get("textarea").type(testFeedback)
+
+    cy.window().then((win) => {
+      cy.stub(win, "fetch").resolves({
+        ok: true,
+        json: () => Promise.resolve({ message: "Merci pour votre retour !" })
+      })
+    })
+
+    cy.contains("button", "J'envoie mon avis").click()
+    cy.getBySel("feedback-popin").should("not.exist")
+  })
+
+  it("Validates required form fields", () => {
path: front/cypress/e2e/popininteractivity.cy.ts
user: login: Marc-AntoineA
body: Ce test devrait être un test de composant. Tu n'utilises pas le fait que tu as monté tout l'application.
created_at: 2025-03-13T10:07:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,9 @@
+import Navbar from "@/components/NavbarComponent.vue"
+
+describe("Component:Navbar", () => {
+  it("renders correctly", () => {
+    cy.mount(Navbar)
+    cy.contains("✉️ Nous envoyer votre retour")
path: front/cypress/components/NavbarComponent.cy.ts
user: login: Marc-AntoineA
body: Idem, utiliser `getBySel`.
created_at: 2025-03-13T10:08:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,15 @@
+import FeedbackPopin from "@/components/FeedbackPopin.vue"
+
+describe("Component: FeedbackPopin", () => {
path: front/cypress/components/FeedbackPopin.cy.ts
user: login: Marc-AntoineA
body: C'est compliqué (et discutable) de la frontière entre tests de composants & tests e2e mais globalement, c'est bien d'essayer d'avoir des tests unitaires précis pour tester des fonctionnalités précises.

En l'occurrence ici tester :
* feedback obligatoire ;
* envoi des bons événements quand on ferme & submit la popin ;

created_at: 2025-03-13T10:10:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,35 @@
+import json
+from django.test import TestCase, Client
+from iarbre_data.models import Feedback
+
+
+class ReceiveFeedbackViewTest(TestCase):
+    def setUp(self):
+        self.client = Client()
+        self.url = "/api/feedback/"
+
+    def test_valid_feedback_submission(self):
+        data = {"email": "molly.maguires@test.fr", "feedback": "Jolie carte!"}
+        response = self.client.post(
+            self.url, json.dumps(data), content_type="application/json"
+        )
+
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(response.json()["message"], "Feedback saved!")
+        self.assertTrue(Feedback.objects.filter(feedback="Jolie carte!").exists())
+
+    def test_missing_feedback_field(self):
path: back/iarbre_data/tests/test_feedback.py
user: login: Marc-AntoineA
body: Tu pourrais avoir un `test_missing_email_field` aussi 
created_at: 2025-03-13T10:10:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,66 @@
+// https://on.cypress.io/api
+
+describe("Feedback Popin interactivity", () => {
+  beforeEach(() => {
+    cy.visit("/")
+    cy.get("@consoleInfo").should("have.been.calledWith", "cypress: map data loaded")
path: front/cypress/e2e/popininteractivity.cy.ts
user: login: ludovicdmt
body: Copier-coller de `mapinteractivity.cy.ts`
created_at: 2025-03-13T11:11:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,35 @@
+import json
+from django.test import TestCase, Client
+from iarbre_data.models import Feedback
+
+
+class ReceiveFeedbackViewTest(TestCase):
+    def setUp(self):
+        self.client = Client()
+        self.url = "/api/feedback/"
+
+    def test_valid_feedback_submission(self):
+        data = {"email": "molly.maguires@test.fr", "feedback": "Jolie carte!"}
+        response = self.client.post(
+            self.url, json.dumps(data), content_type="application/json"
+        )
+
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(response.json()["message"], "Feedback saved!")
+        self.assertTrue(Feedback.objects.filter(feedback="Jolie carte!").exists())
+
+    def test_missing_feedback_field(self):
path: back/iarbre_data/tests/test_feedback.py
user: login: ludovicdmt
body: Ajouté
created_at: 2025-03-13T11:12:08Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,35 @@
+import json
+from django.test import TestCase, Client
+from iarbre_data.models import Feedback
+
+
+class ReceiveFeedbackViewTest(TestCase):
+    def setUp(self):
+        self.client = Client()
+        self.url = "/api/feedback/"
+
+    def test_valid_feedback_submission(self):
+        data = {"email": "molly.maguires@test.fr", "feedback": "Jolie carte!"}
+        response = self.client.post(
+            self.url, json.dumps(data), content_type="application/json"
+        )
+
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(response.json()["message"], "Feedback saved!")
+        self.assertTrue(Feedback.objects.filter(feedback="Jolie carte!").exists())
+
+    def test_missing_feedback_field(self):
+        data = {"email": "molly.maguires@test.fr"}  # No feedback text
+        response = self.client.post(
+            self.url, json.dumps(data), content_type="application/json"
+        )
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json()["error"], "Feedback is required.")
+
+    def test_invalid_json_format(self):
+        response = self.client.post(
+            self.url, "{invalid_json}", content_type="application/json"
+        )
+
+        self.assertEqual(response.status_code, 400)
path: back/iarbre_data/tests/test_feedback.py
user: login: ludovicdmt
body: Ajouté
created_at: 2025-03-13T11:12:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,35 @@
+import json
+from django.test import TestCase, Client
+from iarbre_data.models import Feedback
+
+
+class ReceiveFeedbackViewTest(TestCase):
+    def setUp(self):
+        self.client = Client()
+        self.url = "/api/feedback/"
+
+    def test_valid_feedback_submission(self):
+        data = {"email": "molly.maguires@test.fr", "feedback": "Jolie carte!"}
+        response = self.client.post(
+            self.url, json.dumps(data), content_type="application/json"
+        )
+
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(response.json()["message"], "Feedback saved!")
+        self.assertTrue(Feedback.objects.filter(feedback="Jolie carte!").exists())
path: back/iarbre_data/tests/test_feedback.py
user: login: ludovicdmt
body: Ajouté
created_at: 2025-03-13T11:14:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,66 @@
+// https://on.cypress.io/api
+
+describe("Feedback Popin interactivity", () => {
+  beforeEach(() => {
+    cy.visit("/")
+    cy.get("@consoleInfo").should("have.been.calledWith", "cypress: map data loaded")
+  })
+
+  it("Open Popin on click", () => {
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
path: front/cypress/e2e/popininteractivity.cy.ts
user: login: ludovicdmt
body: Modifé
created_at: 2025-03-13T11:14:31Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,66 @@
+// https://on.cypress.io/api
+
+describe("Feedback Popin interactivity", () => {
+  beforeEach(() => {
+    cy.visit("/")
+    cy.get("@consoleInfo").should("have.been.calledWith", "cypress: map data loaded")
+  })
+
+  it("Open Popin on click", () => {
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+    cy.getBySel("feedback-popin").should("exist")
+  })
+
+  it("Open, close and reopen feedback popin", () => {
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+
+    cy.get("button").contains("x").click()
+    cy.getBySel("feedback-popin").should("not.exist")
+
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+    cy.getBySel("feedback-popin").should("exist")
+  })
+
+  it("Fill and submit the feedback form", () => {
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+
+    const testEmail = "molly.maguire@test.fr"
+    const testFeedback = "Raise the floor, not just the ceiling."
+
+    cy.get('input[type="email"]').type(testEmail)
+    cy.get("textarea").type(testFeedback)
+
+    cy.window().then((win) => {
path: front/cypress/e2e/popininteractivity.cy.ts
user: login: ludovicdmt
body: Merci, j'ai modifé.
created_at: 2025-03-13T11:15:00Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,66 @@
+// https://on.cypress.io/api
+
+describe("Feedback Popin interactivity", () => {
+  beforeEach(() => {
+    cy.visit("/")
+    cy.get("@consoleInfo").should("have.been.calledWith", "cypress: map data loaded")
+  })
+
+  it("Open Popin on click", () => {
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+    cy.getBySel("feedback-popin").should("exist")
+  })
+
+  it("Open, close and reopen feedback popin", () => {
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+
+    cy.get("button").contains("x").click()
+    cy.getBySel("feedback-popin").should("not.exist")
+
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+    cy.getBySel("feedback-popin").should("exist")
+  })
+
+  it("Fill and submit the feedback form", () => {
+    cy.contains("button", "✉️ Nous envoyer votre retour").click()
+
+    const testEmail = "molly.maguire@test.fr"
+    const testFeedback = "Raise the floor, not just the ceiling."
+
+    cy.get('input[type="email"]').type(testEmail)
+    cy.get("textarea").type(testFeedback)
+
+    cy.window().then((win) => {
+      cy.stub(win, "fetch").resolves({
+        ok: true,
+        json: () => Promise.resolve({ message: "Merci pour votre retour !" })
+      })
+    })
+
+    cy.contains("button", "J'envoie mon avis").click()
+    cy.getBySel("feedback-popin").should("not.exist")
+  })
+
+  it("Validates required form fields", () => {
path: front/cypress/e2e/popininteractivity.cy.ts
user: login: ludovicdmt
body: Ok je l'ai mis dans le test compostant de Navbar parce que le call d'API est dans ce composant et pas dans PopIn
created_at: 2025-03-13T11:15:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,15 @@
+import FeedbackPopin from "@/components/FeedbackPopin.vue"
+
+describe("Component: FeedbackPopin", () => {
path: front/cypress/components/FeedbackPopin.cy.ts
user: login: ludovicdmt
body: Ca m'a semblé plus simple de tester l'interactivité plutôt que l'envoie ou non de emit (je ne sais pas faire) donc j'ai gardé comme ça
created_at: 2025-03-13T11:16:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,66 @@
+// https://on.cypress.io/api
+
+describe("Feedback Popin interactivity", () => {
+  beforeEach(() => {
+    cy.visit("/")
+    cy.get("@consoleInfo").should("have.been.calledWith", "cypress: map data loaded")
path: front/cypress/e2e/popininteractivity.cy.ts
user: login: Marc-AntoineA
body: Oui, je sais. Sauf que dans `mapinteractivity`, on a besoin que la map soit chargée. Ce n’est pas ton cas ici.
created_at: 2025-03-13T11:39:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,15 @@
+import FeedbackPopin from "@/components/FeedbackPopin.vue"
+
+describe("Component: FeedbackPopin", () => {
path: front/cypress/components/FeedbackPopin.cy.ts
user: login: Marc-AntoineA
body: Pour tester l'emit, tu as simplement a ajouter comme props un `cy.spy`, voir ci-dessous.
```
  it("displays error message when folder is dropped", () => {
    cy.mount(UploadInput, {
      props: {
        onUpload: cy.spy().as("on-upload-file"),
        allowedExtensions: [],
      },
    })

    const folder = new File([""], "folder", { type: "" })
    const audioFile = new File([""], "file.mp3", { type: "audio/mp3" })

    cy.uploadFiles([audioFile, folder], "upload-input-label")
    cy.getBySel("upload-input-errors").contains("dossier").should("be.visible")

    cy.get("@on-upload-file").should("have.been.calledOnce")
    cy.get("@on-upload-file").should("have.been.calledWith", [folder])
  })
```
created_at: 2025-03-13T11:42:36Z
author_association: CONTRIBUTOR
path: front/src/components/NavbarComponent.vue
user: login: Marc-AntoineA
body: Nom de fichier devrait plutôt être `navbar` pour moi.
created_at: 2025-03-13T11:43:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,31 +23,21 @@ const sendFeedbackToAPI = async (data: { email: string; feedback: string }) => {
 </script>
 
 <template>
-  <div
-    class="fixed top-0 w-full bg-[var(--color-off-white)] h-[var(--header-height)] z-10 flex items-center justify-between overflow-hidden box-border"
-  >
-    <div class="pl-4 md:pl-0">
+  <div class="my-header">
path: front/src/components/NavbarComponent.vue
user: login: Marc-AntoineA
body: Mauvais noms de classe qui ne sont pas du tout explicites. `my-header` n’a aucun sens pour moi, au pire tu mets simplement "header". La même pour tous les `my-<qqch>` .
created_at: 2025-03-13T11:44:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,27 +55,63 @@ body {
 }
 
 /* Custom utility classes */
-.max-full-height {
-  max-height: var(--content-height);
+
+.button {
+  @apply font-[var(--font-accent)] text-[var(--color-brown)] text-base bg-[var(--color-off-white)] border-none outline-none transition duration-300 hover:text-[var(--color-light-green)] cursor-pointer;
 }
 
-.font-accent {
-  font-family: var(--font-accent);
+.my-header {
+  @apply fixed top-0 w-full bg-[var(--color-off-white)] h-[var(--header-height)] z-10 flex items-center justify-between overflow-hidden box-border;
 }
 
-/* Button styles */
-.button {
-  @apply font-[var(--font-accent)] no-underline border-none py-2.5 px-5 rounded-[50px];
+.my-header-logo {
+  @apply pl-4 md:pl-0;
+}
+
+.my-header-nav {
+  @apply pr-4 md:pr-20;
+}
+
+.my-nav-list {
+  @apply flex gap-4 list-none;
+}
+
+.my-link {
path: front/src/styles/main.css
user: login: Marc-AntoineA
body: Il y a des choses ici que je pense qu’on voudrait appliquer à tous les liens. Mais cela pourrait se faire dans un deuxième temps. 
created_at: 2025-03-13T11:46:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,27 +55,63 @@ body {
 }
 
 /* Custom utility classes */
-.max-full-height {
-  max-height: var(--content-height);
+
+.button {
+  @apply font-[var(--font-accent)] text-[var(--color-brown)] text-base bg-[var(--color-off-white)] border-none outline-none transition duration-300 hover:text-[var(--color-light-green)] cursor-pointer;
 }
 
-.font-accent {
-  font-family: var(--font-accent);
+.my-header {
+  @apply fixed top-0 w-full bg-[var(--color-off-white)] h-[var(--header-height)] z-10 flex items-center justify-between overflow-hidden box-border;
 }
 
-/* Button styles */
-.button {
-  @apply font-[var(--font-accent)] no-underline border-none py-2.5 px-5 rounded-[50px];
+.my-header-logo {
+  @apply pl-4 md:pl-0;
+}
+
+.my-header-nav {
+  @apply pr-4 md:pr-20;
+}
+
+.my-nav-list {
+  @apply flex gap-4 list-none;
+}
+
+.my-link {
+  @apply font-[var(--font-accent)] text-[var(--color-brown)] text-base no-underline transition duration-300 hover:text-[var(--color-light-green)];
+}
+
+.my-popin {
+  @apply fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 max-w-sm mx-auto p-5 border border-gray-300 rounded-lg bg-[var(--color-white)] text-[var(--color-dark-green)] text-left flex flex-col text-sm box-border;
+}
+
+.my-popin-close-button {
+  @apply absolute top-2 right-2 text-lg text-[var(--color-dark-green)] cursor-pointer transition-colors duration-300 hover:text-[var(--color-red)];
+}
+
+.my-popin-heading {
+  @apply font-medium;
+}
+
+.my-popin-text {
+  @apply mb-2;
+}
+
+.my-popin-form {
+  @apply w-full;
+}
+
+.my-input {
+  @apply w-full mt-2 p-2 rounded-full border border-gray-300 outline-none;
 }
 
-.button.primary {
-  @apply text-[var(--color-white)] bg-[var(--color-dark-green)];
+.my-textarea {
+  @apply w-full h-36 mt-2 p-2 rounded-3xl border border-gray-300 resize-none outline-none;
 }
 
-.button.small {
-  @apply py-[7px] px-[10px] text-[0.8rem];
+.my-submit-button {
path: front/src/styles/main.css
user: login: Marc-AntoineA
body: Idem ici, ce sont des styles qu’on aimera appliquer à tous les boutons de type "primary". Ok pour un refacto ultérieur.
created_at: 2025-03-13T11:46:27Z
author_association: CONTRIBUTOR
path: back/iarbre_data/migrations/0014_feedback.py
user: login: Marc-AntoineA
body: On peut voir en `peer` pour éviter d’avoir des migrations "fantômes". C’est pas très grave mais tu peux faire plus propre.

Ma technique c’est :
(1) migrer à avant le merge, un truc du genre `migrate iarbre_data 0017` 
(2) Supprimer mon fichier (`0014`) ;
(3) Créer les migrations
(4) Migrer.

En tout cas, j’aime bien l’idée qu’on ait qu’un seul fichier de migration par MR, d’autant plus qu’on squash les commits. 
created_at: 2025-03-13T12:53:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,6 +20,7 @@
     "@tailwindcss/postcss": "^4.0.11",
     "maplibre-gl": "^5.2.0",
     "pinia": "^3.0.0",
+    "tailwind": "^4.0.0",
path: front/package.json
user: login: Marc-AntoineA
body: Pourquoi en avais-tu besoin alors que c’était pas le cas avant ? 
created_at: 2025-03-13T12:54:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,6 +21,7 @@
   --color-light-green: #92a48d;
   --color-brown: #32312d;
   --color-white: #ffffff;
+  --color-red: red;
path: front/src/styles/main.css
user: login: Marc-AntoineA
body: Super
created_at: 2025-03-13T12:55:59Z
author_association: CONTRIBUTOR
path: front/src/components/NavbarComponent.vue
user: login: ludovicdmt
body: J'avais mis ça mais le linter me le refuse 

> 1:1  error  Component name "Navbar" should always be multi-word  vue/multi-word-component-names
created_at: 2025-03-13T13:07:08Z
author_association: MEMBER
diff_hunk: @@ -20,6 +20,7 @@
     "@tailwindcss/postcss": "^4.0.11",
     "maplibre-gl": "^5.2.0",
     "pinia": "^3.0.0",
+    "tailwind": "^4.0.0",
path: front/package.json
user: login: ludovicdmt
body: Ca sert à rien, merci
created_at: 2025-03-13T13:21:10Z
author_association: MEMBER

PR comments:
user: login: ludovicdmt
created_at: 2025-03-04T16:13:26Z
body: ![image](https://github.com/user-attachments/assets/b81a6168-c6a1-4a11-8d56-c5f122dd5213)

Fonctionne aussi côté backend, je rentre bien des messages en base
author_association: MEMBER
user: login: ludovicdmt
created_at: 2025-03-10T13:52:03Z
body: - [x] Create `NavbarComponent` + `FeedbackPopin`
- [x]  Add tests for these components
- [x] Add e2e tests
- [x] Test the new url in the back
- [x] Update sass to use tailwind instead 

author_association: MEMBER
user: login: ludovicdmt
created_at: 2025-03-10T13:54:37Z
body: ![image](https://github.com/user-attachments/assets/a0981008-a638-4b10-8f4c-20b475f1a448)

author_association: MEMBER
user: login: ludovicdmt
created_at: 2025-03-12T10:24:59Z
body: @Marc-AntoineA 

Testé sur [feature](https://feature-carte.iarbre.fr/) et vérifié la présence des feedbacks en base (ça marche)

Pas très content de mon main.css, je m'en sors mal avec tailwind je trouve. Ca me semble dur à maintenir en l'état .Très preneur de retours. 

Preneur de retours aussi sur mes tests qui sont sans doute incomplets ou mal écrites
author_association: MEMBER
user: login: ludovicdmt
created_at: 2025-03-13T16:30:44Z
body: - Il restera à faire une refacto du tailwind pour que ce soit plus clair et maintenable (#122)
author_association: MEMBER