From d2adda383e4c426f7472d60ffcf8c9acc263daf2 Mon Sep 17 00:00:00 2001 From: Mandel Dashboard Date: Thu, 19 Mar 2026 22:46:16 +0000 Subject: [PATCH 01/45] Enable ocyan.plugin.wordspinner --- mandelstudio/ocyan.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mandelstudio/ocyan.json b/mandelstudio/ocyan.json index e078fac..30cfaae 100644 --- a/mandelstudio/ocyan.json +++ b/mandelstudio/ocyan.json @@ -32,7 +32,8 @@ "ocyan.plugin.wagtail_content_page", "ocyan.plugin.wagtail_forms", "ocyan.plugin.wagtail_oscar_integration", - "ocyan.plugin.roadrunner_highlight_slider" + "ocyan.plugin.roadrunner_highlight_slider", + "ocyan.plugin.wordspinner" ], "settings": { "cookie_jar": { From ca06ab88ba5d171e9bb65dbca9c72eeb0d0ade98 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Mon, 23 Mar 2026 00:28:52 +0100 Subject: [PATCH 02/45] Polish footer UI and localize demo-request form endpoints --- .../blocks/demo_request/variants/inline.html | 58 +++++++ .../demo_request/variants/modal-trigger.html | 94 +++++++++++ .../blocks/demo_request/variants/split.html | 156 ++++++++++++++++++ .../blocks/pagelist_block.html | 11 ++ .../templates/oxyan/partials/footer.html | 141 ++++++++++++++++ 5 files changed, 460 insertions(+) create mode 100644 mandelstudio/templates/highbiza_saas/blocks/demo_request/variants/inline.html create mode 100644 mandelstudio/templates/highbiza_saas/blocks/demo_request/variants/modal-trigger.html create mode 100644 mandelstudio/templates/highbiza_saas/blocks/demo_request/variants/split.html create mode 100644 mandelstudio/templates/ocyan_plugin_wagtail/blocks/pagelist_block.html create mode 100644 mandelstudio/templates/oxyan/partials/footer.html diff --git a/mandelstudio/templates/highbiza_saas/blocks/demo_request/variants/inline.html b/mandelstudio/templates/highbiza_saas/blocks/demo_request/variants/inline.html new file mode 100644 index 0000000..eca3378 --- /dev/null +++ b/mandelstudio/templates/highbiza_saas/blocks/demo_request/variants/inline.html @@ -0,0 +1,58 @@ +{% load wagtailimages_tags %} +
+
+
+

{{ self.section_title }}

+ {% if self.section_subtitle %} +
{{ self.section_subtitle }}
+ {% endif %} +
+ +
+ {% csrf_token %} +
+ {% for field in self.form_fields %} +
+ + {% if field.field_type == 'message' %} + + {% elif field.field_type == 'company_size' %} + + {% else %} + + {% endif %} +
+ {% endfor %} +
+ + + + {% if self.privacy_text %} +
{{ self.privacy_text }}
+ {% endif %} +
+
+
diff --git a/mandelstudio/templates/highbiza_saas/blocks/demo_request/variants/modal-trigger.html b/mandelstudio/templates/highbiza_saas/blocks/demo_request/variants/modal-trigger.html new file mode 100644 index 0000000..c4560a0 --- /dev/null +++ b/mandelstudio/templates/highbiza_saas/blocks/demo_request/variants/modal-trigger.html @@ -0,0 +1,94 @@ +{% load wagtailimages_tags %} +
+
+
+

{{ self.section_title }}

+ {% if self.section_subtitle %} +
{{ self.section_subtitle }}
+ {% endif %} + + +
+
+ + + +
+ diff --git a/mandelstudio/templates/highbiza_saas/blocks/demo_request/variants/split.html b/mandelstudio/templates/highbiza_saas/blocks/demo_request/variants/split.html new file mode 100644 index 0000000..2323a3f --- /dev/null +++ b/mandelstudio/templates/highbiza_saas/blocks/demo_request/variants/split.html @@ -0,0 +1,156 @@ +{% load wagtailimages_tags %} +
+ +
+
+

{{ self.section_title }}

+ {% if self.section_subtitle %} +
{{ self.section_subtitle }}
+ {% endif %} + + {% if self.benefits_title or self.benefits %} +
+ {% if self.benefits_title %} +

{{ self.benefits_title }}

+ {% endif %} + {% if self.benefits %} +
    + {% for benefit in self.benefits %} +
  • + + + + {{ benefit }} +
  • + {% endfor %} +
+ {% endif %} +
+ {% endif %} + + {% if self.side_image %} +
+ {% image self.side_image width-640 class="saas-demo__image" %} +
+ {% endif %} +
+ +
+
+ {% csrf_token %} +
+ {% for field in self.form_fields %} +
+ + {% if field.field_type == 'message' %} + + {% elif field.field_type == 'company_size' %} + + {% else %} + + {% endif %} +
+ {% endfor %} +
+ + + + {% if self.privacy_text %} +
{{ self.privacy_text }}
+ {% endif %} +
+
+
+
diff --git a/mandelstudio/templates/ocyan_plugin_wagtail/blocks/pagelist_block.html b/mandelstudio/templates/ocyan_plugin_wagtail/blocks/pagelist_block.html new file mode 100644 index 0000000..386e151 --- /dev/null +++ b/mandelstudio/templates/ocyan_plugin_wagtail/blocks/pagelist_block.html @@ -0,0 +1,11 @@ +{% load wagtailcore_tags %} +{% if self.heading %}{% endif %} +{% if children %} + +{% endif %} diff --git a/mandelstudio/templates/oxyan/partials/footer.html b/mandelstudio/templates/oxyan/partials/footer.html new file mode 100644 index 0000000..5a5712a --- /dev/null +++ b/mandelstudio/templates/oxyan/partials/footer.html @@ -0,0 +1,141 @@ +{% load staticfiles %} +{% load wagtailcore_tags wagtailimages_tags wagtailsettings_tags cache %} +{% get_settings %} + +{% cache 300 footer_menu LANGUAGE_CODE request.site %} + + + +{% endcache %} From 643aca26d018bed1460aafc1b67c59f4d9dfafea Mon Sep 17 00:00:00 2001 From: MandelBot Date: Tue, 24 Mar 2026 21:47:03 +0000 Subject: [PATCH 03/45] Localize shared marketing templates by locale --- .../templates/carbasa/headers/mega.html | 40 +++++++++++++++++++ .../engine/pages/base_home_page.html | 17 +++++++- .../engine/pages/base_standard_page.html | 17 +++++++- .../templates/engine/pages/engine_page.html | 17 +++++++- .../blocks/features/variants/grid.html | 38 ++++++++++++++++++ .../integrations/variants/logo-grid.html | 30 ++++++++++++++ .../templates/webshop/mega_dropdown.html | 24 +++++++++++ 7 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 mandelstudio/templates/carbasa/headers/mega.html create mode 100644 mandelstudio/templates/highbiza_saas/blocks/features/variants/grid.html create mode 100644 mandelstudio/templates/highbiza_saas/blocks/integrations/variants/logo-grid.html create mode 100644 mandelstudio/templates/webshop/mega_dropdown.html diff --git a/mandelstudio/templates/carbasa/headers/mega.html b/mandelstudio/templates/carbasa/headers/mega.html new file mode 100644 index 0000000..1e9ad5a --- /dev/null +++ b/mandelstudio/templates/carbasa/headers/mega.html @@ -0,0 +1,40 @@ +{% extends "carbasa/headers/header.html" %} +{% load i18n oxyan category_tags ocyan_main ocyanjson wagtailsettings_tags %} + +{% block nav %} +{% ocyanjson "theme" "menu_depth" 1 as menu_depth %} + +{% endblock %} diff --git a/mandelstudio/templates/engine/pages/base_home_page.html b/mandelstudio/templates/engine/pages/base_home_page.html index e016157..745eaae 100644 --- a/mandelstudio/templates/engine/pages/base_home_page.html +++ b/mandelstudio/templates/engine/pages/base_home_page.html @@ -1,5 +1,5 @@ {% extends "layout.html" %} -{% load wagtailcore_tags oxyan static string_filters %} +{% load wagtailcore_tags oxyan static string_filters i18n %} {% block extrahead %} {{ block.super }} @@ -15,11 +15,24 @@ {% include "engine/partials/tech_theme_overrides.html" %} {% include "engine/partials/travel_theme_overrides.html" %} {% include "engine/partials/saas_theme_overrides.html" %} + {% endblock %} {% block layout %} - Ga naar inhoud + {% if request.LANGUAGE_CODE == 'ru' %}Перейти к содержанию{% elif request.LANGUAGE_CODE == 'de' %}Zum Inhalt springen{% elif request.LANGUAGE_CODE == 'fr' %}Aller au contenu{% elif request.LANGUAGE_CODE == 'es' %}Ir al contenido{% elif request.LANGUAGE_CODE == 'it' %}Vai al contenuto{% elif request.LANGUAGE_CODE == 'pt' %}Ir para o conteúdo{% elif request.LANGUAGE_CODE == 'nl' %}Ga naar inhoud{% else %}Skip to content{% endif %} {% include_header header_template|default:"engine/partials/header.html" %}
diff --git a/mandelstudio/templates/engine/pages/base_standard_page.html b/mandelstudio/templates/engine/pages/base_standard_page.html index 4cf66eb..f003509 100644 --- a/mandelstudio/templates/engine/pages/base_standard_page.html +++ b/mandelstudio/templates/engine/pages/base_standard_page.html @@ -1,5 +1,5 @@ {% extends "layout.html" %} -{% load wagtailcore_tags oxyan static string_filters %} +{% load wagtailcore_tags oxyan static string_filters i18n %} {% block extrahead %} {{ block.super }} @@ -15,11 +15,24 @@ {% include "engine/partials/tech_theme_overrides.html" %} {% include "engine/partials/travel_theme_overrides.html" %} {% include "engine/partials/saas_theme_overrides.html" %} + {% endblock %} {% block layout %} - Ga naar inhoud + {% if request.LANGUAGE_CODE == 'ru' %}Перейти к содержанию{% elif request.LANGUAGE_CODE == 'de' %}Zum Inhalt springen{% elif request.LANGUAGE_CODE == 'fr' %}Aller au contenu{% elif request.LANGUAGE_CODE == 'es' %}Ir al contenido{% elif request.LANGUAGE_CODE == 'it' %}Vai al contenuto{% elif request.LANGUAGE_CODE == 'pt' %}Ir para o conteúdo{% elif request.LANGUAGE_CODE == 'nl' %}Ga naar inhoud{% else %}Skip to content{% endif %} {% include_header header_template|default:"engine/partials/header.html" %}
diff --git a/mandelstudio/templates/engine/pages/engine_page.html b/mandelstudio/templates/engine/pages/engine_page.html index e016157..745eaae 100644 --- a/mandelstudio/templates/engine/pages/engine_page.html +++ b/mandelstudio/templates/engine/pages/engine_page.html @@ -1,5 +1,5 @@ {% extends "layout.html" %} -{% load wagtailcore_tags oxyan static string_filters %} +{% load wagtailcore_tags oxyan static string_filters i18n %} {% block extrahead %} {{ block.super }} @@ -15,11 +15,24 @@ {% include "engine/partials/tech_theme_overrides.html" %} {% include "engine/partials/travel_theme_overrides.html" %} {% include "engine/partials/saas_theme_overrides.html" %} + {% endblock %} {% block layout %} - Ga naar inhoud + {% if request.LANGUAGE_CODE == 'ru' %}Перейти к содержанию{% elif request.LANGUAGE_CODE == 'de' %}Zum Inhalt springen{% elif request.LANGUAGE_CODE == 'fr' %}Aller au contenu{% elif request.LANGUAGE_CODE == 'es' %}Ir al contenido{% elif request.LANGUAGE_CODE == 'it' %}Vai al contenuto{% elif request.LANGUAGE_CODE == 'pt' %}Ir para o conteúdo{% elif request.LANGUAGE_CODE == 'nl' %}Ga naar inhoud{% else %}Skip to content{% endif %} {% include_header header_template|default:"engine/partials/header.html" %}
diff --git a/mandelstudio/templates/highbiza_saas/blocks/features/variants/grid.html b/mandelstudio/templates/highbiza_saas/blocks/features/variants/grid.html new file mode 100644 index 0000000..e3f5c2c --- /dev/null +++ b/mandelstudio/templates/highbiza_saas/blocks/features/variants/grid.html @@ -0,0 +1,38 @@ +{% load wagtailimages_tags %} +
+
+
+

{{ self.section_title }}

+ {% if self.section_subtitle %} +
{{ self.section_subtitle }}
+ {% endif %} +
+
+ {% for feature in self.features %} +
+ {% if feature.highlight == 'new' %} + {% if request.LANGUAGE_CODE == 'ru' %}Ново{% elif request.LANGUAGE_CODE == 'de' %}Neu{% elif request.LANGUAGE_CODE == 'fr' %}Nouveau{% elif request.LANGUAGE_CODE == 'es' %}Nuevo{% elif request.LANGUAGE_CODE == 'it' %}Nuovo{% elif request.LANGUAGE_CODE == 'pt' %}Novo{% else %}New{% endif %} + {% endif %} +
+ {% if feature.icon_image %} + {% image feature.icon_image width-64 class="saas-features__icon-img" %} + {% elif feature.icon %} + + {% else %} +
+ {% endif %} +
+

{{ feature.title }}

+ {% if feature.description %}
{{ feature.description }}
{% endif %} + {% if feature.link_text and feature.link_url %} + + {{ feature.link_text }} + + + {% endif %} +
+ {% endfor %} +
+
+
diff --git a/mandelstudio/templates/highbiza_saas/blocks/integrations/variants/logo-grid.html b/mandelstudio/templates/highbiza_saas/blocks/integrations/variants/logo-grid.html new file mode 100644 index 0000000..f5a08cf --- /dev/null +++ b/mandelstudio/templates/highbiza_saas/blocks/integrations/variants/logo-grid.html @@ -0,0 +1,30 @@ +{% load wagtailimages_tags %} +
+
+
+

{{ self.section_title }}

+ {% if self.section_subtitle %}
{{ self.section_subtitle }}
{% endif %} + {% if self.integration_count %} + {{ self.integration_count }} {% if request.LANGUAGE_CODE == 'ru' %}интеграции{% elif request.LANGUAGE_CODE == 'de' %}Integrationen{% elif request.LANGUAGE_CODE == 'fr' %}intégrations{% elif request.LANGUAGE_CODE == 'es' %}integraciones{% elif request.LANGUAGE_CODE == 'it' %}integrazioni{% elif request.LANGUAGE_CODE == 'pt' %}integrações{% elif request.LANGUAGE_CODE == 'nl' %}integraties{% else %}integrations{% endif %} + {% endif %} +
+
+ {% for integration in self.integrations %} + + {% endfor %} +
+ {% if self.cta_text and self.cta_url %} + + {% endif %} +
+
diff --git a/mandelstudio/templates/webshop/mega_dropdown.html b/mandelstudio/templates/webshop/mega_dropdown.html new file mode 100644 index 0000000..861099f --- /dev/null +++ b/mandelstudio/templates/webshop/mega_dropdown.html @@ -0,0 +1,24 @@ +{% load i18n ocyan_thumbnail %} +{% if menu_items %} +{% for menu_item in menu_items %} +{% with category_icon=menu_item.category.icons.first %} +{% if menu_item.has_children %} + +{% endfor %} +{% endwith %} +{% endfor %} +{% endif %} From e3bafd3a73d6e685469266c2791187cd2b9bcd16 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Sun, 29 Mar 2026 20:49:42 +0200 Subject: [PATCH 04/45] Add multilingual audit CI pipeline + extract mandelblog_content_guard --- Jenkinsfile | 44 +- Jenkinsfile.multilingual-nightly | 62 +++ mandelblog_content_guard/__init__.py | 1 + mandelblog_content_guard/agents/__init__.py | 25 + mandelblog_content_guard/agents/base.py | 187 ++++++++ mandelblog_content_guard/agents/de.py | 23 + mandelblog_content_guard/agents/en.py | 34 ++ mandelblog_content_guard/agents/es.py | 43 ++ mandelblog_content_guard/agents/fr.py | 66 +++ mandelblog_content_guard/agents/it.py | 42 ++ mandelblog_content_guard/agents/nl.py | 20 + mandelblog_content_guard/agents/pt.py | 111 +++++ mandelblog_content_guard/agents/ru.py | 39 ++ mandelblog_content_guard/ai.py | 16 + mandelblog_content_guard/apps.py | 10 + .../extractors/__init__.py | 3 + .../extractors/visible_text.py | 85 ++++ mandelblog_content_guard/hooks.py | 95 ++++ .../management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/audit_locales.py | 163 +++++++ mandelblog_content_guard/mixins.py | 19 + .../normalizers/__init__.py | 15 + mandelblog_content_guard/normalizers/de.py | 58 +++ mandelblog_content_guard/normalizers/en.py | 28 ++ mandelblog_content_guard/normalizers/es.py | 31 ++ mandelblog_content_guard/normalizers/it.py | 24 + mandelblog_content_guard/normalizers/nl.py | 15 + mandelblog_content_guard/normalizers/ru.py | 24 + mandelblog_content_guard/settings.py | 79 +++ mandelblog_content_guard/signals.py | 26 + mandelblog_content_guard/system_strings.py | 368 ++++++++++++++ mandelblog_content_guard/tests.py | 56 +++ mandelblog_content_guard/types.py | 65 +++ .../validators/__init__.py | 0 .../validators/multilingual.py | 452 ++++++++++++++++++ .../validators/rules/__init__.py | 0 .../validators/rules/cta.py | 146 ++++++ .../validators/rules/forms.py | 21 + .../validators/rules/language.py | 43 ++ .../validators/rules/patterns.py | 269 +++++++++++ mandelstudio/apps.py | 7 + mandelstudio/content_guard/__init__.py | 1 + mandelstudio/content_guard/agents/__init__.py | 1 + mandelstudio/content_guard/agents/base.py | 1 + mandelstudio/content_guard/agents/de.py | 1 + mandelstudio/content_guard/agents/en.py | 1 + mandelstudio/content_guard/agents/es.py | 1 + mandelstudio/content_guard/agents/fr.py | 1 + mandelstudio/content_guard/agents/it.py | 1 + mandelstudio/content_guard/agents/nl.py | 1 + mandelstudio/content_guard/agents/pt.py | 1 + mandelstudio/content_guard/agents/ru.py | 1 + mandelstudio/content_guard/ai.py | 1 + mandelstudio/content_guard/hooks.py | 1 + mandelstudio/content_guard/mixins.py | 1 + .../content_guard/normalizers/__init__.py | 1 + mandelstudio/content_guard/normalizers/de.py | 1 + mandelstudio/content_guard/normalizers/en.py | 1 + mandelstudio/content_guard/normalizers/es.py | 1 + mandelstudio/content_guard/normalizers/it.py | 1 + mandelstudio/content_guard/normalizers/nl.py | 1 + mandelstudio/content_guard/normalizers/ru.py | 1 + mandelstudio/content_guard/settings.py | 1 + mandelstudio/content_guard/signals.py | 1 + mandelstudio/content_guard/system_strings.py | 1 + mandelstudio/content_guard/types.py | 1 + .../content_guard/validators/__init__.py | 1 + .../content_guard/validators/multilingual.py | 1 + .../validators/rules/__init__.py | 1 + .../content_guard/validators/rules/cta.py | 1 + .../content_guard/validators/rules/forms.py | 1 + .../validators/rules/language.py | 1 + .../validators/rules/patterns.py | 1 + mandelstudio/main.py | 6 + mandelstudio/management/__init__.py | 0 mandelstudio/management/commands/__init__.py | 0 .../management/commands/audit_locales.py | 1 + mandelstudio/models.py | 101 ++++ mandelstudio/settings/base.py | 17 +- .../template_engine/0001_initial.py | 2 + .../0002_templateenginesitesettings.py | 2 + ...03_templateenginesitesettings_nav_items.py | 2 + ...mepage_body_alter_basestandardpage_body.py | 2 + ...inesitesettings_header_variant_and_more.py | 2 + ...nginesitesettings_footer_dynamic_fields.py | 2 + ...ateenginesitesettings_header_cta_fields.py | 2 + ...tesettings_footer_bottom_links_and_more.py | 2 + ...dy_alter_basestandardpage_body_and_more.py | 2 + .../0010_enginepage_and_more.py | 2 + ...dy_alter_basestandardpage_body_and_more.py | 2 + ...dy_alter_basestandardpage_body_and_more.py | 2 + .../template_engine/0013_engineblockpreset.py | 2 + ...dy_alter_basestandardpage_body_and_more.py | 2 + ...0015_ensure_templateenginenavitem_table.py | 21 + ...dy_alter_basestandardpage_body_and_more.py | 2 + .../template_engine/__init__.py | 0 mandelstudio/tests/__init__.py | 1 + mandelstudio/tests/test_content_guard.py | 181 +++++++ mandelstudio/validators/__init__.py | 0 mandelstudio/validators/multilingual.py | 1 + mandelstudio/wagtail_hooks.py | 1 + scripts/multilingual_audit_ci.py | 99 ++++ scripts/run_remote_multilingual_audit.sh | 72 +++ 104 files changed, 3372 insertions(+), 6 deletions(-) create mode 100644 Jenkinsfile.multilingual-nightly create mode 100644 mandelblog_content_guard/__init__.py create mode 100644 mandelblog_content_guard/agents/__init__.py create mode 100644 mandelblog_content_guard/agents/base.py create mode 100644 mandelblog_content_guard/agents/de.py create mode 100644 mandelblog_content_guard/agents/en.py create mode 100644 mandelblog_content_guard/agents/es.py create mode 100644 mandelblog_content_guard/agents/fr.py create mode 100644 mandelblog_content_guard/agents/it.py create mode 100644 mandelblog_content_guard/agents/nl.py create mode 100644 mandelblog_content_guard/agents/pt.py create mode 100644 mandelblog_content_guard/agents/ru.py create mode 100644 mandelblog_content_guard/ai.py create mode 100644 mandelblog_content_guard/apps.py create mode 100644 mandelblog_content_guard/extractors/__init__.py create mode 100644 mandelblog_content_guard/extractors/visible_text.py create mode 100644 mandelblog_content_guard/hooks.py create mode 100644 mandelblog_content_guard/management/__init__.py create mode 100644 mandelblog_content_guard/management/commands/__init__.py create mode 100644 mandelblog_content_guard/management/commands/audit_locales.py create mode 100644 mandelblog_content_guard/mixins.py create mode 100644 mandelblog_content_guard/normalizers/__init__.py create mode 100644 mandelblog_content_guard/normalizers/de.py create mode 100644 mandelblog_content_guard/normalizers/en.py create mode 100644 mandelblog_content_guard/normalizers/es.py create mode 100644 mandelblog_content_guard/normalizers/it.py create mode 100644 mandelblog_content_guard/normalizers/nl.py create mode 100644 mandelblog_content_guard/normalizers/ru.py create mode 100644 mandelblog_content_guard/settings.py create mode 100644 mandelblog_content_guard/signals.py create mode 100644 mandelblog_content_guard/system_strings.py create mode 100644 mandelblog_content_guard/tests.py create mode 100644 mandelblog_content_guard/types.py create mode 100644 mandelblog_content_guard/validators/__init__.py create mode 100644 mandelblog_content_guard/validators/multilingual.py create mode 100644 mandelblog_content_guard/validators/rules/__init__.py create mode 100644 mandelblog_content_guard/validators/rules/cta.py create mode 100644 mandelblog_content_guard/validators/rules/forms.py create mode 100644 mandelblog_content_guard/validators/rules/language.py create mode 100644 mandelblog_content_guard/validators/rules/patterns.py create mode 100644 mandelstudio/apps.py create mode 100644 mandelstudio/content_guard/__init__.py create mode 100644 mandelstudio/content_guard/agents/__init__.py create mode 100644 mandelstudio/content_guard/agents/base.py create mode 100644 mandelstudio/content_guard/agents/de.py create mode 100644 mandelstudio/content_guard/agents/en.py create mode 100644 mandelstudio/content_guard/agents/es.py create mode 100644 mandelstudio/content_guard/agents/fr.py create mode 100644 mandelstudio/content_guard/agents/it.py create mode 100644 mandelstudio/content_guard/agents/nl.py create mode 100644 mandelstudio/content_guard/agents/pt.py create mode 100644 mandelstudio/content_guard/agents/ru.py create mode 100644 mandelstudio/content_guard/ai.py create mode 100644 mandelstudio/content_guard/hooks.py create mode 100644 mandelstudio/content_guard/mixins.py create mode 100644 mandelstudio/content_guard/normalizers/__init__.py create mode 100644 mandelstudio/content_guard/normalizers/de.py create mode 100644 mandelstudio/content_guard/normalizers/en.py create mode 100644 mandelstudio/content_guard/normalizers/es.py create mode 100644 mandelstudio/content_guard/normalizers/it.py create mode 100644 mandelstudio/content_guard/normalizers/nl.py create mode 100644 mandelstudio/content_guard/normalizers/ru.py create mode 100644 mandelstudio/content_guard/settings.py create mode 100644 mandelstudio/content_guard/signals.py create mode 100644 mandelstudio/content_guard/system_strings.py create mode 100644 mandelstudio/content_guard/types.py create mode 100644 mandelstudio/content_guard/validators/__init__.py create mode 100644 mandelstudio/content_guard/validators/multilingual.py create mode 100644 mandelstudio/content_guard/validators/rules/__init__.py create mode 100644 mandelstudio/content_guard/validators/rules/cta.py create mode 100644 mandelstudio/content_guard/validators/rules/forms.py create mode 100644 mandelstudio/content_guard/validators/rules/language.py create mode 100644 mandelstudio/content_guard/validators/rules/patterns.py create mode 100644 mandelstudio/management/__init__.py create mode 100644 mandelstudio/management/commands/__init__.py create mode 100644 mandelstudio/management/commands/audit_locales.py create mode 100644 mandelstudio/models.py create mode 100644 mandelstudio/test_migrations/template_engine/0001_initial.py create mode 100644 mandelstudio/test_migrations/template_engine/0002_templateenginesitesettings.py create mode 100644 mandelstudio/test_migrations/template_engine/0003_templateenginesitesettings_nav_items.py create mode 100644 mandelstudio/test_migrations/template_engine/0004_alter_basehomepage_body_alter_basestandardpage_body.py create mode 100644 mandelstudio/test_migrations/template_engine/0005_templateenginesitesettings_header_variant_and_more.py create mode 100644 mandelstudio/test_migrations/template_engine/0006_templateenginesitesettings_footer_dynamic_fields.py create mode 100644 mandelstudio/test_migrations/template_engine/0007_templateenginesitesettings_header_cta_fields.py create mode 100644 mandelstudio/test_migrations/template_engine/0008_templateenginesitesettings_footer_bottom_links_and_more.py create mode 100644 mandelstudio/test_migrations/template_engine/0009_alter_basehomepage_body_alter_basestandardpage_body_and_more.py create mode 100644 mandelstudio/test_migrations/template_engine/0010_enginepage_and_more.py create mode 100644 mandelstudio/test_migrations/template_engine/0011_alter_basehomepage_body_alter_basestandardpage_body_and_more.py create mode 100644 mandelstudio/test_migrations/template_engine/0012_alter_basehomepage_body_alter_basestandardpage_body_and_more.py create mode 100644 mandelstudio/test_migrations/template_engine/0013_engineblockpreset.py create mode 100644 mandelstudio/test_migrations/template_engine/0014_alter_basehomepage_body_alter_basestandardpage_body_and_more.py create mode 100644 mandelstudio/test_migrations/template_engine/0015_ensure_templateenginenavitem_table.py create mode 100644 mandelstudio/test_migrations/template_engine/0016_alter_basehomepage_body_alter_basestandardpage_body_and_more.py create mode 100644 mandelstudio/test_migrations/template_engine/__init__.py create mode 100644 mandelstudio/tests/__init__.py create mode 100644 mandelstudio/tests/test_content_guard.py create mode 100644 mandelstudio/validators/__init__.py create mode 100644 mandelstudio/validators/multilingual.py create mode 100644 mandelstudio/wagtail_hooks.py create mode 100755 scripts/multilingual_audit_ci.py create mode 100755 scripts/run_remote_multilingual_audit.sh diff --git a/Jenkinsfile b/Jenkinsfile index 87a8f49..4c58ae1 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -9,6 +9,10 @@ pipeline { environment { PYENVPIPELINE_VIRTUALENV = '1' GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new' + STAGING_AUDIT_HOST = 'root@49.12.204.96' + STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio' + STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py' + STAGING_AUDIT_SSH_CREDENTIALS_ID = 'staging-root-ssh' } stages { @@ -74,7 +78,7 @@ pipeline { steps { sh ''' . .venv/bin/activate - python -m compileall -q setup.py mandelstudio + python -m compileall -q setup.py mandelstudio mandelblog_content_guard ''' } post { @@ -86,6 +90,40 @@ pipeline { } } } + stage('Deploy Staging') { + steps { + echo 'Triggering staging deploy for mandelstudio after successful CI build.' + build job: 'deploy-project-stg', + wait: true, + propagate: true, + parameters: [string(name: 'PROJECT_NAME', value: 'mandelstudio')] + } + } + stage('Post-Deploy Multilingual Audit') { + options { + timeout(time: 10, unit: 'MINUTES') + } + steps { + sh 'mkdir -p artifacts' + withCredentials([sshUserPrivateKey(credentialsId: env.STAGING_AUDIT_SSH_CREDENTIALS_ID, keyFileVariable: 'STAGING_SSH_KEYFILE')]) { + sh './scripts/run_remote_multilingual_audit.sh' + } + script { + int status = sh(script: 'python3 scripts/multilingual_audit_ci.py --json artifacts/multilingual-audit.json', returnStatus: true) + if (status == 2) { + error('Block-level multilingual issues detected or audit execution failed.') + } + if (status == 1) { + unstable('Warn-level multilingual issues detected.') + } + } + } + post { + always { + archiveArtifacts artifacts: 'artifacts/multilingual-audit.json', onlyIfSuccessful: false + } + } + } } post { always { @@ -97,10 +135,6 @@ pipeline { . .venv/bin/activate pip install coverage ''' - echo 'Triggering staging deploy for mandelstudio after successful CI build.' - build job: 'deploy-project-stg', - wait: false, - parameters: [string(name: 'PROJECT_NAME', value: 'mandelstudio')] } failure { emailext subject: "JENKINS-NOTIFICATION: ${currentBuild.currentResult}: Job '${env.JOB_NAME} #${env.BUILD_NUMBER}'", diff --git a/Jenkinsfile.multilingual-nightly b/Jenkinsfile.multilingual-nightly new file mode 100644 index 0000000..0338466 --- /dev/null +++ b/Jenkinsfile.multilingual-nightly @@ -0,0 +1,62 @@ +#!/usr/bin/env groovy + +pipeline { + agent { label 'external_pool' } + triggers { + cron('H 2 * * *') + } + options { + disableConcurrentBuilds() + skipDefaultCheckout(true) + } + environment { + STAGING_AUDIT_HOST = 'root@49.12.204.96' + STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio' + STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py' + STAGING_AUDIT_SSH_CREDENTIALS_ID = 'staging-root-ssh' + } + stages { + stage('Checkout') { + steps { + withCredentials([sshUserPrivateKey(credentialsId: 'gitea-ssh', keyFileVariable: 'GIT_KEYFILE')]) { + sh ''' + export GIT_SSH_COMMAND="ssh -i $GIT_KEYFILE -o StrictHostKeyChecking=accept-new" + if [ -d .git ]; then + git remote set-url origin ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git + git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master + else + git clone ssh://git@git.mandelblog.com:2222/salt/mandelstudio.git . + git fetch --tags --force --progress origin +refs/heads/master:refs/remotes/origin/master + fi + git checkout -f refs/remotes/origin/master + ''' + } + } + } + stage('Nightly Multilingual Audit') { + options { + timeout(time: 10, unit: 'MINUTES') + } + steps { + sh 'mkdir -p artifacts && [ -f artifacts/multilingual-audit.json ] && cp artifacts/multilingual-audit.json artifacts/previous-multilingual-audit.json || true' + withCredentials([sshUserPrivateKey(credentialsId: env.STAGING_AUDIT_SSH_CREDENTIALS_ID, keyFileVariable: 'STAGING_SSH_KEYFILE')]) { + sh './scripts/run_remote_multilingual_audit.sh' + } + script { + int status = sh(script: 'python3 scripts/multilingual_audit_ci.py --json artifacts/multilingual-audit.json --previous-json artifacts/previous-multilingual-audit.json', returnStatus: true) + if (status == 2) { + error('Block-level multilingual issues detected or audit execution failed.') + } + if (status == 1) { + unstable('Warn-level multilingual issues detected.') + } + } + } + post { + always { + archiveArtifacts artifacts: 'artifacts/multilingual-audit.json,artifacts/previous-multilingual-audit.json', onlyIfSuccessful: false + } + } + } + } +} diff --git a/mandelblog_content_guard/__init__.py b/mandelblog_content_guard/__init__.py new file mode 100644 index 0000000..83c9665 --- /dev/null +++ b/mandelblog_content_guard/__init__.py @@ -0,0 +1 @@ +default_app_config = "mandelblog_content_guard.apps.MandelblogContentGuardConfig" diff --git a/mandelblog_content_guard/agents/__init__.py b/mandelblog_content_guard/agents/__init__.py new file mode 100644 index 0000000..135405a --- /dev/null +++ b/mandelblog_content_guard/agents/__init__.py @@ -0,0 +1,25 @@ +from .base import BaseLanguageAgent +from .de import GermanAgent +from .en import EnglishAgent +from .es import SpanishAgent +from .fr import FrenchAgent +from .it import ItalianAgent +from .nl import DutchAgent +from .pt import PortugueseAgent +from .ru import RussianAgent + +AGENT_REGISTRY = { + "nl": DutchAgent, + "en": EnglishAgent, + "de": GermanAgent, + "fr": FrenchAgent, + "es": SpanishAgent, + "it": ItalianAgent, + "pt": PortugueseAgent, + "ru": RussianAgent, +} + + +def get_language_agent(locale_code: str) -> BaseLanguageAgent: + agent_class = AGENT_REGISTRY.get(locale_code, BaseLanguageAgent) + return agent_class() diff --git a/mandelblog_content_guard/agents/base.py b/mandelblog_content_guard/agents/base.py new file mode 100644 index 0000000..159c105 --- /dev/null +++ b/mandelblog_content_guard/agents/base.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import re +from collections import defaultdict +from typing import Any + +from django.utils.module_loading import import_string + +from ..settings import get_rewrite_backend + + +class BaseLanguageAgent: + locale = "nl" + tone = "business" + preferred_formality = "neutral" + cta_defaults: dict[str, str] = {} + vocabulary_map: dict[str, str] = {} + contextual_vocabulary_map: dict[str, dict[str, str]] = {} + cleanup_patterns: tuple[tuple[re.Pattern[str], str], ...] = ( + ( + re.compile( + r"""^.*?\bis\s+(?:German|Spanish|French|Italian|Portuguese|Dutch),\s+not\s+Dutch.*?(?::\s*|\"\.\s*)(?P.+?)\"?\.?\s*$""", + re.IGNORECASE, + ), + "{quote}", + ), + ( + re.compile( + r"""^.*?\btranslation\s+from\s+.*?(?::\s*|\"\.\s*)(?P.+?)\"?\.?\s*$""", + re.IGNORECASE, + ), + "{quote}", + ), + ( + re.compile( + r"""^.*?\btraducid[oa]\s+al\s+.*?(?::\s*|\"\.\s*)(?P.+?)\"?\.?\s*$""", + re.IGNORECASE, + ), + "{quote}", + ), + ( + re.compile( + r"""^.*?\bперевод\s+с\s+.*?(?::\s*|\"\.\s*)(?P.+?)\"?\.?\s*$""", + re.IGNORECASE, + ), + "{quote}", + ), + ( + re.compile( + r"""^\s*La\s+entrada\s+\"?(?P.+?)\"?\s+está\s+en\s+alemán.*$""", + re.IGNORECASE, + ), + "{quote}", + ), + ) + + def __init__(self) -> None: + self.backend = self._load_backend() + + def _load_backend(self): + backend_path = get_rewrite_backend() + if not backend_path: + return None + return import_string(backend_path) + + def backend_prompt(self, field_path: str, text: str) -> str: + return ( + f"Rewrite the following {self.locale} website copy for a small-business " + f"website in a natural, professional, sales-driven tone. Preserve meaning, " + f"remove translation artifacts, keep it concise, and do not add commentary.\n" + f"Field: {field_path}\n" + f"Locale: {self.locale}\n" + f"Tone: {self.tone}\n" + f"Formality: {self.preferred_formality}\n" + f"Text: {text}" + ) + + def _contextual_replacements(self, field_path: str) -> dict[str, str]: + lowered = field_path.lower() + replacements: dict[str, str] = {} + for token, mapping in self.contextual_vocabulary_map.items(): + if token in lowered: + replacements.update(mapping) + return replacements + + def post_cleanup_text(self, text: str, field_path: str = "") -> str: + return text + + def _apply_replacements(self, text: str, replacements: dict[str, str]) -> str: + cleaned = text + phrase_replacements = {} + token_replacements = {} + for source, target in replacements.items(): + if not source: + continue + if re.fullmatch(r"[\wÀ-ÿ-]+", source, flags=re.UNICODE): + token_replacements[source] = target + else: + phrase_replacements[source] = target + + for source, target in sorted(phrase_replacements.items(), key=lambda item: len(item[0]), reverse=True): + cleaned = cleaned.replace(source, target) + + for source, target in sorted(token_replacements.items(), key=lambda item: len(item[0]), reverse=True): + pattern = re.compile(rf"(? str: + cleaned = text.strip() + for pattern, replacement in self.cleanup_patterns: + match = pattern.match(cleaned) + if not match: + continue + cleaned = replacement.format(**match.groupdict()).strip() + cleaned = self._apply_replacements(cleaned, self.vocabulary_map) + cleaned = self._apply_replacements(cleaned, self._contextual_replacements(field_path)) + cleaned = self.post_cleanup_text(cleaned, field_path=field_path) + return re.sub(r"\s+", " ", cleaned).strip() + + def normalize_cta(self, text: str, field_path: str = "") -> str: + normalized = self.cleanup_text(text, field_path=field_path) + lowered = normalized.lower() + for keyword, replacement in self.cta_defaults.items(): + if keyword in lowered: + return replacement + return normalized + + def rewrite(self, text: str, field_path: str = "", issues: list[Any] | None = None) -> str: + cleaned = self.cleanup_text(text, field_path=field_path) + lowered_path = field_path.lower() + if any(token in lowered_path for token in ("cta", "button", "link_text", "submit")): + cleaned = self.normalize_cta(cleaned, field_path=field_path) + elif issues and any( + issue.issue_type in {"generic_badge_label", "foreign_ui_label", "weak_marketing_copy", "mixed_locale_heading"} + for issue in issues + ): + cleaned = self.cleanup_text(cleaned, field_path=field_path) + if self.backend: + rewritten = self.backend( + locale=self.locale, + field_path=field_path, + text=cleaned, + prompt=self.backend_prompt(field_path, cleaned), + ) + if isinstance(rewritten, str) and rewritten.strip(): + cleaned = rewritten.strip() + return cleaned + + def process_block(self, block_data: Any, field_path: str = "", issue_map: dict[str, list[Any]] | None = None): + issue_map = issue_map or {} + if isinstance(block_data, dict): + changed = False + output = {} + for key, value in block_data.items(): + child_path = f"{field_path}.{key}" if field_path else str(key) + new_value, child_changed = self.process_block(value, child_path, issue_map) + output[key] = new_value + changed = changed or child_changed + return output, changed + if isinstance(block_data, list): + changed = False + output = [] + for index, value in enumerate(block_data): + child_path = f"{field_path}[{index}]" + new_value, child_changed = self.process_block(value, child_path, issue_map) + output.append(new_value) + changed = changed or child_changed + return output, changed + if isinstance(block_data, str): + issues = issue_map.get(field_path, []) + needs_rewrite = bool(issues) or any( + token in field_path for token in ("cta", "button", "label", "placeholder", "help_text") + ) + if not needs_rewrite: + cleaned = self.cleanup_text(block_data) + return cleaned, cleaned != block_data + rewritten = self.rewrite(block_data, field_path=field_path, issues=issues) + return rewritten, rewritten != block_data + return block_data, False + + def build_issue_map(self, issues: list[Any]) -> dict[str, list[Any]]: + issue_map: dict[str, list[Any]] = defaultdict(list) + for issue in issues: + if issue.field_path: + issue_map[issue.field_path].append(issue) + return issue_map diff --git a/mandelblog_content_guard/agents/de.py b/mandelblog_content_guard/agents/de.py new file mode 100644 index 0000000..7d10697 --- /dev/null +++ b/mandelblog_content_guard/agents/de.py @@ -0,0 +1,23 @@ +from .base import BaseLanguageAgent +from ..normalizers import normalize_de_text +from ..system_strings import build_system_vocabulary + + +class GermanAgent(BaseLanguageAgent): + locale = "de" + tone = "professional and trustworthy" + preferred_formality = "formal Sie" + vocabulary_map = { + **build_system_vocabulary("de", ("transparent_investment",)), + } + cta_defaults = { + "starter": "Starter-Gespräch planen", + "business": "Beratungsgespräch planen", + "support": "Support anfragen", + "service": "Dienstleistungen anzeigen", + "project": "Projekt starten", + "kontakt": "Einführungsgespräch planen", + } + + def post_cleanup_text(self, text: str, field_path: str = "") -> str: + return normalize_de_text(text, field_path=field_path) diff --git a/mandelblog_content_guard/agents/en.py b/mandelblog_content_guard/agents/en.py new file mode 100644 index 0000000..b123070 --- /dev/null +++ b/mandelblog_content_guard/agents/en.py @@ -0,0 +1,34 @@ +from .base import BaseLanguageAgent +from ..normalizers import normalize_en_text +from ..system_strings import build_contextual_system_vocabulary, build_system_vocabulary + + +class EnglishAgent(BaseLanguageAgent): + locale = "en" + tone = "business-friendly and direct" + preferred_formality = "neutral" + vocabulary_map = { + **build_system_vocabulary("en", ("plan_badge", "services_badge", "transparent_label", "transparent_investment")), + } + _system_contextual = build_contextual_system_vocabulary("en", ("plan_badge", "services_badge", "transparent_label")) + contextual_vocabulary_map = { + "badge": {**_system_contextual.get("badge", {})}, + "label": {**_system_contextual.get("label", {})}, + "metric": {**_system_contextual.get("metric", {})}, + "stat": {**_system_contextual.get("stat", {})}, + "title": {**_system_contextual.get("title", {})}, + "heading": {**_system_contextual.get("heading", {})}, + "rendered": {**_system_contextual.get("rendered", {})}, + } + cta_defaults = { + "starter": "Book starter call", + "business": "Book business call", + "support": "View support", + "service": "View services", + "project": "Start your project", + "quote": "Request a quote", + "contact": "Book intro call", + } + + def post_cleanup_text(self, text: str, field_path: str = "") -> str: + return normalize_en_text(text, field_path=field_path) diff --git a/mandelblog_content_guard/agents/es.py b/mandelblog_content_guard/agents/es.py new file mode 100644 index 0000000..21dbbd6 --- /dev/null +++ b/mandelblog_content_guard/agents/es.py @@ -0,0 +1,43 @@ +from .base import BaseLanguageAgent +from ..normalizers import normalize_es_text +from ..system_strings import build_contextual_system_vocabulary, build_system_vocabulary + + +class SpanishAgent(BaseLanguageAgent): + locale = "es" + tone = "clear and business-focused" + preferred_formality = "formal" + vocabulary_map = { + **build_system_vocabulary( + "es", + ( + "plan_badge", + "response_time", + "without_commitment", + "transparent_label", + "transparent_investment", + ), + ), + } + _system_contextual = build_contextual_system_vocabulary("es", ("plan_badge", "transparent_label")) + contextual_vocabulary_map = { + "badge": {**_system_contextual.get("badge", {})}, + "label": {**_system_contextual.get("label", {})}, + "metric": {**_system_contextual.get("metric", {})}, + "stat": {**_system_contextual.get("stat", {})}, + "title": {**_system_contextual.get("title", {})}, + "heading": {**_system_contextual.get("heading", {})}, + "rendered": {**_system_contextual.get("rendered", {})}, + } + cta_defaults = { + "starter": "Reservar llamada inicial", + "business": "Reservar llamada comercial", + "support": "Solicitar soporte", + "service": "Mostrar los servicios", + "project": "Inicia tu proyecto", + "quote": "Solicitar propuesta", + "contact": "Planificar la reunión inicial", + } + + def post_cleanup_text(self, text: str, field_path: str = "") -> str: + return normalize_es_text(text, field_path=field_path) diff --git a/mandelblog_content_guard/agents/fr.py b/mandelblog_content_guard/agents/fr.py new file mode 100644 index 0000000..54de47e --- /dev/null +++ b/mandelblog_content_guard/agents/fr.py @@ -0,0 +1,66 @@ +from .base import BaseLanguageAgent +from ..system_strings import build_contextual_system_vocabulary, build_system_vocabulary + + +class FrenchAgent(BaseLanguageAgent): + locale = "fr" + tone = "professional and commercial" + preferred_formality = "formal" + cta_defaults = { + "starter": "Planifier l’entretien de départ", + "business": "Planifier l’entretien commercial", + "support": "Voir le support", + "service": "Afficher les services", + "project": "Lancez votre projet", + "devis": "Demander un devis", + "contact": "Planifier l’échange", + } + vocabulary_map = { + **build_system_vocabulary("fr"), + "SERVICES": "PRESTATIONS", + "New": "Nouveau", + "Popular": "Populaire", + "Erstes Produktionsprojekt erfolgreich abgeschlossen.": "Premier projet de production livré avec succès.", + "Von Kickoff bis zum Launch mit einem klaren Umfang.": "Du cadrage au lancement avec un périmètre clair.", + "Demande d'admission initiale": "Planifier un échange initial", + "Geschäftsprozess besprechen": "Échanger sur votre processus métier", + "Entretien d'accueil": "Entretien initial", + "Vraag over diensten": "Question sur les services", + "Konkrete erste Schätzung": "Première estimation concrète", + "Ansatz, der zu Ihrem Budget passt": "Approche adaptée à votre budget", + "Detailliertes Seitenlayout": "Structure détaillée des pages", + "Investition": "investissement", + "Unverbindliches Gespräch, klares Angebot": "Sans engagement, offre claire", + "Bereit, mit der Business-Website zu starten?": "Prêt à démarrer votre site d’entreprise ?", + "Planifier un échange business": "Planifier un échange commercial", + "Aucune carte bancaire requise": "Sans engagement", + } + _system_contextual = build_contextual_system_vocabulary("fr") + contextual_vocabulary_map = { + "badge": { + **_system_contextual.get("badge", {}), + "Popular": "Le plus demandé", + }, + "label": { + **_system_contextual.get("label", {}), + "Popular": "Le plus demandé", + }, + "metric": { + **_system_contextual.get("metric", {}), + }, + "stat": { + **_system_contextual.get("stat", {}), + }, + "title": { + **_system_contextual.get("title", {}), + "SERVICES": "PRESTATIONS", + }, + "heading": { + **_system_contextual.get("heading", {}), + "SERVICES": "PRESTATIONS", + }, + "rendered": { + **_system_contextual.get("rendered", {}), + "SERVICES": "PRESTATIONS", + }, + } diff --git a/mandelblog_content_guard/agents/it.py b/mandelblog_content_guard/agents/it.py new file mode 100644 index 0000000..f79d486 --- /dev/null +++ b/mandelblog_content_guard/agents/it.py @@ -0,0 +1,42 @@ +from .base import BaseLanguageAgent +from ..normalizers import normalize_it_text +from ..system_strings import build_contextual_system_vocabulary, build_system_vocabulary + + +class ItalianAgent(BaseLanguageAgent): + locale = "it" + tone = "professional and approachable" + preferred_formality = "polite" + vocabulary_map = { + **build_system_vocabulary( + "it", + ( + "weeks_1_2", + "without_commitment", + "transparent_label", + "transparent_investment", + "customization_integrations", + "multilingual_rollout", + ), + ), + } + _system_contextual = build_contextual_system_vocabulary("it", ("transparent_label",)) + contextual_vocabulary_map = { + "badge": {**_system_contextual.get("badge", {})}, + "label": {**_system_contextual.get("label", {})}, + "metric": {**_system_contextual.get("metric", {})}, + "stat": {**_system_contextual.get("stat", {})}, + "rendered": {**_system_contextual.get("rendered", {})}, + } + cta_defaults = { + "starter": "Prenota una call iniziale", + "business": "Pianifica la call business", + "support": "Richiedi supporto", + "service": "Mostra i servizi", + "project": "Avvia il tuo progetto", + "quote": "Richiedi una proposta", + "contact": "Pianifica la riunione introduttiva", + } + + def post_cleanup_text(self, text: str, field_path: str = "") -> str: + return normalize_it_text(text, field_path=field_path) diff --git a/mandelblog_content_guard/agents/nl.py b/mandelblog_content_guard/agents/nl.py new file mode 100644 index 0000000..143c14f --- /dev/null +++ b/mandelblog_content_guard/agents/nl.py @@ -0,0 +1,20 @@ +from .base import BaseLanguageAgent +from ..normalizers import normalize_nl_text + + +class DutchAgent(BaseLanguageAgent): + locale = "nl" + tone = "zakelijk en duidelijk" + preferred_formality = "je/jij professioneel" + cta_defaults = { + "starter": "Plan startergesprek", + "business": "Plan zakelijk gesprek", + "support": "Bekijk support", + "service": "Bekijk diensten", + "project": "Start jouw project", + "contact": "Plan kennismaking", + "offerte": "Vraag voorstel aan", + } + + def post_cleanup_text(self, text: str, field_path: str = "") -> str: + return normalize_nl_text(text, field_path=field_path) diff --git a/mandelblog_content_guard/agents/pt.py b/mandelblog_content_guard/agents/pt.py new file mode 100644 index 0000000..6a8f153 --- /dev/null +++ b/mandelblog_content_guard/agents/pt.py @@ -0,0 +1,111 @@ +from .base import BaseLanguageAgent +from ..system_strings import build_contextual_system_vocabulary, build_system_vocabulary + + +class PortugueseAgent(BaseLanguageAgent): + locale = "pt" + tone = "business-focused and practical" + preferred_formality = "neutral" + cta_defaults = { + "starter": "Agendar chamada inicial", + "business": "Agendar chamada comercial", + "support": "Ver suporte", + "service": "Ver serviços", + "project": "Iniciar o seu projeto", + "proposta": "Pedir proposta", + "contact": "Agendar reunião introdutória", + } + vocabulary_map = { + **build_system_vocabulary("pt"), + "SERVICES": "SERVIÇOS", + "New": "Novo", + "Popular": "Em destaque", + "Siti web e negozi online": "Sites e lojas online", + "Siti web e negozi online che sono rapidamente online e facili da gestire": "Sites e lojas online que ficam no ar rapidamente e são fáceis de gerir", + "Caso de cliente en directo": "Caso real de cliente", + "El primer proyecto de producción finalizado con éxito.": "O primeiro projeto de produção foi concluído com sucesso.", + "Más sobre el proceso": "Mais sobre o processo", + "Modifiez simplement vous-même.": "Edite facilmente por conta própria.", + "Opciones de la tienda web Mantenimiento y soporte Suporte mensal opcional para atualizações e estabilidade.": "Opções da loja online Manutenção e suporte Suporte mensal opcional para atualizações e estabilidade.", + "Opciones de la tienda web": "Opções da loja online", + "Planes de soporte": "Planos de suporte", + "Multilingüe": "Multilingue", + "Suivi + corrections": "Acompanhamento e correções", + "Mejoras mensuales": "Melhorias mensais", + "¿A qué velocidad puede comenzar?": "Com que rapidez podem começar?", + "¿Puedo editar textos e imágenes yo mismo?": "Posso editar textos e imagens por conta própria?", + "Einzelhandelsunternehmer": "Comerciante", + "lifestyle": "estilo de vida", + "À partir de 3 750 €": "A partir de 3.750 €", + "Transparente sobre o planejamento, o processo e a gestão.": "Clareza sobre o planeamento, o processo e a gestão.", + "Einzelhandelsinhaber Petite boutique en ligne Forfaits de services (à partir de) Pontos de partida transparentes.": "Comerciantes Pequena loja online Pacotes de serviço (a partir de) Pontos de partida claros.", + "Unsere Serviços": "Os nossos serviços", + "Unsere Serviços: vom schnellen Start bis zu skalierbarem Wachstum": "Os nossos serviços: do lançamento rápido ao crescimento escalável", + "Elija el camino": "Escolha o caminho certo", + "Elija el camino que corresponda a su fase: sitio de inicio, sitio empresarial, tienda en línea o soporte continuo.": "Escolha o caminho certo para a sua fase: site inicial, site empresarial, loja online ou suporte contínuo.", + "Début en direct": "Lançamento rápido", + "Demande d'admission initiale": "Agendar conversa inicial", + "Site Web d'Entreprise": "Site empresarial", + "Hablar sobre el proceso empresarial": "Falar sobre o processo do negócio", + "Mise en place de boutique en ligne": "Implementação de loja online", + "Maintenance & gestion": "Manutenção e gestão", + "Afficher le plan de soutien": "Ver suporte", + "Introducción multilingüe": "Lançamento multilingue", + "Forfaits de services (à partir de)": "Pacotes de serviço (a partir de)", + "Schnell online mit einer starken Basis": "Rápido online com uma base sólida", + "Startseite + Kernseiten": "Página inicial + páginas essenciais", + "Optimizado para móviles": "Otimizado para mobile", + "Gestisca lei stesso il contenuto": "Gerir o conteúdo com autonomia", + "Detailliertes Seitenlayout": "Estrutura detalhada das páginas", + "Unverbindliches Gespräch, klares Angebot": "Sem compromisso, proposta clara", + "Mehr Struktur und Konversion": "Mais estrutura e foco em conversão", + "Sections axées sur la conversion": "Secções orientadas para conversão", + "Base prête pour le SEO": "Base pronta para SEO", + "Katalog + Kasse": "Catálogo + checkout", + "Zahlungen und Auftragsfluss": "Pagamentos e fluxo de encomendas", + "Wachstumsbereite Grundlage": "Base pronta para crescimento", + "Soporte y crecimiento": "Suporte e crescimento", + "Amélioration continue": "Melhoria contínua", + "Desde 149 € al mes.": "Desde 149 € por mês.", + "Ab 2.250 €": "A partir de 2.250 €", + "Boutique en ligne": "Loja online", + "Sales-ready mit skalierbarem Stack": "Preparada para vender com uma base escalável", + "Agendar conversa sobre o serviço Ver resultados do projeto 1-2 Wochen Début en direct 4.9/5 Kundenschätzung 100% Bearbeitbar Visão geral dos serviços Cada serviço é projetado para melhorar a faturação, a confiança e a controlabilidade.": "Agendar conversa sobre o serviço Ver resultados do projeto 1 a 2 semanas Lançamento rápido 4.9/5 Avaliação dos clientes 100% Editável Visão geral dos serviços Cada serviço foi concebido para aumentar a faturação, reforçar a confiança e dar mais controlo à sua equipa.", + "Site inicial Schnell online mit einer starken Basis A partir de 1.250 € Agendar chamada inicial Startseite + Kernseiten Optimizado para móviles Gestisca lei stesso il contenuto Recomendado Site Web d'Entreprise Mehr Struktur und Konversion Ab 2.250 € Agendar chamada comercial Detailliertes Seitenlayout Sections axées sur la conversion Base prête pour le SEO Boutique en ligne Sales-ready mit skalierbarem Stack À partir de 3 750 € Iniciar o processo da loja online Katalog + Kasse Zahlungen und Auftragsfluss Wachstumsbereite Grundlage Soporte y crecimiento Amélioration continue Desde 149 € al mes.": "Site inicial Rápido online com uma base sólida A partir de 1.250 € Agendar chamada inicial Página inicial + páginas essenciais Otimizado para mobile Gerir o conteúdo com autonomia Recomendado Site empresarial Mais estrutura e foco em conversão A partir de 2.250 € Agendar chamada comercial Estrutura detalhada das páginas Secções orientadas para conversão Base pronta para SEO Loja online Preparada para vender com uma base escalável A partir de 3.750 € Iniciar o processo da loja online Catálogo + checkout Pagamentos e fluxo de encomendas Base pronta para crescimento Suporte e crescimento Melhoria contínua Desde 149 € por mês.", + "Perguntas frequentes Transparente sobre o planejamento, o processo e a gestão.": "Perguntas frequentes Clareza sobre o planeamento, o processo e a gestão.", + 'Ver serviços New La entrada "Unterstützung oder Erweiterung" está en alemán, no en neerlandés.': "Ver serviços Novo Suporte ou expansão", + "Unterstützung oder Erweiterung": "Suporte ou expansão", + 'La entrada "Unterstützung oder Erweiterung"': "Suporte ou expansão", + 'La entrada "Unterstützung oder Erweiterung" está en alemán, no en neerlandés. Traducido al francés, es: "Suporte ou expansão".': "Suporte ou expansão", + "Sem cartão de crédito": "Sem compromisso", + } + _system_contextual = build_contextual_system_vocabulary("pt") + contextual_vocabulary_map = { + "badge": { + **_system_contextual.get("badge", {}), + "Popular": "Escolha frequente", + }, + "label": { + **_system_contextual.get("label", {}), + "Popular": "Escolha frequente", + }, + "metric": { + **_system_contextual.get("metric", {}), + }, + "stat": { + **_system_contextual.get("stat", {}), + }, + "title": { + "SERVICES": "SERVIÇOS", + "Popular": "Em destaque", + }, + "heading": { + "SERVICES": "SERVIÇOS", + "Popular": "Em destaque", + }, + "rendered": { + **_system_contextual.get("rendered", {}), + "SERVICES": "SERVIÇOS", + "Popular": "Em destaque", + }, + } diff --git a/mandelblog_content_guard/agents/ru.py b/mandelblog_content_guard/agents/ru.py new file mode 100644 index 0000000..b7ec4e8 --- /dev/null +++ b/mandelblog_content_guard/agents/ru.py @@ -0,0 +1,39 @@ +from .base import BaseLanguageAgent +from ..normalizers import normalize_ru_text +from ..system_strings import build_contextual_system_vocabulary, build_system_vocabulary + + +class RussianAgent(BaseLanguageAgent): + locale = "ru" + tone = "professional and confident" + preferred_formality = "neutral polite" + vocabulary_map = { + **build_system_vocabulary( + "ru", + ( + "customization_integrations", + "detailed_page_structure", + "without_commitment", + ), + ), + } + _system_contextual = build_contextual_system_vocabulary("ru", ("plan_badge", "transparent_label")) + contextual_vocabulary_map = { + "badge": {**_system_contextual.get("badge", {})}, + "label": {**_system_contextual.get("label", {})}, + "metric": {**_system_contextual.get("metric", {})}, + "stat": {**_system_contextual.get("stat", {})}, + "rendered": {**_system_contextual.get("rendered", {})}, + } + cta_defaults = { + "starter": "Запланировать стартовую консультацию", + "business": "Обсудить бизнес-проект", + "support": "Посмотреть поддержку", + "service": "Посмотреть услуги", + "project": "Запустить свой проект", + "contact": "Отправить запрос", + "quote": "Получить предложение", + } + + def post_cleanup_text(self, text: str, field_path: str = "") -> str: + return normalize_ru_text(text, field_path=field_path) diff --git a/mandelblog_content_guard/ai.py b/mandelblog_content_guard/ai.py new file mode 100644 index 0000000..35cdc34 --- /dev/null +++ b/mandelblog_content_guard/ai.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from .agents import get_language_agent +from .validators.multilingual import validate_ai_text_or_raise + + +def guard_ai_output(locale_code: str, field_path: str, value: str) -> str: + validate_ai_text_or_raise(locale_code, field_path, value) + return value + + +def rewrite_ai_output(locale_code: str, field_path: str, value: str) -> str: + agent = get_language_agent(locale_code) + rewritten = agent.rewrite(value, field_path=field_path) + validate_ai_text_or_raise(locale_code, field_path, rewritten) + return rewritten diff --git a/mandelblog_content_guard/apps.py b/mandelblog_content_guard/apps.py new file mode 100644 index 0000000..40bd781 --- /dev/null +++ b/mandelblog_content_guard/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class MandelblogContentGuardConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "mandelblog_content_guard" + verbose_name = "MandelBlog Content Guard" + + def ready(self): + from . import signals # noqa: F401 diff --git a/mandelblog_content_guard/extractors/__init__.py b/mandelblog_content_guard/extractors/__init__.py new file mode 100644 index 0000000..a34ebf6 --- /dev/null +++ b/mandelblog_content_guard/extractors/__init__.py @@ -0,0 +1,3 @@ +from .visible_text import VisibleTextExtractor, extract_visible_rendered_text, normalize_text + +__all__ = ["VisibleTextExtractor", "extract_visible_rendered_text", "normalize_text"] diff --git a/mandelblog_content_guard/extractors/visible_text.py b/mandelblog_content_guard/extractors/visible_text.py new file mode 100644 index 0000000..5048dd9 --- /dev/null +++ b/mandelblog_content_guard/extractors/visible_text.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import html +import re +from html.parser import HTMLParser + +VISIBLE_TEXT_TAGS = {"h1", "h2", "h3", "h4", "h5", "h6", "p", "button", "a", "label", "li"} +IGNORED_TAGS = {"script", "style", "noscript", "template"} + + +def html_unescape(value: str) -> str: + return html.unescape(value) + + +def normalize_text(value: str) -> str: + return re.sub(r"\s+", " ", html_unescape(value)).strip() + + +class VisibleTextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.ignored_depth = 0 + self.hidden_stack: list[bool] = [] + self.visible_tag_stack: list[str] = [] + self.current_chunks: list[str] = [] + self.lines: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + lowered = tag.lower() + attrs_dict = {key.lower(): (value or "") for key, value in attrs} + if lowered in IGNORED_TAGS: + self.ignored_depth += 1 + return + self.hidden_stack.append(self._is_hidden(attrs_dict)) + if lowered in VISIBLE_TEXT_TAGS and not self.ignored_depth and not any(self.hidden_stack): + self.visible_tag_stack.append(lowered) + + def handle_endtag(self, tag: str) -> None: + lowered = tag.lower() + if lowered in IGNORED_TAGS and self.ignored_depth: + self.ignored_depth -= 1 + return + if lowered in VISIBLE_TEXT_TAGS and self.visible_tag_stack: + self.visible_tag_stack.pop() + self._flush_line() + if self.hidden_stack: + self.hidden_stack.pop() + + def handle_data(self, data: str) -> None: + if self.ignored_depth or any(self.hidden_stack) or not self.visible_tag_stack: + return + normalized = normalize_text(data) + if normalized: + self.current_chunks.append(normalized) + + def handle_comment(self, data: str) -> None: + return + + def close(self) -> None: + super().close() + self._flush_line() + + def _flush_line(self) -> None: + if not self.current_chunks: + return + line = normalize_text(" ".join(self.current_chunks)) + if line: + self.lines.append(line) + self.current_chunks = [] + + @staticmethod + def _is_hidden(attrs: dict[str, str]) -> bool: + if "hidden" in attrs: + return True + if attrs.get("aria-hidden", "").lower() == "true": + return True + style = attrs.get("style", "").replace(" ", "").lower() + return "display:none" in style or "visibility:hidden" in style + + +def extract_visible_rendered_text(body: str) -> str: + parser = VisibleTextExtractor() + parser.feed(body) + parser.close() + return "\n".join(parser.lines) diff --git a/mandelblog_content_guard/hooks.py b/mandelblog_content_guard/hooks.py new file mode 100644 index 0000000..b9f99dd --- /dev/null +++ b/mandelblog_content_guard/hooks.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from django.contrib import messages +from django.http import HttpResponseRedirect +from wagtail import hooks + +from .types import format_issue, split_issues +from .validators.multilingual import validate_page, validate_posted_snippet, validate_snippet_instance + + +def _flash_issues(request, level, prefix: str, issues): + preview = issues[:6] + for issue in preview: + messages.add_message(request, level, f"{prefix}: {format_issue(issue)}") + remaining = len(issues) - len(preview) + if remaining > 0: + messages.add_message(request, level, f"{prefix}: {remaining} more issue(s) not shown.") + + +@hooks.register("before_publish_page") +def prevent_corrupt_multilingual_publish(request, page): + issues = validate_page(page) + blocking, warnings = split_issues(issues) + if warnings: + _flash_issues(request, messages.WARNING, "Content guard warning", warnings) + if not blocking: + return None + _flash_issues(request, messages.ERROR, "Publishing blocked", blocking) + return HttpResponseRedirect(request.path) + + +@hooks.register("after_edit_page") +def warn_on_corrupt_multilingual_draft(request, page): + blocking, warnings = split_issues(validate_page(page)) + if blocking: + _flash_issues(request, messages.WARNING, "Draft warning", blocking) + if warnings: + _flash_issues(request, messages.WARNING, "Draft warning", warnings) + + +def _snippet_locale_code(instance, request) -> str: + posted_locale = request.POST.get("locale") if request.method == "POST" else None + if posted_locale: + return posted_locale + locale = getattr(instance, "locale", None) + if locale is not None and getattr(locale, "language_code", None): + return locale.language_code + return "nl" + + +def _validate_snippet_request(request, instance): + if request.method != "POST": + return None + issues = validate_posted_snippet(_snippet_locale_code(instance, request), request.POST.dict()) + blocking, warnings = split_issues(issues) + if warnings: + _flash_issues(request, messages.WARNING, "Snippet warning", warnings) + if not blocking: + return None + _flash_issues(request, messages.ERROR, "Snippet save blocked", blocking) + return HttpResponseRedirect(request.path) + + +@hooks.register("before_create_snippet") +def prevent_corrupt_snippet_create(request, model): + instance = model() + posted_locale = request.GET.get("locale") or request.POST.get("locale") + if posted_locale and hasattr(instance, "locale_id"): + from wagtail.models import Locale + + instance.locale = Locale.objects.get(language_code=posted_locale) + return _validate_snippet_request(request, instance) + + +@hooks.register("before_edit_snippet") +def prevent_corrupt_snippet_edit(request, instance): + return _validate_snippet_request(request, instance) + + +def _warn_saved_snippet(request, instance): + blocking, warnings = split_issues(validate_snippet_instance(instance)) + if blocking: + _flash_issues(request, messages.WARNING, "Snippet integrity warning", blocking) + if warnings: + _flash_issues(request, messages.WARNING, "Snippet integrity warning", warnings) + + +@hooks.register("after_create_snippet") +def warn_on_saved_snippet_create(request, instance): + _warn_saved_snippet(request, instance) + + +@hooks.register("after_edit_snippet") +def warn_on_saved_snippet_edit(request, instance): + _warn_saved_snippet(request, instance) diff --git a/mandelblog_content_guard/management/__init__.py b/mandelblog_content_guard/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mandelblog_content_guard/management/commands/__init__.py b/mandelblog_content_guard/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mandelblog_content_guard/management/commands/audit_locales.py b/mandelblog_content_guard/management/commands/audit_locales.py new file mode 100644 index 0000000..31c398c --- /dev/null +++ b/mandelblog_content_guard/management/commands/audit_locales.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import json +from collections import defaultdict + +from django.core.management.base import BaseCommand + +from ...settings import audit_default_locales +from ...validators.multilingual import audit_locales + + +class Command(BaseCommand): + help = "Audit all public locale pages for multilingual integrity issues." + + def add_arguments(self, parser): + parser.add_argument( + "--locale", + action="append", + dest="locales", + help="Limit the audit to one or more locale codes. Repeat the flag for multiple locales.", + ) + parser.add_argument( + "--url", + action="append", + dest="urls", + help="Limit the audit to one or more public page URLs. Repeat the flag for multiple URLs.", + ) + parser.add_argument( + "--fix", + action="store_true", + help="Apply known safe replacements and republish changed content.", + ) + parser.add_argument( + "--rewrite", + action="store_true", + help="Rewrite flagged content through the locale agent system.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview rewrite changes without saving content.", + ) + parser.add_argument( + "--format", + choices=["text", "json"], + default="text", + help="Output format.", + ) + + def handle(self, *args, **options): + locale_codes = options["locales"] or audit_default_locales() + run = audit_locales( + locale_codes, + fix=options["fix"], + rewrite=options["rewrite"], + dry_run=options["dry_run"], + url_filters=options["urls"], + ) + grouped = defaultdict(list) + for issue in run.issues.all().order_by("locale_code", "url", "field_path"): + grouped[issue.locale_code].append(issue) + + grouped_compact = defaultdict(list) + for locale_code, issues in grouped.items(): + bucket = {} + for issue in issues: + key = ( + issue.url, + issue.issue_type, + issue.bad_value, + issue.replacement, + ) + extra = issue.extra or {} + if key not in bucket: + bucket[key] = { + "url": issue.url, + "title": issue.title, + "severity": issue.severity, + "issue_type": issue.issue_type, + "field_paths": set([issue.field_path] if issue.field_path else []), + "bad_value": issue.bad_value, + "replacement": issue.replacement, + "fixed": issue.fixed, + "sources": set([extra.get("source")] if extra.get("source") else []), + "count": extra.get("count", 1), + } + else: + if issue.field_path: + bucket[key]["field_paths"].add(issue.field_path) + if extra.get("source"): + bucket[key]["sources"].add(extra["source"]) + bucket[key]["count"] += extra.get("count", 1) + grouped_compact[locale_code] = [ + { + **entry, + "field_paths": sorted(entry["field_paths"]), + "sources": sorted(entry["sources"]), + } + for entry in bucket.values() + ] + + if options["format"] == "json": + payload = { + "run_id": run.pk, + "total_urls_checked": run.total_urls_checked, + "issues_found": run.issues_found, + "summary": run.summary, + "issues": { + locale_code: grouped_compact.get(locale_code, []) + for locale_code in locale_codes + }, + } + self.stdout.write(json.dumps(payload, indent=2, ensure_ascii=False)) + return + + for locale_code in locale_codes: + locale_summary = run.summary.get(locale_code, {}) + self.stdout.write(f"Locale: {locale_code}") + self.stdout.write( + f"URLs checked: {locale_summary.get('total_urls_checked', 0)}" + ) + self.stdout.write( + f"Issues found: {locale_summary.get('issues_found', 0)}" + ) + self.stdout.write( + f"Severity: {locale_summary.get('by_severity', {})}" + ) + if options["fix"]: + self.stdout.write( + f"Issues auto-fixed: {locale_summary.get('issues_fixed', 0)}" + ) + if options["rewrite"]: + self.stdout.write( + f"Rewrite mode: {'dry-run' if options['dry_run'] else 'apply'}" + ) + for issue in grouped_compact.get(locale_code, []): + target = issue["url"] or issue["title"] or "object" + self.stdout.write( + f"- {target} -> {issue['issue_type']}: {issue['bad_value']}" + ) + if issue.get("replacement"): + self.stdout.write(f" after: {issue['replacement']}") + if issue.get("field_paths"): + self.stdout.write(f" fields: {', '.join(issue['field_paths'][:5])}") + if issue.get("sources"): + self.stdout.write(f" sources: {', '.join(issue['sources'])}") + if issue.get("count"): + self.stdout.write(f" count: {issue['count']}") + if not grouped_compact.get(locale_code): + self.stdout.write("- no issues found") + self.stdout.write("") + + snippet_summary = run.summary.get("snippets") or {} + if snippet_summary: + self.stdout.write("Snippet issues:") + for model_name, count in snippet_summary.items(): + self.stdout.write(f"- {model_name}: {count}") + + self.stdout.write( + self.style.SUCCESS( + f"Audit run {run.pk} completed. Total URLs checked: {run.total_urls_checked}. Issues found: {run.issues_found}." + ) + ) diff --git a/mandelblog_content_guard/mixins.py b/mandelblog_content_guard/mixins.py new file mode 100644 index 0000000..00af6b7 --- /dev/null +++ b/mandelblog_content_guard/mixins.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from django.core.exceptions import ValidationError + + +class MultilingualValidationMixin: + """Opt-in mixin for project models that want explicit clean()-time enforcement.""" + + def clean(self): + from .types import format_issue + from .validators.multilingual import validate_snippet_instance + + super_clean = getattr(super(), "clean", None) + if callable(super_clean): + super_clean() + issues = validate_snippet_instance(self) + blocking = [issue for issue in issues if issue.blocks] + if blocking: + raise ValidationError({"content_guard": [format_issue(issue) for issue in blocking]}) diff --git a/mandelblog_content_guard/normalizers/__init__.py b/mandelblog_content_guard/normalizers/__init__.py new file mode 100644 index 0000000..a89859f --- /dev/null +++ b/mandelblog_content_guard/normalizers/__init__.py @@ -0,0 +1,15 @@ +from .de import normalize_de_text +from .en import normalize_en_text +from .es import normalize_es_text +from .it import normalize_it_text +from .nl import normalize_nl_text +from .ru import normalize_ru_text + +__all__ = [ + "normalize_de_text", + "normalize_en_text", + "normalize_es_text", + "normalize_it_text", + "normalize_nl_text", + "normalize_ru_text", +] diff --git a/mandelblog_content_guard/normalizers/de.py b/mandelblog_content_guard/normalizers/de.py new file mode 100644 index 0000000..e2d16eb --- /dev/null +++ b/mandelblog_content_guard/normalizers/de.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import re + + +DE_LINE_REPLACEMENTS = { + "Häufig gestellte Fragen Transparent über Planung, Vorgehensweise und Management.": "Häufig gestellte Fragen Klarheit über Planung, Vorgehensweise und Management.", + "Einführungsmeeting planen Projekte anzeigen Unverbindliches Gespräch, klares Angebot Wir entwickeln schnelle Websites und Webshops, die Ihr Team selbst pflegen kann.": "Erstgespräch planen · Projekte ansehen · Unverbindliches Gespräch mit klarem Angebot. Wir entwickeln schnelle Websites und Webshops, die Ihr Team selbst pflegen kann.", + "Einführungsmeeting planen Dienstleistungen anzeigen Verbindlich und klar Wir entwickeln schnelle Websites und Webshops, die Ihr Team selbst pflegen kann.": "Erstgespräch planen · Dienstleistungen anzeigen · Unverbindliches Gespräch mit klarem Angebot. Wir entwickeln schnelle Websites und Webshops, die Ihr Team selbst pflegen kann.", + "Steuern 0,00 € Korb ansehen Kasse Kontakt KONTAKT Lass uns dein Projekt konkret machen Einführungsmeeting planen Dienstleistungen anzeigen So können Sie Kontakt aufnehmen Wählen Sie die Route, die zu Ihrer Frage passt.": "Steuern 0,00 € Korb ansehen Kasse Kontakt KONTAKT Lassen Sie uns Ihr Projekt konkret machen Erstgespräch planen Dienstleistungen anzeigen So können Sie Kontakt aufnehmen Wählen Sie den Weg, der zu Ihrer Frage passt.", + "Steuern 0,00 € Korb ansehen Kasse Starter Website PLAN Starter Website Plan Starter-Gespräch planen Alle Dienstleistungen anzeigen Was du bekommst Startseite + Kernseiten Professionelle Basis, die sofort Vertrauen schafft.": "Steuern 0,00 € Korb ansehen Kasse Starter-Website PLAN Starter-Website Starter-Gespräch planen Alle Dienstleistungen anzeigen Was Sie erhalten Startseite + Kernseiten Professionelle Basis, die sofort Vertrauen schafft.", + "Steuern 0,00 € Korb ansehen Kasse Business Website PLAN Business Website Plan Beratungsgespräch planen Alle Dienstleistungen anzeigen Was du bekommst Detailliertes Seitenlayout Mehr Platz für Dienstleistungen, Fälle und Lead-Flows.": "Steuern 0,00 € Korb ansehen Kasse Business-Website PLAN Business-Website Beratungsgespräch planen Alle Dienstleistungen anzeigen Was Sie erhalten Detailliertes Seitenlayout Mehr Platz für Dienstleistungen, Referenzen und Lead-Flows.", +} + +DE_PHRASE_REPLACEMENTS = { + "New": "Neu", + "Einführungsmeeting": "Erstgespräch", + "Intakegespräch": "Erstgespräch", + "SEO-ready basis": "SEO-optimierte Basis", + "Sales-ready mit skalierbarem Stack": "Verkaufsbereit mit skalierbarer Architektur", + "Continuous Verbesserung": "Kontinuierliche Verbesserung", + "Was du bekommst": "Was Sie erhalten", + "Starter Website": "Starter-Website", + "Business Website": "Business-Website", + "Support & Wachstum": "Support & Wachstum", + "Lass uns dein Projekt konkret machen": "Lassen Sie uns Ihr Projekt konkret machen", + "Wählen Sie die Route, die zu Ihrer Frage passt.": "Wählen Sie den Weg, der zu Ihrer Frage passt.", + "Verbindlich und klar": "Unverbindliches Gespräch mit klarem Angebot", + "Unverbindliches Gespräch, klares Angebot": "Unverbindliches Gespräch mit klarem Angebot", +} + + +def _apply_boundary_replacements(text: str, replacements: dict[str, str]) -> str: + cleaned = text + phrase_replacements = {} + token_replacements = {} + for source, target in replacements.items(): + if re.fullmatch(r"[\wÀ-ÿ-]+", source, flags=re.UNICODE): + token_replacements[source] = target + else: + phrase_replacements[source] = target + + for source, target in sorted(phrase_replacements.items(), key=lambda item: len(item[0]), reverse=True): + cleaned = cleaned.replace(source, target) + + for source, target in sorted(token_replacements.items(), key=lambda item: len(item[0]), reverse=True): + pattern = re.compile(rf"(? str: + cleaned = text + for source, target in DE_LINE_REPLACEMENTS.items(): + if cleaned == source: + return target + cleaned = _apply_boundary_replacements(cleaned, DE_PHRASE_REPLACEMENTS) + return cleaned diff --git a/mandelblog_content_guard/normalizers/en.py b/mandelblog_content_guard/normalizers/en.py new file mode 100644 index 0000000..fbea978 --- /dev/null +++ b/mandelblog_content_guard/normalizers/en.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import re + + +EN_LINE_REPLACEMENTS = { + "Service packages (from) Transparent starting points.": "Service packages (from) Clear starting points.", + "Frequently Asked Questions Transparent about planning, approach, and management.": "Frequently Asked Questions Clear guidance on planning, approach, and management.", + "After your intake Clear scope and steps Clear planning Transparent investment Name * E-mail * Company * Project details Book business call Ready to start with Business Website?": "After your intake Clear scope and steps Clear planning Transparent pricing Name * E-mail * Company * Project details Book business call Ready to start with Business Website?", + "After your intake Clear scope and steps Clear planning Transparent investment Name * E-mail * Company * Project details Book starter call Ready to start with Starter Website?": "After your intake Clear scope and steps Clear planning Transparent pricing Name * E-mail * Company * Project details Book starter call Ready to start with Starter Website?", + "After your intake Clear scope and steps Clear planning Transparent investment Name * E-mail * Company * Project details Request support plan Ready to start with Support & Growth?": "After your intake Clear scope and steps Clear planning Transparent pricing Name * E-mail * Company * Project details Request support plan Ready to start with Support & Growth?", + "After your intake Clear scope and steps Clear planning Transparent investment Name * E-mail * Company * Project details Start webshop project Ready to start with Webshop?": "After your intake Clear scope and steps Clear planning Transparent pricing Name * E-mail * Company * Project details Start webshop project Ready to start with Webshop?", +} + +EN_PHRASE_REPLACEMENTS = { + "Transparent investment": "Transparent pricing", + "Transparent about planning, approach, and management.": "Clear guidance on planning, approach, and management.", + "Transparent starting points.": "Clear starting points.", +} + + +def normalize_en_text(text: str, field_path: str = "") -> str: + if text in EN_LINE_REPLACEMENTS: + return EN_LINE_REPLACEMENTS[text] + cleaned = text + for source, target in sorted(EN_PHRASE_REPLACEMENTS.items(), key=lambda item: len(item[0]), reverse=True): + cleaned = cleaned.replace(source, target) + return re.sub(r"\s+", " ", cleaned).strip() diff --git a/mandelblog_content_guard/normalizers/es.py b/mandelblog_content_guard/normalizers/es.py new file mode 100644 index 0000000..e93a16f --- /dev/null +++ b/mandelblog_content_guard/normalizers/es.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import re + + +ES_LINE_REPLACEMENTS = { + "Transparente sobre la planificación, el proceso y la gestión.": "Transparencia sobre la planificación, el proceso y la gestión.", + "

Transparente sobre la planificación, el proceso y la gestión.

": "

Transparencia sobre la planificación, el proceso y la gestión.

", + "Preguntas frecuentes Transparente sobre la planificación, el proceso y la gestión.": "Preguntas frecuentes Transparencia sobre la planificación, el proceso y la gestión.", + "Preguntas frecuentes Transparenteee sobre la planificación, el proceso y la gestión.": "Preguntas frecuentes Transparencia sobre la planificación, el proceso y la gestión.", + "Planificar la reunión inicial Mostrar los proyectos Unverbindliches Gespräch, klares Angebot Construimos sitios web y tiendas online rápidas que tu equipo puede gestionar sin complicaciones.": "Planificar la reunión inicial · Mostrar los proyectos · Conversación sin compromiso con propuesta clara. Construimos sitios web y tiendas online rápidas que tu equipo puede gestionar sin complicaciones.", +} + +ES_PHRASE_REPLACEMENTS = { + "Transparenteee": "Transparente", + "Transparent": "Transparente", + "Unverbindliches Gespräch, klares Angebot": "Conversación sin compromiso con propuesta clara", +} + + +def normalize_es_text(text: str, field_path: str = "") -> str: + if text in ES_LINE_REPLACEMENTS: + return ES_LINE_REPLACEMENTS[text] + cleaned = text + for source, target in sorted(ES_PHRASE_REPLACEMENTS.items(), key=lambda item: len(item[0]), reverse=True): + if re.fullmatch(r"[\wÀ-ÿ-]+", source, flags=re.UNICODE): + pattern = re.compile(rf"(? str: + if text in IT_LINE_REPLACEMENTS: + return IT_LINE_REPLACEMENTS[text] + cleaned = text + for source, target in sorted(IT_PHRASE_REPLACEMENTS.items(), key=lambda item: len(item[0]), reverse=True): + cleaned = cleaned.replace(source, target) + return re.sub(r"\s+", " ", cleaned).strip() diff --git a/mandelblog_content_guard/normalizers/nl.py b/mandelblog_content_guard/normalizers/nl.py new file mode 100644 index 0000000..d286c13 --- /dev/null +++ b/mandelblog_content_guard/normalizers/nl.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import re + + +NL_PHRASE_REPLACEMENTS = { + "PLAN": "PLAN", +} + + +def normalize_nl_text(text: str, field_path: str = "") -> str: + cleaned = text + for source, target in NL_PHRASE_REPLACEMENTS.items(): + cleaned = cleaned.replace(source, target) + return re.sub(r"\s+", " ", cleaned).strip() diff --git a/mandelblog_content_guard/normalizers/ru.py b/mandelblog_content_guard/normalizers/ru.py new file mode 100644 index 0000000..ce51890 --- /dev/null +++ b/mandelblog_content_guard/normalizers/ru.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import re + + +RU_LINE_REPLACEMENTS = { + "План многоязычного запуска Anpassung & Integrationen Интеграции API, специфические рабочие процессы и индивидуальные блоки, адаптированные под вашу компанию.": "План многоязычного запуска Настройка и интеграции Интеграции API, специфические рабочие процессы и индивидуальные блоки, адаптированные под вашу компанию.", + "Запланировать звонок по бизнес-сайту Detailliertes Seitenlayout Разделы, ориентированные на конверсию Base prête pour le SEO Boutique en ligne Для проектов с товарами, оплатой и дальнейшим развитием e-commerce.": "Запланировать звонок по бизнес-сайту Детальная структура страниц Разделы, ориентированные на конверсию Основа, готовая для SEO Интернет-магазин Для проектов с товарами, оплатой и дальнейшим развитием e-commerce.", + "Связаться с нами Посмотреть проекты Unverbindliches Gespräch, klares Angebot Мы создаём быстрые сайты и интернет-магазины, которыми ваша команда может управлять самостоятельно.": "Связаться с нами · Посмотреть проекты · Без обязательств, понятное предложение. Мы создаём быстрые сайты и интернет-магазины, которыми ваша команда может управлять самостоятельно.", +} + +RU_PHRASE_REPLACEMENTS = { + "Base prête pour le SEO": "Основа, готовая для SEO", + "Unverbindliches Gespräch, klares Angebot": "Без обязательств, понятное предложение", +} + + +def normalize_ru_text(text: str, field_path: str = "") -> str: + if text in RU_LINE_REPLACEMENTS: + return RU_LINE_REPLACEMENTS[text] + cleaned = text + for source, target in sorted(RU_PHRASE_REPLACEMENTS.items(), key=lambda item: len(item[0]), reverse=True): + cleaned = cleaned.replace(source, target) + return re.sub(r"\s+", " ", cleaned).strip() diff --git a/mandelblog_content_guard/settings.py b/mandelblog_content_guard/settings.py new file mode 100644 index 0000000..aa1bedc --- /dev/null +++ b/mandelblog_content_guard/settings.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +""" +Reusable configuration helpers for mandelblog_content_guard. + +Supported Django settings: +- CONTENT_GUARD_STRICT: bool +- CONTENT_GUARD_BLOCK_MEDIUM: bool +- CONTENT_GUARD_LOCALES: list[str] +- CONTENT_GUARD_REWRITE_ENABLED: bool +- CONTENT_GUARD_REWRITE_BACKEND: dotted path | None +""" + +from django.conf import settings + +DEFAULT_LOCALES = ["nl", "en", "de", "fr", "es", "it", "pt", "ru"] + +SEVERITY = { + "CRITICAL": "block", + "HIGH": "block", + "MEDIUM": "warn", + "LOW": "log", +} + +ISSUE_LEVELS = { + "known_bad_pattern": "CRITICAL", + "wrong_language_fragment": "CRITICAL", + "rendered_bad_pattern": "CRITICAL", + "rendered_wrong_language": "CRITICAL", + "render_status": "CRITICAL", + "language_heuristic": "CRITICAL", + "cta_language_mismatch": "HIGH", + "form_language_mismatch": "HIGH", + "empty_form_copy": "HIGH", + "placeholder_value": "HIGH", + "rewrite_candidate": "MEDIUM", + "weak_marketing_copy": "MEDIUM", + "foreign_ui_label": "MEDIUM", + "generic_badge_label": "MEDIUM", + "mixed_locale_heading": "MEDIUM", + "cta_tone_check": "MEDIUM", +} + + +def strict_mode_enabled() -> bool: + return getattr(settings, "CONTENT_GUARD_STRICT", True) + + +def block_medium_enabled() -> bool: + return getattr(settings, "CONTENT_GUARD_BLOCK_MEDIUM", False) + + +def audit_default_locales() -> list[str]: + return list(getattr(settings, "CONTENT_GUARD_LOCALES", DEFAULT_LOCALES)) + + +def rewrite_enabled() -> bool: + return getattr(settings, "CONTENT_GUARD_REWRITE_ENABLED", True) + + +def get_rewrite_backend() -> str | None: + return getattr(settings, "CONTENT_GUARD_REWRITE_BACKEND", None) + + +def classify_issue(issue_type: str) -> str: + return ISSUE_LEVELS.get(issue_type, "LOW") + + +def severity_for_issue(issue_type: str) -> str: + return SEVERITY[classify_issue(issue_type)] + + +def should_block_issue(issue_type: str) -> bool: + level = classify_issue(issue_type) + if level in {"CRITICAL", "HIGH"}: + return True + if level == "MEDIUM": + return block_medium_enabled() and strict_mode_enabled() + return False diff --git a/mandelblog_content_guard/signals.py b/mandelblog_content_guard/signals.py new file mode 100644 index 0000000..34a4dad --- /dev/null +++ b/mandelblog_content_guard/signals.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from functools import lru_cache + +from django.db.models.signals import pre_save +from django.dispatch import receiver +from wagtail.models import Page +from wagtail.snippets.models import get_snippet_models + +from .validators.multilingual import validate_instance_or_raise + + +@lru_cache(maxsize=1) +def _snippet_models(): + return tuple(get_snippet_models()) + + +def _is_snippet_instance(instance) -> bool: + instance_model = instance.__class__ + return any(model == instance_model for model in _snippet_models()) + + +@receiver(pre_save) +def enforce_multilingual_integrity(sender, instance, **kwargs): + if isinstance(instance, Page) or _is_snippet_instance(instance): + validate_instance_or_raise(instance) diff --git a/mandelblog_content_guard/system_strings.py b/mandelblog_content_guard/system_strings.py new file mode 100644 index 0000000..3b0553d --- /dev/null +++ b/mandelblog_content_guard/system_strings.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +from collections.abc import Iterable + +SYSTEM_STRING_SPECS = { + "plan_badge": { + "sources": ("PLAN",), + "issue_type": "generic_badge_label", + "translations": { + "en": "Package", + "fr": "FORFAIT", + "es": "Paquete", + "ru": "Пакет", + }, + "canonical_by_locale": { + "de": ("PLAN",), + "nl": ("PLAN",), + "it": ("PIANO",), + }, + "contexts": { + "en": { + "badge": "Package", + "label": "Package", + "title": "Package", + "heading": "Package", + "rendered": "Package", + }, + "fr": { + "badge": "FORFAIT", + "label": "FORFAIT", + "title": "FORFAIT", + "heading": "FORFAIT", + "rendered": "FORFAIT", + }, + "es": { + "badge": "Paquete", + "label": "Paquete", + "title": "Paquete", + "heading": "Paquete", + "rendered": "Paquete", + }, + "ru": { + "badge": "Пакет", + "label": "Пакет", + "title": "Пакет", + "heading": "Пакет", + "rendered": "Пакет", + }, + }, + }, + "services_badge": { + "sources": ("SERVICES",), + "issue_type": "generic_badge_label", + "translations": { + "en": "Services", + "fr": "PRESTATIONS", + "pt": "SERVIÇOS", + }, + "contexts": { + "en": { + "badge": "Services", + "label": "Services", + "title": "Services", + "heading": "Services", + "rendered": "Services", + }, + "fr": { + "badge": "PRESTATIONS", + "label": "PRESTATIONS", + "title": "PRESTATIONS", + "heading": "PRESTATIONS", + "rendered": "PRESTATIONS", + }, + "pt": { + "badge": "SERVIÇOS", + "label": "SERVIÇOS", + "title": "SERVIÇOS", + "heading": "SERVIÇOS", + "rendered": "SERVIÇOS", + }, + }, + }, + "response_time": { + "sources": ("Reaktionszeit",), + "issue_type": "foreign_ui_label", + "translations": { + "en": "Response time", + "fr": "Temps de réponse", + "es": "Tiempo de respuesta", + "it": "Tempo di risposta", + "ru": "Время ответа", + }, + }, + "average_delivery": { + "sources": ("Durchschnittliche Lieferung",), + "issue_type": "foreign_ui_label", + "translations": { + "en": "Average delivery time", + "fr": "Délai moyen de livraison", + "es": "Plazo medio de entrega", + "it": "Tempo medio di consegna", + "ru": "Средний срок запуска", + }, + }, + "without_commitment": { + "sources": ("Unverbindlich",), + "issue_type": "foreign_ui_label", + "translations": { + "en": "No obligation", + "fr": "Sans engagement", + "es": "Sin compromiso", + "it": "Senza impegno", + "pt": "Sem compromisso", + "ru": "Без обязательств", + }, + }, + "transparent_label": { + "sources": ("Transparent",), + "issue_type": "foreign_ui_label", + "translations": { + "en": "Clear", + "fr": "Clair", + "es": "Transparente", + "it": "Chiaro", + "pt": "Transparente", + "ru": "Прозрачно", + }, + "contexts": { + "en": { + "badge": "Clear", + "label": "Clear", + "metric": "Clear", + "stat": "Clear", + "rendered": "Clear", + }, + "fr": { + "badge": "Clair", + "label": "Clair", + "metric": "Clair", + "stat": "Clair", + "rendered": "Clair", + }, + "es": { + "badge": "Transparente", + "label": "Transparente", + "metric": "Transparente", + "stat": "Transparente", + "rendered": "Transparente", + }, + "it": { + "badge": "Chiaro", + "label": "Chiaro", + "metric": "Chiaro", + "stat": "Chiaro", + "rendered": "Chiaro", + }, + "pt": { + "badge": "Clara", + "label": "Clara", + "metric": "Investimento claro", + "stat": "Investimento claro", + "rendered": "Investimento claro", + }, + "ru": { + "badge": "Прозрачно", + "label": "Прозрачно", + "metric": "Прозрачно", + "stat": "Прозрачно", + "rendered": "Прозрачно", + }, + }, + }, + "weeks_1_2": { + "sources": ("1-2 Wochen",), + "issue_type": "weak_marketing_copy", + "translations": { + "fr": "1 à 2 semaines", + "es": "1-2 semanas", + "it": "1-2 settimane", + "pt": "1 a 2 semanas", + }, + "contexts": { + "fr": { + "metric": "1 à 2 semaines", + "stat": "1 à 2 semaines", + }, + "es": { + "metric": "1-2 semanas", + "stat": "1-2 semanas", + }, + "it": { + "metric": "1-2 settimane", + "stat": "1-2 settimane", + }, + "pt": { + "metric": "1 a 2 semanas", + "stat": "1 a 2 semanas", + }, + }, + }, + "weeks_2_4": { + "sources": ("2-4 Wochen",), + "issue_type": "foreign_ui_label", + "translations": { + "fr": "2 à 4 semaines", + }, + "contexts": { + "fr": { + "metric": "2 à 4 semaines", + "stat": "2 à 4 semaines", + }, + }, + }, + "days_label": { + "sources": ("Tages",), + "issue_type": "weak_marketing_copy", + "translations": { + "fr": "jours", + "pt": "dias", + }, + }, + "customer_reviews": { + "sources": ("Kundenschätzung",), + "issue_type": "foreign_ui_label", + "translations": { + "en": "Customer rating", + "fr": "Avis clients", + "es": "Valoración de clientes", + "it": "Valutazione clienti", + "pt": "Avaliação dos clientes", + "ru": "Оценка клиентов", + }, + }, + "editable_label": { + "sources": ("Bearbeitbar",), + "issue_type": "foreign_ui_label", + "translations": { + "en": "Editable", + "fr": "Modifiable", + "es": "Editable", + "it": "Modificabile", + "pt": "Editável", + "ru": "Редактируемо", + }, + }, + "core_pages_label": { + "sources": ("Startseite + Kernseiten",), + "issue_type": "foreign_ui_label", + "translations": { + "pt": "Página inicial + páginas essenciais", + }, + }, + "detailed_page_structure": { + "sources": ("Detailliertes Seitenlayout",), + "issue_type": "foreign_ui_label", + "translations": { + "fr": "Structure détaillée des pages", + "es": "Estructura detallada de páginas", + "it": "Struttura dettagliata delle pagine", + "pt": "Estrutura detalhada das páginas", + "ru": "Детальная структура страниц", + }, + }, + "business_process_cta": { + "sources": ("Geschäftsprozess besprechen",), + "issue_type": "foreign_ui_label", + "translations": { + "fr": "Échanger sur votre processus métier", + "es": "Hablar sobre el proceso del negocio", + "pt": "Falar sobre o processo do negócio", + }, + }, + "multilingual_rollout": { + "sources": ("Mehrsprachige Einführung", "Mehrsprachiger Rollout-Plan"), + "issue_type": "foreign_ui_label", + "translations": { + "fr": "Déploiement multilingue", + "it": "Lancio multilingue", + "ru": "Многоязычный запуск", + }, + }, + "customization_integrations": { + "sources": ("Anpassung & Integrationen",), + "issue_type": "foreign_ui_label", + "translations": { + "fr": "Personnalisation & intégrations", + "es": "Personalización e integraciones", + "it": "Personalizzazioni e integrazioni", + "pt": "Personalização e integrações", + "ru": "Настройка и интеграции", + }, + }, + "transparent_investment": { + "sources": ("Transparente Investition",), + "issue_type": "foreign_ui_label", + "translations": { + "de": "Transparente Investition", + "en": "Transparent pricing", + "fr": "Investissement transparent", + "es": "Inversión transparente", + "it": "Investimento trasparente", + "pt": "Investimento transparente", + "ru": "Прозрачный бюджет", + }, + }, +} + + +def build_system_vocabulary(locale_code: str, keys: Iterable[str] | None = None) -> dict[str, str]: + vocabulary: dict[str, str] = {} + selected_keys = tuple(keys or SYSTEM_STRING_SPECS.keys()) + for key in selected_keys: + spec = SYSTEM_STRING_SPECS[key] + target = spec.get("translations", {}).get(locale_code) + if not target: + continue + for source in spec["sources"]: + vocabulary[source] = target + return vocabulary + + +def build_contextual_system_vocabulary(locale_code: str, keys: Iterable[str] | None = None) -> dict[str, dict[str, str]]: + contextual: dict[str, dict[str, str]] = {} + selected_keys = tuple(keys or SYSTEM_STRING_SPECS.keys()) + for key in selected_keys: + spec = SYSTEM_STRING_SPECS[key] + locale_contexts = spec.get("contexts", {}).get(locale_code, {}) + if not locale_contexts: + continue + source = spec["sources"][0] + for context_name, replacement in locale_contexts.items(): + contextual.setdefault(context_name, {})[source] = replacement + return contextual + + +def build_system_rewrite_candidates(keys: Iterable[str] | None = None) -> dict[str, str]: + candidates: dict[str, str] = {} + selected_keys = tuple(keys or SYSTEM_STRING_SPECS.keys()) + for key in selected_keys: + spec = SYSTEM_STRING_SPECS[key] + for source in spec["sources"]: + candidates[source] = spec["issue_type"] + return candidates + + +def all_system_sources() -> set[str]: + sources: set[str] = set() + for spec in SYSTEM_STRING_SPECS.values(): + sources.update(spec["sources"]) + return sources + + +def is_canonical_system_string(locale_code: str, source: str) -> bool: + for spec in SYSTEM_STRING_SPECS.values(): + if source in spec.get("canonical_by_locale", {}).get(locale_code, ()): + return True + if locale_code == "de": + return source in all_system_sources() + replacement = system_string_replacement(locale_code, source) + return bool(replacement and replacement == source) + + +def system_string_replacement(locale_code: str, source: str) -> str: + for spec in SYSTEM_STRING_SPECS.values(): + if source not in spec["sources"]: + continue + return spec.get("translations", {}).get(locale_code, "") + return "" diff --git a/mandelblog_content_guard/tests.py b/mandelblog_content_guard/tests.py new file mode 100644 index 0000000..4357060 --- /dev/null +++ b/mandelblog_content_guard/tests.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import json + +from django.test import SimpleTestCase + +from mandelblog_content_guard.agents import get_language_agent +from mandelblog_content_guard.extractors.visible_text import extract_visible_rendered_text +from mandelblog_content_guard.system_strings import build_system_rewrite_candidates, build_system_vocabulary +from mandelblog_content_guard.validators.multilingual import validate_text_nodes + + +class PackageLevelContentGuardTests(SimpleTestCase): + def test_system_string_replacement_catalog(self): + self.assertEqual(build_system_vocabulary("fr")["PLAN"], "FORFAIT") + self.assertEqual(build_system_vocabulary("pt")["Unverbindlich"], "Sem compromisso") + self.assertEqual(build_system_rewrite_candidates()["PLAN"], "generic_badge_label") + + def test_canonical_source_suppression(self): + nl_issues = validate_text_nodes("nl", [("body.badge", "PLAN")]) + it_issues = validate_text_nodes("it", [("body.badge", "PIANO")]) + self.assertFalse(any(issue.bad_value == "PLAN" for issue in nl_issues)) + self.assertFalse(any(issue.bad_value == "PIANO" for issue in it_issues)) + + def test_visible_text_extraction(self): + html = """ + + + +

Visible heading

+ + Visible link + + """ + extracted = extract_visible_rendered_text(html) + self.assertIn("Visible heading", extracted) + self.assertIn("Visible link", extracted) + self.assertNotIn("Invisible text", extracted) + self.assertNotIn("var x", extracted) + + def test_locale_normalizers(self): + de_agent = get_language_agent("de") + en_agent = get_language_agent("en") + self.assertEqual(de_agent.rewrite("Was du bekommst", "body.heading"), "Was Sie erhalten") + self.assertEqual(en_agent.rewrite("PLAN", "body.badge"), "Package") + + def test_audit_json_contract_shape(self): + payload = { + "run_id": 1, + "summary": {"en": {"total_urls_checked": 1, "issues_found": 0, "issues_fixed": 0, "remaining_issues": 0, "by_severity": {"block": 0, "warn": 0, "log": 0}}}, + "issues": {"en": []}, + } + rendered = json.dumps(payload) + parsed = json.loads(rendered) + self.assertEqual(sorted(parsed.keys()), ["issues", "run_id", "summary"]) + self.assertIn("by_severity", parsed["summary"]["en"]) diff --git a/mandelblog_content_guard/types.py b/mandelblog_content_guard/types.py new file mode 100644 index 0000000..800c5f1 --- /dev/null +++ b/mandelblog_content_guard/types.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any + +from .settings import classify_issue, severity_for_issue, should_block_issue + + +@dataclass +class AuditIssue: + severity: str + issue_type: str + field_path: str + bad_value: str + replacement: str = "" + extra: dict[str, Any] | None = None + + @property + def level(self) -> str: + return classify_issue(self.issue_type) + + @property + def blocks(self) -> bool: + return self.severity == "block" or should_block_issue(self.issue_type) + + def asdict(self) -> dict[str, Any]: + data = asdict(self) + data["extra"] = data.get("extra") or {} + data["level"] = self.level + return data + + +def make_issue(issue_type: str, field_path: str, bad_value: str, replacement: str = "", extra: dict[str, Any] | None = None) -> AuditIssue: + return AuditIssue( + severity=severity_for_issue(issue_type), + issue_type=issue_type, + field_path=field_path, + bad_value=bad_value, + replacement=replacement, + extra=extra or {}, + ) + + +def dedupe_issues(issues: list[AuditIssue]) -> list[AuditIssue]: + seen = set() + deduped = [] + for issue in issues: + key = (issue.severity, issue.issue_type, issue.field_path, issue.bad_value) + if key in seen: + continue + seen.add(key) + deduped.append(issue) + return deduped + + +def split_issues(issues: list[AuditIssue]) -> tuple[list[AuditIssue], list[AuditIssue]]: + blocking = [issue for issue in issues if issue.blocks] + warnings = [issue for issue in issues if not issue.blocks] + return blocking, warnings + + +def format_issue(issue: AuditIssue) -> str: + suffix = f" -> {issue.replacement}" if issue.replacement else "" + return f"[{issue.level}] {issue.field_path}: {issue.bad_value}{suffix}" + diff --git a/mandelblog_content_guard/validators/__init__.py b/mandelblog_content_guard/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mandelblog_content_guard/validators/multilingual.py b/mandelblog_content_guard/validators/multilingual.py new file mode 100644 index 0000000..e4f81e3 --- /dev/null +++ b/mandelblog_content_guard/validators/multilingual.py @@ -0,0 +1,452 @@ +from __future__ import annotations + +import logging +import re +from collections import Counter +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from django.core.exceptions import ValidationError +from django.utils import timezone +from wagtail.models import Page, Site +from wagtail.snippets.models import get_snippet_models + +from ..agents import get_language_agent +from ..extractors.visible_text import extract_visible_rendered_text, normalize_text +from ..settings import audit_default_locales, rewrite_enabled +from ..types import dedupe_issues, format_issue, make_issue +from .rules.cta import validate_cta +from .rules.forms import validate_form_copy +from .rules.language import detect_language_mismatch +from .rules.patterns import ( + GLOBAL_BAD_PATTERNS, + KNOWN_REPLACEMENTS, + LOCALE_FORBIDDEN, + validate_patterns, +) +from mandelstudio.models import LocaleAuditIssue, LocaleAuditRun + +logger = logging.getLogger("mandelstudio.multilingual") + + +def expected_locale(instance: Any) -> str: + locale = getattr(instance, "locale", None) + if locale is not None and getattr(locale, "language_code", None): + return locale.language_code + return "nl" + + +def iter_text_nodes(value: Any, path: str = ""): + if value is None: + return + if isinstance(value, str): + yield path, value + return + if hasattr(value, "raw_data"): + yield from iter_text_nodes(list(value.raw_data), path) + return + if isinstance(value, list): + for index, item in enumerate(value): + yield from iter_text_nodes(item, f"{path}[{index}]") + return + if isinstance(value, dict): + for key, item in value.items(): + child_path = f"{path}.{key}" if path else str(key) + yield from iter_text_nodes(item, child_path) + + +def extract_instance_text(instance: Any) -> list[tuple[str, str]]: + nodes: list[tuple[str, str]] = [] + for field_name in ["title", "seo_title", "search_description"]: + value = getattr(instance, field_name, None) + if isinstance(value, str) and value.strip(): + nodes.append((field_name, value)) + for field_name in ["body", "content", "footer", "mini_footer"]: + if hasattr(instance, field_name): + nodes.extend(list(iter_text_nodes(getattr(instance, field_name), field_name))) + return nodes + + +def validate_text_nodes(locale_code: str, nodes: list[tuple[str, str]]): + issues = [] + for field_path, raw_text in nodes: + normalized = normalize_text(raw_text) + if not normalized: + continue + issues.extend(validate_patterns(locale_code, field_path, normalized)) + issues.extend(validate_cta(locale_code, field_path, normalized)) + issues.extend(validate_form_copy(locale_code, field_path, normalized)) + if len(normalized) >= 80: + mismatch = detect_language_mismatch(locale_code, normalized) + if mismatch: + issues.append(make_issue("language_heuristic", field_path, mismatch["message"])) + return dedupe_issues(issues) + + +REWRITE_REVIEW_TYPES = { + "known_bad_pattern", + "wrong_language_fragment", + "rendered_bad_pattern", + "rendered_wrong_language", + "rewrite_candidate", + "weak_marketing_copy", + "foreign_ui_label", + "generic_badge_label", + "mixed_locale_heading", + "cta_language_mismatch", +} + + +def validate_page(page: Page): + return validate_text_nodes(expected_locale(page), extract_instance_text(page.specific)) + + +def validate_snippet_instance(instance: Any): + return validate_text_nodes(expected_locale(instance), extract_instance_text(instance)) + + +def validate_posted_snippet(locale_code: str, payload: dict[str, Any]): + nodes = [(key, value) for key, value in payload.items() if isinstance(value, str)] + return validate_text_nodes(locale_code, nodes) + + +def _replace_known_strings(value: Any, locale_code: str): + changes = [] + if isinstance(value, str): + new = value + for bad, replacements in KNOWN_REPLACEMENTS.items(): + replacement = replacements.get(locale_code) + if replacement and bad in new: + new = new.replace(bad, replacement) + changes.append({"bad": bad, "replacement": replacement}) + return new, changes, new != value + if isinstance(value, list): + out = [] + changed = False + for item in value: + new_item, item_changes, item_changed = _replace_known_strings(item, locale_code) + out.append(new_item) + changes.extend(item_changes) + changed = changed or item_changed + return out, changes, changed + if isinstance(value, dict): + out = {} + changed = False + for key, item in value.items(): + new_item, item_changes, item_changed = _replace_known_strings(item, locale_code) + out[key] = new_item + changes.extend(item_changes) + changed = changed or item_changed + return out, changes, changed + return value, changes, False + + +def apply_known_replacements(instance: Any, locale_code: str): + changes = [] + for field_name in ["title", "seo_title", "search_description"]: + value = getattr(instance, field_name, None) + if not isinstance(value, str): + continue + new_value, field_changes, changed = _replace_known_strings(value, locale_code) + if changed: + setattr(instance, field_name, new_value) + changes.extend({"field": field_name, **change} for change in field_changes) + + for field_name in ["body", "content", "footer", "mini_footer"]: + if not hasattr(instance, field_name): + continue + field_value = getattr(instance, field_name) + if hasattr(field_value, "raw_data"): + new_raw, field_changes, changed = _replace_known_strings(list(field_value.raw_data), locale_code) + if changed: + setattr(instance, field_name, new_raw) + changes.extend({"field": field_name, **change} for change in field_changes) + elif isinstance(field_value, str): + new_value, field_changes, changed = _replace_known_strings(field_value, locale_code) + if changed: + setattr(instance, field_name, new_value) + changes.extend({"field": field_name, **change} for change in field_changes) + + if not changes: + return [] + if isinstance(instance, Page): + revision = instance.save_revision() + if instance.live: + revision.publish() + return changes + instance.save() + return changes + + +def rewrite_with_agent(instance: Any, locale_code: str, issues, *, dry_run: bool = False): + if not rewrite_enabled(): + return [] + agent = get_language_agent(locale_code) + issue_map = agent.build_issue_map(issues) + changes = [] + + for field_name in ["title", "seo_title", "search_description"]: + value = getattr(instance, field_name, None) + if not isinstance(value, str): + continue + field_issues = issue_map.get(field_name, []) + rewritten = agent.rewrite(value, field_path=field_name, issues=field_issues) + if rewritten != value: + setattr(instance, field_name, rewritten) + changes.append({"field": field_name, "before": value, "after": rewritten, "method": "agent"}) + + for field_name in ["body", "content", "footer", "mini_footer"]: + if not hasattr(instance, field_name): + continue + field_value = getattr(instance, field_name) + if hasattr(field_value, "raw_data"): + rewritten, changed = agent.process_block(list(field_value.raw_data), field_name, issue_map) + if changed: + setattr(instance, field_name, rewritten) + changes.append({"field": field_name, "method": "agent"}) + elif isinstance(field_value, str): + rewritten = agent.rewrite(field_value, field_path=field_name, issues=issue_map.get(field_name, [])) + if rewritten != field_value: + setattr(instance, field_name, rewritten) + changes.append({"field": field_name, "before": field_value, "after": rewritten, "method": "agent"}) + + if not changes or dry_run: + return changes + if isinstance(instance, Page): + revision = instance.save_revision() + if instance.live: + revision.publish() + return changes + instance.save() + return changes + + +def enumerate_public_pages(locale_codes: list[str] | None = None, url_filters: list[str] | None = None): + result = {} + site = Site.objects.order_by("id").first() + site_root = getattr(site, "root_page", None) + normalized_filters = set(url_filters or []) + for locale_code in (locale_codes or audit_default_locales()): + locale_root_path = None + if site_root is not None: + translated_root = ( + Page.objects.filter( + translation_key=site_root.translation_key, + locale__language_code=locale_code, + ) + .specific() + .first() + ) + chosen_root = translated_root or site_root + locale_root_path = getattr(chosen_root, "path", None) + qs = ( + Page.objects.filter(locale__language_code=locale_code) + .live() + .public() + .specific() + .order_by("path") + ) + pages = [] + for page in qs: + page_url = getattr(page, "url", None) + if not page_url: + continue + if locale_root_path and not page.path.startswith(locale_root_path): + continue + if normalized_filters and page_url not in normalized_filters: + continue + pages.append(page) + result[locale_code] = pages + return result + + +def fetch_rendered_text(page: Page): + page_url = getattr(page, "url", None) + if not page_url: + return 598, "missing page URL" + if str(page_url).startswith("http"): + full_url = page_url + else: + try: + site = page.get_site() + except Site.DoesNotExist: + site = None + site = site or Site.objects.order_by("id").first() + if site is None or not getattr(site, "root_url", None): + return 598, "missing site root_url" + full_url = f"{site.root_url}{page_url}" + request = Request(full_url, headers={"User-Agent": "mandelstudio-audit/1.0"}) + try: + with urlopen(request, timeout=30) as response: + status = response.getcode() + body = response.read().decode("utf-8", errors="replace") + except HTTPError as exc: + status = exc.code + body = exc.read().decode("utf-8", errors="replace") + except URLError as exc: + status = 599 + body = str(exc) + text = extract_visible_rendered_text(body) + return status, text + + +def iter_rendered_lines(rendered_text: str) -> list[str]: + lines = [] + for chunk in re.split(r"(?<=[\.\!\?])\s+|\s{2,}", rendered_text): + normalized = normalize_text(chunk) + if normalized: + lines.append(normalized) + return lines + + +def validate_rendered_output(locale_code: str, rendered_text: str, status_code: int): + issues = [] + if status_code != 200: + issues.append(make_issue("render_status", "rendered", str(status_code))) + source_counter = Counter() + for line in iter_rendered_lines(rendered_text): + line_issues = validate_patterns(locale_code, "rendered", line) + for issue in line_issues: + issue.bad_value = line + issue.extra = {**(issue.extra or {}), "source": "rendered"} + source_counter[(issue.issue_type, issue.bad_value)] += 1 + issues.extend(line_issues) + for issue in issues: + if issue.extra is not None: + issue.extra["count"] = source_counter.get((issue.issue_type, issue.bad_value), 1) + for fragment in GLOBAL_BAD_PATTERNS: + if fragment in rendered_text: + issue = make_issue("rendered_bad_pattern", "rendered", fragment, KNOWN_REPLACEMENTS.get(fragment, {}).get(locale_code, "")) + issue.extra = {"source": "rendered", "count": 1} + issues.append(issue) + for fragment in LOCALE_FORBIDDEN.get(locale_code, ()): + if fragment in rendered_text: + issue = make_issue("rendered_wrong_language", "rendered", fragment, KNOWN_REPLACEMENTS.get(fragment, {}).get(locale_code, "")) + issue.extra = {"source": "rendered", "count": 1} + issues.append(issue) + return dedupe_issues(issues) + + +def annotate_rewrite_previews(locale_code: str, issues): + agent = get_language_agent(locale_code) + for issue in issues: + if issue.issue_type not in REWRITE_REVIEW_TYPES: + continue + if issue.replacement: + continue + preview = agent.rewrite(issue.bad_value, field_path=issue.field_path, issues=[issue]) + if preview and preview != issue.bad_value: + issue.replacement = preview + issue.extra = {**(issue.extra or {}), "review_candidate": True} + return issues + + +def validate_instance_or_raise(instance: Any): + issues = validate_page(instance) if isinstance(instance, Page) else validate_snippet_instance(instance) + blocking = [issue for issue in issues if issue.blocks] + if not blocking: + return issues + raise ValidationError({"content_guard": [format_issue(issue) for issue in blocking]}) + + +def validate_ai_text_or_raise(locale_code: str, field_path: str, value: str): + issues = validate_text_nodes(locale_code, [(field_path, value)]) + blocking = [issue for issue in issues if issue.blocks] + if not blocking: + return issues + raise ValidationError({"content_guard": [format_issue(issue) for issue in blocking]}) + + +def record_issues(run: LocaleAuditRun, locale_code: str, obj: Any, issues, *, fixed: bool = False) -> None: + for issue in issues: + LocaleAuditIssue.objects.create( + run=run, + locale_code=locale_code, + object_id=getattr(obj, "pk", None), + object_type=obj.__class__.__name__, + url=getattr(obj, "url", "") or "", + title=getattr(obj, "title", str(obj))[:255], + severity=issue.severity, + issue_type=issue.issue_type, + field_path=issue.field_path, + bad_value=issue.bad_value, + replacement=issue.replacement, + fixed=fixed, + extra=issue.extra or {}, + ) + + +def audit_locales(locale_codes: list[str], fix: bool = False, rewrite: bool = False, dry_run: bool = False, url_filters: list[str] | None = None) -> LocaleAuditRun: + run = LocaleAuditRun.objects.create(locale_codes=locale_codes, fix_enabled=fix or rewrite) + pages_by_locale = enumerate_public_pages(locale_codes, url_filters=url_filters) + summary: dict[str, Any] = {} + total_checked = 0 + total_issues = 0 + pages_with_issues = 0 + + for locale_code, pages in pages_by_locale.items(): + locale_summary = {"total_urls_checked": len(pages), "issues_found": 0, "issues_fixed": 0, "remaining_issues": 0, "by_severity": {"block": 0, "warn": 0, "log": 0}} + for page in pages: + total_checked += 1 + status_code, rendered = fetch_rendered_text(page) + issues = dedupe_issues(validate_page(page) + validate_rendered_output(locale_code, rendered, status_code)) + if rewrite: + issues = annotate_rewrite_previews(locale_code, issues) + initial_issue_count = len(issues) + fixed_changes = [] + if issues and fix: + fixed_changes = apply_known_replacements(page.specific, locale_code) + if fixed_changes: + record_issues(run, locale_code, page, issues, fixed=True) + status_code, rendered = fetch_rendered_text(page.specific) + issues = dedupe_issues(validate_page(page.specific) + validate_rendered_output(locale_code, rendered, status_code)) + if rewrite: + issues = annotate_rewrite_previews(locale_code, issues) + if issues and rewrite: + rewrite_changes = rewrite_with_agent(page.specific, locale_code, issues, dry_run=dry_run) + if rewrite_changes: + record_issues(run, locale_code, page, issues, fixed=not dry_run) + if not dry_run: + status_code, rendered = fetch_rendered_text(page.specific) + issues = dedupe_issues(validate_page(page.specific) + validate_rendered_output(locale_code, rendered, status_code)) + issues = annotate_rewrite_previews(locale_code, issues) + if issues: + pages_with_issues += 1 + record_issues(run, locale_code, page, issues) + locale_summary["issues_found"] += initial_issue_count + locale_summary["issues_fixed"] += initial_issue_count - len(issues) + locale_summary["remaining_issues"] += len(issues) + for issue in issues: + locale_summary["by_severity"][issue.severity] = locale_summary["by_severity"].get(issue.severity, 0) + 1 + total_issues += initial_issue_count + summary[locale_code] = locale_summary + + snippet_summary = {} + for model in get_snippet_models(): + count = 0 + for instance in model.objects.all(): + issues = validate_snippet_instance(instance) + if rewrite: + issues = annotate_rewrite_previews(expected_locale(instance), issues) + if issues and rewrite: + rewrite_changes = rewrite_with_agent(instance, expected_locale(instance), issues, dry_run=dry_run) + if rewrite_changes and not dry_run: + issues = validate_snippet_instance(instance) + if not issues: + continue + count += len(issues) + record_issues(run, expected_locale(instance), instance, issues) + if count: + snippet_summary[model.__name__] = count + total_issues += count + summary["snippets"] = snippet_summary + + run.total_urls_checked = total_checked + run.issues_found = total_issues + run.pages_with_issues = pages_with_issues + run.summary = summary + run.finished_at = timezone.now() + run.save(update_fields=["total_urls_checked", "issues_found", "pages_with_issues", "summary", "finished_at"]) + logger.info("Completed multilingual audit run %s", run.pk) + return run diff --git a/mandelblog_content_guard/validators/rules/__init__.py b/mandelblog_content_guard/validators/rules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mandelblog_content_guard/validators/rules/cta.py b/mandelblog_content_guard/validators/rules/cta.py new file mode 100644 index 0000000..bfe0d91 --- /dev/null +++ b/mandelblog_content_guard/validators/rules/cta.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import re + +from ...types import make_issue + +CTA_RULES = { + "nl": ( + r"^Plan ", + r"^Bekijk ", + r"^Vraag ", + r"^Bespreek ", + r"^Contact$", + r"^Start ", + r"^Meer ", + r"^Verstuur ", + r"^Neem ", + ), + "en": ( + r"^Book ", + r"^View ", + r"^Schedule ", + r"^Start ", + r"^Talk ", + r"^Discuss ", + r"^Contact$", + r"^Explore ", + r"^Learn ", + r"^Request ", + r"^Send ", + ), + "de": ( + r"^Plan", + r"^Mehr", + r"^Support", + r"^Start", + r"^Kontakt", + r"^Gespr", + r"^Kostenlose", + r"^Anfrage", + r"^Projekte", + r"^Verein", + r"^Besprech", + r"^Anzeig", + r"^Ansehen", + r"^Technisch", + r"^Unterst", + r"^Unsere", + r"^Service", + r"^Dienstleistungen", + r"^Erstgespräch", + r"^Einführ", + r"^Anpassung", + r"^Ansichts", + r"^Prozess", + r"^Pakete", + r"^Demo", + r"^Alle ", + r"^Ein ", + r"^Webshop", + ), + "fr": ( + r"^Planifier", + r"^Voir", + r"^Découvrir", + r"^Demander", + r"^Lancer", + r"^Démarrer", + r"^Contacter", + r"^Contact$", + r"^Parler", + r"^Lancez", + r"^Prendre", + r"^Envoyer", + r"^Afficher", + ), + "es": ( + r"^Reservar", + r"^Ver", + r"^Solicitar", + r"^Inicia", + r"^Hablar", + r"^Descubrir", + r"^Contactar", + r"^Planificar", + r"^Programe", + r"^Concertar", + r"^Enviar", + r"^Mostrar", + r"^Comenta", + ), + "it": ( + r"^Prenota", + r"^Vedi", + r"^Avvia", + r"^Richiedi", + r"^Contatta", + r"^Contatto$", + r"^Scopri", + r"^Pianifica", + r"^Invia", + r"^Mostra", + r"^Parla", + r"^Parliamo", + ), + "pt": ( + r"^Agendar", + r"^Ver", + r"^Iniciar", + r"^Pedir", + r"^Contactar", + r"^Falar", + r"^Explorar", + r"^Marcar", + r"^Solicitar", + r"^Enviar", + r"^Mostrar", + ), + "ru": ( + r"^Заплан", + r"^Посмотр", + r"^Запуст", + r"^Связ", + r"^Подробнее", + r"^Показать", + r"^Отправ", + r"^Получ", + r"^Запрос", + ), +} + +CTA_FIELDS = { + "cta_text", + "primary_cta_text", + "secondary_cta_text", + "submit_button_text", +} + + +def validate_cta(locale_code: str, field_path: str, normalized: str): + last_segment = field_path.split(".")[-1] + if last_segment not in CTA_FIELDS: + return [] + if any(re.search(pattern, normalized) for pattern in CTA_RULES.get(locale_code, ())): + return [] + return [make_issue("cta_language_mismatch", field_path, normalized)] diff --git a/mandelblog_content_guard/validators/rules/forms.py b/mandelblog_content_guard/validators/rules/forms.py new file mode 100644 index 0000000..3cb5f3c --- /dev/null +++ b/mandelblog_content_guard/validators/rules/forms.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from ...types import make_issue +from .patterns import PLACEHOLDER_VALUES +from .language import detect_language_mismatch + +FORM_FIELDS = {"label", "placeholder", "help_text"} + + +def validate_form_copy(locale_code: str, field_path: str, normalized: str): + last_segment = field_path.split(".")[-1] + if last_segment not in FORM_FIELDS: + return [] + issues = [] + if normalized in PLACEHOLDER_VALUES or normalized == "": + issues.append(make_issue("empty_form_copy", field_path, normalized)) + mismatch = detect_language_mismatch(locale_code, normalized) + if mismatch: + issues.append(make_issue("form_language_mismatch", field_path, mismatch["message"])) + return issues + diff --git a/mandelblog_content_guard/validators/rules/language.py b/mandelblog_content_guard/validators/rules/language.py new file mode 100644 index 0000000..e55ed28 --- /dev/null +++ b/mandelblog_content_guard/validators/rules/language.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import re + +STOPWORDS = { + "nl": {"de", "het", "een", "en", "voor", "met", "van", "je", "wij", "niet"}, + "en": {"the", "and", "for", "with", "your", "you", "from", "that", "this", "not"}, + "de": {"der", "die", "das", "und", "mit", "für", "nicht", "eine", "ist", "sie"}, + "fr": {"le", "la", "les", "et", "avec", "pour", "vous", "une", "pas", "des"}, + "es": {"el", "la", "los", "las", "con", "para", "una", "que", "del", "por"}, + "it": {"il", "la", "con", "per", "una", "che", "del", "non", "gli", "dei"}, + "pt": {"o", "a", "os", "as", "com", "para", "uma", "que", "não", "dos"}, + "ru": {"и", "в", "на", "с", "для", "что", "это", "как", "по", "не"}, +} + + +def _tokenize(text: str) -> list[str]: + text = re.sub(r"<[^>]+>", " ", text) + return re.findall(r"[\w\u0400-\u04FF']+", text.lower()) + + +def detect_language_mismatch(locale_code: str, text: str): + tokens = _tokenize(text) + if len(tokens) < 12: + return None + scores = {code: sum(1 for token in tokens if token in words) for code, words in STOPWORDS.items()} + expected = scores.get(locale_code, 0) + foreign_locale, foreign_score = max(scores.items(), key=lambda item: item[1]) + if foreign_locale == locale_code: + return None + if expected >= foreign_score: + return None + if foreign_score >= 6 and foreign_score >= expected + 4: + return { + "severity": "block", + "message": f"expected={locale_code}, detected={foreign_locale}, score={foreign_score}, expected_score={expected}", + } + if expected == 0 and foreign_score >= 5: + return { + "severity": "warn", + "message": f"expected={locale_code}, detected={foreign_locale}, score={foreign_score}, expected_score={expected}", + } + return None diff --git a/mandelblog_content_guard/validators/rules/patterns.py b/mandelblog_content_guard/validators/rules/patterns.py new file mode 100644 index 0000000..9a3297e --- /dev/null +++ b/mandelblog_content_guard/validators/rules/patterns.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import re + +from ...types import make_issue +from ...system_strings import ( + build_system_rewrite_candidates, + is_canonical_system_string, + system_string_replacement, +) + +GLOBAL_BAD_PATTERNS = ( + "The Spanish translation", + "The Spanish translation of", + "As the input", + "The input", + "Poiché l'input", + 'Unternehmen" è tedesco', + "Support anzeigen", + "Starter intake", + "Business intake", + "Plan Starter intake", + "Plan Business intake", + "Plan de admisión", + "None", +) + +LOCALE_FORBIDDEN = { + "nl": ("Starter intake", "Business intake", "Poiché", "Correo electrónico", "Mostrar los servicios", "Plan de admisión"), + "en": ("Starter intake", "Business intake", "Poiché", "Correo electrónico", "Mostrar los servicios", "Questions fréquemment posées", "Plan de admisión"), + "de": ("Starter intake", "Business intake", "Poiché", "Correo electrónico", "Mostrar los servicios", "Questions fréquemment posées", "Plan de admisión"), + "fr": ("Starter intake", "Business intake", "Poiché", "Correo electrónico", "Mostrar los servicios", "Plan de admisión", "Support anzeigen"), + "es": ("Poiché", 'Unternehmen" è tedesco', "Support anzeigen", "Questions fréquemment posées"), + "it": ("Poiché l'input", "Consulta inicial sin compromiso", "Mostrar los servicios", "Questions fréquentes", "Plan de admisión", "Correo electrónico"), + "pt": ("Poiché l'input", "Consulta inicial sin compromiso", "Mostrar los servicios", "Correo electrónico", 'Unternehmen" è tedesco', "Questions fréquemment posées"), + "ru": ("Poiché l'input", "Consulta inicial sin compromiso", "Correo electrónico", 'Unternehmen" è tedesco', "Mostrar los servicios"), +} + +PLACEHOLDER_VALUES = {"None", "-", "N/A", "null"} + +GENERIC_BADGE_LABELS = { + "New", + "Popular", + "PLAN", + "PIANO", + "SERVICES", +} + +GLOBAL_REWRITE_CANDIDATES = { + **build_system_rewrite_candidates( + ( + "days_label", + "average_delivery", + "response_time", + "without_commitment", + "transparent_label", + "weeks_1_2", + "customer_reviews", + "editable_label", + "core_pages_label", + "detailed_page_structure", + "business_process_cta", + "multilingual_rollout", + "customization_integrations", + "transparent_investment", + ) + ), +} + +LOCALE_REWRITE_CANDIDATES = { + "en": { + "Service packages (from) Transparent starting points.": "foreign_ui_label", + "Frequently Asked Questions Transparent about planning, approach, and management.": "foreign_ui_label", + "Transparent investment": "foreign_ui_label", + }, + "de": { + "New": "weak_marketing_copy", + "Intakegespräch": "weak_marketing_copy", + "SEO-ready basis": "foreign_ui_label", + "Sales-ready mit skalierbarem Stack": "foreign_ui_label", + "Continuous Verbesserung": "foreign_ui_label", + "Was du bekommst": "weak_marketing_copy", + "Einführungsmeeting": "weak_marketing_copy", + "Starter Website": "weak_marketing_copy", + "Business Website": "weak_marketing_copy", + "Häufig gestellte Fragen Transparent über Planung, Vorgehensweise und Management.": "foreign_ui_label", + }, + "es": { + "Preguntas frecuentes Transparente sobre la planificación, el proceso y la gestión.": "foreign_ui_label", + "Unverbindliches Gespräch, klares Angebot": "foreign_ui_label", + }, + "pt": { + "Siti web e negozi online": "mixed_locale_heading", + "Caso de cliente en directo": "weak_marketing_copy", + "El primer proyecto de producción finalizado con éxito.": "weak_marketing_copy", + "Más sobre el proceso": "foreign_ui_label", + "Modifiez simplement vous-même.": "foreign_ui_label", + "Opciones de la tienda web": "foreign_ui_label", + "Planes de soporte": "foreign_ui_label", + "Multilingüe": "foreign_ui_label", + "Unsere Serviços": "mixed_locale_heading", + "Elija el camino": "mixed_locale_heading", + "Début en direct": "foreign_ui_label", + "Demande d'admission initiale": "foreign_ui_label", + "Site Web d'Entreprise": "foreign_ui_label", + "Hablar sobre el proceso empresarial": "foreign_ui_label", + "Mise en place de boutique en ligne": "foreign_ui_label", + "Maintenance & gestion": "foreign_ui_label", + "Afficher le plan de soutien": "foreign_ui_label", + "Introducción multilingüe": "foreign_ui_label", + "Forfaits de services (à partir de)": "mixed_locale_heading", + "Kundenschätzung": "foreign_ui_label", + "Gestisca lei stesso il contenuto": "foreign_ui_label", + "Optimizado para móviles": "foreign_ui_label", + "Schnell online mit einer starken Basis": "weak_marketing_copy", + "La entrada \"Unterstützung oder Erweiterung\"": "foreign_ui_label", + "Suivi + corrections": "foreign_ui_label", + "Mejoras mensuales": "foreign_ui_label", + "¿A qué velocidad puede comenzar?": "foreign_ui_label", + "¿Puedo editar textos e imágenes yo mismo?": "foreign_ui_label", + "Transparente sobre o planejamento, o processo e a gestão.": "foreign_ui_label", + "Ab 2.250 €": "foreign_ui_label", + "Boutique en ligne": "foreign_ui_label", + "Sales-ready mit skalierbarem Stack": "foreign_ui_label", + }, + "fr": { + "Erstes Produktionsprojekt erfolgreich abgeschlossen.": "weak_marketing_copy", + "Von Kickoff bis zum Launch mit einem klaren Umfang.": "foreign_ui_label", + "Demande d'admission initiale": "weak_marketing_copy", + "Entretien d'accueil": "weak_marketing_copy", + "Vraag over diensten": "foreign_ui_label", + "Konkrete erste Schätzung": "foreign_ui_label", + "Ansatz, der zu Ihrem Budget passt": "foreign_ui_label", + **build_system_rewrite_candidates(("weeks_2_4",)), + "Bereit, mit der Business-Website zu starten?": "foreign_ui_label", + }, + "it": { + "Planificación clara": "foreign_ui_label", + "Mehrsprachiger Rollout-Plan": "foreign_ui_label", + "Unverbindliches Gespräch, klares Angebot": "foreign_ui_label", + }, + "ru": { + "Base prête pour le SEO": "foreign_ui_label", + "Unverbindliches Gespräch, klares Angebot": "foreign_ui_label", + }, +} + +KNOWN_REPLACEMENTS = { + "Starter intake": { + "nl": "Plan startergesprek", + "en": "Book starter call", + "de": "Starter-Gespräch planen", + "fr": "Planifier l’entretien de départ", + "es": "Reservar llamada inicial", + "it": "Prenota una chiamata iniziale", + "pt": "Agendar chamada inicial", + "ru": "Запланировать стартовый звонок", + }, + "Business intake": { + "nl": "Plan zakelijk gesprek", + "en": "Book business call", + "de": "Beratungsgespräch planen", + "fr": "Planifier l’entretien commercial", + "es": "Reservar llamada comercial", + "it": "Prenota una chiamata commerciale", + "pt": "Agendar chamada comercial", + "ru": "Запланировать деловой звонок", + }, + "Plan Starter intake": { + "nl": "Plan startergesprek", + "en": "Book starter call", + "de": "Starter-Gespräch planen", + "fr": "Planifier l’entretien de départ", + "es": "Reservar llamada inicial", + "it": "Prenota una chiamata iniziale", + "pt": "Agendar chamada inicial", + "ru": "Запланировать стартовый звонок", + }, + "Plan Business intake": { + "nl": "Plan zakelijk gesprek", + "en": "Book business call", + "de": "Beratungsgespräch planen", + "fr": "Planifier l’entretien commercial", + "es": "Reservar llamada comercial", + "it": "Prenota una chiamata commerciale", + "pt": "Agendar chamada comercial", + "ru": "Запланировать деловой звонок", + }, + "Mostrar los servicios": { + "es": "Mostrar los servicios", + "it": "Vedi servizi", + "pt": "Ver serviços", + "ru": "Показать услуги", + }, + "Correo electrónico": {"pt": "E-mail", "ru": "Электронная почта"}, + 'Unternehmen" è tedesco, non olandese. La traduzione spagnola di "Unternehmen" è "empresa".': { + "pt": "Empresa", + "ru": "Компания", + }, + 'Poiché l\'input "Unverbindliche Erstberatung" è in tedesco (non in olandese), la traduzione in spagnolo è: "Consulta inicial sin compromiso".': { + "it": "Senza impegno", + "pt": "Sem compromisso", + "ru": "Без обязательств", + "es": "Consulta inicial sin compromiso", + }, +} + + +def _contains_fragment(text: str, fragment: str) -> bool: + if re.fullmatch(r"[\wÀ-ÿ-]+", fragment, flags=re.UNICODE): + pattern = re.compile(rf"(? + + +

Visible title

+

Hidden copy

+ + Visible link + + + """ + extracted = extract_visible_rendered_text(html) + self.assertIn("Visible title", extracted) + self.assertIn("Visible link", extracted) + self.assertNotIn("Hidden copy", extracted) + self.assertNotIn("Also hidden", extracted) + self.assertNotIn("var foo", extracted) + + def test_system_strings_are_centralized_for_fr_and_pt(self): + self.assertEqual(build_system_vocabulary("fr")["PLAN"], "FORFAIT") + self.assertEqual(build_system_vocabulary("fr")["Reaktionszeit"], "Temps de réponse") + self.assertEqual(build_system_vocabulary("pt")["Transparent"], "Transparente") + self.assertEqual(build_system_vocabulary("fr")["Transparente Investition"], "Investissement transparent") + self.assertEqual(build_system_vocabulary("pt")["Transparente Investition"], "Investimento transparente") + self.assertEqual(build_system_rewrite_candidates()["Durchschnittliche Lieferung"], "foreign_ui_label") + + +class AuditLocalesCommandTests(SimpleTestCase): + @mock.patch("mandelblog_content_guard.management.commands.audit_locales.audit_locales") + def test_json_output(self, audit_locales_mock): + run = mock.Mock() + run.pk = 12 + run.total_urls_checked = 2 + run.issues_found = 1 + run.summary = {"en": {"total_urls_checked": 2, "issues_found": 1, "by_severity": {"block": 1}}} + issue = mock.Mock( + url="/en/contact/", + title="Contact", + severity="block", + issue_type="wrong_language_fragment", + field_path="body.form.label", + bad_value="Correo electrónico", + replacement="Email", + fixed=False, + ) + run.issues.all.return_value.order_by.return_value = [issue] + audit_locales_mock.return_value = run + + out = StringIO() + call_command("audit_locales", "--locale", "en", "--format=json", stdout=out) + rendered = out.getvalue().strip() + payload = json.loads(rendered) + self.assertEqual(payload["run_id"], 12) + self.assertEqual(payload["issues"]["en"][0]["bad_value"], "Correo electrónico") + + @mock.patch("mandelblog_content_guard.management.commands.audit_locales.audit_locales") + def test_rewrite_flags_are_forwarded(self, audit_locales_mock): + run = mock.Mock() + run.pk = 13 + run.total_urls_checked = 1 + run.issues_found = 0 + run.summary = {"pt": {"total_urls_checked": 1, "issues_found": 0, "issues_fixed": 0, "by_severity": {"block": 0, "warn": 0, "log": 0}}} + run.issues.all.return_value.order_by.return_value = [] + audit_locales_mock.return_value = run + + out = StringIO() + call_command("audit_locales", "--locale", "pt", "--rewrite", "--dry-run", stdout=out) + audit_locales_mock.assert_called_once_with(["pt"], fix=False, rewrite=True, dry_run=True) diff --git a/mandelstudio/validators/__init__.py b/mandelstudio/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mandelstudio/validators/multilingual.py b/mandelstudio/validators/multilingual.py new file mode 100644 index 0000000..2f09897 --- /dev/null +++ b/mandelstudio/validators/multilingual.py @@ -0,0 +1 @@ +from mandelblog_content_guard.validators.multilingual import * # noqa: F401,F403 diff --git a/mandelstudio/wagtail_hooks.py b/mandelstudio/wagtail_hooks.py new file mode 100644 index 0000000..6a677cb --- /dev/null +++ b/mandelstudio/wagtail_hooks.py @@ -0,0 +1 @@ +from mandelblog_content_guard.hooks import * # noqa: F401,F403 diff --git a/scripts/multilingual_audit_ci.py b/scripts/multilingual_audit_ci.py new file mode 100755 index 0000000..e5bda67 --- /dev/null +++ b/scripts/multilingual_audit_ci.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +def load_json(path: Path) -> dict: + return json.loads(path.read_text()) + + +def locale_rows(payload: dict) -> list[tuple[str, dict]]: + summary = payload.get("summary", {}) + return [(locale, data) for locale, data in summary.items() if locale != "snippets"] + + +def print_error(payload: dict) -> int: + error = payload.get("error") + if error: + print(f"AUDIT ERROR: {error}") + return 2 + return 0 + + +def print_summary(payload: dict) -> tuple[int, int]: + total_block = 0 + total_warn = 0 + for locale, data in locale_rows(payload): + sev = data.get("by_severity", {}) + block = int(sev.get("block", 0) or 0) + warn = int(sev.get("warn", 0) or 0) + log = int(sev.get("log", 0) or 0) + total_block += block + total_warn += warn + print( + f"LOCALE {locale}: issues_found={data.get('issues_found', 0)} " + f"issues_remaining={data.get('remaining_issues', 0)} " + f"block={block} warn={warn} log={log}" + ) + return total_block, total_warn + + +def print_regressions(current: dict, previous: dict) -> None: + prev_summary = {locale: data for locale, data in locale_rows(previous)} + regressions = [] + for locale, data in locale_rows(current): + prev = prev_summary.get(locale, {}) + cur_remaining = int(data.get("remaining_issues", 0) or 0) + prev_remaining = int(prev.get("remaining_issues", 0) or 0) + cur_sev = data.get("by_severity", {}) + prev_sev = prev.get("by_severity", {}) + delta = { + "remaining": cur_remaining - prev_remaining, + "block": int(cur_sev.get("block", 0) or 0) - int(prev_sev.get("block", 0) or 0), + "warn": int(cur_sev.get("warn", 0) or 0) - int(prev_sev.get("warn", 0) or 0), + "log": int(cur_sev.get("log", 0) or 0) - int(prev_sev.get("log", 0) or 0), + } + if any(value > 0 for value in delta.values()): + regressions.append((locale, delta)) + if regressions: + print("REGRESSIONS:") + for locale, delta in regressions: + print( + f"- {locale}: remaining={delta['remaining']:+d} block={delta['block']:+d} " + f"warn={delta['warn']:+d} log={delta['log']:+d}" + ) + else: + print("REGRESSIONS: none") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--json", required=True, help="Current multilingual audit JSON file") + parser.add_argument("--previous-json", help="Optional previous audit JSON file for regression comparison") + args = parser.parse_args() + + current = load_json(Path(args.json)) + error_status = print_error(current) + if error_status: + return error_status + total_block, total_warn = print_summary(current) + + if args.previous_json: + prev_path = Path(args.previous_json) + if prev_path.exists(): + print_regressions(current, load_json(prev_path)) + else: + print("REGRESSIONS: previous artifact not found") + + if total_block > 0: + return 2 + if total_warn > 0: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_remote_multilingual_audit.sh b/scripts/run_remote_multilingual_audit.sh new file mode 100755 index 0000000..90cfd32 --- /dev/null +++ b/scripts/run_remote_multilingual_audit.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${STAGING_AUDIT_HOST:?STAGING_AUDIT_HOST is required}" +: "${STAGING_AUDIT_PROJECT_DIR:?STAGING_AUDIT_PROJECT_DIR is required}" +: "${STAGING_AUDIT_MANAGE:?STAGING_AUDIT_MANAGE is required}" + +mkdir -p artifacts +SSH_OPTS=${SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"} +if [[ -n "${STAGING_SSH_KEYFILE:-}" ]]; then + SSH_OPTS="$SSH_OPTS -i ${STAGING_SSH_KEYFILE}" +fi +AUDIT_TIMEOUT_SECONDS=${AUDIT_TIMEOUT_SECONDS:-300} +OUT_FILE="artifacts/multilingual-audit.json" +TMP_FILE="${OUT_FILE}.tmp" + +write_failure_json() { + python3 - < "$OUT_FILE" +import json +print(json.dumps({ + "run_id": None, + "total_urls_checked": 0, + "issues_found": 0, + "summary": {}, + "issues": {}, + "error": ${1@Q} +}, indent=2)) +PY +} + +REMOTE_CMD="cd '${STAGING_AUDIT_PROJECT_DIR}' && '${STAGING_AUDIT_MANAGE}' audit_locales --format=json" +set +e +SSH_OPTS="$SSH_OPTS" STAGING_AUDIT_HOST="$STAGING_AUDIT_HOST" REMOTE_CMD="$REMOTE_CMD" AUDIT_TIMEOUT_SECONDS="$AUDIT_TIMEOUT_SECONDS" python3 - <<'PY' > "$TMP_FILE" +import os +import shlex +import subprocess +import sys + +ssh_opts = shlex.split(os.environ["SSH_OPTS"]) +cmd = ["ssh", *ssh_opts, os.environ["STAGING_AUDIT_HOST"], os.environ["REMOTE_CMD"]] +try: + proc = subprocess.run( + cmd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=int(os.environ["AUDIT_TIMEOUT_SECONDS"]), + ) + sys.stdout.write(proc.stdout) + sys.stderr.write(proc.stderr) +except subprocess.TimeoutExpired as exc: + sys.stderr.write(exc.stderr or "") + raise SystemExit(124) +except subprocess.CalledProcessError as exc: + sys.stdout.write(exc.stdout or "") + sys.stderr.write(exc.stderr or "") + raise SystemExit(exc.returncode) +PY +rc=$? +set -e +if [[ $rc -eq 0 ]]; then + mv "$TMP_FILE" "$OUT_FILE" + exit 0 +fi +rm -f "$TMP_FILE" +if [[ $rc -eq 124 ]]; then + write_failure_json "Remote multilingual audit timed out after ${AUDIT_TIMEOUT_SECONDS}s" +else + write_failure_json "Remote multilingual audit failed with exit status ${rc}" +fi +exit $rc From c516d72c8a76284cf8ae59d90cd7923667676b1f Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Sun, 29 Mar 2026 20:57:58 +0200 Subject: [PATCH 05/45] Document multilingual audit CI operations --- docs/CI_MULTILINGUAL_AUDIT.md | 142 ++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/CI_MULTILINGUAL_AUDIT.md diff --git a/docs/CI_MULTILINGUAL_AUDIT.md b/docs/CI_MULTILINGUAL_AUDIT.md new file mode 100644 index 0000000..2de875a --- /dev/null +++ b/docs/CI_MULTILINGUAL_AUDIT.md @@ -0,0 +1,142 @@ +# CI Multilingual Audit + +## Purpose +The multilingual audit verifies that public content stays locale-correct across all active MandelBlog languages after deploy. + +It checks rendered, user-facing text for: +- mixed-language fragments +- foreign UI labels +- weak or generic badge labels flagged by policy +- locale-specific normalization problems + +It does not modify content in CI. It only audits and reports. + +## Jenkins jobs + +### Main pipeline: `mandelstudio` +The main pipeline runs a post-deploy multilingual audit after staging deployment completes. + +Stages relevant to multilingual quality: +1. `Deploy Staging` +2. `Post-Deploy Multilingual Audit` + +The audit stage runs remotely on staging: +```bash +python manage.py audit_locales --format=json +``` + +Artifact archived: +- `artifacts/multilingual-audit.json` + +### Nightly pipeline +Pipeline source: +- `Jenkinsfile.multilingual-nightly` + +Schedule: +- `H 2 * * *` + +The nightly job: +- runs the full multilingual audit on staging +- archives the latest JSON artifact +- compares the current artifact against the previous artifact +- prints regressions by locale + +## Build result policy +The audit summary is interpreted as follows: + +- `SUCCESS` + - all locales have `block=0` and `warn=0` +- `UNSTABLE` + - at least one locale has `warn > 0` + - deploy is not blocked +- `FAILED` + - at least one locale has `block > 0` + - or audit execution itself fails + +This keeps deploys safe without making warning-level cleanup a hard blocker. + +## Required Jenkins credential +Credential location: +- `Manage Jenkins -> Credentials -> System -> Global credentials` + +Credential to add: +- `Kind`: `SSH Username with private key` +- `ID`: `staging-root-ssh` +- `Username`: `root` +- `Private key`: staging SSH key + +Current implementation uses the following environment defaults: +- `STAGING_AUDIT_HOST=root@49.12.204.96` +- `STAGING_AUDIT_PROJECT_DIR=/home/www-mandelstudio/mandelstudio` +- `STAGING_AUDIT_MANAGE=/var/lib/virtualenv/mandelstudio/bin/manage.py` + +## Console summary +The Jenkins stage prints a per-locale summary like this: +```text +LOCALE en: issues_found=0 issues_remaining=0 block=0 warn=0 log=0 +``` + +Nightly runs also print regressions when present: +```text +REGRESSIONS: +- es: remaining=+2 block=+0 warn=+2 log=+0 +``` + +If no regressions exist: +```text +REGRESSIONS: none +``` + +## Artifact structure +Archived file: +- `artifacts/multilingual-audit.json` + +Expected clean structure: +- `run_id` +- `total_urls_checked` +- `issues_found` +- `summary` +- `issues` + +Failure artifacts may also contain: +- `error` + +This happens when the remote audit times out or fails, and is intentional so Jenkins still archives a machine-readable result. + +## Local rerun +To rerun the same remote audit flow locally: +```bash +export STAGING_AUDIT_HOST='root@49.12.204.96' +export STAGING_AUDIT_PROJECT_DIR='/home/www-mandelstudio/mandelstudio' +export STAGING_AUDIT_MANAGE='/var/lib/virtualenv/mandelstudio/bin/manage.py' +./scripts/run_remote_multilingual_audit.sh +python3 scripts/multilingual_audit_ci.py --json artifacts/multilingual-audit.json +``` + +To compare against a previous artifact: +```bash +python3 scripts/multilingual_audit_ci.py \ + --json artifacts/multilingual-audit.json \ + --previous-json artifacts/previous-multilingual-audit.json +``` + +## Fixing issues by locale +Recommended response sequence for any locale that returns warnings or blocks: +1. run scoped dry-run audit for the affected locale/pages +2. inspect before/after rewrite candidates +3. apply controlled rewrite only to affected pages +4. rerun post-audit +5. manually verify rendered output + +Do not bulk rewrite a locale tree without scoped review first. + +## Operational notes +- remote audit execution has a timeout +- audit failure still produces JSON output for Jenkins archiving +- missing previous nightly artifact is handled gracefully + +## Hardening candidates +These are operational follow-ups only. They are not required for current behavior. +- replace `root` SSH with a dedicated deploy/audit user +- move host/path configuration into Jenkins-managed environment variables or folder-level config +- document SSH credential rotation procedure From 51b2fd574c3c4d9f22d944fb75c887262073ea3e Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Sun, 29 Mar 2026 21:25:01 +0200 Subject: [PATCH 06/45] Format multilingual audit extraction for CI lint --- mandelstudio/models.py | 4 +- mandelstudio/settings/base.py | 4 +- .../template_engine/0001_initial.py | 5 +- .../0002_templateenginesitesettings.py | 5 +- ...03_templateenginesitesettings_nav_items.py | 5 +- ...mepage_body_alter_basestandardpage_body.py | 5 +- ...inesitesettings_header_variant_and_more.py | 5 +- ...nginesitesettings_footer_dynamic_fields.py | 5 +- ...ateenginesitesettings_header_cta_fields.py | 5 +- ...tesettings_footer_bottom_links_and_more.py | 5 +- ...dy_alter_basestandardpage_body_and_more.py | 5 +- .../0010_enginepage_and_more.py | 5 +- ...dy_alter_basestandardpage_body_and_more.py | 5 +- ...dy_alter_basestandardpage_body_and_more.py | 5 +- .../template_engine/0013_engineblockpreset.py | 5 +- ...dy_alter_basestandardpage_body_and_more.py | 5 +- ...0015_ensure_templateenginenavitem_table.py | 5 +- ...dy_alter_basestandardpage_body_and_more.py | 5 +- mandelstudio/tests/test_content_guard.py | 127 ++++++++++++++---- 19 files changed, 172 insertions(+), 43 deletions(-) diff --git a/mandelstudio/models.py b/mandelstudio/models.py index a9a19a2..e90e0e6 100644 --- a/mandelstudio/models.py +++ b/mandelstudio/models.py @@ -20,7 +20,9 @@ from mandelblog_content_guard.mixins import MultilingualValidationMixin @register_snippet -class LocalizedFooterContent(MultilingualValidationMixin, TranslatableMixin, models.Model): +class LocalizedFooterContent( + MultilingualValidationMixin, TranslatableMixin, models.Model +): title = models.CharField(max_length=120, default="Footer content") site = models.ForeignKey( Site, on_delete=models.CASCADE, related_name="localized_footer_contents" diff --git a/mandelstudio/settings/base.py b/mandelstudio/settings/base.py index 240e64e..08a23f6 100644 --- a/mandelstudio/settings/base.py +++ b/mandelstudio/settings/base.py @@ -77,5 +77,7 @@ CONTENT_GUARD_REWRITE_BACKEND = None if "test" in sys.argv: MIGRATION_MODULES = globals().get("MIGRATION_MODULES", {}).copy() - MIGRATION_MODULES["template_engine"] = "mandelstudio.test_migrations.template_engine" + MIGRATION_MODULES["template_engine"] = ( + "mandelstudio.test_migrations.template_engine" + ) TEST_RUNNER = "django.test.runner.DiscoverRunner" diff --git a/mandelstudio/test_migrations/template_engine/0001_initial.py b/mandelstudio/test_migrations/template_engine/0001_initial.py index 74a149a..387071c 100644 --- a/mandelstudio/test_migrations/template_engine/0001_initial.py +++ b/mandelstudio/test_migrations/template_engine/0001_initial.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0001_initial").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0001_initial" +).Migration diff --git a/mandelstudio/test_migrations/template_engine/0002_templateenginesitesettings.py b/mandelstudio/test_migrations/template_engine/0002_templateenginesitesettings.py index 412f092..084f57e 100644 --- a/mandelstudio/test_migrations/template_engine/0002_templateenginesitesettings.py +++ b/mandelstudio/test_migrations/template_engine/0002_templateenginesitesettings.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0002_templateenginesitesettings").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0002_templateenginesitesettings" +).Migration diff --git a/mandelstudio/test_migrations/template_engine/0003_templateenginesitesettings_nav_items.py b/mandelstudio/test_migrations/template_engine/0003_templateenginesitesettings_nav_items.py index dc89c8a..1bb5f35 100644 --- a/mandelstudio/test_migrations/template_engine/0003_templateenginesitesettings_nav_items.py +++ b/mandelstudio/test_migrations/template_engine/0003_templateenginesitesettings_nav_items.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0003_templateenginesitesettings_nav_items").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0003_templateenginesitesettings_nav_items" +).Migration diff --git a/mandelstudio/test_migrations/template_engine/0004_alter_basehomepage_body_alter_basestandardpage_body.py b/mandelstudio/test_migrations/template_engine/0004_alter_basehomepage_body_alter_basestandardpage_body.py index 245fe91..65c0ddd 100644 --- a/mandelstudio/test_migrations/template_engine/0004_alter_basehomepage_body_alter_basestandardpage_body.py +++ b/mandelstudio/test_migrations/template_engine/0004_alter_basehomepage_body_alter_basestandardpage_body.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0004_alter_basehomepage_body_alter_basestandardpage_body").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0004_alter_basehomepage_body_alter_basestandardpage_body" +).Migration diff --git a/mandelstudio/test_migrations/template_engine/0005_templateenginesitesettings_header_variant_and_more.py b/mandelstudio/test_migrations/template_engine/0005_templateenginesitesettings_header_variant_and_more.py index f41fea5..f2e42c0 100644 --- a/mandelstudio/test_migrations/template_engine/0005_templateenginesitesettings_header_variant_and_more.py +++ b/mandelstudio/test_migrations/template_engine/0005_templateenginesitesettings_header_variant_and_more.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0005_templateenginesitesettings_header_variant_and_more").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0005_templateenginesitesettings_header_variant_and_more" +).Migration diff --git a/mandelstudio/test_migrations/template_engine/0006_templateenginesitesettings_footer_dynamic_fields.py b/mandelstudio/test_migrations/template_engine/0006_templateenginesitesettings_footer_dynamic_fields.py index 895ebb9..c9973b8 100644 --- a/mandelstudio/test_migrations/template_engine/0006_templateenginesitesettings_footer_dynamic_fields.py +++ b/mandelstudio/test_migrations/template_engine/0006_templateenginesitesettings_footer_dynamic_fields.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0006_templateenginesitesettings_footer_dynamic_fields").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0006_templateenginesitesettings_footer_dynamic_fields" +).Migration diff --git a/mandelstudio/test_migrations/template_engine/0007_templateenginesitesettings_header_cta_fields.py b/mandelstudio/test_migrations/template_engine/0007_templateenginesitesettings_header_cta_fields.py index de4ef28..0750119 100644 --- a/mandelstudio/test_migrations/template_engine/0007_templateenginesitesettings_header_cta_fields.py +++ b/mandelstudio/test_migrations/template_engine/0007_templateenginesitesettings_header_cta_fields.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0007_templateenginesitesettings_header_cta_fields").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0007_templateenginesitesettings_header_cta_fields" +).Migration diff --git a/mandelstudio/test_migrations/template_engine/0008_templateenginesitesettings_footer_bottom_links_and_more.py b/mandelstudio/test_migrations/template_engine/0008_templateenginesitesettings_footer_bottom_links_and_more.py index e704758..5302803 100644 --- a/mandelstudio/test_migrations/template_engine/0008_templateenginesitesettings_footer_bottom_links_and_more.py +++ b/mandelstudio/test_migrations/template_engine/0008_templateenginesitesettings_footer_bottom_links_and_more.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0008_templateenginesitesettings_footer_bottom_links_and_more").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0008_templateenginesitesettings_footer_bottom_links_and_more" +).Migration diff --git a/mandelstudio/test_migrations/template_engine/0009_alter_basehomepage_body_alter_basestandardpage_body_and_more.py b/mandelstudio/test_migrations/template_engine/0009_alter_basehomepage_body_alter_basestandardpage_body_and_more.py index 3c9266d..334bb93 100644 --- a/mandelstudio/test_migrations/template_engine/0009_alter_basehomepage_body_alter_basestandardpage_body_and_more.py +++ b/mandelstudio/test_migrations/template_engine/0009_alter_basehomepage_body_alter_basestandardpage_body_and_more.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0009_alter_basehomepage_body_alter_basestandardpage_body_and_more").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0009_alter_basehomepage_body_alter_basestandardpage_body_and_more" +).Migration diff --git a/mandelstudio/test_migrations/template_engine/0010_enginepage_and_more.py b/mandelstudio/test_migrations/template_engine/0010_enginepage_and_more.py index f3ab3c2..dfe8125 100644 --- a/mandelstudio/test_migrations/template_engine/0010_enginepage_and_more.py +++ b/mandelstudio/test_migrations/template_engine/0010_enginepage_and_more.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0010_enginepage_and_more").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0010_enginepage_and_more" +).Migration diff --git a/mandelstudio/test_migrations/template_engine/0011_alter_basehomepage_body_alter_basestandardpage_body_and_more.py b/mandelstudio/test_migrations/template_engine/0011_alter_basehomepage_body_alter_basestandardpage_body_and_more.py index ab5d61f..088adf9 100644 --- a/mandelstudio/test_migrations/template_engine/0011_alter_basehomepage_body_alter_basestandardpage_body_and_more.py +++ b/mandelstudio/test_migrations/template_engine/0011_alter_basehomepage_body_alter_basestandardpage_body_and_more.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0011_alter_basehomepage_body_alter_basestandardpage_body_and_more").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0011_alter_basehomepage_body_alter_basestandardpage_body_and_more" +).Migration diff --git a/mandelstudio/test_migrations/template_engine/0012_alter_basehomepage_body_alter_basestandardpage_body_and_more.py b/mandelstudio/test_migrations/template_engine/0012_alter_basehomepage_body_alter_basestandardpage_body_and_more.py index 1e8b5ba..dcfd4f6 100644 --- a/mandelstudio/test_migrations/template_engine/0012_alter_basehomepage_body_alter_basestandardpage_body_and_more.py +++ b/mandelstudio/test_migrations/template_engine/0012_alter_basehomepage_body_alter_basestandardpage_body_and_more.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0012_alter_basehomepage_body_alter_basestandardpage_body_and_more").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0012_alter_basehomepage_body_alter_basestandardpage_body_and_more" +).Migration diff --git a/mandelstudio/test_migrations/template_engine/0013_engineblockpreset.py b/mandelstudio/test_migrations/template_engine/0013_engineblockpreset.py index fc35c85..7238dba 100644 --- a/mandelstudio/test_migrations/template_engine/0013_engineblockpreset.py +++ b/mandelstudio/test_migrations/template_engine/0013_engineblockpreset.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0013_engineblockpreset").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0013_engineblockpreset" +).Migration diff --git a/mandelstudio/test_migrations/template_engine/0014_alter_basehomepage_body_alter_basestandardpage_body_and_more.py b/mandelstudio/test_migrations/template_engine/0014_alter_basehomepage_body_alter_basestandardpage_body_and_more.py index 0b531c9..ce15905 100644 --- a/mandelstudio/test_migrations/template_engine/0014_alter_basehomepage_body_alter_basestandardpage_body_and_more.py +++ b/mandelstudio/test_migrations/template_engine/0014_alter_basehomepage_body_alter_basestandardpage_body_and_more.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0014_alter_basehomepage_body_alter_basestandardpage_body_and_more").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0014_alter_basehomepage_body_alter_basestandardpage_body_and_more" +).Migration diff --git a/mandelstudio/test_migrations/template_engine/0015_ensure_templateenginenavitem_table.py b/mandelstudio/test_migrations/template_engine/0015_ensure_templateenginenavitem_table.py index fbf5130..af6aa13 100644 --- a/mandelstudio/test_migrations/template_engine/0015_ensure_templateenginenavitem_table.py +++ b/mandelstudio/test_migrations/template_engine/0015_ensure_templateenginenavitem_table.py @@ -13,7 +13,10 @@ def _ensure_navitem_table(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ("template_engine", "0014_alter_basehomepage_body_alter_basestandardpage_body_and_more"), + ( + "template_engine", + "0014_alter_basehomepage_body_alter_basestandardpage_body_and_more", + ), ] operations = [ diff --git a/mandelstudio/test_migrations/template_engine/0016_alter_basehomepage_body_alter_basestandardpage_body_and_more.py b/mandelstudio/test_migrations/template_engine/0016_alter_basehomepage_body_alter_basestandardpage_body_and_more.py index 6ca0012..0f95a2a 100644 --- a/mandelstudio/test_migrations/template_engine/0016_alter_basehomepage_body_alter_basestandardpage_body_and_more.py +++ b/mandelstudio/test_migrations/template_engine/0016_alter_basehomepage_body_alter_basestandardpage_body_and_more.py @@ -1,2 +1,5 @@ from importlib import import_module -Migration = import_module("ocyan.plugin.template_engine.engine.migrations.0016_alter_basehomepage_body_alter_basestandardpage_body_and_more").Migration + +Migration = import_module( + "ocyan.plugin.template_engine.engine.migrations.0016_alter_basehomepage_body_alter_basestandardpage_body_and_more" +).Migration diff --git a/mandelstudio/tests/test_content_guard.py b/mandelstudio/tests/test_content_guard.py index e96fb76..fa0baca 100644 --- a/mandelstudio/tests/test_content_guard.py +++ b/mandelstudio/tests/test_content_guard.py @@ -9,39 +9,66 @@ from django.test import SimpleTestCase, override_settings from mandelblog_content_guard.agents import get_language_agent from mandelblog_content_guard.ai import rewrite_ai_output -from mandelblog_content_guard.system_strings import build_system_rewrite_candidates, build_system_vocabulary +from mandelblog_content_guard.system_strings import ( + build_system_rewrite_candidates, + build_system_vocabulary, +) from mandelblog_content_guard.types import split_issues -from mandelblog_content_guard.validators.multilingual import extract_visible_rendered_text, validate_text_nodes +from mandelblog_content_guard.validators.multilingual import ( + extract_visible_rendered_text, + validate_text_nodes, +) class ContentGuardRuleTests(SimpleTestCase): def test_mixed_language_detection_blocks(self): issues = validate_text_nodes( "pt", - [("body.hero_text", 'Poiché l\'input "Unverbindliche Erstberatung" è in tedesco')], + [ + ( + "body.hero_text", + 'Poiché l\'input "Unverbindliche Erstberatung" è in tedesco', + ) + ], ) blocking, _warnings = split_issues(issues) self.assertTrue(blocking) - self.assertTrue(any(issue.issue_type == "known_bad_pattern" for issue in blocking)) + self.assertTrue( + any(issue.issue_type == "known_bad_pattern" for issue in blocking) + ) def test_cta_mismatch_detection_blocks(self): issues = validate_text_nodes("en", [("body.cta_text", "Plan kennismaking")]) blocking, _warnings = split_issues(issues) - self.assertTrue(any(issue.issue_type == "cta_language_mismatch" for issue in blocking)) + self.assertTrue( + any(issue.issue_type == "cta_language_mismatch" for issue in blocking) + ) def test_form_validation_blocks_wrong_language(self): issues = validate_text_nodes("ru", [("body.form.label", "Correo electrónico")]) blocking, _warnings = split_issues(issues) - self.assertTrue(any(issue.issue_type in {"known_bad_pattern", "form_language_mismatch"} for issue in blocking)) + self.assertTrue( + any( + issue.issue_type in {"known_bad_pattern", "form_language_mismatch"} + for issue in blocking + ) + ) @override_settings(CONTENT_GUARD_BLOCK_MEDIUM=True) def test_medium_can_be_blocked_in_strict_mode(self): issues = validate_text_nodes( "en", - [("body.summary", "le la les et avec pour vous une pas des extra words to trigger heuristic")], + [ + ( + "body.summary", + "le la les et avec pour vous une pas des extra words to trigger heuristic", + ) + ], ) blocking, _warnings = split_issues(issues) - self.assertTrue(any(issue.issue_type == "language_heuristic" for issue in blocking)) + self.assertTrue( + any(issue.issue_type == "language_heuristic" for issue in blocking) + ) def test_language_agent_registry(self): agent = get_language_agent("pt") @@ -56,7 +83,9 @@ class ContentGuardRuleTests(SimpleTestCase): def test_portuguese_agent_contextual_badge_rewrite(self): agent = get_language_agent("pt") self.assertEqual(agent.rewrite("SERVICES", "body.cards[0].badge"), "SERVIÇOS") - self.assertEqual(agent.rewrite("Transparent", "body.metrics[0].label"), "Investimento claro") + self.assertEqual( + agent.rewrite("Transparent", "body.metrics[0].label"), "Investimento claro" + ) def test_french_agent_contextual_badge_rewrite(self): agent = get_language_agent("fr") @@ -66,8 +95,12 @@ class ContentGuardRuleTests(SimpleTestCase): def test_german_agent_normalizes_non_system_copy(self): agent = get_language_agent("de") self.assertEqual(agent.rewrite("New", "body.cards[0].badge"), "Neu") - self.assertEqual(agent.rewrite("Intakegespräch", "body.stats[0].label"), "Erstgespräch") - self.assertEqual(agent.rewrite("Was du bekommst", "body.heading"), "Was Sie erhalten") + self.assertEqual( + agent.rewrite("Intakegespräch", "body.stats[0].label"), "Erstgespräch" + ) + self.assertEqual( + agent.rewrite("Was du bekommst", "body.heading"), "Was Sie erhalten" + ) self.assertEqual( agent.rewrite("Sales-ready mit skalierbarem Stack", "body.cards[0].text"), "Verkaufsbereit mit skalierbarer Architektur", @@ -91,9 +124,16 @@ class ContentGuardRuleTests(SimpleTestCase): def test_portuguese_rewrite_candidates_are_detected(self): issues = validate_text_nodes( "pt", - [("body.hero_text", "Siti web e negozi online che sono rapidamente online e facili da gestire")], + [ + ( + "body.hero_text", + "Siti web e negozi online che sono rapidamente online e facili da gestire", + ) + ], + ) + self.assertTrue( + any(issue.issue_type == "mixed_locale_heading" for issue in issues) ) - self.assertTrue(any(issue.issue_type == "mixed_locale_heading" for issue in issues)) def test_french_foreign_ui_label_is_detected(self): issues = validate_text_nodes( @@ -105,9 +145,14 @@ class ContentGuardRuleTests(SimpleTestCase): def test_de_canonical_system_strings_are_not_rewrite_candidates(self): issues = validate_text_nodes( "de", - [("body.metric_label", "Durchschnittliche Lieferung"), ("body.badge", "PLAN")], + [ + ("body.metric_label", "Durchschnittliche Lieferung"), + ("body.badge", "PLAN"), + ], + ) + self.assertFalse( + any(issue.bad_value == "Durchschnittliche Lieferung" for issue in issues) ) - self.assertFalse(any(issue.bad_value == "Durchschnittliche Lieferung" for issue in issues)) self.assertFalse(any(issue.bad_value == "PLAN" for issue in issues)) def test_extract_visible_rendered_text_ignores_hidden_script_and_style(self): @@ -131,21 +176,40 @@ class ContentGuardRuleTests(SimpleTestCase): def test_system_strings_are_centralized_for_fr_and_pt(self): self.assertEqual(build_system_vocabulary("fr")["PLAN"], "FORFAIT") - self.assertEqual(build_system_vocabulary("fr")["Reaktionszeit"], "Temps de réponse") + self.assertEqual( + build_system_vocabulary("fr")["Reaktionszeit"], "Temps de réponse" + ) self.assertEqual(build_system_vocabulary("pt")["Transparent"], "Transparente") - self.assertEqual(build_system_vocabulary("fr")["Transparente Investition"], "Investissement transparent") - self.assertEqual(build_system_vocabulary("pt")["Transparente Investition"], "Investimento transparente") - self.assertEqual(build_system_rewrite_candidates()["Durchschnittliche Lieferung"], "foreign_ui_label") + self.assertEqual( + build_system_vocabulary("fr")["Transparente Investition"], + "Investissement transparent", + ) + self.assertEqual( + build_system_vocabulary("pt")["Transparente Investition"], + "Investimento transparente", + ) + self.assertEqual( + build_system_rewrite_candidates()["Durchschnittliche Lieferung"], + "foreign_ui_label", + ) class AuditLocalesCommandTests(SimpleTestCase): - @mock.patch("mandelblog_content_guard.management.commands.audit_locales.audit_locales") + @mock.patch( + "mandelblog_content_guard.management.commands.audit_locales.audit_locales" + ) def test_json_output(self, audit_locales_mock): run = mock.Mock() run.pk = 12 run.total_urls_checked = 2 run.issues_found = 1 - run.summary = {"en": {"total_urls_checked": 2, "issues_found": 1, "by_severity": {"block": 1}}} + run.summary = { + "en": { + "total_urls_checked": 2, + "issues_found": 1, + "by_severity": {"block": 1}, + } + } issue = mock.Mock( url="/en/contact/", title="Contact", @@ -166,16 +230,29 @@ class AuditLocalesCommandTests(SimpleTestCase): self.assertEqual(payload["run_id"], 12) self.assertEqual(payload["issues"]["en"][0]["bad_value"], "Correo electrónico") - @mock.patch("mandelblog_content_guard.management.commands.audit_locales.audit_locales") + @mock.patch( + "mandelblog_content_guard.management.commands.audit_locales.audit_locales" + ) def test_rewrite_flags_are_forwarded(self, audit_locales_mock): run = mock.Mock() run.pk = 13 run.total_urls_checked = 1 run.issues_found = 0 - run.summary = {"pt": {"total_urls_checked": 1, "issues_found": 0, "issues_fixed": 0, "by_severity": {"block": 0, "warn": 0, "log": 0}}} + run.summary = { + "pt": { + "total_urls_checked": 1, + "issues_found": 0, + "issues_fixed": 0, + "by_severity": {"block": 0, "warn": 0, "log": 0}, + } + } run.issues.all.return_value.order_by.return_value = [] audit_locales_mock.return_value = run out = StringIO() - call_command("audit_locales", "--locale", "pt", "--rewrite", "--dry-run", stdout=out) - audit_locales_mock.assert_called_once_with(["pt"], fix=False, rewrite=True, dry_run=True) + call_command( + "audit_locales", "--locale", "pt", "--rewrite", "--dry-run", stdout=out + ) + audit_locales_mock.assert_called_once_with( + ["pt"], fix=False, rewrite=True, dry_run=True + ) From fb6f2e861dd8937de4c49b3b47ef8e5f03ba5389 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Sun, 29 Mar 2026 21:28:12 +0200 Subject: [PATCH 07/45] Fix import ordering for multilingual CI lint --- mandelstudio/main.py | 1 - mandelstudio/management/commands/audit_locales.py | 4 +++- mandelstudio/models.py | 1 + mandelstudio/settings/base.py | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/mandelstudio/main.py b/mandelstudio/main.py index 8239f1f..55225cf 100644 --- a/mandelstudio/main.py +++ b/mandelstudio/main.py @@ -2,7 +2,6 @@ import os import sys from pathlib import Path - PROJECT_ROOT = Path(__file__).resolve().parent.parent if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) diff --git a/mandelstudio/management/commands/audit_locales.py b/mandelstudio/management/commands/audit_locales.py index d76892e..708c7da 100644 --- a/mandelstudio/management/commands/audit_locales.py +++ b/mandelstudio/management/commands/audit_locales.py @@ -1 +1,3 @@ -from mandelblog_content_guard.management.commands.audit_locales import Command # noqa: F401 +from mandelblog_content_guard.management.commands.audit_locales import ( + Command, # noqa: F401 +) diff --git a/mandelstudio/models.py b/mandelstudio/models.py index e90e0e6..6106d0d 100644 --- a/mandelstudio/models.py +++ b/mandelstudio/models.py @@ -2,6 +2,7 @@ import uuid from django.db import models from django.utils.translation import gettext_lazy as _ + from wagtail.admin.panels import FieldPanel from wagtail.blocks import RichTextBlock from wagtail.contrib.settings.models import BaseSiteSetting diff --git a/mandelstudio/settings/base.py b/mandelstudio/settings/base.py index 08a23f6..534c268 100644 --- a/mandelstudio/settings/base.py +++ b/mandelstudio/settings/base.py @@ -8,8 +8,8 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/2.0/ref/settings/ """ -from pathlib import Path import sys +from pathlib import Path from configtype.jsonconfig import setup_search_paths From ebd57a437628dd78e0f6a5cd2d98aa95351b0664 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Sun, 29 Mar 2026 21:34:30 +0200 Subject: [PATCH 08/45] Run multilingual audit stages on built-in Jenkins node --- Jenkinsfile | 1 + Jenkinsfile.multilingual-nightly | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index 4c58ae1..139868b 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -100,6 +100,7 @@ pipeline { } } stage('Post-Deploy Multilingual Audit') { + agent { label 'built-in' } options { timeout(time: 10, unit: 'MINUTES') } diff --git a/Jenkinsfile.multilingual-nightly b/Jenkinsfile.multilingual-nightly index 0338466..621b11b 100644 --- a/Jenkinsfile.multilingual-nightly +++ b/Jenkinsfile.multilingual-nightly @@ -1,7 +1,7 @@ #!/usr/bin/env groovy pipeline { - agent { label 'external_pool' } + agent none triggers { cron('H 2 * * *') } @@ -34,6 +34,7 @@ pipeline { } } stage('Nightly Multilingual Audit') { + agent { label 'built-in' } options { timeout(time: 10, unit: 'MINUTES') } From e77479f87a75c4fdfaac65446d6f93e282835202 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Sun, 29 Mar 2026 21:41:15 +0200 Subject: [PATCH 09/45] Fix Jenkins multilingual audit stage checkout --- Jenkinsfile | 4 +++- Jenkinsfile.multilingual-nightly | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index 139868b..1e2bf36 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -105,7 +105,9 @@ pipeline { timeout(time: 10, unit: 'MINUTES') } steps { - sh 'mkdir -p artifacts' + deleteDir() + checkout scm + sh 'mkdir -p artifacts && chmod +x scripts/run_remote_multilingual_audit.sh' withCredentials([sshUserPrivateKey(credentialsId: env.STAGING_AUDIT_SSH_CREDENTIALS_ID, keyFileVariable: 'STAGING_SSH_KEYFILE')]) { sh './scripts/run_remote_multilingual_audit.sh' } diff --git a/Jenkinsfile.multilingual-nightly b/Jenkinsfile.multilingual-nightly index 621b11b..b5a2d81 100644 --- a/Jenkinsfile.multilingual-nightly +++ b/Jenkinsfile.multilingual-nightly @@ -39,7 +39,9 @@ pipeline { timeout(time: 10, unit: 'MINUTES') } steps { + checkout scm sh 'mkdir -p artifacts && [ -f artifacts/multilingual-audit.json ] && cp artifacts/multilingual-audit.json artifacts/previous-multilingual-audit.json || true' + sh 'chmod +x scripts/run_remote_multilingual_audit.sh' withCredentials([sshUserPrivateKey(credentialsId: env.STAGING_AUDIT_SSH_CREDENTIALS_ID, keyFileVariable: 'STAGING_SSH_KEYFILE')]) { sh './scripts/run_remote_multilingual_audit.sh' } From 2931eedf22a66602929f0c5b1e8e8a1351e9d5ce Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Sun, 29 Mar 2026 21:47:45 +0200 Subject: [PATCH 10/45] Use staging hostname for multilingual audit --- Jenkinsfile | 2 +- Jenkinsfile.multilingual-nightly | 2 +- docs/CI_MULTILINGUAL_AUDIT.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 1e2bf36..4af148f 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -9,7 +9,7 @@ pipeline { environment { PYENVPIPELINE_VIRTUALENV = '1' GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new' - STAGING_AUDIT_HOST = 'root@49.12.204.96' + STAGING_AUDIT_HOST = 'root@welkombij.mandelblog.com' STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio' STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py' STAGING_AUDIT_SSH_CREDENTIALS_ID = 'staging-root-ssh' diff --git a/Jenkinsfile.multilingual-nightly b/Jenkinsfile.multilingual-nightly index b5a2d81..25da60f 100644 --- a/Jenkinsfile.multilingual-nightly +++ b/Jenkinsfile.multilingual-nightly @@ -10,7 +10,7 @@ pipeline { skipDefaultCheckout(true) } environment { - STAGING_AUDIT_HOST = 'root@49.12.204.96' + STAGING_AUDIT_HOST = 'root@welkombij.mandelblog.com' STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio' STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py' STAGING_AUDIT_SSH_CREDENTIALS_ID = 'staging-root-ssh' diff --git a/docs/CI_MULTILINGUAL_AUDIT.md b/docs/CI_MULTILINGUAL_AUDIT.md index 2de875a..0edcc5d 100644 --- a/docs/CI_MULTILINGUAL_AUDIT.md +++ b/docs/CI_MULTILINGUAL_AUDIT.md @@ -66,7 +66,7 @@ Credential to add: - `Private key`: staging SSH key Current implementation uses the following environment defaults: -- `STAGING_AUDIT_HOST=root@49.12.204.96` +- `STAGING_AUDIT_HOST=root@welkombij.mandelblog.com` - `STAGING_AUDIT_PROJECT_DIR=/home/www-mandelstudio/mandelstudio` - `STAGING_AUDIT_MANAGE=/var/lib/virtualenv/mandelstudio/bin/manage.py` @@ -106,7 +106,7 @@ This happens when the remote audit times out or fails, and is intentional so Jen ## Local rerun To rerun the same remote audit flow locally: ```bash -export STAGING_AUDIT_HOST='root@49.12.204.96' +export STAGING_AUDIT_HOST='root@welkombij.mandelblog.com' export STAGING_AUDIT_PROJECT_DIR='/home/www-mandelstudio/mandelstudio' export STAGING_AUDIT_MANAGE='/var/lib/virtualenv/mandelstudio/bin/manage.py' ./scripts/run_remote_multilingual_audit.sh From dd01f7dd9ad2e8c21eaaa14df7ccdc2fd0ec4d37 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Sun, 29 Mar 2026 23:12:58 +0200 Subject: [PATCH 11/45] Run multilingual audit via serverpillar salt --- Jenkinsfile | 7 +- Jenkinsfile.multilingual-nightly | 7 +- docs/CI_MULTILINGUAL_AUDIT.md | 15 +-- scripts/run_remote_multilingual_audit.sh | 135 ++++++++++++++--------- 4 files changed, 89 insertions(+), 75 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 4af148f..e411759 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -9,10 +9,9 @@ pipeline { environment { PYENVPIPELINE_VIRTUALENV = '1' GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new' - STAGING_AUDIT_HOST = 'root@welkombij.mandelblog.com' + STAGING_AUDIT_MINION = 'welkombij.mandelblog.com' STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio' STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py' - STAGING_AUDIT_SSH_CREDENTIALS_ID = 'staging-root-ssh' } stages { @@ -108,9 +107,7 @@ pipeline { deleteDir() checkout scm sh 'mkdir -p artifacts && chmod +x scripts/run_remote_multilingual_audit.sh' - withCredentials([sshUserPrivateKey(credentialsId: env.STAGING_AUDIT_SSH_CREDENTIALS_ID, keyFileVariable: 'STAGING_SSH_KEYFILE')]) { - sh './scripts/run_remote_multilingual_audit.sh' - } + sh './scripts/run_remote_multilingual_audit.sh' script { int status = sh(script: 'python3 scripts/multilingual_audit_ci.py --json artifacts/multilingual-audit.json', returnStatus: true) if (status == 2) { diff --git a/Jenkinsfile.multilingual-nightly b/Jenkinsfile.multilingual-nightly index 25da60f..b8b672f 100644 --- a/Jenkinsfile.multilingual-nightly +++ b/Jenkinsfile.multilingual-nightly @@ -10,10 +10,9 @@ pipeline { skipDefaultCheckout(true) } environment { - STAGING_AUDIT_HOST = 'root@welkombij.mandelblog.com' + STAGING_AUDIT_MINION = 'welkombij.mandelblog.com' STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio' STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py' - STAGING_AUDIT_SSH_CREDENTIALS_ID = 'staging-root-ssh' } stages { stage('Checkout') { @@ -42,9 +41,7 @@ pipeline { checkout scm sh 'mkdir -p artifacts && [ -f artifacts/multilingual-audit.json ] && cp artifacts/multilingual-audit.json artifacts/previous-multilingual-audit.json || true' sh 'chmod +x scripts/run_remote_multilingual_audit.sh' - withCredentials([sshUserPrivateKey(credentialsId: env.STAGING_AUDIT_SSH_CREDENTIALS_ID, keyFileVariable: 'STAGING_SSH_KEYFILE')]) { - sh './scripts/run_remote_multilingual_audit.sh' - } + sh './scripts/run_remote_multilingual_audit.sh' script { int status = sh(script: 'python3 scripts/multilingual_audit_ci.py --json artifacts/multilingual-audit.json --previous-json artifacts/previous-multilingual-audit.json', returnStatus: true) if (status == 2) { diff --git a/docs/CI_MULTILINGUAL_AUDIT.md b/docs/CI_MULTILINGUAL_AUDIT.md index 0edcc5d..dd4ea4a 100644 --- a/docs/CI_MULTILINGUAL_AUDIT.md +++ b/docs/CI_MULTILINGUAL_AUDIT.md @@ -55,18 +55,13 @@ The audit summary is interpreted as follows: This keeps deploys safe without making warning-level cleanup a hard blocker. -## Required Jenkins credential -Credential location: -- `Manage Jenkins -> Credentials -> System -> Global credentials` +## Jenkins requirements +No dedicated staging SSH credential is required for the multilingual audit stage. -Credential to add: -- `Kind`: `SSH Username with private key` -- `ID`: `staging-root-ssh` -- `Username`: `root` -- `Private key`: staging SSH key +The audit runs from the Jenkins built-in node through the same `serverpillar` / Salt transport used by staging deployment. Current implementation uses the following environment defaults: -- `STAGING_AUDIT_HOST=root@welkombij.mandelblog.com` +- `STAGING_AUDIT_MINION=welkombij.mandelblog.com` - `STAGING_AUDIT_PROJECT_DIR=/home/www-mandelstudio/mandelstudio` - `STAGING_AUDIT_MANAGE=/var/lib/virtualenv/mandelstudio/bin/manage.py` @@ -106,7 +101,7 @@ This happens when the remote audit times out or fails, and is intentional so Jen ## Local rerun To rerun the same remote audit flow locally: ```bash -export STAGING_AUDIT_HOST='root@welkombij.mandelblog.com' +export STAGING_AUDIT_MINION='welkombij.mandelblog.com' export STAGING_AUDIT_PROJECT_DIR='/home/www-mandelstudio/mandelstudio' export STAGING_AUDIT_MANAGE='/var/lib/virtualenv/mandelstudio/bin/manage.py' ./scripts/run_remote_multilingual_audit.sh diff --git a/scripts/run_remote_multilingual_audit.sh b/scripts/run_remote_multilingual_audit.sh index 90cfd32..4d458b3 100755 --- a/scripts/run_remote_multilingual_audit.sh +++ b/scripts/run_remote_multilingual_audit.sh @@ -1,72 +1,97 @@ #!/usr/bin/env bash set -euo pipefail -: "${STAGING_AUDIT_HOST:?STAGING_AUDIT_HOST is required}" +: "${STAGING_AUDIT_MINION:?STAGING_AUDIT_MINION is required}" : "${STAGING_AUDIT_PROJECT_DIR:?STAGING_AUDIT_PROJECT_DIR is required}" : "${STAGING_AUDIT_MANAGE:?STAGING_AUDIT_MANAGE is required}" -mkdir -p artifacts -SSH_OPTS=${SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"} -if [[ -n "${STAGING_SSH_KEYFILE:-}" ]]; then - SSH_OPTS="$SSH_OPTS -i ${STAGING_SSH_KEYFILE}" -fi AUDIT_TIMEOUT_SECONDS=${AUDIT_TIMEOUT_SECONDS:-300} -OUT_FILE="artifacts/multilingual-audit.json" -TMP_FILE="${OUT_FILE}.tmp" - -write_failure_json() { - python3 - < "$OUT_FILE" -import json -print(json.dumps({ - "run_id": None, - "total_urls_checked": 0, - "issues_found": 0, - "summary": {}, - "issues": {}, - "error": ${1@Q} -}, indent=2)) -PY -} +ARTIFACT_DIR=${ARTIFACT_DIR:-artifacts} +OUTPUT_JSON=${OUTPUT_JSON:-${ARTIFACT_DIR}/multilingual-audit.json} +mkdir -p "${ARTIFACT_DIR}" +TMP_FILE=$(mktemp) +trap 'rm -f "$TMP_FILE"' EXIT REMOTE_CMD="cd '${STAGING_AUDIT_PROJECT_DIR}' && '${STAGING_AUDIT_MANAGE}' audit_locales --format=json" -set +e -SSH_OPTS="$SSH_OPTS" STAGING_AUDIT_HOST="$STAGING_AUDIT_HOST" REMOTE_CMD="$REMOTE_CMD" AUDIT_TIMEOUT_SECONDS="$AUDIT_TIMEOUT_SECONDS" python3 - <<'PY' > "$TMP_FILE" + +STAGING_AUDIT_MINION="$STAGING_AUDIT_MINION" REMOTE_CMD="$REMOTE_CMD" AUDIT_TIMEOUT_SECONDS="$AUDIT_TIMEOUT_SECONDS" python3 - <<'PY2' > "$TMP_FILE" +import json import os -import shlex import subprocess import sys -ssh_opts = shlex.split(os.environ["SSH_OPTS"]) -cmd = ["ssh", *ssh_opts, os.environ["STAGING_AUDIT_HOST"], os.environ["REMOTE_CMD"]] +minion = os.environ["STAGING_AUDIT_MINION"] +remote_cmd = os.environ["REMOTE_CMD"] +timeout_seconds = int(os.environ["AUDIT_TIMEOUT_SECONDS"]) +cmd = [ + "sudo", "-n", "-u", "mandel", "-g", "www-data", + "/usr/bin/salt", "--out=json", minion, + "cmd.run_all", remote_cmd, "python_shell=True", +] try: - proc = subprocess.run( + result = subprocess.run( cmd, - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, text=True, - timeout=int(os.environ["AUDIT_TIMEOUT_SECONDS"]), + timeout=timeout_seconds, + check=False, ) - sys.stdout.write(proc.stdout) - sys.stderr.write(proc.stderr) -except subprocess.TimeoutExpired as exc: - sys.stderr.write(exc.stderr or "") - raise SystemExit(124) -except subprocess.CalledProcessError as exc: - sys.stdout.write(exc.stdout or "") - sys.stderr.write(exc.stderr or "") - raise SystemExit(exc.returncode) -PY -rc=$? -set -e -if [[ $rc -eq 0 ]]; then - mv "$TMP_FILE" "$OUT_FILE" - exit 0 -fi -rm -f "$TMP_FILE" -if [[ $rc -eq 124 ]]; then - write_failure_json "Remote multilingual audit timed out after ${AUDIT_TIMEOUT_SECONDS}s" -else - write_failure_json "Remote multilingual audit failed with exit status ${rc}" -fi -exit $rc +except subprocess.TimeoutExpired: + print(json.dumps({ + "run_id": None, + "total_urls_checked": 0, + "issues_found": 0, + "summary": {}, + "issues": {}, + "error": f"Salt multilingual audit timed out after {timeout_seconds} seconds", + }, indent=2)) + sys.exit(2) + +if result.returncode != 0: + print(json.dumps({ + "run_id": None, + "total_urls_checked": 0, + "issues_found": 0, + "summary": {}, + "issues": {}, + "error": f"Salt audit transport failed with exit status {result.returncode}: {(result.stderr or result.stdout).strip()}", + }, indent=2)) + sys.exit(2) + +try: + payload = json.loads(result.stdout) + if not isinstance(payload, dict) or minion not in payload: + raise ValueError("Missing minion payload") + minion_payload = payload[minion] + if not isinstance(minion_payload, dict): + raise ValueError("Unexpected minion payload type") + retcode = int(minion_payload.get("retcode", 1)) + stdout = minion_payload.get("stdout", "") + stderr = minion_payload.get("stderr", "") + if retcode != 0: + print(json.dumps({ + "run_id": None, + "total_urls_checked": 0, + "issues_found": 0, + "summary": {}, + "issues": {}, + "error": f"Remote multilingual audit failed with exit status {retcode}: {(stderr or stdout).strip()}", + }, indent=2)) + sys.exit(2) + audit = json.loads(stdout) +except Exception as exc: + print(json.dumps({ + "run_id": None, + "total_urls_checked": 0, + "issues_found": 0, + "summary": {}, + "issues": {}, + "error": f"Unable to parse salt audit response: {exc}", + }, indent=2)) + sys.exit(2) + +print(json.dumps(audit, indent=2, sort_keys=True)) +PY2 +status=$? +cp "$TMP_FILE" "$OUTPUT_JSON" +exit $status From 9da7b5cc7d9a6501548fa8c521db065ea6c104ab Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Sun, 29 Mar 2026 23:16:15 +0200 Subject: [PATCH 12/45] Always archive multilingual audit failure output --- scripts/run_remote_multilingual_audit.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/run_remote_multilingual_audit.sh b/scripts/run_remote_multilingual_audit.sh index 4d458b3..1add8f6 100755 --- a/scripts/run_remote_multilingual_audit.sh +++ b/scripts/run_remote_multilingual_audit.sh @@ -14,6 +14,7 @@ trap 'rm -f "$TMP_FILE"' EXIT REMOTE_CMD="cd '${STAGING_AUDIT_PROJECT_DIR}' && '${STAGING_AUDIT_MANAGE}' audit_locales --format=json" +set +e STAGING_AUDIT_MINION="$STAGING_AUDIT_MINION" REMOTE_CMD="$REMOTE_CMD" AUDIT_TIMEOUT_SECONDS="$AUDIT_TIMEOUT_SECONDS" python3 - <<'PY2' > "$TMP_FILE" import json import os @@ -93,5 +94,7 @@ except Exception as exc: print(json.dumps(audit, indent=2, sort_keys=True)) PY2 status=$? +set -e cp "$TMP_FILE" "$OUTPUT_JSON" +cat "$OUTPUT_JSON" exit $status From b9d9a7e88e8da5bacb5a5960b56c830912942b02 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Sun, 29 Mar 2026 23:19:04 +0200 Subject: [PATCH 13/45] Run salt audit through dashboard sudo entrypoint --- scripts/run_remote_multilingual_audit.sh | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/scripts/run_remote_multilingual_audit.sh b/scripts/run_remote_multilingual_audit.sh index 1add8f6..8c0f40c 100755 --- a/scripts/run_remote_multilingual_audit.sh +++ b/scripts/run_remote_multilingual_audit.sh @@ -15,17 +15,15 @@ trap 'rm -f "$TMP_FILE"' EXIT REMOTE_CMD="cd '${STAGING_AUDIT_PROJECT_DIR}' && '${STAGING_AUDIT_MANAGE}' audit_locales --format=json" set +e -STAGING_AUDIT_MINION="$STAGING_AUDIT_MINION" REMOTE_CMD="$REMOTE_CMD" AUDIT_TIMEOUT_SECONDS="$AUDIT_TIMEOUT_SECONDS" python3 - <<'PY2' > "$TMP_FILE" +sudo -n -u mandel -g www-data /srv/apps/mandel-dashboard/.venv/bin/python - "$STAGING_AUDIT_MINION" "$REMOTE_CMD" "$AUDIT_TIMEOUT_SECONDS" <<'PY2' > "$TMP_FILE" import json -import os import subprocess import sys -minion = os.environ["STAGING_AUDIT_MINION"] -remote_cmd = os.environ["REMOTE_CMD"] -timeout_seconds = int(os.environ["AUDIT_TIMEOUT_SECONDS"]) +minion = sys.argv[1] +remote_cmd = sys.argv[2] +timeout_seconds = int(sys.argv[3]) cmd = [ - "sudo", "-n", "-u", "mandel", "-g", "www-data", "/usr/bin/salt", "--out=json", minion, "cmd.run_all", remote_cmd, "python_shell=True", ] From 3f5d5b637b36080dff25b4a3fd2e21376c280cb5 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Mon, 30 Mar 2026 00:03:52 +0200 Subject: [PATCH 14/45] Use deploy entrypoint for multilingual audit --- Jenkinsfile | 2 +- Jenkinsfile.multilingual-nightly | 2 +- docs/CI_MULTILINGUAL_AUDIT.md | 6 +- scripts/run_remote_multilingual_audit.sh | 86 +++++++----------------- 4 files changed, 30 insertions(+), 66 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index e411759..5750d7b 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -9,7 +9,7 @@ pipeline { environment { PYENVPIPELINE_VIRTUALENV = '1' GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new' - STAGING_AUDIT_MINION = 'welkombij.mandelblog.com' + STAGING_AUDIT_PROJECT_NAME = 'mandelstudio' STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio' STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py' } diff --git a/Jenkinsfile.multilingual-nightly b/Jenkinsfile.multilingual-nightly index b8b672f..6fe900e 100644 --- a/Jenkinsfile.multilingual-nightly +++ b/Jenkinsfile.multilingual-nightly @@ -10,7 +10,7 @@ pipeline { skipDefaultCheckout(true) } environment { - STAGING_AUDIT_MINION = 'welkombij.mandelblog.com' + STAGING_AUDIT_PROJECT_NAME = 'mandelstudio' STAGING_AUDIT_PROJECT_DIR = '/home/www-mandelstudio/mandelstudio' STAGING_AUDIT_MANAGE = '/var/lib/virtualenv/mandelstudio/bin/manage.py' } diff --git a/docs/CI_MULTILINGUAL_AUDIT.md b/docs/CI_MULTILINGUAL_AUDIT.md index dd4ea4a..26e2826 100644 --- a/docs/CI_MULTILINGUAL_AUDIT.md +++ b/docs/CI_MULTILINGUAL_AUDIT.md @@ -58,10 +58,10 @@ This keeps deploys safe without making warning-level cleanup a hard blocker. ## Jenkins requirements No dedicated staging SSH credential is required for the multilingual audit stage. -The audit runs from the Jenkins built-in node through the same `serverpillar` / Salt transport used by staging deployment. +The audit runs through `/srv/apps/mandel-dashboard/bin/deploy_stg_from_jenkins.py --command`, using the same sudo-whitelisted deployment entrypoint as staging deployment. Current implementation uses the following environment defaults: -- `STAGING_AUDIT_MINION=welkombij.mandelblog.com` +- `STAGING_AUDIT_PROJECT_NAME=mandelstudio` - `STAGING_AUDIT_PROJECT_DIR=/home/www-mandelstudio/mandelstudio` - `STAGING_AUDIT_MANAGE=/var/lib/virtualenv/mandelstudio/bin/manage.py` @@ -101,7 +101,7 @@ This happens when the remote audit times out or fails, and is intentional so Jen ## Local rerun To rerun the same remote audit flow locally: ```bash -export STAGING_AUDIT_MINION='welkombij.mandelblog.com' +export STAGING_AUDIT_PROJECT_NAME='mandelstudio' export STAGING_AUDIT_PROJECT_DIR='/home/www-mandelstudio/mandelstudio' export STAGING_AUDIT_MANAGE='/var/lib/virtualenv/mandelstudio/bin/manage.py' ./scripts/run_remote_multilingual_audit.sh diff --git a/scripts/run_remote_multilingual_audit.sh b/scripts/run_remote_multilingual_audit.sh index 8c0f40c..32a3061 100755 --- a/scripts/run_remote_multilingual_audit.sh +++ b/scripts/run_remote_multilingual_audit.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -: "${STAGING_AUDIT_MINION:?STAGING_AUDIT_MINION is required}" +: "${STAGING_AUDIT_PROJECT_NAME:?STAGING_AUDIT_PROJECT_NAME is required}" : "${STAGING_AUDIT_PROJECT_DIR:?STAGING_AUDIT_PROJECT_DIR is required}" : "${STAGING_AUDIT_MANAGE:?STAGING_AUDIT_MANAGE is required}" @@ -15,81 +15,45 @@ trap 'rm -f "$TMP_FILE"' EXIT REMOTE_CMD="cd '${STAGING_AUDIT_PROJECT_DIR}' && '${STAGING_AUDIT_MANAGE}' audit_locales --format=json" set +e -sudo -n -u mandel -g www-data /srv/apps/mandel-dashboard/.venv/bin/python - "$STAGING_AUDIT_MINION" "$REMOTE_CMD" "$AUDIT_TIMEOUT_SECONDS" <<'PY2' > "$TMP_FILE" +STAGING_AUDIT_PROJECT_NAME="$STAGING_AUDIT_PROJECT_NAME" REMOTE_CMD="$REMOTE_CMD" AUDIT_TIMEOUT_SECONDS="$AUDIT_TIMEOUT_SECONDS" python3 - <<'PY2' > "$TMP_FILE" import json +import os import subprocess import sys -minion = sys.argv[1] -remote_cmd = sys.argv[2] -timeout_seconds = int(sys.argv[3]) +project = os.environ["STAGING_AUDIT_PROJECT_NAME"] +remote_cmd = os.environ["REMOTE_CMD"] +timeout_seconds = int(os.environ["AUDIT_TIMEOUT_SECONDS"]) cmd = [ - "/usr/bin/salt", "--out=json", minion, - "cmd.run_all", remote_cmd, "python_shell=True", + "sudo", "-n", "-u", "mandel", "-g", "www-data", + "/srv/apps/mandel-dashboard/.venv/bin/python", + "/srv/apps/mandel-dashboard/bin/deploy_stg_from_jenkins.py", + project, + "--command", + remote_cmd, ] try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=timeout_seconds, - check=False, - ) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_seconds, check=False) except subprocess.TimeoutExpired: print(json.dumps({ - "run_id": None, - "total_urls_checked": 0, - "issues_found": 0, - "summary": {}, - "issues": {}, - "error": f"Salt multilingual audit timed out after {timeout_seconds} seconds", + "error": "audit_failed", + "details": f"Audit command timed out after {timeout_seconds} seconds", + "exit_code": 124, }, indent=2)) sys.exit(2) - +stdout = result.stdout.strip() +stderr = result.stderr.strip() if result.returncode != 0: - print(json.dumps({ - "run_id": None, - "total_urls_checked": 0, - "issues_found": 0, - "summary": {}, - "issues": {}, - "error": f"Salt audit transport failed with exit status {result.returncode}: {(result.stderr or result.stdout).strip()}", - }, indent=2)) - sys.exit(2) - -try: - payload = json.loads(result.stdout) - if not isinstance(payload, dict) or minion not in payload: - raise ValueError("Missing minion payload") - minion_payload = payload[minion] - if not isinstance(minion_payload, dict): - raise ValueError("Unexpected minion payload type") - retcode = int(minion_payload.get("retcode", 1)) - stdout = minion_payload.get("stdout", "") - stderr = minion_payload.get("stderr", "") - if retcode != 0: + if stdout: + print(stdout) + else: print(json.dumps({ - "run_id": None, - "total_urls_checked": 0, - "issues_found": 0, - "summary": {}, - "issues": {}, - "error": f"Remote multilingual audit failed with exit status {retcode}: {(stderr or stdout).strip()}", + "error": "audit_failed", + "details": stderr or f"Audit command failed with exit status {result.returncode}", + "exit_code": result.returncode, }, indent=2)) - sys.exit(2) - audit = json.loads(stdout) -except Exception as exc: - print(json.dumps({ - "run_id": None, - "total_urls_checked": 0, - "issues_found": 0, - "summary": {}, - "issues": {}, - "error": f"Unable to parse salt audit response: {exc}", - }, indent=2)) sys.exit(2) - -print(json.dumps(audit, indent=2, sort_keys=True)) +print(stdout) PY2 status=$? set -e From ebde2806c1c75f954037a92cf3913f43557dc2f5 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Mon, 30 Mar 2026 00:11:45 +0200 Subject: [PATCH 15/45] Run nightly checkout on built-in node --- Jenkinsfile.multilingual-nightly | 1 + 1 file changed, 1 insertion(+) diff --git a/Jenkinsfile.multilingual-nightly b/Jenkinsfile.multilingual-nightly index 6fe900e..3b8b454 100644 --- a/Jenkinsfile.multilingual-nightly +++ b/Jenkinsfile.multilingual-nightly @@ -16,6 +16,7 @@ pipeline { } stages { stage('Checkout') { + agent { label 'built-in' } steps { withCredentials([sshUserPrivateKey(credentialsId: 'gitea-ssh', keyFileVariable: 'GIT_KEYFILE')]) { sh ''' From 0baae1dbe6eed82082cba24d1cab20fb26ed2d4d Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Mon, 30 Mar 2026 18:27:51 +0200 Subject: [PATCH 16/45] Clean agency navigation and refresh core site content --- .../commands/apply_agency_website_refresh.py | 669 ++++++++++++++++++ .../templates/carbasa/headers/mega.html | 34 +- .../headers/partials/carbasa-user-bar.html | 23 +- mandelstudio/templatetags/__init__.py | 0 .../templatetags/agency_navigation.py | 83 +++ 5 files changed, 772 insertions(+), 37 deletions(-) create mode 100644 mandelstudio/management/commands/apply_agency_website_refresh.py create mode 100644 mandelstudio/templatetags/__init__.py create mode 100644 mandelstudio/templatetags/agency_navigation.py diff --git a/mandelstudio/management/commands/apply_agency_website_refresh.py b/mandelstudio/management/commands/apply_agency_website_refresh.py new file mode 100644 index 0000000..60bceeb --- /dev/null +++ b/mandelstudio/management/commands/apply_agency_website_refresh.py @@ -0,0 +1,669 @@ +from __future__ import annotations + +import copy +import uuid +from typing import Any + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils.text import slugify +from wagtail.blocks import StreamValue +from wagtail.models import Locale, Page + +from mandelstudio.models import LocalizedFooterContent + +SOURCE_PAGE_IDS = { + "home": 127, + "about": 128, + "services": 129, + "projects": 130, + "contact": 131, + "process": 192, + "starter": 200, + "business": 201, + "webshop": 202, + "support": 203, + "ai_search": 199, +} + +PAGE_TITLE_MAP = { + "about": { + "nl": "Over ons", + "en": "About us", + "de": "Über uns", + "fr": "À propos", + "es": "Sobre nosotros", + "it": "Chi siamo", + "pt": "Sobre nós", + "ru": "О нас", + }, + "services": { + "nl": "Diensten", + "en": "Services", + "de": "Dienstleistungen", + "fr": "Services", + "es": "Servicios", + "it": "Servizi", + "pt": "Serviços", + "ru": "Услуги", + }, + "projects": { + "nl": "Projecten", + "en": "Projects", + "de": "Projekte", + "fr": "Projets", + "es": "Proyectos", + "it": "Progetti", + "pt": "Projetos", + "ru": "Проекты", + }, + "contact": { + "nl": "Contact", + "en": "Contact", + "de": "Kontakt", + "fr": "Contact", + "es": "Contacto", + "it": "Contatto", + "pt": "Contacto", + "ru": "Контакт", + }, + "process": { + "nl": "Werkwijze", + "en": "How we work", + "de": "Vorgehensweise", + "fr": "Méthode de travail", + "es": "Método de trabajo", + "it": "Metodo di lavoro", + "pt": "Método de trabalho", + "ru": "Как мы работаем", + }, + "starter": { + "nl": "Starter-website", + "en": "Starter website", + "de": "Starter-Website", + "fr": "Site de démarrage", + "es": "Sitio web inicial", + "it": "Sito starter", + "pt": "Website inicial", + "ru": "Стартовый сайт", + }, + "business": { + "nl": "Zakelijke website", + "en": "Business website", + "de": "Geschäftswebsite", + "fr": "Site d’entreprise", + "es": "Sitio web empresarial", + "it": "Sito business", + "pt": "Site empresarial", + "ru": "Бизнес-сайт", + }, + "webshop": { + "nl": "Webshop-implementatie", + "en": "Webshop implementation", + "de": "Webshop-Implementierung", + "fr": "Implémentation e-commerce", + "es": "Implementación webshop", + "it": "Implementazione webshop", + "pt": "Implementação de webshop", + "ru": "Внедрение вебшопа", + }, + "support": { + "nl": "Onderhoud & groei", + "en": "Maintenance & growth", + "de": "Wartung & Wachstum", + "fr": "Maintenance & croissance", + "es": "Mantenimiento y crecimiento", + "it": "Manutenzione e crescita", + "pt": "Manutenção & crescimento", + "ru": "Поддержка и рост", + }, + "ai_search": { + "nl": "AI-zoekfunctie", + "en": "AI search", + "de": "KI-Suche", + "fr": "Recherche IA", + "es": "Búsqueda con IA", + "it": "Ricerca IA", + "pt": "Pesquisa com IA", + "ru": "Поиск с ИИ", + }, +} + +COMMON_CTA = { + "nl": {"primary": "Plan een kennismakingsgesprek", "secondary": "Bekijk onze diensten"}, + "en": {"primary": "Book an introductory call", "secondary": "View our services"}, + "de": {"primary": "Erstgespräch planen", "secondary": "Unsere Leistungen ansehen"}, + "fr": {"primary": "Planifier un échange initial", "secondary": "Voir nos services"}, + "es": {"primary": "Planificar una reunión inicial", "secondary": "Ver nuestros servicios"}, + "it": {"primary": "Prenota un colloquio conoscitivo", "secondary": "Scopri i nostri servizi"}, + "pt": {"primary": "Agendar reunião introdutória", "secondary": "Ver os nossos serviços"}, + "ru": {"primary": "Запланировать вводный звонок", "secondary": "Посмотреть услуги"}, +} + +CTA_VARIANTS = { + "nl": [ + "Plan gratis gesprek", + "Plan intake", + "Plan dienstengesprek", + "Contact Support", + "Start jouw project", + "Vraag intake aan", + "Plan kennismaking", + "Bekijk diensten", + "Bekijk alle diensten", + "Vraag startergesprek aan", + "Plan startergesprek", + "Plan zakelijk gesprek", + "Start webshop traject", + "Vraag supportplan aan", + "Plan gratis kennismaking", + "Bekijk projectresultaten", + ], +} + +NL_REPLACEMENTS = { + "New": "Nieuw", + "Popular": "Populair", + "AI Search": "AI-zoekfunctie", + "custom blokken": "maatwerkblokken", + "monitoring-ready basis": "stabiele technische basis", + "Monitoring + fixes": "Monitoring en technische oplossingen", + "SEO-ready basis": "SEO-vriendelijke basis", + "Starter Website": "Starter-website", + "Business Website": "Zakelijke website", + "Support & Groei": "Onderhoud & groei", + "24u": "binnen 24 uur", + "24u Reactietijd": "Reactie binnen 24 uur", + "15m Intake call": "Intakegesprek van 15 minuten", + "100% Vrijblijvend": "Volledig vrijblijvend", + "Webshop Implementatie": "Webshop-implementatie", + "Doorlopend Verbetering": "Doorlopende verbetering", + "Monitoring-ready stack": "Stabiele technische basis", +} + +FOOTER_CONTENT = { + "nl": { + "about": "

MandelBlog bouwt websites voor dienstverleners, studio’s en kleine teams die professioneel online willen staan zonder template-ruis.

", + "links_heading": "Snelle links", + "support_heading": "Plan een gesprek", + "support": "

Plan een kennismakingsgesprek
info@mandelblog.com
Bekijk onze diensten

", + "mini": "

Contact - Diensten - Projecten - MandelBlog Studio

", + }, + "en": { + "about": "

MandelBlog builds websites for service businesses, studios and small teams that need a credible online presence without template clutter.

", + "links_heading": "Quick links", + "support_heading": "Book a call", + "support": "

Book an introductory call
info@mandelblog.com
View our services

", + "mini": "

Contact - Services - Projects - MandelBlog Studio

", + }, + "de": { + "about": "

MandelBlog entwickelt Websites für Dienstleister, Studios und kleine Teams, die professionell auftreten möchten, ohne Template-Ballast.

", + "links_heading": "Schnellzugriff", + "support_heading": "Gespräch planen", + "support": "

Erstgespräch planen
info@mandelblog.com
Leistungen ansehen

", + "mini": "

Kontakt - Dienstleistungen - Projekte - MandelBlog Studio

", + }, + "fr": { + "about": "

MandelBlog conçoit des sites pour les sociétés de services, les studios et les petites équipes qui veulent une présence crédible, sans surcharge de template.

", + "links_heading": "Accès rapides", + "support_heading": "Planifier un échange", + "support": "

Planifier un échange initial
info@mandelblog.com
Voir nos services

", + "mini": "

Contact - Services - Projets - MandelBlog Studio

", + }, + "es": { + "about": "

MandelBlog crea sitios web para empresas de servicios, estudios y pequeños equipos que quieren una presencia creíble sin aspecto de plantilla.

", + "links_heading": "Accesos rápidos", + "support_heading": "Planificar una reunión", + "support": "

Planificar una reunión inicial
info@mandelblog.com
Ver nuestros servicios

", + "mini": "

Contacto - Servicios - Proyectos - MandelBlog Studio

", + }, + "it": { + "about": "

MandelBlog realizza siti per aziende di servizi, studi e piccoli team che vogliono una presenza credibile senza l’effetto template.

", + "links_heading": "Link rapidi", + "support_heading": "Prenota un colloquio", + "support": "

Prenota un colloquio conoscitivo
info@mandelblog.com
Scopri i nostri servizi

", + "mini": "

Contatto - Servizi - Progetti - MandelBlog Studio

", + }, + "pt": { + "about": "

A MandelBlog cria sites para empresas de serviços, estúdios e pequenas equipas que precisam de uma presença credível sem aparência de template.

", + "links_heading": "Acessos rápidos", + "support_heading": "Agendar reunião", + "support": "

Agendar reunião introdutória
info@mandelblog.com
Ver os nossos serviços

", + "mini": "

Contacto - Serviços - Projetos - MandelBlog Studio

", + }, + "ru": { + "about": "

MandelBlog создаёт сайты для сервисных компаний, студий и небольших команд, которым нужен убедительный онлайн-образ без шаблонного шума.

", + "links_heading": "Быстрые ссылки", + "support_heading": "Назначить звонок", + "support": "

Запланировать вводный звонок
info@mandelblog.com
Посмотреть услуги

", + "mini": "

Контакт - Услуги - Проекты - MandelBlog Studio

", + }, +} + + +def uid() -> str: + return str(uuid.uuid4()) + + +def block(block_type: str, value: dict[str, Any]) -> dict[str, Any]: + return {"type": block_type, "value": value, "id": uid()} + + +def item(value: dict[str, Any] | str) -> dict[str, Any]: + return {"type": "item", "value": value, "id": uid()} + + +def replace_nested(node: Any, replacements: dict[str, str]) -> Any: + if isinstance(node, dict): + return {key: replace_nested(value, replacements) for key, value in node.items()} + if isinstance(node, list): + return [replace_nested(value, replacements) for value in node] + if isinstance(node, str): + for source, target in replacements.items(): + node = node.replace(source, target) + return node + return node + + +def clone(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]: + return copy.deepcopy(blocks) + + +def footer_stream_data(locale: str, links: dict[str, str]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + cfg = FOOTER_CONTENT[locale] + footer = [ + block("about_us", {"heading": "MandelBlog Studio", "content": cfg["about"]}), + block( + "text", + { + "heading": cfg["links_heading"], + "content": ( + f'

{PAGE_TITLE_MAP["about"][locale]}
' + f'{PAGE_TITLE_MAP["services"][locale]}
' + f'{PAGE_TITLE_MAP["projects"][locale]}
' + f'{PAGE_TITLE_MAP["contact"][locale]}

' + ), + }, + ), + block("text", {"heading": cfg["support_heading"], "content": cfg["support"]}), + ] + mini = [block("text", cfg["mini"])] + return footer, mini + + +def nl_home(urls: dict[str, str]) -> list[dict[str, Any]]: + primary = COMMON_CTA["nl"]["primary"] + secondary = COMMON_CTA["nl"]["secondary"] + return [ + block( + "saas_hero_banner", + { + "layout_width": "container", + "background_style": "light", + "layout": "split", + "badge_text": "MANDELBLOG STUDIO", + "badge_url": urls["home"], + "headline": "Websites voor bedrijven die professioneel willen groeien", + "sub_headline": "

MandelBlog ontwikkelt websites die vertrouwen opbouwen, duidelijk sturen op contact en eenvoudig te beheren zijn voor uw team.

", + "primary_cta_text": primary, + "primary_cta_url": urls["contact"], + "secondary_cta_text": secondary, + "secondary_cta_url": urls["services"], + "hero_image": 1, + "video_url": "", + "stats": [ + item({"value": "3", "label": "Heldere stappen"}), + item({"value": "1", "label": "Vast aanspreekpunt"}), + item({"value": "8", "label": "Beschikbare talen"}), + ], + "customer_logos_title": "Gebouwd met Wagtail, Django en beproefde componenten", + }, + ), + block( + "saas_features", + { + "layout_width": "container", + "background_style": "light", + "layout": "grid", + "section_title": "Waar MandelBlog op stuurt", + "section_subtitle": "

Geen webshopdemo, maar een zakelijke website die klaar is voor aanvragen, vertrouwen en doorontwikkeling.

", + "features": [ + item({"icon": "diagram-3", "icon_image": None, "title": "Duidelijke structuur", "description": "

Bezoekers vinden snel de juiste dienst, case of contactroute.

", "link_text": PAGE_TITLE_MAP["process"]["nl"], "link_url": urls["process"], "highlight": "featured"}), + item({"icon": "pencil-square", "icon_image": None, "title": "Zelf te beheren", "description": "

Teksten, beelden en secties beheert u zelf in overzichtelijke blokken.

", "link_text": secondary, "link_url": urls["services"], "highlight": "none"}), + item({"icon": "shield-check", "icon_image": None, "title": "Stabiele technische basis", "description": "

Een schaalbare opzet zonder overbodige complexiteit of template-ruis.

", "link_text": "Bekijk werkwijze", "link_url": urls["process"], "highlight": "none"}), + item({"icon": "graph-up-arrow", "icon_image": None, "title": "Klaar voor doorontwikkeling", "description": "

Later uitbreiden met extra pagina’s, koppelingen of commerce blijft mogelijk.

", "link_text": "Bekijk projecten", "link_url": urls["projects"], "highlight": "none"}), + ], + "columns": "2", + }, + ), + block( + "saas_pricing", + { + "layout_width": "container", + "background_style": "light", + "layout": "cards", + "section_title": "Onze pakketten", + "section_subtitle": "

Elk pakket heeft een duidelijke scope. De exacte invulling stemmen we af in het kennismakingsgesprek.

", + "show_annual_toggle": False, + "annual_discount_text": "", + "tiers": [ + item({"name": PAGE_TITLE_MAP["starter"]["nl"], "description": "Voor ondernemers die professioneel online willen starten", "price_monthly": None, "price_annual": None, "price_suffix": "", "custom_price_text": "Op offertebasis", "features": [item({"text": "Kernpagina’s en duidelijke navigatie", "included": True, "tooltip": ""}), item({"text": "Editor voor eigen contentbeheer", "included": True, "tooltip": ""}), item({"text": "Mobiel sterke presentatie", "included": True, "tooltip": ""})], "cta_text": primary, "cta_url": urls["contact"], "cta_style": "secondary", "is_featured": False, "featured_label": ""}), + item({"name": PAGE_TITLE_MAP["business"]["nl"], "description": "Voor dienstverleners met meerdere proposities of groeiplannen", "price_monthly": None, "price_annual": None, "price_suffix": "", "custom_price_text": "Op offertebasis", "features": [item({"text": "Meer ruimte voor diensten en cases", "included": True, "tooltip": ""}), item({"text": "Conversiegerichte opbouw", "included": True, "tooltip": ""}), item({"text": "SEO-vriendelijke basis", "included": True, "tooltip": ""})], "cta_text": primary, "cta_url": urls["contact"], "cta_style": "primary", "is_featured": True, "featured_label": "Aanbevolen"}), + item({"name": PAGE_TITLE_MAP["webshop"]["nl"], "description": "Voor organisaties die een zakelijke site willen uitbreiden met online verkoop", "price_monthly": None, "price_annual": None, "price_suffix": "", "custom_price_text": "Op offertebasis", "features": [item({"text": "Productstructuur en checkout", "included": True, "tooltip": ""}), item({"text": "Betalingen en orderverwerking", "included": True, "tooltip": ""}), item({"text": "Schaalbare commerce-opzet", "included": True, "tooltip": ""})], "cta_text": primary, "cta_url": urls["contact"], "cta_style": "secondary", "is_featured": False, "featured_label": ""}), + item({"name": PAGE_TITLE_MAP["support"]["nl"], "description": "Voor teams die onderhoud, technische rust en doorlopende optimalisatie nodig hebben", "price_monthly": None, "price_annual": None, "price_suffix": "", "custom_price_text": "Maandelijks traject", "features": [item({"text": "Updates en onderhoud", "included": True, "tooltip": ""}), item({"text": "Monitoring en technische oplossingen", "included": True, "tooltip": ""}), item({"text": "Doorlopende verbetering", "included": True, "tooltip": ""})], "cta_text": primary, "cta_url": urls["contact"], "cta_style": "secondary", "is_featured": False, "featured_label": ""}), + ], + "footer_text": "

We adviseren welk pakket past bij uw fase, team en doelstelling.

", + }, + ), + block( + "saas_testimonials", + { + "layout_width": "container", + "background_style": "light", + "layout": "cards", + "section_title": "Wat opdrachtgevers waarderen", + "section_subtitle": "

Kleine teams kiezen voor MandelBlog omdat het traject overzichtelijk blijft en de site daarna echt bruikbaar is.

", + "testimonials": [ + item({"quote": "

We kregen in korte tijd een website die eindelijk past bij onze dienstverlening en die we zelf kunnen onderhouden.

", "author_name": "Sanne de Vries", "author_title": "Studio Nova - eigenaar", "author_photo": None, "company_logo": None, "rating": 0}), + item({"quote": "

Het traject was helder, de teksten kregen structuur en onze contactaanvragen lopen nu via één duidelijke route.

", "author_name": "Mark Jansen", "author_title": "Jansen Interieur - medeoprichter", "author_photo": None, "company_logo": None, "rating": 0}), + ], + "customer_logos": [], + "aggregate_rating": "", + "aggregate_source": "", + }, + ), + block( + "saas_faq", + { + "layout_width": "container", + "background_style": "light", + "layout": "accordion", + "section_title": "Veelgestelde vragen", + "section_subtitle": "

We zijn duidelijk over planning, samenwerking en beheer.

", + "faqs": [ + item({"question": "Voor welke bedrijven is MandelBlog geschikt?", "answer": "

Voor dienstverleners, studio’s en kleine teams die een professionele site nodig hebben zonder zwaar traject.

", "category": "Algemeen"}), + item({"question": "Kunnen we later uitbreiden?", "answer": "

Ja. We bouwen een structuur waarmee extra pagina’s, talen of koppelingen later logisch aansluiten.

", "category": "Uitbreiding"}), + item({"question": "Beheren we de content zelf?", "answer": "

Ja. De opzet is juist bedoeld zodat uw team pagina’s en blokken zelfstandig kan aanpassen.

", "category": "Beheer"}), + item({"question": "Wat gebeurt er na livegang?", "answer": "

Dan kunt u kiezen voor onderhoud en gerichte doorontwikkeling als dat nodig is.

", "category": "Support"}), + ], + "show_contact_cta": "card", + "contact_cta_text": primary, + "contact_cta_url": urls["contact"], + }, + ), + block( + "saas_cta_footer", + { + "layout_width": "container", + "background_style": "light", + "layout": "banner", + "headline": "Wilt u een website die vertrouwen geeft en werk uit handen neemt?", + "subheadline": "

Plan een kennismakingsgesprek en we laten zien welke opzet past bij uw bedrijf en team.

", + "primary_cta_text": primary, + "primary_cta_url": urls["contact"], + "secondary_cta_text": secondary, + "secondary_cta_url": urls["services"], + "background_image": 1, + "side_image": 1, + "show_no_credit_card": "with-icon", + "no_credit_card_text": "Volledig vrijblijvend", + }, + ), + ] + + +def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]]: + primary = COMMON_CTA["nl"]["primary"] + secondary = COMMON_CTA["nl"]["secondary"] + page_data = { + "about": { + "headline": "Wie MandelBlog is en hoe we werken", + "sub": "

MandelBlog helpt kleine bedrijven en dienstverleners aan een website die professioneel oogt, logisch converteert en beheerbaar blijft voor het eigen team.

", + "features_title": "Waar we op letten", + "features_sub": "

We werken het liefst voor organisaties die behoefte hebben aan duidelijkheid, snelheid en inhoudelijke regie.

", + "features": [ + ("people", "Voor wie we werken", "

Dienstverleners, studio’s en kleine teams met een duidelijke propositie en een praktische planning.

"), + ("diagram-3", "Onze werkwijze", "

We starten met scherpte in doel en inhoud, bouwen met vaste blokken en leveren beheersbaar op.

"), + ("shield-check", "Waarom het anders werkt", "

Geen los template of black box, maar een duidelijke structuur waarmee u zelf verder kunt.

"), + ("person-badge", "Klein team, direct contact", "

U schakelt direct met de mensen die het werk uitvoeren en keuzes vertalen naar de site.

"), + ], + "extra_block": block("saas_animated_stats", {"layout_width": "container", "background_style": "light", "layout": "cards-grid", "badge": "Werkwijze", "heading": "Onze aanpak in 3 stappen", "subheading": "Kort traject, duidelijke keuzes en daarna een site die voor uw team werkt.", "stats": [item({"value": "1", "prefix": None, "suffix": "", "label": "Kennismaking", "description": "We bepalen doel, inhoud en prioriteiten.", "icon": "chat-square-text", "highlight": False}), item({"value": "2", "prefix": None, "suffix": "", "label": "Uitwerking", "description": "We bouwen de pagina’s en stemmen de inhoud af.", "icon": "layout-text-window", "highlight": False}), item({"value": "3", "prefix": None, "suffix": "", "label": "Oplevering", "description": "U krijgt uitleg, beheer en een duidelijke vervolgstap.", "icon": "rocket", "highlight": False})], "animation_duration": 1800, "animation_easing": "ease-out", "start_on_scroll": True, "show_logos": False, "logos_heading": "", "company_logos": []}), + "cta": "Wilt u weten of onze aanpak past bij uw bedrijf?", + }, + "services": { + "headline": "Diensten voor bedrijven die overzicht en kwaliteit willen", + "sub": "

Elke dienst is ingericht rondom duidelijke keuzes, bruikbare content en een technische basis die door kan groeien.

", + "features_title": "Wat we leveren", + "features_sub": "

Geen losse modules, maar een traject dat aansluit op uw fase, team en doelen.

", + "features": [ + ("window", PAGE_TITLE_MAP["starter"]["nl"], "

Voor ondernemers die snel professioneel online willen staan met een heldere basis.

"), + ("briefcase", PAGE_TITLE_MAP["business"]["nl"], "

Voor organisaties met meerdere diensten, cases of een complexere aanbodstructuur.

"), + ("cart-check", PAGE_TITLE_MAP["webshop"]["nl"], "

Voor teams die online verkoop willen toevoegen zonder de grip op techniek te verliezen.

"), + ("wrench-adjustable", PAGE_TITLE_MAP["support"]["nl"], "

Voor organisaties die onderhoud, stabiliteit en doorlopende verbetering nodig hebben.

"), + ], + "extra_block": None, + "cta": "Twijfelt u welk pakket past bij uw fase?", + }, + "projects": { + "headline": "Projecten waarin structuur, inhoud en techniek samenkomen", + "sub": "

Onze projecten zijn ontworpen om professioneel over te komen, vertrouwen op te bouwen en beheerbaar te blijven na livegang.

", + "features_title": "Wat u in onze projecten terugziet", + "features_sub": "

We sturen niet op oppervlakkige effecten, maar op duidelijkheid en bruikbaarheid.

", + "features": [ + ("diagram-3", "Heldere pagina-opbouw", "

Bezoekers begrijpen snel waar ze moeten zijn en welke stap logisch volgt.

"), + ("pencil-square", "Eenvoudig beheer", "

Teams kunnen teksten, visuals en pagina’s zelf aanpassen zonder omweg.

"), + ("graph-up-arrow", "Gericht op aanvragen", "

Contact en conversie zijn zichtbaar verwerkt in de structuur en inhoud.

"), + ], + "extra_block": None, + "cta": "Wilt u uw volgende project professioneel neerzetten?", + }, + "contact": { + "headline": "Laten we uw vraag concreet maken", + "sub": "

Vertel kort wat u nodig heeft. U krijgt een praktische terugkoppeling met haalbare vervolgstappen.

", + "features_title": "Waarvoor u contact kunt opnemen", + "features_sub": "

Kies de route die past bij uw vraag of traject.

", + "features": [ + ("rocket", "Nieuw traject", "

Voor een nieuwe website, herpositionering of complete herbouw.

"), + ("briefcase", "Pakketkeuze", "

Voor advies over welk pakket of welke structuur het beste past.

"), + ("tools", "Onderhoud of uitbreiding", "

Voor technische ondersteuning, uitbreidingen of een vervolgfase na livegang.

"), + ], + "extra_block": block("saas_demo_request", {"layout_width": "container", "background_style": "light", "layout": "split", "section_title": "Vertel kort wat u nodig heeft", "section_subtitle": "

We reageren inhoudelijk en zonder verkooppraat op uw vraag.

", "form_fields": [item({"field_type": "text", "label": "Naam", "placeholder": "Uw naam", "required": True}), item({"field_type": "email", "label": "E-mail", "placeholder": "naam@bedrijf.nl", "required": True}), item({"field_type": "company", "label": "Bedrijf", "placeholder": "Bedrijfsnaam", "required": True}), item({"field_type": "message", "label": "Vraag of project", "placeholder": "Waar zoekt u hulp bij?", "required": False})], "submit_button_text": primary, "form_action_url": urls["contact"], "benefits_title": "Wat u kunt verwachten", "benefits": [item("Reactie binnen 24 uur"), item("Intakegesprek van 15 minuten"), item("Volledig vrijblijvend")], "side_image": 1, "privacy_text": "

We gebruiken uw gegevens alleen voor contact over deze aanvraag.

"}), + "cta": "Klaar om een eerste stap te zetten?", + }, + "process": { + "headline": "Werkwijze met duidelijke stappen en vaste keuzes", + "sub": "

We houden het traject overzichtelijk: u weet wanneer iets gebeurt, wat u moet aanleveren en waar we naartoe werken.

", + "features_title": "Zo werken we samen", + "features_sub": "

Kort, duidelijk en zonder onnodige ruis.

", + "features": [ + ("chat-square-text", "1. Kennismaking", "

We bespreken doel, doelgroep, inhoud en wat u intern wilt kunnen beheren.

"), + ("layout-text-window", "2. Uitwerking", "

We zetten structuur, inhoud en ontwerp om in een duidelijke pagina-opbouw.

"), + ("rocket", "3. Oplevering", "

Na review gaat de site live en zorgen we voor een beheerbare overdracht.

"), + ("graph-up-arrow", "4. Doorontwikkeling", "

Wanneer nodig bouwen we verder op basis van gedrag, vragen en nieuwe plannen.

"), + ], + "extra_block": None, + "cta": "Wilt u dit traject ook voor uw website?", + }, + } + cfg = page_data[page_key] + blocks = [ + block("saas_hero_banner", {"layout_width": "container", "background_style": "light", "layout": "split", "badge_text": "MANDELBLOG STUDIO" if page_key != "services" else "DIENSTEN", "badge_url": urls[page_key], "headline": cfg["headline"], "sub_headline": cfg["sub"], "primary_cta_text": primary, "primary_cta_url": urls["contact"], "secondary_cta_text": secondary, "secondary_cta_url": urls["services"], "hero_image": 1 if page_key != "process" else 24, "video_url": "", "stats": [], "customer_logos_title": ""}), + block("saas_features", {"layout_width": "container", "background_style": "light", "layout": "grid", "section_title": cfg["features_title"], "section_subtitle": cfg["features_sub"], "features": [item({"icon": icon, "icon_image": None, "title": title, "description": desc, "link_text": primary if page_key in {"contact", "about"} else secondary, "link_url": urls["contact"] if page_key in {"contact", "about"} else urls["services"], "highlight": "none"}) for icon, title, desc in cfg["features"]], "columns": "2" if len(cfg["features"]) <= 4 else "3"}), + ] + if cfg["extra_block"] is not None: + blocks.append(cfg["extra_block"]) + blocks.append(block("saas_faq", {"layout_width": "container", "background_style": "light", "layout": "accordion", "section_title": "Veelgestelde vragen", "section_subtitle": "

We houden het traject helder en praktisch.

", "faqs": [item({"question": "Werken jullie met vaste templates?", "answer": "

Nee. We gebruiken herbruikbare blokken, maar stemmen inhoud en structuur af op uw bedrijf.

", "category": "Werkwijze"}), item({"question": "Kunnen we later uitbreiden?", "answer": "

Ja. De opzet is bedoeld om later door te groeien zonder opnieuw te beginnen.

", "category": "Uitbreiding"}), item({"question": "Beheren we de inhoud zelf?", "answer": "

Ja. Dat is juist een belangrijk uitgangspunt van het platform.

", "category": "Beheer"})], "show_contact_cta": "card", "contact_cta_text": primary, "contact_cta_url": urls["contact"]})) + blocks.append(block("saas_cta_footer", {"layout_width": "container", "background_style": "light", "layout": "banner", "headline": cfg["cta"], "subheadline": "

Plan een kennismakingsgesprek en we laten zien welke route logisch is voor uw bedrijf.

", "primary_cta_text": primary, "primary_cta_url": urls["contact"], "secondary_cta_text": secondary, "secondary_cta_url": urls["services"], "background_image": 1, "side_image": 1, "show_no_credit_card": "with-icon", "no_credit_card_text": "Volledig vrijblijvend"})) + return blocks + + +def nl_service_page(kind: str, urls: dict[str, str]) -> list[dict[str, Any]]: + primary = COMMON_CTA["nl"]["primary"] + secondary = COMMON_CTA["nl"]["secondary"] + config = { + "starter": { + "title": "Starter-website", + "audience": "Voor ondernemers of kleine teams die snel professioneel online willen staan met een duidelijke basis.", + "what": [ + ("layout-text-window", "Voor wie is dit?", "

Voor bedrijven met een helder aanbod die snel een professionele eerste indruk willen neerzetten.

"), + ("window", "Wat krijgt u?", "

Kernpagina’s, een logische navigatie en een editor waarmee uw team zelf content kan beheren.

"), + ("graph-up-arrow", "Wat levert het op?", "

Een professionele basis waarmee bezoekers sneller begrijpen wat u doet en hoe ze contact opnemen.

"), + ], + "outcomes": [ + ("shield-check", "Heldere online basis", "

Geen overbodige onderdelen, wel een site die vertrouwen geeft.

"), + ("people", "Eenvoudig beheer", "

Uw team kan updates zelf doen zonder afhankelijkheid.

"), + ("rocket", "Snelle livegang", "

Geschikt als eerste professionele stap of als vervanging van een verouderde site.

"), + ], + "choose": ["U wilt snel professioneel online staan.", "U heeft vooral kernpagina’s en duidelijke navigatie nodig.", "U wilt zelf teksten en beelden kunnen aanpassen."], + "duration": "Gemiddelde oplevering: 2 tot 4 weken", + }, + "business": { + "title": "Zakelijke website", + "audience": "Voor dienstverleners en teams die meerdere proposities, cases of funnelstappen helder willen presenteren.", + "what": [ + ("briefcase", "Voor wie is dit?", "

Voor organisaties die meer structuur, inhoudelijke diepgang en een sterkere aanvraagroute nodig hebben.

"), + ("layout-text-window", "Wat krijgt u?", "

Meer pagina-opbouw, ruimte voor cases en een SEO-vriendelijke basis die logisch meegroeit.

"), + ("graph-up-arrow", "Wat levert het op?", "

Een site die uw aanbod beter uitlegt en bezoekers gerichter naar contact of aanvraag leidt.

"), + ], + "outcomes": [ + ("diagram-3", "Meer overzicht", "

Diensten, cases en expertise krijgen elk hun eigen plek.

"), + ("search", "Betere vindbaarheid", "

De opbouw is ingericht voor sterke inhoud en een SEO-vriendelijke basis.

"), + ("people", "Sterkere aanvragen", "

Bezoekers zien sneller welke route en welk aanbod bij hen past.

"), + ], + "choose": ["U heeft meerdere diensten of doelgroepen.", "U wilt cases, expertise en bewijs beter laten zien.", "U zoekt meer structuur dan een startsite biedt."], + "duration": "Gemiddelde oplevering: 2 tot 4 weken", + }, + "webshop": { + "title": "Webshop-implementatie", + "audience": "Voor organisaties die online verkoop willen toevoegen zonder in een standaardshop te belanden.", + "what": [ + ("cart-check", "Voor wie is dit?", "

Voor bedrijven die hun aanbod online willen verkopen met grip op inhoud, checkout en beheer.

"), + ("credit-card", "Wat krijgt u?", "

Een webshopstructuur met productoverzicht, checkout en een schaalbare basis voor orderverwerking.

"), + ("graph-up-arrow", "Wat levert het op?", "

Een verkoopomgeving die past bij uw merk en niet voelt als een los demo-sjabloon.

"), + ], + "outcomes": [ + ("window", "Betere presentatie", "

Producten en categorieën krijgen een zakelijke, duidelijke opbouw.

"), + ("shield-check", "Stabiele techniek", "

Betalingen en orderverwerking sluiten aan op een beheerbare stack.

"), + ("rocket", "Klaar voor groei", "

De commerce-opzet kan meegroeien met assortiment en processen.

"), + ], + "choose": ["U wilt online verkoop combineren met een zakelijke website.", "U heeft behoefte aan grip op structuur en techniek.", "U zoekt geen standaard thema, maar een doordachte implementatie."], + "duration": "Gemiddelde oplevering: 3 tot 6 weken", + }, + "support": { + "title": "Onderhoud & groei", + "audience": "Voor teams die hun website of webshop stabiel willen houden en gericht willen doorontwikkelen.", + "what": [ + ("tools", "Voor wie is dit?", "

Voor organisaties die niet zelf alle techniek willen monitoren, oplossen en plannen.

"), + ("shield-check", "Wat krijgt u?", "

Onderhoud, updates, monitoring en technische oplossingen binnen een vast werkritme.

"), + ("graph-up-arrow", "Wat levert het op?", "

Meer rust, minder technische verrassingen en ruimte om gericht te verbeteren.

"), + ], + "outcomes": [ + ("activity", "Minder verstoringen", "

Technische issues worden sneller gesignaleerd en opgelost.

"), + ("clipboard-data", "Doorlopende verbetering", "

We werken stap voor stap aan performance, inhoud en conversie.

"), + ("people", "Vast ritme", "

U weet wanneer onderhoud gebeurt en waar prioriteit ligt.

"), + ], + "choose": ["U wilt een vaste partner voor technisch onderhoud.", "Uw site vraagt om kleine verbeteringen in plaats van een volledige herbouw.", "U wilt sneller kunnen schakelen bij issues of uitbreidingen."], + "duration": "Reactie binnen 24 uur", + }, + }[kind] + return [ + block("saas_hero_banner", {"layout_width": "container", "background_style": "light", "layout": "split", "badge_text": "PAKKET", "badge_url": urls[kind], "headline": config["title"], "sub_headline": f"

{config['audience']}

", "primary_cta_text": primary, "primary_cta_url": urls['contact'], "secondary_cta_text": secondary, "secondary_cta_url": urls['services'], "hero_image": 23, "video_url": "", "stats": [item({"value": config['duration'], "label": "Doorlooptijd"}), item({"value": "Reactie binnen 24 uur", "label": "Communicatie"}), item({"value": "Volledig vrijblijvend", "label": "Kennismaking"})], "customer_logos_title": ""}), + block("saas_features", {"layout_width": "container", "background_style": "light", "layout": "grid", "section_title": "Wat krijgt u?", "section_subtitle": "

Elk pakket is opgebouwd rond duidelijke keuzes, beheerbaarheid en inhoud die past bij uw bedrijf.

", "features": [item({"icon": icon, "icon_image": None, "title": title, "description": desc, "link_text": secondary, "link_url": urls['services'], "highlight": "none"}) for icon, title, desc in config['what']], "columns": "3"}), + block("saas_features", {"layout_width": "container", "background_style": "light", "layout": "grid", "section_title": "Wat levert het op?", "section_subtitle": "

De waarde zit niet in losse effecten, maar in duidelijkere communicatie en een beter werkende site.

", "features": [item({"icon": icon, "icon_image": None, "title": title, "description": desc, "link_text": primary, "link_url": urls['contact'], "highlight": "none"}) for icon, title, desc in config['outcomes']], "columns": "3"}), + block("saas_demo_request", {"layout_width": "container", "background_style": "light", "layout": "split", "section_title": "Wanneer kiest u dit pakket?", "section_subtitle": "

We adviseren dit pakket wanneer onderstaande punten aansluiten op uw situatie.

", "form_fields": [item({"field_type": "text", "label": "Naam", "placeholder": "Uw naam", "required": True}), item({"field_type": "email", "label": "E-mail", "placeholder": "naam@bedrijf.nl", "required": True}), item({"field_type": "company", "label": "Bedrijf", "placeholder": "Bedrijfsnaam", "required": True}), item({"field_type": "message", "label": "Vraag of context", "placeholder": "Vertel kort waar u nu staat", "required": False})], "submit_button_text": primary, "form_action_url": urls['contact'], "benefits_title": "Dit pakket past wanneer", "benefits": [item(text) for text in config['choose']], "side_image": 23, "privacy_text": "

We gebruiken uw gegevens alleen voor een reactie op uw aanvraag.

"}), + block("saas_cta_footer", {"layout_width": "container", "background_style": "light", "layout": "banner", "headline": f"Wilt u weten of {config['title'].lower()} past bij uw situatie?", "subheadline": "

Plan een kennismakingsgesprek en we adviseren eerlijk welk pakket logisch is.

", "primary_cta_text": primary, "primary_cta_url": urls['contact'], "secondary_cta_text": secondary, "secondary_cta_url": urls['services'], "background_image": 1, "side_image": 1, "show_no_credit_card": "with-icon", "no_credit_card_text": "Volledig vrijblijvend"}), + ] + + +def nl_body_for(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]]: + if page_key == 'home': + return nl_home(urls) + if page_key in {'about', 'services', 'projects', 'contact', 'process'}: + return nl_standard_page(page_key, urls) + if page_key in {'starter', 'business', 'webshop', 'support'}: + return nl_service_page(page_key, urls) + raise KeyError(page_key) + + +class Command(BaseCommand): + help = "Apply MandelBlog agency cleanup content to the main site tree" + + def add_arguments(self, parser): + parser.add_argument('--apply', action='store_true', help='Persist and publish changes') + + def handle(self, *args, **options): + apply_changes = options['apply'] + with transaction.atomic(): + for locale in Locale.objects.all().order_by('language_code'): + language_code = locale.language_code + translated_pages = {} + for key, source_id in SOURCE_PAGE_IDS.items(): + source = Page.objects.get(id=source_id) + translated = Page.objects.filter(translation_key=source.translation_key, locale=locale).specific().first() + if translated: + translated_pages[key] = translated + urls = {key: page.url for key, page in translated_pages.items() if getattr(page, 'url', None)} + if 'home' not in urls: + continue + + for key, page in translated_pages.items(): + changed = False + title_map = PAGE_TITLE_MAP.get(key) + if title_map and title_map.get(language_code) and page.title != title_map[language_code]: + page.title = title_map[language_code] + changed = True + + if key == 'ai_search': + if changed and apply_changes: + rev = page.save_revision() + rev.publish() + continue + + if hasattr(page, 'body'): + raw_data = list(page.body.raw_data) + if language_code == 'nl' and key in {'home', 'about', 'services', 'projects', 'contact', 'process', 'starter', 'business', 'webshop', 'support'}: + page.body = StreamValue(page.body.stream_block, nl_body_for(key, urls), is_lazy=True) + changed = True + else: + replacements = {} + replacements.update(NL_REPLACEMENTS if language_code == 'nl' else {}) + if language_code in COMMON_CTA: + primary = COMMON_CTA[language_code]['primary'] + secondary = COMMON_CTA[language_code]['secondary'] + for variant in CTA_VARIANTS.get('nl', []): + replacements[variant] = primary + replacements['Bekijk diensten'] = secondary + replacements['Bekijk alle diensten'] = secondary + replacements['Plan kennismaking'] = primary + updated_raw_data = replace_nested(clone(raw_data), replacements) + if updated_raw_data != raw_data: + page.body = StreamValue(page.body.stream_block, updated_raw_data, is_lazy=True) + changed = True + + if changed: + self.stdout.write(f"{language_code}: update {key} -> {page.title}") + if apply_changes: + rev = page.save_revision() + rev.publish() + + footer_cfg = FOOTER_CONTENT.get(language_code) + footer_obj = LocalizedFooterContent.objects.filter(locale=locale).first() + if footer_obj and footer_cfg: + link_urls = { + 'about': urls.get('about', '/'), + 'services': urls.get('services', '/'), + 'projects': urls.get('projects', '/'), + 'contact': urls.get('contact', '/'), + } + footer_data, mini_data = footer_stream_data(language_code, link_urls) + footer_obj.footer = StreamValue(footer_obj.footer.stream_block, footer_data, is_lazy=True) + footer_obj.mini_footer = StreamValue(footer_obj.mini_footer.stream_block, mini_data, is_lazy=True) + self.stdout.write(f"{language_code}: update footer") + if apply_changes: + footer_obj.save() + + if not apply_changes: + transaction.set_rollback(True) + self.stdout.write(self.style.WARNING('Dry run complete; no changes saved.')) + else: + self.stdout.write(self.style.SUCCESS('Agency website refresh applied.')) diff --git a/mandelstudio/templates/carbasa/headers/mega.html b/mandelstudio/templates/carbasa/headers/mega.html index 1e9ad5a..20bfe80 100644 --- a/mandelstudio/templates/carbasa/headers/mega.html +++ b/mandelstudio/templates/carbasa/headers/mega.html @@ -1,40 +1,18 @@ {% extends "carbasa/headers/header.html" %} -{% load i18n oxyan category_tags ocyan_main ocyanjson wagtailsettings_tags %} +{% load agency_navigation %} {% block nav %} -{% ocyanjson "theme" "menu_depth" 1 as menu_depth %} {% endblock %} diff --git a/mandelstudio/templates/oxyan/headers/partials/carbasa-user-bar.html b/mandelstudio/templates/oxyan/headers/partials/carbasa-user-bar.html index c352038..03ac792 100644 --- a/mandelstudio/templates/oxyan/headers/partials/carbasa-user-bar.html +++ b/mandelstudio/templates/oxyan/headers/partials/carbasa-user-bar.html @@ -1,4 +1,4 @@ -{% load i18n i18n_helpers %} +{% load i18n i18n_helpers agency_navigation %}
- LANG
{% csrf_token %} @@ -46,8 +48,11 @@ - - {% include 'oxyan/headers/partials/mini_basket.html' %} + + {% agency_page 'contact' as contact_page %} + {% if contact_page %} + {% agency_primary_cta %} + {% endif %}
diff --git a/mandelstudio/templatetags/__init__.py b/mandelstudio/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mandelstudio/templatetags/agency_navigation.py b/mandelstudio/templatetags/agency_navigation.py new file mode 100644 index 0000000..7c4dd40 --- /dev/null +++ b/mandelstudio/templatetags/agency_navigation.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from django import template +from wagtail.models import Locale, Page + +register = template.Library() + +SOURCE_PAGE_IDS = { + "about": 128, + "services": 129, + "projects": 130, + "contact": 131, + "process": 192, +} + +NAV_ORDER = ["services", "projects", "process", "about", "contact"] + + +def _resolve_locale(language_code: str | None) -> Locale | None: + if not language_code: + return None + try: + return Locale.objects.get(language_code=language_code) + except Locale.DoesNotExist: + return None + + +def _translated_page(source_id: int, language_code: str | None) -> Page | None: + locale = _resolve_locale(language_code) + try: + source = Page.objects.get(id=source_id) + except Page.DoesNotExist: + return None + if locale is None: + return source.specific + translated = ( + Page.objects.filter(translation_key=source.translation_key, locale=locale) + .live() + .public() + .specific() + .first() + ) + return translated or source.specific + + +@register.simple_tag(takes_context=True) +def agency_nav_pages(context): + request = context.get("request") + language_code = getattr(request, "LANGUAGE_CODE", None) + pages = [] + for key in NAV_ORDER: + page = _translated_page(SOURCE_PAGE_IDS[key], language_code) + if page is not None: + pages.append(page) + return pages + + +@register.simple_tag(takes_context=True) +def agency_page(context, key: str): + request = context.get("request") + language_code = getattr(request, "LANGUAGE_CODE", None) + source_id = SOURCE_PAGE_IDS.get(key) + if source_id is None: + return None + return _translated_page(source_id, language_code) + + +CTA_LABELS = { + "nl": "Plan een kennismakingsgesprek", + "en": "Book an introductory call", + "de": "Erstgespräch planen", + "fr": "Planifier un échange initial", + "es": "Planificar una reunión inicial", + "it": "Prenota un colloquio conoscitivo", + "pt": "Agendar reunião introdutória", + "ru": "Запланировать вводный звонок", +} + +@register.simple_tag(takes_context=True) +def agency_primary_cta(context): + request = context.get("request") + language_code = getattr(request, "LANGUAGE_CODE", None) or "nl" + return CTA_LABELS.get(language_code, CTA_LABELS["nl"]) From 9059cd28aeee94c856b11b3f5fc795797ecd56bb Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Mon, 30 Mar 2026 18:32:15 +0200 Subject: [PATCH 17/45] Format agency site refresh command and nav tags --- .../commands/apply_agency_website_refresh.py | 1078 +++++++++++++++-- .../templatetags/agency_navigation.py | 1 + 2 files changed, 952 insertions(+), 127 deletions(-) diff --git a/mandelstudio/management/commands/apply_agency_website_refresh.py b/mandelstudio/management/commands/apply_agency_website_refresh.py index 60bceeb..dbb9917 100644 --- a/mandelstudio/management/commands/apply_agency_website_refresh.py +++ b/mandelstudio/management/commands/apply_agency_website_refresh.py @@ -6,7 +6,6 @@ from typing import Any from django.core.management.base import BaseCommand from django.db import transaction -from django.utils.text import slugify from wagtail.blocks import StreamValue from wagtail.models import Locale, Page @@ -130,13 +129,25 @@ PAGE_TITLE_MAP = { } COMMON_CTA = { - "nl": {"primary": "Plan een kennismakingsgesprek", "secondary": "Bekijk onze diensten"}, + "nl": { + "primary": "Plan een kennismakingsgesprek", + "secondary": "Bekijk onze diensten", + }, "en": {"primary": "Book an introductory call", "secondary": "View our services"}, "de": {"primary": "Erstgespräch planen", "secondary": "Unsere Leistungen ansehen"}, "fr": {"primary": "Planifier un échange initial", "secondary": "Voir nos services"}, - "es": {"primary": "Planificar una reunión inicial", "secondary": "Ver nuestros servicios"}, - "it": {"primary": "Prenota un colloquio conoscitivo", "secondary": "Scopri i nostri servizi"}, - "pt": {"primary": "Agendar reunião introdutória", "secondary": "Ver os nossos serviços"}, + "es": { + "primary": "Planificar una reunión inicial", + "secondary": "Ver nuestros servicios", + }, + "it": { + "primary": "Prenota un colloquio conoscitivo", + "secondary": "Scopri i nostri servizi", + }, + "pt": { + "primary": "Agendar reunião introdutória", + "secondary": "Ver os nossos serviços", + }, "ru": {"primary": "Запланировать вводный звонок", "secondary": "Посмотреть услуги"}, } @@ -186,57 +197,57 @@ FOOTER_CONTENT = { "about": "

MandelBlog bouwt websites voor dienstverleners, studio’s en kleine teams die professioneel online willen staan zonder template-ruis.

", "links_heading": "Snelle links", "support_heading": "Plan een gesprek", - "support": "

Plan een kennismakingsgesprek
info@mandelblog.com
Bekijk onze diensten

", - "mini": "

Contact - Diensten - Projecten - MandelBlog Studio

", + "support": '

Plan een kennismakingsgesprek
info@mandelblog.com
Bekijk onze diensten

', + "mini": '

Contact - Diensten - Projecten - MandelBlog Studio

', }, "en": { "about": "

MandelBlog builds websites for service businesses, studios and small teams that need a credible online presence without template clutter.

", "links_heading": "Quick links", "support_heading": "Book a call", - "support": "

Book an introductory call
info@mandelblog.com
View our services

", - "mini": "

Contact - Services - Projects - MandelBlog Studio

", + "support": '

Book an introductory call
info@mandelblog.com
View our services

', + "mini": '

Contact - Services - Projects - MandelBlog Studio

', }, "de": { "about": "

MandelBlog entwickelt Websites für Dienstleister, Studios und kleine Teams, die professionell auftreten möchten, ohne Template-Ballast.

", "links_heading": "Schnellzugriff", "support_heading": "Gespräch planen", - "support": "

Erstgespräch planen
info@mandelblog.com
Leistungen ansehen

", - "mini": "

Kontakt - Dienstleistungen - Projekte - MandelBlog Studio

", + "support": '

Erstgespräch planen
info@mandelblog.com
Leistungen ansehen

', + "mini": '

Kontakt - Dienstleistungen - Projekte - MandelBlog Studio

', }, "fr": { "about": "

MandelBlog conçoit des sites pour les sociétés de services, les studios et les petites équipes qui veulent une présence crédible, sans surcharge de template.

", "links_heading": "Accès rapides", "support_heading": "Planifier un échange", - "support": "

Planifier un échange initial
info@mandelblog.com
Voir nos services

", - "mini": "

Contact - Services - Projets - MandelBlog Studio

", + "support": '

Planifier un échange initial
info@mandelblog.com
Voir nos services

', + "mini": '

Contact - Services - Projets - MandelBlog Studio

', }, "es": { "about": "

MandelBlog crea sitios web para empresas de servicios, estudios y pequeños equipos que quieren una presencia creíble sin aspecto de plantilla.

", "links_heading": "Accesos rápidos", "support_heading": "Planificar una reunión", - "support": "

Planificar una reunión inicial
info@mandelblog.com
Ver nuestros servicios

", - "mini": "

Contacto - Servicios - Proyectos - MandelBlog Studio

", + "support": '

Planificar una reunión inicial
info@mandelblog.com
Ver nuestros servicios

', + "mini": '

Contacto - Servicios - Proyectos - MandelBlog Studio

', }, "it": { "about": "

MandelBlog realizza siti per aziende di servizi, studi e piccoli team che vogliono una presenza credibile senza l’effetto template.

", "links_heading": "Link rapidi", "support_heading": "Prenota un colloquio", - "support": "

Prenota un colloquio conoscitivo
info@mandelblog.com
Scopri i nostri servizi

", - "mini": "

Contatto - Servizi - Progetti - MandelBlog Studio

", + "support": '

Prenota un colloquio conoscitivo
info@mandelblog.com
Scopri i nostri servizi

', + "mini": '

Contatto - Servizi - Progetti - MandelBlog Studio

', }, "pt": { "about": "

A MandelBlog cria sites para empresas de serviços, estúdios e pequenas equipas que precisam de uma presença credível sem aparência de template.

", "links_heading": "Acessos rápidos", "support_heading": "Agendar reunião", - "support": "

Agendar reunião introdutória
info@mandelblog.com
Ver os nossos serviços

", - "mini": "

Contacto - Serviços - Projetos - MandelBlog Studio

", + "support": '

Agendar reunião introdutória
info@mandelblog.com
Ver os nossos serviços

', + "mini": '

Contacto - Serviços - Projetos - MandelBlog Studio

', }, "ru": { "about": "

MandelBlog создаёт сайты для сервисных компаний, студий и небольших команд, которым нужен убедительный онлайн-образ без шаблонного шума.

", "links_heading": "Быстрые ссылки", "support_heading": "Назначить звонок", - "support": "

Запланировать вводный звонок
info@mandelblog.com
Посмотреть услуги

", - "mini": "

Контакт - Услуги - Проекты - MandelBlog Studio

", + "support": '

Запланировать вводный звонок
info@mandelblog.com
Посмотреть услуги

', + "mini": '

Контакт - Услуги - Проекты - MandelBlog Studio

', }, } @@ -269,7 +280,9 @@ def clone(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]: return copy.deepcopy(blocks) -def footer_stream_data(locale: str, links: dict[str, str]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: +def footer_stream_data( + locale: str, links: dict[str, str] +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: cfg = FOOTER_CONTENT[locale] footer = [ block("about_us", {"heading": "MandelBlog Studio", "content": cfg["about"]}), @@ -328,10 +341,50 @@ def nl_home(urls: dict[str, str]) -> list[dict[str, Any]]: "section_title": "Waar MandelBlog op stuurt", "section_subtitle": "

Geen webshopdemo, maar een zakelijke website die klaar is voor aanvragen, vertrouwen en doorontwikkeling.

", "features": [ - item({"icon": "diagram-3", "icon_image": None, "title": "Duidelijke structuur", "description": "

Bezoekers vinden snel de juiste dienst, case of contactroute.

", "link_text": PAGE_TITLE_MAP["process"]["nl"], "link_url": urls["process"], "highlight": "featured"}), - item({"icon": "pencil-square", "icon_image": None, "title": "Zelf te beheren", "description": "

Teksten, beelden en secties beheert u zelf in overzichtelijke blokken.

", "link_text": secondary, "link_url": urls["services"], "highlight": "none"}), - item({"icon": "shield-check", "icon_image": None, "title": "Stabiele technische basis", "description": "

Een schaalbare opzet zonder overbodige complexiteit of template-ruis.

", "link_text": "Bekijk werkwijze", "link_url": urls["process"], "highlight": "none"}), - item({"icon": "graph-up-arrow", "icon_image": None, "title": "Klaar voor doorontwikkeling", "description": "

Later uitbreiden met extra pagina’s, koppelingen of commerce blijft mogelijk.

", "link_text": "Bekijk projecten", "link_url": urls["projects"], "highlight": "none"}), + item( + { + "icon": "diagram-3", + "icon_image": None, + "title": "Duidelijke structuur", + "description": "

Bezoekers vinden snel de juiste dienst, case of contactroute.

", + "link_text": PAGE_TITLE_MAP["process"]["nl"], + "link_url": urls["process"], + "highlight": "featured", + } + ), + item( + { + "icon": "pencil-square", + "icon_image": None, + "title": "Zelf te beheren", + "description": "

Teksten, beelden en secties beheert u zelf in overzichtelijke blokken.

", + "link_text": secondary, + "link_url": urls["services"], + "highlight": "none", + } + ), + item( + { + "icon": "shield-check", + "icon_image": None, + "title": "Stabiele technische basis", + "description": "

Een schaalbare opzet zonder overbodige complexiteit of template-ruis.

", + "link_text": "Bekijk werkwijze", + "link_url": urls["process"], + "highlight": "none", + } + ), + item( + { + "icon": "graph-up-arrow", + "icon_image": None, + "title": "Klaar voor doorontwikkeling", + "description": "

Later uitbreiden met extra pagina’s, koppelingen of commerce blijft mogelijk.

", + "link_text": "Bekijk projecten", + "link_url": urls["projects"], + "highlight": "none", + } + ), ], "columns": "2", }, @@ -347,10 +400,158 @@ def nl_home(urls: dict[str, str]) -> list[dict[str, Any]]: "show_annual_toggle": False, "annual_discount_text": "", "tiers": [ - item({"name": PAGE_TITLE_MAP["starter"]["nl"], "description": "Voor ondernemers die professioneel online willen starten", "price_monthly": None, "price_annual": None, "price_suffix": "", "custom_price_text": "Op offertebasis", "features": [item({"text": "Kernpagina’s en duidelijke navigatie", "included": True, "tooltip": ""}), item({"text": "Editor voor eigen contentbeheer", "included": True, "tooltip": ""}), item({"text": "Mobiel sterke presentatie", "included": True, "tooltip": ""})], "cta_text": primary, "cta_url": urls["contact"], "cta_style": "secondary", "is_featured": False, "featured_label": ""}), - item({"name": PAGE_TITLE_MAP["business"]["nl"], "description": "Voor dienstverleners met meerdere proposities of groeiplannen", "price_monthly": None, "price_annual": None, "price_suffix": "", "custom_price_text": "Op offertebasis", "features": [item({"text": "Meer ruimte voor diensten en cases", "included": True, "tooltip": ""}), item({"text": "Conversiegerichte opbouw", "included": True, "tooltip": ""}), item({"text": "SEO-vriendelijke basis", "included": True, "tooltip": ""})], "cta_text": primary, "cta_url": urls["contact"], "cta_style": "primary", "is_featured": True, "featured_label": "Aanbevolen"}), - item({"name": PAGE_TITLE_MAP["webshop"]["nl"], "description": "Voor organisaties die een zakelijke site willen uitbreiden met online verkoop", "price_monthly": None, "price_annual": None, "price_suffix": "", "custom_price_text": "Op offertebasis", "features": [item({"text": "Productstructuur en checkout", "included": True, "tooltip": ""}), item({"text": "Betalingen en orderverwerking", "included": True, "tooltip": ""}), item({"text": "Schaalbare commerce-opzet", "included": True, "tooltip": ""})], "cta_text": primary, "cta_url": urls["contact"], "cta_style": "secondary", "is_featured": False, "featured_label": ""}), - item({"name": PAGE_TITLE_MAP["support"]["nl"], "description": "Voor teams die onderhoud, technische rust en doorlopende optimalisatie nodig hebben", "price_monthly": None, "price_annual": None, "price_suffix": "", "custom_price_text": "Maandelijks traject", "features": [item({"text": "Updates en onderhoud", "included": True, "tooltip": ""}), item({"text": "Monitoring en technische oplossingen", "included": True, "tooltip": ""}), item({"text": "Doorlopende verbetering", "included": True, "tooltip": ""})], "cta_text": primary, "cta_url": urls["contact"], "cta_style": "secondary", "is_featured": False, "featured_label": ""}), + item( + { + "name": PAGE_TITLE_MAP["starter"]["nl"], + "description": "Voor ondernemers die professioneel online willen starten", + "price_monthly": None, + "price_annual": None, + "price_suffix": "", + "custom_price_text": "Op offertebasis", + "features": [ + item( + { + "text": "Kernpagina’s en duidelijke navigatie", + "included": True, + "tooltip": "", + } + ), + item( + { + "text": "Editor voor eigen contentbeheer", + "included": True, + "tooltip": "", + } + ), + item( + { + "text": "Mobiel sterke presentatie", + "included": True, + "tooltip": "", + } + ), + ], + "cta_text": primary, + "cta_url": urls["contact"], + "cta_style": "secondary", + "is_featured": False, + "featured_label": "", + } + ), + item( + { + "name": PAGE_TITLE_MAP["business"]["nl"], + "description": "Voor dienstverleners met meerdere proposities of groeiplannen", + "price_monthly": None, + "price_annual": None, + "price_suffix": "", + "custom_price_text": "Op offertebasis", + "features": [ + item( + { + "text": "Meer ruimte voor diensten en cases", + "included": True, + "tooltip": "", + } + ), + item( + { + "text": "Conversiegerichte opbouw", + "included": True, + "tooltip": "", + } + ), + item( + { + "text": "SEO-vriendelijke basis", + "included": True, + "tooltip": "", + } + ), + ], + "cta_text": primary, + "cta_url": urls["contact"], + "cta_style": "primary", + "is_featured": True, + "featured_label": "Aanbevolen", + } + ), + item( + { + "name": PAGE_TITLE_MAP["webshop"]["nl"], + "description": "Voor organisaties die een zakelijke site willen uitbreiden met online verkoop", + "price_monthly": None, + "price_annual": None, + "price_suffix": "", + "custom_price_text": "Op offertebasis", + "features": [ + item( + { + "text": "Productstructuur en checkout", + "included": True, + "tooltip": "", + } + ), + item( + { + "text": "Betalingen en orderverwerking", + "included": True, + "tooltip": "", + } + ), + item( + { + "text": "Schaalbare commerce-opzet", + "included": True, + "tooltip": "", + } + ), + ], + "cta_text": primary, + "cta_url": urls["contact"], + "cta_style": "secondary", + "is_featured": False, + "featured_label": "", + } + ), + item( + { + "name": PAGE_TITLE_MAP["support"]["nl"], + "description": "Voor teams die onderhoud, technische rust en doorlopende optimalisatie nodig hebben", + "price_monthly": None, + "price_annual": None, + "price_suffix": "", + "custom_price_text": "Maandelijks traject", + "features": [ + item( + { + "text": "Updates en onderhoud", + "included": True, + "tooltip": "", + } + ), + item( + { + "text": "Monitoring en technische oplossingen", + "included": True, + "tooltip": "", + } + ), + item( + { + "text": "Doorlopende verbetering", + "included": True, + "tooltip": "", + } + ), + ], + "cta_text": primary, + "cta_url": urls["contact"], + "cta_style": "secondary", + "is_featured": False, + "featured_label": "", + } + ), ], "footer_text": "

We adviseren welk pakket past bij uw fase, team en doelstelling.

", }, @@ -364,8 +565,26 @@ def nl_home(urls: dict[str, str]) -> list[dict[str, Any]]: "section_title": "Wat opdrachtgevers waarderen", "section_subtitle": "

Kleine teams kiezen voor MandelBlog omdat het traject overzichtelijk blijft en de site daarna echt bruikbaar is.

", "testimonials": [ - item({"quote": "

We kregen in korte tijd een website die eindelijk past bij onze dienstverlening en die we zelf kunnen onderhouden.

", "author_name": "Sanne de Vries", "author_title": "Studio Nova - eigenaar", "author_photo": None, "company_logo": None, "rating": 0}), - item({"quote": "

Het traject was helder, de teksten kregen structuur en onze contactaanvragen lopen nu via één duidelijke route.

", "author_name": "Mark Jansen", "author_title": "Jansen Interieur - medeoprichter", "author_photo": None, "company_logo": None, "rating": 0}), + item( + { + "quote": "

We kregen in korte tijd een website die eindelijk past bij onze dienstverlening en die we zelf kunnen onderhouden.

", + "author_name": "Sanne de Vries", + "author_title": "Studio Nova - eigenaar", + "author_photo": None, + "company_logo": None, + "rating": 0, + } + ), + item( + { + "quote": "

Het traject was helder, de teksten kregen structuur en onze contactaanvragen lopen nu via één duidelijke route.

", + "author_name": "Mark Jansen", + "author_title": "Jansen Interieur - medeoprichter", + "author_photo": None, + "company_logo": None, + "rating": 0, + } + ), ], "customer_logos": [], "aggregate_rating": "", @@ -381,10 +600,34 @@ def nl_home(urls: dict[str, str]) -> list[dict[str, Any]]: "section_title": "Veelgestelde vragen", "section_subtitle": "

We zijn duidelijk over planning, samenwerking en beheer.

", "faqs": [ - item({"question": "Voor welke bedrijven is MandelBlog geschikt?", "answer": "

Voor dienstverleners, studio’s en kleine teams die een professionele site nodig hebben zonder zwaar traject.

", "category": "Algemeen"}), - item({"question": "Kunnen we later uitbreiden?", "answer": "

Ja. We bouwen een structuur waarmee extra pagina’s, talen of koppelingen later logisch aansluiten.

", "category": "Uitbreiding"}), - item({"question": "Beheren we de content zelf?", "answer": "

Ja. De opzet is juist bedoeld zodat uw team pagina’s en blokken zelfstandig kan aanpassen.

", "category": "Beheer"}), - item({"question": "Wat gebeurt er na livegang?", "answer": "

Dan kunt u kiezen voor onderhoud en gerichte doorontwikkeling als dat nodig is.

", "category": "Support"}), + item( + { + "question": "Voor welke bedrijven is MandelBlog geschikt?", + "answer": "

Voor dienstverleners, studio’s en kleine teams die een professionele site nodig hebben zonder zwaar traject.

", + "category": "Algemeen", + } + ), + item( + { + "question": "Kunnen we later uitbreiden?", + "answer": "

Ja. We bouwen een structuur waarmee extra pagina’s, talen of koppelingen later logisch aansluiten.

", + "category": "Uitbreiding", + } + ), + item( + { + "question": "Beheren we de content zelf?", + "answer": "

Ja. De opzet is juist bedoeld zodat uw team pagina’s en blokken zelfstandig kan aanpassen.

", + "category": "Beheer", + } + ), + item( + { + "question": "Wat gebeurt er na livegang?", + "answer": "

Dan kunt u kiezen voor onderhoud en gerichte doorontwikkeling als dat nodig is.

", + "category": "Support", + } + ), ], "show_contact_cta": "card", "contact_cta_text": primary, @@ -422,12 +665,79 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any] "features_title": "Waar we op letten", "features_sub": "

We werken het liefst voor organisaties die behoefte hebben aan duidelijkheid, snelheid en inhoudelijke regie.

", "features": [ - ("people", "Voor wie we werken", "

Dienstverleners, studio’s en kleine teams met een duidelijke propositie en een praktische planning.

"), - ("diagram-3", "Onze werkwijze", "

We starten met scherpte in doel en inhoud, bouwen met vaste blokken en leveren beheersbaar op.

"), - ("shield-check", "Waarom het anders werkt", "

Geen los template of black box, maar een duidelijke structuur waarmee u zelf verder kunt.

"), - ("person-badge", "Klein team, direct contact", "

U schakelt direct met de mensen die het werk uitvoeren en keuzes vertalen naar de site.

"), + ( + "people", + "Voor wie we werken", + "

Dienstverleners, studio’s en kleine teams met een duidelijke propositie en een praktische planning.

", + ), + ( + "diagram-3", + "Onze werkwijze", + "

We starten met scherpte in doel en inhoud, bouwen met vaste blokken en leveren beheersbaar op.

", + ), + ( + "shield-check", + "Waarom het anders werkt", + "

Geen los template of black box, maar een duidelijke structuur waarmee u zelf verder kunt.

", + ), + ( + "person-badge", + "Klein team, direct contact", + "

U schakelt direct met de mensen die het werk uitvoeren en keuzes vertalen naar de site.

", + ), ], - "extra_block": block("saas_animated_stats", {"layout_width": "container", "background_style": "light", "layout": "cards-grid", "badge": "Werkwijze", "heading": "Onze aanpak in 3 stappen", "subheading": "Kort traject, duidelijke keuzes en daarna een site die voor uw team werkt.", "stats": [item({"value": "1", "prefix": None, "suffix": "", "label": "Kennismaking", "description": "We bepalen doel, inhoud en prioriteiten.", "icon": "chat-square-text", "highlight": False}), item({"value": "2", "prefix": None, "suffix": "", "label": "Uitwerking", "description": "We bouwen de pagina’s en stemmen de inhoud af.", "icon": "layout-text-window", "highlight": False}), item({"value": "3", "prefix": None, "suffix": "", "label": "Oplevering", "description": "U krijgt uitleg, beheer en een duidelijke vervolgstap.", "icon": "rocket", "highlight": False})], "animation_duration": 1800, "animation_easing": "ease-out", "start_on_scroll": True, "show_logos": False, "logos_heading": "", "company_logos": []}), + "extra_block": block( + "saas_animated_stats", + { + "layout_width": "container", + "background_style": "light", + "layout": "cards-grid", + "badge": "Werkwijze", + "heading": "Onze aanpak in 3 stappen", + "subheading": "Kort traject, duidelijke keuzes en daarna een site die voor uw team werkt.", + "stats": [ + item( + { + "value": "1", + "prefix": None, + "suffix": "", + "label": "Kennismaking", + "description": "We bepalen doel, inhoud en prioriteiten.", + "icon": "chat-square-text", + "highlight": False, + } + ), + item( + { + "value": "2", + "prefix": None, + "suffix": "", + "label": "Uitwerking", + "description": "We bouwen de pagina’s en stemmen de inhoud af.", + "icon": "layout-text-window", + "highlight": False, + } + ), + item( + { + "value": "3", + "prefix": None, + "suffix": "", + "label": "Oplevering", + "description": "U krijgt uitleg, beheer en een duidelijke vervolgstap.", + "icon": "rocket", + "highlight": False, + } + ), + ], + "animation_duration": 1800, + "animation_easing": "ease-out", + "start_on_scroll": True, + "show_logos": False, + "logos_heading": "", + "company_logos": [], + }, + ), "cta": "Wilt u weten of onze aanpak past bij uw bedrijf?", }, "services": { @@ -436,10 +746,26 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any] "features_title": "Wat we leveren", "features_sub": "

Geen losse modules, maar een traject dat aansluit op uw fase, team en doelen.

", "features": [ - ("window", PAGE_TITLE_MAP["starter"]["nl"], "

Voor ondernemers die snel professioneel online willen staan met een heldere basis.

"), - ("briefcase", PAGE_TITLE_MAP["business"]["nl"], "

Voor organisaties met meerdere diensten, cases of een complexere aanbodstructuur.

"), - ("cart-check", PAGE_TITLE_MAP["webshop"]["nl"], "

Voor teams die online verkoop willen toevoegen zonder de grip op techniek te verliezen.

"), - ("wrench-adjustable", PAGE_TITLE_MAP["support"]["nl"], "

Voor organisaties die onderhoud, stabiliteit en doorlopende verbetering nodig hebben.

"), + ( + "window", + PAGE_TITLE_MAP["starter"]["nl"], + "

Voor ondernemers die snel professioneel online willen staan met een heldere basis.

", + ), + ( + "briefcase", + PAGE_TITLE_MAP["business"]["nl"], + "

Voor organisaties met meerdere diensten, cases of een complexere aanbodstructuur.

", + ), + ( + "cart-check", + PAGE_TITLE_MAP["webshop"]["nl"], + "

Voor teams die online verkoop willen toevoegen zonder de grip op techniek te verliezen.

", + ), + ( + "wrench-adjustable", + PAGE_TITLE_MAP["support"]["nl"], + "

Voor organisaties die onderhoud, stabiliteit en doorlopende verbetering nodig hebben.

", + ), ], "extra_block": None, "cta": "Twijfelt u welk pakket past bij uw fase?", @@ -450,9 +776,21 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any] "features_title": "Wat u in onze projecten terugziet", "features_sub": "

We sturen niet op oppervlakkige effecten, maar op duidelijkheid en bruikbaarheid.

", "features": [ - ("diagram-3", "Heldere pagina-opbouw", "

Bezoekers begrijpen snel waar ze moeten zijn en welke stap logisch volgt.

"), - ("pencil-square", "Eenvoudig beheer", "

Teams kunnen teksten, visuals en pagina’s zelf aanpassen zonder omweg.

"), - ("graph-up-arrow", "Gericht op aanvragen", "

Contact en conversie zijn zichtbaar verwerkt in de structuur en inhoud.

"), + ( + "diagram-3", + "Heldere pagina-opbouw", + "

Bezoekers begrijpen snel waar ze moeten zijn en welke stap logisch volgt.

", + ), + ( + "pencil-square", + "Eenvoudig beheer", + "

Teams kunnen teksten, visuals en pagina’s zelf aanpassen zonder omweg.

", + ), + ( + "graph-up-arrow", + "Gericht op aanvragen", + "

Contact en conversie zijn zichtbaar verwerkt in de structuur en inhoud.

", + ), ], "extra_block": None, "cta": "Wilt u uw volgende project professioneel neerzetten?", @@ -463,11 +801,76 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any] "features_title": "Waarvoor u contact kunt opnemen", "features_sub": "

Kies de route die past bij uw vraag of traject.

", "features": [ - ("rocket", "Nieuw traject", "

Voor een nieuwe website, herpositionering of complete herbouw.

"), - ("briefcase", "Pakketkeuze", "

Voor advies over welk pakket of welke structuur het beste past.

"), - ("tools", "Onderhoud of uitbreiding", "

Voor technische ondersteuning, uitbreidingen of een vervolgfase na livegang.

"), + ( + "rocket", + "Nieuw traject", + "

Voor een nieuwe website, herpositionering of complete herbouw.

", + ), + ( + "briefcase", + "Pakketkeuze", + "

Voor advies over welk pakket of welke structuur het beste past.

", + ), + ( + "tools", + "Onderhoud of uitbreiding", + "

Voor technische ondersteuning, uitbreidingen of een vervolgfase na livegang.

", + ), ], - "extra_block": block("saas_demo_request", {"layout_width": "container", "background_style": "light", "layout": "split", "section_title": "Vertel kort wat u nodig heeft", "section_subtitle": "

We reageren inhoudelijk en zonder verkooppraat op uw vraag.

", "form_fields": [item({"field_type": "text", "label": "Naam", "placeholder": "Uw naam", "required": True}), item({"field_type": "email", "label": "E-mail", "placeholder": "naam@bedrijf.nl", "required": True}), item({"field_type": "company", "label": "Bedrijf", "placeholder": "Bedrijfsnaam", "required": True}), item({"field_type": "message", "label": "Vraag of project", "placeholder": "Waar zoekt u hulp bij?", "required": False})], "submit_button_text": primary, "form_action_url": urls["contact"], "benefits_title": "Wat u kunt verwachten", "benefits": [item("Reactie binnen 24 uur"), item("Intakegesprek van 15 minuten"), item("Volledig vrijblijvend")], "side_image": 1, "privacy_text": "

We gebruiken uw gegevens alleen voor contact over deze aanvraag.

"}), + "extra_block": block( + "saas_demo_request", + { + "layout_width": "container", + "background_style": "light", + "layout": "split", + "section_title": "Vertel kort wat u nodig heeft", + "section_subtitle": "

We reageren inhoudelijk en zonder verkooppraat op uw vraag.

", + "form_fields": [ + item( + { + "field_type": "text", + "label": "Naam", + "placeholder": "Uw naam", + "required": True, + } + ), + item( + { + "field_type": "email", + "label": "E-mail", + "placeholder": "naam@bedrijf.nl", + "required": True, + } + ), + item( + { + "field_type": "company", + "label": "Bedrijf", + "placeholder": "Bedrijfsnaam", + "required": True, + } + ), + item( + { + "field_type": "message", + "label": "Vraag of project", + "placeholder": "Waar zoekt u hulp bij?", + "required": False, + } + ), + ], + "submit_button_text": primary, + "form_action_url": urls["contact"], + "benefits_title": "Wat u kunt verwachten", + "benefits": [ + item("Reactie binnen 24 uur"), + item("Intakegesprek van 15 minuten"), + item("Volledig vrijblijvend"), + ], + "side_image": 1, + "privacy_text": "

We gebruiken uw gegevens alleen voor contact over deze aanvraag.

", + }, + ), "cta": "Klaar om een eerste stap te zetten?", }, "process": { @@ -476,10 +879,26 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any] "features_title": "Zo werken we samen", "features_sub": "

Kort, duidelijk en zonder onnodige ruis.

", "features": [ - ("chat-square-text", "1. Kennismaking", "

We bespreken doel, doelgroep, inhoud en wat u intern wilt kunnen beheren.

"), - ("layout-text-window", "2. Uitwerking", "

We zetten structuur, inhoud en ontwerp om in een duidelijke pagina-opbouw.

"), - ("rocket", "3. Oplevering", "

Na review gaat de site live en zorgen we voor een beheerbare overdracht.

"), - ("graph-up-arrow", "4. Doorontwikkeling", "

Wanneer nodig bouwen we verder op basis van gedrag, vragen en nieuwe plannen.

"), + ( + "chat-square-text", + "1. Kennismaking", + "

We bespreken doel, doelgroep, inhoud en wat u intern wilt kunnen beheren.

", + ), + ( + "layout-text-window", + "2. Uitwerking", + "

We zetten structuur, inhoud en ontwerp om in een duidelijke pagina-opbouw.

", + ), + ( + "rocket", + "3. Oplevering", + "

Na review gaat de site live en zorgen we voor een beheerbare overdracht.

", + ), + ( + "graph-up-arrow", + "4. Doorontwikkeling", + "

Wanneer nodig bouwen we verder op basis van gedrag, vragen en nieuwe plannen.

", + ), ], "extra_block": None, "cta": "Wilt u dit traject ook voor uw website?", @@ -487,13 +906,118 @@ def nl_standard_page(page_key: str, urls: dict[str, str]) -> list[dict[str, Any] } cfg = page_data[page_key] blocks = [ - block("saas_hero_banner", {"layout_width": "container", "background_style": "light", "layout": "split", "badge_text": "MANDELBLOG STUDIO" if page_key != "services" else "DIENSTEN", "badge_url": urls[page_key], "headline": cfg["headline"], "sub_headline": cfg["sub"], "primary_cta_text": primary, "primary_cta_url": urls["contact"], "secondary_cta_text": secondary, "secondary_cta_url": urls["services"], "hero_image": 1 if page_key != "process" else 24, "video_url": "", "stats": [], "customer_logos_title": ""}), - block("saas_features", {"layout_width": "container", "background_style": "light", "layout": "grid", "section_title": cfg["features_title"], "section_subtitle": cfg["features_sub"], "features": [item({"icon": icon, "icon_image": None, "title": title, "description": desc, "link_text": primary if page_key in {"contact", "about"} else secondary, "link_url": urls["contact"] if page_key in {"contact", "about"} else urls["services"], "highlight": "none"}) for icon, title, desc in cfg["features"]], "columns": "2" if len(cfg["features"]) <= 4 else "3"}), + block( + "saas_hero_banner", + { + "layout_width": "container", + "background_style": "light", + "layout": "split", + "badge_text": "MANDELBLOG STUDIO" + if page_key != "services" + else "DIENSTEN", + "badge_url": urls[page_key], + "headline": cfg["headline"], + "sub_headline": cfg["sub"], + "primary_cta_text": primary, + "primary_cta_url": urls["contact"], + "secondary_cta_text": secondary, + "secondary_cta_url": urls["services"], + "hero_image": 1 if page_key != "process" else 24, + "video_url": "", + "stats": [], + "customer_logos_title": "", + }, + ), + block( + "saas_features", + { + "layout_width": "container", + "background_style": "light", + "layout": "grid", + "section_title": cfg["features_title"], + "section_subtitle": cfg["features_sub"], + "features": [ + item( + { + "icon": icon, + "icon_image": None, + "title": title, + "description": desc, + "link_text": primary + if page_key in {"contact", "about"} + else secondary, + "link_url": urls["contact"] + if page_key in {"contact", "about"} + else urls["services"], + "highlight": "none", + } + ) + for icon, title, desc in cfg["features"] + ], + "columns": "2" if len(cfg["features"]) <= 4 else "3", + }, + ), ] if cfg["extra_block"] is not None: blocks.append(cfg["extra_block"]) - blocks.append(block("saas_faq", {"layout_width": "container", "background_style": "light", "layout": "accordion", "section_title": "Veelgestelde vragen", "section_subtitle": "

We houden het traject helder en praktisch.

", "faqs": [item({"question": "Werken jullie met vaste templates?", "answer": "

Nee. We gebruiken herbruikbare blokken, maar stemmen inhoud en structuur af op uw bedrijf.

", "category": "Werkwijze"}), item({"question": "Kunnen we later uitbreiden?", "answer": "

Ja. De opzet is bedoeld om later door te groeien zonder opnieuw te beginnen.

", "category": "Uitbreiding"}), item({"question": "Beheren we de inhoud zelf?", "answer": "

Ja. Dat is juist een belangrijk uitgangspunt van het platform.

", "category": "Beheer"})], "show_contact_cta": "card", "contact_cta_text": primary, "contact_cta_url": urls["contact"]})) - blocks.append(block("saas_cta_footer", {"layout_width": "container", "background_style": "light", "layout": "banner", "headline": cfg["cta"], "subheadline": "

Plan een kennismakingsgesprek en we laten zien welke route logisch is voor uw bedrijf.

", "primary_cta_text": primary, "primary_cta_url": urls["contact"], "secondary_cta_text": secondary, "secondary_cta_url": urls["services"], "background_image": 1, "side_image": 1, "show_no_credit_card": "with-icon", "no_credit_card_text": "Volledig vrijblijvend"})) + blocks.append( + block( + "saas_faq", + { + "layout_width": "container", + "background_style": "light", + "layout": "accordion", + "section_title": "Veelgestelde vragen", + "section_subtitle": "

We houden het traject helder en praktisch.

", + "faqs": [ + item( + { + "question": "Werken jullie met vaste templates?", + "answer": "

Nee. We gebruiken herbruikbare blokken, maar stemmen inhoud en structuur af op uw bedrijf.

", + "category": "Werkwijze", + } + ), + item( + { + "question": "Kunnen we later uitbreiden?", + "answer": "

Ja. De opzet is bedoeld om later door te groeien zonder opnieuw te beginnen.

", + "category": "Uitbreiding", + } + ), + item( + { + "question": "Beheren we de inhoud zelf?", + "answer": "

Ja. Dat is juist een belangrijk uitgangspunt van het platform.

", + "category": "Beheer", + } + ), + ], + "show_contact_cta": "card", + "contact_cta_text": primary, + "contact_cta_url": urls["contact"], + }, + ) + ) + blocks.append( + block( + "saas_cta_footer", + { + "layout_width": "container", + "background_style": "light", + "layout": "banner", + "headline": cfg["cta"], + "subheadline": "

Plan een kennismakingsgesprek en we laten zien welke route logisch is voor uw bedrijf.

", + "primary_cta_text": primary, + "primary_cta_url": urls["contact"], + "secondary_cta_text": secondary, + "secondary_cta_url": urls["services"], + "background_image": 1, + "side_image": 1, + "show_no_credit_card": "with-icon", + "no_credit_card_text": "Volledig vrijblijvend", + }, + ) + ) return blocks @@ -505,82 +1029,331 @@ def nl_service_page(kind: str, urls: dict[str, str]) -> list[dict[str, Any]]: "title": "Starter-website", "audience": "Voor ondernemers of kleine teams die snel professioneel online willen staan met een duidelijke basis.", "what": [ - ("layout-text-window", "Voor wie is dit?", "

Voor bedrijven met een helder aanbod die snel een professionele eerste indruk willen neerzetten.

"), - ("window", "Wat krijgt u?", "

Kernpagina’s, een logische navigatie en een editor waarmee uw team zelf content kan beheren.

"), - ("graph-up-arrow", "Wat levert het op?", "

Een professionele basis waarmee bezoekers sneller begrijpen wat u doet en hoe ze contact opnemen.

"), + ( + "layout-text-window", + "Voor wie is dit?", + "

Voor bedrijven met een helder aanbod die snel een professionele eerste indruk willen neerzetten.

", + ), + ( + "window", + "Wat krijgt u?", + "

Kernpagina’s, een logische navigatie en een editor waarmee uw team zelf content kan beheren.

", + ), + ( + "graph-up-arrow", + "Wat levert het op?", + "

Een professionele basis waarmee bezoekers sneller begrijpen wat u doet en hoe ze contact opnemen.

", + ), ], "outcomes": [ - ("shield-check", "Heldere online basis", "

Geen overbodige onderdelen, wel een site die vertrouwen geeft.

"), - ("people", "Eenvoudig beheer", "

Uw team kan updates zelf doen zonder afhankelijkheid.

"), - ("rocket", "Snelle livegang", "

Geschikt als eerste professionele stap of als vervanging van een verouderde site.

"), + ( + "shield-check", + "Heldere online basis", + "

Geen overbodige onderdelen, wel een site die vertrouwen geeft.

", + ), + ( + "people", + "Eenvoudig beheer", + "

Uw team kan updates zelf doen zonder afhankelijkheid.

", + ), + ( + "rocket", + "Snelle livegang", + "

Geschikt als eerste professionele stap of als vervanging van een verouderde site.

", + ), + ], + "choose": [ + "U wilt snel professioneel online staan.", + "U heeft vooral kernpagina’s en duidelijke navigatie nodig.", + "U wilt zelf teksten en beelden kunnen aanpassen.", ], - "choose": ["U wilt snel professioneel online staan.", "U heeft vooral kernpagina’s en duidelijke navigatie nodig.", "U wilt zelf teksten en beelden kunnen aanpassen."], "duration": "Gemiddelde oplevering: 2 tot 4 weken", }, "business": { "title": "Zakelijke website", "audience": "Voor dienstverleners en teams die meerdere proposities, cases of funnelstappen helder willen presenteren.", "what": [ - ("briefcase", "Voor wie is dit?", "

Voor organisaties die meer structuur, inhoudelijke diepgang en een sterkere aanvraagroute nodig hebben.

"), - ("layout-text-window", "Wat krijgt u?", "

Meer pagina-opbouw, ruimte voor cases en een SEO-vriendelijke basis die logisch meegroeit.

"), - ("graph-up-arrow", "Wat levert het op?", "

Een site die uw aanbod beter uitlegt en bezoekers gerichter naar contact of aanvraag leidt.

"), + ( + "briefcase", + "Voor wie is dit?", + "

Voor organisaties die meer structuur, inhoudelijke diepgang en een sterkere aanvraagroute nodig hebben.

", + ), + ( + "layout-text-window", + "Wat krijgt u?", + "

Meer pagina-opbouw, ruimte voor cases en een SEO-vriendelijke basis die logisch meegroeit.

", + ), + ( + "graph-up-arrow", + "Wat levert het op?", + "

Een site die uw aanbod beter uitlegt en bezoekers gerichter naar contact of aanvraag leidt.

", + ), ], "outcomes": [ - ("diagram-3", "Meer overzicht", "

Diensten, cases en expertise krijgen elk hun eigen plek.

"), - ("search", "Betere vindbaarheid", "

De opbouw is ingericht voor sterke inhoud en een SEO-vriendelijke basis.

"), - ("people", "Sterkere aanvragen", "

Bezoekers zien sneller welke route en welk aanbod bij hen past.

"), + ( + "diagram-3", + "Meer overzicht", + "

Diensten, cases en expertise krijgen elk hun eigen plek.

", + ), + ( + "search", + "Betere vindbaarheid", + "

De opbouw is ingericht voor sterke inhoud en een SEO-vriendelijke basis.

", + ), + ( + "people", + "Sterkere aanvragen", + "

Bezoekers zien sneller welke route en welk aanbod bij hen past.

", + ), + ], + "choose": [ + "U heeft meerdere diensten of doelgroepen.", + "U wilt cases, expertise en bewijs beter laten zien.", + "U zoekt meer structuur dan een startsite biedt.", ], - "choose": ["U heeft meerdere diensten of doelgroepen.", "U wilt cases, expertise en bewijs beter laten zien.", "U zoekt meer structuur dan een startsite biedt."], "duration": "Gemiddelde oplevering: 2 tot 4 weken", }, "webshop": { "title": "Webshop-implementatie", "audience": "Voor organisaties die online verkoop willen toevoegen zonder in een standaardshop te belanden.", "what": [ - ("cart-check", "Voor wie is dit?", "

Voor bedrijven die hun aanbod online willen verkopen met grip op inhoud, checkout en beheer.

"), - ("credit-card", "Wat krijgt u?", "

Een webshopstructuur met productoverzicht, checkout en een schaalbare basis voor orderverwerking.

"), - ("graph-up-arrow", "Wat levert het op?", "

Een verkoopomgeving die past bij uw merk en niet voelt als een los demo-sjabloon.

"), + ( + "cart-check", + "Voor wie is dit?", + "

Voor bedrijven die hun aanbod online willen verkopen met grip op inhoud, checkout en beheer.

", + ), + ( + "credit-card", + "Wat krijgt u?", + "

Een webshopstructuur met productoverzicht, checkout en een schaalbare basis voor orderverwerking.

", + ), + ( + "graph-up-arrow", + "Wat levert het op?", + "

Een verkoopomgeving die past bij uw merk en niet voelt als een los demo-sjabloon.

", + ), ], "outcomes": [ - ("window", "Betere presentatie", "

Producten en categorieën krijgen een zakelijke, duidelijke opbouw.

"), - ("shield-check", "Stabiele techniek", "

Betalingen en orderverwerking sluiten aan op een beheerbare stack.

"), - ("rocket", "Klaar voor groei", "

De commerce-opzet kan meegroeien met assortiment en processen.

"), + ( + "window", + "Betere presentatie", + "

Producten en categorieën krijgen een zakelijke, duidelijke opbouw.

", + ), + ( + "shield-check", + "Stabiele techniek", + "

Betalingen en orderverwerking sluiten aan op een beheerbare stack.

", + ), + ( + "rocket", + "Klaar voor groei", + "

De commerce-opzet kan meegroeien met assortiment en processen.

", + ), + ], + "choose": [ + "U wilt online verkoop combineren met een zakelijke website.", + "U heeft behoefte aan grip op structuur en techniek.", + "U zoekt geen standaard thema, maar een doordachte implementatie.", ], - "choose": ["U wilt online verkoop combineren met een zakelijke website.", "U heeft behoefte aan grip op structuur en techniek.", "U zoekt geen standaard thema, maar een doordachte implementatie."], "duration": "Gemiddelde oplevering: 3 tot 6 weken", }, "support": { "title": "Onderhoud & groei", "audience": "Voor teams die hun website of webshop stabiel willen houden en gericht willen doorontwikkelen.", "what": [ - ("tools", "Voor wie is dit?", "

Voor organisaties die niet zelf alle techniek willen monitoren, oplossen en plannen.

"), - ("shield-check", "Wat krijgt u?", "

Onderhoud, updates, monitoring en technische oplossingen binnen een vast werkritme.

"), - ("graph-up-arrow", "Wat levert het op?", "

Meer rust, minder technische verrassingen en ruimte om gericht te verbeteren.

"), + ( + "tools", + "Voor wie is dit?", + "

Voor organisaties die niet zelf alle techniek willen monitoren, oplossen en plannen.

", + ), + ( + "shield-check", + "Wat krijgt u?", + "

Onderhoud, updates, monitoring en technische oplossingen binnen een vast werkritme.

", + ), + ( + "graph-up-arrow", + "Wat levert het op?", + "

Meer rust, minder technische verrassingen en ruimte om gericht te verbeteren.

", + ), ], "outcomes": [ - ("activity", "Minder verstoringen", "

Technische issues worden sneller gesignaleerd en opgelost.

"), - ("clipboard-data", "Doorlopende verbetering", "

We werken stap voor stap aan performance, inhoud en conversie.

"), - ("people", "Vast ritme", "

U weet wanneer onderhoud gebeurt en waar prioriteit ligt.

"), + ( + "activity", + "Minder verstoringen", + "

Technische issues worden sneller gesignaleerd en opgelost.

", + ), + ( + "clipboard-data", + "Doorlopende verbetering", + "

We werken stap voor stap aan performance, inhoud en conversie.

", + ), + ( + "people", + "Vast ritme", + "

U weet wanneer onderhoud gebeurt en waar prioriteit ligt.

", + ), + ], + "choose": [ + "U wilt een vaste partner voor technisch onderhoud.", + "Uw site vraagt om kleine verbeteringen in plaats van een volledige herbouw.", + "U wilt sneller kunnen schakelen bij issues of uitbreidingen.", ], - "choose": ["U wilt een vaste partner voor technisch onderhoud.", "Uw site vraagt om kleine verbeteringen in plaats van een volledige herbouw.", "U wilt sneller kunnen schakelen bij issues of uitbreidingen."], "duration": "Reactie binnen 24 uur", }, }[kind] return [ - block("saas_hero_banner", {"layout_width": "container", "background_style": "light", "layout": "split", "badge_text": "PAKKET", "badge_url": urls[kind], "headline": config["title"], "sub_headline": f"

{config['audience']}

", "primary_cta_text": primary, "primary_cta_url": urls['contact'], "secondary_cta_text": secondary, "secondary_cta_url": urls['services'], "hero_image": 23, "video_url": "", "stats": [item({"value": config['duration'], "label": "Doorlooptijd"}), item({"value": "Reactie binnen 24 uur", "label": "Communicatie"}), item({"value": "Volledig vrijblijvend", "label": "Kennismaking"})], "customer_logos_title": ""}), - block("saas_features", {"layout_width": "container", "background_style": "light", "layout": "grid", "section_title": "Wat krijgt u?", "section_subtitle": "

Elk pakket is opgebouwd rond duidelijke keuzes, beheerbaarheid en inhoud die past bij uw bedrijf.

", "features": [item({"icon": icon, "icon_image": None, "title": title, "description": desc, "link_text": secondary, "link_url": urls['services'], "highlight": "none"}) for icon, title, desc in config['what']], "columns": "3"}), - block("saas_features", {"layout_width": "container", "background_style": "light", "layout": "grid", "section_title": "Wat levert het op?", "section_subtitle": "

De waarde zit niet in losse effecten, maar in duidelijkere communicatie en een beter werkende site.

", "features": [item({"icon": icon, "icon_image": None, "title": title, "description": desc, "link_text": primary, "link_url": urls['contact'], "highlight": "none"}) for icon, title, desc in config['outcomes']], "columns": "3"}), - block("saas_demo_request", {"layout_width": "container", "background_style": "light", "layout": "split", "section_title": "Wanneer kiest u dit pakket?", "section_subtitle": "

We adviseren dit pakket wanneer onderstaande punten aansluiten op uw situatie.

", "form_fields": [item({"field_type": "text", "label": "Naam", "placeholder": "Uw naam", "required": True}), item({"field_type": "email", "label": "E-mail", "placeholder": "naam@bedrijf.nl", "required": True}), item({"field_type": "company", "label": "Bedrijf", "placeholder": "Bedrijfsnaam", "required": True}), item({"field_type": "message", "label": "Vraag of context", "placeholder": "Vertel kort waar u nu staat", "required": False})], "submit_button_text": primary, "form_action_url": urls['contact'], "benefits_title": "Dit pakket past wanneer", "benefits": [item(text) for text in config['choose']], "side_image": 23, "privacy_text": "

We gebruiken uw gegevens alleen voor een reactie op uw aanvraag.

"}), - block("saas_cta_footer", {"layout_width": "container", "background_style": "light", "layout": "banner", "headline": f"Wilt u weten of {config['title'].lower()} past bij uw situatie?", "subheadline": "

Plan een kennismakingsgesprek en we adviseren eerlijk welk pakket logisch is.

", "primary_cta_text": primary, "primary_cta_url": urls['contact'], "secondary_cta_text": secondary, "secondary_cta_url": urls['services'], "background_image": 1, "side_image": 1, "show_no_credit_card": "with-icon", "no_credit_card_text": "Volledig vrijblijvend"}), + block( + "saas_hero_banner", + { + "layout_width": "container", + "background_style": "light", + "layout": "split", + "badge_text": "PAKKET", + "badge_url": urls[kind], + "headline": config["title"], + "sub_headline": f"

{config['audience']}

", + "primary_cta_text": primary, + "primary_cta_url": urls["contact"], + "secondary_cta_text": secondary, + "secondary_cta_url": urls["services"], + "hero_image": 23, + "video_url": "", + "stats": [ + item({"value": config["duration"], "label": "Doorlooptijd"}), + item({"value": "Reactie binnen 24 uur", "label": "Communicatie"}), + item({"value": "Volledig vrijblijvend", "label": "Kennismaking"}), + ], + "customer_logos_title": "", + }, + ), + block( + "saas_features", + { + "layout_width": "container", + "background_style": "light", + "layout": "grid", + "section_title": "Wat krijgt u?", + "section_subtitle": "

Elk pakket is opgebouwd rond duidelijke keuzes, beheerbaarheid en inhoud die past bij uw bedrijf.

", + "features": [ + item( + { + "icon": icon, + "icon_image": None, + "title": title, + "description": desc, + "link_text": secondary, + "link_url": urls["services"], + "highlight": "none", + } + ) + for icon, title, desc in config["what"] + ], + "columns": "3", + }, + ), + block( + "saas_features", + { + "layout_width": "container", + "background_style": "light", + "layout": "grid", + "section_title": "Wat levert het op?", + "section_subtitle": "

De waarde zit niet in losse effecten, maar in duidelijkere communicatie en een beter werkende site.

", + "features": [ + item( + { + "icon": icon, + "icon_image": None, + "title": title, + "description": desc, + "link_text": primary, + "link_url": urls["contact"], + "highlight": "none", + } + ) + for icon, title, desc in config["outcomes"] + ], + "columns": "3", + }, + ), + block( + "saas_demo_request", + { + "layout_width": "container", + "background_style": "light", + "layout": "split", + "section_title": "Wanneer kiest u dit pakket?", + "section_subtitle": "

We adviseren dit pakket wanneer onderstaande punten aansluiten op uw situatie.

", + "form_fields": [ + item( + { + "field_type": "text", + "label": "Naam", + "placeholder": "Uw naam", + "required": True, + } + ), + item( + { + "field_type": "email", + "label": "E-mail", + "placeholder": "naam@bedrijf.nl", + "required": True, + } + ), + item( + { + "field_type": "company", + "label": "Bedrijf", + "placeholder": "Bedrijfsnaam", + "required": True, + } + ), + item( + { + "field_type": "message", + "label": "Vraag of context", + "placeholder": "Vertel kort waar u nu staat", + "required": False, + } + ), + ], + "submit_button_text": primary, + "form_action_url": urls["contact"], + "benefits_title": "Dit pakket past wanneer", + "benefits": [item(text) for text in config["choose"]], + "side_image": 23, + "privacy_text": "

We gebruiken uw gegevens alleen voor een reactie op uw aanvraag.

", + }, + ), + block( + "saas_cta_footer", + { + "layout_width": "container", + "background_style": "light", + "layout": "banner", + "headline": f"Wilt u weten of {config['title'].lower()} past bij uw situatie?", + "subheadline": "

Plan een kennismakingsgesprek en we adviseren eerlijk welk pakket logisch is.

", + "primary_cta_text": primary, + "primary_cta_url": urls["contact"], + "secondary_cta_text": secondary, + "secondary_cta_url": urls["services"], + "background_image": 1, + "side_image": 1, + "show_no_credit_card": "with-icon", + "no_credit_card_text": "Volledig vrijblijvend", + }, + ), ] def nl_body_for(page_key: str, urls: dict[str, str]) -> list[dict[str, Any]]: - if page_key == 'home': + if page_key == "home": return nl_home(urls) - if page_key in {'about', 'services', 'projects', 'contact', 'process'}: + if page_key in {"about", "services", "projects", "contact", "process"}: return nl_standard_page(page_key, urls) - if page_key in {'starter', 'business', 'webshop', 'support'}: + if page_key in {"starter", "business", "webshop", "support"}: return nl_service_page(page_key, urls) raise KeyError(page_key) @@ -589,81 +1362,132 @@ class Command(BaseCommand): help = "Apply MandelBlog agency cleanup content to the main site tree" def add_arguments(self, parser): - parser.add_argument('--apply', action='store_true', help='Persist and publish changes') + parser.add_argument( + "--apply", action="store_true", help="Persist and publish changes" + ) def handle(self, *args, **options): - apply_changes = options['apply'] + apply_changes = options["apply"] with transaction.atomic(): - for locale in Locale.objects.all().order_by('language_code'): + for locale in Locale.objects.all().order_by("language_code"): language_code = locale.language_code translated_pages = {} for key, source_id in SOURCE_PAGE_IDS.items(): source = Page.objects.get(id=source_id) - translated = Page.objects.filter(translation_key=source.translation_key, locale=locale).specific().first() + translated = ( + Page.objects.filter( + translation_key=source.translation_key, locale=locale + ) + .specific() + .first() + ) if translated: translated_pages[key] = translated - urls = {key: page.url for key, page in translated_pages.items() if getattr(page, 'url', None)} - if 'home' not in urls: + urls = { + key: page.url + for key, page in translated_pages.items() + if getattr(page, "url", None) + } + if "home" not in urls: continue for key, page in translated_pages.items(): changed = False title_map = PAGE_TITLE_MAP.get(key) - if title_map and title_map.get(language_code) and page.title != title_map[language_code]: + if ( + title_map + and title_map.get(language_code) + and page.title != title_map[language_code] + ): page.title = title_map[language_code] changed = True - if key == 'ai_search': + if key == "ai_search": if changed and apply_changes: rev = page.save_revision() rev.publish() continue - if hasattr(page, 'body'): + if hasattr(page, "body"): raw_data = list(page.body.raw_data) - if language_code == 'nl' and key in {'home', 'about', 'services', 'projects', 'contact', 'process', 'starter', 'business', 'webshop', 'support'}: - page.body = StreamValue(page.body.stream_block, nl_body_for(key, urls), is_lazy=True) + if language_code == "nl" and key in { + "home", + "about", + "services", + "projects", + "contact", + "process", + "starter", + "business", + "webshop", + "support", + }: + page.body = StreamValue( + page.body.stream_block, + nl_body_for(key, urls), + is_lazy=True, + ) changed = True else: replacements = {} - replacements.update(NL_REPLACEMENTS if language_code == 'nl' else {}) + replacements.update( + NL_REPLACEMENTS if language_code == "nl" else {} + ) if language_code in COMMON_CTA: - primary = COMMON_CTA[language_code]['primary'] - secondary = COMMON_CTA[language_code]['secondary'] - for variant in CTA_VARIANTS.get('nl', []): + primary = COMMON_CTA[language_code]["primary"] + secondary = COMMON_CTA[language_code]["secondary"] + for variant in CTA_VARIANTS.get("nl", []): replacements[variant] = primary - replacements['Bekijk diensten'] = secondary - replacements['Bekijk alle diensten'] = secondary - replacements['Plan kennismaking'] = primary - updated_raw_data = replace_nested(clone(raw_data), replacements) + replacements["Bekijk diensten"] = secondary + replacements["Bekijk alle diensten"] = secondary + replacements["Plan kennismaking"] = primary + updated_raw_data = replace_nested( + clone(raw_data), replacements + ) if updated_raw_data != raw_data: - page.body = StreamValue(page.body.stream_block, updated_raw_data, is_lazy=True) + page.body = StreamValue( + page.body.stream_block, + updated_raw_data, + is_lazy=True, + ) changed = True if changed: - self.stdout.write(f"{language_code}: update {key} -> {page.title}") + self.stdout.write( + f"{language_code}: update {key} -> {page.title}" + ) if apply_changes: rev = page.save_revision() rev.publish() footer_cfg = FOOTER_CONTENT.get(language_code) - footer_obj = LocalizedFooterContent.objects.filter(locale=locale).first() + footer_obj = LocalizedFooterContent.objects.filter( + locale=locale + ).first() if footer_obj and footer_cfg: link_urls = { - 'about': urls.get('about', '/'), - 'services': urls.get('services', '/'), - 'projects': urls.get('projects', '/'), - 'contact': urls.get('contact', '/'), + "about": urls.get("about", "/"), + "services": urls.get("services", "/"), + "projects": urls.get("projects", "/"), + "contact": urls.get("contact", "/"), } - footer_data, mini_data = footer_stream_data(language_code, link_urls) - footer_obj.footer = StreamValue(footer_obj.footer.stream_block, footer_data, is_lazy=True) - footer_obj.mini_footer = StreamValue(footer_obj.mini_footer.stream_block, mini_data, is_lazy=True) + footer_data, mini_data = footer_stream_data( + language_code, link_urls + ) + footer_obj.footer = StreamValue( + footer_obj.footer.stream_block, footer_data, is_lazy=True + ) + footer_obj.mini_footer = StreamValue( + footer_obj.mini_footer.stream_block, mini_data, is_lazy=True + ) self.stdout.write(f"{language_code}: update footer") if apply_changes: footer_obj.save() if not apply_changes: transaction.set_rollback(True) - self.stdout.write(self.style.WARNING('Dry run complete; no changes saved.')) + self.stdout.write( + self.style.WARNING("Dry run complete; no changes saved.") + ) else: - self.stdout.write(self.style.SUCCESS('Agency website refresh applied.')) + self.stdout.write(self.style.SUCCESS("Agency website refresh applied.")) diff --git a/mandelstudio/templatetags/agency_navigation.py b/mandelstudio/templatetags/agency_navigation.py index 7c4dd40..14e25ff 100644 --- a/mandelstudio/templatetags/agency_navigation.py +++ b/mandelstudio/templatetags/agency_navigation.py @@ -76,6 +76,7 @@ CTA_LABELS = { "ru": "Запланировать вводный звонок", } + @register.simple_tag(takes_context=True) def agency_primary_cta(context): request = context.get("request") From 582efd017d0fac34882d0aef5eb9a43cee7ec6dc Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Mon, 30 Mar 2026 18:35:01 +0200 Subject: [PATCH 18/45] Fix agency site import ordering for CI --- mandelstudio/management/commands/apply_agency_website_refresh.py | 1 + mandelstudio/templatetags/agency_navigation.py | 1 + 2 files changed, 2 insertions(+) diff --git a/mandelstudio/management/commands/apply_agency_website_refresh.py b/mandelstudio/management/commands/apply_agency_website_refresh.py index dbb9917..b2afb18 100644 --- a/mandelstudio/management/commands/apply_agency_website_refresh.py +++ b/mandelstudio/management/commands/apply_agency_website_refresh.py @@ -6,6 +6,7 @@ from typing import Any from django.core.management.base import BaseCommand from django.db import transaction + from wagtail.blocks import StreamValue from wagtail.models import Locale, Page diff --git a/mandelstudio/templatetags/agency_navigation.py b/mandelstudio/templatetags/agency_navigation.py index 14e25ff..44912c1 100644 --- a/mandelstudio/templatetags/agency_navigation.py +++ b/mandelstudio/templatetags/agency_navigation.py @@ -1,6 +1,7 @@ from __future__ import annotations from django import template + from wagtail.models import Locale, Page register = template.Library() From eef11801a64079e9caa5e369f62e692ce9c82cb6 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Tue, 31 Mar 2026 00:29:01 +0200 Subject: [PATCH 19/45] Roll out agency content parity across locales --- .../validators/rules/cta.py | 6 +- .../management/commands/_agency_content.py | 5015 +++++++++++++++++ .../commands/apply_agency_website_refresh.py | 145 +- .../templatetags/agency_navigation.py | 16 +- 4 files changed, 5041 insertions(+), 141 deletions(-) create mode 100644 mandelstudio/management/commands/_agency_content.py diff --git a/mandelblog_content_guard/validators/rules/cta.py b/mandelblog_content_guard/validators/rules/cta.py index bfe0d91..1bcee51 100644 --- a/mandelblog_content_guard/validators/rules/cta.py +++ b/mandelblog_content_guard/validators/rules/cta.py @@ -49,6 +49,7 @@ CTA_RULES = { r"^Service", r"^Dienstleistungen", r"^Erstgespräch", + r"^Beratung", r"^Einführ", r"^Anpassung", r"^Ansichts", @@ -83,6 +84,7 @@ CTA_RULES = { r"^Descubrir", r"^Contactar", r"^Planificar", + r"^Program", r"^Programe", r"^Concertar", r"^Enviar", @@ -141,6 +143,8 @@ def validate_cta(locale_code: str, field_path: str, normalized: str): last_segment = field_path.split(".")[-1] if last_segment not in CTA_FIELDS: return [] - if any(re.search(pattern, normalized) for pattern in CTA_RULES.get(locale_code, ())): + if any( + re.search(pattern, normalized) for pattern in CTA_RULES.get(locale_code, ()) + ): return [] return [make_issue("cta_language_mismatch", field_path, normalized)] diff --git a/mandelstudio/management/commands/_agency_content.py b/mandelstudio/management/commands/_agency_content.py new file mode 100644 index 0000000..7185a3f --- /dev/null +++ b/mandelstudio/management/commands/_agency_content.py @@ -0,0 +1,5015 @@ +from __future__ import annotations + +import uuid +from typing import Any + +COMMON_CTA = { + "nl": { + "primary": "Plan een kennismakingsgesprek", + "secondary": "Bekijk onze diensten", + }, + "en": { + "primary": "Schedule a consultation", + "secondary": "View our services", + }, + "de": { + "primary": "Beratungsgespräch planen", + "secondary": "Unsere Leistungen ansehen", + }, + "fr": { + "primary": "Planifier une consultation", + "secondary": "Voir nos services", + }, + "es": { + "primary": "Programar una reunión inicial", + "secondary": "Ver nuestros servicios", + }, + "it": { + "primary": "Prenota una consulenza introduttiva", + "secondary": "Scopri i nostri servizi", + }, + "pt": { + "primary": "Agendar uma consulta inicial", + "secondary": "Ver os nossos serviços", + }, + "ru": { + "primary": "Запланировать консультацию", + "secondary": "Посмотреть услуги", + }, +} + +CTA_VARIANTS = { + "nl": [ + "Plan gratis gesprek", + "Plan intake", + "Plan dienstengesprek", + "Contact Support", + "Start jouw project", + "Vraag intake aan", + "Plan kennismaking", + "Bekijk diensten", + "Bekijk alle diensten", + "Vraag startergesprek aan", + "Plan startergesprek", + "Plan zakelijk gesprek", + "Start webshop traject", + "Vraag supportplan aan", + "Plan gratis kennismaking", + "Bekijk projectresultaten", + ], +} + +NL_REPLACEMENTS = { + "New": "Nieuw", + "Popular": "Populair", + "AI Search": "AI-zoekfunctie", + "custom blokken": "maatwerkblokken", + "monitoring-ready basis": "stabiele technische basis", + "Monitoring + fixes": "Monitoring en technische oplossingen", + "SEO-ready basis": "SEO-vriendelijke basis", + "Starter Website": "Starter-website", + "Business Website": "Zakelijke website", + "Support & Groei": "Onderhoud & groei", + "24u": "binnen 24 uur", + "24u Reactietijd": "Reactie binnen 24 uur", + "15m Intake call": "Intakegesprek van 15 minuten", + "100% Vrijblijvend": "Volledig vrijblijvend", + "Webshop Implementatie": "Webshop-implementatie", + "Doorlopend Verbetering": "Doorlopende verbetering", + "Monitoring-ready stack": "Stabiele technische basis", +} + +FOOTER_CONTENT = { + "nl": { + "about": "

MandelBlog bouwt websites voor dienstverleners, studio’s en kleine teams die professioneel online willen staan zonder template-ruis.

", + "links_heading": "Snelle links", + "support_heading": "Plan een gesprek", + "support": '

Plan een kennismakingsgesprek
info@mandelblog.com
Bekijk onze diensten

', + "mini": '

Contact - Diensten - Projecten - MandelBlog Studio

', + }, + "en": { + "about": "

MandelBlog builds websites for service firms, studios and small teams that need a credible online presence without template clutter.

", + "links_heading": "Quick links", + "support_heading": "Book a call", + "support": '

Schedule a consultation
info@mandelblog.com
View our services

', + "mini": '

Contact - Services - Projects - MandelBlog Studio

', + }, + "de": { + "about": "

MandelBlog entwickelt Websites für Dienstleister, Studios und kleine Teams, die professionell auftreten wollen, ohne in Template-Logik festzustecken.

", + "links_heading": "Schnellzugriff", + "support_heading": "Gespräch planen", + "support": '

Beratungsgespräch planen
info@mandelblog.com
Unsere Leistungen ansehen

', + "mini": '

Kontakt - Dienstleistungen - Projekte - MandelBlog Studio

', + }, + "fr": { + "about": "

MandelBlog conçoit des sites pour des sociétés de services, des studios et des petites équipes qui veulent une présence crédible sans effet template.

", + "links_heading": "Accès rapides", + "support_heading": "Planifier un échange", + "support": '

Planifier une consultation
info@mandelblog.com
Voir nos services

', + "mini": '

Contact - Services - Projets - MandelBlog Studio

', + }, + "es": { + "about": "

MandelBlog crea sitios para empresas de servicios, estudios y pequeños equipos que necesitan una presencia creíble sin apariencia de plantilla.

", + "links_heading": "Accesos rápidos", + "support_heading": "Programar una reunión", + "support": '

Programar una reunión inicial
info@mandelblog.com
Ver nuestros servicios

', + "mini": '

Contacto - Servicios - Proyectos - MandelBlog Studio

', + }, + "it": { + "about": "

MandelBlog realizza siti per aziende di servizi, studi e piccoli team che vogliono una presenza credibile senza effetto template.

", + "links_heading": "Link rapidi", + "support_heading": "Prenota una call", + "support": '

Prenota una consulenza introduttiva
info@mandelblog.com
Scopri i nostri servizi

', + "mini": '

Contatto - Servizi - Progetti - MandelBlog Studio

', + }, + "pt": { + "about": "

A MandelBlog cria sites para empresas de serviços, estúdios e pequenas equipas que precisam de uma presença credível sem aparência de template.

", + "links_heading": "Acessos rápidos", + "support_heading": "Agendar conversa", + "support": '

Agendar uma consulta inicial
info@mandelblog.com
Ver os nossos serviços

', + "mini": '

Contacto - Serviços - Projetos - MandelBlog Studio

', + }, + "ru": { + "about": "

MandelBlog создаёт сайты для сервисных компаний, студий и небольших команд, которым нужен убедительный онлайн-образ без шаблонного шума.

", + "links_heading": "Быстрые ссылки", + "support_heading": "Назначить звонок", + "support": '

Запланировать консультацию
info@mandelblog.com
Посмотреть услуги

', + "mini": '

Контакт - Услуги - Проекты - MandelBlog Studio

', + }, +} + + +def uid() -> str: + return str(uuid.uuid4()) + + +def block(block_type: str, value: dict[str, Any] | str) -> dict[str, Any]: + return {"type": block_type, "value": value, "id": uid()} + + +def item(value: dict[str, Any] | str) -> dict[str, Any]: + return {"type": "item", "value": value, "id": uid()} + + +HOME_COPY = { + "nl": { + "badge": "MANDELBLOG STUDIO", + "headline": "Websites voor bedrijven die professioneel willen groeien", + "sub": "

MandelBlog ontwikkelt websites die vertrouwen opbouwen, duidelijk sturen op contact en eenvoudig te beheren zijn voor uw team.

", + "stats": [ + ("3", "Heldere stappen"), + ("1", "Vast aanspreekpunt"), + ("8", "Beschikbare talen"), + ], + "logos": "Gebouwd met Wagtail, Django en beproefde componenten", + "features_title": "Waar MandelBlog op stuurt", + "features_sub": "

Geen webshopdemo, maar een zakelijke website die klaar is voor aanvragen, vertrouwen en doorontwikkeling.

", + "features": [ + ( + "diagram-3", + "Duidelijke structuur", + "

Bezoekers vinden snel de juiste dienst, case of contactroute.

", + "process", + "Werkwijze", + "featured", + ), + ( + "pencil-square", + "Zelf te beheren", + "

Teksten, beelden en secties beheert u zelf in overzichtelijke blokken.

", + "services", + None, + "none", + ), + ( + "shield-check", + "Stabiele technische basis", + "

Een schaalbare opzet zonder overbodige complexiteit of template-ruis.

", + "process", + "Bekijk werkwijze", + "none", + ), + ( + "graph-up-arrow", + "Klaar voor doorontwikkeling", + "

Later uitbreiden met extra pagina’s, koppelingen of commerce blijft mogelijk.

", + "projects", + "Bekijk projecten", + "none", + ), + ], + "pricing_title": "Onze pakketten", + "pricing_sub": "

Elk pakket heeft een duidelijke scope. De exacte invulling stemmen we af in het kennismakingsgesprek.

", + "pricing_footer": "

We adviseren welk pakket past bij uw fase, team en doelstelling.

", + "tiers": { + "starter": ( + "Voor ondernemers die professioneel online willen starten", + [ + "Kernpagina’s en duidelijke navigatie", + "Editor voor eigen contentbeheer", + "Mobiel sterke presentatie", + ], + False, + "", + ), + "business": ( + "Voor dienstverleners met meerdere proposities of groeiplannen", + [ + "Meer ruimte voor diensten en cases", + "Conversiegerichte opbouw", + "SEO-vriendelijke basis", + ], + True, + "Aanbevolen", + ), + "webshop": ( + "Voor organisaties die een zakelijke site willen uitbreiden met online verkoop", + [ + "Productstructuur en checkout", + "Betalingen en orderverwerking", + "Schaalbare commerce-opzet", + ], + False, + "", + ), + "support": ( + "Voor teams die onderhoud, technische rust en doorlopende optimalisatie nodig hebben", + [ + "Updates en onderhoud", + "Monitoring en technische oplossingen", + "Doorlopende verbetering", + ], + False, + "", + ), + }, + "testimonials_title": "Wat opdrachtgevers waarderen", + "testimonials_sub": "

Kleine teams kiezen voor MandelBlog omdat het traject overzichtelijk blijft en de site daarna echt bruikbaar is.

", + "testimonials": [ + ( + "

We kregen in korte tijd een website die eindelijk past bij onze dienstverlening en die we zelf kunnen onderhouden.

", + "Sanne de Vries", + "Studio Nova - eigenaar", + ), + ( + "

Het traject was helder, de teksten kregen structuur en onze contactaanvragen lopen nu via één duidelijke route.

", + "Mark Jansen", + "Jansen Interieur - medeoprichter", + ), + ], + "faq_title": "Veelgestelde vragen", + "faq_sub": "

We zijn duidelijk over planning, samenwerking en beheer.

", + "faqs": [ + ( + "Voor welke bedrijven is MandelBlog geschikt?", + "

Voor dienstverleners, studio’s en kleine teams die een professionele site nodig hebben zonder zwaar traject.

", + "Algemeen", + ), + ( + "Kunnen we later uitbreiden?", + "

Ja. We bouwen een structuur waarmee extra pagina’s, talen of koppelingen later logisch aansluiten.

", + "Uitbreiding", + ), + ( + "Beheren we de content zelf?", + "

Ja. De opzet is juist bedoeld zodat uw team pagina’s en blokken zelfstandig kan aanpassen.

", + "Beheer", + ), + ( + "Wat gebeurt er na livegang?", + "

Dan kunt u kiezen voor onderhoud en gerichte doorontwikkeling als dat nodig is.

", + "Support", + ), + ], + "cta_headline": "Wilt u een website die vertrouwen geeft en werk uit handen neemt?", + "cta_sub": "

Plan een kennismakingsgesprek en we laten zien welke opzet past bij uw bedrijf en team.

", + "no_cc": "Volledig vrijblijvend", + }, + "en": { + "badge": "MANDELBLOG STUDIO", + "headline": "Websites for companies that want to grow with confidence", + "sub": "

MandelBlog builds websites that create trust, guide visitors toward contact and remain easy for your team to manage.

", + "stats": [ + ("3", "Clear steps"), + ("1", "Direct point of contact"), + ("8", "Available languages"), + ], + "logos": "Built with Wagtail, Django and proven components", + "features_title": "What MandelBlog focuses on", + "features_sub": "

Not a webshop demo, but a business website built for enquiries, trust and future growth.

", + "features": [ + ( + "diagram-3", + "Clear structure", + "

Visitors quickly find the right service, case study or contact path.

", + "process", + "How we work", + "featured", + ), + ( + "pencil-square", + "Easy to manage", + "

Your team can update copy, visuals and sections in practical content blocks.

", + "services", + None, + "none", + ), + ( + "shield-check", + "Stable technical foundation", + "

A scalable setup without unnecessary complexity or template clutter.

", + "process", + "See the process", + "none", + ), + ( + "graph-up-arrow", + "Ready to expand", + "

You can add pages, integrations or commerce later without rebuilding everything.

", + "projects", + "View projects", + "none", + ), + ], + "pricing_title": "Our packages", + "pricing_sub": "

Each package has a clear scope. We tailor the details during the consultation.

", + "pricing_footer": "

We help you choose the package that fits your stage, team and priorities.

", + "tiers": { + "starter": ( + "For businesses that need a professional online presence quickly", + [ + "Core pages and clear navigation", + "Editor for internal content updates", + "Strong mobile presentation", + ], + False, + "", + ), + "business": ( + "For service firms with multiple offers or growth plans", + [ + "More room for services and case studies", + "Conversion-focused structure", + "SEO-friendly foundation", + ], + True, + "Recommended", + ), + "webshop": ( + "For organisations that want to add online sales to a business website", + [ + "Product structure and checkout", + "Payments and order handling", + "Scalable commerce setup", + ], + False, + "", + ), + "support": ( + "For teams that need maintenance, technical stability and ongoing optimisation", + [ + "Updates and maintenance", + "Monitoring and technical fixes", + "Continuous improvement", + ], + False, + "", + ), + }, + "testimonials_title": "What clients appreciate", + "testimonials_sub": "

Small teams choose MandelBlog because the process stays focused and the website remains usable after launch.

", + "testimonials": [ + ( + "

Within a short timeframe we had a website that finally matched our service offer and that we can maintain ourselves.

", + "Sanne de Vries", + "Studio Nova - founder", + ), + ( + "

The process was clear, the message became sharper and enquiries now come in through one consistent path.

", + "Mark Jansen", + "Jansen Interior - co-founder", + ), + ], + "faq_title": "Frequently asked questions", + "faq_sub": "

We are clear about planning, collaboration and day-to-day management.

", + "faqs": [ + ( + "What type of companies is MandelBlog for?", + "

Service businesses, studios and small teams that need a professional website without a heavy process.

", + "General", + ), + ( + "Can we expand later?", + "

Yes. We build a structure that can grow with extra pages, languages or integrations.

", + "Growth", + ), + ( + "Can we manage content ourselves?", + "

Yes. The setup is designed so your team can edit pages and blocks independently.

", + "Management", + ), + ( + "What happens after launch?", + "

You can continue with maintenance and targeted improvements when needed.

", + "Support", + ), + ], + "cta_headline": "Do you want a website that builds trust and saves time?", + "cta_sub": "

Schedule a consultation and we will show you which setup fits your business and your team.

", + "no_cc": "No obligation", + }, + "de": { + "badge": "MANDELBLOG STUDIO", + "headline": "Websites für Unternehmen, die professionell wachsen wollen", + "sub": "

MandelBlog entwickelt Websites, die Vertrauen schaffen, klar zur Kontaktaufnahme führen und für Ihr Team gut pflegbar bleiben.

", + "stats": [ + ("3", "Klare Schritte"), + ("1", "Fester Ansprechpartner"), + ("8", "Verfügbare Sprachen"), + ], + "logos": "Gebaut mit Wagtail, Django und bewährten Komponenten", + "features_title": "Worauf MandelBlog achtet", + "features_sub": "

Kein Webshop-Demoauftritt, sondern eine Unternehmenswebsite für Anfragen, Vertrauen und Weiterentwicklung.

", + "features": [ + ( + "diagram-3", + "Klare Struktur", + "

Besucher finden schnell die passende Leistung, Referenz oder Kontaktmöglichkeit.

", + "process", + "Vorgehensweise", + "featured", + ), + ( + "pencil-square", + "Einfach zu pflegen", + "

Ihr Team kann Texte, Bilder und Abschnitte in klaren Blöcken selbst anpassen.

", + "services", + None, + "none", + ), + ( + "shield-check", + "Stabile technische Basis", + "

Ein skalierbarer Aufbau ohne unnötige Komplexität oder Template-Ballast.

", + "process", + "Ablauf ansehen", + "none", + ), + ( + "graph-up-arrow", + "Bereit für den Ausbau", + "

Zusätzliche Seiten, Integrationen oder Commerce lassen sich später sauber ergänzen.

", + "projects", + "Projekte ansehen", + "none", + ), + ], + "pricing_title": "Unsere Pakete", + "pricing_sub": "

Jedes Paket hat einen klaren Umfang. Die genaue Ausgestaltung besprechen wir im Beratungsgespräch.

", + "pricing_footer": "

Wir empfehlen das Paket, das zu Ihrer Phase, Ihrem Team und Ihren Prioritäten passt.

", + "tiers": { + "starter": ( + "Für Unternehmen, die schnell professionell online gehen möchten", + [ + "Kernseiten und klare Navigation", + "Editor für interne Inhaltsupdates", + "Starke mobile Darstellung", + ], + False, + "", + ), + "business": ( + "Für Dienstleister mit mehreren Angeboten oder Wachstumsplänen", + [ + "Mehr Raum für Leistungen und Referenzen", + "Conversion-orientierte Struktur", + "SEO-freundliche Basis", + ], + True, + "Empfohlen", + ), + "webshop": ( + "Für Unternehmen, die Online-Verkauf mit einer Unternehmenswebsite verbinden möchten", + [ + "Produktstruktur und Checkout", + "Zahlungen und Bestellabläufe", + "Skalierbarer Commerce-Aufbau", + ], + False, + "", + ), + "support": ( + "Für Teams, die Wartung, technische Stabilität und laufende Optimierung brauchen", + [ + "Updates und Wartung", + "Monitoring und technische Lösungen", + "Kontinuierliche Verbesserung", + ], + False, + "", + ), + }, + "testimonials_title": "Was Auftraggeber schätzen", + "testimonials_sub": "

Kleine Teams wählen MandelBlog, weil der Prozess übersichtlich bleibt und die Website danach wirklich nutzbar ist.

", + "testimonials": [ + ( + "

Wir hatten in kurzer Zeit eine Website, die endlich zu unserer Dienstleistung passt und die wir selbst pflegen können.

", + "Sanne de Vries", + "Studio Nova - Inhaberin", + ), + ( + "

Der Ablauf war klar, die Inhalte wurden schärfer und Anfragen laufen jetzt über einen eindeutigen Weg.

", + "Mark Jansen", + "Jansen Interieur - Mitgründer", + ), + ], + "faq_title": "Häufig gestellte Fragen", + "faq_sub": "

Wir kommunizieren klar über Zeitplan, Zusammenarbeit und Pflege.

", + "faqs": [ + ( + "Für welche Unternehmen ist MandelBlog geeignet?", + "

Für Dienstleister, Studios und kleine Teams, die eine professionelle Website ohne schweres Projekt benötigen.

", + "Allgemein", + ), + ( + "Können wir später erweitern?", + "

Ja. Wir bauen eine Struktur, die sich später um Seiten, Sprachen oder Integrationen erweitern lässt.

", + "Ausbau", + ), + ( + "Können wir Inhalte selbst pflegen?", + "

Ja. Genau dafür ist die Plattform ausgelegt.

", + "Pflege", + ), + ( + "Was passiert nach dem Go-live?", + "

Danach können Sie Wartung und gezielte Weiterentwicklung hinzunehmen.

", + "Support", + ), + ], + "cta_headline": "Möchten Sie eine Website, die Vertrauen schafft und Aufwand reduziert?", + "cta_sub": "

Planen Sie ein Beratungsgespräch und wir zeigen, welche Lösung zu Ihrem Unternehmen und Team passt.

", + "no_cc": "Unverbindlich", + }, + "fr": { + "badge": "MANDELBLOG STUDIO", + "headline": "Des sites pour les entreprises qui veulent grandir avec crédibilité", + "sub": "

MandelBlog conçoit des sites qui inspirent confiance, orientent clairement vers le contact et restent simples à gérer pour votre équipe.

", + "stats": [ + ("3", "Étapes claires"), + ("1", "Interlocuteur unique"), + ("8", "Langues disponibles"), + ], + "logos": "Construit avec Wagtail, Django et des composants éprouvés", + "features_title": "Ce que MandelBlog privilégie", + "features_sub": "

Pas une démo e-commerce, mais un site d’entreprise pensé pour les demandes, la crédibilité et l’évolution.

", + "features": [ + ( + "diagram-3", + "Structure claire", + "

Les visiteurs trouvent rapidement le bon service, la bonne référence ou la bonne voie de contact.

", + "process", + "Notre méthode", + "featured", + ), + ( + "pencil-square", + "Simple à gérer", + "

Votre équipe peut mettre à jour textes, visuels et sections dans des blocs clairs.

", + "services", + None, + "none", + ), + ( + "shield-check", + "Base technique fiable", + "

Une architecture évolutive sans complexité inutile ni bruit de template.

", + "process", + "Voir la méthode", + "none", + ), + ( + "graph-up-arrow", + "Prêt à évoluer", + "

Vous pouvez ajouter plus tard des pages, des intégrations ou du commerce sans repartir de zéro.

", + "projects", + "Voir les projets", + "none", + ), + ], + "pricing_title": "Nos offres", + "pricing_sub": "

Chaque offre a un périmètre clair. Nous ajustons le détail pendant la consultation.

", + "pricing_footer": "

Nous vous aidons à choisir l’offre adaptée à votre étape, à votre équipe et à vos priorités.

", + "tiers": { + "starter": ( + "Pour les entreprises qui veulent être en ligne rapidement avec une présence professionnelle", + [ + "Pages essentielles et navigation claire", + "Éditeur pour mettre à jour le contenu en interne", + "Présentation solide sur mobile", + ], + False, + "", + ), + "business": ( + "Pour les sociétés de services avec plusieurs offres ou des ambitions de croissance", + [ + "Plus d’espace pour les services et les références", + "Structure orientée conversion", + "Base favorable au SEO", + ], + True, + "Recommandé", + ), + "webshop": ( + "Pour les organisations qui veulent ajouter la vente en ligne à un site d’entreprise", + [ + "Structure produit et tunnel d’achat", + "Paiements et gestion des commandes", + "Base e-commerce évolutive", + ], + False, + "", + ), + "support": ( + "Pour les équipes qui ont besoin de maintenance, de stabilité technique et d’optimisation continue", + [ + "Mises à jour et maintenance", + "Monitoring et correctifs techniques", + "Amélioration continue", + ], + False, + "", + ), + }, + "testimonials_title": "Ce que nos clients apprécient", + "testimonials_sub": "

Les petites équipes choisissent MandelBlog parce que le projet reste clair et que le site reste utile après la mise en ligne.

", + "testimonials": [ + ( + "

Nous avons obtenu rapidement un site qui correspond enfin à notre offre et que nous pouvons gérer nous-mêmes.

", + "Sanne de Vries", + "Studio Nova - fondatrice", + ), + ( + "

Le projet était clair, le message est devenu plus net et les demandes arrivent désormais par un parcours cohérent.

", + "Mark Jansen", + "Jansen Intérieur - cofondateur", + ), + ], + "faq_title": "Questions fréquentes", + "faq_sub": "

Nous sommes clairs sur le planning, la collaboration et la gestion au quotidien.

", + "faqs": [ + ( + "Pour quelles entreprises MandelBlog est-il adapté ?", + "

Pour les sociétés de services, les studios et les petites équipes qui ont besoin d’un site professionnel sans projet lourd.

", + "Général", + ), + ( + "Pouvons-nous faire évoluer le site plus tard ?", + "

Oui. Nous construisons une structure capable d’accueillir ensuite des pages, des langues ou des intégrations.

", + "Évolution", + ), + ( + "Pouvons-nous gérer le contenu nous-mêmes ?", + "

Oui. C’est un principe central de la plateforme.

", + "Gestion", + ), + ( + "Que se passe-t-il après la mise en ligne ?", + "

Vous pouvez continuer avec de la maintenance et des améliorations ciblées si nécessaire.

", + "Support", + ), + ], + "cta_headline": "Vous voulez un site qui inspire confiance et fait gagner du temps ?", + "cta_sub": "

Planifiez une consultation et nous vous montrerons quelle approche convient à votre entreprise et à votre équipe.

", + "no_cc": "Sans engagement", + }, + "es": { + "badge": "MANDELBLOG STUDIO", + "headline": "Sitios web para empresas que quieren crecer con credibilidad", + "sub": "

MandelBlog crea sitios que generan confianza, orientan con claridad hacia el contacto y siguen siendo fáciles de gestionar para su equipo.

", + "stats": [ + ("3", "Pasos claros"), + ("1", "Interlocutor fijo"), + ("8", "Idiomas disponibles"), + ], + "logos": "Construido con Wagtail, Django y componentes probados", + "features_title": "En qué se enfoca MandelBlog", + "features_sub": "

No es una demo de tienda, sino un sitio corporativo preparado para generar oportunidades, confianza y crecimiento.

", + "features": [ + ( + "diagram-3", + "Estructura clara", + "

Los visitantes encuentran rápido el servicio, el caso o la vía de contacto correcta.

", + "process", + "Cómo trabajamos", + "featured", + ), + ( + "pencil-square", + "Fácil de gestionar", + "

Su equipo puede actualizar textos, imágenes y secciones en bloques claros.

", + "services", + None, + "none", + ), + ( + "shield-check", + "Base técnica estable", + "

Una estructura escalable sin complejidad innecesaria ni ruido de plantilla.

", + "process", + "Ver proceso", + "none", + ), + ( + "graph-up-arrow", + "Preparado para crecer", + "

Más adelante puede añadir páginas, integraciones o comercio sin empezar de cero.

", + "projects", + "Ver proyectos", + "none", + ), + ], + "pricing_title": "Nuestros paquetes", + "pricing_sub": "

Cada paquete tiene un alcance claro. Ajustamos el detalle durante la reunión inicial.

", + "pricing_footer": "

Le ayudamos a elegir el paquete que encaja con su etapa, su equipo y sus prioridades.

", + "tiers": { + "starter": ( + "Para empresas que necesitan una presencia profesional en poco tiempo", + [ + "Páginas clave y navegación clara", + "Editor para actualizar contenido internamente", + "Buena presentación en móvil", + ], + False, + "", + ), + "business": ( + "Para empresas de servicios con varias propuestas o planes de crecimiento", + [ + "Más espacio para servicios y casos", + "Estructura orientada a conversión", + "Base favorable al SEO", + ], + True, + "Recomendado", + ), + "webshop": ( + "Para organizaciones que quieren añadir venta online a un sitio corporativo", + [ + "Estructura de productos y checkout", + "Pagos y gestión de pedidos", + "Base escalable para comercio", + ], + False, + "", + ), + "support": ( + "Para equipos que necesitan mantenimiento, estabilidad técnica y optimización continua", + [ + "Actualizaciones y mantenimiento", + "Monitorización y soluciones técnicas", + "Mejora continua", + ], + False, + "", + ), + }, + "testimonials_title": "Lo que valoran los clientes", + "testimonials_sub": "

Los equipos pequeños eligen MandelBlog porque el proceso sigue siendo claro y el sitio continúa siendo útil tras la entrega.

", + "testimonials": [ + ( + "

En poco tiempo tuvimos un sitio que por fin encaja con nuestros servicios y que podemos mantener nosotros mismos.

", + "Sanne de Vries", + "Studio Nova - fundadora", + ), + ( + "

El proceso fue claro, el mensaje ganó estructura y ahora las solicitudes llegan por una ruta única y lógica.

", + "Mark Jansen", + "Jansen Interior - cofundador", + ), + ], + "faq_title": "Preguntas frecuentes", + "faq_sub": "

Somos claros con la planificación, la colaboración y la gestión del sitio.

", + "faqs": [ + ( + "¿Para qué tipo de empresas es MandelBlog?", + "

Para empresas de servicios, estudios y equipos pequeños que necesitan un sitio profesional sin un proyecto pesado.

", + "General", + ), + ( + "¿Podemos ampliar más adelante?", + "

Sí. Construimos una estructura que puede crecer con páginas, idiomas o integraciones.

", + "Crecimiento", + ), + ( + "¿Podemos gestionar el contenido nosotros mismos?", + "

Sí. Es una base importante de la plataforma.

", + "Gestión", + ), + ( + "¿Qué ocurre después de la publicación?", + "

Después puede continuar con mantenimiento y mejoras puntuales si lo necesita.

", + "Soporte", + ), + ], + "cta_headline": "¿Quiere un sitio web que genere confianza y ahorre tiempo?", + "cta_sub": "

Programe una reunión inicial y le mostraremos qué enfoque encaja con su empresa y su equipo.

", + "no_cc": "Sin compromiso", + }, + "it": { + "badge": "MANDELBLOG STUDIO", + "headline": "Siti web per aziende che vogliono crescere in modo credibile", + "sub": "

MandelBlog realizza siti che trasmettono fiducia, guidano con chiarezza verso il contatto e restano semplici da gestire per il vostro team.

", + "stats": [ + ("3", "Passi chiari"), + ("1", "Referente unico"), + ("8", "Lingue disponibili"), + ], + "logos": "Realizzato con Wagtail, Django e componenti collaudati", + "features_title": "Su cosa punta MandelBlog", + "features_sub": "

Non un template da shop, ma un sito aziendale pensato per richieste, fiducia e crescita.

", + "features": [ + ( + "diagram-3", + "Struttura chiara", + "

I visitatori trovano rapidamente il servizio, il caso studio o il contatto giusto.

", + "process", + "Come lavoriamo", + "featured", + ), + ( + "pencil-square", + "Facile da gestire", + "

Il vostro team può aggiornare testi, immagini e sezioni in blocchi chiari.

", + "services", + None, + "none", + ), + ( + "shield-check", + "Base tecnica stabile", + "

Una struttura scalabile senza complessità inutile né effetto template.

", + "process", + "Vedi il metodo", + "none", + ), + ( + "graph-up-arrow", + "Pronto a crescere", + "

Più avanti potete aggiungere pagine, integrazioni o commercio senza rifare tutto.

", + "projects", + "Vedi i progetti", + "none", + ), + ], + "pricing_title": "I nostri pacchetti", + "pricing_sub": "

Ogni pacchetto ha un perimetro chiaro. I dettagli vengono definiti durante la consulenza iniziale.

", + "pricing_footer": "

Vi aiutiamo a scegliere il pacchetto più adatto alla vostra fase, al vostro team e alle vostre priorità.

", + "tiers": { + "starter": ( + "Per aziende che vogliono essere online in modo professionale in tempi rapidi", + [ + "Pagine essenziali e navigazione chiara", + "Editor per aggiornare i contenuti internamente", + "Presentazione solida su mobile", + ], + False, + "", + ), + "business": ( + "Per aziende di servizi con più offerte o obiettivi di crescita", + [ + "Più spazio per servizi e casi", + "Struttura orientata alla conversione", + "Base favorevole alla SEO", + ], + True, + "Consigliato", + ), + "webshop": ( + "Per organizzazioni che vogliono aggiungere la vendita online a un sito aziendale", + [ + "Struttura prodotti e checkout", + "Pagamenti e gestione ordini", + "Base e-commerce scalabile", + ], + False, + "", + ), + "support": ( + "Per team che hanno bisogno di manutenzione, stabilità tecnica e ottimizzazione continua", + [ + "Aggiornamenti e manutenzione", + "Monitoraggio e interventi tecnici", + "Miglioramento continuo", + ], + False, + "", + ), + }, + "testimonials_title": "Cosa apprezzano i clienti", + "testimonials_sub": "

I piccoli team scelgono MandelBlog perché il percorso resta chiaro e il sito rimane utile anche dopo la pubblicazione.

", + "testimonials": [ + ( + "

In poco tempo abbiamo avuto un sito che finalmente rispecchia i nostri servizi e che possiamo gestire internamente.

", + "Sanne de Vries", + "Studio Nova - fondatrice", + ), + ( + "

Il progetto è stato chiaro, il messaggio è diventato più forte e ora le richieste arrivano attraverso un percorso coerente.

", + "Mark Jansen", + "Jansen Interior - cofondatore", + ), + ], + "faq_title": "Domande frequenti", + "faq_sub": "

Siamo chiari su tempi, collaborazione e gestione del sito.

", + "faqs": [ + ( + "Per quali aziende è adatto MandelBlog?", + "

Per aziende di servizi, studi e piccoli team che hanno bisogno di un sito professionale senza un progetto pesante.

", + "Generale", + ), + ( + "Possiamo espandere il sito in seguito?", + "

Sì. Costruiamo una struttura che può crescere con pagine, lingue o integrazioni.

", + "Crescita", + ), + ( + "Possiamo gestire i contenuti da soli?", + "

Sì. È uno dei principi centrali della piattaforma.

", + "Gestione", + ), + ( + "Cosa succede dopo il lancio?", + "

Potete proseguire con manutenzione e miglioramenti mirati quando serve.

", + "Supporto", + ), + ], + "cta_headline": "Volete un sito che trasmetta fiducia e faccia risparmiare tempo?", + "cta_sub": "

Prenotate una consulenza introduttiva e vi mostreremo quale impostazione è adatta alla vostra azienda e al vostro team.

", + "no_cc": "Senza impegno", + }, + "pt": { + "badge": "MANDELBLOG STUDIO", + "headline": "Websites para empresas que querem crescer com credibilidade", + "sub": "

A MandelBlog cria websites que transmitem confiança, orientam claramente para o contacto e continuam fáceis de gerir pela sua equipa.

", + "stats": [ + ("3", "Etapas claras"), + ("1", "Ponto de contacto"), + ("8", "Idiomas disponíveis"), + ], + "logos": "Construído com Wagtail, Django e componentes comprovados", + "features_title": "No que a MandelBlog se concentra", + "features_sub": "

Não é uma demo de loja online, mas um website empresarial preparado para pedidos, confiança e crescimento.

", + "features": [ + ( + "diagram-3", + "Estrutura clara", + "

Os visitantes encontram rapidamente o serviço, o caso ou o caminho de contacto certo.

", + "process", + "Método de trabalho", + "featured", + ), + ( + "pencil-square", + "Fácil de gerir", + "

A sua equipa pode atualizar textos, imagens e secções em blocos claros.

", + "services", + None, + "none", + ), + ( + "shield-check", + "Base técnica estável", + "

Uma estrutura escalável sem complexidade desnecessária nem aparência de template.

", + "process", + "Ver método", + "none", + ), + ( + "graph-up-arrow", + "Preparado para crescer", + "

Mais tarde pode acrescentar páginas, integrações ou comércio sem recomeçar.

", + "projects", + "Ver projetos", + "none", + ), + ], + "pricing_title": "Os nossos pacotes", + "pricing_sub": "

Cada pacote tem um âmbito claro. Ajustamos os detalhes durante a consulta inicial.

", + "pricing_footer": "

Ajudamos a escolher o pacote mais adequado à sua fase, equipa e prioridades.

", + "tiers": { + "starter": ( + "Para empresas que precisam de uma presença profissional online rapidamente", + [ + "Páginas principais e navegação clara", + "Editor para atualizar conteúdo internamente", + "Apresentação forte em mobile", + ], + False, + "", + ), + "business": ( + "Para empresas de serviços com várias ofertas ou planos de crescimento", + [ + "Mais espaço para serviços e casos", + "Estrutura orientada para conversão", + "Base favorável a SEO", + ], + True, + "Recomendado", + ), + "webshop": ( + "Para organizações que querem juntar vendas online a um website empresarial", + [ + "Estrutura de produtos e checkout", + "Pagamentos e gestão de encomendas", + "Base escalável para comércio", + ], + False, + "", + ), + "support": ( + "Para equipas que precisam de manutenção, estabilidade técnica e otimização contínua", + [ + "Atualizações e manutenção", + "Monitorização e soluções técnicas", + "Melhoria contínua", + ], + False, + "", + ), + }, + "testimonials_title": "O que os clientes valorizam", + "testimonials_sub": "

As pequenas equipas escolhem a MandelBlog porque o processo se mantém claro e o website continua útil depois do lançamento.

", + "testimonials": [ + ( + "

Em pouco tempo tivemos um website que finalmente corresponde aos nossos serviços e que conseguimos gerir internamente.

", + "Sanne de Vries", + "Studio Nova - fundadora", + ), + ( + "

O processo foi claro, a mensagem ganhou estrutura e agora os pedidos chegam por um percurso consistente.

", + "Mark Jansen", + "Jansen Interior - cofundador", + ), + ], + "faq_title": "Perguntas frequentes", + "faq_sub": "

Somos claros quanto a planeamento, colaboração e gestão do website.

", + "faqs": [ + ( + "Para que empresas a MandelBlog é indicada?", + "

Para empresas de serviços, estúdios e pequenas equipas que precisam de um website profissional sem um projeto pesado.

", + "Geral", + ), + ( + "Podemos expandir mais tarde?", + "

Sim. Construímos uma estrutura que pode crescer com páginas, idiomas ou integrações.

", + "Expansão", + ), + ( + "Podemos gerir o conteúdo internamente?", + "

Sim. Esse é um princípio central da plataforma.

", + "Gestão", + ), + ( + "O que acontece depois do lançamento?", + "

Pode continuar com manutenção e melhorias direcionadas quando fizer sentido.

", + "Suporte", + ), + ], + "cta_headline": "Quer um website que transmita confiança e poupe tempo?", + "cta_sub": "

Agende uma consulta inicial e mostramos qual a abordagem mais adequada para a sua empresa e equipa.

", + "no_cc": "Sem compromisso", + }, + "ru": { + "badge": "MANDELBLOG STUDIO", + "headline": "Сайты для компаний, которым важен профессиональный рост", + "sub": "

MandelBlog создаёт сайты, которые вызывают доверие, понятно ведут к контакту и остаются удобными для вашей команды.

", + "stats": [ + ("3", "Чёткие этапы"), + ("1", "Единый контакт"), + ("8", "Доступные языки"), + ], + "logos": "Собрано на Wagtail, Django и проверенных компонентах", + "features_title": "На чём делает акцент MandelBlog", + "features_sub": "

Это не демо-магазин, а корпоративный сайт для заявок, доверия и дальнейшего роста.

", + "features": [ + ( + "diagram-3", + "Понятная структура", + "

Посетители быстро находят нужную услугу, кейс или путь к контакту.

", + "process", + "Как мы работаем", + "featured", + ), + ( + "pencil-square", + "Удобно управлять", + "

Ваша команда может обновлять тексты, изображения и секции в понятных блоках.

", + "services", + None, + "none", + ), + ( + "shield-check", + "Стабильная техническая база", + "

Масштабируемая структура без лишней сложности и шаблонного шума.

", + "process", + "Посмотреть процесс", + "none", + ), + ( + "graph-up-arrow", + "Готов к развитию", + "

Позже можно добавить страницы, интеграции или коммерческие функции без полной переделки.

", + "projects", + "Посмотреть проекты", + "none", + ), + ], + "pricing_title": "Наши пакеты", + "pricing_sub": "

У каждого пакета понятный объём. Детали мы согласовываем на вводной консультации.

", + "pricing_footer": "

Мы помогаем выбрать пакет, который подходит вашему этапу, команде и приоритетам.

", + "tiers": { + "starter": ( + "Для компаний, которым нужно быстро выйти онлайн с профессиональной подачей", + [ + "Основные страницы и понятная навигация", + "Редактор для обновления контента внутри команды", + "Сильная мобильная версия", + ], + False, + "", + ), + "business": ( + "Для сервисных компаний с несколькими направлениями или планами роста", + [ + "Больше места для услуг и кейсов", + "Структура, ориентированная на конверсию", + "SEO-дружественная база", + ], + True, + "Рекомендуем", + ), + "webshop": ( + "Для компаний, которые хотят добавить онлайн-продажи к корпоративному сайту", + [ + "Структура каталога и оформление заказа", + "Платежи и обработка заказов", + "Масштабируемая e-commerce база", + ], + False, + "", + ), + "support": ( + "Для команд, которым нужны поддержка, техническая стабильность и постоянное улучшение", + [ + "Обновления и обслуживание", + "Мониторинг и технические решения", + "Непрерывное улучшение", + ], + False, + "", + ), + }, + "testimonials_title": "Что ценят клиенты", + "testimonials_sub": "

Небольшие команды выбирают MandelBlog, потому что процесс остаётся понятным, а сайт полезным после запуска.

", + "testimonials": [ + ( + "

За короткое время мы получили сайт, который наконец отражает наши услуги и который можем поддерживать сами.

", + "Sanne de Vries", + "Studio Nova - основатель", + ), + ( + "

Процесс был понятным, сообщение стало чётче, а заявки теперь приходят по одному логичному сценарию.

", + "Mark Jansen", + "Jansen Interior - сооснователь", + ), + ], + "faq_title": "Частые вопросы", + "faq_sub": "

Мы понятно объясняем сроки, формат работы и дальнейшее управление сайтом.

", + "faqs": [ + ( + "Для каких компаний подходит MandelBlog?", + "

Для сервисных компаний, студий и небольших команд, которым нужен профессиональный сайт без тяжёлого проекта.

", + "Общее", + ), + ( + "Можно ли расширить сайт позже?", + "

Да. Мы строим структуру, которую можно развивать страницами, языками и интеграциями.

", + "Развитие", + ), + ( + "Сможем ли мы сами управлять контентом?", + "

Да. Это один из базовых принципов платформы.

", + "Управление", + ), + ( + "Что происходит после запуска?", + "

При необходимости вы можете продолжить с поддержкой и точечными улучшениями.

", + "Поддержка", + ), + ], + "cta_headline": "Нужен сайт, который вызывает доверие и экономит время?", + "cta_sub": "

Запланируйте консультацию, и мы покажем, какой формат подойдёт вашей компании и команде.

", + "no_cc": "Без обязательств", + }, +} + +STANDARD_COPY = { + "nl": { + "faq_title": "Veelgestelde vragen", + "faq_sub": "

We houden het traject helder en praktisch.

", + "faq_items": [ + ( + "Werken jullie met vaste templates?", + "

Nee. We gebruiken herbruikbare blokken, maar stemmen inhoud en structuur af op uw bedrijf.

", + "Werkwijze", + ), + ( + "Kunnen we later uitbreiden?", + "

Ja. De opzet is bedoeld om later door te groeien zonder opnieuw te beginnen.

", + "Uitbreiding", + ), + ( + "Beheren we de inhoud zelf?", + "

Ja. Dat is juist een belangrijk uitgangspunt van het platform.

", + "Beheer", + ), + ], + "cta_sub": "

Plan een kennismakingsgesprek en we laten zien welke route logisch is voor uw bedrijf.

", + "no_cc": "Volledig vrijblijvend", + "pages": { + "about": { + "headline": "Wie MandelBlog is en hoe we werken", + "sub": "

MandelBlog helpt kleine bedrijven en dienstverleners aan een website die professioneel oogt, logisch converteert en beheerbaar blijft voor het eigen team.

", + "features_title": "Waar we op letten", + "features_sub": "

We werken het liefst voor organisaties die behoefte hebben aan duidelijkheid, snelheid en inhoudelijke regie.

", + "features": [ + ( + "people", + "Voor wie we werken", + "

Dienstverleners, studio’s en kleine teams met een duidelijke propositie en een praktische planning.

", + ), + ( + "diagram-3", + "Onze werkwijze", + "

We starten met scherpte in doel en inhoud, bouwen met vaste blokken en leveren beheersbaar op.

", + ), + ( + "shield-check", + "Waarom het anders werkt", + "

Geen los template of black box, maar een duidelijke structuur waarmee u zelf verder kunt.

", + ), + ( + "person-badge", + "Klein team, direct contact", + "

U schakelt direct met de mensen die het werk uitvoeren en keuzes vertalen naar de site.

", + ), + ], + "steps_badge": "Werkwijze", + "steps_heading": "Onze aanpak in 3 stappen", + "steps_sub": "Kort traject, duidelijke keuzes en daarna een site die voor uw team werkt.", + "steps": [ + ( + "1", + "Kennismaking", + "We bepalen doel, inhoud en prioriteiten.", + "chat-square-text", + ), + ( + "2", + "Uitwerking", + "We bouwen de pagina’s en stemmen de inhoud af.", + "layout-text-window", + ), + ( + "3", + "Oplevering", + "U krijgt uitleg, beheer en een duidelijke vervolgstap.", + "rocket", + ), + ], + "cta": "Wilt u weten of onze aanpak past bij uw bedrijf?", + }, + "services": { + "headline": "Diensten voor bedrijven die overzicht en kwaliteit willen", + "sub": "

Elke dienst is ingericht rondom duidelijke keuzes, bruikbare content en een technische basis die door kan groeien.

", + "features_title": "Wat we leveren", + "features_sub": "

Geen losse modules, maar een traject dat aansluit op uw fase, team en doelen.

", + "features": [ + ( + "window", + "Starter-website", + "

Voor ondernemers die snel professioneel online willen staan met een heldere basis.

", + ), + ( + "briefcase", + "Zakelijke website", + "

Voor organisaties met meerdere diensten, cases of een complexere aanbodstructuur.

", + ), + ( + "cart-check", + "Webshop-implementatie", + "

Voor teams die online verkoop willen toevoegen zonder de grip op techniek te verliezen.

", + ), + ( + "wrench-adjustable", + "Onderhoud & groei", + "

Voor organisaties die onderhoud, stabiliteit en doorlopende verbetering nodig hebben.

", + ), + ], + "cta": "Twijfelt u welk pakket past bij uw fase?", + }, + "projects": { + "headline": "Projecten waarin structuur, inhoud en techniek samenkomen", + "sub": "

Onze projecten zijn ontworpen om professioneel over te komen, vertrouwen op te bouwen en beheerbaar te blijven na livegang.

", + "features_title": "Wat u in onze projecten terugziet", + "features_sub": "

We sturen niet op oppervlakkige effecten, maar op duidelijkheid en bruikbaarheid.

", + "features": [ + ( + "diagram-3", + "Heldere pagina-opbouw", + "

Bezoekers begrijpen snel waar ze moeten zijn en welke stap logisch volgt.

", + ), + ( + "pencil-square", + "Eenvoudig beheer", + "

Teams kunnen teksten, visuals en pagina’s zelf aanpassen zonder omweg.

", + ), + ( + "graph-up-arrow", + "Gericht op aanvragen", + "

Contact en conversie zijn zichtbaar verwerkt in de structuur en inhoud.

", + ), + ], + "cta": "Wilt u uw volgende project professioneel neerzetten?", + }, + "contact": { + "headline": "Laten we uw vraag concreet maken", + "sub": "

Vertel kort wat u nodig heeft. U krijgt een praktische terugkoppeling met haalbare vervolgstappen.

", + "features_title": "Waarvoor u contact kunt opnemen", + "features_sub": "

Kies de route die past bij uw vraag of traject.

", + "features": [ + ( + "rocket", + "Nieuw traject", + "

Voor een nieuwe website, herpositionering of complete herbouw.

", + ), + ( + "briefcase", + "Pakketkeuze", + "

Voor advies over welk pakket of welke structuur het beste past.

", + ), + ( + "tools", + "Onderhoud of uitbreiding", + "

Voor technische ondersteuning, uitbreidingen of een vervolgfase na livegang.

", + ), + ], + "form_title": "Vertel kort wat u nodig heeft", + "form_sub": "

We reageren inhoudelijk en zonder verkooppraat op uw vraag.

", + "form_fields": [ + ("text", "Naam", "Uw naam"), + ("email", "E-mail", "naam@bedrijf.nl"), + ("company", "Bedrijf", "Bedrijfsnaam"), + ("message", "Vraag of project", "Waar zoekt u hulp bij?"), + ], + "benefits_title": "Wat u kunt verwachten", + "benefits": [ + "Reactie binnen 24 uur", + "Intakegesprek van 15 minuten", + "Volledig vrijblijvend", + ], + "privacy": "

We gebruiken uw gegevens alleen voor contact over deze aanvraag.

", + "cta": "Klaar om een eerste stap te zetten?", + }, + "process": { + "headline": "Werkwijze met duidelijke stappen en vaste keuzes", + "sub": "

We houden het traject overzichtelijk: u weet wanneer iets gebeurt, wat u moet aanleveren en waar we naartoe werken.

", + "features_title": "Zo werken we samen", + "features_sub": "

Kort, duidelijk en zonder onnodige ruis.

", + "features": [ + ( + "chat-square-text", + "1. Kennismaking", + "

We bespreken doel, doelgroep, inhoud en wat u intern wilt kunnen beheren.

", + ), + ( + "layout-text-window", + "2. Uitwerking", + "

We zetten structuur, inhoud en ontwerp om in een duidelijke pagina-opbouw.

", + ), + ( + "rocket", + "3. Oplevering", + "

Na review gaat de site live en zorgen we voor een beheerbare overdracht.

", + ), + ( + "graph-up-arrow", + "4. Doorontwikkeling", + "

Wanneer nodig bouwen we verder op basis van gedrag, vragen en nieuwe plannen.

", + ), + ], + "cta": "Wilt u dit traject ook voor uw website?", + }, + }, + }, + "en": { + "faq_title": "Frequently asked questions", + "faq_sub": "

We keep the process clear and practical.

", + "faq_items": [ + ( + "Do you work with fixed templates?", + "

No. We use reusable blocks, but the content and structure are tailored to your business.

", + "Process", + ), + ( + "Can we expand later?", + "

Yes. The setup is designed to grow without starting over.

", + "Growth", + ), + ( + "Can we manage the content ourselves?", + "

Yes. That is a core principle of the platform.

", + "Management", + ), + ], + "cta_sub": "

Schedule a consultation and we will show you the most sensible route for your business.

", + "no_cc": "No obligation", + "pages": { + "about": { + "headline": "Who MandelBlog is and how we work", + "sub": "

MandelBlog helps service businesses and small teams launch websites that look credible, convert clearly and remain easy to manage.

", + "features_title": "What we pay attention to", + "features_sub": "

We work best with organisations that value clarity, speed and editorial control.

", + "features": [ + ( + "people", + "Who we help", + "

Service firms, studios and small teams with a clear offer and a practical timeline.

", + ), + ( + "diagram-3", + "How we work", + "

We start by sharpening the goal and the message, build with proven blocks and deliver something your team can actually use.

", + ), + ( + "shield-check", + "Why it works differently", + "

Not a disconnected template or a black box, but a clear structure you can keep building on.

", + ), + ( + "person-badge", + "Small team, direct contact", + "

You speak directly with the people doing the work and translating decisions into the website.

", + ), + ], + "steps_badge": "Process", + "steps_heading": "Our 3-step approach", + "steps_sub": "A focused project, clear decisions and then a site your team can work with.", + "steps": [ + ( + "1", + "Introductory call", + "We define goals, content and priorities.", + "chat-square-text", + ), + ( + "2", + "Build phase", + "We create the pages and refine the content together.", + "layout-text-window", + ), + ( + "3", + "Launch", + "You receive guidance, handover and the next practical step.", + "rocket", + ), + ], + "cta": "Would you like to know whether our approach fits your business?", + }, + "services": { + "headline": "Services for companies that want clarity and quality", + "sub": "

Each service is shaped around clear choices, useful content and a technical foundation that can grow with you.

", + "features_title": "What we deliver", + "features_sub": "

Not disconnected modules, but a trajectory that matches your stage, team and objectives.

", + "features": [ + ( + "window", + "Starter website", + "

For businesses that need a professional online presence quickly with a clear foundation.

", + ), + ( + "briefcase", + "Business website", + "

For organisations with multiple services, case studies or a more complex offer structure.

", + ), + ( + "cart-check", + "Webshop implementation", + "

For teams that want online sales without losing grip on structure and technology.

", + ), + ( + "wrench-adjustable", + "Maintenance & growth", + "

For organisations that need maintenance, stability and continuous improvement.

", + ), + ], + "cta": "Unsure which package fits your stage?", + }, + "projects": { + "headline": "Projects where structure, content and technology work together", + "sub": "

Our projects are designed to look credible, build trust and stay manageable after launch.

", + "features_title": "What you see in our work", + "features_sub": "

We do not optimise for surface effects. We optimise for clarity and usability.

", + "features": [ + ( + "diagram-3", + "Clear page structure", + "

Visitors understand quickly where they are and what the next step should be.

", + ), + ( + "pencil-square", + "Easy management", + "

Teams can update copy, visuals and pages themselves without a workaround.

", + ), + ( + "graph-up-arrow", + "Built for enquiries", + "

Contact and conversion are visible in both the structure and the content.

", + ), + ], + "cta": "Do you want to present your next project professionally?", + }, + "contact": { + "headline": "Let’s turn your question into a concrete plan", + "sub": "

Tell us briefly what you need. You will receive a practical response with realistic next steps.

", + "features_title": "What you can contact us about", + "features_sub": "

Choose the route that fits your question or current stage.

", + "features": [ + ( + "rocket", + "New project", + "

For a new website, repositioning or complete rebuild.

", + ), + ( + "briefcase", + "Package advice", + "

For guidance on the package or structure that fits best.

", + ), + ( + "tools", + "Maintenance or expansion", + "

For technical support, follow-up improvements or the next phase after launch.

", + ), + ], + "form_title": "Tell us briefly what you need", + "form_sub": "

We respond with substance and without sales pressure.

", + "form_fields": [ + ("text", "Name", "Your name"), + ("email", "Email", "name@company.com"), + ("company", "Company", "Company name"), + ("message", "Question or project", "What do you need help with?"), + ], + "benefits_title": "What to expect", + "benefits": [ + "Response within 24 hours", + "15-minute introductory call", + "No obligation", + ], + "privacy": "

We use your details only to respond to this request.

", + "cta": "Ready to take the first step?", + }, + "process": { + "headline": "A working process with clear steps and fixed decisions", + "sub": "

We keep the trajectory manageable: you know what happens when, what you need to provide and what we are working towards.

", + "features_title": "How we work together", + "features_sub": "

Short, clear and without unnecessary noise.

", + "features": [ + ( + "chat-square-text", + "1. Consultation", + "

We discuss goals, audience, content and what your team wants to manage internally.

", + ), + ( + "layout-text-window", + "2. Build", + "

We turn structure, content and design into a clear page setup.

", + ), + ( + "rocket", + "3. Launch", + "

After review the site goes live and we deliver a manageable handover.

", + ), + ( + "graph-up-arrow", + "4. Further growth", + "

When needed we continue based on behaviour, questions and new plans.

", + ), + ], + "cta": "Would you like this approach for your website too?", + }, + }, + }, +} +# Remaining locale dictionaries are appended below for brevity in this generated file. +STANDARD_COPY["de"] = { + "faq_title": "Häufig gestellte Fragen", + "faq_sub": "

Wir halten den Ablauf klar und praxisnah.

", + "faq_items": [ + ( + "Arbeiten Sie mit festen Templates?", + "

Nein. Wir nutzen wiederverwendbare Blöcke, stimmen Inhalt und Struktur aber auf Ihr Unternehmen ab.

", + "Vorgehen", + ), + ( + "Können wir später erweitern?", + "

Ja. Der Aufbau ist dafür gedacht, ohne Neustart weiterzuwachsen.

", + "Ausbau", + ), + ( + "Können wir die Inhalte selbst pflegen?", + "

Ja. Genau das ist ein Grundprinzip der Plattform.

", + "Pflege", + ), + ], + "cta_sub": "

Planen Sie ein Beratungsgespräch und wir zeigen Ihnen den sinnvollsten Weg für Ihr Unternehmen.

", + "no_cc": "Unverbindlich", + "pages": { + "about": { + "headline": "Wer MandelBlog ist und wie wir arbeiten", + "sub": "

MandelBlog unterstützt Dienstleister und kleine Unternehmen mit Websites, die professionell wirken, klar konvertieren und für das eigene Team pflegbar bleiben.

", + "features_title": "Worauf wir achten", + "features_sub": "

Wir arbeiten am liebsten mit Organisationen, die Klarheit, Tempo und inhaltliche Steuerung schätzen.

", + "features": [ + ( + "people", + "Für wen wir arbeiten", + "

Dienstleister, Studios und kleine Teams mit einem klaren Angebot und realistischem Zeitplan.

", + ), + ( + "diagram-3", + "Unsere Arbeitsweise", + "

Wir schärfen zunächst Ziel und Inhalt, bauen mit bewährten Blöcken und liefern beherrschbar aus.

", + ), + ( + "shield-check", + "Warum das anders funktioniert", + "

Kein loses Template und keine Black Box, sondern eine klare Struktur, auf der Sie weiterarbeiten können.

", + ), + ( + "person-badge", + "Kleines Team, direkter Kontakt", + "

Sie sprechen direkt mit den Menschen, die die Arbeit umsetzen und Entscheidungen in die Website übertragen.

", + ), + ], + "steps_badge": "Vorgehensweise", + "steps_heading": "Unser Ansatz in 3 Schritten", + "steps_sub": "Ein kompaktes Projekt, klare Entscheidungen und danach eine Website, mit der Ihr Team arbeiten kann.", + "steps": [ + ( + "1", + "Erstgespräch", + "Wir definieren Ziel, Inhalte und Prioritäten.", + "chat-square-text", + ), + ( + "2", + "Ausarbeitung", + "Wir erstellen die Seiten und schärfen die Inhalte gemeinsam.", + "layout-text-window", + ), + ( + "3", + "Livegang", + "Sie erhalten Einweisung, Übergabe und einen klaren nächsten Schritt.", + "rocket", + ), + ], + "cta": "Möchten Sie wissen, ob unser Ansatz zu Ihrem Unternehmen passt?", + }, + "services": { + "headline": "Leistungen für Unternehmen, die Übersicht und Qualität wollen", + "sub": "

Jede Leistung ist auf klare Entscheidungen, nutzbare Inhalte und eine technische Basis ausgelegt, die mitwachsen kann.

", + "features_title": "Was wir liefern", + "features_sub": "

Keine losen Module, sondern ein Weg, der zu Ihrer Phase, Ihrem Team und Ihren Zielen passt.

", + "features": [ + ( + "window", + "Starter-Website", + "

Für Unternehmen, die schnell professionell online gehen wollen.

", + ), + ( + "briefcase", + "Geschäftswebsite", + "

Für Organisationen mit mehreren Leistungen, Referenzen oder komplexerer Angebotsstruktur.

", + ), + ( + "cart-check", + "Webshop-Implementierung", + "

Für Teams, die Online-Verkauf ergänzen wollen, ohne die technische Kontrolle zu verlieren.

", + ), + ( + "wrench-adjustable", + "Wartung & Wachstum", + "

Für Organisationen, die Wartung, Stabilität und kontinuierliche Verbesserung brauchen.

", + ), + ], + "cta": "Sie sind unsicher, welches Paket zu Ihrer Phase passt?", + }, + "projects": { + "headline": "Projekte, in denen Struktur, Inhalt und Technik zusammenarbeiten", + "sub": "

Unsere Projekte sind darauf ausgelegt, professionell zu wirken, Vertrauen aufzubauen und nach dem Go-live beherrschbar zu bleiben.

", + "features_title": "Was Sie in unseren Projekten sehen", + "features_sub": "

Wir optimieren nicht auf Effekte, sondern auf Klarheit und Nutzbarkeit.

", + "features": [ + ( + "diagram-3", + "Klare Seitenstruktur", + "

Besucher verstehen schnell, wo sie sind und welcher Schritt als Nächstes sinnvoll ist.

", + ), + ( + "pencil-square", + "Einfaches Management", + "

Teams können Texte, Bilder und Seiten selbst anpassen.

", + ), + ( + "graph-up-arrow", + "Auf Anfragen ausgerichtet", + "

Kontakt und Conversion sind sichtbar in Struktur und Inhalt verankert.

", + ), + ], + "cta": "Möchten Sie Ihr nächstes Projekt professionell präsentieren?", + }, + "contact": { + "headline": "Lassen Sie uns Ihre Anfrage konkret machen", + "sub": "

Beschreiben Sie kurz, was Sie brauchen. Sie erhalten eine praktische Rückmeldung mit realistischen nächsten Schritten.

", + "features_title": "Wofür Sie uns kontaktieren können", + "features_sub": "

Wählen Sie den Weg, der zu Ihrer Frage oder Ihrem Projektstand passt.

", + "features": [ + ( + "rocket", + "Neues Projekt", + "

Für eine neue Website, Neupositionierung oder einen kompletten Relaunch.

", + ), + ( + "briefcase", + "Paketberatung", + "

Für Beratung dazu, welches Paket oder welche Struktur am besten passt.

", + ), + ( + "tools", + "Wartung oder Ausbau", + "

Für technischen Support, Erweiterungen oder die nächste Phase nach dem Go-live.

", + ), + ], + "form_title": "Beschreiben Sie kurz, was Sie brauchen", + "form_sub": "

Wir antworten inhaltlich und ohne Verkaufsdruck.

", + "form_fields": [ + ("text", "Name", "Ihr Name"), + ("email", "E-Mail", "name@unternehmen.de"), + ("company", "Unternehmen", "Firmenname"), + ("message", "Frage oder Projekt", "Wobei brauchen Sie Unterstützung?"), + ], + "benefits_title": "Was Sie erwarten können", + "benefits": [ + "Rückmeldung innerhalb von 24 Stunden", + "15-minütiges Erstgespräch", + "Unverbindlich", + ], + "privacy": "

Wir nutzen Ihre Angaben nur, um auf diese Anfrage zu antworten.

", + "cta": "Bereit für den ersten Schritt?", + }, + "process": { + "headline": "Vorgehensweise mit klaren Schritten und festen Entscheidungen", + "sub": "

Wir halten das Projekt übersichtlich: Sie wissen, wann etwas passiert, was Sie liefern müssen und worauf wir hinarbeiten.

", + "features_title": "So arbeiten wir zusammen", + "features_sub": "

Kurz, klar und ohne unnötiges Rauschen.

", + "features": [ + ( + "chat-square-text", + "1. Erstgespräch", + "

Wir besprechen Ziel, Zielgruppe, Inhalte und was Ihr Team intern pflegen möchte.

", + ), + ( + "layout-text-window", + "2. Ausarbeitung", + "

Wir übersetzen Struktur, Inhalt und Gestaltung in einen klaren Seitenaufbau.

", + ), + ( + "rocket", + "3. Livegang", + "

Nach der Freigabe geht die Website live und wir sorgen für eine saubere Übergabe.

", + ), + ( + "graph-up-arrow", + "4. Weiterentwicklung", + "

Wenn nötig bauen wir auf Basis von Verhalten, Fragen und neuen Plänen weiter.

", + ), + ], + "cta": "Möchten Sie diese Vorgehensweise auch für Ihre Website?", + }, + }, +} +# Additional locales added below through assignments to keep generation manageable. +STANDARD_COPY["fr"] = { + "faq_title": "Questions fréquentes", + "faq_sub": "

Nous gardons le projet clair et concret.

", + "faq_items": [ + ( + "Travaillez-vous avec des templates fixes ?", + "

Non. Nous utilisons des blocs réutilisables, mais le contenu et la structure sont adaptés à votre entreprise.

", + "Méthode", + ), + ( + "Peut-on faire évoluer le site ensuite ?", + "

Oui. La base est prévue pour évoluer sans repartir de zéro.

", + "Évolution", + ), + ( + "Pouvons-nous gérer le contenu nous-mêmes ?", + "

Oui. C’est un principe important de la plateforme.

", + "Gestion", + ), + ], + "cta_sub": "

Planifiez une consultation et nous vous montrerons la voie la plus logique pour votre entreprise.

", + "no_cc": "Sans engagement", + "pages": { + "about": { + "headline": "Qui est MandelBlog et comment nous travaillons", + "sub": "

MandelBlog aide les sociétés de services et les petites équipes à lancer des sites crédibles, orientés conversion et simples à gérer.

", + "features_title": "Ce à quoi nous faisons attention", + "features_sub": "

Nous travaillons le mieux avec des organisations qui recherchent de la clarté, de la rapidité et de l’autonomie éditoriale.

", + "features": [ + ( + "people", + "Qui nous accompagnons", + "

Des sociétés de services, des studios et de petites équipes avec une offre claire et un planning réaliste.

", + ), + ( + "diagram-3", + "Notre méthode", + "

Nous clarifions d’abord l’objectif et le message, puis nous construisons avec des blocs éprouvés.

", + ), + ( + "shield-check", + "Pourquoi cela fonctionne autrement", + "

Pas de template déconnecté ni de boîte noire, mais une structure claire que vous pouvez faire évoluer.

", + ), + ( + "person-badge", + "Petite équipe, contact direct", + "

Vous échangez directement avec les personnes qui réalisent le travail.

", + ), + ], + "steps_badge": "Méthode", + "steps_heading": "Notre approche en 3 étapes", + "steps_sub": "Un projet concentré, des choix clairs et ensuite un site que votre équipe peut utiliser au quotidien.", + "steps": [ + ( + "1", + "Échange initial", + "Nous définissons les objectifs, le contenu et les priorités.", + "chat-square-text", + ), + ( + "2", + "Production", + "Nous construisons les pages et affinons le contenu avec vous.", + "layout-text-window", + ), + ( + "3", + "Mise en ligne", + "Vous recevez l’accompagnement, la transmission et la prochaine étape utile.", + "rocket", + ), + ], + "cta": "Vous voulez savoir si notre approche correspond à votre entreprise ?", + }, + "services": { + "headline": "Des services pour les entreprises qui veulent de la clarté et de la qualité", + "sub": "

Chaque service repose sur des choix clairs, un contenu utile et une base technique capable d’évoluer.

", + "features_title": "Ce que nous livrons", + "features_sub": "

Pas des modules isolés, mais un parcours adapté à votre étape, à votre équipe et à vos objectifs.

", + "features": [ + ( + "window", + "Site de démarrage", + "

Pour les entreprises qui veulent être en ligne rapidement avec une base professionnelle.

", + ), + ( + "briefcase", + "Site d’entreprise", + "

Pour les organisations qui ont plusieurs services, références ou une offre plus structurée.

", + ), + ( + "cart-check", + "Implémentation e-commerce", + "

Pour les équipes qui veulent ajouter la vente en ligne sans perdre la maîtrise de la structure et de la technique.

", + ), + ( + "wrench-adjustable", + "Maintenance & croissance", + "

Pour les organisations qui ont besoin de maintenance, de stabilité et d’amélioration continue.

", + ), + ], + "cta": "Vous hésitez sur l’offre adaptée à votre étape ?", + }, + "projects": { + "headline": "Des projets où structure, contenu et technique travaillent ensemble", + "sub": "

Nos projets sont conçus pour inspirer confiance, structurer le message et rester simples à gérer après la mise en ligne.

", + "features_title": "Ce que vous retrouvez dans nos projets", + "features_sub": "

Nous ne cherchons pas l’effet de surface, mais la clarté et l’utilité.

", + "features": [ + ( + "diagram-3", + "Structure de page claire", + "

Les visiteurs comprennent rapidement où ils sont et quelle étape vient ensuite.

", + ), + ( + "pencil-square", + "Gestion simple", + "

Les équipes peuvent modifier textes, visuels et pages sans détour.

", + ), + ( + "graph-up-arrow", + "Pensé pour les demandes", + "

Le contact et la conversion sont visibles dans la structure comme dans le contenu.

", + ), + ], + "cta": "Vous voulez présenter votre prochain projet de manière plus professionnelle ?", + }, + "contact": { + "headline": "Transformons votre question en plan concret", + "sub": "

Expliquez brièvement ce dont vous avez besoin. Vous recevrez un retour utile avec des prochaines étapes réalistes.

", + "features_title": "Pour quelles demandes nous contacter", + "features_sub": "

Choisissez la voie qui correspond à votre question ou à votre étape actuelle.

", + "features": [ + ( + "rocket", + "Nouveau projet", + "

Pour un nouveau site, un repositionnement ou une refonte complète.

", + ), + ( + "briefcase", + "Choix de l’offre", + "

Pour savoir quelle offre ou quelle structure est la plus adaptée.

", + ), + ( + "tools", + "Maintenance ou évolution", + "

Pour du support technique, des améliorations ou une phase suivante après la mise en ligne.

", + ), + ], + "form_title": "Expliquez brièvement votre besoin", + "form_sub": "

Nous répondons avec du contenu utile, sans pression commerciale.

", + "form_fields": [ + ("text", "Nom", "Votre nom"), + ("email", "E-mail", "nom@entreprise.fr"), + ("company", "Entreprise", "Nom de l’entreprise"), + ("message", "Question ou projet", "Sur quoi avez-vous besoin d’aide ?"), + ], + "benefits_title": "Ce que vous pouvez attendre", + "benefits": [ + "Réponse sous 24 heures", + "Échange initial de 15 minutes", + "Sans engagement", + ], + "privacy": "

Nous utilisons vos coordonnées uniquement pour répondre à cette demande.

", + "cta": "Prêt à franchir une première étape ?", + }, + "process": { + "headline": "Une méthode avec des étapes claires et des choix assumés", + "sub": "

Nous gardons le projet lisible : vous savez quand chaque chose se passe, ce que vous devez fournir et où nous allons.

", + "features_title": "Comment nous travaillons ensemble", + "features_sub": "

Simple, clair et sans bruit inutile.

", + "features": [ + ( + "chat-square-text", + "1. Consultation", + "

Nous discutons des objectifs, des publics, du contenu et de ce que votre équipe veut gérer en interne.

", + ), + ( + "layout-text-window", + "2. Production", + "

Nous transformons structure, contenu et design en pages claires et cohérentes.

", + ), + ( + "rocket", + "3. Mise en ligne", + "

Après validation, le site est publié et nous assurons une transmission claire.

", + ), + ( + "graph-up-arrow", + "4. Évolutions", + "

Ensuite, nous pouvons continuer selon les retours, les usages et vos nouveaux projets.

", + ), + ], + "cta": "Vous voulez appliquer cette méthode à votre site ?", + }, + }, +} +# To keep this file tractable, ES/IT/PT/RU standard and all service copy are defined in compact dictionaries below. +SERVICE_COPY = {} +SERVICE_COMMON = { + "nl": { + "section_what": "Wat krijgt u?", + "section_what_sub": "

Elk pakket is opgebouwd rond duidelijke keuzes, beheerbaarheid en inhoud die past bij uw bedrijf.

", + "section_outcomes": "Wat levert het op?", + "section_outcomes_sub": "

De waarde zit niet in losse effecten, maar in duidelijkere communicatie en een beter werkende site.

", + "section_choose": "Wanneer kiest u dit pakket?", + "section_choose_sub": "

We adviseren dit pakket wanneer onderstaande punten aansluiten op uw situatie.

", + "choose_title": "Dit pakket past wanneer", + "privacy": "

We gebruiken uw gegevens alleen voor een reactie op uw aanvraag.

", + "name_label": "Naam", + "name_placeholder": "Uw naam", + "email_label": "E-mail", + "email_placeholder": "naam@bedrijf.nl", + "company_label": "Bedrijf", + "company_placeholder": "Bedrijfsnaam", + "message_label": "Vraag of context", + "message_placeholder": "Vertel kort waar u nu staat", + "timeline_label": "Doorlooptijd", + "communication": "Reactie binnen 24 uur", + "intro": "Volledig vrijblijvend", + "cta_sub": "

Plan een kennismakingsgesprek en we adviseren eerlijk welk pakket logisch is.

", + "no_cc": "Volledig vrijblijvend", + }, + "en": { + "section_what": "What you get", + "section_what_sub": "

Each package is structured around clear decisions, manageable content and a setup that fits your business.

", + "section_outcomes": "What it delivers", + "section_outcomes_sub": "

The value is not in visual tricks, but in clearer communication and a website that performs better.

", + "section_choose": "When to choose this package", + "section_choose_sub": "

We recommend this package when the points below match your situation.

", + "choose_title": "This package fits when", + "privacy": "

We only use your details to respond to this request.

", + "name_label": "Name", + "name_placeholder": "Your name", + "email_label": "Email", + "email_placeholder": "name@company.com", + "company_label": "Company", + "company_placeholder": "Company name", + "message_label": "Question or context", + "message_placeholder": "Briefly describe your current situation", + "timeline_label": "Timeline", + "communication": "Response within 24 hours", + "intro": "No obligation", + "cta_sub": "

Schedule a consultation and we will advise honestly which package makes sense.

", + "no_cc": "No obligation", + }, + "de": { + "section_what": "Was Sie bekommen", + "section_what_sub": "

Jedes Paket ist auf klare Entscheidungen, gute Pflegebarkeit und Inhalte aufgebaut, die zu Ihrem Unternehmen passen.

", + "section_outcomes": "Was es bringt", + "section_outcomes_sub": "

Der Wert liegt nicht in Effekten, sondern in klarerer Kommunikation und einer Website, die besser funktioniert.

", + "section_choose": "Wann Sie dieses Paket wählen", + "section_choose_sub": "

Wir empfehlen dieses Paket, wenn die folgenden Punkte zu Ihrer Situation passen.

", + "choose_title": "Dieses Paket passt, wenn", + "privacy": "

Wir verwenden Ihre Angaben nur, um auf diese Anfrage zu antworten.

", + "name_label": "Name", + "name_placeholder": "Ihr Name", + "email_label": "E-Mail", + "email_placeholder": "name@unternehmen.de", + "company_label": "Unternehmen", + "company_placeholder": "Firmenname", + "message_label": "Frage oder Kontext", + "message_placeholder": "Beschreiben Sie kurz Ihre Ausgangslage", + "timeline_label": "Zeitrahmen", + "communication": "Rückmeldung innerhalb von 24 Stunden", + "intro": "Unverbindlich", + "cta_sub": "

Planen Sie ein Beratungsgespräch und wir empfehlen ehrlich, welches Paket sinnvoll ist.

", + "no_cc": "Unverbindlich", + }, + "fr": { + "section_what": "Ce que vous obtenez", + "section_what_sub": "

Chaque offre est structurée autour de choix clairs, d’un contenu gérable et d’une base adaptée à votre entreprise.

", + "section_outcomes": "Ce que cela apporte", + "section_outcomes_sub": "

La valeur ne vient pas d’effets superficiels, mais d’un message plus clair et d’un site plus efficace.

", + "section_choose": "Quand choisir cette offre", + "section_choose_sub": "

Nous recommandons cette offre lorsque les points ci-dessous correspondent à votre situation.

", + "choose_title": "Cette offre convient lorsque", + "privacy": "

Nous utilisons vos coordonnées uniquement pour répondre à votre demande.

", + "name_label": "Nom", + "name_placeholder": "Votre nom", + "email_label": "E-mail", + "email_placeholder": "nom@entreprise.fr", + "company_label": "Entreprise", + "company_placeholder": "Nom de l’entreprise", + "message_label": "Question ou contexte", + "message_placeholder": "Expliquez brièvement votre situation", + "timeline_label": "Délai", + "communication": "Réponse sous 24 heures", + "intro": "Sans engagement", + "cta_sub": "

Planifiez une consultation et nous vous dirons honnêtement quelle offre est la plus cohérente.

", + "no_cc": "Sans engagement", + }, + "es": { + "section_what": "Qué obtiene", + "section_what_sub": "

Cada paquete se estructura alrededor de decisiones claras, contenido gestionable y una base adecuada para su empresa.

", + "section_outcomes": "Qué aporta", + "section_outcomes_sub": "

El valor no está en efectos superficiales, sino en una comunicación más clara y un sitio que funciona mejor.

", + "section_choose": "Cuándo elegir este paquete", + "section_choose_sub": "

Recomendamos este paquete cuando los puntos siguientes encajan con su situación.

", + "choose_title": "Este paquete encaja cuando", + "privacy": "

Usamos sus datos únicamente para responder a esta solicitud.

", + "name_label": "Nombre", + "name_placeholder": "Su nombre", + "email_label": "Correo electrónico", + "email_placeholder": "nombre@empresa.es", + "company_label": "Empresa", + "company_placeholder": "Nombre de la empresa", + "message_label": "Pregunta o contexto", + "message_placeholder": "Explique brevemente su situación actual", + "timeline_label": "Plazo", + "communication": "Respuesta en 24 horas", + "intro": "Sin compromiso", + "cta_sub": "

Programe una reunión inicial y le indicaremos con honestidad qué paquete tiene más sentido.

", + "no_cc": "Sin compromiso", + }, + "it": { + "section_what": "Cosa riceve", + "section_what_sub": "

Ogni pacchetto è costruito attorno a scelte chiare, contenuti gestibili e una base adatta alla vostra azienda.

", + "section_outcomes": "Cosa porta", + "section_outcomes_sub": "

Il valore non sta negli effetti superficiali, ma in una comunicazione più chiara e in un sito che funziona meglio.

", + "section_choose": "Quando scegliere questo pacchetto", + "section_choose_sub": "

Consigliamo questo pacchetto quando i punti seguenti corrispondono alla vostra situazione.

", + "choose_title": "Questo pacchetto è adatto quando", + "privacy": "

Usiamo i vostri dati solo per rispondere a questa richiesta.

", + "name_label": "Nome", + "name_placeholder": "Il vostro nome", + "email_label": "E-mail", + "email_placeholder": "nome@azienda.it", + "company_label": "Azienda", + "company_placeholder": "Nome dell’azienda", + "message_label": "Domanda o contesto", + "message_placeholder": "Descrivete brevemente la vostra situazione", + "timeline_label": "Tempistiche", + "communication": "Risposta entro 24 ore", + "intro": "Senza impegno", + "cta_sub": "

Prenotate una consulenza introduttiva e vi diremo con chiarezza quale pacchetto è più adatto.

", + "no_cc": "Senza impegno", + }, + "pt": { + "section_what": "O que recebe", + "section_what_sub": "

Cada pacote é estruturado em torno de escolhas claras, conteúdo gerível e uma base adequada à sua empresa.

", + "section_outcomes": "O que entrega", + "section_outcomes_sub": "

O valor não está em efeitos superficiais, mas numa comunicação mais clara e num website que funciona melhor.

", + "section_choose": "Quando escolher este pacote", + "section_choose_sub": "

Recomendamos este pacote quando os pontos abaixo correspondem à sua situação.

", + "choose_title": "Este pacote faz sentido quando", + "privacy": "

Usamos os seus dados apenas para responder a este pedido.

", + "name_label": "Nome", + "name_placeholder": "O seu nome", + "email_label": "E-mail", + "email_placeholder": "nome@empresa.pt", + "company_label": "Empresa", + "company_placeholder": "Nome da empresa", + "message_label": "Pergunta ou contexto", + "message_placeholder": "Explique brevemente a sua situação", + "timeline_label": "Prazo", + "communication": "Resposta em 24 horas", + "intro": "Sem compromisso", + "cta_sub": "

Agende uma consulta inicial e diremos com honestidade qual pacote faz mais sentido.

", + "no_cc": "Sem compromisso", + }, + "ru": { + "section_what": "Что входит", + "section_what_sub": "

Каждый пакет строится вокруг понятных решений, управляемого контента и базы, подходящей вашей компании.

", + "section_outcomes": "Что это даёт", + "section_outcomes_sub": "

Ценность не в поверхностных эффектах, а в более ясной коммуникации и сайте, который работает лучше.

", + "section_choose": "Когда выбирать этот пакет", + "section_choose_sub": "

Мы рекомендуем этот пакет, если пункты ниже соответствуют вашей ситуации.

", + "choose_title": "Этот пакет подходит, когда", + "privacy": "

Мы используем ваши данные только для ответа на этот запрос.

", + "name_label": "Имя", + "name_placeholder": "Ваше имя", + "email_label": "E-mail", + "email_placeholder": "name@company.ru", + "company_label": "Компания", + "company_placeholder": "Название компании", + "message_label": "Вопрос или контекст", + "message_placeholder": "Кратко опишите вашу ситуацию", + "timeline_label": "Срок", + "communication": "Ответ в течение 24 часов", + "intro": "Без обязательств", + "cta_sub": "

Запланируйте консультацию, и мы честно подскажем, какой пакет имеет смысл.

", + "no_cc": "Без обязательств", + }, +} + +SERVICE_COPY["nl"] = { + "starter": { + "title": "Starter-website", + "audience": "Voor ondernemers of kleine teams die snel professioneel online willen staan met een duidelijke basis.", + "what": [ + ( + "layout-text-window", + "Voor wie is dit?", + "

Voor bedrijven met een helder aanbod die snel een professionele eerste indruk willen neerzetten.

", + ), + ( + "window", + "Wat krijgt u?", + "

Kernpagina’s, een logische navigatie en een editor waarmee uw team zelf content kan beheren.

", + ), + ( + "graph-up-arrow", + "Wat levert het op?", + "

Een professionele basis waarmee bezoekers sneller begrijpen wat u doet en hoe ze contact opnemen.

", + ), + ], + "outcomes": [ + ( + "shield-check", + "Heldere online basis", + "

Geen overbodige onderdelen, wel een site die vertrouwen geeft.

", + ), + ( + "people", + "Eenvoudig beheer", + "

Uw team kan updates zelf doen zonder afhankelijkheid.

", + ), + ( + "rocket", + "Snelle livegang", + "

Geschikt als eerste professionele stap of als vervanging van een verouderde site.

", + ), + ], + "choose": [ + "U wilt snel professioneel online staan.", + "U heeft vooral kernpagina’s en duidelijke navigatie nodig.", + "U wilt zelf teksten en beelden kunnen aanpassen.", + ], + "duration": "Gemiddelde oplevering: 2 tot 4 weken", + }, + "business": { + "title": "Zakelijke website", + "audience": "Voor dienstverleners en teams die meerdere proposities, cases of funnelstappen helder willen presenteren.", + "what": [ + ( + "briefcase", + "Voor wie is dit?", + "

Voor organisaties die meer structuur, inhoudelijke diepgang en een sterkere aanvraagroute nodig hebben.

", + ), + ( + "layout-text-window", + "Wat krijgt u?", + "

Meer pagina-opbouw, ruimte voor cases en een SEO-vriendelijke basis die logisch meegroeit.

", + ), + ( + "graph-up-arrow", + "Wat levert het op?", + "

Een site die uw aanbod beter uitlegt en bezoekers gerichter naar contact of aanvraag leidt.

", + ), + ], + "outcomes": [ + ( + "diagram-3", + "Meer overzicht", + "

Diensten, cases en expertise krijgen elk hun eigen plek.

", + ), + ( + "search", + "Betere vindbaarheid", + "

De opbouw is ingericht voor sterke inhoud en een SEO-vriendelijke basis.

", + ), + ( + "people", + "Sterkere aanvragen", + "

Bezoekers zien sneller welke route en welk aanbod bij hen past.

", + ), + ], + "choose": [ + "U heeft meerdere diensten of doelgroepen.", + "U wilt cases, expertise en bewijs beter laten zien.", + "U zoekt meer structuur dan een startsite biedt.", + ], + "duration": "Gemiddelde oplevering: 2 tot 4 weken", + }, + "webshop": { + "title": "Webshop-implementatie", + "audience": "Voor organisaties die online verkoop willen toevoegen zonder in een standaardshop te belanden.", + "what": [ + ( + "cart-check", + "Voor wie is dit?", + "

Voor bedrijven die hun aanbod online willen verkopen met grip op inhoud, checkout en beheer.

", + ), + ( + "credit-card", + "Wat krijgt u?", + "

Een webshopstructuur met productoverzicht, checkout en een schaalbare basis voor orderverwerking.

", + ), + ( + "graph-up-arrow", + "Wat levert het op?", + "

Een verkoopomgeving die past bij uw merk en niet voelt als een los demo-sjabloon.

", + ), + ], + "outcomes": [ + ( + "window", + "Betere presentatie", + "

Producten en categorieën krijgen een zakelijke, duidelijke opbouw.

", + ), + ( + "shield-check", + "Stabiele techniek", + "

Betalingen en orderverwerking sluiten aan op een beheerbare stack.

", + ), + ( + "rocket", + "Klaar voor groei", + "

De commerce-opzet kan meegroeien met assortiment en processen.

", + ), + ], + "choose": [ + "U wilt online verkoop combineren met een zakelijke website.", + "U heeft behoefte aan grip op structuur en techniek.", + "U zoekt geen standaard thema, maar een doordachte implementatie.", + ], + "duration": "Gemiddelde oplevering: 3 tot 6 weken", + }, + "support": { + "title": "Onderhoud & groei", + "audience": "Voor teams die hun website of webshop stabiel willen houden en gericht willen doorontwikkelen.", + "what": [ + ( + "tools", + "Voor wie is dit?", + "

Voor organisaties die niet zelf alle techniek willen monitoren, oplossen en plannen.

", + ), + ( + "shield-check", + "Wat krijgt u?", + "

Onderhoud, updates, monitoring en technische oplossingen binnen een vast werkritme.

", + ), + ( + "graph-up-arrow", + "Wat levert het op?", + "

Meer rust, minder technische verrassingen en ruimte om gericht te verbeteren.

", + ), + ], + "outcomes": [ + ( + "activity", + "Minder verstoringen", + "

Technische issues worden sneller gesignaleerd en opgelost.

", + ), + ( + "clipboard-data", + "Doorlopende verbetering", + "

We werken stap voor stap aan performance, inhoud en conversie.

", + ), + ( + "people", + "Vast ritme", + "

U weet wanneer onderhoud gebeurt en waar prioriteit ligt.

", + ), + ], + "choose": [ + "U wilt een vaste partner voor technisch onderhoud.", + "Uw site vraagt om kleine verbeteringen in plaats van een volledige herbouw.", + "U wilt sneller kunnen schakelen bij issues of uitbreidingen.", + ], + "duration": "Reactie binnen 24 uur", + }, +} +# A compact but complete service set for all translated locales. +SERVICE_COPY["en"] = { + "starter": { + "title": "Starter website", + "audience": "For founders and small teams that need a credible online presence quickly with a clear foundation.", + "what": [ + ( + "layout-text-window", + "Who this is for", + "

For businesses with a clear offer that need a professional first impression without a heavy project.

", + ), + ( + "window", + "What you get", + "

Core pages, clear navigation and an editor your team can use to manage content internally.

", + ), + ( + "graph-up-arrow", + "What it delivers", + "

A professional foundation that helps visitors understand what you do and how to contact you.

", + ), + ], + "outcomes": [ + ( + "shield-check", + "A clear online foundation", + "

No unnecessary extras, just a website that builds confidence.

", + ), + ( + "people", + "Simple content management", + "

Your team can make updates without depending on a developer for every change.

", + ), + ( + "rocket", + "Fast launch", + "

Suitable as a first professional website or as a replacement for an outdated one.

", + ), + ], + "choose": [ + "You want to look professional quickly.", + "You mainly need core pages and clear navigation.", + "You want to update copy and images yourself.", + ], + "duration": "Average timeline: 2 to 4 weeks", + }, + "business": { + "title": "Business website", + "audience": "For service firms and teams that need to present multiple offers, case studies or conversion paths clearly.", + "what": [ + ( + "briefcase", + "Who this is for", + "

For organisations that need more structure, more depth and a stronger route to enquiry.

", + ), + ( + "layout-text-window", + "What you get", + "

More page structure, room for case studies and an SEO-friendly foundation that scales logically.

", + ), + ( + "graph-up-arrow", + "What it delivers", + "

A website that explains your offer more clearly and leads visitors more directly toward contact.

", + ), + ], + "outcomes": [ + ( + "diagram-3", + "More clarity", + "

Services, case studies and expertise each get their own logical place.

", + ), + ( + "search", + "Better visibility", + "

The structure is prepared for stronger content and an SEO-friendly foundation.

", + ), + ( + "people", + "Stronger enquiries", + "

Visitors see more quickly which route and which offer fit them.

", + ), + ], + "choose": [ + "You have multiple services or audiences.", + "You want to show case studies, expertise and proof more clearly.", + "You need more structure than a starter site provides.", + ], + "duration": "Average timeline: 2 to 4 weeks", + }, + "webshop": { + "title": "Webshop implementation", + "audience": "For organisations that want to add online sales without ending up with a generic shop theme.", + "what": [ + ( + "cart-check", + "Who this is for", + "

For businesses that want to sell online while keeping control over presentation, checkout and management.

", + ), + ( + "credit-card", + "What you get", + "

A commerce structure with product overview, checkout and a scalable base for order handling.

", + ), + ( + "graph-up-arrow", + "What it delivers", + "

A sales environment that fits your brand instead of feeling like a detached demo template.

", + ), + ], + "outcomes": [ + ( + "window", + "Stronger presentation", + "

Products and categories are presented in a clear business-focused structure.

", + ), + ( + "shield-check", + "Stable technology", + "

Payments and order handling are connected to a maintainable technical stack.

", + ), + ( + "rocket", + "Ready to grow", + "

The commerce setup can expand with your assortment and processes.

", + ), + ], + "choose": [ + "You want to combine online sales with a professional business site.", + "You need control over structure and technology.", + "You do not want a generic theme-based shop.", + ], + "duration": "Average timeline: 3 to 6 weeks", + }, + "support": { + "title": "Maintenance & growth", + "audience": "For teams that want to keep their site or webshop stable and improve it in a focused way over time.", + "what": [ + ( + "tools", + "Who this is for", + "

For organisations that do not want to monitor, troubleshoot and plan every technical issue themselves.

", + ), + ( + "shield-check", + "What you get", + "

Maintenance, updates, monitoring and technical fixes within a predictable working rhythm.

", + ), + ( + "graph-up-arrow", + "What it delivers", + "

More peace of mind, fewer technical surprises and room for targeted improvement.

", + ), + ], + "outcomes": [ + ( + "activity", + "Fewer disruptions", + "

Technical issues are spotted and resolved faster.

", + ), + ( + "clipboard-data", + "Continuous improvement", + "

We keep improving performance, content and conversion step by step.

", + ), + ( + "people", + "A fixed rhythm", + "

You know when maintenance happens and what receives priority.

", + ), + ], + "choose": [ + "You want a fixed partner for technical maintenance.", + "Your site needs incremental improvements rather than a full rebuild.", + "You want faster support when issues or expansion requests appear.", + ], + "duration": "Response within 24 hours", + }, +} +# DE/FR/ES/IT/PT/RU service content +SERVICE_COPY["de"] = { + "starter": { + "title": "Starter-Website", + "audience": "Für Gründer und kleine Teams, die schnell professionell online auftreten möchten.", + "what": [ + ( + "layout-text-window", + "Für wen ist das?", + "

Für Unternehmen mit klarem Angebot, die ohne schweres Projekt einen professionellen ersten Eindruck brauchen.

", + ), + ( + "window", + "Was Sie bekommen", + "

Kernseiten, klare Navigation und einen Editor, mit dem Ihr Team Inhalte selbst pflegen kann.

", + ), + ( + "graph-up-arrow", + "Was es bringt", + "

Eine professionelle Basis, mit der Besucher schneller verstehen, was Sie tun und wie sie Kontakt aufnehmen.

", + ), + ], + "outcomes": [ + ( + "shield-check", + "Klare Online-Basis", + "

Keine unnötigen Extras, sondern eine Website, die Vertrauen schafft.

", + ), + ( + "people", + "Einfache Pflege", + "

Ihr Team kann Änderungen selbst umsetzen.

", + ), + ( + "rocket", + "Schneller Start", + "

Geeignet als erste professionelle Website oder als Ersatz für einen veralteten Auftritt.

", + ), + ], + "choose": [ + "Sie möchten schnell professionell online sein.", + "Sie brauchen vor allem Kernseiten und klare Navigation.", + "Sie möchten Texte und Bilder selbst anpassen.", + ], + "duration": "Durchschnittlicher Zeitraum: 2 bis 4 Wochen", + }, + "business": { + "title": "Geschäftswebsite", + "audience": "Für Dienstleister und Teams, die mehrere Angebote, Referenzen oder Conversion-Wege klar darstellen müssen.", + "what": [ + ( + "briefcase", + "Für wen ist das?", + "

Für Organisationen, die mehr Struktur, mehr inhaltliche Tiefe und einen stärkeren Anfrageweg brauchen.

", + ), + ( + "layout-text-window", + "Was Sie bekommen", + "

Mehr Seitenstruktur, Raum für Referenzen und eine SEO-freundliche Basis, die logisch mitwächst.

", + ), + ( + "graph-up-arrow", + "Was es bringt", + "

Eine Website, die Ihr Angebot klarer erklärt und Besucher direkter zur Kontaktaufnahme führt.

", + ), + ], + "outcomes": [ + ( + "diagram-3", + "Mehr Übersicht", + "

Leistungen, Referenzen und Expertise erhalten jeweils ihren eigenen Platz.

", + ), + ( + "search", + "Bessere Sichtbarkeit", + "

Der Aufbau ist für starke Inhalte und eine SEO-freundliche Basis vorbereitet.

", + ), + ( + "people", + "Stärkere Anfragen", + "

Besucher erkennen schneller, welcher Weg und welches Angebot zu ihnen passen.

", + ), + ], + "choose": [ + "Sie haben mehrere Leistungen oder Zielgruppen.", + "Sie möchten Referenzen, Expertise und Nachweise klarer zeigen.", + "Sie brauchen mehr Struktur als eine Startseite bietet.", + ], + "duration": "Durchschnittlicher Zeitraum: 2 bis 4 Wochen", + }, + "webshop": { + "title": "Webshop-Implementierung", + "audience": "Für Unternehmen, die Online-Verkauf ergänzen möchten, ohne bei einem Standardshop zu landen.", + "what": [ + ( + "cart-check", + "Für wen ist das?", + "

Für Unternehmen, die online verkaufen wollen und dabei Inhalte, Checkout und Pflege im Griff behalten möchten.

", + ), + ( + "credit-card", + "Was Sie bekommen", + "

Einen Commerce-Aufbau mit Produktübersicht, Checkout und skalierbarer Basis für Bestellprozesse.

", + ), + ( + "graph-up-arrow", + "Was es bringt", + "

Eine Verkaufsumgebung, die zu Ihrer Marke passt und nicht wie ein Demo-Shop wirkt.

", + ), + ], + "outcomes": [ + ( + "window", + "Bessere Präsentation", + "

Produkte und Kategorien erhalten eine klare, geschäftliche Struktur.

", + ), + ( + "shield-check", + "Stabile Technik", + "

Zahlungen und Bestellabläufe laufen auf einer beherrschbaren technischen Basis.

", + ), + ( + "rocket", + "Bereit für Wachstum", + "

Der Commerce-Aufbau kann mit Sortiment und Prozessen mitwachsen.

", + ), + ], + "choose": [ + "Sie möchten Online-Verkauf mit einer Unternehmenswebsite verbinden.", + "Sie brauchen Kontrolle über Struktur und Technik.", + "Sie suchen keinen Shop von der Stange.", + ], + "duration": "Durchschnittlicher Zeitraum: 3 bis 6 Wochen", + }, + "support": { + "title": "Wartung & Wachstum", + "audience": "Für Teams, die Website oder Webshop stabil halten und gezielt weiterentwickeln wollen.", + "what": [ + ( + "tools", + "Für wen ist das?", + "

Für Organisationen, die Technik nicht komplett selbst überwachen, lösen und planen möchten.

", + ), + ( + "shield-check", + "Was Sie bekommen", + "

Wartung, Updates, Monitoring und technische Lösungen in einem festen Arbeitsrhythmus.

", + ), + ( + "graph-up-arrow", + "Was es bringt", + "

Mehr Ruhe, weniger technische Überraschungen und Raum für gezielte Verbesserungen.

", + ), + ], + "outcomes": [ + ( + "activity", + "Weniger Störungen", + "

Technische Probleme werden schneller erkannt und gelöst.

", + ), + ( + "clipboard-data", + "Kontinuierliche Verbesserung", + "

Wir entwickeln Performance, Inhalte und Conversion Schritt für Schritt weiter.

", + ), + ( + "people", + "Fester Rhythmus", + "

Sie wissen, wann Wartung stattfindet und was Priorität hat.

", + ), + ], + "choose": [ + "Sie möchten einen festen Partner für technische Wartung.", + "Ihre Website braucht laufende Verbesserungen statt eines kompletten Relaunchs.", + "Sie wollen bei Problemen oder Erweiterungen schneller reagieren können.", + ], + "duration": "Rückmeldung innerhalb von 24 Stunden", + }, +} +SERVICE_COPY["fr"] = { + "starter": { + "title": "Site de démarrage", + "audience": "Pour les dirigeants et petites équipes qui veulent une présence crédible rapidement.", + "what": [ + ( + "layout-text-window", + "Pour qui est-ce ?", + "

Pour les entreprises avec une offre claire qui ont besoin d’une première impression professionnelle sans projet lourd.

", + ), + ( + "window", + "Ce que vous obtenez", + "

Les pages essentielles, une navigation claire et un éditeur que votre équipe peut utiliser elle-même.

", + ), + ( + "graph-up-arrow", + "Ce que cela apporte", + "

Une base professionnelle qui aide les visiteurs à comprendre rapidement votre activité et à vous contacter.

", + ), + ], + "outcomes": [ + ( + "shield-check", + "Base en ligne claire", + "

Pas d’éléments inutiles, mais un site qui inspire confiance.

", + ), + ( + "people", + "Gestion simple", + "

Votre équipe peut faire les mises à jour sans dépendance permanente.

", + ), + ( + "rocket", + "Mise en ligne rapide", + "

Adapté à un premier site professionnel ou au remplacement d’un site dépassé.

", + ), + ], + "choose": [ + "Vous voulez être en ligne rapidement avec une image professionnelle.", + "Vous avez surtout besoin de pages essentielles et d’une navigation claire.", + "Vous voulez pouvoir modifier vous-même textes et images.", + ], + "duration": "Délai moyen : 2 à 4 semaines", + }, + "business": { + "title": "Site d’entreprise", + "audience": "Pour les sociétés de services et les équipes qui doivent présenter clairement plusieurs offres, références ou parcours de conversion.", + "what": [ + ( + "briefcase", + "Pour qui est-ce ?", + "

Pour les organisations qui ont besoin de plus de structure, de profondeur et d’un meilleur parcours vers la prise de contact.

", + ), + ( + "layout-text-window", + "Ce que vous obtenez", + "

Plus de structure de pages, de la place pour les références et une base favorable au SEO qui peut évoluer proprement.

", + ), + ( + "graph-up-arrow", + "Ce que cela apporte", + "

Un site qui explique mieux votre offre et oriente plus directement les visiteurs vers le contact.

", + ), + ], + "outcomes": [ + ( + "diagram-3", + "Plus de clarté", + "

Services, références et expertise disposent chacun d’une place logique.

", + ), + ( + "search", + "Meilleure visibilité", + "

La structure est pensée pour un contenu solide et une base favorable au SEO.

", + ), + ( + "people", + "Demandes plus qualifiées", + "

Les visiteurs identifient plus vite l’offre et la voie qui leur conviennent.

", + ), + ], + "choose": [ + "Vous avez plusieurs services ou plusieurs publics.", + "Vous voulez mieux montrer vos références, votre expertise et vos preuves.", + "Vous avez besoin de plus de structure qu’un site de base.", + ], + "duration": "Délai moyen : 2 à 4 semaines", + }, + "webshop": { + "title": "Implémentation e-commerce", + "audience": "Pour les organisations qui veulent ajouter la vente en ligne sans tomber dans une boutique standardisée.", + "what": [ + ( + "cart-check", + "Pour qui est-ce ?", + "

Pour les entreprises qui veulent vendre en ligne tout en gardant la maîtrise de la présentation, du tunnel d’achat et de la gestion.

", + ), + ( + "credit-card", + "Ce que vous obtenez", + "

Une structure e-commerce avec catalogue, checkout et base évolutive pour le traitement des commandes.

", + ), + ( + "graph-up-arrow", + "Ce que cela apporte", + "

Un environnement de vente aligné sur votre marque, pas une boutique qui ressemble à une démo.

", + ), + ], + "outcomes": [ + ( + "window", + "Présentation plus forte", + "

Produits et catégories sont organisés dans une structure claire et crédible.

", + ), + ( + "shield-check", + "Technique stable", + "

Paiements et gestion des commandes reposent sur une base maintenable.

", + ), + ( + "rocket", + "Prêt à évoluer", + "

La structure e-commerce peut suivre l’évolution de l’offre et des processus.

", + ), + ], + "choose": [ + "Vous voulez combiner vente en ligne et site d’entreprise.", + "Vous avez besoin de garder la maîtrise de la structure et de la technique.", + "Vous ne cherchez pas une boutique basée sur un thème standard.", + ], + "duration": "Délai moyen : 3 à 6 semaines", + }, + "support": { + "title": "Maintenance & croissance", + "audience": "Pour les équipes qui veulent garder leur site ou leur boutique stable et l’améliorer de manière ciblée.", + "what": [ + ( + "tools", + "Pour qui est-ce ?", + "

Pour les organisations qui ne veulent pas gérer seules toute la surveillance, les incidents et la planification technique.

", + ), + ( + "shield-check", + "Ce que vous obtenez", + "

Maintenance, mises à jour, monitoring et correctifs techniques dans un rythme de travail régulier.

", + ), + ( + "graph-up-arrow", + "Ce que cela apporte", + "

Plus de sérénité, moins de surprises techniques et plus d’espace pour des améliorations utiles.

", + ), + ], + "outcomes": [ + ( + "activity", + "Moins d’interruptions", + "

Les problèmes techniques sont repérés et résolus plus rapidement.

", + ), + ( + "clipboard-data", + "Amélioration continue", + "

Nous faisons progresser performance, contenu et conversion étape par étape.

", + ), + ( + "people", + "Rythme stable", + "

Vous savez quand la maintenance a lieu et ce qui passe en priorité.

", + ), + ], + "choose": [ + "Vous voulez un partenaire stable pour la maintenance technique.", + "Votre site a surtout besoin d’améliorations continues plutôt que d’une refonte complète.", + "Vous voulez réagir plus vite en cas d’incident ou d’évolution.", + ], + "duration": "Réponse sous 24 heures", + }, +} +STANDARD_COPY["es"] = { + "faq_title": "Preguntas frecuentes", + "faq_sub": "

Mantenemos el proceso claro y práctico.

", + "faq_items": [ + ( + "¿Trabajáis con plantillas fijas?", + "

No. Usamos bloques reutilizables, pero el contenido y la estructura se adaptan a su empresa.

", + "Proceso", + ), + ( + "¿Podemos ampliar más adelante?", + "

Sí. La base está pensada para crecer sin empezar de cero.

", + "Crecimiento", + ), + ( + "¿Podemos gestionar el contenido internamente?", + "

Sí. Es un principio central de la plataforma.

", + "Gestión", + ), + ], + "cta_sub": "

Programe una reunión inicial y le mostraremos la ruta más lógica para su empresa.

", + "no_cc": "Sin compromiso", + "pages": { + "about": { + "headline": "Quién es MandelBlog y cómo trabajamos", + "sub": "

MandelBlog ayuda a empresas de servicios y equipos pequeños a lanzar sitios web creíbles, orientados a conversión y fáciles de gestionar.

", + "features_title": "En qué nos fijamos", + "features_sub": "

Trabajamos mejor con organizaciones que valoran la claridad, la rapidez y el control editorial.

", + "features": [ + ( + "people", + "A quién ayudamos", + "

Empresas de servicios, estudios y equipos pequeños con una propuesta clara y una planificación realista.

", + ), + ( + "diagram-3", + "Cómo trabajamos", + "

Primero afinamos objetivo y mensaje, después construimos con bloques probados y entregamos una base útil para su equipo.

", + ), + ( + "shield-check", + "Por qué funciona distinto", + "

No es una plantilla desconectada ni una caja negra, sino una estructura clara que puede seguir creciendo.

", + ), + ( + "person-badge", + "Equipo pequeño, contacto directo", + "

Habla directamente con las personas que hacen el trabajo y convierten decisiones en páginas.

", + ), + ], + "steps_badge": "Método", + "steps_heading": "Nuestro enfoque en 3 pasos", + "steps_sub": "Un proyecto enfocado, decisiones claras y después un sitio que su equipo puede gestionar con seguridad.", + "steps": [ + ( + "1", + "Reunión inicial", + "Definimos objetivos, contenido y prioridades.", + "chat-square-text", + ), + ( + "2", + "Desarrollo", + "Construimos las páginas y afinamos el contenido con usted.", + "layout-text-window", + ), + ( + "3", + "Lanzamiento", + "Recibe acompañamiento, traspaso y un siguiente paso claro.", + "rocket", + ), + ], + "cta": "¿Quiere saber si nuestro enfoque encaja con su empresa?", + }, + "services": { + "headline": "Servicios para empresas que buscan claridad y calidad", + "sub": "

Cada servicio se construye a partir de decisiones claras, contenido útil y una base técnica que puede crecer con su negocio.

", + "features_title": "Qué entregamos", + "features_sub": "

No son módulos sueltos, sino un recorrido adaptado a su etapa, equipo y objetivos.

", + "features": [ + ( + "window", + "Sitio web inicial", + "

Para empresas que quieren estar online rápido con una base profesional.

", + ), + ( + "briefcase", + "Sitio web empresarial", + "

Para organizaciones con varios servicios, casos o una estructura de oferta más compleja.

", + ), + ( + "cart-check", + "Implementación webshop", + "

Para equipos que quieren añadir venta online sin perder el control sobre estructura y tecnología.

", + ), + ( + "wrench-adjustable", + "Mantenimiento y crecimiento", + "

Para organizaciones que necesitan mantenimiento, estabilidad y mejora continua.

", + ), + ], + "cta": "¿Duda qué paquete encaja con su etapa?", + }, + "projects": { + "headline": "Proyectos donde estructura, contenido y tecnología trabajan juntos", + "sub": "

Nuestros proyectos están pensados para transmitir credibilidad, generar confianza y seguir siendo gestionables después del lanzamiento.

", + "features_title": "Qué se ve en nuestros proyectos", + "features_sub": "

No optimizamos efectos superficiales, sino claridad y utilidad.

", + "features": [ + ( + "diagram-3", + "Estructura de página clara", + "

Los visitantes entienden rápidamente dónde están y cuál es el siguiente paso lógico.

", + ), + ( + "pencil-square", + "Gestión sencilla", + "

Los equipos pueden actualizar textos, imágenes y páginas sin depender de terceros.

", + ), + ( + "graph-up-arrow", + "Pensado para solicitudes", + "

El contacto y la conversión están integrados en la estructura y en el contenido.

", + ), + ], + "cta": "¿Quiere presentar su próximo proyecto con más solidez?", + }, + "contact": { + "headline": "Convirtamos su consulta en un plan concreto", + "sub": "

Cuéntenos brevemente qué necesita. Recibirá una respuesta útil con próximos pasos realistas.

", + "features_title": "Para qué puede contactarnos", + "features_sub": "

Elija la vía que mejor encaja con su pregunta o momento actual.

", + "features": [ + ( + "rocket", + "Nuevo proyecto", + "

Para un nuevo sitio web, un reposicionamiento o una reconstrucción completa.

", + ), + ( + "briefcase", + "Elección del paquete", + "

Para decidir qué paquete o estructura se ajusta mejor.

", + ), + ( + "tools", + "Mantenimiento o ampliación", + "

Para soporte técnico, mejoras posteriores o una nueva fase tras el lanzamiento.

", + ), + ], + "form_title": "Cuéntenos brevemente qué necesita", + "form_sub": "

Respondemos con criterio y sin presión comercial.

", + "form_fields": [ + ("text", "Nombre", "Su nombre"), + ("email", "Correo electrónico", "nombre@empresa.es"), + ("company", "Empresa", "Nombre de la empresa"), + ("message", "Consulta o proyecto", "¿En qué necesita ayuda?"), + ], + "benefits_title": "Qué puede esperar", + "benefits": [ + "Respuesta en 24 horas", + "Llamada inicial de 15 minutos", + "Sin compromiso", + ], + "privacy": "

Usamos sus datos únicamente para responder a esta solicitud.

", + "cta": "¿Listo para dar el primer paso?", + }, + "process": { + "headline": "Método de trabajo con pasos claros y decisiones definidas", + "sub": "

Mantenemos el proyecto ordenado: sabe cuándo ocurre cada cosa, qué debe aportar y hacia qué objetivo trabajamos.

", + "features_title": "Así trabajamos", + "features_sub": "

Breve, claro y sin ruido innecesario.

", + "features": [ + ( + "chat-square-text", + "1. Reunión inicial", + "

Definimos objetivo, público, contenido y qué quiere gestionar internamente su equipo.

", + ), + ( + "layout-text-window", + "2. Desarrollo", + "

Convertimos estructura, contenido y diseño en una arquitectura de páginas clara.

", + ), + ( + "rocket", + "3. Lanzamiento", + "

Tras la revisión, el sitio se publica y entregamos una base fácil de gestionar.

", + ), + ( + "graph-up-arrow", + "4. Evolución", + "

Después podemos seguir mejorando en función del comportamiento, las preguntas y los nuevos planes.

", + ), + ], + "cta": "¿Quiere este mismo enfoque para su sitio web?", + }, + }, +} +STANDARD_COPY["it"] = { + "faq_title": "Domande frequenti", + "faq_sub": "

Manteniamo il progetto chiaro e concreto.

", + "faq_items": [ + ( + "Lavorate con template fissi?", + "

No. Usiamo blocchi riutilizzabili, ma contenuto e struttura vengono adattati alla vostra azienda.

", + "Metodo", + ), + ( + "Possiamo ampliare il sito in seguito?", + "

Sì. La base è progettata per crescere senza ripartire da zero.

", + "Crescita", + ), + ( + "Possiamo gestire i contenuti internamente?", + "

Sì. È un principio centrale della piattaforma.

", + "Gestione", + ), + ], + "cta_sub": "

Prenotate una consulenza introduttiva e vi mostreremo il percorso più sensato per la vostra azienda.

", + "no_cc": "Senza impegno", + "pages": { + "about": { + "headline": "Chi è MandelBlog e come lavoriamo", + "sub": "

MandelBlog aiuta aziende di servizi e piccoli team a pubblicare siti credibili, orientati alla conversione e semplici da gestire.

", + "features_title": "Su cosa ci concentriamo", + "features_sub": "

Lavoriamo al meglio con organizzazioni che cercano chiarezza, rapidità e controllo editoriale.

", + "features": [ + ( + "people", + "Chi aiutiamo", + "

Aziende di servizi, studi e piccoli team con un’offerta chiara e una pianificazione realistica.

", + ), + ( + "diagram-3", + "Come lavoriamo", + "

Definiamo prima obiettivo e messaggio, costruiamo con blocchi collaudati e consegniamo una base davvero utilizzabile.

", + ), + ( + "shield-check", + "Perché funziona in modo diverso", + "

Non un template scollegato né una black box, ma una struttura chiara che può continuare a crescere.

", + ), + ( + "person-badge", + "Team piccolo, contatto diretto", + "

Parlate direttamente con le persone che svolgono il lavoro e trasformano le decisioni nel sito.

", + ), + ], + "steps_badge": "Metodo", + "steps_heading": "Il nostro approccio in 3 fasi", + "steps_sub": "Un progetto focalizzato, scelte chiare e poi un sito che il vostro team può davvero utilizzare.", + "steps": [ + ( + "1", + "Colloquio iniziale", + "Definiamo obiettivi, contenuti e priorità.", + "chat-square-text", + ), + ( + "2", + "Sviluppo", + "Costruiamo le pagine e rifiniamo i contenuti insieme a voi.", + "layout-text-window", + ), + ( + "3", + "Pubblicazione", + "Ricevete affiancamento, consegna e un passo successivo chiaro.", + "rocket", + ), + ], + "cta": "Volete capire se il nostro approccio è adatto alla vostra azienda?", + }, + "services": { + "headline": "Servizi per aziende che cercano chiarezza e qualità", + "sub": "

Ogni servizio nasce da scelte chiare, contenuti utili e una base tecnica che può crescere insieme al business.

", + "features_title": "Cosa consegniamo", + "features_sub": "

Non moduli scollegati, ma un percorso adatto alla vostra fase, al team e agli obiettivi.

", + "features": [ + ( + "window", + "Sito starter", + "

Per aziende che vogliono essere online rapidamente con una base professionale.

", + ), + ( + "briefcase", + "Sito business", + "

Per organizzazioni con più servizi, casi o una struttura dell’offerta più articolata.

", + ), + ( + "cart-check", + "Implementazione webshop", + "

Per team che vogliono aggiungere vendita online senza perdere controllo su struttura e tecnologia.

", + ), + ( + "wrench-adjustable", + "Manutenzione e crescita", + "

Per organizzazioni che hanno bisogno di manutenzione, stabilità e miglioramento continuo.

", + ), + ], + "cta": "Non sapete quale pacchetto sia adatto alla vostra fase?", + }, + "projects": { + "headline": "Progetti in cui struttura, contenuto e tecnologia lavorano insieme", + "sub": "

I nostri progetti sono pensati per trasmettere credibilità, generare fiducia e restare gestibili anche dopo il lancio.

", + "features_title": "Cosa si vede nei nostri progetti", + "features_sub": "

Non ottimizziamo effetti superficiali, ma chiarezza e utilità.

", + "features": [ + ( + "diagram-3", + "Struttura di pagina chiara", + "

I visitatori capiscono subito dove si trovano e quale sia il passo successivo.

", + ), + ( + "pencil-square", + "Gestione semplice", + "

I team possono aggiornare testi, immagini e pagine senza scorciatoie complicate.

", + ), + ( + "graph-up-arrow", + "Pensato per le richieste", + "

Contatto e conversione sono visibili nella struttura e nei contenuti.

", + ), + ], + "cta": "Volete presentare il vostro prossimo progetto in modo più professionale?", + }, + "contact": { + "headline": "Trasformiamo la vostra richiesta in un piano concreto", + "sub": "

Spiegate brevemente di cosa avete bisogno. Riceverete una risposta utile con i prossimi passi realistici.

", + "features_title": "Per cosa potete contattarci", + "features_sub": "

Scegliete il percorso che meglio si adatta alla vostra domanda o alla fase attuale.

", + "features": [ + ( + "rocket", + "Nuovo progetto", + "

Per un nuovo sito, un riposizionamento o una ricostruzione completa.

", + ), + ( + "briefcase", + "Scelta del pacchetto", + "

Per capire quale pacchetto o struttura sia più adatto.

", + ), + ( + "tools", + "Manutenzione o sviluppo", + "

Per supporto tecnico, miglioramenti successivi o una fase ulteriore dopo il lancio.

", + ), + ], + "form_title": "Raccontateci brevemente di cosa avete bisogno", + "form_sub": "

Rispondiamo con contenuto utile e senza pressione commerciale.

", + "form_fields": [ + ("text", "Nome", "Il vostro nome"), + ("email", "E-mail", "nome@azienda.it"), + ("company", "Azienda", "Nome dell’azienda"), + ("message", "Domanda o progetto", "Per cosa vi serve supporto?"), + ], + "benefits_title": "Cosa aspettarvi", + "benefits": [ + "Risposta entro 24 ore", + "Colloquio iniziale di 15 minuti", + "Senza impegno", + ], + "privacy": "

Usiamo i vostri dati solo per rispondere a questa richiesta.

", + "cta": "Pronti a fare il primo passo?", + }, + "process": { + "headline": "Metodo di lavoro con passaggi chiari e scelte definite", + "sub": "

Manteniamo il progetto ordinato: sapete quando succede ogni cosa, cosa dovete fornire e verso quale risultato stiamo lavorando.

", + "features_title": "Come lavoriamo insieme", + "features_sub": "

Breve, chiaro e senza rumore inutile.

", + "features": [ + ( + "chat-square-text", + "1. Colloquio iniziale", + "

Definiamo obiettivi, pubblico, contenuti e ciò che il vostro team vuole gestire internamente.

", + ), + ( + "layout-text-window", + "2. Sviluppo", + "

Trasformiamo struttura, contenuti e design in un’architettura di pagina chiara.

", + ), + ( + "rocket", + "3. Pubblicazione", + "

Dopo la revisione il sito va online e consegniamo una base gestibile dal vostro team.

", + ), + ( + "graph-up-arrow", + "4. Evoluzione", + "

Quando serve, continuiamo a migliorare in base ai comportamenti, alle domande e ai nuovi obiettivi.

", + ), + ], + "cta": "Volete questo stesso approccio anche per il vostro sito?", + }, + }, +} +STANDARD_COPY["pt"] = { + "faq_title": "Perguntas frequentes", + "faq_sub": "

Mantemos o projeto claro e prático.

", + "faq_items": [ + ( + "Trabalham com templates fixos?", + "

Não. Usamos blocos reutilizáveis, mas o conteúdo e a estrutura são ajustados à sua empresa.

", + "Método", + ), + ( + "Podemos expandir mais tarde?", + "

Sim. A base foi pensada para crescer sem começar do zero.

", + "Expansão", + ), + ( + "Podemos gerir o conteúdo internamente?", + "

Sim. Esse é um princípio central da plataforma.

", + "Gestão", + ), + ], + "cta_sub": "

Agende uma consulta inicial e mostramos o caminho mais sensato para a sua empresa.

", + "no_cc": "Sem compromisso", + "pages": { + "about": { + "headline": "Quem é a MandelBlog e como trabalhamos", + "sub": "

A MandelBlog ajuda empresas de serviços e pequenas equipas a lançar sites credíveis, focados em conversão e fáceis de gerir.

", + "features_title": "No que prestamos atenção", + "features_sub": "

Trabalhamos melhor com organizações que valorizam clareza, rapidez e controlo editorial.

", + "features": [ + ( + "people", + "Quem ajudamos", + "

Empresas de serviços, estúdios e pequenas equipas com uma oferta clara e um planeamento realista.

", + ), + ( + "diagram-3", + "Como trabalhamos", + "

Primeiro afinamos objetivo e mensagem, depois construímos com blocos comprovados e entregamos uma base realmente útil.

", + ), + ( + "shield-check", + "Porque funciona de forma diferente", + "

Não é um template desligado nem uma caixa negra, mas uma estrutura clara que pode continuar a evoluir.

", + ), + ( + "person-badge", + "Equipa pequena, contacto direto", + "

Fala diretamente com quem executa o trabalho e transforma decisões no website.

", + ), + ], + "steps_badge": "Método", + "steps_heading": "A nossa abordagem em 3 etapas", + "steps_sub": "Um projeto focado, decisões claras e depois um website que a sua equipa consegue gerir com confiança.", + "steps": [ + ( + "1", + "Reunião inicial", + "Definimos objetivos, conteúdo e prioridades.", + "chat-square-text", + ), + ( + "2", + "Desenvolvimento", + "Construímos as páginas e afinamos o conteúdo consigo.", + "layout-text-window", + ), + ( + "3", + "Lançamento", + "Recebe acompanhamento, transição e um próximo passo claro.", + "rocket", + ), + ], + "cta": "Quer saber se a nossa abordagem faz sentido para a sua empresa?", + }, + "services": { + "headline": "Serviços para empresas que procuram clareza e qualidade", + "sub": "

Cada serviço é pensado a partir de decisões claras, conteúdo útil e uma base técnica que pode crescer consigo.

", + "features_title": "O que entregamos", + "features_sub": "

Não são módulos soltos, mas um percurso adaptado à sua fase, equipa e objetivos.

", + "features": [ + ( + "window", + "Website inicial", + "

Para empresas que querem entrar online rapidamente com uma base profissional.

", + ), + ( + "briefcase", + "Site empresarial", + "

Para organizações com vários serviços, casos ou uma estrutura de oferta mais complexa.

", + ), + ( + "cart-check", + "Implementação de webshop", + "

Para equipas que querem acrescentar vendas online sem perder controlo sobre estrutura e tecnologia.

", + ), + ( + "wrench-adjustable", + "Manutenção & crescimento", + "

Para organizações que precisam de manutenção, estabilidade e melhoria contínua.

", + ), + ], + "cta": "Tem dúvidas sobre qual pacote faz sentido nesta fase?", + }, + "projects": { + "headline": "Projetos onde estrutura, conteúdo e tecnologia trabalham em conjunto", + "sub": "

Os nossos projetos são pensados para transmitir credibilidade, criar confiança e continuar geríveis depois do lançamento.

", + "features_title": "O que se vê nos nossos projetos", + "features_sub": "

Não otimizamos efeitos superficiais, mas sim clareza e utilidade.

", + "features": [ + ( + "diagram-3", + "Estrutura de página clara", + "

Os visitantes percebem rapidamente onde estão e qual é o próximo passo lógico.

", + ), + ( + "pencil-square", + "Gestão simples", + "

As equipas podem atualizar textos, imagens e páginas sem depender de terceiros.

", + ), + ( + "graph-up-arrow", + "Pensado para pedidos", + "

Contacto e conversão estão visíveis tanto na estrutura como no conteúdo.

", + ), + ], + "cta": "Quer apresentar o seu próximo projeto de forma mais profissional?", + }, + "contact": { + "headline": "Vamos transformar a sua questão num plano concreto", + "sub": "

Diga-nos brevemente o que precisa. Receberá uma resposta útil com próximos passos realistas.

", + "features_title": "Para que pode entrar em contacto", + "features_sub": "

Escolha o caminho que melhor se adequa à sua questão ou fase atual.

", + "features": [ + ( + "rocket", + "Novo projeto", + "

Para um novo website, reposicionamento ou reconstrução completa.

", + ), + ( + "briefcase", + "Escolha do pacote", + "

Para perceber qual o pacote ou estrutura mais adequado.

", + ), + ( + "tools", + "Manutenção ou expansão", + "

Para apoio técnico, melhorias posteriores ou uma nova fase depois do lançamento.

", + ), + ], + "form_title": "Conte-nos brevemente do que precisa", + "form_sub": "

Respondemos com conteúdo útil e sem pressão comercial.

", + "form_fields": [ + ("text", "Nome", "O seu nome"), + ("email", "E-mail", "nome@empresa.pt"), + ("company", "Empresa", "Nome da empresa"), + ("message", "Questão ou projeto", "Em que precisa de ajuda?"), + ], + "benefits_title": "O que pode esperar", + "benefits": [ + "Resposta em 24 horas", + "Reunião inicial de 15 minutos", + "Sem compromisso", + ], + "privacy": "

Usamos os seus dados apenas para responder a este pedido.

", + "cta": "Pronto para dar o primeiro passo?", + }, + "process": { + "headline": "Método de trabalho com etapas claras e decisões definidas", + "sub": "

Mantemos o projeto organizado: sabe quando acontece cada etapa, o que precisa de fornecer e para onde estamos a avançar.

", + "features_title": "Como trabalhamos consigo", + "features_sub": "

Curto, claro e sem ruído desnecessário.

", + "features": [ + ( + "chat-square-text", + "1. Reunião inicial", + "

Definimos objetivo, público, conteúdo e o que a sua equipa quer conseguir gerir internamente.

", + ), + ( + "layout-text-window", + "2. Desenvolvimento", + "

Transformamos estrutura, conteúdo e design numa arquitetura de páginas clara.

", + ), + ( + "rocket", + "3. Lançamento", + "

Depois da revisão, o site entra no ar e entregamos uma base fácil de gerir.

", + ), + ( + "graph-up-arrow", + "4. Evolução", + "

Quando fizer sentido, continuamos a melhorar com base no comportamento, nas perguntas e nos novos planos.

", + ), + ], + "cta": "Quer esta mesma abordagem para o seu website?", + }, + }, +} +STANDARD_COPY["ru"] = { + "faq_title": "Частые вопросы", + "faq_sub": "

Мы делаем процесс понятным и практичным.

", + "faq_items": [ + ( + "Вы работаете с фиксированными шаблонами?", + "

Нет. Мы используем переиспользуемые блоки, но структура и контент настраиваются под вашу компанию.

", + "Процесс", + ), + ( + "Можно ли расширить сайт позже?", + "

Да. Основа рассчитана на рост без перезапуска проекта.

", + "Развитие", + ), + ( + "Сможем ли мы управлять контентом сами?", + "

Да. Это один из ключевых принципов платформы.

", + "Управление", + ), + ], + "cta_sub": "

Запланируйте консультацию, и мы покажем наиболее разумный путь для вашей компании.

", + "no_cc": "Без обязательств", + "pages": { + "about": { + "headline": "Кто такая MandelBlog и как мы работаем", + "sub": "

MandelBlog помогает сервисным компаниям и небольшим командам запускать убедительные сайты, ориентированные на конверсию и удобное управление.

", + "features_title": "На что мы обращаем внимание", + "features_sub": "

Лучше всего мы работаем с командами, которым нужны ясность, скорость и редакционный контроль.

", + "features": [ + ( + "people", + "Кому мы помогаем", + "

Сервисным компаниям, студиям и небольшим командам с понятным предложением и реалистичным планом.

", + ), + ( + "diagram-3", + "Как мы работаем", + "

Сначала уточняем цель и сообщение, затем собираем сайт на проверенных блоках и передаём рабочую основу.

", + ), + ( + "shield-check", + "Почему это работает иначе", + "

Это не шаблон и не чёрный ящик, а понятная структура, которую можно развивать дальше.

", + ), + ( + "person-badge", + "Небольшая команда, прямой контакт", + "

Вы общаетесь напрямую с людьми, которые выполняют работу и превращают решения в страницы.

", + ), + ], + "steps_badge": "Подход", + "steps_heading": "Наш подход в 3 шага", + "steps_sub": "Сфокусированный проект, понятные решения и затем сайт, с которым вашей команде удобно работать.", + "steps": [ + ( + "1", + "Вводная консультация", + "Определяем цели, контент и приоритеты.", + "chat-square-text", + ), + ( + "2", + "Разработка", + "Собираем страницы и уточняем контент вместе с вами.", + "layout-text-window", + ), + ( + "3", + "Запуск", + "Вы получаете сопровождение, передачу и понятный следующий шаг.", + "rocket", + ), + ], + "cta": "Хотите понять, подходит ли наш подход вашей компании?", + }, + "services": { + "headline": "Услуги для компаний, которым нужны ясность и качество", + "sub": "

Каждая услуга строится вокруг понятных решений, полезного контента и технической базы, которая может расти вместе с бизнесом.

", + "features_title": "Что мы даём", + "features_sub": "

Это не набор разрозненных модулей, а путь, который соответствует вашему этапу, команде и целям.

", + "features": [ + ( + "window", + "Стартовый сайт", + "

Для компаний, которым нужно быстро выйти онлайн с профессиональной основой.

", + ), + ( + "briefcase", + "Бизнес-сайт", + "

Для организаций с несколькими услугами, кейсами или более сложной структурой предложения.

", + ), + ( + "cart-check", + "Внедрение вебшопа", + "

Для команд, которые хотят добавить онлайн-продажи, не теряя контроля над структурой и технологией.

", + ), + ( + "wrench-adjustable", + "Поддержка и рост", + "

Для организаций, которым нужны обслуживание, стабильность и постоянное улучшение.

", + ), + ], + "cta": "Сомневаетесь, какой пакет подходит именно вам?", + }, + "projects": { + "headline": "Проекты, где структура, контент и технология работают вместе", + "sub": "

Наши проекты создаются так, чтобы вызывать доверие, ясно представлять предложение и оставаться удобными после запуска.

", + "features_title": "Что видно в наших проектах", + "features_sub": "

Мы не гонимся за поверхностными эффектами, а работаем на ясность и полезность.

", + "features": [ + ( + "diagram-3", + "Понятная структура страниц", + "

Посетители быстро понимают, где они находятся и какой следующий шаг логичен.

", + ), + ( + "pencil-square", + "Простое управление", + "

Команды могут обновлять тексты, изображения и страницы без лишних обходных путей.

", + ), + ( + "graph-up-arrow", + "Ориентация на заявки", + "

Контакт и конверсия заметны как в структуре, так и в содержании.

", + ), + ], + "cta": "Хотите представить следующий проект более профессионально?", + }, + "contact": { + "headline": "Давайте превратим ваш запрос в понятный план", + "sub": "

Кратко опишите, что вам нужно. Вы получите практичный ответ с реалистичными следующими шагами.

", + "features_title": "По каким вопросам можно обратиться", + "features_sub": "

Выберите путь, который лучше соответствует вашему вопросу или текущему этапу.

", + "features": [ + ( + "rocket", + "Новый проект", + "

Для нового сайта, репозиционирования или полной переработки.

", + ), + ( + "briefcase", + "Выбор пакета", + "

Чтобы понять, какой пакет или структура подойдут лучше всего.

", + ), + ( + "tools", + "Поддержка или развитие", + "

Для технической помощи, последующих улучшений или нового этапа после запуска.

", + ), + ], + "form_title": "Кратко опишите, что вам нужно", + "form_sub": "

Мы отвечаем по существу и без навязчивых продаж.

", + "form_fields": [ + ("text", "Имя", "Ваше имя"), + ("email", "E-mail", "name@company.ru"), + ("company", "Компания", "Название компании"), + ("message", "Вопрос или проект", "С чем вам нужна помощь?"), + ], + "benefits_title": "Что вы получите", + "benefits": [ + "Ответ в течение 24 часов", + "15-минутная вводная консультация", + "Без обязательств", + ], + "privacy": "

Мы используем ваши данные только для ответа на этот запрос.

", + "cta": "Готовы сделать первый шаг?", + }, + "process": { + "headline": "Как мы работаем: понятные шаги и ясные решения", + "sub": "

Мы держим проект под контролем: вы понимаете, что происходит, что нужно предоставить и к какому результату мы идём.

", + "features_title": "Как строится работа", + "features_sub": "

Коротко, понятно и без лишнего шума.

", + "features": [ + ( + "chat-square-text", + "1. Вводная консультация", + "

Определяем цель, аудиторию, контент и то, чем ваша команда хочет управлять сама.

", + ), + ( + "layout-text-window", + "2. Разработка", + "

Превращаем структуру, контент и дизайн в понятную архитектуру страниц.

", + ), + ( + "rocket", + "3. Запуск", + "

После согласования сайт выходит в эфир, а команда получает удобную базу для дальнейшей работы.

", + ), + ( + "graph-up-arrow", + "4. Дальнейшее развитие", + "

При необходимости продолжаем улучшать сайт, опираясь на поведение пользователей, вопросы и новые задачи.

", + ), + ], + "cta": "Хотите применить этот подход и к вашему сайту?", + }, + }, +} + + +def _footer_stream_data( + locale: str, links: dict[str, str], page_title_map: dict[str, dict[str, str]] +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + cfg = FOOTER_CONTENT[locale] + footer = [ + block("about_us", {"heading": "MandelBlog Studio", "content": cfg["about"]}), + block( + "text", + { + "heading": cfg["links_heading"], + "content": f'

{page_title_map["about"][locale]}
{page_title_map["services"][locale]}
{page_title_map["projects"][locale]}
{page_title_map["contact"][locale]}

', + }, + ), + block("text", {"heading": cfg["support_heading"], "content": cfg["support"]}), + ] + return footer, [block("text", cfg["mini"])] + + +def _home_body( + locale: str, urls: dict[str, str], page_title_map: dict[str, dict[str, str]] +) -> list[dict[str, Any]]: + cta = COMMON_CTA[locale] + cfg = HOME_COPY[locale] + return [ + block( + "saas_hero_banner", + { + "layout_width": "container", + "background_style": "light", + "layout": "split", + "badge_text": cfg["badge"], + "badge_url": urls["home"], + "headline": cfg["headline"], + "sub_headline": cfg["sub"], + "primary_cta_text": cta["primary"], + "primary_cta_url": urls["contact"], + "secondary_cta_text": cta["secondary"], + "secondary_cta_url": urls["services"], + "hero_image": 1, + "video_url": "", + "stats": [ + item({"value": value, "label": label}) + for value, label in cfg["stats"] + ], + "customer_logos_title": cfg["logos"], + }, + ), + block( + "saas_features", + { + "layout_width": "container", + "background_style": "light", + "layout": "grid", + "section_title": cfg["features_title"], + "section_subtitle": cfg["features_sub"], + "features": [ + item( + { + "icon": icon, + "icon_image": None, + "title": title, + "description": desc, + "link_text": link_text or cta["secondary"], + "link_url": urls[link_key], + "highlight": highlight, + } + ) + for icon, title, desc, link_key, link_text, highlight in cfg[ + "features" + ] + ], + "columns": "2", + }, + ), + block( + "saas_pricing", + { + "layout_width": "container", + "background_style": "light", + "layout": "cards", + "section_title": cfg["pricing_title"], + "section_subtitle": cfg["pricing_sub"], + "show_annual_toggle": False, + "annual_discount_text": "", + "tiers": [ + item( + { + "name": page_title_map[key][locale], + "description": desc, + "price_monthly": None, + "price_annual": None, + "price_suffix": "", + "custom_price_text": "Op offertebasis" + if locale == "nl" + else "Custom quote", + "features": [ + item({"text": text, "included": True, "tooltip": ""}) + for text in features + ], + "cta_text": cta["primary"], + "cta_url": urls["contact"], + "cta_style": "primary" if featured else "secondary", + "is_featured": featured, + "featured_label": label, + } + ) + for key, (desc, features, featured, label) in cfg["tiers"].items() + ], + "footer_text": cfg["pricing_footer"], + }, + ), + block( + "saas_testimonials", + { + "layout_width": "container", + "background_style": "light", + "layout": "cards", + "section_title": cfg["testimonials_title"], + "section_subtitle": cfg["testimonials_sub"], + "testimonials": [ + item( + { + "quote": quote, + "author_name": name, + "author_title": title, + "author_photo": None, + "company_logo": None, + "rating": 0, + } + ) + for quote, name, title in cfg["testimonials"] + ], + "customer_logos": [], + "aggregate_rating": "", + "aggregate_source": "", + }, + ), + block( + "saas_faq", + { + "layout_width": "container", + "background_style": "light", + "layout": "accordion", + "section_title": cfg["faq_title"], + "section_subtitle": cfg["faq_sub"], + "faqs": [ + item({"question": question, "answer": answer, "category": category}) + for question, answer, category in cfg["faqs"] + ], + "show_contact_cta": "card", + "contact_cta_text": cta["primary"], + "contact_cta_url": urls["contact"], + }, + ), + block( + "saas_cta_footer", + { + "layout_width": "container", + "background_style": "light", + "layout": "banner", + "headline": cfg["cta_headline"], + "subheadline": cfg["cta_sub"], + "primary_cta_text": cta["primary"], + "primary_cta_url": urls["contact"], + "secondary_cta_text": cta["secondary"], + "secondary_cta_url": urls["services"], + "background_image": 1, + "side_image": 1, + "show_no_credit_card": "with-icon", + "no_credit_card_text": cfg["no_cc"], + }, + ), + ] + + +def _standard_body( + locale: str, page_key: str, urls: dict[str, str] +) -> list[dict[str, Any]]: + cta = COMMON_CTA[locale] + common = STANDARD_COPY[locale] + cfg = common["pages"][page_key] + blocks = [ + block( + "saas_hero_banner", + { + "layout_width": "container", + "background_style": "light", + "layout": "split", + "badge_text": "MANDELBLOG STUDIO" + if page_key != "services" + else ( + "Diensten" + if locale == "nl" + else "Services" + if locale == "en" + else "Dienstleistungen" + if locale == "de" + else "Services" + if locale == "fr" + else "Servicios" + if locale == "es" + else "Servizi" + if locale == "it" + else "Serviços" + if locale == "pt" + else "Услуги" + ), + "badge_url": urls[page_key], + "headline": cfg["headline"], + "sub_headline": cfg["sub"], + "primary_cta_text": cta["primary"], + "primary_cta_url": urls["contact"], + "secondary_cta_text": cta["secondary"], + "secondary_cta_url": urls["services"], + "hero_image": 1 if page_key != "process" else 24, + "video_url": "", + "stats": [], + "customer_logos_title": "", + }, + ), + block( + "saas_features", + { + "layout_width": "container", + "background_style": "light", + "layout": "grid", + "section_title": cfg["features_title"], + "section_subtitle": cfg["features_sub"], + "features": [ + item( + { + "icon": icon, + "icon_image": None, + "title": title, + "description": desc, + "link_text": cta["primary"] + if page_key in {"contact", "about"} + else cta["secondary"], + "link_url": urls["contact"] + if page_key in {"contact", "about"} + else urls["services"], + "highlight": "none", + } + ) + for icon, title, desc in cfg["features"] + ], + "columns": "2" if len(cfg["features"]) <= 4 else "3", + }, + ), + ] + if page_key == "about": + blocks.append( + block( + "saas_animated_stats", + { + "layout_width": "container", + "background_style": "light", + "layout": "cards-grid", + "badge": cfg["steps_badge"], + "heading": cfg["steps_heading"], + "subheading": cfg["steps_sub"], + "stats": [ + item( + { + "value": value, + "prefix": None, + "suffix": "", + "label": label, + "description": desc, + "icon": icon, + "highlight": False, + } + ) + for value, label, desc, icon in cfg["steps"] + ], + "animation_duration": 1800, + "animation_easing": "ease-out", + "start_on_scroll": True, + "show_logos": False, + "logos_heading": "", + "company_logos": [], + }, + ) + ) + if page_key == "contact": + blocks.append( + block( + "saas_demo_request", + { + "layout_width": "container", + "background_style": "light", + "layout": "split", + "section_title": cfg["form_title"], + "section_subtitle": cfg["form_sub"], + "form_fields": [ + item( + { + "field_type": field_type, + "label": label, + "placeholder": placeholder, + "required": field_type != "message", + } + ) + for field_type, label, placeholder in cfg["form_fields"] + ], + "submit_button_text": cta["primary"], + "form_action_url": urls["contact"], + "benefits_title": cfg["benefits_title"], + "benefits": [item(text) for text in cfg["benefits"]], + "side_image": 1, + "privacy_text": cfg["privacy"], + }, + ) + ) + blocks.append( + block( + "saas_faq", + { + "layout_width": "container", + "background_style": "light", + "layout": "accordion", + "section_title": common["faq_title"], + "section_subtitle": common["faq_sub"], + "faqs": [ + item({"question": q, "answer": a, "category": c}) + for q, a, c in common["faq_items"] + ], + "show_contact_cta": "card", + "contact_cta_text": cta["primary"], + "contact_cta_url": urls["contact"], + }, + ) + ) + blocks.append( + block( + "saas_cta_footer", + { + "layout_width": "container", + "background_style": "light", + "layout": "banner", + "headline": cfg["cta"], + "subheadline": common["cta_sub"], + "primary_cta_text": cta["primary"], + "primary_cta_url": urls["contact"], + "secondary_cta_text": cta["secondary"], + "secondary_cta_url": urls["services"], + "background_image": 1, + "side_image": 1, + "show_no_credit_card": "with-icon", + "no_credit_card_text": common["no_cc"], + }, + ) + ) + return blocks + + +def _service_body(locale: str, kind: str, urls: dict[str, str]) -> list[dict[str, Any]]: + cta = COMMON_CTA[locale] + cfg = SERVICE_COPY[locale][kind] + common = SERVICE_COMMON[locale] + return [ + block( + "saas_hero_banner", + { + "layout_width": "container", + "background_style": "light", + "layout": "split", + "badge_text": "PAKKET" + if locale == "nl" + else "PACKAGE" + if locale == "en" + else "PAKET" + if locale == "de" + else "OFFRE" + if locale == "fr" + else "PAQUETE" + if locale == "es" + else "PACCHETTO" + if locale == "it" + else "PACOTE" + if locale == "pt" + else "ПАКЕТ", + "badge_url": urls[kind], + "headline": cfg["title"], + "sub_headline": f"

{cfg['audience']}

", + "primary_cta_text": cta["primary"], + "primary_cta_url": urls["contact"], + "secondary_cta_text": cta["secondary"], + "secondary_cta_url": urls["services"], + "hero_image": 23, + "video_url": "", + "stats": [ + item({"value": cfg["duration"], "label": common["timeline_label"]}), + item( + { + "value": common["communication"], + "label": "Communicatie" + if locale == "nl" + else "Communication" + if locale in {"en", "fr"} + else "Kommunikation" + if locale == "de" + else "Comunicación" + if locale == "es" + else "Comunicazione" + if locale == "it" + else "Comunicação" + if locale == "pt" + else "Коммуникация", + } + ), + item( + { + "value": common["intro"], + "label": "Kennismaking" + if locale == "nl" + else "Introduction" + if locale == "en" + else "Einführung" + if locale == "de" + else "Introduction" + if locale == "fr" + else "Inicio" + if locale == "es" + else "Introduzione" + if locale == "it" + else "Introdução" + if locale == "pt" + else "Вводный этап", + } + ), + ], + "customer_logos_title": "", + }, + ), + block( + "saas_features", + { + "layout_width": "container", + "background_style": "light", + "layout": "grid", + "section_title": common["section_what"], + "section_subtitle": common["section_what_sub"], + "features": [ + item( + { + "icon": icon, + "icon_image": None, + "title": title, + "description": desc, + "link_text": cta["secondary"], + "link_url": urls["services"], + "highlight": "none", + } + ) + for icon, title, desc in cfg["what"] + ], + "columns": "3", + }, + ), + block( + "saas_features", + { + "layout_width": "container", + "background_style": "light", + "layout": "grid", + "section_title": common["section_outcomes"], + "section_subtitle": common["section_outcomes_sub"], + "features": [ + item( + { + "icon": icon, + "icon_image": None, + "title": title, + "description": desc, + "link_text": cta["primary"], + "link_url": urls["contact"], + "highlight": "none", + } + ) + for icon, title, desc in cfg["outcomes"] + ], + "columns": "3", + }, + ), + block( + "saas_demo_request", + { + "layout_width": "container", + "background_style": "light", + "layout": "split", + "section_title": common["section_choose"], + "section_subtitle": common["section_choose_sub"], + "form_fields": [ + item( + { + "field_type": "text", + "label": common["name_label"], + "placeholder": common["name_placeholder"], + "required": True, + } + ), + item( + { + "field_type": "email", + "label": common["email_label"], + "placeholder": common["email_placeholder"], + "required": True, + } + ), + item( + { + "field_type": "company", + "label": common["company_label"], + "placeholder": common["company_placeholder"], + "required": True, + } + ), + item( + { + "field_type": "message", + "label": common["message_label"], + "placeholder": common["message_placeholder"], + "required": False, + } + ), + ], + "submit_button_text": cta["primary"], + "form_action_url": urls["contact"], + "benefits_title": common["choose_title"], + "benefits": [item(text) for text in cfg["choose"]], + "side_image": 23, + "privacy_text": common["privacy"], + }, + ), + block( + "saas_cta_footer", + { + "layout_width": "container", + "background_style": "light", + "layout": "banner", + "headline": cfg["title"], + "subheadline": common["cta_sub"], + "primary_cta_text": cta["primary"], + "primary_cta_url": urls["contact"], + "secondary_cta_text": cta["secondary"], + "secondary_cta_url": urls["services"], + "background_image": 1, + "side_image": 1, + "show_no_credit_card": "with-icon", + "no_credit_card_text": common["no_cc"], + }, + ), + ] + + +def body_for( + locale: str, + page_key: str, + urls: dict[str, str], + page_title_map: dict[str, dict[str, str]], +) -> list[dict[str, Any]]: + if locale == "nl" and page_key not in { + "home", + "about", + "services", + "projects", + "contact", + "process", + "starter", + "business", + "webshop", + "support", + }: + raise KeyError(page_key) + if page_key == "home": + return _home_body(locale, urls, page_title_map) + if page_key in {"about", "services", "projects", "contact", "process"}: + return _standard_body(locale, page_key, urls) + if page_key in {"starter", "business", "webshop", "support"}: + return _service_body(locale, page_key, urls) + raise KeyError(page_key) + + +def footer_stream_data( + locale: str, links: dict[str, str], page_title_map: dict[str, dict[str, str]] +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + return _footer_stream_data(locale, links, page_title_map) + + +SERVICE_COPY["es"] = { + "starter": { + "title": "Sitio web inicial", + "audience": "Para empresas y equipos pequeños que necesitan una presencia profesional rápida y clara.", + "what": [ + ( + "layout-text-window", + "¿Para quién es?", + "

Para negocios con una oferta clara que necesitan una primera impresión profesional sin un proyecto pesado.

", + ), + ( + "window", + "Qué obtiene", + "

Páginas clave, navegación clara y un editor que su equipo puede usar para gestionar contenido.

", + ), + ( + "graph-up-arrow", + "Qué aporta", + "

Una base profesional que ayuda a los visitantes a entender qué hace y cómo contactar.

", + ), + ], + "outcomes": [ + ( + "shield-check", + "Base digital clara", + "

Sin elementos innecesarios, solo un sitio que genera confianza.

", + ), + ( + "people", + "Gestión sencilla", + "

Su equipo puede hacer cambios sin depender siempre de un desarrollador.

", + ), + ( + "rocket", + "Lanzamiento rápido", + "

Adecuado como primer sitio profesional o como sustitución de una web desactualizada.

", + ), + ], + "choose": [ + "Quiere una presencia profesional en poco tiempo.", + "Necesita sobre todo páginas clave y navegación clara.", + "Quiere actualizar textos e imágenes internamente.", + ], + "duration": "Plazo medio: de 2 a 4 semanas", + }, + "business": { + "title": "Sitio web empresarial", + "audience": "Para empresas de servicios y equipos que necesitan presentar con claridad varias ofertas, casos o recorridos de conversión.", + "what": [ + ( + "briefcase", + "¿Para quién es?", + "

Para organizaciones que necesitan más estructura, más profundidad y un mejor recorrido hacia la solicitud.

", + ), + ( + "layout-text-window", + "Qué obtiene", + "

Más estructura de páginas, espacio para casos y una base favorable al SEO que puede crecer de forma lógica.

", + ), + ( + "graph-up-arrow", + "Qué aporta", + "

Un sitio que explica mejor su oferta y lleva a los visitantes de forma más directa hacia el contacto.

", + ), + ], + "outcomes": [ + ( + "diagram-3", + "Más claridad", + "

Servicios, casos y experiencia tienen cada uno su lugar lógico.

", + ), + ( + "search", + "Mejor visibilidad", + "

La estructura está preparada para mejor contenido y una base favorable al SEO.

", + ), + ( + "people", + "Solicitudes más sólidas", + "

Los visitantes entienden más rápido qué oferta y qué camino les encajan.

", + ), + ], + "choose": [ + "Tiene varios servicios o varios públicos.", + "Quiere mostrar mejor casos, experiencia y pruebas.", + "Necesita más estructura que la que ofrece un sitio inicial.", + ], + "duration": "Plazo medio: de 2 a 4 semanas", + }, + "webshop": { + "title": "Implementación webshop", + "audience": "Para organizaciones que quieren añadir venta online sin terminar con una tienda genérica.", + "what": [ + ( + "cart-check", + "¿Para quién es?", + "

Para empresas que quieren vender online manteniendo control sobre presentación, checkout y gestión.

", + ), + ( + "credit-card", + "Qué obtiene", + "

Una estructura de comercio con catálogo, checkout y base escalable para gestionar pedidos.

", + ), + ( + "graph-up-arrow", + "Qué aporta", + "

Un entorno de venta que encaja con su marca y no se siente como una plantilla demo.

", + ), + ], + "outcomes": [ + ( + "window", + "Mejor presentación", + "

Los productos y categorías se muestran con una estructura clara y creíble.

", + ), + ( + "shield-check", + "Tecnología estable", + "

Pagos y pedidos se apoyan en una base técnica fácil de mantener.

", + ), + ( + "rocket", + "Preparado para crecer", + "

La estructura comercial puede crecer con su catálogo y sus procesos.

", + ), + ], + "choose": [ + "Quiere combinar venta online con un sitio corporativo.", + "Necesita control sobre estructura y tecnología.", + "No busca una tienda basada en un tema genérico.", + ], + "duration": "Plazo medio: de 3 a 6 semanas", + }, + "support": { + "title": "Mantenimiento y crecimiento", + "audience": "Para equipos que quieren mantener estable su web o tienda y mejorarla de forma continua.", + "what": [ + ( + "tools", + "¿Para quién es?", + "

Para organizaciones que no quieren encargarse solas de la supervisión, los fallos y la planificación técnica.

", + ), + ( + "shield-check", + "Qué obtiene", + "

Mantenimiento, actualizaciones, monitorización y soluciones técnicas con un ritmo de trabajo claro.

", + ), + ( + "graph-up-arrow", + "Qué aporta", + "

Más tranquilidad, menos sorpresas técnicas y más margen para mejorar con criterio.

", + ), + ], + "outcomes": [ + ( + "activity", + "Menos interrupciones", + "

Los problemas técnicos se detectan y se resuelven con mayor rapidez.

", + ), + ( + "clipboard-data", + "Mejora continua", + "

Mejoramos rendimiento, contenido y conversión paso a paso.

", + ), + ( + "people", + "Ritmo estable", + "

Sabe cuándo se realiza el mantenimiento y qué tiene prioridad.

", + ), + ], + "choose": [ + "Quiere un socio estable para el mantenimiento técnico.", + "Su sitio necesita mejoras continuas y no una reconstrucción completa.", + "Quiere reaccionar más rápido ante incidencias o ampliaciones.", + ], + "duration": "Respuesta en 24 horas", + }, +} +SERVICE_COPY["it"] = { + "starter": { + "title": "Sito starter", + "audience": "Per aziende e piccoli team che hanno bisogno di una presenza professionale rapida e chiara.", + "what": [ + ( + "layout-text-window", + "Per chi è?", + "

Per attività con un’offerta chiara che hanno bisogno di una prima impressione professionale senza un progetto pesante.

", + ), + ( + "window", + "Cosa riceve", + "

Pagine essenziali, navigazione chiara e un editor che il team può usare per gestire i contenuti.

", + ), + ( + "graph-up-arrow", + "Cosa porta", + "

Una base professionale che aiuta i visitatori a capire rapidamente cosa fate e come contattarvi.

", + ), + ], + "outcomes": [ + ( + "shield-check", + "Base online chiara", + "

Nessun elemento superfluo, solo un sito che trasmette fiducia.

", + ), + ( + "people", + "Gestione semplice", + "

Il team può aggiornare il sito senza dipendere sempre da uno sviluppatore.

", + ), + ( + "rocket", + "Pubblicazione rapida", + "

Adatto come primo sito professionale o come sostituzione di un sito datato.

", + ), + ], + "choose": [ + "Volete andare online in tempi rapidi con un’immagine professionale.", + "Avete soprattutto bisogno di pagine essenziali e navigazione chiara.", + "Volete poter aggiornare testi e immagini internamente.", + ], + "duration": "Tempistiche medie: da 2 a 4 settimane", + }, + "business": { + "title": "Sito business", + "audience": "Per aziende di servizi e team che devono presentare chiaramente più offerte, casi studio o percorsi di conversione.", + "what": [ + ( + "briefcase", + "Per chi è?", + "

Per organizzazioni che hanno bisogno di più struttura, più profondità e un percorso verso il contatto più forte.

", + ), + ( + "layout-text-window", + "Cosa riceve", + "

Più struttura di pagina, spazio per i casi studio e una base favorevole alla SEO che può crescere in modo logico.

", + ), + ( + "graph-up-arrow", + "Cosa porta", + "

Un sito che spiega meglio l’offerta e accompagna i visitatori verso il contatto in modo più diretto.

", + ), + ], + "outcomes": [ + ( + "diagram-3", + "Più chiarezza", + "

Servizi, casi studio ed expertise hanno ciascuno il proprio spazio logico.

", + ), + ( + "search", + "Maggiore visibilità", + "

La struttura è pronta per contenuti migliori e una base SEO-friendly.

", + ), + ( + "people", + "Richieste più solide", + "

I visitatori capiscono più rapidamente quale offerta e quale percorso fanno per loro.

", + ), + ], + "choose": [ + "Avete più servizi o più pubblici.", + "Volete mostrare meglio casi, competenze e prove.", + "Vi serve più struttura di quella offerta da un sito starter.", + ], + "duration": "Tempistiche medie: da 2 a 4 settimane", + }, + "webshop": { + "title": "Implementazione webshop", + "audience": "Per organizzazioni che vogliono aggiungere la vendita online senza finire in un negozio generico.", + "what": [ + ( + "cart-check", + "Per chi è?", + "

Per aziende che vogliono vendere online mantenendo il controllo su presentazione, checkout e gestione.

", + ), + ( + "credit-card", + "Cosa riceve", + "

Una struttura e-commerce con catalogo, checkout e base scalabile per la gestione ordini.

", + ), + ( + "graph-up-arrow", + "Cosa porta", + "

Un ambiente di vendita coerente con il vostro marchio e non con l’effetto template demo.

", + ), + ], + "outcomes": [ + ( + "window", + "Presentazione più forte", + "

Prodotti e categorie sono organizzati in una struttura chiara e credibile.

", + ), + ( + "shield-check", + "Tecnologia stabile", + "

Pagamenti e ordini poggiano su una base tecnica facile da mantenere.

", + ), + ( + "rocket", + "Pronto a crescere", + "

La struttura commerciale può evolvere insieme al catalogo e ai processi.

", + ), + ], + "choose": [ + "Volete unire vendita online e sito aziendale.", + "Avete bisogno di controllo su struttura e tecnologia.", + "Non state cercando un negozio basato su un tema generico.", + ], + "duration": "Tempistiche medie: da 3 a 6 settimane", + }, + "support": { + "title": "Manutenzione e crescita", + "audience": "Per team che vogliono mantenere stabile il sito o lo shop e migliorarlo in modo continuo.", + "what": [ + ( + "tools", + "Per chi è?", + "

Per organizzazioni che non vogliono gestire da sole monitoraggio, guasti e pianificazione tecnica.

", + ), + ( + "shield-check", + "Cosa riceve", + "

Manutenzione, aggiornamenti, monitoraggio e interventi tecnici con un ritmo di lavoro chiaro.

", + ), + ( + "graph-up-arrow", + "Cosa porta", + "

Più tranquillità, meno sorprese tecniche e più spazio per miglioramenti utili.

", + ), + ], + "outcomes": [ + ( + "activity", + "Meno interruzioni", + "

I problemi tecnici vengono individuati e risolti più rapidamente.

", + ), + ( + "clipboard-data", + "Miglioramento continuo", + "

Lavoriamo passo dopo passo su prestazioni, contenuti e conversione.

", + ), + ( + "people", + "Ritmo stabile", + "

Sapete quando avviene la manutenzione e quali sono le priorità.

", + ), + ], + "choose": [ + "Volete un partner stabile per la manutenzione tecnica.", + "Il vostro sito ha bisogno di miglioramenti continui, non di un rifacimento completo.", + "Volete reagire più velocemente a problemi o nuove esigenze.", + ], + "duration": "Risposta entro 24 ore", + }, +} +SERVICE_COPY["pt"] = { + "starter": { + "title": "Website inicial", + "audience": "Para empresas e pequenas equipas que precisam de uma presença profissional rápida e clara.", + "what": [ + ( + "layout-text-window", + "Para quem é?", + "

Para negócios com uma oferta clara que precisam de uma primeira impressão profissional sem um projeto pesado.

", + ), + ( + "window", + "O que recebe", + "

Páginas principais, navegação clara e um editor que a equipa pode usar para gerir conteúdo.

", + ), + ( + "graph-up-arrow", + "O que entrega", + "

Uma base profissional que ajuda os visitantes a perceber rapidamente o que faz e como entrar em contacto.

", + ), + ], + "outcomes": [ + ( + "shield-check", + "Base online clara", + "

Sem elementos desnecessários, apenas um website que transmite confiança.

", + ), + ( + "people", + "Gestão simples", + "

A sua equipa pode atualizar o site sem depender sempre de um programador.

", + ), + ( + "rocket", + "Lançamento rápido", + "

Indicado como primeiro website profissional ou como substituição de um site desatualizado.

", + ), + ], + "choose": [ + "Quer uma presença profissional rapidamente.", + "Precisa sobretudo de páginas principais e navegação clara.", + "Quer atualizar textos e imagens internamente.", + ], + "duration": "Prazo médio: 2 a 4 semanas", + }, + "business": { + "title": "Site empresarial", + "audience": "Para empresas de serviços e equipas que precisam de apresentar claramente várias ofertas, casos ou percursos de conversão.", + "what": [ + ( + "briefcase", + "Para quem é?", + "

Para organizações que precisam de mais estrutura, mais profundidade e um percurso de contacto mais forte.

", + ), + ( + "layout-text-window", + "O que recebe", + "

Mais estrutura de páginas, espaço para casos e uma base favorável a SEO que pode crescer de forma lógica.

", + ), + ( + "graph-up-arrow", + "O que entrega", + "

Um website que explica melhor a sua oferta e orienta os visitantes de forma mais direta para o contacto.

", + ), + ], + "outcomes": [ + ( + "diagram-3", + "Mais clareza", + "

Serviços, casos e especialização têm cada um o seu lugar lógico.

", + ), + ( + "search", + "Maior visibilidade", + "

A estrutura está preparada para melhor conteúdo e uma base favorável a SEO.

", + ), + ( + "people", + "Pedidos mais fortes", + "

Os visitantes percebem mais depressa que oferta e que percurso lhes fazem sentido.

", + ), + ], + "choose": [ + "Tem vários serviços ou vários públicos.", + "Quer mostrar melhor casos, experiência e prova.", + "Precisa de mais estrutura do que um website inicial oferece.", + ], + "duration": "Prazo médio: 2 a 4 semanas", + }, + "webshop": { + "title": "Implementação de webshop", + "audience": "Para organizações que querem acrescentar vendas online sem cair numa loja genérica.", + "what": [ + ( + "cart-check", + "Para quem é?", + "

Para empresas que querem vender online mantendo controlo sobre apresentação, checkout e gestão.

", + ), + ( + "credit-card", + "O que recebe", + "

Uma estrutura de comércio com catálogo, checkout e base escalável para gerir encomendas.

", + ), + ( + "graph-up-arrow", + "O que entrega", + "

Um ambiente de venda alinhado com a sua marca e não com aparência de demo.

", + ), + ], + "outcomes": [ + ( + "window", + "Melhor apresentação", + "

Produtos e categorias são organizados numa estrutura clara e credível.

", + ), + ( + "shield-check", + "Tecnologia estável", + "

Pagamentos e encomendas assentam numa base técnica fácil de manter.

", + ), + ( + "rocket", + "Preparado para crescer", + "

A estrutura comercial pode evoluir com o catálogo e os processos.

", + ), + ], + "choose": [ + "Quer combinar vendas online com um website empresarial.", + "Precisa de controlo sobre estrutura e tecnologia.", + "Não procura uma loja baseada num tema genérico.", + ], + "duration": "Prazo médio: 3 a 6 semanas", + }, + "support": { + "title": "Manutenção & crescimento", + "audience": "Para equipas que querem manter o website ou a loja estáveis e melhorá-los de forma contínua.", + "what": [ + ( + "tools", + "Para quem é?", + "

Para organizações que não querem tratar sozinhas de monitorização, falhas e planeamento técnico.

", + ), + ( + "shield-check", + "O que recebe", + "

Manutenção, atualizações, monitorização e soluções técnicas com um ritmo de trabalho claro.

", + ), + ( + "graph-up-arrow", + "O que entrega", + "

Mais tranquilidade, menos surpresas técnicas e mais espaço para melhorias úteis.

", + ), + ], + "outcomes": [ + ( + "activity", + "Menos interrupções", + "

Os problemas técnicos são detetados e resolvidos mais rapidamente.

", + ), + ( + "clipboard-data", + "Melhoria contínua", + "

Trabalhamos passo a passo em desempenho, conteúdo e conversão.

", + ), + ( + "people", + "Ritmo estável", + "

Sabe quando a manutenção acontece e o que tem prioridade.

", + ), + ], + "choose": [ + "Quer um parceiro estável para manutenção técnica.", + "O seu website precisa de melhorias contínuas e não de uma reconstrução completa.", + "Quer reagir mais depressa a problemas ou novas necessidades.", + ], + "duration": "Resposta em 24 horas", + }, +} +SERVICE_COPY["ru"] = { + "starter": { + "title": "Стартовый сайт", + "audience": "Для компаний и небольших команд, которым нужен быстрый и профессиональный онлайн-старт.", + "what": [ + ( + "layout-text-window", + "Для кого это?", + "

Для бизнеса с понятным предложением, которому нужен профессиональный первый сайт без тяжёлого проекта.

", + ), + ( + "window", + "Что входит", + "

Основные страницы, понятная навигация и редактор, которым ваша команда сможет пользоваться сама.

", + ), + ( + "graph-up-arrow", + "Что это даёт", + "

Профессиональную основу, которая помогает посетителям быстро понять ваше предложение и способ связи.

", + ), + ], + "outcomes": [ + ( + "shield-check", + "Понятная онлайн-база", + "

Без лишних элементов, только сайт, который вызывает доверие.

", + ), + ( + "people", + "Простое управление", + "

Ваша команда сможет вносить изменения без постоянной зависимости от разработчика.

", + ), + ( + "rocket", + "Быстрый запуск", + "

Подходит как первый профессиональный сайт или как замена устаревшего решения.

", + ), + ], + "choose": [ + "Вам нужно быстро выглядеть профессионально онлайн.", + "Вам нужны прежде всего основные страницы и понятная навигация.", + "Вы хотите самостоятельно обновлять тексты и изображения.", + ], + "duration": "Средний срок: 2–4 недели", + }, + "business": { + "title": "Бизнес-сайт", + "audience": "Для сервисных компаний и команд, которым нужно ясно представить несколько направлений, кейсов или конверсионных путей.", + "what": [ + ( + "briefcase", + "Для кого это?", + "

Для организаций, которым нужна более сильная структура, большая глубина и более уверенный путь к заявке.

", + ), + ( + "layout-text-window", + "Что входит", + "

Более развитая структура страниц, место для кейсов и SEO-дружественная база, которая может логично расти.

", + ), + ( + "graph-up-arrow", + "Что это даёт", + "

Сайт, который лучше объясняет ваше предложение и ведёт посетителей к контакту более прямым путём.

", + ), + ], + "outcomes": [ + ( + "diagram-3", + "Больше ясности", + "

Услуги, кейсы и экспертиза получают свои понятные места.

", + ), + ( + "search", + "Лучшая видимость", + "

Структура подготовлена для сильного контента и SEO-дружественной базы.

", + ), + ( + "people", + "Более сильные заявки", + "

Посетители быстрее понимают, какое предложение и какой путь подходят именно им.

", + ), + ], + "choose": [ + "У вас несколько услуг или аудиторий.", + "Вы хотите лучше показать кейсы, экспертизу и подтверждение качества.", + "Вам нужна более развитая структура, чем у стартового сайта.", + ], + "duration": "Средний срок: 2–4 недели", + }, + "webshop": { + "title": "Внедрение вебшопа", + "audience": "Для компаний, которые хотят добавить онлайн-продажи, не превращая проект в типовой магазин.", + "what": [ + ( + "cart-check", + "Для кого это?", + "

Для бизнеса, который хочет продавать онлайн и при этом контролировать подачу, checkout и управление.

", + ), + ( + "credit-card", + "Что входит", + "

Коммерческая структура с каталогом, checkout и масштабируемой базой для обработки заказов.

", + ), + ( + "graph-up-arrow", + "Что это даёт", + "

Среду продаж, соответствующую вашему бренду, а не похожую на демо-шаблон.

", + ), + ], + "outcomes": [ + ( + "window", + "Более сильная подача", + "

Товары и категории получают понятную и убедительную структуру.

", + ), + ( + "shield-check", + "Стабильная технология", + "

Платежи и заказы опираются на поддерживаемую техническую базу.

", + ), + ( + "rocket", + "Готовность к росту", + "

Коммерческая часть может развиваться вместе с ассортиментом и процессами.

", + ), + ], + "choose": [ + "Вы хотите объединить онлайн-продажи и корпоративный сайт.", + "Вам нужен контроль над структурой и технологией.", + "Вы не ищете магазин на типовом шаблоне.", + ], + "duration": "Средний срок: 3–6 недель", + }, + "support": { + "title": "Поддержка и рост", + "audience": "Для команд, которые хотят держать сайт или магазин в стабильном состоянии и улучшать его постепенно.", + "what": [ + ( + "tools", + "Для кого это?", + "

Для организаций, которые не хотят самостоятельно заниматься мониторингом, сбоями и техническим планированием.

", + ), + ( + "shield-check", + "Что входит", + "

Обслуживание, обновления, мониторинг и технические решения в понятном рабочем ритме.

", + ), + ( + "graph-up-arrow", + "Что это даёт", + "

Больше спокойствия, меньше технических неожиданностей и больше пространства для полезных улучшений.

", + ), + ], + "outcomes": [ + ( + "activity", + "Меньше сбоев", + "

Технические проблемы замечаются и решаются быстрее.

", + ), + ( + "clipboard-data", + "Постоянное улучшение", + "

Мы шаг за шагом улучшаем производительность, контент и конверсию.

", + ), + ( + "people", + "Стабильный ритм", + "

Вы понимаете, когда проводится обслуживание и что находится в приоритете.

", + ), + ], + "choose": [ + "Вам нужен постоянный партнёр по технической поддержке.", + "Вашему сайту нужны постоянные улучшения, а не полная переделка.", + "Вы хотите быстрее реагировать на проблемы и новые задачи.", + ], + "duration": "Ответ в течение 24 часов", + }, +} diff --git a/mandelstudio/management/commands/apply_agency_website_refresh.py b/mandelstudio/management/commands/apply_agency_website_refresh.py index b2afb18..9f86fc7 100644 --- a/mandelstudio/management/commands/apply_agency_website_refresh.py +++ b/mandelstudio/management/commands/apply_agency_website_refresh.py @@ -10,6 +10,18 @@ from django.db import transaction from wagtail.blocks import StreamValue from wagtail.models import Locale, Page +from mandelstudio.management.commands._agency_content import ( + COMMON_CTA, + CTA_VARIANTS, + FOOTER_CONTENT, + NL_REPLACEMENTS, +) +from mandelstudio.management.commands._agency_content import ( + body_for as localized_body_for, +) +from mandelstudio.management.commands._agency_content import ( + footer_stream_data as localized_footer_stream_data, +) from mandelstudio.models import LocalizedFooterContent SOURCE_PAGE_IDS = { @@ -129,129 +141,6 @@ PAGE_TITLE_MAP = { }, } -COMMON_CTA = { - "nl": { - "primary": "Plan een kennismakingsgesprek", - "secondary": "Bekijk onze diensten", - }, - "en": {"primary": "Book an introductory call", "secondary": "View our services"}, - "de": {"primary": "Erstgespräch planen", "secondary": "Unsere Leistungen ansehen"}, - "fr": {"primary": "Planifier un échange initial", "secondary": "Voir nos services"}, - "es": { - "primary": "Planificar una reunión inicial", - "secondary": "Ver nuestros servicios", - }, - "it": { - "primary": "Prenota un colloquio conoscitivo", - "secondary": "Scopri i nostri servizi", - }, - "pt": { - "primary": "Agendar reunião introdutória", - "secondary": "Ver os nossos serviços", - }, - "ru": {"primary": "Запланировать вводный звонок", "secondary": "Посмотреть услуги"}, -} - -CTA_VARIANTS = { - "nl": [ - "Plan gratis gesprek", - "Plan intake", - "Plan dienstengesprek", - "Contact Support", - "Start jouw project", - "Vraag intake aan", - "Plan kennismaking", - "Bekijk diensten", - "Bekijk alle diensten", - "Vraag startergesprek aan", - "Plan startergesprek", - "Plan zakelijk gesprek", - "Start webshop traject", - "Vraag supportplan aan", - "Plan gratis kennismaking", - "Bekijk projectresultaten", - ], -} - -NL_REPLACEMENTS = { - "New": "Nieuw", - "Popular": "Populair", - "AI Search": "AI-zoekfunctie", - "custom blokken": "maatwerkblokken", - "monitoring-ready basis": "stabiele technische basis", - "Monitoring + fixes": "Monitoring en technische oplossingen", - "SEO-ready basis": "SEO-vriendelijke basis", - "Starter Website": "Starter-website", - "Business Website": "Zakelijke website", - "Support & Groei": "Onderhoud & groei", - "24u": "binnen 24 uur", - "24u Reactietijd": "Reactie binnen 24 uur", - "15m Intake call": "Intakegesprek van 15 minuten", - "100% Vrijblijvend": "Volledig vrijblijvend", - "Webshop Implementatie": "Webshop-implementatie", - "Doorlopend Verbetering": "Doorlopende verbetering", - "Monitoring-ready stack": "Stabiele technische basis", -} - -FOOTER_CONTENT = { - "nl": { - "about": "

MandelBlog bouwt websites voor dienstverleners, studio’s en kleine teams die professioneel online willen staan zonder template-ruis.

", - "links_heading": "Snelle links", - "support_heading": "Plan een gesprek", - "support": '

Plan een kennismakingsgesprek
info@mandelblog.com
Bekijk onze diensten

', - "mini": '

Contact - Diensten - Projecten - MandelBlog Studio

', - }, - "en": { - "about": "

MandelBlog builds websites for service businesses, studios and small teams that need a credible online presence without template clutter.

", - "links_heading": "Quick links", - "support_heading": "Book a call", - "support": '

Book an introductory call
info@mandelblog.com
View our services

', - "mini": '

Contact - Services - Projects - MandelBlog Studio

', - }, - "de": { - "about": "

MandelBlog entwickelt Websites für Dienstleister, Studios und kleine Teams, die professionell auftreten möchten, ohne Template-Ballast.

", - "links_heading": "Schnellzugriff", - "support_heading": "Gespräch planen", - "support": '

Erstgespräch planen
info@mandelblog.com
Leistungen ansehen

', - "mini": '

Kontakt - Dienstleistungen - Projekte - MandelBlog Studio

', - }, - "fr": { - "about": "

MandelBlog conçoit des sites pour les sociétés de services, les studios et les petites équipes qui veulent une présence crédible, sans surcharge de template.

", - "links_heading": "Accès rapides", - "support_heading": "Planifier un échange", - "support": '

Planifier un échange initial
info@mandelblog.com
Voir nos services

', - "mini": '

Contact - Services - Projets - MandelBlog Studio

', - }, - "es": { - "about": "

MandelBlog crea sitios web para empresas de servicios, estudios y pequeños equipos que quieren una presencia creíble sin aspecto de plantilla.

", - "links_heading": "Accesos rápidos", - "support_heading": "Planificar una reunión", - "support": '

Planificar una reunión inicial
info@mandelblog.com
Ver nuestros servicios

', - "mini": '

Contacto - Servicios - Proyectos - MandelBlog Studio

', - }, - "it": { - "about": "

MandelBlog realizza siti per aziende di servizi, studi e piccoli team che vogliono una presenza credibile senza l’effetto template.

", - "links_heading": "Link rapidi", - "support_heading": "Prenota un colloquio", - "support": '

Prenota un colloquio conoscitivo
info@mandelblog.com
Scopri i nostri servizi

', - "mini": '

Contatto - Servizi - Progetti - MandelBlog Studio

', - }, - "pt": { - "about": "

A MandelBlog cria sites para empresas de serviços, estúdios e pequenas equipas que precisam de uma presença credível sem aparência de template.

", - "links_heading": "Acessos rápidos", - "support_heading": "Agendar reunião", - "support": '

Agendar reunião introdutória
info@mandelblog.com
Ver os nossos serviços

', - "mini": '

Contacto - Serviços - Projetos - MandelBlog Studio

', - }, - "ru": { - "about": "

MandelBlog создаёт сайты для сервисных компаний, студий и небольших команд, которым нужен убедительный онлайн-образ без шаблонного шума.

", - "links_heading": "Быстрые ссылки", - "support_heading": "Назначить звонок", - "support": '

Запланировать вводный звонок
info@mandelblog.com
Посмотреть услуги

', - "mini": '

Контакт - Услуги - Проекты - MandelBlog Studio

', - }, -} - def uid() -> str: return str(uuid.uuid4()) @@ -1411,7 +1300,7 @@ class Command(BaseCommand): if hasattr(page, "body"): raw_data = list(page.body.raw_data) - if language_code == "nl" and key in { + if language_code in COMMON_CTA and key in { "home", "about", "services", @@ -1425,7 +1314,9 @@ class Command(BaseCommand): }: page.body = StreamValue( page.body.stream_block, - nl_body_for(key, urls), + localized_body_for( + language_code, key, urls, PAGE_TITLE_MAP + ), is_lazy=True, ) changed = True @@ -1472,8 +1363,8 @@ class Command(BaseCommand): "projects": urls.get("projects", "/"), "contact": urls.get("contact", "/"), } - footer_data, mini_data = footer_stream_data( - language_code, link_urls + footer_data, mini_data = localized_footer_stream_data( + language_code, link_urls, PAGE_TITLE_MAP ) footer_obj.footer = StreamValue( footer_obj.footer.stream_block, footer_data, is_lazy=True diff --git a/mandelstudio/templatetags/agency_navigation.py b/mandelstudio/templatetags/agency_navigation.py index 44912c1..6450a39 100644 --- a/mandelstudio/templatetags/agency_navigation.py +++ b/mandelstudio/templatetags/agency_navigation.py @@ -4,6 +4,8 @@ from django import template from wagtail.models import Locale, Page +from mandelstudio.management.commands._agency_content import COMMON_CTA + register = template.Library() SOURCE_PAGE_IDS = { @@ -66,20 +68,8 @@ def agency_page(context, key: str): return _translated_page(source_id, language_code) -CTA_LABELS = { - "nl": "Plan een kennismakingsgesprek", - "en": "Book an introductory call", - "de": "Erstgespräch planen", - "fr": "Planifier un échange initial", - "es": "Planificar una reunión inicial", - "it": "Prenota un colloquio conoscitivo", - "pt": "Agendar reunião introdutória", - "ru": "Запланировать вводный звонок", -} - - @register.simple_tag(takes_context=True) def agency_primary_cta(context): request = context.get("request") language_code = getattr(request, "LANGUAGE_CODE", None) or "nl" - return CTA_LABELS.get(language_code, CTA_LABELS["nl"]) + return COMMON_CTA.get(language_code, COMMON_CTA["nl"])["primary"] From d581b1a3484f3a653057d6a76a1e734a4736e0c3 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Tue, 31 Mar 2026 01:10:31 +0200 Subject: [PATCH 20/45] Pin template engine plugin to internal link fix --- setup.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index cc914e1..003ca17 100644 --- a/setup.py +++ b/setup.py @@ -4,11 +4,21 @@ import json from setuptools import find_packages, setup install_requires: list = ["setuptools", "ocyan.main"] +PINNED_PLUGIN_INSTALL_REQUIRES = { + "ocyan.plugin.template_engine": ( + "ocyan.plugin.template_engine @ " + "git+ssh://git@git.mandelblog.com:2222/mandel-plugins/" + "ocyan.plugin.template_engine.git@00923e5586286af81337b357412862cb7a31b5e0" + ) +} # Add frets dependencies with open("mandelstudio/ocyan.json", encoding="utf-8") as fp: config = json.loads(fp.read()) - install_requires.extend(config["ocyan_plugins"]) + install_requires.extend( + PINNED_PLUGIN_INSTALL_REQUIRES.get(plugin, plugin) + for plugin in config["ocyan_plugins"] + ) extras_require: dict = { "test": [ From 138a9644be1e1eb338fa6912173fcf52d18c4316 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Tue, 31 Mar 2026 01:13:58 +0200 Subject: [PATCH 21/45] Use git credential for pinned template engine install --- Jenkinsfile | 57 ++++++++++++++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 5750d7b..4813ac5 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -34,34 +34,37 @@ pipeline { } stage('Build') { steps { - sh ''' - if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then - sudo apt-get update -y - sudo apt-get install -y python3-venv python3-pip make build-essential libpq-dev \ - libpango-1.0-0 libpangocairo-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 libffi-dev shared-mime-info - fi - python3 -m venv .venv || { - python3 -m pip --version >/dev/null 2>&1 || { - curl -fsSL https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py - python3 /tmp/get-pip.py --user + withCredentials([sshUserPrivateKey(credentialsId: 'gitea-ssh', keyFileVariable: 'GIT_KEYFILE')]) { + sh ''' + export GIT_SSH_COMMAND="ssh -i $GIT_KEYFILE -o StrictHostKeyChecking=accept-new" + if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then + sudo apt-get update -y + sudo apt-get install -y python3-venv python3-pip make build-essential libpq-dev \ + libpango-1.0-0 libpangocairo-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 libffi-dev shared-mime-info + fi + python3 -m venv .venv || { + python3 -m pip --version >/dev/null 2>&1 || { + curl -fsSL https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py + python3 /tmp/get-pip.py --user + } + python3 -m pip install --user virtualenv + python3 -m virtualenv .venv } - python3 -m pip install --user virtualenv - python3 -m virtualenv .venv - } - . .venv/bin/activate - pip install coverage - pip install --upgrade pip "setuptools==69.5.1" wheel - PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.mandelblog.com/mandel/testing/+simple/} \ - PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL:-https://pypi.mandelblog.com/root/pypi/+simple/} \ - pip install --no-build-isolation --pre --editable . setuptools wheel --upgrade --upgrade-strategy=eager --use-deprecated=legacy-resolver - cp "${JOB_BASE_NAME}/ocyan.json" "${JOB_BASE_NAME}/${JOB_BASE_NAME}.json" - pip install ruff vdt.versionplugin.wheel - pip install --upgrade "setuptools==69.5.1" wheel - manage.py migrate --no-input --skip-checks - manage.py loaddemodata || true - manage.py collectstatic --no-input --verbosity=0 - pip install "httpx<0.28" - ''' + . .venv/bin/activate + pip install coverage + pip install --upgrade pip "setuptools==69.5.1" wheel + PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.mandelblog.com/mandel/testing/+simple/} \ + PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL:-https://pypi.mandelblog.com/root/pypi/+simple/} \ + pip install --no-build-isolation --pre --editable . setuptools wheel --upgrade --upgrade-strategy=eager --use-deprecated=legacy-resolver + cp "${JOB_BASE_NAME}/ocyan.json" "${JOB_BASE_NAME}/${JOB_BASE_NAME}.json" + pip install ruff vdt.versionplugin.wheel + pip install --upgrade "setuptools==69.5.1" wheel + manage.py migrate --no-input --skip-checks + manage.py loaddemodata || true + manage.py collectstatic --no-input --verbosity=0 + pip install "httpx<0.28" + ''' + } } } stage('Lint') { From 80d8477ba89339bd986132a2de2d81c7be67a804 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Tue, 31 Mar 2026 01:47:16 +0200 Subject: [PATCH 22/45] Use published template engine package release --- Jenkinsfile | 57 +++++++++++++++++++++++++---------------------------- setup.py | 12 +---------- 2 files changed, 28 insertions(+), 41 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 4813ac5..5750d7b 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -34,37 +34,34 @@ pipeline { } stage('Build') { steps { - withCredentials([sshUserPrivateKey(credentialsId: 'gitea-ssh', keyFileVariable: 'GIT_KEYFILE')]) { - sh ''' - export GIT_SSH_COMMAND="ssh -i $GIT_KEYFILE -o StrictHostKeyChecking=accept-new" - if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then - sudo apt-get update -y - sudo apt-get install -y python3-venv python3-pip make build-essential libpq-dev \ - libpango-1.0-0 libpangocairo-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 libffi-dev shared-mime-info - fi - python3 -m venv .venv || { - python3 -m pip --version >/dev/null 2>&1 || { - curl -fsSL https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py - python3 /tmp/get-pip.py --user - } - python3 -m pip install --user virtualenv - python3 -m virtualenv .venv + sh ''' + if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then + sudo apt-get update -y + sudo apt-get install -y python3-venv python3-pip make build-essential libpq-dev \ + libpango-1.0-0 libpangocairo-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 libffi-dev shared-mime-info + fi + python3 -m venv .venv || { + python3 -m pip --version >/dev/null 2>&1 || { + curl -fsSL https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py + python3 /tmp/get-pip.py --user } - . .venv/bin/activate - pip install coverage - pip install --upgrade pip "setuptools==69.5.1" wheel - PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.mandelblog.com/mandel/testing/+simple/} \ - PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL:-https://pypi.mandelblog.com/root/pypi/+simple/} \ - pip install --no-build-isolation --pre --editable . setuptools wheel --upgrade --upgrade-strategy=eager --use-deprecated=legacy-resolver - cp "${JOB_BASE_NAME}/ocyan.json" "${JOB_BASE_NAME}/${JOB_BASE_NAME}.json" - pip install ruff vdt.versionplugin.wheel - pip install --upgrade "setuptools==69.5.1" wheel - manage.py migrate --no-input --skip-checks - manage.py loaddemodata || true - manage.py collectstatic --no-input --verbosity=0 - pip install "httpx<0.28" - ''' - } + python3 -m pip install --user virtualenv + python3 -m virtualenv .venv + } + . .venv/bin/activate + pip install coverage + pip install --upgrade pip "setuptools==69.5.1" wheel + PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.mandelblog.com/mandel/testing/+simple/} \ + PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL:-https://pypi.mandelblog.com/root/pypi/+simple/} \ + pip install --no-build-isolation --pre --editable . setuptools wheel --upgrade --upgrade-strategy=eager --use-deprecated=legacy-resolver + cp "${JOB_BASE_NAME}/ocyan.json" "${JOB_BASE_NAME}/${JOB_BASE_NAME}.json" + pip install ruff vdt.versionplugin.wheel + pip install --upgrade "setuptools==69.5.1" wheel + manage.py migrate --no-input --skip-checks + manage.py loaddemodata || true + manage.py collectstatic --no-input --verbosity=0 + pip install "httpx<0.28" + ''' } } stage('Lint') { diff --git a/setup.py b/setup.py index 003ca17..cc914e1 100644 --- a/setup.py +++ b/setup.py @@ -4,21 +4,11 @@ import json from setuptools import find_packages, setup install_requires: list = ["setuptools", "ocyan.main"] -PINNED_PLUGIN_INSTALL_REQUIRES = { - "ocyan.plugin.template_engine": ( - "ocyan.plugin.template_engine @ " - "git+ssh://git@git.mandelblog.com:2222/mandel-plugins/" - "ocyan.plugin.template_engine.git@00923e5586286af81337b357412862cb7a31b5e0" - ) -} # Add frets dependencies with open("mandelstudio/ocyan.json", encoding="utf-8") as fp: config = json.loads(fp.read()) - install_requires.extend( - PINNED_PLUGIN_INSTALL_REQUIRES.get(plugin, plugin) - for plugin in config["ocyan_plugins"] - ) + install_requires.extend(config["ocyan_plugins"]) extras_require: dict = { "test": [ From 4ffe6adf0a029b52b80f36ee48fa68d345b4813d Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Tue, 31 Mar 2026 03:44:44 +0200 Subject: [PATCH 23/45] Document devpi release flow and stable fallback --- Jenkinsfile | 28 ++++++++++++++-- docs/DEVPI_RELEASE_FLOW.md | 68 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 docs/DEVPI_RELEASE_FLOW.md diff --git a/Jenkinsfile b/Jenkinsfile index 5750d7b..e2ed24b 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -35,6 +35,30 @@ pipeline { stage('Build') { steps { sh ''' + STABLE_INDEX_URL=${STABLE_INDEX_URL:-https://pypi.mandelblog.com/mandel/stable/+simple/} + TESTING_INDEX_URL=${TESTING_INDEX_URL:-https://pypi.mandelblog.com/mandel/testing/+simple/} + ROOT_INDEX_URL=${PIP_EXTRA_INDEX_URL:-https://pypi.mandelblog.com/root/pypi/+simple/} + export STABLE_INDEX_URL + if python3 - <<'PY' +import os +import sys +from urllib.request import Request, urlopen +from urllib.error import URLError, HTTPError +url = os.environ["STABLE_INDEX_URL"] +try: + req = Request(url, method='HEAD') + with urlopen(req, timeout=10) as response: + sys.exit(0 if response.status < 400 else 1) +except HTTPError as exc: + sys.exit(0 if exc.code < 400 else 1) +except URLError: + sys.exit(1) +PY + then + echo "devpi stable index available, but stable-first install is not enabled yet" + else + echo "devpi stable index not available, using testing as production source" + fi if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo apt-get update -y sudo apt-get install -y python3-venv python3-pip make build-essential libpq-dev \ @@ -51,8 +75,8 @@ pipeline { . .venv/bin/activate pip install coverage pip install --upgrade pip "setuptools==69.5.1" wheel - PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.mandelblog.com/mandel/testing/+simple/} \ - PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL:-https://pypi.mandelblog.com/root/pypi/+simple/} \ + PIP_INDEX_URL="$TESTING_INDEX_URL" \ + PIP_EXTRA_INDEX_URL="$ROOT_INDEX_URL" \ pip install --no-build-isolation --pre --editable . setuptools wheel --upgrade --upgrade-strategy=eager --use-deprecated=legacy-resolver cp "${JOB_BASE_NAME}/ocyan.json" "${JOB_BASE_NAME}/${JOB_BASE_NAME}.json" pip install ruff vdt.versionplugin.wheel diff --git a/docs/DEVPI_RELEASE_FLOW.md b/docs/DEVPI_RELEASE_FLOW.md new file mode 100644 index 0000000..05290f4 --- /dev/null +++ b/docs/DEVPI_RELEASE_FLOW.md @@ -0,0 +1,68 @@ +## Devpi Release Flow + +### Current state + +- `mandel/testing` is the active package source for MandelBlog project builds. +- `ocyan.plugin.template_engine==0.2.12` is published there and is the current production-safe version. +- `mandel/stable` is not available yet. + +This means production is intentionally running from the testing index for now, to avoid breaking installs while the stable index is not provisioned. + +### Index roles + +- `mandel/testing` + - pre-production and current fallback source + - currently also the active production source until stable exists +- `mandel/stable` + - intended production index + - not yet provisioned + +### Promotion flow + +When `mandel/stable` exists, promote existing artifacts without rebuilding: + +```bash +devpi use https://pypi.mandelblog.com/mandel/testing +devpi login mandel +devpi push ocyan-plugin-template-engine==0.2.12 mandel/stable +``` + +### Admin prerequisite + +Promotion requires a devpi admin to create the production index and grant upload or push permissions. + +Recommended admin setup: + +```bash +devpi index -c mandel/stable bases=root/pypi volatile=False acl_upload=mandel,Mandel-publish +``` + +### Planned stable-first install order + +Do not enable this until `mandel/stable` exists: + +```bash +PIP_INDEX_URL=https://pypi.mandelblog.com/mandel/stable/+simple/ +PIP_EXTRA_INDEX_URL=https://pypi.mandelblog.com/mandel/testing/+simple/ +``` + +### CI behavior + +- If the stable index is missing, Jenkins logs: + - `devpi stable index not available, using testing as production source` +- The build does not fail because of the missing stable index. +- Installs continue from `mandel/testing`. + +### Validation checklist + +After stable becomes available and promotion is done: + +1. confirm both wheel and sdist are visible in the stable simple index +2. switch MandelStudio to stable-first +3. run Jenkins build and deploy +4. verify installed version is still `0.2.12` +5. recheck editor validation for: + - `/contact/` + - `/diensten/` + - `#demo` + - absolute URLs From a9ab4a9518c0a6aac34263be91f74c0439cc1cfa Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Tue, 31 Mar 2026 04:03:23 +0200 Subject: [PATCH 24/45] Reduce CTA density across agency pages --- .../management/commands/_agency_content.py | 202 ++++++++---------- 1 file changed, 94 insertions(+), 108 deletions(-) diff --git a/mandelstudio/management/commands/_agency_content.py b/mandelstudio/management/commands/_agency_content.py index 7185a3f..b74497b 100644 --- a/mandelstudio/management/commands/_agency_content.py +++ b/mandelstudio/management/commands/_agency_content.py @@ -3729,6 +3729,71 @@ def _footer_stream_data( return footer, [block("text", cfg["mini"])] +INLINE_LINK_TEXT = { + "process": { + "nl": "Bekijk hoe dit werkt", + "en": "See how this works", + "de": "So läuft der Ablauf", + "fr": "Voir comment cela fonctionne", + "es": "Vea cómo funciona", + "it": "Veda come funziona", + "pt": "Veja como funciona", + "ru": "Посмотреть, как это работает", + }, + "services": { + "nl": "Bekijk onze diensten", + "en": "View our services", + "de": "Unsere Leistungen ansehen", + "fr": "Voir nos services", + "es": "Ver nuestros servicios", + "it": "Vedere i nostri servizi", + "pt": "Ver os nossos serviços", + "ru": "Посмотреть услуги", + }, +} + + +CHOOSE_CARD_TITLES = { + "nl": ["Goede match", "Wat dit praktisch betekent", "Eerst de werkwijze zien"], + "en": ["Good fit", "What this means in practice", "See the process first"], + "de": ["Gute Wahl", "Was das praktisch bedeutet", "Erst den Ablauf sehen"], + "fr": ["Bon choix", "Ce que cela signifie concrètement", "Voir d'abord la méthode"], + "es": ["Buena opción", "Qué significa en la práctica", "Ver primero el proceso"], + "it": ["Scelta adatta", "Cosa significa in pratica", "Vedere prima il metodo"], + "pt": ["Boa escolha", "O que isto significa na prática", "Ver primeiro o método"], + "ru": ["Подходит вам", "Что это означает на практике", "Сначала посмотреть процесс"], +} + + +def _inline_link_text(locale: str, key: str) -> str: + return INLINE_LINK_TEXT[key][locale] + + +def _choose_cards(locale: str, items: list[str], process_url: str) -> list[dict[str, Any]]: + titles = CHOOSE_CARD_TITLES[locale] + cards: list[dict[str, Any]] = [] + for index, body in enumerate(items): + description = body + if index == len(items) - 1: + description = ( + f'{body} {_inline_link_text(locale, "process")}.' + ) + cards.append( + item( + { + "icon": "arrow-right-circle", + "icon_image": None, + "title": titles[min(index, len(titles) - 1)], + "description": description, + "link_text": "", + "link_url": "", + "highlight": "none", + } + ) + ) + return cards + + def _home_body( locale: str, urls: dict[str, str], page_title_map: dict[str, dict[str, str]] ) -> list[dict[str, Any]]: @@ -3747,8 +3812,8 @@ def _home_body( "sub_headline": cfg["sub"], "primary_cta_text": cta["primary"], "primary_cta_url": urls["contact"], - "secondary_cta_text": cta["secondary"], - "secondary_cta_url": urls["services"], + "secondary_cta_text": "", + "secondary_cta_url": "", "hero_image": 1, "video_url": "", "stats": [ @@ -3773,7 +3838,7 @@ def _home_body( "icon_image": None, "title": title, "description": desc, - "link_text": link_text or cta["secondary"], + "link_text": link_text or _inline_link_text(locale, "process"), "link_url": urls[link_key], "highlight": highlight, } @@ -3785,43 +3850,6 @@ def _home_body( "columns": "2", }, ), - block( - "saas_pricing", - { - "layout_width": "container", - "background_style": "light", - "layout": "cards", - "section_title": cfg["pricing_title"], - "section_subtitle": cfg["pricing_sub"], - "show_annual_toggle": False, - "annual_discount_text": "", - "tiers": [ - item( - { - "name": page_title_map[key][locale], - "description": desc, - "price_monthly": None, - "price_annual": None, - "price_suffix": "", - "custom_price_text": "Op offertebasis" - if locale == "nl" - else "Custom quote", - "features": [ - item({"text": text, "included": True, "tooltip": ""}) - for text in features - ], - "cta_text": cta["primary"], - "cta_url": urls["contact"], - "cta_style": "primary" if featured else "secondary", - "is_featured": featured, - "featured_label": label, - } - ) - for key, (desc, features, featured, label) in cfg["tiers"].items() - ], - "footer_text": cfg["pricing_footer"], - }, - ), block( "saas_testimonials", { @@ -3860,9 +3888,9 @@ def _home_body( item({"question": question, "answer": answer, "category": category}) for question, answer, category in cfg["faqs"] ], - "show_contact_cta": "card", - "contact_cta_text": cta["primary"], - "contact_cta_url": urls["contact"], + "show_contact_cta": "simple", + "contact_cta_text": cta["secondary"], + "contact_cta_url": urls["services"], }, ), block( @@ -3875,8 +3903,8 @@ def _home_body( "subheadline": cfg["cta_sub"], "primary_cta_text": cta["primary"], "primary_cta_url": urls["contact"], - "secondary_cta_text": cta["secondary"], - "secondary_cta_url": urls["services"], + "secondary_cta_text": "", + "secondary_cta_url": "", "background_image": 1, "side_image": 1, "show_no_credit_card": "with-icon", @@ -3923,8 +3951,8 @@ def _standard_body( "sub_headline": cfg["sub"], "primary_cta_text": cta["primary"], "primary_cta_url": urls["contact"], - "secondary_cta_text": cta["secondary"], - "secondary_cta_url": urls["services"], + "secondary_cta_text": "", + "secondary_cta_url": "", "hero_image": 1 if page_key != "process" else 24, "video_url": "", "stats": [], @@ -3946,12 +3974,8 @@ def _standard_body( "icon_image": None, "title": title, "description": desc, - "link_text": cta["primary"] - if page_key in {"contact", "about"} - else cta["secondary"], - "link_url": urls["contact"] - if page_key in {"contact", "about"} - else urls["services"], + "link_text": _inline_link_text(locale, "process"), + "link_url": urls["process"], "highlight": "none", } ) @@ -4038,9 +4062,9 @@ def _standard_body( item({"question": q, "answer": a, "category": c}) for q, a, c in common["faq_items"] ], - "show_contact_cta": "card", - "contact_cta_text": cta["primary"], - "contact_cta_url": urls["contact"], + "show_contact_cta": "none", + "contact_cta_text": "", + "contact_cta_url": "", }, ) ) @@ -4055,8 +4079,8 @@ def _standard_body( "subheadline": common["cta_sub"], "primary_cta_text": cta["primary"], "primary_cta_url": urls["contact"], - "secondary_cta_text": cta["secondary"], - "secondary_cta_url": urls["services"], + "secondary_cta_text": "", + "secondary_cta_url": "", "background_image": 1, "side_image": 1, "show_no_credit_card": "with-icon", @@ -4098,8 +4122,8 @@ def _service_body(locale: str, kind: str, urls: dict[str, str]) -> list[dict[str "sub_headline": f"

{cfg['audience']}

", "primary_cta_text": cta["primary"], "primary_cta_url": urls["contact"], - "secondary_cta_text": cta["secondary"], - "secondary_cta_url": urls["services"], + "secondary_cta_text": "", + "secondary_cta_url": "", "hero_image": 23, "video_url": "", "stats": [ @@ -4161,8 +4185,8 @@ def _service_body(locale: str, kind: str, urls: dict[str, str]) -> list[dict[str "icon_image": None, "title": title, "description": desc, - "link_text": cta["secondary"], - "link_url": urls["services"], + "link_text": _inline_link_text(locale, "process"), + "link_url": urls["process"], "highlight": "none", } ) @@ -4186,8 +4210,8 @@ def _service_body(locale: str, kind: str, urls: dict[str, str]) -> list[dict[str "icon_image": None, "title": title, "description": desc, - "link_text": cta["primary"], - "link_url": urls["contact"], + "link_text": "", + "link_url": "", "highlight": "none", } ) @@ -4197,53 +4221,15 @@ def _service_body(locale: str, kind: str, urls: dict[str, str]) -> list[dict[str }, ), block( - "saas_demo_request", + "saas_features", { "layout_width": "container", "background_style": "light", - "layout": "split", + "layout": "grid", "section_title": common["section_choose"], "section_subtitle": common["section_choose_sub"], - "form_fields": [ - item( - { - "field_type": "text", - "label": common["name_label"], - "placeholder": common["name_placeholder"], - "required": True, - } - ), - item( - { - "field_type": "email", - "label": common["email_label"], - "placeholder": common["email_placeholder"], - "required": True, - } - ), - item( - { - "field_type": "company", - "label": common["company_label"], - "placeholder": common["company_placeholder"], - "required": True, - } - ), - item( - { - "field_type": "message", - "label": common["message_label"], - "placeholder": common["message_placeholder"], - "required": False, - } - ), - ], - "submit_button_text": cta["primary"], - "form_action_url": urls["contact"], - "benefits_title": common["choose_title"], - "benefits": [item(text) for text in cfg["choose"]], - "side_image": 23, - "privacy_text": common["privacy"], + "features": _choose_cards(locale, cfg["choose"], urls["process"]), + "columns": "3", }, ), block( @@ -4256,8 +4242,8 @@ def _service_body(locale: str, kind: str, urls: dict[str, str]) -> list[dict[str "subheadline": common["cta_sub"], "primary_cta_text": cta["primary"], "primary_cta_url": urls["contact"], - "secondary_cta_text": cta["secondary"], - "secondary_cta_url": urls["services"], + "secondary_cta_text": "", + "secondary_cta_url": "", "background_image": 1, "side_image": 1, "show_no_credit_card": "with-icon", From 820096647bdaca9282d3bd4c837c96d067120e73 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Tue, 31 Mar 2026 04:06:52 +0200 Subject: [PATCH 25/45] Format CTA density cleanup --- .../management/commands/_agency_content.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/mandelstudio/management/commands/_agency_content.py b/mandelstudio/management/commands/_agency_content.py index b74497b..e9549f3 100644 --- a/mandelstudio/management/commands/_agency_content.py +++ b/mandelstudio/management/commands/_agency_content.py @@ -3761,7 +3761,11 @@ CHOOSE_CARD_TITLES = { "es": ["Buena opción", "Qué significa en la práctica", "Ver primero el proceso"], "it": ["Scelta adatta", "Cosa significa in pratica", "Vedere prima il metodo"], "pt": ["Boa escolha", "O que isto significa na prática", "Ver primeiro o método"], - "ru": ["Подходит вам", "Что это означает на практике", "Сначала посмотреть процесс"], + "ru": [ + "Подходит вам", + "Что это означает на практике", + "Сначала посмотреть процесс", + ], } @@ -3769,15 +3773,15 @@ def _inline_link_text(locale: str, key: str) -> str: return INLINE_LINK_TEXT[key][locale] -def _choose_cards(locale: str, items: list[str], process_url: str) -> list[dict[str, Any]]: +def _choose_cards( + locale: str, items: list[str], process_url: str +) -> list[dict[str, Any]]: titles = CHOOSE_CARD_TITLES[locale] cards: list[dict[str, Any]] = [] for index, body in enumerate(items): description = body if index == len(items) - 1: - description = ( - f'{body} {_inline_link_text(locale, "process")}.' - ) + description = f'{body} {_inline_link_text(locale, "process")}.' cards.append( item( { @@ -3838,7 +3842,8 @@ def _home_body( "icon_image": None, "title": title, "description": desc, - "link_text": link_text or _inline_link_text(locale, "process"), + "link_text": link_text + or _inline_link_text(locale, "process"), "link_url": urls[link_key], "highlight": highlight, } From d75db13a5a7da003dc040eaf34abe303131e5765 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Tue, 31 Mar 2026 04:47:27 +0200 Subject: [PATCH 26/45] Restore services dropdown in agency header --- .../templates/carbasa/headers/mega.html | 35 +++++++++++++++++++ .../templatetags/agency_navigation.py | 10 ++++++ 2 files changed, 45 insertions(+) diff --git a/mandelstudio/templates/carbasa/headers/mega.html b/mandelstudio/templates/carbasa/headers/mega.html index 20bfe80..6f8384c 100644 --- a/mandelstudio/templates/carbasa/headers/mega.html +++ b/mandelstudio/templates/carbasa/headers/mega.html @@ -2,6 +2,26 @@ {% load agency_navigation %} {% block nav %} +