diff --git a/config/settings/base.py b/config/settings/base.py index 1c1bbc40d..6eef527f3 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -156,6 +156,7 @@ "lemarche.cms", # Brevo CRM "lemarche.crm", + "lemarche.django_shepherd", ] WAGTAIL_APPS = [ diff --git a/config/urls.py b/config/urls.py index 5476ca536..39aa60f77 100644 --- a/config/urls.py +++ b/config/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path("admin/", admin_site.urls), path("api/", include("lemarche.api.urls")), + path("django_shepherd/", include("lemarche.django_shepherd.urls")), path("accounts/", include("lemarche.www.auth.urls")), path("besoins/", include("lemarche.www.tenders.urls")), path("prestataires/", include("lemarche.www.siaes.urls")), diff --git a/lemarche/django_shepherd/__init__.py b/lemarche/django_shepherd/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lemarche/django_shepherd/admin.py b/lemarche/django_shepherd/admin.py new file mode 100644 index 000000000..6786114f1 --- /dev/null +++ b/lemarche/django_shepherd/admin.py @@ -0,0 +1,31 @@ +from ckeditor.widgets import CKEditorWidget +from django.contrib import admin +from django.db import models + +from lemarche.utils.admin.admin_site import admin_site + +from .models import GuideStep, UserGuide + + +class GuideStepInline(admin.TabularInline): + model = GuideStep + extra = 1 + formfield_overrides = { + models.TextField: {"widget": CKEditorWidget(config_name="frontuser")}, + } + + +@admin.register(UserGuide, site=admin_site) +class UserGuideAdmin(admin.ModelAdmin): + list_display = [ + "id", + "name", + "description", + "created_at", + ] + + inlines = [GuideStepInline] + + formfield_overrides = { + models.TextField: {"widget": CKEditorWidget(config_name="frontuser")}, + } diff --git a/lemarche/django_shepherd/migrations/0001_initial.py b/lemarche/django_shepherd/migrations/0001_initial.py new file mode 100644 index 000000000..741193bf0 --- /dev/null +++ b/lemarche/django_shepherd/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.13 on 2024-06-26 16:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="UserGuide", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=200, unique=True)), + ("description", models.TextField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name="GuideStep", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(max_length=200)), + ("text", models.TextField()), + ("element", models.CharField(max_length=200)), + ( + "position", + models.CharField( + choices=[("top", "Top"), ("bottom", "Bottom"), ("left", "Left"), ("right", "Right")], + max_length=50, + ), + ), + ( + "guide", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="steps", + to="django_shepherd.userguide", + ), + ), + ], + ), + ] diff --git a/lemarche/django_shepherd/migrations/0002_userguide_created_at_userguide_slug_and_more.py b/lemarche/django_shepherd/migrations/0002_userguide_created_at_userguide_slug_and_more.py new file mode 100644 index 000000000..f17301a17 --- /dev/null +++ b/lemarche/django_shepherd/migrations/0002_userguide_created_at_userguide_slug_and_more.py @@ -0,0 +1,67 @@ +# Generated by Django 4.2.13 on 2024-06-28 16:50 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("django_shepherd", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="userguide", + name="created_at", + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name="Date de création"), + ), + migrations.AddField( + model_name="userguide", + name="slug", + field=models.SlugField( + help_text="Identifiant permettant d'identifier le guide en js", + null=True, + unique=True, + verbose_name="Slug (unique)", + ), + ), + migrations.AddField( + model_name="userguide", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Date de modification"), + ), + migrations.AlterField( + model_name="guidestep", + name="element", + field=models.CharField(max_length=200, null=True, verbose_name="Élément css à rattacher"), + ), + migrations.AlterField( + model_name="guidestep", + name="position", + field=models.CharField( + choices=[("top", "Top"), ("bottom", "Bottom"), ("left", "Left"), ("right", "Right")], + max_length=50, + null=True, + ), + ), + migrations.AlterField( + model_name="guidestep", + name="text", + field=models.TextField(verbose_name="Contenu text de l'étape"), + ), + migrations.AlterField( + model_name="guidestep", + name="title", + field=models.CharField(max_length=200, verbose_name="Titre dans la popup"), + ), + migrations.AlterField( + model_name="userguide", + name="description", + field=models.TextField(blank=True, null=True, verbose_name="Description"), + ), + migrations.AlterField( + model_name="userguide", + name="name", + field=models.CharField(max_length=200, unique=True, verbose_name="Nom du Guide"), + ), + ] diff --git a/lemarche/django_shepherd/migrations/__init__.py b/lemarche/django_shepherd/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lemarche/django_shepherd/models.py b/lemarche/django_shepherd/models.py new file mode 100644 index 000000000..a5bf9fbdc --- /dev/null +++ b/lemarche/django_shepherd/models.py @@ -0,0 +1,45 @@ +from django.db import models +from django.template.defaultfilters import slugify +from django.utils import timezone + + +class UserGuide(models.Model): + name = models.CharField("Nom du Guide", max_length=200, unique=True) + description = models.TextField("Description", blank=True, null=True) + slug = models.SlugField( + "Slug (unique)", + max_length=50, + unique=True, + help_text="Identifiant permettant d'identifier le guide en js", + null=True, + ) + created_at = models.DateTimeField("Date de création", default=timezone.now) + updated_at = models.DateTimeField("Date de modification", auto_now=True) + + def set_slug(self): + """ + The slug field should be unique. + """ + if not self.slug: + self.slug = slugify(self.name)[:50] + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + """Generate the slug field before saving.""" + self.set_slug() + super().save(*args, **kwargs) + + +class GuideStep(models.Model): + guide = models.ForeignKey(UserGuide, related_name="steps", on_delete=models.CASCADE) + title = models.CharField("Titre dans la popup", max_length=200) + text = models.TextField("Contenu text de l'étape") + element = models.CharField("Élément css à rattacher", max_length=200, null=True) + position = models.CharField( + max_length=50, choices=[("top", "Top"), ("bottom", "Bottom"), ("left", "Left"), ("right", "Right")], null=True + ) + + def __str__(self): + return self.title diff --git a/lemarche/django_shepherd/static/django_shepherd/user_guide.js b/lemarche/django_shepherd/static/django_shepherd/user_guide.js new file mode 100644 index 000000000..d512e823f --- /dev/null +++ b/lemarche/django_shepherd/static/django_shepherd/user_guide.js @@ -0,0 +1,67 @@ +class UserGuide { + constructor() { + this.tour = new Shepherd.Tour({ + useModalOverlay: true, + defaultStepOptions: { + classes: 'shepherd-theme-arrows', + scrollTo: { + behavior: 'smooth', + block: 'center' + }, + } + }); + } + + init() { + document.addEventListener('startUserGuide', (event) => { + const guideName = event.detail.guideName; + this.startGuide(guideName); + }); + + // Listen to htmx events + document.body.addEventListener('htmx:afterSwap', (event) => { + if (event.detail.target.id === 'guideContainer') { + const guideName = event.detail.target.getAttribute('data-guide-name'); + this.startGuide(guideName); + } + }); + } + + startGuide(guideName) { + fetch(`http://localhost:8000/django_shepherd/get_guide/${guideName}/`) + .then(response => response.json()) + .then(data => { + this.tour.steps = []; // Clear previous steps + data.steps.forEach((step, index) => { + const isFirstStep = index === 0; + const isLastStep = index === data.steps.length - 1; + this.tour.addStep({ + title: step.title, + text: step.text, + attachTo: { + element: step.element, + on: step.position + }, + buttons: [ + { + text: 'Ignorer', + action: this.tour.cancel, + classes: 'btn btn-secondary' + }, + !isFirstStep && { + text: 'Précédent', + action: this.tour.back, + classes: 'btn btn-primary' + }, + { + text: isLastStep ? 'Finir' : 'Suivant', + action: this.tour.next, + classes: 'btn ' + (isLastStep ? 'btn-success' : 'btn-primary') + } + ].filter(Boolean) + }); + }); + this.tour.start(); + }); + } +} \ No newline at end of file diff --git a/lemarche/django_shepherd/urls.py b/lemarche/django_shepherd/urls.py new file mode 100644 index 000000000..3b609984a --- /dev/null +++ b/lemarche/django_shepherd/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import UserGuideView + + +urlpatterns = [ + path("get_guide//", UserGuideView.as_view(), name="get_guide"), +] diff --git a/lemarche/django_shepherd/views.py b/lemarche/django_shepherd/views.py new file mode 100644 index 000000000..7c91e0fe4 --- /dev/null +++ b/lemarche/django_shepherd/views.py @@ -0,0 +1,20 @@ +from django.http import JsonResponse +from django.views import View + +from .models import UserGuide + + +class UserGuideView(View): + def get(self, request, guide_name): + guide = UserGuide.objects.get(name=guide_name) + steps = guide.steps.all() + steps_data = [ + { + "title": step.title, + "text": step.text, + "element": step.element, + "position": step.position, + } + for step in steps + ] + return JsonResponse({"steps": steps_data}) diff --git a/lemarche/static/itou_marche/utils.scss b/lemarche/static/itou_marche/utils.scss index e5dc55d38..bb30482fc 100644 --- a/lemarche/static/itou_marche/utils.scss +++ b/lemarche/static/itou_marche/utils.scss @@ -42,3 +42,84 @@ color: $green; font-weight: bold; } + +/* custom_theme.css */ + +/* Shepherd.js custom styles */ +.shepherd-element { + z-index: 1050; /* Ensure Shepherd elements are above Bootstrap modals */ + max-width: 600px !important; +} + +.shepherd-content { + background-color: #fff; + border: 1px solid #dee2e6; + border-radius: 0.25rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + padding: 1.5rem; +} + +.shepherd-header { + font-size: 1.25rem; + margin-bottom: 0.75rem; +} + +.shepherd-text { + font-size: 1rem; + color: #212529; +} + +.shepherd-footer { + margin-top: 1rem; + display: flex; + justify-content: flex-end; +} + +.shepherd-button { + background-color: #007bff; + border: none; + color: #fff; + padding: 0.375rem 0.75rem; + font-size: 1rem; + border-radius: 0.25rem; + cursor: pointer; + transition: background-color 0.15s ease-in-out; +} + +.shepherd-button:hover { + background-color: #0056b3; +} + +.shepherd-button-secondary { + background-color: #6c757d; +} + +.shepherd-button-secondary:hover { + background-color: #5a6268; +} + +/* Responsive adjustments */ +@media (max-width: 576px) { + .shepherd-content { + padding: 1rem; + font-size: 0.875rem; + } + + .shepherd-header { + font-size: 1rem; + } + + .shepherd-text { + font-size: 0.875rem; + } + + .shepherd-footer { + flex-direction: column; + align-items: stretch; + } + + .shepherd-button { + margin-top: 0.5rem; + width: 100%; + } +} diff --git a/lemarche/templates/siaes/search_results.html b/lemarche/templates/siaes/search_results.html index 1d83c7c4d..d8cd908ec 100644 --- a/lemarche/templates/siaes/search_results.html +++ b/lemarche/templates/siaes/search_results.html @@ -325,6 +325,23 @@

+ + {% if user.is_authenticated %} {% endif %}