30 Commits

Author SHA1 Message Date
215297ef41 Style shared richtext blocks via project template override 2026-04-03 01:27:36 +02:00
4b6581c7fe Fix NL go-live legal, SEO, and footer foundations 2026-04-02 17:55:39 +02:00
b0d8a96b76 Replace demo copy and imagery in agency content 2026-04-01 01:54:28 +02:00
02f3007e9e Open services dropdown on hover 2026-04-01 01:29:20 +02:00
d75db13a5a Restore services dropdown in agency header 2026-03-31 04:47:44 +02:00
820096647b Format CTA density cleanup 2026-03-31 04:06:52 +02:00
a9ab4a9518 Reduce CTA density across agency pages 2026-03-31 04:03:23 +02:00
4ffe6adf0a Document devpi release flow and stable fallback 2026-03-31 03:44:44 +02:00
80d8477ba8 Use published template engine package release 2026-03-31 01:47:16 +02:00
138a9644be Use git credential for pinned template engine install 2026-03-31 01:13:58 +02:00
d581b1a348 Pin template engine plugin to internal link fix 2026-03-31 01:11:02 +02:00
eef11801a6 Roll out agency content parity across locales 2026-03-31 00:29:01 +02:00
582efd017d Fix agency site import ordering for CI 2026-03-30 18:35:01 +02:00
9059cd28ae Format agency site refresh command and nav tags 2026-03-30 18:32:15 +02:00
0baae1dbe6 Clean agency navigation and refresh core site content 2026-03-30 18:27:51 +02:00
ebde2806c1 Run nightly checkout on built-in node 2026-03-30 00:11:45 +02:00
3f5d5b637b Use deploy entrypoint for multilingual audit 2026-03-30 00:03:52 +02:00
b9d9a7e88e Run salt audit through dashboard sudo entrypoint 2026-03-29 23:19:04 +02:00
9da7b5cc7d Always archive multilingual audit failure output 2026-03-29 23:16:15 +02:00
dd01f7dd9a Run multilingual audit via serverpillar salt 2026-03-29 23:12:58 +02:00
2931eedf22 Use staging hostname for multilingual audit 2026-03-29 21:47:45 +02:00
e77479f87a Fix Jenkins multilingual audit stage checkout 2026-03-29 21:41:15 +02:00
ebd57a4376 Run multilingual audit stages on built-in Jenkins node 2026-03-29 21:34:31 +02:00
fb6f2e861d Fix import ordering for multilingual CI lint 2026-03-29 21:28:31 +02:00
51b2fd574c Format multilingual audit extraction for CI lint 2026-03-29 21:25:37 +02:00
c516d72c8a Document multilingual audit CI operations 2026-03-29 20:58:34 +02:00
e3bafd3a73 Add multilingual audit CI pipeline + extract mandelblog_content_guard 2026-03-29 20:50:21 +02:00
MandelBot
643aca26d0 Localize shared marketing templates by locale 2026-03-24 21:48:51 +00:00
ca06ab88ba Polish footer UI and localize demo-request form endpoints 2026-03-23 00:29:12 +01:00
Mandel Dashboard
d2adda383e Enable ocyan.plugin.wordspinner 2026-03-19 22:46:16 +00:00
31 changed files with 7440 additions and 106 deletions

37
Jenkinsfile vendored
View File

@@ -9,10 +9,9 @@ pipeline {
environment {
PYENVPIPELINE_VIRTUALENV = '1'
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
STAGING_AUDIT_HOST = 'root@49.12.204.96'
STAGING_AUDIT_PROJECT_NAME = 'mandelstudio'
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 {
@@ -36,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 \
@@ -52,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
@@ -105,10 +128,10 @@ pipeline {
timeout(time: 10, unit: 'MINUTES')
}
steps {
sh 'mkdir -p artifacts'
withCredentials([sshUserPrivateKey(credentialsId: env.STAGING_AUDIT_SSH_CREDENTIALS_ID, keyFileVariable: 'STAGING_SSH_KEYFILE')]) {
deleteDir()
checkout scm
sh 'mkdir -p artifacts && chmod +x 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) {

View File

@@ -10,13 +10,13 @@ pipeline {
skipDefaultCheckout(true)
}
environment {
STAGING_AUDIT_HOST = 'root@49.12.204.96'
STAGING_AUDIT_PROJECT_NAME = 'mandelstudio'
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') {
agent { label 'built-in' }
steps {
withCredentials([sshUserPrivateKey(credentialsId: 'gitea-ssh', keyFileVariable: 'GIT_KEYFILE')]) {
sh '''
@@ -39,10 +39,10 @@ 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'
withCredentials([sshUserPrivateKey(credentialsId: env.STAGING_AUDIT_SSH_CREDENTIALS_ID, keyFileVariable: 'STAGING_SSH_KEYFILE')]) {
sh 'chmod +x 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) {

View File

@@ -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 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_HOST=root@49.12.204.96`
- `STAGING_AUDIT_PROJECT_NAME=mandelstudio`
- `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@49.12.204.96'
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

View File

@@ -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

View File

@@ -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)]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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": {

View File

@@ -1,16 +1,61 @@
{% 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 %}
<style>
.agency-nav-dropdown .dropdown-menu {
min-width: 16rem;
border-radius: 1rem;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.12);
padding: 0.55rem;
}
.agency-nav-dropdown .dropdown-item {
border-radius: 0.75rem;
font-weight: 600;
padding: 0.65rem 0.8rem;
}
.agency-nav-dropdown .dropdown-toggle::after {
margin-left: 0.45rem;
vertical-align: 0.15em;
}
@media (min-width: 992px) {
.agency-nav-dropdown:hover > .dropdown-menu,
.agency-nav-dropdown:focus-within > .dropdown-menu {
display: block;
margin-top: 0;
}
}
</style>
<div class="collapse navbar-collapse menu-bar page-menu-bar" id="navbarSupportedContent">
<div class="brand-wrapper">
{% include 'partials/brand.html' with big=True %}
</div>
{% agency_nav_pages as nav_pages %}
<ul class="navbar-nav">
{% rootpage_as_category as page_tree_root %}
{% category_tree 2 page_tree_root as page_tree_items %}
{% include "partials/dropdown.html" with menu_items=page_tree_items limit=2 %}
{% for nav_page in nav_pages %}
{% if nav_page.nav_children %}
<li class="nav-item dropdown agency-nav-dropdown">
<a class="nav-link dropdown-toggle" href="{{ nav_page.url }}" id="agency-nav-{{ nav_page.nav_key }}" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ nav_page.title }}
</a>
<ul class="dropdown-menu" aria-labelledby="agency-nav-{{ nav_page.nav_key }}">
{% for child_page in nav_page.nav_children %}
<li>
<a class="dropdown-item" href="{{ child_page.url }}">{{ child_page.title }}</a>
</li>
{% endfor %}
</ul>
</li>
{% else %}
<li class="nav-item child">
<a class="nav-link" href="{{ nav_page.url }}">{{ nav_page.title }}</a>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
{% endblock %}

View File

@@ -0,0 +1,45 @@
<section class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-9">
<div class="te-richtext card border-0 shadow-sm rounded-4">
<div class="card-body p-4 p-md-5">
{{ value.content }}
</div>
</div>
</div>
</div>
</section>
<style>
.te-modern-saas .te-richtext {
color: var(--te-color-text-base);
background: color-mix(in srgb, var(--te-color-surface-soft) 18%, white 82%);
}
.te-modern-saas .te-richtext .card-body > * + * {
margin-top: 1rem;
}
.te-modern-saas .te-richtext h2,
.te-modern-saas .te-richtext h3,
.te-modern-saas .te-richtext h4 {
color: var(--te-color-surface-strong);
margin-top: 2rem;
margin-bottom: 0.75rem;
}
.te-modern-saas .te-richtext p,
.te-modern-saas .te-richtext li {
line-height: 1.75;
}
.te-modern-saas .te-richtext ul,
.te-modern-saas .te-richtext ol {
padding-left: 1.25rem;
margin-bottom: 0;
}
.te-modern-saas .te-richtext a {
font-weight: 600;
}
</style>

View File

@@ -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" %}
<style>
:root { --mb-site-header-height: 88px; }
header.mega_header {
z-index: 1200;
}
.te-modern-saas .te-block--saas-testimonials .saas-testimonials__header {
top: calc(var(--mb-site-header-height) + 8px);
z-index: 20;
}
@media (max-width: 991.98px) {
:root { --mb-site-header-height: 72px; }
}
</style>
{% endblock %}
{% block layout %}
<a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2">
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 %}
</a>
{% include_header header_template|default:"engine/partials/header.html" %}
<div id="main_content" tabindex="-1">

View File

@@ -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" %}
<style>
:root { --mb-site-header-height: 88px; }
header.mega_header {
z-index: 1200;
}
.te-modern-saas .te-block--saas-testimonials .saas-testimonials__header {
top: calc(var(--mb-site-header-height) + 8px);
z-index: 20;
}
@media (max-width: 991.98px) {
:root { --mb-site-header-height: 72px; }
}
</style>
{% endblock %}
{% block layout %}
<a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2">
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 %}
</a>
{% include_header header_template|default:"engine/partials/header.html" %}
<div id="main_content" tabindex="-1">

View File

@@ -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" %}
<style>
:root { --mb-site-header-height: 88px; }
header.mega_header {
z-index: 1200;
}
.te-modern-saas .te-block--saas-testimonials .saas-testimonials__header {
top: calc(var(--mb-site-header-height) + 8px);
z-index: 20;
}
@media (max-width: 991.98px) {
:root { --mb-site-header-height: 72px; }
}
</style>
{% endblock %}
{% block layout %}
<a class="btn btn-secondary hidelink" id="main_content_link" href="#skip_header" tabindex="2">
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 %}
</a>
{% include_header header_template|default:"engine/partials/header.html" %}
<div id="main_content" tabindex="-1">

View File

@@ -1 +0,0 @@
{% include "carbasa/headers/header.html" %}

View File

@@ -1,6 +0,0 @@
{% comment %}
Project-level header override:
force engine pages to render the Carbasa header instead of
the template_engine fallback header.
{% endcomment %}
{% include "carbasa/headers/header.html" %}

View File

@@ -1 +0,0 @@
{% include "carbasa/headers/header.html" %}

View File

@@ -0,0 +1,58 @@
{% load wagtailimages_tags %}
<section class="saas-demo saas-demo--inline saas-demo--{{ self.background_style }}"
data-width="{{ self.layout_width }}">
<div class="saas-demo__container">
<header class="saas-demo__header">
<h2 class="saas-demo__title">{{ self.section_title }}</h2>
{% if self.section_subtitle %}
<div class="saas-demo__subtitle">{{ self.section_subtitle }}</div>
{% endif %}
</header>
<form class="saas-demo__form" action="{% url "contact_form:contact-form-handler" %}" method="post">
{% csrf_token %}
<div class="saas-demo__fields">
{% for field in self.form_fields %}
<div class="saas-demo__field">
<label class="saas-demo__label" for="demo-{{ field.field_type }}">
{{ field.label }}
{% if field.required %}<span class="saas-demo__required">*</span>{% endif %}
</label>
{% if field.field_type == 'message' %}
<textarea class="saas-demo__textarea"
id="demo-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
placeholder="{{ field.placeholder }}"
{% if field.required %}required{% endif %}></textarea>
{% elif field.field_type == 'company_size' %}
<select class="saas-demo__select"
id="demo-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
{% if field.required %}required{% endif %}>
<option value="">{{ field.placeholder|default:"Select company size" }}</option>
<option value="1-10">1-10 employees</option>
<option value="11-50">11-50 employees</option>
<option value="51-200">51-200 employees</option>
<option value="201-500">201-500 employees</option>
<option value="500+">500+ employees</option>
</select>
{% else %}
<input class="saas-demo__input"
type="{% if field.field_type == 'email' %}email{% elif field.field_type == 'phone' %}tel{% else %}text{% endif %}"
id="demo-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
placeholder="{{ field.placeholder }}"
{% if field.required %}required{% endif %}>
{% endif %}
</div>
{% endfor %}
</div>
<button type="submit" class="saas-demo__submit">{{ self.submit_button_text }}</button>
{% if self.privacy_text %}
<div class="saas-demo__privacy">{{ self.privacy_text }}</div>
{% endif %}
</form>
</div>
</section>

View File

@@ -0,0 +1,94 @@
{% load wagtailimages_tags %}
<section class="saas-demo saas-demo--modal-trigger saas-demo--{{ self.background_style }}"
data-width="{{ self.layout_width }}"
data-demo-modal-root>
<div class="saas-demo__container">
<div class="saas-demo__content">
<h2 class="saas-demo__title">{{ self.section_title }}</h2>
{% if self.section_subtitle %}
<div class="saas-demo__subtitle">{{ self.section_subtitle }}</div>
{% endif %}
<button type="button" class="saas-demo__trigger" data-demo-modal-open>
{{ self.submit_button_text }}
</button>
</div>
</div>
<!-- Modal -->
<div class="saas-demo__modal" data-demo-modal hidden>
<div class="saas-demo__modal-backdrop" data-demo-modal-close></div>
<div class="saas-demo__modal-content">
<button type="button" class="saas-demo__modal-close" data-demo-modal-close aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<h3 class="saas-demo__modal-title">{{ self.section_title }}</h3>
<form class="saas-demo__form" action="{% url "contact_form:contact-form-handler" %}" method="post">
{% csrf_token %}
<div class="saas-demo__fields">
{% for field in self.form_fields %}
<div class="saas-demo__field">
<label class="saas-demo__label" for="modal-{{ field.field_type }}">
{{ field.label }}
{% if field.required %}<span class="saas-demo__required">*</span>{% endif %}
</label>
{% if field.field_type == 'message' %}
<textarea class="saas-demo__textarea"
id="modal-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
placeholder="{{ field.placeholder }}"
{% if field.required %}required{% endif %}></textarea>
{% else %}
<input class="saas-demo__input"
type="{% if field.field_type == 'email' %}email{% elif field.field_type == 'phone' %}tel{% else %}text{% endif %}"
id="modal-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
placeholder="{{ field.placeholder }}"
{% if field.required %}required{% endif %}>
{% endif %}
</div>
{% endfor %}
</div>
<button type="submit" class="saas-demo__submit">{{ self.submit_button_text }}</button>
{% if self.privacy_text %}
<div class="saas-demo__privacy">{{ self.privacy_text }}</div>
{% endif %}
</form>
</div>
</div>
</section>
<script>
(function () {
const roots = document.querySelectorAll('[data-demo-modal-root]');
roots.forEach((root) => {
if (root.dataset.modalBound === "1") return;
root.dataset.modalBound = "1";
const modal = root.querySelector('[data-demo-modal]');
const openBtn = root.querySelector('[data-demo-modal-open]');
const closeBtns = root.querySelectorAll('[data-demo-modal-close]');
if (!modal || !openBtn) return;
const openModal = () => {
modal.hidden = false;
document.body.style.overflow = 'hidden';
};
const closeModal = () => {
modal.hidden = true;
document.body.style.overflow = '';
};
openBtn.addEventListener('click', openModal);
closeBtns.forEach((btn) => btn.addEventListener('click', closeModal));
root.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !modal.hidden) closeModal();
});
});
})();
</script>

View File

@@ -0,0 +1,156 @@
{% load wagtailimages_tags %}
<section class="saas-demo saas-demo--split saas-demo--{{ self.background_style }} mandelstudio-demo-split"
data-width="{{ self.layout_width }}">
<style>
.mandelstudio-demo-split {
position: relative;
border-radius: 18px;
overflow: hidden;
background: linear-gradient(180deg, #f8fbff 0%, #f3f7fc 100%);
}
.mandelstudio-demo-split .saas-demo__container {
max-width: 1200px;
margin: 0 auto;
padding: clamp(1.25rem, 2.2vw, 2.25rem);
gap: clamp(1rem, 2vw, 2rem);
}
.mandelstudio-demo-split .saas-demo__title {
font-size: clamp(1.9rem, 3vw, 3rem);
line-height: 1.1;
letter-spacing: -0.03em;
margin-bottom: .8rem;
}
.mandelstudio-demo-split .saas-demo__subtitle {
color: #556070;
max-width: 54ch;
margin-bottom: .9rem;
}
.mandelstudio-demo-split .saas-demo__benefits-list {
margin-bottom: 1.15rem;
}
.mandelstudio-demo-split .saas-demo__benefit {
color: #212b3a;
}
.mandelstudio-demo-split .saas-demo__visual {
margin-top: .5rem;
}
.mandelstudio-demo-split .saas-demo__image {
width: 100%;
border-radius: 16px;
border: 1px solid rgba(39, 66, 107, .14);
box-shadow: 0 12px 28px rgba(20, 35, 68, .12);
}
.mandelstudio-demo-split .saas-demo__form-wrapper {
border: 1px solid rgba(42, 72, 120, .15);
border-radius: 16px;
background: #fff;
box-shadow: 0 14px 30px rgba(18, 38, 76, .09);
}
.mandelstudio-demo-split .saas-demo__form {
padding: clamp(1rem, 2vw, 1.5rem);
}
.mandelstudio-demo-split .saas-demo__input,
.mandelstudio-demo-split .saas-demo__select,
.mandelstudio-demo-split .saas-demo__textarea {
background: #fbfdff;
border-color: #d8e1ec;
transition: border-color .2s ease, box-shadow .2s ease;
}
.mandelstudio-demo-split .saas-demo__input:focus,
.mandelstudio-demo-split .saas-demo__select:focus,
.mandelstudio-demo-split .saas-demo__textarea:focus {
border-color: #377dff;
box-shadow: 0 0 0 .2rem rgba(55, 125, 255, .15);
}
.mandelstudio-demo-split .saas-demo__submit {
box-shadow: 0 8px 18px rgba(40, 95, 214, .3);
}
@media (max-width: 991.98px) {
.mandelstudio-demo-split .saas-demo__visual {
display: none;
}
}
</style>
<div class="saas-demo__container">
<div class="saas-demo__content">
<h2 class="saas-demo__title">{{ self.section_title }}</h2>
{% if self.section_subtitle %}
<div class="saas-demo__subtitle">{{ self.section_subtitle }}</div>
{% endif %}
{% if self.benefits_title or self.benefits %}
<div class="saas-demo__benefits">
{% if self.benefits_title %}
<h3 class="saas-demo__benefits-title">{{ self.benefits_title }}</h3>
{% endif %}
{% if self.benefits %}
<ul class="saas-demo__benefits-list">
{% for benefit in self.benefits %}
<li class="saas-demo__benefit">
<svg class="saas-demo__check" width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M4 10L8 14L16 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{{ benefit }}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
{% if self.side_image %}
<div class="saas-demo__visual">
{% image self.side_image width-640 class="saas-demo__image" %}
</div>
{% endif %}
</div>
<div class="saas-demo__form-wrapper">
<form class="saas-demo__form" action="{% url "contact_form:contact-form-handler" %}" method="post">
{% csrf_token %}
<div class="saas-demo__fields">
{% for field in self.form_fields %}
<div class="saas-demo__field">
<label class="saas-demo__label" for="split-{{ field.field_type }}">
{{ field.label }}
{% if field.required %}<span class="saas-demo__required">*</span>{% endif %}
</label>
{% if field.field_type == 'message' %}
<textarea class="saas-demo__textarea"
id="split-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
placeholder="{{ field.placeholder }}"
{% if field.required %}required{% endif %}></textarea>
{% elif field.field_type == 'company_size' %}
<select class="saas-demo__select"
id="split-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
{% if field.required %}required{% endif %}>
<option value="">{{ field.placeholder|default:"Select company size" }}</option>
<option value="1-10">1-10 employees</option>
<option value="11-50">11-50 employees</option>
<option value="51-200">51-200 employees</option>
<option value="201-500">201-500 employees</option>
<option value="500+">500+ employees</option>
</select>
{% else %}
<input class="saas-demo__input"
type="{% if field.field_type == 'email' %}email{% elif field.field_type == 'phone' %}tel{% else %}text{% endif %}"
id="split-{{ field.field_type }}"
name="{% if field.field_type == "email" %}email_from{% elif field.field_type == "phone" %}phonenumber{% elif field.field_type == "text" %}name{% else %}{{ field.field_type }}{% endif %}"
placeholder="{{ field.placeholder }}"
{% if field.required %}required{% endif %}>
{% endif %}
</div>
{% endfor %}
</div>
<button type="submit" class="saas-demo__submit">{{ self.submit_button_text }}</button>
{% if self.privacy_text %}
<div class="saas-demo__privacy">{{ self.privacy_text }}</div>
{% endif %}
</form>
</div>
</div>
</section>

View File

@@ -0,0 +1,38 @@
{% load wagtailimages_tags %}
<section class="saas-features saas-features--grid saas-features--{{ self.background_style }}"
data-width="{{ self.layout_width }}">
<div class="saas-features__container">
<header class="saas-features__header">
<h2 class="saas-features__title">{{ self.section_title }}</h2>
{% if self.section_subtitle %}
<div class="saas-features__subtitle">{{ self.section_subtitle }}</div>
{% endif %}
</header>
<div class="saas-features__grid saas-features__grid--cols-{{ self.columns }}">
{% for feature in self.features %}
<article class="saas-features__card{% if feature.highlight == 'featured' %} saas-features__card--featured{% endif %}">
{% if feature.highlight == 'new' %}
<span class="saas-features__badge">{% 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 %}</span>
{% endif %}
<div class="saas-features__icon-wrapper">
{% if feature.icon_image %}
{% image feature.icon_image width-64 class="saas-features__icon-img" %}
{% elif feature.icon %}
<i class="saas-features__icon bi bi-{{ feature.icon }}"></i>
{% else %}
<div class="saas-features__icon-placeholder"></div>
{% endif %}
</div>
<h3 class="saas-features__card-title">{{ feature.title }}</h3>
{% if feature.description %}<div class="saas-features__card-desc">{{ feature.description }}</div>{% endif %}
{% if feature.link_text and feature.link_url %}
<a href="{{ feature.link_url }}" class="saas-features__card-link">
{{ feature.link_text }}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8H13M13 8L9 4M13 8L9 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
{% endif %}
</article>
{% endfor %}
</div>
</div>
</section>

View File

@@ -0,0 +1,30 @@
{% load wagtailimages_tags %}
<section class="saas-integrations saas-integrations--logo-grid saas-integrations--{{ self.background_style }}" data-width="{{ self.layout_width }}">
<div class="saas-integrations__container">
<header class="saas-integrations__header">
<h2 class="saas-integrations__title">{{ self.section_title }}</h2>
{% if self.section_subtitle %}<div class="saas-integrations__subtitle">{{ self.section_subtitle }}</div>{% endif %}
{% if self.integration_count %}
<span class="saas-integrations__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 %}</span>
{% endif %}
</header>
<div class="saas-integrations__grid">
{% for integration in self.integrations %}
<div class="saas-integrations__item{% if integration.is_featured != 'none' %} saas-integrations__item--{{ integration.is_featured }}{% endif %}">
{% if integration.is_featured == 'new' %}
<span class="saas-integrations__badge">{% 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{% elif request.LANGUAGE_CODE == 'nl' %}Nieuw{% else %}New{% endif %}</span>
{% elif integration.is_featured == 'popular' %}
<span class="saas-integrations__badge saas-integrations__badge--popular">{% if request.LANGUAGE_CODE == 'ru' %}Populair{% elif request.LANGUAGE_CODE == 'de' %}Beliebt{% elif request.LANGUAGE_CODE == 'fr' %}Populaire{% elif request.LANGUAGE_CODE == 'es' %}Popular{% elif request.LANGUAGE_CODE == 'it' %}Popolare{% elif request.LANGUAGE_CODE == 'pt' %}Popular{% elif request.LANGUAGE_CODE == 'nl' %}Populair{% else %}Popular{% endif %}</span>
{% endif %}
{% if integration.url %}<a href="{{ integration.url }}" class="saas-integrations__link">{% endif %}
<div class="saas-integrations__logo">{% image integration.logo width-48 class="saas-integrations__logo-img" %}</div>
<span class="saas-integrations__name">{{ integration.name }}</span>
{% if integration.url %}</a>{% endif %}
</div>
{% endfor %}
</div>
{% if self.cta_text and self.cta_url %}
<div class="saas-integrations__footer"><a href="{{ self.cta_url }}" class="saas-integrations__cta">{{ self.cta_text }}<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8H13M13 8L9 4M13 8L9 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a></div>
{% endif %}
</div>
</section>

View File

@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% load compress %}
{% load i18n %}
{% load oxyan %}
{% load ocyan_main %}
{% load ocyanjson %}
{% load static %}
{% load wagtailcore_tags wagtailimages_tags wagtailuserbar %}
{% block title %}{% firstof page.seo_title self.seo_title page.title self.title shop_name %}{% endblock %}
{% block description %}{% firstof page.search_description self.search_description "" %}{% endblock %}
{% block extrahead %}
{% if cookie_jar.settings.google_tag_manager and cookie_jar.functional.is_allowed %}
<link rel="preconnect" href="https://www.googletagmanager.com"/>
{% endif %}
{% if cookie_jar.settings.google_analytics and cookie_jar.functional.is_allowed %}
<link rel="preconnect" href="https://www.google-analytics.com/">
{% endif %}
{{ block.super }}
{% if cookie_jar.needs_approval %}
<link rel="stylesheet" type="text/css" href="{% static 'cookie_jar/css/cookie_jar.css' %}">
{% endif %}
{% for header_snippet in cookie_jar.activated_snippet_header_templates %}
{% include header_snippet %}
{% endfor %}
{% endblock %}
{% block layout %}
{% if show_basket_popup_setting %}
{% esi_fragment "partials/added_success.html" with sessionid=True oscar_open_basket=True request=request csrf_token=csrf_token only %}
{% endif %}
{% block navbar %}
{% include_header 'oxyan/headers/header.html' %}
{% endblock %}
{% block content_wrapper %}
<div id="main_content" tabindex="-1">
{% block content %}{% endblock %}
</div>
{% endblock %}
{% block footer %}
{% include "oxyan/partials/footer.html" %}
{% endblock %}
{% ocyanjson "themes" "theme-switcher" as theme_switcher %}
{% if theme_switcher %}
{% include "oxyan/partials/theme_switcher.html" %}
{% endif %}
{% endblock %}
{% block extrascripts %}
{% include "oscar/partials/extrascripts.html" %}
{{ block.super }}
{% if cookie_jar.needs_approval %}
<script src="{% static 'cookie_jar/js/cookie_jar.js' %}"></script>
{% endif %}
{% endblock %}
{% block onbodyload %}
{{ block.super }}
oxyan.layout()
oxyan.initModalPopup()
oxyan.initializePriceUpdate()
oxyan.IconHoverFix()
oxyan.lazyIconDropdown()
oxyan.toasts()
oxyan.commerseHeader()
oxyan.initWCAG()
{% ocyanjson "themes" "image_zoom" as image_zoom %}
{% if image_zoom %}
oxyan.initImageZoom()
{% endif %}
{% endblock %}
{% block cdn_scripts %}
{{ block.super }}
{% ocyanjson "wagtail" "wagtailuserbar_position" as position %}
{% if position %}
{% wagtailuserbar position %}
{% endif %}
{% for footer_snippet in cookie_jar.activated_snippet_footer_templates %}
{% include footer_snippet %}
{% endfor %}
{% include "cookie_jar/cookie_banner.html" %}
{% if cookie_jar.needs_approval %}
{% include "cookie_jar/partials/preferences_saved_toast.html" %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% load wagtailcore_tags %}
{% if self.heading %}<p class="footer_header">{{ self.heading }}</p>{% endif %}
{% if children %}
<ul class="mb-footer-links list-unstyled m-0">
{% for page in children %}
<li class="mb-footer-links__item mb-2">
<a href="{% pageurl page %}">{{ page.title }}</a>
</li>
{% endfor %}
</ul>
{% endif %}

View File

@@ -1 +0,0 @@
{% extends "carbasa/headers/header.html" %}

View File

@@ -1 +0,0 @@
{% extends "carbasa/headers/mega.html" %}

View File

@@ -1,11 +1,39 @@
{% load i18n i18n_helpers %}
{% get_current_language as LANGUAGE_CODE %}
{% load i18n i18n_helpers agency_navigation %}
<style>
.ms-lang-switcher { display: inline-flex; align-items: center; }
.ms-lang-switcher .form-select {
border-radius: 999px;
border: 1px solid #c7d4e9;
background: #ffffff;
color: #0f172a;
font-size: 0.82rem;
font-weight: 600;
line-height: 1.1;
padding: 0.36rem 1.85rem 0.36rem 0.8rem;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.ms-lang-switcher .form-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 0.18rem rgba(59, 130, 246, 0.18);
}
.ms-header-cta {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.55rem 1rem;
font-size: 0.84rem;
font-weight: 700;
text-decoration: none;
margin-left: 0.5rem;
}
</style>
<div class="header-right">
<form action="{% url 'set_language' %}" method="post" class="ms-lang-switcher me-2" aria-label="Language switcher">
{% csrf_token %}
<input name="next" type="hidden" value="{{ request.path|untranslated_url }}">
<label for="header-language-switcher" class="visually-hidden">{% trans "Language" %}</label>
<select id="header-language-switcher" name="language" class="form-select form-select-sm" onchange="this.form.submit()">
{% get_current_language as LANGUAGE_CODE %}
<option value="nl" {% if LANGUAGE_CODE == 'nl' %}selected{% endif %}>NL</option>
<option value="en" {% if LANGUAGE_CODE == 'en' %}selected{% endif %}>EN</option>
<option value="de" {% if LANGUAGE_CODE == 'de' %}selected{% endif %}>DE</option>
@@ -20,8 +48,11 @@
<a tabindex="0" aria-label="Open Search" role="search" class="search-toggler user-button menu-circle">
<i class="fa fa-search"></i>
</a>
<a href="{% url 'customer:summary' %}" aria-label="{% trans 'Customer summary' %}" class="user-button menu-circle"><i class="fa fa-user-solid"></i></a>
{% include 'oxyan/headers/partials/mini_basket.html' %}
{% agency_page 'contact' as contact_page %}
{% if contact_page %}
<a href="{{ contact_page.url }}" class="btn btn-primary ms-header-cta">{% agency_primary_cta %}</a>
{% endif %}
</div>
<div class="alert-messages-header" aria-live="polite">

View File

@@ -0,0 +1,141 @@
{% load staticfiles %}
{% load wagtailcore_tags wagtailimages_tags wagtailsettings_tags cache %}
{% get_settings %}
{% cache 300 footer_menu LANGUAGE_CODE request.site %}
<style>
.mb-footer-wrap {
margin-top: clamp(2rem, 4vw, 3.5rem);
position: relative;
}
.mb-footer {
position: relative;
background:
radial-gradient(120% 120% at 0% 0%, rgba(84, 149, 230, .22) 0%, rgba(84, 149, 230, 0) 45%),
radial-gradient(90% 120% at 100% 0%, rgba(65, 206, 186, .16) 0%, rgba(65, 206, 186, 0) 45%),
linear-gradient(180deg, #264f72 0%, #203f5c 100%);
border-radius: 28px 28px 0 0;
padding: clamp(2rem, 4vw, 3rem) 0;
box-shadow:
inset 0 1px 0 rgba(255,255,255,.12),
0 -10px 24px rgba(20, 43, 72, .20);
overflow: hidden;
}
.mb-footer:before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(255,255,255,.07) 0%, rgba(255,255,255,0) 35%, rgba(255,255,255,.06) 100%);
pointer-events: none;
}
.mb-footer .footer_column {
padding-top: .75rem;
padding-bottom: .75rem;
}
.mb-footer .mb-footer__card {
height: 100%;
background: rgba(255, 255, 255, .055);
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 16px;
padding: 1.1rem 1.15rem;
backdrop-filter: blur(1.2px);
}
.mb-footer .footer_header {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: .01em;
margin-bottom: .9rem;
color: #f4f8ff;
}
.mb-footer .footer_column,
.mb-footer .footer_column * {
color: rgba(237, 244, 255, .93);
}
.mb-footer .footer_column a {
color: #eef4ff;
text-decoration: none;
transition: color .2s ease, transform .2s ease;
}
.mb-footer .footer_column a:hover {
color: #ffffff;
transform: translateX(2px);
}
.mb-footer .footer_column .rich-text p {
margin-bottom: .65rem;
line-height: 1.65;
max-width: 34ch;
}
.mb-footer .mb-footer__card .aboutus-logo {
max-height: 52px;
width: auto;
}
.mb-footer .mb-footer__card .social {
margin-top: 1rem;
}
.mb-footer .mb-footer__card .social a {
border-color: rgba(255, 255, 255, .42);
color: #ffffff;
background: rgba(255,255,255,.08);
}
.mb-footer .mb-footer__card .social a:hover {
background: rgba(255,255,255,.18);
}
.mb-copyright {
background: #1b3650;
padding: 1rem 0;
border-top: 1px solid rgba(255,255,255,.16);
}
.mb-copyright .copyright_block,
.mb-copyright .copyright_block * {
color: rgba(234, 241, 255, .92);
margin: 0;
font-size: .95rem;
}
.mb-copyright .copyright_block a {
color: #ffffff;
text-decoration: none;
}
.mb-copyright .copyright_block a:hover {
text-decoration: underline;
}
@media (max-width: 991.98px) {
.mb-footer {
border-radius: 20px 20px 0 0;
}
.mb-footer .mb-footer__card {
padding: 1rem;
}
}
</style>
<div class="mb-footer-wrap">
<footer class="footer mb-footer">
<div class="container">
<div class="row g-4">
{% with footer=settings.ocyan_plugin_wagtail.OcyanSettings.footer %}
{% for block in footer %}
{% if block.block_type == 'page_list' and block.value.page and not block.value.page.get_children.live.public %}
{% else %}
<div class="{% if footer|length == 3 %}col-lg-4{% elif footer|length == 2 %}col-lg-6{% else %}col-lg-3{% endif %} col-md-6 col-sm-12 footer_column {{ block.block_type|slugify }}">
<div class="mb-footer__card">
{% include_block block %}
</div>
</div>
{% endif %}
{% endfor %}
{% endwith %}
</div>
</div>
</footer>
<section class="copyright_wrapper mb-copyright">
<div class="container">
<div class="row">
<div class="col-lg-12 copyright_block">
{% include_block settings.ocyan_plugin_wagtail.OcyanSettings.mini_footer %}
</div>
</div>
</div>
</section>
</div>
{% endcache %}

View File

@@ -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 %}
<li class="nav-item has_children">
<a class="nav-link category-label" data-name="{{ menu_item.name|safe }}" data-href="{{ menu_item.get_absolute_url }}" tabindex="-1">
<span>{% trans "Show everything in" %}</span>{{ menu_item.name }}
</a>
<ul class="menu-level">
{% else %}
<li class="nav-item child">
<a class="nav-link child-category" href="{{ menu_item.get_absolute_url }}" tabindex="-1">
{{ menu_item.name }}
</a>
</li>
{% endif %}
{% for close in menu_item.num_to_close %}
</ul>
</li>
{% endfor %}
{% endwith %}
{% endfor %}
{% endif %}

View File

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
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 = {
"about": 128,
"services": 129,
"projects": 130,
"contact": 131,
"process": 192,
}
NAV_CHILDREN = {
"services": [200, 201, 202, 203],
}
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:
page.nav_children = [
child
for source_id in NAV_CHILDREN.get(key, [])
if (child := _translated_page(source_id, language_code)) is not None
]
page.nav_key = key
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)
@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 COMMON_CTA.get(language_code, COMMON_CTA["nl"])["primary"]

View File

@@ -1,72 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
: "${STAGING_AUDIT_HOST:?STAGING_AUDIT_HOST 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}"
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 - <<PY > "$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_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 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"]]
project = os.environ["STAGING_AUDIT_PROJECT_NAME"]
remote_cmd = os.environ["REMOTE_CMD"]
timeout_seconds = int(os.environ["AUDIT_TIMEOUT_SECONDS"])
cmd = [
"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:
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=$?
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_seconds, check=False)
except subprocess.TimeoutExpired:
print(json.dumps({
"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:
if stdout:
print(stdout)
else:
print(json.dumps({
"error": "audit_failed",
"details": stderr or f"Audit command failed with exit status {result.returncode}",
"exit_code": result.returncode,
}, indent=2))
sys.exit(2)
print(stdout)
PY2
status=$?
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
cp "$TMP_FILE" "$OUTPUT_JSON"
cat "$OUTPUT_JSON"
exit $status