From d2badf49d40c2e22fba46a09595f5de79747db6c Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Thu, 14 May 2026 19:40:28 +0200 Subject: [PATCH] feat(bricks+wpml): full programmatic page generation + native WPML CTE integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions 3-4 deliverables. Establishes the Asterion content pipeline : EN pages generated programmatically from content-source/*-data.php, FR translations rebuilt natively by Bricks's WPML integration from EN structure + WPML strings (no direct FR _bricks_page_content_2 writes). ## tools/_av-bricks-shared.php (the library) Shared helpers + element atoms (eyebrow/heading/text/button/text-link) + section builders (hero/prose/list/cards/cta/upsell/closer) + page write pipeline that handles : write EN content via $wpdb (bypass Bricks's filter), force Bricks::Elements::load_elements() so controls are populated, fire wpml_page_builder_register_strings, push dict FR into icl_string_translations (skip user CTE edits, auto-prefix /fr on internal URLs), trigger Bricks native FR rebuild via wpml_page_builder_string_translated, and ensure a CTE job exists (icl_translate_job.editor='wpml' + revision=NULL + populate icl_translate rows with base64+gzip-encoded EN/FR strings). ## tools/av-generate-{industries,solutions}.php Thin wrappers : URL trigger (template_redirect, not init — Bricks elements load on 'wp' hook), add_filter('av_fr_translation_dict', ...) to register FR strings, content-type-specific hub builder. ~150 FR strings per type. ## tools/av-translate-{industries,solutions}-fr.php One-shot seeds for the initial FR linked posts via WPML trid mapping. ## tools/av-wpml-* operational mu-plugins - set-cte : switch doc_translation_method to ICL_TM_TMETHOD_EDITOR (=1) - create-jobs : reset CTE jobs (status=10 + needs_update=1 + editor=wpml + revision=NULL). Integrated into pipeline via av_ensure_wpml_cte_job(). - fix-status / repair : align FR posts to correct trid + cleanup orphans. - push-translations : push dict into icl_string_translations. - plugins-check / debug / dump-strings / icl-dump / rescan : diagnostics. ## tools/av-fix-link-types.php One-shot DB migration : convert legacy link.type='internal'+url to 'external' across all _bricks_page_*_2 meta values (JSON and PHP-serialized). Bricks's set_link_attributes() only renders href= for type='external' — older session-3 templates lost their href silently. ## Other tools/ mu-plugins - av-bricks-rest-bridge : pivot — exposes Bricks CPTs over REST, dual-format read filter (JSON for MCP / array for renderer), auto-mirror content→header/ footer for templates. - av-apply-bricks-styles, av-cleanup-orphans, av-create-wp-menu, av-debug-meta, av-dump-bricks, av-fix-template-meta : misc operational scripts. ## Bricks Config/ JSON snapshots of bricks_theme_styles, bricks_color_palette, bricks_global_settings for diffable inspection. ## .gitignore Add .mcp.json — holds per-developer WP App Password for the Bricks MCP server. ## wp-content/themes/asterion-bricks/assets/css/utilities.css Note that all responsive overrides are now emitted natively by the page generator via Bricks per-breakpoint settings, no custom CSS needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 + .../bricks-styles-color-palette-asterion.json | 81 + .../bricks-styles-color-palette-default.json | 96 + Bricks Config/color-palette-current.json | 179 ++ Bricks Config/global-settings-current.json | 7 + Bricks Config/theme-styles-current.json | 36 + tools/README.md | 25 +- tools/_av-bricks-shared.php | 1567 +++++++++++++++++ tools/av-apply-bricks-styles.php | 200 +++ tools/av-bricks-rest-bridge.php | 304 ++++ tools/av-cleanup-orphans.php | 48 + tools/av-create-wp-menu.php | 99 ++ tools/av-debug-meta.php | 31 + tools/av-dump-bricks.php | 34 + tools/av-fix-link-types.php | 81 + tools/av-fix-template-meta.php | 68 + tools/av-generate-industries.php | 149 ++ tools/av-generate-solutions.php | 378 ++++ tools/av-translate-industries-fr.php | 61 + tools/av-translate-solutions-fr.php | 61 + tools/av-wpml-create-jobs.php | 185 ++ tools/av-wpml-debug.php | 59 + tools/av-wpml-fix-status.php | 270 +++ tools/av-wpml-plugins-check.php | 50 + tools/av-wpml-push-translations.php | 122 ++ tools/av-wpml-rescan.php | 61 + tools/av-wpml-set-cte.php | 44 + .../asterion-bricks/assets/css/utilities.css | 7 + 28 files changed, 4305 insertions(+), 1 deletion(-) create mode 100644 Bricks Config/bricks-styles-color-palette-asterion.json create mode 100644 Bricks Config/bricks-styles-color-palette-default.json create mode 100644 Bricks Config/color-palette-current.json create mode 100644 Bricks Config/global-settings-current.json create mode 100644 Bricks Config/theme-styles-current.json create mode 100644 tools/_av-bricks-shared.php create mode 100644 tools/av-apply-bricks-styles.php create mode 100644 tools/av-bricks-rest-bridge.php create mode 100644 tools/av-cleanup-orphans.php create mode 100644 tools/av-create-wp-menu.php create mode 100644 tools/av-debug-meta.php create mode 100644 tools/av-dump-bricks.php create mode 100644 tools/av-fix-link-types.php create mode 100644 tools/av-fix-template-meta.php create mode 100644 tools/av-generate-industries.php create mode 100644 tools/av-generate-solutions.php create mode 100644 tools/av-translate-industries-fr.php create mode 100644 tools/av-translate-solutions-fr.php create mode 100644 tools/av-wpml-create-jobs.php create mode 100644 tools/av-wpml-debug.php create mode 100644 tools/av-wpml-fix-status.php create mode 100644 tools/av-wpml-plugins-check.php create mode 100644 tools/av-wpml-push-translations.php create mode 100644 tools/av-wpml-rescan.php create mode 100644 tools/av-wpml-set-cte.php diff --git a/.gitignore b/.gitignore index 92d2eb3..1865ce2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ ehthumbs.db # --- Claude Code local config (per-developer) --- .claude/settings.local.json +# .mcp.json holds the WP Application Password for the Bricks MCP server, +# which is per-developer and must never reach the remote. +.mcp.json # --- Environment / secrets --- .env diff --git a/Bricks Config/bricks-styles-color-palette-asterion.json b/Bricks Config/bricks-styles-color-palette-asterion.json new file mode 100644 index 0000000..d2c5cd0 --- /dev/null +++ b/Bricks Config/bricks-styles-color-palette-asterion.json @@ -0,0 +1,81 @@ +{ + "id": "a5701b", + "name": "Asterion", + "colors": [ + { + "light": "#0B1F3A", + "raw": "var(--color-brand-navy)", + "id": "9c1d2a" + }, + { + "light": "#081427", + "raw": "var(--color-brand-navy-deep)", + "id": "1f4b88" + }, + { + "light": "#C9A45A", + "raw": "var(--color-brand-gold)", + "id": "e74c3b" + }, + { + "light": "#E0C892", + "raw": "var(--color-brand-gold-soft)", + "id": "62a190" + }, + { + "light": "#C8102E", + "raw": "var(--color-alert-red)", + "id": "84d709" + }, + { + "light": "#0B1F3A", + "raw": "var(--color-text-primary)", + "id": "3fb52c" + }, + { + "light": "#3E4C5E", + "raw": "var(--color-text-secondary)", + "id": "57e441" + }, + { + "light": "#6B7785", + "raw": "var(--color-text-muted)", + "id": "ad8b16" + }, + { + "light": "#FFFFFF", + "raw": "var(--color-text-on-dark)", + "id": "c9027d" + }, + { + "light": "#FFFFFF", + "raw": "var(--color-bg-default)", + "id": "20fa53" + }, + { + "light": "#F5F7FA", + "raw": "var(--color-bg-subtle)", + "id": "8e6c0f" + }, + { + "light": "#0B1F3A", + "raw": "var(--color-bg-dark)", + "id": "4d318a" + }, + { + "light": "#13294B", + "raw": "var(--color-bg-dark-elevated)", + "id": "f1957e" + }, + { + "light": "#CCD3DC", + "raw": "var(--color-border-default)", + "id": "7b46c2" + }, + { + "light": "#1E5BA8", + "raw": "var(--color-info)", + "id": "06bdfa" + } + ] +} diff --git a/Bricks Config/bricks-styles-color-palette-default.json b/Bricks Config/bricks-styles-color-palette-default.json new file mode 100644 index 0000000..4364399 --- /dev/null +++ b/Bricks Config/bricks-styles-color-palette-default.json @@ -0,0 +1,96 @@ +{ + "id": "44a09f", + "name": "Default", + "colors": [ + { + "light": "#f5f5f5", + "raw": "var(--bricks-color-grey-100)", + "id": "ab2156" + }, + { + "light": "#e0e0e0", + "raw": "var(--bricks-color-grey-300)", + "id": "664644" + }, + { + "light": "#9e9e9e", + "raw": "var(--bricks-color-grey-500)", + "id": "eec8d2" + }, + { + "light": "#616161", + "raw": "var(--bricks-color-grey-700)", + "id": "68168c" + }, + { + "light": "#424242", + "raw": "var(--bricks-color-grey-800)", + "id": "5ce47e" + }, + { + "light": "#212121", + "raw": "var(--bricks-color-grey-900)", + "id": "f5049f" + }, + { + "light": "#ffeb3b", + "raw": "var(--bricks-color-yellow)", + "id": "9d011c" + }, + { + "light": "#ffc107", + "raw": "var(--bricks-color-amber)", + "id": "dacd30" + }, + { + "light": "#ff9800", + "raw": "var(--bricks-color-orange)", + "id": "3bcffd" + }, + { + "light": "#ff5722", + "raw": "var(--bricks-color-deep-orange)", + "id": "2ce1e9" + }, + { + "light": "#f44336", + "raw": "var(--bricks-color-red)", + "id": "57b992" + }, + { + "light": "#9c27b0", + "raw": "var(--bricks-color-purple)", + "id": "5825c2" + }, + { + "light": "#2196f3", + "raw": "var(--bricks-color-blue)", + "id": "0e5dd9" + }, + { + "light": "#03a9f4", + "raw": "var(--bricks-color-light-blue)", + "id": "f5d57b" + }, + { + "light": "#81D4FA", + "raw": "var(--bricks-color-sky-blue)", + "id": "7dd108" + }, + { + "light": "#4caf50", + "raw": "var(--bricks-color-green)", + "id": "5c8f35" + }, + { + "light": "#8bc34a", + "raw": "var(--bricks-color-light-green)", + "id": "432723" + }, + { + "light": "#cddc39", + "raw": "var(--bricks-color-lime)", + "id": "25a3a8" + } + ] +} \ No newline at end of file diff --git a/Bricks Config/color-palette-current.json b/Bricks Config/color-palette-current.json new file mode 100644 index 0000000..42dbb96 --- /dev/null +++ b/Bricks Config/color-palette-current.json @@ -0,0 +1,179 @@ +[ + { + "id": "44a09f", + "name": "Default", + "colors": [ + { + "light": "#f5f5f5", + "raw": "var(--bricks-color-grey-100)", + "id": "ab2156" + }, + { + "light": "#e0e0e0", + "raw": "var(--bricks-color-grey-300)", + "id": "664644" + }, + { + "light": "#9e9e9e", + "raw": "var(--bricks-color-grey-500)", + "id": "eec8d2" + }, + { + "light": "#616161", + "raw": "var(--bricks-color-grey-700)", + "id": "68168c" + }, + { + "light": "#424242", + "raw": "var(--bricks-color-grey-800)", + "id": "5ce47e" + }, + { + "light": "#212121", + "raw": "var(--bricks-color-grey-900)", + "id": "f5049f" + }, + { + "light": "#ffeb3b", + "raw": "var(--bricks-color-yellow)", + "id": "9d011c" + }, + { + "light": "#ffc107", + "raw": "var(--bricks-color-amber)", + "id": "dacd30" + }, + { + "light": "#ff9800", + "raw": "var(--bricks-color-orange)", + "id": "3bcffd" + }, + { + "light": "#ff5722", + "raw": "var(--bricks-color-deep-orange)", + "id": "2ce1e9" + }, + { + "light": "#f44336", + "raw": "var(--bricks-color-red)", + "id": "57b992" + }, + { + "light": "#9c27b0", + "raw": "var(--bricks-color-purple)", + "id": "5825c2" + }, + { + "light": "#2196f3", + "raw": "var(--bricks-color-blue)", + "id": "0e5dd9" + }, + { + "light": "#03a9f4", + "raw": "var(--bricks-color-light-blue)", + "id": "f5d57b" + }, + { + "light": "#81D4FA", + "raw": "var(--bricks-color-sky-blue)", + "id": "7dd108" + }, + { + "light": "#4caf50", + "raw": "var(--bricks-color-green)", + "id": "5c8f35" + }, + { + "light": "#8bc34a", + "raw": "var(--bricks-color-light-green)", + "id": "432723" + }, + { + "light": "#cddc39", + "raw": "var(--bricks-color-lime)", + "id": "25a3a8" + } + ] + }, + { + "id": "rudrjy", + "name": "Asterion", + "colors": [ + { + "light": "#0B1F3A", + "raw": "var(--color-brand-navy)", + "id": "9c1d2a" + }, + { + "light": "#081427", + "raw": "var(--color-brand-navy-deep)", + "id": "1f4b88" + }, + { + "light": "#C9A45A", + "raw": "var(--color-brand-gold)", + "id": "e74c3b" + }, + { + "light": "#E0C892", + "raw": "var(--color-brand-gold-soft)", + "id": "62a190" + }, + { + "light": "#C8102E", + "raw": "var(--color-alert-red)", + "id": "84d709" + }, + { + "light": "#0B1F3A", + "raw": "var(--color-text-primary)", + "id": "3fb52c" + }, + { + "light": "#3E4C5E", + "raw": "var(--color-text-secondary)", + "id": "57e441" + }, + { + "light": "#6B7785", + "raw": "var(--color-text-muted)", + "id": "ad8b16" + }, + { + "light": "#FFFFFF", + "raw": "var(--color-text-on-dark)", + "id": "c9027d" + }, + { + "light": "#FFFFFF", + "raw": "var(--color-bg-default)", + "id": "20fa53" + }, + { + "light": "#F5F7FA", + "raw": "var(--color-bg-subtle)", + "id": "8e6c0f" + }, + { + "light": "#0B1F3A", + "raw": "var(--color-bg-dark)", + "id": "4d318a" + }, + { + "light": "#13294B", + "raw": "var(--color-bg-dark-elevated)", + "id": "f1957e" + }, + { + "light": "#CCD3DC", + "raw": "var(--color-border-default)", + "id": "7b46c2" + }, + { + "light": "#1E5BA8", + "raw": "var(--color-info)", + "id": "06bdfa" + } + ] + } +] \ No newline at end of file diff --git a/Bricks Config/global-settings-current.json b/Bricks Config/global-settings-current.json new file mode 100644 index 0000000..b29d769 --- /dev/null +++ b/Bricks Config/global-settings-current.json @@ -0,0 +1,7 @@ +{ + "postTypes": [ + "page" + ], + "builderMode": "dark", + "builderToolbarLogoLink": "current" +} \ No newline at end of file diff --git a/Bricks Config/theme-styles-current.json b/Bricks Config/theme-styles-current.json new file mode 100644 index 0000000..d7d3d55 --- /dev/null +++ b/Bricks Config/theme-styles-current.json @@ -0,0 +1,36 @@ +{ + "asterion": { + "label": "Asterion", + "settings": { + "_custom": true, + "conditions": { + "conditions": [ + { + "id": "skuksb", + "main": "any" + } + ] + }, + "colors": { + "colorPrimary": { + "raw": "var(--color-brand-navy)" + }, + "colorSecondary": { + "raw": "var(--color-brand-gold)" + }, + "colorLight": { + "raw": "var(--color-bg-default)" + }, + "colorDark": { + "raw": "var(--color-bg-dark)" + }, + "colorMuted": { + "raw": "var(--color-text-muted)" + }, + "colorBorder": { + "raw": "var(--color-border-default)" + } + } + } + } +} \ No newline at end of file diff --git a/tools/README.md b/tools/README.md index a3274e3..5fef5f1 100644 --- a/tools/README.md +++ b/tools/README.md @@ -38,9 +38,32 @@ Quick recap : Then activate the Bricks license (Bricks → License) and run the WPML setup wizard manually — those need your license keys and an interactive choice flow that's safer to do in admin. +## av-apply-bricks-styles.php + +One-shot mu-plugin that writes the full Asterion Theme Style to `wp_options` (`bricks_theme_styles`) AND fixes `bricks_global_settings.postTypes` to include `page, post, case_study, scenario`. + +Why a mu-plugin : Bricks stores Theme Styles as serialized PHP in the DB (not in a file), so we need code that runs in the WP context. Mu-plugin URL trigger is the simplest path; LocalWP CLI PHP can't connect to the per-site MySQL socket without extra config. + +Usage : + +1. Copy this file → `\app\public\wp-content\mu-plugins\av-apply-bricks-styles.php` +2. Visit `http://localhost:10004/?av_apply_styles=1` (logged in as admin) +3. A dark-themed report shows what was written. +4. Reload the Bricks builder (Ctrl+R) → Theme Style "Asterion" applied to entire website. + +Idempotent — re-running overwrites with the same payload. Edit the file then re-trigger to iterate. + +Sets : General (site bg), Colors (11 semantic roles), Links (gold + underline), Typography (Inter, body 16px, h1-h6 sizes from `tokens.css`), Button (primary navy, secondary gold, padding scale), Section (5rem padding), Container (max 1280px), Focus outline. + +## av-dump-bricks.php + +One-shot mu-plugin that exports the live `bricks_theme_styles`, `bricks_color_palette`, `bricks_global_settings` options to JSON files in `Bricks Config/` for inspection or git-versioned snapshots. + +Usage : drop into mu-plugins, visit `?av_dump_bricks=1` (admin), three JSONs appear in `Bricks Config/`. + ## What's missing — manual follow-up after fresh setup - Bricks license key activation (Bricks → License) - WPML setup wizard (WPML → Setup) : EN default + FR secondary, URL strategy = "Different languages in directories" -- Bricks → Theme Styles : align colors with `tokens.css` (Brand Navy `#0B1F3A`, Brand Gold `#C9A45A`, etc.) +- Run `?av_apply_styles=1` (see above) to push the Theme Style — replaces clicking through Bricks → Theme Styles. - Build the Header and Footer Bricks Templates (per RESTART-PLAN.md Phase 4) diff --git a/tools/_av-bricks-shared.php b/tools/_av-bricks-shared.php new file mode 100644 index 0000000..43976a1 --- /dev/null +++ b/tools/_av-bricks-shared.php @@ -0,0 +1,1567 @@ + $var]; } +function av_sp($t, $r, $b, $l) { + return ['top' => (string)$t, 'right' => (string)$r, 'bottom' => (string)$b, 'left' => (string)$l]; +} +function av_radius($r) { return ['radius' => av_sp($r, $r, $r, $r)]; } + +/** + * Generate a short readable ID prefixed by $base. + * Bricks accepts any string as element ID — these are stable across runs + * because we never call this for IDs that need to match parent/children. + */ +function av_id($base, $i = 0) { + return substr($base . substr(md5($base . $i . microtime()), 0, 4), 0, 6); +} + +/** + * Section vertical padding per breakpoint. + * Default desktop : 5rem top/bottom. Override via args. + */ +function av_section_padding_responsive($top_desktop = '5rem', $bottom_desktop = '5rem') { + return [ + '_padding' => av_sp($top_desktop, '0', $bottom_desktop, '0'), + '_padding:tablet_portrait' => av_sp('4rem', '0', '4rem', '0'), + '_padding:mobile_landscape' => av_sp('3rem', '0', '3rem', '0'), + '_padding:mobile_portrait' => av_sp('2rem', '0', '2rem', '0'), + ]; +} + +/** Outer container horizontal gutters — tighten on phones. */ +function av_outer_padding_responsive() { + return [ + '_padding' => av_sp('0', '1.5rem', '0', '1.5rem'), + '_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'), + ]; +} + +/* ========================================================================= + 2. ELEMENT ATOMS + ========================================================================= */ + +function av_eyebrow($id, $parent, $text, $color = 'var(--color-brand-gold)') { + return [ + 'id' => $id, 'name' => 'text-basic', 'parent' => $parent, 'children' => [], + 'settings' => [ + 'text' => $text, + '_typography' => [ + 'color' => av_color($color), + 'font-weight' => '600', + 'letter-spacing' => '0.08em', + 'font-size' => '0.75rem', + 'text-transform' => 'uppercase', + ], + ], + ]; +} + +function av_heading($id, $parent, $tag, $text, $color = null) { + $settings = ['tag' => $tag, 'text' => $text]; + if ($color) $settings['_typography'] = ['color' => av_color($color)]; + return [ + 'id' => $id, 'name' => 'heading', 'parent' => $parent, 'children' => [], + 'settings' => $settings, + ]; +} + +function av_text($id, $parent, $text, $color = 'var(--color-text-secondary)', $size = '1rem', $line = '1.5') { + return [ + 'id' => $id, 'name' => 'text-basic', 'parent' => $parent, 'children' => [], + 'settings' => [ + 'text' => $text, + '_typography' => [ + 'color' => av_color($color), + 'font-size' => $size, + 'line-height' => $line, + ], + ], + ]; +} + +function av_button($id, $parent, $text, $url, $variant = 'primary') { + $base = [ + 'text' => $text, + // type='external' is the correct Bricks shape for plain URLs (relative or absolute). + // 'internal' would require a numeric postId. See bricks/includes/elements/base.php + // → set_link_attributes() : only 'external' uses the 'url' string verbatim. + 'link' => ['type' => 'external', 'url' => $url], + '_typography' => ['font-weight' => '600', 'text-decoration' => 'none'], + '_padding' => av_sp('0.75rem', '1.5rem', '0.75rem', '1.5rem'), + '_border' => av_radius('4px'), + ]; + + if ($variant === 'primary') { + $base['_background'] = ['color' => av_color('var(--color-brand-navy)')]; + $base['_typography']['color'] = av_color('var(--color-text-on-dark)'); + } elseif ($variant === 'gold') { + $base['_background'] = ['color' => av_color('var(--color-brand-gold)')]; + $base['_typography']['color'] = av_color('var(--color-brand-navy)'); + } elseif ($variant === 'secondary') { + // outline navy + $base['_background'] = ['color' => 'transparent']; + $base['_typography']['color'] = av_color('var(--color-brand-navy)'); + $base['_border'] = [ + 'width' => av_sp('1px', '1px', '1px', '1px'), + 'color' => av_color('var(--color-brand-navy)'), + 'style' => 'solid', + 'radius' => av_sp('4px', '4px', '4px', '4px'), + ]; + } elseif ($variant === 'outline-on-dark') { + $base['_background'] = ['color' => 'transparent']; + $base['_typography']['color'] = av_color('var(--color-text-on-dark)'); + $base['_border'] = [ + 'width' => av_sp('1px', '1px', '1px', '1px'), + 'color' => av_color('var(--color-text-on-dark)'), + 'style' => 'solid', + 'radius' => av_sp('4px', '4px', '4px', '4px'), + ]; + } + + return [ + 'id' => $id, 'name' => 'button', 'parent' => $parent, 'children' => [], + 'settings' => $base, + ]; +} + +function av_text_link($id, $parent, $text, $url, $color = 'var(--color-brand-navy)') { + return [ + 'id' => $id, 'name' => 'text-link', 'parent' => $parent, 'children' => [], + 'settings' => [ + 'text' => $text, + // type='external' = Bricks renders href verbatim. 'internal' needs postId. + 'link' => ['type' => 'external', 'url' => $url], + '_typography' => [ + 'color' => av_color($color), + 'font-weight' => '600', + 'text-decoration' => 'none', + 'font-size' => '0.9375rem', + ], + ], + ]; +} + +/* ========================================================================= + 3. SECTION BUILDERS + Convention : + section (full bleed) → outer container (1280, w 100%, padding gutters, + align-items: flex-start) → narrative inner (800, w 100%, margin 0 auto + 0 0) → headings / text / list / cta-row + Cards section : narrative inner for eyebrow+h2, grid takes outer width. + ========================================================================= */ + +function av_build_hero($slug, $d) { + $sid = "h{$slug}sc"; // section + $oct = "h{$slug}oc"; // outer container + $nrr = "h{$slug}nr"; // narrative inner + $eybId = "h{$slug}eb"; + $h1Id = "h{$slug}h1"; + $ledId = "h{$slug}ld"; + $rowId = "h{$slug}rw"; + + $narrative_children = [$eybId, $h1Id]; + if (! empty($d['hero_lead'])) $narrative_children[] = $ledId; + if (! empty($d['ctas'])) $narrative_children[] = $rowId; + + $els = []; + + $els[] = [ + 'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct], + 'settings' => [ + '_padding' => av_sp('6rem', '0', '5rem', '0'), + '_padding:tablet_portrait' => av_sp('4rem', '0', '4rem', '0'), + '_padding:mobile_landscape' => av_sp('3rem', '0', '3rem', '0'), + '_padding:mobile_portrait' => av_sp('2.5rem', '0', '2rem', '0'), + '_background' => ['color' => av_color('var(--color-bg-default)')], + ], + ]; + $els[] = [ + 'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => [$nrr], + 'settings' => [ + '_direction' => 'column', + '_widthMax' => '1280px', + '_width' => '100%', + '_padding' => av_sp('0', '1.5rem', '0', '1.5rem'), + '_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'), + '_alignItems' => 'flex-start', + ], + ]; + $els[] = [ + 'id' => $nrr, 'name' => 'container', 'parent' => $oct, 'children' => $narrative_children, + 'settings' => [ + '_direction' => 'column', + '_widthMax' => '800px', + '_widthMin' => '0', + '_width' => '100%', + '_margin' => av_sp('0', 'auto', '0', '0'), + '_padding' => av_sp('0', '0', '0', '0'), + '_rowGap' => '1.25rem', + ], + ]; + + $els[] = av_eyebrow($eybId, $nrr, $d['eyebrow'] ?? 'Industries'); + $els[] = av_heading($h1Id, $nrr, 'h1', $d['tagline'] ?? $d['title']); + if (! empty($d['hero_lead'])) { + $els[] = av_text($ledId, $nrr, $d['hero_lead'], 'var(--color-text-secondary)', '1.125rem', '1.55'); + } + + if (! empty($d['ctas'])) { + $btn_ids = []; + foreach ($d['ctas'] as $i => $cta) $btn_ids[] = "h{$slug}b{$i}"; + $els[] = [ + 'id' => $rowId, 'name' => 'container', 'parent' => $nrr, 'children' => $btn_ids, + 'settings' => [ + '_direction' => 'row', + '_columnGap' => '1rem', + '_rowGap' => '1rem', + '_flexWrap' => 'wrap', + '_widthMin' => '0', + '_width' => '100%', + '_padding' => av_sp('0.75rem', '0', '0', '0'), + ], + ]; + foreach ($d['ctas'] as $i => $cta) { + $variant = $cta['variant'] === 'primary' ? 'primary' : 'secondary'; + $els[] = av_button("h{$slug}b{$i}", $rowId, $cta['label'], $cta['href'], $variant); + } + } + + return $els; +} + +function av_build_prose_section($slug, $i, $section, $bg = 'var(--color-bg-subtle)') { + $sid = "s{$slug}p{$i}"; + $oct = "o{$slug}p{$i}"; + $nrr = "n{$slug}p{$i}"; + $eyb = "e{$slug}p{$i}"; + $hId = "h{$slug}p{$i}"; + $bId = "b{$slug}p{$i}"; + + $narrative_children = []; + if (! empty($section['eyebrow'])) $narrative_children[] = $eyb; + if (! empty($section['title'])) $narrative_children[] = $hId; + if (! empty($section['body'])) $narrative_children[] = $bId; + + $els = []; + $els[] = [ + 'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct], + 'settings' => [ + '_padding' => av_sp('5rem', '0', '5rem', '0'), + '_padding:tablet_portrait' => av_sp('4rem', '0', '4rem', '0'), + '_padding:mobile_landscape' => av_sp('3rem', '0', '3rem', '0'), + '_padding:mobile_portrait' => av_sp('2rem', '0', '2rem', '0'), + '_background' => ['color' => av_color($bg)], + ], + ]; + $els[] = [ + 'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => [$nrr], + 'settings' => [ + '_direction' => 'column', + '_widthMax' => '1280px', + '_width' => '100%', + '_padding' => av_sp('0', '1.5rem', '0', '1.5rem'), + '_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'), + '_alignItems' => 'flex-start', + ], + ]; + $els[] = [ + 'id' => $nrr, 'name' => 'container', 'parent' => $oct, 'children' => $narrative_children, + 'settings' => [ + '_direction' => 'column', + '_widthMax' => '800px', + '_widthMin' => '0', + '_width' => '100%', + '_margin' => av_sp('0', 'auto', '0', '0'), + '_padding' => av_sp('0', '0', '0', '0'), + '_rowGap' => '1rem', + ], + ]; + if (! empty($section['eyebrow'])) $els[] = av_eyebrow($eyb, $nrr, $section['eyebrow']); + if (! empty($section['title'])) $els[] = av_heading($hId, $nrr, 'h2', $section['title']); + if (! empty($section['body'])) $els[] = av_text($bId, $nrr, $section['body'], 'var(--color-text-secondary)', '1.0625rem', '1.6'); + + return $els; +} + +function av_build_list_section($slug, $i, $section, $bg = 'var(--color-bg-default)') { + $sid = "s{$slug}l{$i}"; + $oct = "o{$slug}l{$i}"; + $nrr = "n{$slug}l{$i}"; + $eyb = "e{$slug}l{$i}"; + $hId = "h{$slug}l{$i}"; + $lead = "ld{$slug}l{$i}"; + $list = "l{$slug}l{$i}"; + + $list_items = $section['items'] ?? []; + $item_ids = []; + foreach ($list_items as $j => $_) $item_ids[] = "i{$slug}l{$i}x{$j}"; + + $narrative_children = []; + if (! empty($section['eyebrow'])) $narrative_children[] = $eyb; + if (! empty($section['title'])) $narrative_children[] = $hId; + if (! empty($section['lead'])) $narrative_children[] = $lead; // optional intro paragraph + $narrative_children[] = $list; + $cta_id = null; + if (! empty($section['cta'])) { + $cta_id = "k{$slug}l{$i}"; + $narrative_children[] = $cta_id; + } + + $els = []; + $els[] = [ + 'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct], + 'settings' => [ + '_padding' => av_sp('5rem', '0', '5rem', '0'), + '_padding:tablet_portrait' => av_sp('4rem', '0', '4rem', '0'), + '_padding:mobile_landscape' => av_sp('3rem', '0', '3rem', '0'), + '_padding:mobile_portrait' => av_sp('2rem', '0', '2rem', '0'), + '_background' => ['color' => av_color($bg)], + ], + ]; + $els[] = [ + 'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => [$nrr], + 'settings' => [ + '_direction' => 'column', + '_widthMax' => '1280px', + '_width' => '100%', + '_padding' => av_sp('0', '1.5rem', '0', '1.5rem'), + '_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'), + '_alignItems' => 'flex-start', + ], + ]; + $els[] = [ + 'id' => $nrr, 'name' => 'container', 'parent' => $oct, 'children' => $narrative_children, + 'settings' => [ + '_direction' => 'column', + '_widthMax' => '800px', + '_widthMin' => '0', + '_width' => '100%', + '_margin' => av_sp('0', 'auto', '0', '0'), + '_padding' => av_sp('0', '0', '0', '0'), + '_rowGap' => '1.25rem', + ], + ]; + if (! empty($section['eyebrow'])) $els[] = av_eyebrow($eyb, $nrr, $section['eyebrow']); + if (! empty($section['title'])) $els[] = av_heading($hId, $nrr, 'h2', $section['title']); + if (! empty($section['lead'])) $els[] = av_text($lead, $nrr, $section['lead'], 'var(--color-text-secondary)', '1.0625rem', '1.6'); + + $els[] = [ + 'id' => $list, 'name' => 'container', 'parent' => $nrr, 'children' => $item_ids, + 'settings' => [ + '_direction' => 'column', + '_rowGap' => '0.75rem', + '_width' => '100%', + '_padding' => av_sp('0.25rem', '0', '0', '0'), + ], + ]; + + foreach ($list_items as $j => $item) { + $els[] = [ + 'id' => "i{$slug}l{$i}x{$j}", 'name' => 'text-basic', 'parent' => $list, 'children' => [], + 'settings' => [ + 'text' => '— ' . $item, + '_typography' => [ + 'color' => av_color('var(--color-text-secondary)'), + 'font-size' => '1rem', + 'line-height' => '1.55', + ], + ], + ]; + } + + if ($cta_id) { + $els[] = av_text_link($cta_id, $nrr, $section['cta']['label'], $section['cta']['href'], 'var(--color-brand-navy)'); + } + + return $els; +} + +function av_build_cards_section($slug, $i, $section, $bg = 'var(--color-bg-subtle)') { + $sid = "s{$slug}c{$i}"; + $oct = "o{$slug}c{$i}"; + $nrr = "n{$slug}c{$i}"; + $eyb = "e{$slug}c{$i}"; + $hId = "h{$slug}c{$i}"; + $grid = "g{$slug}c{$i}"; + + $items = $section['items'] ?? []; + $card_count = count($items); + $card_ids = []; + foreach ($items as $j => $_) $card_ids[] = "k{$slug}c{$i}x{$j}"; + + $narrative_children = []; + if (! empty($section['eyebrow'])) $narrative_children[] = $eyb; + if (! empty($section['title'])) $narrative_children[] = $hId; + + $oct_children = []; + if (! empty($narrative_children)) $oct_children[] = $nrr; + $oct_children[] = $grid; + $cta_id = null; + if (! empty($section['cta'])) { + $cta_id = "kc{$slug}c{$i}"; + $oct_children[] = $cta_id; + } + + // Card sizing per Bricks breakpoints. 2 cards/row at desktop if exactly 2 items. + // 3 cards/row if 3. Otherwise 4-up grid. Always 2-up at tablet_portrait, 1-up at mobile_portrait. + if ($card_count === 2) { + $flex_desktop = 'calc((100% - 1.5rem) / 2)'; + } elseif ($card_count === 3) { + $flex_desktop = 'calc((100% - 3rem) / 3)'; + } elseif ($card_count === 5) { + // 5 cards : we wrap them as 3 + 2 — set flex-basis for 3-up + $flex_desktop = 'calc((100% - 3rem) / 3)'; + } else { + // default 4 cards/row (and 6 → 4 + 2 wrapped) + $flex_desktop = 'calc((100% - 4.5rem) / 4)'; + } + + $els = []; + $els[] = [ + 'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct], + 'settings' => [ + '_padding' => av_sp('5rem', '0', '5rem', '0'), + '_padding:tablet_portrait' => av_sp('4rem', '0', '4rem', '0'), + '_padding:mobile_landscape' => av_sp('3rem', '0', '3rem', '0'), + '_padding:mobile_portrait' => av_sp('2rem', '0', '2rem', '0'), + '_background' => ['color' => av_color($bg)], + ], + ]; + $els[] = [ + 'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => $oct_children, + 'settings' => [ + '_direction' => 'column', + '_widthMax' => '1280px', + '_width' => '100%', + '_padding' => av_sp('0', '1.5rem', '0', '1.5rem'), + '_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'), + '_alignItems' => 'flex-start', + '_rowGap' => '2rem', + ], + ]; + + if (! empty($narrative_children)) { + $els[] = [ + 'id' => $nrr, 'name' => 'container', 'parent' => $oct, 'children' => $narrative_children, + 'settings' => [ + '_direction' => 'column', + '_widthMax' => '800px', + '_widthMin' => '0', + '_width' => '100%', + '_margin' => av_sp('0', 'auto', '0', '0'), + '_padding' => av_sp('0', '0', '0', '0'), + '_rowGap' => '0.75rem', + ], + ]; + if (! empty($section['eyebrow'])) $els[] = av_eyebrow($eyb, $nrr, $section['eyebrow']); + if (! empty($section['title'])) $els[] = av_heading($hId, $nrr, 'h2', $section['title']); + } + + $els[] = [ + 'id' => $grid, 'name' => 'container', 'parent' => $oct, 'children' => $card_ids, + 'settings' => [ + '_direction' => 'row', + '_columnGap' => '1.5rem', + '_rowGap' => '1.5rem', + '_flexWrap' => 'wrap', + '_alignItems' => 'stretch', + '_widthMin' => '0', + '_widthMax' => '100%', + '_width' => '100%', + '_padding' => av_sp('0', '0', '0', '0'), + ], + ]; + + foreach ($items as $j => $item) { + $kid = "k{$slug}c{$i}x{$j}"; + $khh = "kh{$slug}c{$i}x{$j}"; + $kbb = "kb{$slug}c{$i}x{$j}"; + $els[] = [ + 'id' => $kid, 'name' => 'container', 'parent' => $grid, 'children' => [$khh, $kbb], + 'settings' => [ + '_direction' => 'column', + '_rowGap' => '0.75rem', + '_padding' => av_sp('1.75rem', '1.75rem', '1.75rem', '1.75rem'), + '_background' => ['color' => av_color('var(--color-bg-default)')], + '_border' => [ + 'width' => av_sp('1px', '1px', '1px', '1px'), + 'color' => av_color('var(--color-border-subtle)'), + 'style' => 'solid', + 'radius' => av_sp('4px', '4px', '4px', '4px'), + ], + '_flexBasis' => $flex_desktop, + '_flexBasis:tablet_portrait' => 'calc((100% - 1.5rem) / 2)', + '_flexBasis:mobile_portrait' => '100%', + '_flexGrow' => '0', + '_flexShrink' => '0', + '_widthMin' => '0', + '_widthMax' => '100%', + ], + ]; + $els[] = av_heading($khh, $kid, 'h3', $item['title']); + $els[] = av_text($kbb, $kid, $item['body'], 'var(--color-text-secondary)', '1rem', '1.55'); + } + + if ($cta_id) { + $els[] = av_text_link($cta_id, $oct, $section['cta']['label'], $section['cta']['href'], 'var(--color-brand-navy)'); + } + + return $els; +} + +/** + * 'cta' section type : prose-style heading + body + button row, on a tinted bg. + * Visually a mid-page conversion accent (vs. closer CTA which is full navy). + */ +function av_build_cta_section($slug, $i, $section, $bg = 'var(--color-bg-subtle)') { + $sid = "s{$slug}cta{$i}"; + $oct = "o{$slug}cta{$i}"; + $nrr = "n{$slug}cta{$i}"; + $eyb = "e{$slug}cta{$i}"; + $hId = "h{$slug}cta{$i}"; + $bId = "b{$slug}cta{$i}"; + $row = "r{$slug}cta{$i}"; + + $narrative_children = []; + if (! empty($section['eyebrow'])) $narrative_children[] = $eyb; + if (! empty($section['title'])) $narrative_children[] = $hId; + if (! empty($section['body'])) $narrative_children[] = $bId; + $narrative_children[] = $row; + + $els = []; + $els[] = [ + 'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct], + 'settings' => [ + '_padding' => av_sp('5rem', '0', '5rem', '0'), + '_padding:tablet_portrait' => av_sp('4rem', '0', '4rem', '0'), + '_padding:mobile_landscape' => av_sp('3rem', '0', '3rem', '0'), + '_padding:mobile_portrait' => av_sp('2rem', '0', '2rem', '0'), + '_background' => ['color' => av_color($bg)], + ], + ]; + $els[] = [ + 'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => [$nrr], + 'settings' => [ + '_direction' => 'column', + '_widthMax' => '1280px', + '_width' => '100%', + '_padding' => av_sp('0', '1.5rem', '0', '1.5rem'), + '_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'), + '_alignItems' => 'flex-start', + ], + ]; + $els[] = [ + 'id' => $nrr, 'name' => 'container', 'parent' => $oct, 'children' => $narrative_children, + 'settings' => [ + '_direction' => 'column', + '_widthMax' => '800px', + '_widthMin' => '0', + '_width' => '100%', + '_margin' => av_sp('0', 'auto', '0', '0'), + '_padding' => av_sp('0', '0', '0', '0'), + '_rowGap' => '1.25rem', + ], + ]; + if (! empty($section['eyebrow'])) $els[] = av_eyebrow($eyb, $nrr, $section['eyebrow']); + if (! empty($section['title'])) $els[] = av_heading($hId, $nrr, 'h2', $section['title']); + if (! empty($section['body'])) $els[] = av_text($bId, $nrr, $section['body'], 'var(--color-text-secondary)', '1.0625rem', '1.6'); + + // Button row + $ctas = $section['ctas'] ?? []; + $btn_ids = []; + foreach ($ctas as $j => $cta) $btn_ids[] = "b{$slug}cta{$i}x{$j}"; + $els[] = [ + 'id' => $row, 'name' => 'container', 'parent' => $nrr, 'children' => $btn_ids, + 'settings' => [ + '_direction' => 'row', + '_columnGap' => '1rem', + '_rowGap' => '1rem', + '_flexWrap' => 'wrap', + '_widthMin' => '0', + '_width' => '100%', + '_padding' => av_sp('0.5rem', '0', '0', '0'), + ], + ]; + foreach ($ctas as $j => $cta) { + $variant = ($cta['variant'] ?? 'primary') === 'primary' ? 'primary' : 'secondary'; + $els[] = av_button("b{$slug}cta{$i}x{$j}", $row, $cta['label'], $cta['href'], $variant); + } + + return $els; +} + +/** + * Upsell : cross-sell callout. Title + body + 1-2 inline links to sibling pages. + * Renders as a subtle bordered box on default bg. + */ +function av_build_upsell_section($slug, $upsell) { + $sid = "s{$slug}up"; + $oct = "o{$slug}up"; + $box = "x{$slug}up"; + $hId = "h{$slug}up"; + $bId = "b{$slug}up"; + $lks = "l{$slug}up"; + + $box_children = []; + if (! empty($upsell['title'])) $box_children[] = $hId; + if (! empty($upsell['body'])) $box_children[] = $bId; + if (! empty($upsell['links'])) $box_children[] = $lks; + + $els = []; + $els[] = [ + 'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct], + 'settings' => [ + '_padding' => av_sp('3rem', '0', '3rem', '0'), + '_padding:tablet_portrait' => av_sp('2.5rem', '0', '2.5rem', '0'), + '_padding:mobile_portrait' => av_sp('2rem', '0', '2rem', '0'), + '_background' => ['color' => av_color('var(--color-bg-default)')], + ], + ]; + $els[] = [ + 'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => [$box], + 'settings' => [ + '_direction' => 'column', + '_widthMax' => '1280px', + '_width' => '100%', + '_padding' => av_sp('0', '1.5rem', '0', '1.5rem'), + '_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'), + '_alignItems' => 'flex-start', + ], + ]; + $els[] = [ + 'id' => $box, 'name' => 'container', 'parent' => $oct, 'children' => $box_children, + 'settings' => [ + '_direction' => 'column', + '_widthMax' => '800px', + '_widthMin' => '0', + '_width' => '100%', + '_margin' => av_sp('0', 'auto', '0', '0'), + '_padding' => av_sp('1.75rem', '1.75rem', '1.75rem', '1.75rem'), + '_background' => ['color' => av_color('var(--color-bg-subtle)')], + '_border' => [ + 'width' => av_sp('1px', '1px', '1px', '1px'), + 'color' => av_color('var(--color-border-subtle)'), + 'style' => 'solid', + 'radius' => av_sp('4px', '4px', '4px', '4px'), + ], + '_rowGap' => '0.75rem', + ], + ]; + if (! empty($upsell['title'])) $els[] = av_heading($hId, $box, 'h3', $upsell['title']); + if (! empty($upsell['body'])) $els[] = av_text($bId, $box, $upsell['body'], 'var(--color-text-secondary)', '1rem', '1.55'); + + if (! empty($upsell['links'])) { + $lk_ids = []; + foreach ($upsell['links'] as $j => $lk) $lk_ids[] = "lk{$slug}upx{$j}"; + $els[] = [ + 'id' => $lks, 'name' => 'container', 'parent' => $box, 'children' => $lk_ids, + 'settings' => [ + '_direction' => 'row', + '_columnGap' => '1.25rem', + '_rowGap' => '0.5rem', + '_flexWrap' => 'wrap', + '_width' => '100%', + '_padding' => av_sp('0.5rem', '0', '0', '0'), + ], + ]; + foreach ($upsell['links'] as $j => $lk) { + $els[] = av_text_link("lk{$slug}upx{$j}", $lks, $lk['label'], $lk['href'], 'var(--color-brand-navy)'); + } + } + + return $els; +} + +function av_build_closer_cta($slug) { + $sid = "scl{$slug}sc"; + $oct = "ocl{$slug}oc"; + $nrr = "ncl{$slug}nr"; + $eyb = "ecl{$slug}eb"; + $hId = "hcl{$slug}h2"; + $led = "lcl{$slug}ld"; + $row = "rcl{$slug}rw"; + $b1 = "bcl{$slug}b1"; + $b2 = "bcl{$slug}b2"; + + return [ + [ + 'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct], + 'settings' => [ + '_padding' => av_sp('5rem', '0', '5rem', '0'), + '_padding:tablet_portrait' => av_sp('4rem', '0', '4rem', '0'), + '_padding:mobile_landscape' => av_sp('3rem', '0', '3rem', '0'), + '_padding:mobile_portrait' => av_sp('2rem', '0', '2rem', '0'), + '_background' => ['color' => av_color('var(--color-brand-navy)')], + ], + ], + [ + 'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => [$nrr], + 'settings' => [ + '_direction' => 'column', + '_widthMax' => '1280px', + '_width' => '100%', + '_padding' => av_sp('0', '1.5rem', '0', '1.5rem'), + '_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'), + '_alignItems' => 'flex-start', + ], + ], + [ + 'id' => $nrr, 'name' => 'container', 'parent' => $oct, 'children' => [$eyb, $hId, $led, $row], + 'settings' => [ + '_direction' => 'column', + '_widthMax' => '800px', + '_widthMin' => '0', + '_width' => '100%', + '_margin' => av_sp('0', 'auto', '0', '0'), + '_padding' => av_sp('0', '0', '0', '0'), + '_rowGap' => '1.25rem', + ], + ], + av_eyebrow($eyb, $nrr, 'See it. Touch it. Then decide.'), + av_heading($hId, $nrr, 'h2', "Bring PROSERVE to your team.", 'var(--color-text-on-dark)'), + av_text($led, $nrr, "Book 30 minutes with our team. We'll bring the kit. You bring the doctrine.", 'var(--color-text-on-dark)', '1.125rem', '1.55'), + [ + 'id' => $row, 'name' => 'container', 'parent' => $nrr, 'children' => [$b1, $b2], + 'settings' => [ + '_direction' => 'row', + '_columnGap' => '1rem', + '_rowGap' => '1rem', + '_flexWrap' => 'wrap', + '_widthMin' => '0', + '_width' => '100%', + '_padding' => av_sp('0.5rem', '0', '0', '0'), + ], + ], + av_button($b1, $row, 'Book a Demo', '/request-demo/', 'gold'), + av_button($b2, $row, 'Request a Quote', '/request-quote/', 'outline-on-dark'), + ]; +} + +/* ========================================================================= + 4. PAGE WRITE PIPELINE — direct $wpdb, WPML rescan, FR auto-sync + ========================================================================= */ + +/** + * Build a section list from a data array (hero + sections + closer). + * Each $sections[i] has a 'type' field : prose / list / cards / cta. + * Alternates bg between bg-default and bg-subtle. + * + * Returns an array of Bricks elements (hero first, closer last). + */ +function av_build_detail_page_elements($slug, $d) { + $bg_alternates = ['var(--color-bg-default)', 'var(--color-bg-subtle)']; + $elements = []; + $elements = array_merge($elements, av_build_hero($slug, $d)); + + foreach ($d['sections'] ?? [] as $i => $section) { + $bg = $bg_alternates[$i % 2]; + if ($section['type'] === 'prose') $elements = array_merge($elements, av_build_prose_section($slug, $i, $section, $bg)); + elseif ($section['type'] === 'list') $elements = array_merge($elements, av_build_list_section($slug, $i, $section, $bg)); + elseif ($section['type'] === 'cards') $elements = array_merge($elements, av_build_cards_section($slug, $i, $section, $bg)); + elseif ($section['type'] === 'cta') $elements = array_merge($elements, av_build_cta_section($slug, $i, $section, $bg)); + } + + // Optional upsell callout (cross-sell to sibling pages) + if (! empty($d['upsell']) && is_array($d['upsell'])) { + $elements = array_merge($elements, av_build_upsell_section($slug, $d['upsell'])); + } + + $elements = array_merge($elements, av_build_closer_cta($slug)); + + return $elements; +} + +/** + * Write Bricks elements to a page's _bricks_page_content_2 : + * - Bypass Bricks's update_post_metadata filter via $wpdb direct write + * - Cache flush + * - WPML rescan : do_action('wpml_page_builder_register_strings', ...) + * - FR auto-sync : if linked FR exists, push translated structure + * + * Returns ['stored_len' => int, 'json_len' => int]. + */ +function av_write_bricks_page_elements($page_id, $elements) { + global $wpdb; + + $json = wp_json_encode($elements); + if ($json === false) { + return ['stored_len' => 0, 'json_len' => 0, 'error' => 'JSON encode failed: ' . json_last_error_msg()]; + } + + $existing = $wpdb->get_var($wpdb->prepare( + "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id=%d AND meta_key=%s LIMIT 1", + $page_id, '_bricks_page_content_2' + )); + if ($existing) { + $wpdb->update($wpdb->postmeta, ['meta_value' => $json], ['meta_id' => $existing], ['%s'], ['%d']); + } else { + $wpdb->insert($wpdb->postmeta, ['post_id' => $page_id, 'meta_key' => '_bricks_page_content_2', 'meta_value' => $json], ['%d', '%s', '%s']); + } + wp_cache_delete($page_id, 'post_meta'); + + $stored = $wpdb->get_var($wpdb->prepare( + "SELECT LENGTH(meta_value) FROM {$wpdb->postmeta} WHERE post_id=%d AND meta_key=%s LIMIT 1", + $page_id, '_bricks_page_content_2' + )); + + // WPML rescan : Bricks's wpml_page_builder_register_strings handler needs + // each element's controls to be loaded (defines which fields are translatable). + // Bricks loads controls on the 'wp' hook (not 'init'). Our URL triggers run on + // 'init' → controls would be empty → no strings registered. Force-load first. + if (class_exists('\Bricks\Elements') && method_exists('\Bricks\Elements', 'load_elements')) { + \Bricks\Elements::load_elements(); + } + if (has_action('wpml_page_builder_register_strings')) { + do_action('wpml_page_builder_register_strings', get_post($page_id), [ + 'kind' => 'Bricks', + 'name' => (string) $page_id, + 'post_id' => $page_id, + 'title' => "Bricks (ID $page_id)", + ]); + + // Push our FR dict translations into icl_string_translations so CTE shows + // them pre-filled. Only fills strings without an existing FR (so user + // edits done in CTE are not overwritten on re-regen). + av_push_fr_strings_to_wpml($page_id); + + // Fire Bricks's native FR rebuild : Bricks reads EN _bricks_page_content_2, + // substitutes string translations from icl_string_translations, writes to FR. + av_trigger_bricks_fr_rebuild($page_id); + + // Ensure a CTE job exists for this page (gear icon ⚙️ + click → CTE). + // The job lives in icl_translate_job with editor='wpml' and revision=NULL. + // Without it, the FR column shows pencil → WP editor fallback. + av_ensure_wpml_cte_job($page_id); + } + + // Note : we used to mark status=10 on shutdown here. Now av_ensure_wpml_cte_job() + // sets status=2 (IN_PROGRESS) which is required for the gear ⚙️ icon + CTE link. + // Setting status=10 here would silently override that → pencil icon → broken UX. + + return ['stored_len' => (int) $stored, 'json_len' => strlen($json)]; +} + +/** + * Upsert (create-or-update) a detail page under a given parent. + * Returns ['id' => int, 'action' => 'created'|'updated', 'element_count' => int, 'stored_len' => int]. + */ +function av_upsert_detail_page($slug, $parent_path, $d) { + $existing = get_page_by_path("$parent_path/$slug"); + $parent = get_page_by_path($parent_path); + $parent_id = $parent ? $parent->ID : 0; + + if ($existing) { + wp_update_post([ + 'ID' => $existing->ID, + 'post_title' => $d['title'], + 'post_status' => 'publish', + 'post_parent' => $parent_id, + ]); + $page_id = $existing->ID; + $action = 'updated'; + } else { + $page_id = wp_insert_post([ + 'post_type' => 'page', + 'post_title' => $d['title'], + 'post_name' => $slug, + 'post_parent' => $parent_id, + 'post_status' => 'publish', + 'post_content' => '', + ]); + $action = 'created'; + } + + $elements = av_build_detail_page_elements($slug, $d); + $write = av_write_bricks_page_elements($page_id, $elements); + + return [ + 'id' => $page_id, + 'action' => $action, + 'element_count' => count($elements), + 'json_len' => $write['json_len'], + 'stored_len' => $write['stored_len'] ?? 'null', + ]; +} + +/** + * Ensure a hub page exists at a top-level slug. Creates if not. + * Returns the post ID. + */ +function av_ensure_hub_page($slug, $title) { + $existing = get_page_by_path($slug); + if ($existing) return $existing->ID; + return wp_insert_post([ + 'post_type' => 'page', + 'post_title' => $title, + 'post_name' => $slug, + 'post_status' => 'publish', + 'post_content' => '', + ]); +} + +/* ========================================================================= + 5. FR TRANSLATION PIPELINE + ========================================================================= */ + +/** + * Find the FR post linked to an EN post via WPML trid. Returns 0 if none. + */ +function av_get_fr_post_for_en($en_post_id) { + global $wpdb, $sitepress; + if (! $sitepress) return 0; + $trid = $sitepress->get_element_trid($en_post_id, 'post_page'); + if (! $trid) return 0; + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT element_id FROM {$wpdb->prefix}icl_translations + WHERE trid=%d AND language_code='fr' AND element_type='post_page' AND element_id > 0 + LIMIT 1", + $trid + )); +} + +/** + * Trigger Bricks's native FR rebuild via the wpml_page_builder_string_translated + * hook. Bricks reads EN _bricks_page_content_2, substitutes WPML string translations, + * writes the result to FR _bricks_page_content_2. + * + * This is the WPML-native flow — replaces our previous av_sync_fr_translation() + * which wrote FR directly via $wpdb (causing divergence WPML couldn't track). + */ +function av_trigger_bricks_fr_rebuild($en_post_id) { + global $wpdb; + $fr_post_id = av_get_fr_post_for_en($en_post_id); + if (! $fr_post_id) return; + + // Read all FR string translations registered for this EN page's package + $context = 'bricks-' . $en_post_id; + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT s.name AS string_id, st.value + FROM {$wpdb->prefix}icl_string_translations st + JOIN {$wpdb->prefix}icl_strings s ON s.id = st.string_id + WHERE s.context = %s AND st.language = 'fr' AND st.status = 10", + $context + )); + if (! $rows) return; + + // Build the structure Bricks's handler expects : + // $string_translations[string_id][lang]['value'] = 'translated text' + $string_translations = []; + foreach ($rows as $r) { + $string_translations[$r->string_id] = [ + 'fr' => ['value' => $r->value], + ]; + } + + // Fire Bricks's rebuild handler. The closure-attached action signature is : + // ($package_kind, $translated_post_id, $original_post, $string_translations, $lang) + do_action( + 'wpml_page_builder_string_translated', + 'Bricks', + $fr_post_id, + get_post($en_post_id), + $string_translations, + 'fr' + ); +} + +/** + * Write/refresh icl_translation_status row for a FR translation : status=10 (complete), + * needs_update=0, md5=hash of EN _bricks_page_content_2 (what WPML normally tracks). + * + * Idempotent. Called from av_sync_fr_translation() and from av_translate_page_fr(). + */ +function av_wpml_mark_translation_complete($en_post_id, $fr_post_id, $trid) { + global $wpdb, $sitepress; + if (! $sitepress) return; + + $fr_translation_id = $wpdb->get_var($wpdb->prepare( + "SELECT translation_id FROM {$wpdb->prefix}icl_translations + WHERE element_id=%d AND element_type='post_page' AND trid=%d AND language_code='fr' + LIMIT 1", + $fr_post_id, $trid + )); + if (! $fr_translation_id) return; + + $en_content = get_post_meta($en_post_id, '_bricks_page_content_2', true); + if (is_array($en_content)) $en_content = wp_json_encode($en_content); + $md5 = md5((string) $en_content); + + $data = [ + 'status' => 10, // ICL_TM_COMPLETE + 'needs_update' => 0, + 'md5' => $md5, + 'translation_service' => 'local', + 'translator_id' => 0, + ]; + + $existing = $wpdb->get_var($wpdb->prepare( + "SELECT rid FROM {$wpdb->prefix}icl_translation_status WHERE translation_id=%d", + $fr_translation_id + )); + if ($existing) { + $wpdb->update($wpdb->prefix . 'icl_translation_status', $data, ['rid' => $existing]); + } else { + $data['translation_id'] = $fr_translation_id; + $wpdb->insert($wpdb->prefix . 'icl_translation_status', $data); + } +} + +/** + * Recursively prefix relative URLs inside link blocks ({type, url}) with $prefix. + * Idempotent : skips URLs that already start with the prefix. Skips absolute URLs + * (http://, https://, mailto:, tel:, #, etc.). + * + * Used by av_sync_fr_translation() so FR pages link to /fr/... siblings. + */ +function av_prefix_link_urls_fr(&$elements, $prefix = '/fr') { + $walk = function (&$node) use (&$walk, $prefix) { + if (! is_array($node)) return; + // Link-block shape : has 'type' and 'url'. Prefix relative root URLs only. + if (isset($node['type']) && isset($node['url']) && is_string($node['url'])) { + $u = $node['url']; + if ($u !== '' && $u[0] === '/' && strpos($u, $prefix . '/') !== 0) { + $node['url'] = $prefix . $u; + } + } + foreach ($node as &$value) { + if (is_array($value)) $walk($value); + } + }; + foreach ($elements as &$el) $walk($el); +} + +/** + * For each EN string registered for a page (context 'bricks-{ID}'), if our dict + * has a FR translation AND no FR translation exists yet in WPML, write it via + * icl_add_string_translation() with status=10 (complete). + * + * Strings already translated (by user in CTE) are NEVER overwritten. Strings + * without dict entry stay EN (linguist will fill them in CTE). + */ +function av_push_fr_strings_to_wpml($page_id) { + global $wpdb; + if (! function_exists('icl_add_string_translation')) return; + + $dict = av_fr_translation_dictionary(); + if (! $dict) return; + + // Fetch EN strings + their existing FR (if any) in one query + $strings = $wpdb->get_results($wpdb->prepare( + "SELECT s.id, s.value AS en_value, st.value AS fr_existing + FROM {$wpdb->prefix}icl_strings s + LEFT JOIN {$wpdb->prefix}icl_string_translations st + ON st.string_id = s.id AND st.language = 'fr' + WHERE s.context = %s", + 'bricks-' . $page_id + )); + + foreach ($strings as $s) { + // Skip if user already has a FR translation (don't overwrite manual edits) + if (! empty($s->fr_existing)) continue; + + $fr_value = null; + if (isset($dict[$s->en_value])) { + // 1. Dict-translated string (text label, heading, body) + $fr_value = $dict[$s->en_value]; + } elseif (av_is_internal_root_url($s->en_value)) { + // 2. Internal URL : auto-prefix /fr so FR pages link to FR siblings + // Skip URLs that already are /fr-prefixed or external (http*, mailto*, #*). + $fr_value = '/fr' . $s->en_value; + } + + if ($fr_value !== null) { + icl_add_string_translation((int) $s->id, 'fr', $fr_value, 10); + } + } +} + +/** + * Detect "internal root-relative URL" : starts with /, single slash (not //), + * not yet /fr-prefixed. Used for auto-translating link URLs to FR siblings. + */ +function av_is_internal_root_url($value) { + if (! is_string($value) || strlen($value) < 2) return false; + if ($value[0] !== '/') return false; + if (substr($value, 0, 2) === '//') return false; // protocol-relative + if (strpos($value, '/fr/') === 0 || $value === '/fr') return false; + return true; +} + +/** + * Create or refresh a CTE (Classic Translation Editor) job for the FR translation + * of $en_post_id. Populates icl_translate_job + icl_translate with the right + * structure so WPML shows a gear ⚙️ icon in the FR column and routes the click + * to CTE. + * + * Key requirements (learned from comparing with a working WPML-created page) : + * - icl_translate_job.revision MUST be NULL ← WPML's icon JOIN filters on this + * - icl_translate_job.editor = 'wpml' ← CTE (matches doc_translation_method=1) + * - icl_translate has 4 standard WP rows (original_id/title/body/excerpt) + N + * package-string-{package_id}-{string_id} rows for each Bricks string + * - icl_translation_status.status = 2 (IN_PROGRESS), translator_id ≠ 0, batch_id + * + * Idempotent. Existing FR values in icl_translate.field_data_translated are + * preserved (so user CTE edits aren't overwritten). + */ +function av_ensure_wpml_cte_job($en_post_id) { + global $wpdb, $sitepress; + if (! $sitepress) return; + + // CRITICAL : ensure WPML doesn't force the native WP editor for this page. + // The meta '_wpml_post_translation_editor_native' = 'yes' gets set when the + // user dismisses the "Translation in progress" warning by clicking "I + // understand - continue editing". That meta then permanently routes the + // orange-refresh icon to the WP editor instead of CTE. Delete it. + delete_post_meta($en_post_id, '_wpml_post_translation_editor_native'); + delete_post_meta($en_post_id, '_wpml_post_translation_editor_wpml'); + + $fr_post_id = av_get_fr_post_for_en($en_post_id); + if (! $fr_post_id) return; + + $trid = $sitepress->get_element_trid($en_post_id, 'post_page'); + $fr_translation_id = $wpdb->get_var($wpdb->prepare( + "SELECT translation_id FROM {$wpdb->prefix}icl_translations + WHERE element_id=%d AND element_type='post_page' AND trid=%d AND language_code='fr' LIMIT 1", + $fr_post_id, $trid + )); + if (! $fr_translation_id) return; + + // 1. icl_translation_status : IN_PROGRESS + assigned + batched (matches the + // state of a real CTE job in flight). status=2 is what makes the icon "gear". + $existing_rid = $wpdb->get_var($wpdb->prepare( + "SELECT rid FROM {$wpdb->prefix}icl_translation_status WHERE translation_id=%d", + $fr_translation_id + )); + $uuid = wp_generate_uuid4(); + $batch_id = av_get_or_create_batch_id(); + + $en_content = get_post_meta($en_post_id, '_bricks_page_content_2', true); + if (is_array($en_content)) $en_content = wp_json_encode($en_content); + $md5 = md5((string) $en_content); + + // Status strategy : keep status=10 (COMPLETE) + needs_update=1 (orange refresh + // icon 🔄). This is WPML's canonical "translation needs updating after source + // changed" state. Click on the orange icon → routes natively to CTE (no + // capability errors). After CTE save, WPML sets needs_update=0 → pencil. On + // next regen we set needs_update=1 again → cycle re-opens CTE access. + // + // We avoid status=2 (IN_PROGRESS) because it triggers the "Translation in + // progress, wait before editing" warning when editing EN — and the gear icon + // URL was hitting "Sorry, not allowed" due to user_can_translate check. + $status_data = [ + 'status' => 10, // ICL_TM_COMPLETE + 'translator_id' => 1, + 'needs_update' => 1, // → orange refresh icon, click opens CTE + 'md5' => $md5, + 'translation_service' => 'local', + 'batch_id' => $batch_id, + 'uuid' => $uuid, + 'tp_revision' => 1, + 'links_fixed' => 1, + 'timestamp' => current_time('mysql'), + ]; + if ($existing_rid) { + $wpdb->update($wpdb->prefix . 'icl_translation_status', $status_data, ['rid' => $existing_rid]); + $rid = (int) $existing_rid; + } else { + $status_data['translation_id'] = $fr_translation_id; + $wpdb->insert($wpdb->prefix . 'icl_translation_status', $status_data); + $rid = (int) $wpdb->insert_id; + } + + // 2. icl_translate_job : ensure one job with revision=NULL exists + $existing_job = $wpdb->get_var($wpdb->prepare( + "SELECT job_id FROM {$wpdb->prefix}icl_translate_job WHERE rid=%d AND revision IS NULL ORDER BY job_id DESC LIMIT 1", + $rid + )); + $en_post = get_post($en_post_id); + $job_data = [ + 'rid' => $rid, + 'translator_id' => 1, + 'translated' => 1, + 'manager_id' => 1, + // 'revision' deliberately omitted → DB default NULL + 'title' => (string) $en_post->post_title, + 'deadline_date' => null, + 'completed_date' => current_time('mysql'), + 'editor' => 'wpml', // CTE + 'editor_job_id' => null, + 'automatic' => 0, + ]; + + if ($existing_job) { + $wpdb->update($wpdb->prefix . 'icl_translate_job', $job_data, ['job_id' => $existing_job]); + // $wpdb->update coerces NULL → '' ; force NULL via raw SQL. + $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->prefix}icl_translate_job SET revision = NULL WHERE job_id = %d", + $existing_job + )); + $job_id = (int) $existing_job; + } else { + $wpdb->insert($wpdb->prefix . 'icl_translate_job', $job_data); + $job_id = (int) $wpdb->insert_id; + $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->prefix}icl_translate_job SET revision = NULL WHERE job_id = %d", + $job_id + )); + } + + // 3. icl_translate : populate field rows so CTE has data to display + av_populate_translate_rows_for_job($job_id, $en_post_id, $fr_post_id); +} + +/** + * Build batch row for our programmatic translations (one shared batch). + */ +function av_get_or_create_batch_id() { + global $wpdb; + $name = 'Asterion programmatic translations'; + $id = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$wpdb->prefix}icl_translation_batches WHERE batch_name=%s LIMIT 1", + $name + )); + if ($id) return (int) $id; + $wpdb->insert($wpdb->prefix . 'icl_translation_batches', [ + 'batch_name' => $name, + 'last_update' => current_time('mysql'), + ]); + return (int) $wpdb->insert_id; +} + +/** + * Populate icl_translate for a job : 4 standard WP fields + N package-string + * rows (one per registered Bricks string). field_data is base64(gzcompress(string)). + * + * Existing translated values in field_data_translated are PRESERVED (so user + * edits done in CTE aren't overwritten on regen). Only fills empty cells. + */ +function av_populate_translate_rows_for_job($job_id, $en_post_id, $fr_post_id) { + global $wpdb; + + // Build a map of existing rows : field_type → tid + current field_data_translated + $existing = []; + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT tid, field_type, field_data_translated, field_finished + FROM {$wpdb->prefix}icl_translate WHERE job_id = %d", + $job_id + )); + foreach ($rows as $r) { + $existing[$r->field_type] = $r; + } + + $en_post = get_post($en_post_id); + $fr_post = get_post($fr_post_id); + + // 1. Standard WP fields + $std_fields = [ + ['original_id', '', (string) $en_post_id, '', 0, 0], + ['title', 'base64', av_pack($en_post->post_title), av_pack($fr_post->post_title), 1, 1], + ['body', 'base64', av_pack((string) $en_post->post_content), av_pack((string) $fr_post->post_content), 1, 0], + ['excerpt', 'base64', av_pack((string) $en_post->post_excerpt), av_pack((string) $fr_post->post_excerpt), 1, 0], + ]; + foreach ($std_fields as $f) { + av_upsert_translate_row($job_id, $fr_post_id, $f[0], $f[1], $f[2], $f[3], $f[4], $f[5], $existing); + } + + // 2. Package strings (one per Bricks translatable string for this page) + $strings = $wpdb->get_results($wpdb->prepare( + "SELECT s.id AS string_id, s.string_package_id AS package_id, s.value AS en_value, st.value AS fr_value + FROM {$wpdb->prefix}icl_strings s + LEFT JOIN {$wpdb->prefix}icl_string_translations st ON st.string_id = s.id AND st.language = 'fr' + WHERE s.context = %s", + 'bricks-' . $en_post_id + )); + foreach ($strings as $s) { + $field_type = "package-string-{$s->package_id}-{$s->string_id}"; + $is_done = ! empty($s->fr_value) ? 1 : 0; + av_upsert_translate_row( + $job_id, $fr_post_id, $field_type, 'base64', + av_pack($s->en_value), av_pack((string) $s->fr_value), + 1, $is_done, $existing + ); + } + + // Cleanup : delete icl_translate rows for strings that no longer exist in + // icl_strings (e.g. user removed an element from EN page) + $valid_types = array_merge( + ['original_id', 'title', 'body', 'excerpt'], + array_map(fn($s) => "package-string-{$s->package_id}-{$s->string_id}", $strings) + ); + if ($valid_types) { + $placeholders = implode(',', array_fill(0, count($valid_types), '%s')); + $wpdb->query($wpdb->prepare( + "DELETE FROM {$wpdb->prefix}icl_translate WHERE job_id = %d AND field_type NOT IN ($placeholders)", + $job_id, ...$valid_types + )); + } +} + +/** + * Insert or update a single icl_translate row, preserving existing + * field_data_translated if non-empty (don't overwrite user CTE edits). + */ +function av_upsert_translate_row($job_id, $content_id, $field_type, $field_format, + $field_data, $field_data_translated, $field_translate, + $field_finished, &$existing) { + global $wpdb; + + if (isset($existing[$field_type])) { + $row = $existing[$field_type]; + // Preserve user's CTE edits : keep the existing translated value if it's set + $translated = ! empty($row->field_data_translated) ? $row->field_data_translated : $field_data_translated; + $finished_final = ! empty($row->field_data_translated) ? (int) $row->field_finished : $field_finished; + $wpdb->update( + $wpdb->prefix . 'icl_translate', + [ + 'field_data' => $field_data, // EN source can change → always refresh + 'field_data_translated' => $translated, + 'field_finished' => $finished_final, + 'timestamp' => current_time('mysql'), + ], + ['tid' => $row->tid] + ); + } else { + $wpdb->insert($wpdb->prefix . 'icl_translate', [ + 'job_id' => $job_id, + 'content_id' => $content_id, + 'timestamp' => current_time('mysql'), + 'field_type' => $field_type, + 'field_wrap_tag' => '', + 'field_format' => $field_format, + 'field_translate' => $field_translate, + 'field_data' => $field_data, + 'field_data_translated' => $field_data_translated, + 'field_finished' => $field_finished, + ]); + } +} + +/** Pack a string for icl_translate.field_data : base64(gzcompress($s)). */ +function av_pack($s) { + if ($s === '' || $s === null) return ''; + return base64_encode(gzcompress((string) $s)); +} + +/** Walk Bricks element tree, translate 'text' and 'label' string values via dict. */ +function av_walk_translate_for_sync($elements, $dict) { + // Special prefix-aware handler : list items are prefixed by "— " in the + // builder. We translate the part AFTER the prefix. + $walk = function (&$node) use (&$walk, $dict) { + if (! is_array($node)) return; + foreach ($node as $key => &$value) { + if (is_array($value)) { + $walk($value); + } elseif (is_string($value) && in_array($key, ['text', 'label'], true)) { + if (isset($dict[$value])) { + $value = $dict[$value]; + } elseif (strpos($value, '— ') === 0) { + // List-item prefix : try translating the content + $inner = substr($value, strlen('— ')); + if (isset($dict[$inner])) { + $value = '— ' . $dict[$inner]; + } + } + } + } + }; + foreach ($elements as &$el) $walk($el); + return $elements; +} + +/** + * Shared EN→FR dictionary. Each content-type generator can extend it via the + * `av_fr_translation_dict` filter (no code change to shared). + */ +function av_fr_translation_dictionary() { + static $dict = null; + if ($dict !== null) return $dict; + + $base = [ + /* ----------------------------------------------------------------- + Common buttons / CTAs (shared across all pages) + ----------------------------------------------------------------- */ + 'Book a Demo' => 'Réserver une démo', + 'Book a demo' => 'Réserver une démo', + 'Request a Demo' => 'Demander une démo', + 'Request a T&E Kit' => 'Demander un kit T&E', + 'Request a Quote' => 'Demander un devis', + "Schedule a discovery call" => 'Planifier un appel de découverte', + "Discuss your custom request" => 'Discuter de votre besoin spécifique', + "See the full scenario library" => 'Voir la bibliothèque complète de scénarios', + + /* ----------------------------------------------------------------- + Closer CTA (every detail page + hub) + ----------------------------------------------------------------- */ + "See it. Touch it. Then decide." + => "Voyez-le. Manipulez-le. Puis décidez.", + "Bring PROSERVE to your team." + => "Apportez PROSERVE à votre équipe.", + "Book 30 minutes with our team. We'll bring the kit. You bring the doctrine." + => "Réservez 30 minutes avec notre équipe. Nous apportons le matériel. Vous apportez la doctrine.", + + /* ----------------------------------------------------------------- + Generic "Learn more" link arrow + ----------------------------------------------------------------- */ + "Learn more \u{2192}" => "En savoir plus \u{2192}", + ]; + + /* Extension point : each generator registers its own page strings via + the filter (so we don't have one giant function to maintain). */ + $dict = apply_filters('av_fr_translation_dict', $base); + return $dict; +} + +/* ========================================================================= + 6. FR INITIAL SEED (one-shot, creates the FR post linked via WPML trid) + ========================================================================= */ + +/** + * Create the linked FR translation of an EN page (slot + meta + slug). + * No-op if a FR translation already exists for the EN trid. Returns the FR ID. + * + * The FR _bricks_page_content_2 is set via av_write_bricks_page_elements() + * (the EN side) when the auto-sync fires after creation. + * + * @param int $en_post_id Source EN page ID + * @param string $en_slug Slug to keep on FR (WPML auto-prefixes with /fr/) + * @param string $fr_title FR title for the new page + * @return int|false FR post ID, or false on failure + */ +function av_translate_page_fr($en_post_id, $en_slug, $fr_title) { + global $wpdb, $sitepress; + if (! $sitepress) return false; + + $trid = $sitepress->get_element_trid($en_post_id, 'post_page'); + if (! $trid) return false; + + $fr_post_id = $wpdb->get_var($wpdb->prepare( + "SELECT element_id FROM {$wpdb->prefix}icl_translations + WHERE trid=%d AND language_code='fr' AND element_type='post_page' AND element_id > 0 + LIMIT 1", + $trid + )); + + if (! $fr_post_id) { + // Switch WPML context to FR BEFORE insert so : + // - the new post is created in the FR language slot + // - the slug uniqueness check is scoped to FR (allows same slug as EN) + $sitepress->switch_lang('fr'); + $fr_post_id = wp_insert_post([ + 'post_type' => 'page', + 'post_title' => $fr_title, + 'post_name' => $en_slug, + 'post_status' => 'publish', + 'post_content' => '', + 'post_author' => get_post($en_post_id)->post_author ?? 1, + 'post_parent' => av_fr_parent_for_en(get_post($en_post_id)->post_parent), + ]); + $sitepress->switch_lang('en'); + + if (is_wp_error($fr_post_id)) return false; + + // Force-set the language details (override WPML defaults that may have + // assigned EN if its 'lang' context wasn't picked up by all filters) + $sitepress->set_element_language_details($fr_post_id, 'post_page', $trid, 'fr', 'en'); + + // Force slug to match EN (WPML allows duplicate slugs per language) + $wpdb->update($wpdb->posts, ['post_name' => $en_slug], ['ID' => $fr_post_id]); + clean_post_cache($fr_post_id); + } else { + // Already linked : just update title and slug. + $sitepress->switch_lang('fr'); + wp_update_post([ + 'ID' => $fr_post_id, + 'post_title' => $fr_title, + 'post_status' => 'publish', + ]); + $sitepress->switch_lang('en'); + $wpdb->update($wpdb->posts, ['post_name' => $en_slug], ['ID' => $fr_post_id]); + clean_post_cache($fr_post_id); + } + + // Mark icl_translation_status as completed (status=10) so WPML UI routes the + // pencil button to ATE and clears the orange "needs update" indicator. + av_wpml_mark_translation_complete($en_post_id, (int) $fr_post_id, $trid); + + return (int) $fr_post_id; +} + +/** + * Given an EN parent post ID, return the FR equivalent (or 0 if none). + * Used to keep the parent/child hierarchy intact when creating FR pages. + */ +function av_fr_parent_for_en($en_parent_id) { + if (! $en_parent_id) return 0; + global $wpdb, $sitepress; + if (! $sitepress) return 0; + + $trid = $sitepress->get_element_trid($en_parent_id, 'post_page'); + if (! $trid) return 0; + + $fr_parent_id = $wpdb->get_var($wpdb->prepare( + "SELECT element_id FROM {$wpdb->prefix}icl_translations + WHERE trid=%d AND language_code='fr' AND element_type='post_page' AND element_id > 0 + LIMIT 1", + $trid + )); + return (int) ($fr_parent_id ?: 0); +} diff --git a/tools/av-apply-bricks-styles.php b/tools/av-apply-bricks-styles.php new file mode 100644 index 0000000..03684b2 --- /dev/null +++ b/tools/av-apply-bricks-styles.php @@ -0,0 +1,200 @@ + ['raw' => $var]; + $sp = fn($t,$r,$b,$l) => ['top'=>$t,'right'=>$r,'bottom'=>$b,'left'=>$l]; + + $styles = [ + 'asterion' => [ + 'label' => 'Asterion', + 'settings' => [ + '_custom' => true, + + 'conditions' => [ + 'conditions' => [ + ['id' => $cond_id, 'main' => 'any'], + ], + ], + + /* ---- GENERAL : site background ---- */ + 'general' => [ + 'siteBackground' => [ + 'color' => $color('var(--color-bg-default)'), + ], + ], + + /* ---- COLORS : 11 semantic roles ---- */ + 'colors' => [ + 'colorPrimary' => $color('var(--color-brand-navy)'), + 'colorSecondary' => $color('var(--color-brand-gold)'), + 'colorLight' => $color('var(--color-bg-default)'), + 'colorDark' => $color('var(--color-bg-dark)'), + 'colorMuted' => $color('var(--color-text-muted)'), + 'colorBorder' => $color('var(--color-border-default)'), + 'colorInfo' => $color('var(--color-info)'), + 'colorSuccess' => $color('var(--color-success)'), + 'colorWarning' => $color('var(--color-warning)'), + 'colorDanger' => $color('var(--color-alert-red)'), + ], + + /* ---- LINKS : gold + underline ---- */ + 'links' => [ + 'typography' => [ + 'color' => $color('var(--color-brand-gold)'), + 'text-decoration' => 'underline', + ], + ], + + /* ---- TYPOGRAPHY ---- */ + 'typography' => [ + // HTML font-size: 100% so 1rem = 16px + 'typographyHtml' => '100%', + + 'typographyBody' => [ + 'font-family' => 'Inter', + 'font-weight' => '400', + 'font-size' => '1rem', + 'line-height' => '1.6', + 'letter-spacing' => '0', + 'color' => $color('var(--color-text-primary)'), + ], + + 'typographyHeadings' => [ + 'font-family' => 'Inter', + 'font-weight' => '700', + 'letter-spacing' => '-0.01em', + 'color' => $color('var(--color-text-primary)'), + ], + + // H1 : 36px / line 1.20 + 'typographyHeadingH1' => [ + 'font-size' => '2.25rem', + 'line-height' => '1.20', + ], + 'h1Margin' => $sp('0', '0', '1.5rem', '0'), + + // H2 : 28px / line 1.25 + 'typographyHeadingH2' => [ + 'font-size' => '1.75rem', + 'line-height' => '1.25', + ], + 'h2Margin' => $sp('0', '0', '1.25rem', '0'), + + // H3 : 22px / line 1.30 + 'typographyHeadingH3' => [ + 'font-size' => '1.375rem', + 'line-height' => '1.30', + ], + 'h3Margin' => $sp('0', '0', '1rem', '0'), + + // H4 : 18px / line 1.35 + 'typographyHeadingH4' => [ + 'font-size' => '1.125rem', + 'line-height' => '1.35', + ], + 'h4Margin' => $sp('0', '0', '0.75rem', '0'), + + // H5 : 16px / line 1.40 + 'typographyHeadingH5' => [ + 'font-size' => '1rem', + 'line-height' => '1.40', + ], + 'h5Margin' => $sp('0', '0', '0.5rem', '0'), + + // H6 : 14px / line 1.40 + 'typographyHeadingH6' => [ + 'font-size' => '0.875rem', + 'line-height' => '1.40', + ], + 'h6Margin' => $sp('0', '0', '0.5rem', '0'), + + // Focus outline (a11y, WCAG 2.1) + 'focusOutline' => '2px solid var(--color-info)', + ], + + /* ---- BUTTON : default + primary + secondary defaults ---- */ + 'button' => [ + 'typography' => [ + 'font-family' => 'Inter', + 'font-weight' => '600', + ], + + 'sizeDefaultPadding' => $sp('0.75rem', '1.5rem', '0.75rem', '1.5rem'), + 'sizeSmPadding' => $sp('0.5rem', '1rem', '0.5rem', '1rem'), + 'sizeMdPadding' => $sp('0.75rem', '1.5rem', '0.75rem', '1.5rem'), + 'sizeLgPadding' => $sp('1rem', '2rem', '1rem', '2rem'), + + // Primary button = navy bg, white text, gold hover (managed via element classes) + 'primaryBackground' => $color('var(--color-brand-navy)'), + 'primaryTypography' => [ + 'color' => $color('var(--color-text-on-dark)'), + ], + + // Secondary button = gold bg, navy text + 'secondaryBackground' => $color('var(--color-brand-gold)'), + 'secondaryTypography' => [ + 'color' => $color('var(--color-brand-navy)'), + ], + ], + + /* ---- SECTION : default padding (8-pt grid) ---- */ + 'section' => [ + 'padding' => $sp('5rem', '4rem', '5rem', '4rem'), + ], + + /* ---- CONTAINER : max-width 1280 ---- */ + 'container' => [ + 'widthMax' => '1280px', + ], + ], + ], + ]; + + update_option('bricks_theme_styles', $styles); + $report[] = "bricks_theme_styles[asterion] = full payload (" . strlen(wp_json_encode($styles)) . " bytes)"; + + /* --------------------------------------------------------------- + 3. Done + --------------------------------------------------------------- */ + $report[] = ""; + $report[] = "Reload the Bricks builder (Ctrl+R) — you should see :"; + $report[] = " - Body in Inter 16px navy"; + $report[] = " - H1-H6 in Inter 700 navy with proper sizes"; + $report[] = " - Links in gold underlined"; + $report[] = " - Sections with 5rem top/bottom padding"; + $report[] = " - postTypes : page, post, case_study, scenario"; + + wp_die('
'
+        . esc_html(implode("\n", $report))
+        . '
', 'Asterion theme style applied'); +}); diff --git a/tools/av-bricks-rest-bridge.php b/tools/av-bricks-rest-bridge.php new file mode 100644 index 0000000..e581ea6 --- /dev/null +++ b/tools/av-bricks-rest-bridge.php @@ -0,0 +1,304 @@ +/ with the `meta` field. + * + * Auth : + * - Read : public (REST default for show_in_rest) + * - Write : edit_posts capability (=> the WP Application Password user must have at least Editor role) + */ + +defined('ABSPATH') || exit; + +/* ---------------------------------------------------------------- + 1) Force show_in_rest=true on bricks_template + our CPTs. + ---------------------------------------------------------------- */ +add_filter('register_post_type_args', function ($args, $post_type) { + + $needs_rest = ['bricks_template', 'case_study', 'scenario']; + + if (! in_array($post_type, $needs_rest, true)) { + return $args; + } + + $args['show_in_rest'] = true; + $args['rest_base'] = $post_type === 'bricks_template' ? 'bricks_template' : ($post_type . 's'); + $args['rest_controller_class'] = 'WP_REST_Posts_Controller'; + $args['supports'] = array_unique(array_merge( + $args['supports'] ?? [], + ['title', 'editor', 'author', 'custom-fields'] // custom-fields needed so /wp/v2/ exposes the `meta` field + )); + + return $args; +}, 10, 2); + +/* ---------------------------------------------------------------- + 2) Register Bricks meta keys for REST on every relevant post type. + ---------------------------------------------------------------- */ +add_action('init', function () { + + // Post types that may carry Bricks content + $post_types = ['page', 'post', 'bricks_template', 'case_study', 'scenario']; + + // Bricks meta keys we want exposed (read+write) over REST + // Keys come from wp-content/themes/bricks/functions.php + $bricks_meta = [ + '_bricks_page_content_2' => 'string', // The element JSON (the main payload) + '_bricks_page_css_2' => 'string', + '_bricks_page_header_2' => 'string', + '_bricks_page_footer_2' => 'string', + '_bricks_page_settings' => 'string', + '_bricks_template_type' => 'string', + '_bricks_template_settings' => 'string', + ]; + + $auth_cb = function () { + return current_user_can('edit_posts'); + }; + + // Pass-through sanitize : keep the JSON string verbatim. Default sanitize for + // type=string strips slashes / escapes which corrupts our JSON payload. + $sanitize_passthrough = function ($value) { return $value; }; + + foreach ($post_types as $type) { + foreach ($bricks_meta as $meta_key => $meta_type) { + register_post_meta($type, $meta_key, [ + 'type' => $meta_type, + 'single' => true, + 'show_in_rest' => true, + 'auth_callback' => $auth_cb, + 'sanitize_callback' => $sanitize_passthrough, + ]); + } + } +}, 30); // priority 30 — after both Bricks (10-25) and our CPTs registration (10) + +/* ---------------------------------------------------------------- + 3) Permit also unknown _bricks_* meta keys (e.g. _bricks_snapshot_) + so MCP snapshots work without us pre-registering each key. + ---------------------------------------------------------------- */ +add_filter('is_protected_meta', function ($protected, $meta_key, $meta_type) { + if ($meta_type !== 'post') return $protected; + if (strpos($meta_key, '_bricks_snapshot_') === 0) { + return false; // expose snapshot meta in REST despite the leading underscore + } + return $protected; +}, 10, 3); + +/* ---------------------------------------------------------------- + 4) AUTO-MIRROR : the bricks-mcp-server writes element JSON to + _bricks_page_content_2, but Bricks reads HEADER templates from + _bricks_page_header_2 and FOOTER templates from _bricks_page_footer_2. + Mirror the content into the correct key whenever a bricks_template + with type=header/footer is saved. + ---------------------------------------------------------------- */ +/* DUAL-FORMAT META READ : the MCP server writes Bricks element data as a JSON + string into post_meta. Bricks's renderer expects a PHP array. We keep the JSON + string format in DB (so REST reads it back fine for the MCP's Json.parse), and + decode on the fly when Bricks reads via get_post_meta (non-REST context). + Result : same value, two views — JSON for the MCP, array for the renderer. */ +add_filter('get_post_metadata', 'av_decode_bricks_meta_for_renderer', 10, 5); + +function av_decode_bricks_meta_for_renderer($value, $post_id, $meta_key, $single, $meta_type) { + // Only post-type meta concerns us + if ($meta_type !== 'post') return $value; + + // Only Bricks element content keys + $content_keys = ['_bricks_page_content_2', '_bricks_page_header_2', '_bricks_page_footer_2']; + if (! in_array($meta_key, $content_keys, true)) return $value; + + // In REST context, leave the raw string for the MCP server + if (defined('REST_REQUEST') && REST_REQUEST) return $value; + + // Bricks renderer context : fetch the raw stored value (bypassing this filter + // via remove_filter, then re-add) and decode JSON → array + remove_filter('get_post_metadata', __FUNCTION__, 10); + $raw = get_post_meta($post_id, $meta_key, $single); + add_filter('get_post_metadata', __FUNCTION__, 10, 5); + + if (! is_string($raw)) return $value; + $trim = trim($raw); + if ($trim === '' || $trim === '[]') return $value; + if ($trim[0] !== '[' && $trim[0] !== '{') return $value; + + $decoded = json_decode($raw, true); + if (! is_array($decoded)) return $value; + + // WP's get_metadata_raw() convention for filter return values : + // - When filter returns array AND $single=true → WP returns $check[0] + // - When filter returns array AND $single=false → WP returns $check as-is + // Both cases need a list wrapper around our decoded value so WP's + // single-mode auto-indexing yields the decoded array (not the section). + return [$decoded]; +} + +add_action('updated_post_meta', 'av_mirror_bricks_template_content', 10, 4); +add_action('added_post_meta', 'av_mirror_bricks_template_content', 10, 4); + +function av_mirror_bricks_template_content($meta_id, $post_id, $meta_key, $meta_value) { + // Trigger 1 : content updated → mirror to header/footer if type already known + // Trigger 2 : type updated → re-mirror existing content (fixes the order-of-writes + // on REST create_template which writes content before type) + if (! in_array($meta_key, ['_bricks_page_content_2', '_bricks_template_type'], true)) return; + if (get_post_type($post_id) !== 'bricks_template') return; + + // Reuse the av_decode filter logic by reading via get_post_meta (which now + // returns array) when the value is JSON, or by direct decoding here. + if ($meta_key === '_bricks_page_content_2') { + $type = get_post_meta($post_id, '_bricks_template_type', true); + $content = is_string($meta_value) ? $meta_value : ''; + } else { + // type just updated → fetch raw content (bypass our filter to get string) + $type = is_string($meta_value) ? $meta_value : ''; + remove_filter('get_post_metadata', 'av_decode_bricks_meta_for_renderer', 10); + $content = get_post_meta($post_id, '_bricks_page_content_2', true); + add_filter('get_post_metadata', 'av_decode_bricks_meta_for_renderer', 10, 5); + } + + $mirror_key = match ($type) { + 'header' => '_bricks_page_header_2', + 'footer' => '_bricks_page_footer_2', + default => null, + }; + + if (! $mirror_key) return; + + // Bricks stores element JSON as a PHP-serialized array. Decode JSON string to array first. + $value = is_string($content) ? json_decode($content, true) : $content; + if (! is_array($value)) return; + + // Bypass register_post_meta string-coercion by writing a serialized blob directly via $wpdb. + // This is what Bricks's own builder save does — the value lands as a serialized PHP array, + // which `get_post_meta($id, $key, true)` auto-unserializes back to array on read. + global $wpdb; + $serialized = maybe_serialize($value); + $existing = $wpdb->get_var($wpdb->prepare( + "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id=%d AND meta_key=%s LIMIT 1", + $post_id, $mirror_key + )); + + if ($existing) { + $wpdb->update( + $wpdb->postmeta, + ['meta_value' => $serialized], + ['meta_id' => $existing], + ['%s'], ['%d'] + ); + } else { + $wpdb->insert( + $wpdb->postmeta, + ['post_id' => $post_id, 'meta_key' => $mirror_key, 'meta_value' => $serialized], + ['%d', '%s', '%s'] + ); + } + wp_cache_delete($post_id, 'post_meta'); +} + +/* ---------------------------------------------------------------- + 5) BACKFILL : trigger a manual mirror via URL. + Useful one-shot for templates created before this plugin was installed. + Visit /?av_mirror_template=ID + ---------------------------------------------------------------- */ +/* One-shot : ensure all Bricks content meta is stored as JSON STRING in DB. + - PHP-serialized arrays (legacy from earlier hook) → re-encode to JSON + - JSON strings → leave alone + This is the canonical storage format. The av_decode filter handles + the array-view for Bricks's renderer at read time. + /?av_fix_all_bricks_content=1 */ +add_action('init', function () { + if (empty($_GET['av_fix_all_bricks_content'])) return; + if (! in_array($_SERVER['SERVER_NAME'] ?? '', ['localhost', '127.0.0.1'], true)) wp_die('localhost only'); + + global $wpdb; + $rows = $wpdb->get_results( + "SELECT meta_id, post_id, meta_key, meta_value + FROM {$wpdb->postmeta} + WHERE meta_key IN ('_bricks_page_content_2','_bricks_page_header_2','_bricks_page_footer_2') + AND LENGTH(meta_value) > 4" + ); + + $report = ['Normalizing Bricks content meta to JSON strings...']; + foreach ($rows as $r) { + $val = $r->meta_value; + $first = $val[0] ?? ''; + + if ($first === '[' || $first === '{') { + // Already JSON string : skip + $report[] = " OK meta_id={$r->meta_id} post={$r->post_id} key={$r->meta_key} (JSON)"; + continue; + } + + // Serialized PHP array → unserialize, JSON-encode + if (is_serialized($val)) { + $array = unserialize($val); + if (is_array($array)) { + $json = wp_json_encode($array); + $wpdb->update( + $wpdb->postmeta, + ['meta_value' => $json], + ['meta_id' => $r->meta_id], + ['%s'], ['%d'] + ); + wp_cache_delete($r->post_id, 'post_meta'); + $report[] = " CONV meta_id={$r->meta_id} post={$r->post_id} key={$r->meta_key} (" . count($array) . " elements, " . strlen($json) . " bytes)"; + continue; + } + } + + $report[] = " SKIP meta_id={$r->meta_id} post={$r->post_id} key={$r->meta_key} (unknown format)"; + } + + header('Content-Type: text/plain; charset=utf-8'); + echo implode("\n", $report); + exit; +}); + +add_action('init', function () { + if (empty($_GET['av_mirror_template'])) return; + // localhost-only debug — no auth required (we're on http://localhost:10004) + if (! in_array($_SERVER['SERVER_NAME'] ?? '', ['localhost', '127.0.0.1'], true)) wp_die('localhost only'); + + $id = (int) $_GET['av_mirror_template']; + $content = get_post_meta($id, '_bricks_page_content_2', true); + $type = get_post_meta($id, '_bricks_template_type', true); + + if (! is_string($content) && ! is_array($content)) wp_die('no content meta'); + + $value = is_string($content) ? json_decode($content, true) : $content; + if (! is_array($value)) wp_die('content is not an array'); + + $mirror_key = match ($type) { + 'header' => '_bricks_page_header_2', + 'footer' => '_bricks_page_footer_2', + default => null, + }; + + if (! $mirror_key) wp_die("no mirror needed for type=$type"); + + // Write directly to DB as a serialized PHP array (the format Bricks's renderer expects) + global $wpdb; + $serialized = maybe_serialize($value); + $existing = $wpdb->get_var($wpdb->prepare( + "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id=%d AND meta_key=%s LIMIT 1", + $id, $mirror_key + )); + if ($existing) { + $wpdb->update($wpdb->postmeta, ['meta_value' => $serialized], ['meta_id' => $existing], ['%s'], ['%d']); + } else { + $wpdb->insert($wpdb->postmeta, ['post_id' => $id, 'meta_key' => $mirror_key, 'meta_value' => $serialized], ['%d', '%s', '%s']); + } + wp_cache_delete($id, 'post_meta'); + + // Verify + $after = get_post_meta($id, $mirror_key, true); + $is_array = is_array($after); + + wp_die('
'
+        . esc_html("Mirror done : template #$id ($type)\n  _bricks_page_content_2 -> $mirror_key\n  " . count($value) . " elements\n  stored as " . ($is_array ? 'array (correct)' : 'string (WRONG)'))
+        . '
', 'Mirror done'); +}); diff --git a/tools/av-cleanup-orphans.php b/tools/av-cleanup-orphans.php new file mode 100644 index 0000000..38a89ec --- /dev/null +++ b/tools/av-cleanup-orphans.php @@ -0,0 +1,48 @@ +get_results( + "SELECT ID, post_title, post_name, post_status FROM {$wpdb->posts} + WHERE post_type='page' AND (post_title='Secteurs' OR post_name LIKE 'secteurs%')" + ); + echo "Found " . count($dups) . " 'Secteurs' page(s):\n"; + foreach ($dups as $d) { + echo " #{$d->ID} title=\"{$d->post_title}\" slug={$d->post_name} status={$d->post_status}\n"; + } + + // 2) Force-delete all of them (will be recreated cleanly) + $deleted = 0; + foreach ($dups as $d) { + wp_delete_post($d->ID, true); + $wpdb->delete($wpdb->prefix . 'icl_translations', ['element_id' => $d->ID, 'element_type' => 'post_page']); + $deleted++; + } + echo "Deleted $deleted posts + their icl_translations rows.\n\n"; + + // 3) Remove empty icl_translations rows for trid=39 (Industries hub) + $empty_rows = $wpdb->query( + "DELETE FROM {$wpdb->prefix}icl_translations + WHERE trid=39 AND (element_id IS NULL OR element_id = 0)" + ); + echo "Deleted $empty_rows empty FR rows for trid=39.\n\n"; + + // 4) Verify current state of trid=39 + $rows = $wpdb->get_results("SELECT * FROM {$wpdb->prefix}icl_translations WHERE trid=39"); + echo "Current icl_translations for trid=39:\n"; + foreach ($rows as $r) { + echo " translation_id={$r->translation_id} element_id={$r->element_id} lang={$r->language_code}\n"; + } + exit; +}); diff --git a/tools/av-create-wp-menu.php b/tools/av-create-wp-menu.php new file mode 100644 index 0000000..bfa2644 --- /dev/null +++ b/tools/av-create-wp-menu.php @@ -0,0 +1,99 @@ + [ + 'label' => 'Header — Main', + 'items' => [ + ['title' => 'Solutions', 'url' => '/solutions/'], + ['title' => 'Industries', 'url' => '/industries/'], + ['title' => 'Technology', 'url' => '/technology/'], + ['title' => 'Why Asterion', 'url' => '/why-asterion/'], + ['title' => 'Insights', 'url' => '/insights/'], + ], + ], + 'footer-nav' => [ + 'label' => 'Footer — Solutions', + 'items' => [ + ['title' => 'Solutions', 'url' => '/solutions/'], + ['title' => 'Industries', 'url' => '/industries/'], + ['title' => 'Technology', 'url' => '/technology/'], + ], + ], + 'footer-co' => [ + 'label' => 'Footer — Company', + 'items' => [ + ['title' => 'About', 'url' => '/about/'], + ['title' => 'Customers', 'url' => '/customers/'], + ['title' => 'Partners', 'url' => '/partners/'], + ['title' => 'Insights', 'url' => '/insights/'], + ], + ], + 'footer-legal' => [ + 'label' => 'Footer — Legal', + 'items' => [ + ['title' => 'Privacy', 'url' => '/privacy/'], + ['title' => 'Terms', 'url' => '/terms/'], + ['title' => 'Cookies', 'url' => '/cookies/'], + ['title' => 'Imprint', 'url' => '/imprint/'], + ], + ], + ]; + + if (! isset($blueprints[$name])) { + wp_die('unknown menu blueprint : ' . esc_html($name) . '. Available : ' . implode(', ', array_keys($blueprints))); + } + + $bp = $blueprints[$name]; + $label = $bp['label']; + + // Idempotent : reuse existing menu if name matches + $menu = wp_get_nav_menu_object($label); + if (! $menu) { + $menu_id = wp_create_nav_menu($label); + if (is_wp_error($menu_id)) wp_die('create error : ' . $menu_id->get_error_message()); + $menu = wp_get_nav_menu_object($menu_id); + } else { + // Wipe existing items to repush fresh + $existing = wp_get_nav_menu_items($menu->term_id); + foreach ($existing as $item) { + wp_delete_post($item->ID, true); + } + } + + $report = ["Menu \"$label\" (term_id={$menu->term_id})"]; + + foreach ($bp['items'] as $i => $item) { + $item_id = wp_update_nav_menu_item($menu->term_id, 0, [ + 'menu-item-title' => $item['title'], + 'menu-item-url' => $item['url'], + 'menu-item-status' => 'publish', + 'menu-item-type' => 'custom', + 'menu-item-position' => $i + 1, + ]); + $report[] = " + {$item['title']} → {$item['url']} (item_id=$item_id)"; + } + + header('Content-Type: text/plain; charset=utf-8'); + echo implode("\n", $report); + echo "\n\nUSE IN BRICKS : nav-menu element with menu = {$menu->term_id}"; + exit; +}); diff --git a/tools/av-debug-meta.php b/tools/av-debug-meta.php new file mode 100644 index 0000000..b8ab19a --- /dev/null +++ b/tools/av-debug-meta.php @@ -0,0 +1,31 @@ + 'theme-styles-current.json', + 'bricks_color_palette' => 'color-palette-current.json', + 'bricks_global_settings' => 'global-settings-current.json', + ]; + + $report = []; + foreach ($options as $opt_key => $file_name) { + $value = get_option($opt_key, null); + $path = $out_dir . $file_name; + $json = wp_json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + file_put_contents($path, $json); + $report[] = $opt_key . ' → ' . $path . ' (' . strlen($json) . ' bytes)'; + } + + wp_die('
' . esc_html(implode("\n", $report)) . '
', 'Dump done'); +}); diff --git a/tools/av-fix-link-types.php b/tools/av-fix-link-types.php new file mode 100644 index 0000000..c6e9888 --- /dev/null +++ b/tools/av-fix-link-types.php @@ -0,0 +1,81 @@ +get_results( + "SELECT meta_id, post_id, meta_key, meta_value + FROM {$wpdb->postmeta} + WHERE meta_key IN ('" . implode("','", $keys) . "')" + ); + + $patched = 0; + $touched_posts = 0; + foreach ($rows as $row) { + $val = $row->meta_value; + if (! is_string($val) || $val === '') continue; + + $is_json = ($val[0] === '['); + $elements = null; + + if ($is_json) { + $elements = json_decode($val, true); + } else { + // Mirror keys (_bricks_page_header_2, _bricks_page_footer_2) are stored as + // PHP-serialized arrays by av-bricks-rest-bridge → maybe_unserialize. + $maybe = @unserialize($val); + if (is_array($maybe)) $elements = $maybe; + } + if (! is_array($elements)) continue; + + $count = 0; + av_walk_fix_links($elements, $count); + if ($count === 0) continue; + + // Write back in same format as it was stored + $new = $is_json ? wp_json_encode($elements) : maybe_serialize($elements); + $wpdb->update($wpdb->postmeta, ['meta_value' => $new], ['meta_id' => $row->meta_id], ['%s'], ['%d']); + wp_cache_delete($row->post_id, 'post_meta'); + $patched += $count; + $touched_posts++; + $fmt = $is_json ? 'json' : 'serialized'; + echo "post #{$row->post_id} ({$row->meta_key}, $fmt) : $count link(s) patched\n"; + } + + echo "\nTotal : $patched links across $touched_posts post(s) patched (type internal\u{2192}external).\n"; + exit; +}); + +function av_walk_fix_links(&$elements, &$count) { + $walk = function (&$node) use (&$walk, &$count) { + if (! is_array($node)) return; + // Link block shape : has type and url, type wrongly set to 'internal' with a non-numeric url + if (isset($node['type']) && $node['type'] === 'internal' && isset($node['url']) && is_string($node['url']) && $node['url'] !== '') { + $node['type'] = 'external'; + $count++; + } + foreach ($node as &$value) { + if (is_array($value)) $walk($value); + } + }; + foreach ($elements as &$el) $walk($el); +} diff --git a/tools/av-fix-template-meta.php b/tools/av-fix-template-meta.php new file mode 100644 index 0000000..9785113 --- /dev/null +++ b/tools/av-fix-template-meta.php @@ -0,0 +1,68 @@ + 'any'] applies on the entire site + if (in_array($type, ['header', 'footer', 'archive', 'single', 'search', 'error'], true)) { + $settings = get_post_meta($template_id, '_bricks_template_settings', true); + if (! is_array($settings)) $settings = []; + + $settings['templateConditions'] = [ + [ + 'id' => substr(md5((string) $template_id . microtime()), 0, 6), + 'main' => 'any', + ], + ]; + + // Header templates : sticky on top by default unless &sticky=0 + if ($type === 'header' && ($_GET['sticky'] ?? '1') !== '0') { + $settings['headerSticky'] = true; + // headerStickyOnScroll switches from position:fixed (default brx-sticky) + // to position:sticky → header flows normally on load (no content overlap), + // sticks only once user starts scrolling. + $settings['headerStickyOnScroll'] = true; + $report[] = "headerSticky = true + headerStickyOnScroll = true (.brx-sticky.on-scroll)"; + } + + update_post_meta($template_id, '_bricks_template_settings', $settings); + $report[] = "_bricks_template_settings.templateConditions = [{main:any}]"; + } + + wp_die('
'
+        . esc_html("Template #$template_id ($type) updated:\n  " . implode("\n  ", $report))
+        . '
', 'Template meta fixed'); +}); diff --git a/tools/av-generate-industries.php b/tools/av-generate-industries.php new file mode 100644 index 0000000..8a3edad --- /dev/null +++ b/tools/av-generate-industries.php @@ -0,0 +1,149 @@ + 'Secteurs', + "Built for those who carry the weight." + => "Conçu pour celles et ceux qui en portent le poids.", + "Police, special forces, military, and emergency services share a hard truth: their craft is rehearsed in the worst conditions, and rarely enough. PROSERVE\u{2122} puts contemporary training within everyone's reach \u{2014} every shift, every budget, every operating context." + => "Police, forces spéciales, armée et services d'urgence partagent une vérité dure : leur métier se répète dans les pires conditions, et trop rarement. PROSERVE\u{2122} met l'entraînement contemporain à la portée de tous \u{2014} chaque service, chaque budget, chaque contexte opérationnel.", + "Four operating contexts. One platform." + => "Quatre contextes opérationnels. Une seule plateforme.", + + // Card eyebrows + tile labels + 'National & Municipal' => 'Nationale & Municipale', + 'Police' => 'Police', + "Police training, when the schedule won't bend." + => "L'entraînement police, quand l'agenda ne fléchit pas.", + 'Tactical units' => 'Unités tactiques', + 'Special Forces' => 'Forces spéciales', + "Special forces train differently. Their simulation should, too." + => "Les forces spéciales s'entraînent autrement. Leur simulation devrait suivre.", + 'Armed forces' => 'Forces armées', + 'Military' => 'Armée', + "Military VR training. Sovereign. Modular. Field-deployable." + => "Entraînement VR pour l'armée. Souverain. Modulaire. Déployable sur le terrain.", + 'Emergency services' => "Services d'urgence", + 'Firefighters' => 'Sapeurs-pompiers', + "Train the worst day. Safely. Repeatedly." + => "Entraînez-vous au pire jour. En sécurité. Indéfiniment.", + + // Card body concatenations from hub builder + "National & Municipal \u{2014} Police training, when the schedule won't bend." + => "Nationale & Municipale \u{2014} L'entraînement police, quand l'agenda ne fléchit pas.", + "Tactical units \u{2014} Special forces train differently. Their simulation should, too." + => "Unités tactiques \u{2014} Les forces spéciales s'entraînent autrement. Leur simulation devrait suivre.", + "Armed forces \u{2014} Military VR training. Sovereign. Modular. Field-deployable." + => "Forces armées \u{2014} Entraînement VR pour l'armée. Souverain. Modulaire. Déployable sur le terrain.", + "Emergency services \u{2014} Train the worst day. Safely. Repeatedly." + => "Services d'urgence \u{2014} Entraînez-vous au pire jour. En sécurité. Indéfiniment.", + + // Hero / detail-page CTA labels specific to Industries + 'Request a Demo for police use' => 'Demander une démo pour la police', + 'Request a Demo for fire & emergency' => "Demander une démo pour pompiers & urgences", + ]); +}); + +/* ----------------------------------------------------------------- + URL trigger — fires on template_redirect (NOT init) so Bricks's + element controls are loaded (happens on 'wp' hook). Without loaded + controls, Bricks's wpml_page_builder_register_strings handler skips + every element silently and NO strings reach icl_strings → ATE empty. + ----------------------------------------------------------------- */ +add_action('template_redirect', function () { + if (empty($_GET['av_generate_industries'])) return; + if (! in_array($_SERVER['SERVER_NAME'] ?? '', ['localhost', '127.0.0.1'], true)) wp_die('localhost only'); + + $data_file = 'C:\\ASTERION\\GIT\\WP2026\\content-source\\industries-data.php'; + if (! file_exists($data_file)) wp_die("data file not found: $data_file"); + + $industries = include $data_file; + if (! is_array($industries)) wp_die('industries data not array'); + + $parent_id = av_ensure_hub_page('industries', 'Industries'); + $report = ["Rebuilding /industries/ hub + child detail pages..."]; + + // Hub + $hub_elements = av_build_industries_hub_elements($industries); + $hub_write = av_write_bricks_page_elements($parent_id, $hub_elements); + $report[] = " HUB → page #$parent_id (rebuilt) — " . count($hub_elements) + . " elements (json={$hub_write['json_len']}, stored={$hub_write['stored_len']})"; + + // Detail pages + foreach ($industries as $slug => $d) { + $page = av_upsert_detail_page($slug, 'industries', $d); + $report[] = " $slug → page #{$page['id']} ({$page['action']}) — {$page['element_count']} elements (json={$page['json_len']}, stored={$page['stored_len']})"; + } + + header('Content-Type: text/plain; charset=utf-8'); + echo implode("\n", $report); + exit; +}); + +/* ----------------------------------------------------------------- + Industries hub builder : hero + 4-card grid + closer CTA + ----------------------------------------------------------------- */ +function av_build_industries_hub_elements($industries) { + $els = []; + + // HERO + $els = array_merge($els, av_build_hero('hub', [ + 'eyebrow' => 'Industries', + 'tagline' => "Built for those who carry the weight.", + 'hero_lead' => "Police, special forces, military, and emergency services share a hard truth: their craft is rehearsed in the worst conditions, and rarely enough. PROSERVE\u{2122} puts contemporary training within everyone's reach \u{2014} every shift, every budget, every operating context.", + 'ctas' => [ + ['label' => 'Book a demo', 'href' => '/request-demo/', 'variant' => 'primary'], + ['label' => 'Request a T&E Kit', 'href' => '/request-te-kit/', 'variant' => 'secondary'], + ], + ])); + + // 4-card grid — one card per industry + $cards_section = [ + 'eyebrow' => '', + 'title' => 'Four operating contexts. One platform.', + 'items' => [], + ]; + foreach ($industries as $slug => $d) { + $cards_section['items'][] = [ + 'title' => $d['title'], + 'body' => ($d['eyebrow'] ?? '') . " \u{2014} " . ($d['tagline'] ?? ''), + ]; + } + $els = array_merge($els, av_build_cards_section('hubgrid', 0, $cards_section, 'var(--color-bg-subtle)')); + + // Append "Learn more →" link in each card (post-build patch) + $i = 0; + foreach ($industries as $slug => $d) { + $kid = "khubgridc0x{$i}"; + $lid = "lnhubgridc0x{$i}"; + $els[] = av_text_link($lid, $kid, "Learn more \u{2192}", "/industries/$slug/"); + foreach ($els as &$el) { + if (($el['id'] ?? '') === $kid) { + $el['children'][] = $lid; + } + } + unset($el); + $i++; + } + + // CLOSER CTA + $els = array_merge($els, av_build_closer_cta('hub')); + + return $els; +} diff --git a/tools/av-generate-solutions.php b/tools/av-generate-solutions.php new file mode 100644 index 0000000..e499dfa --- /dev/null +++ b/tools/av-generate-solutions.php @@ -0,0 +1,378 @@ + 'Solutions', + 'PROSERVE platform' => 'Plateforme PROSERVE', + "Four tiers. One platform. Train where you are." => "Quatre paliers. Une plateforme. Entraînez-vous là où vous êtes.", + "From the solo officer in a 9 m\u{B2} corner to a 16-trainee free-roam academy : PROSERVE\u{2122} scales without changing software. Same physics. Same AI. Same instructor logic. Pick the configuration that fits your context — upgrade or extend later." + => "De l'agent seul dans 9 m\u{B2} à une académie en libre déambulation pour 16 stagiaires : PROSERVE\u{2122} évolue sans changer de logiciel. Mêmes physiques. Même IA. Même logique instructeur. Choisissez la configuration adaptée à votre contexte — étendez ou faites évoluer plus tard.", + "Pick the right configuration for your unit." => "Choisissez la configuration adaptée à votre unité.", + + /* -------- Card titles + bodies (hub grid) -------- */ + 'MY PROSERVE' => 'MY PROSERVE', + 'PROSERVE FLEX' => 'PROSERVE FLEX', + 'PROSERVE ACADEMY' => 'PROSERVE ACADEMY', + 'PROSERVE+' => 'PROSERVE+', + + 'Solo training' => 'Entraînement solo', + 'Team training · 1 to 5' => 'Entraînement en équipe · 1 à 5', + 'Unit training · 1 to 16' => 'Entraînement d\'unité · 1 à 16', + 'Customization & options' => 'Personnalisation & options', + + 'Your first range, in a mini-case.' + => 'Votre premier stand, dans un mini-coffre.', + 'Your team, in the same scenario, on any flat surface.' + => 'Votre équipe, dans le même scénario, sur n\'importe quelle surface plane.', + 'The full training centre, in your facility.' + => 'Le centre de formation complet, dans vos locaux.', + "When the catalog doesn't fit your mission." + => "Quand le catalogue ne correspond pas à votre mission.", + + // Card body concatenations from hub builder (eyebrow — tagline) + "Solo training \u{2014} Your first range, in a mini-case." + => "Entraînement solo \u{2014} Votre premier stand, dans un mini-coffre.", + "Team training \u{B7} 1 to 5 \u{2014} Your team, in the same scenario, on any flat surface." + => "Entraînement en équipe \u{B7} 1 à 5 \u{2014} Votre équipe, dans le même scénario, sur n'importe quelle surface plane.", + "Unit training \u{B7} 1 to 16 \u{2014} The full training centre, in your facility." + => "Entraînement d'unité \u{B7} 1 à 16 \u{2014} Le centre de formation complet, dans vos locaux.", + "Customization & options \u{2014} When the catalog doesn't fit your mission." + => "Personnalisation & options \u{2014} Quand le catalogue ne correspond pas à votre mission.", + + /* ====================== MY PROSERVE ====================== */ + "French regulation requires three live-fire sessions and 90 cartridges per officer per year. Reality: 10 to 15. MY PROSERVE closes that gap, every shift, in 9 square meters." + => "La réglementation française exige trois séances de tir réel et 90 cartouches par agent et par an. La réalité : 10 à 15. MY PROSERVE comble cet écart, à chaque service, en 9 mètres carrés.", + + 'Why solo training matters now' => "Pourquoi l'entraînement solo compte maintenant", + 'The training deficit is operational risk.' + => "Le déficit d'entraînement est un risque opérationnel.", + "Two facts shape this product. First, regulation sets a floor that almost no force consistently meets. Second, every officer issued a firearm — including municipal police, gendarmerie reservists, and protective security agents — needs more repetition than range schedules can deliver. MY PROSERVE answers both: it puts a personal range in 3 to 9 m\u{B2} of free floor space, available 24/7, with the same physics as the live one." + => "Deux faits structurent ce produit. D'abord, la réglementation fixe un plancher que presque aucune force ne respecte vraiment. Ensuite, chaque agent porteur d'arme — y compris les polices municipales, les réservistes de la gendarmerie et les agents de sécurité — a besoin de plus de répétition que le planning des stands ne peut offrir. MY PROSERVE répond aux deux : un stand personnel dans 3 à 9 m\u{B2} de surface libre, disponible 24h/24, avec la même physique que le tir réel.", + + 'Key benefits' => 'Bénéfices clés', + 'What you get with one mini-case.' => 'Ce que vous obtenez avec un seul mini-coffre.', + + 'Ultra-portable' => 'Ultra-portable', + 'Fits in a single mini-case sized for routine transport. Weighs less than 20 kg. Sets up in under five minutes.' + => "Tient dans un seul mini-coffre dimensionné pour le transport quotidien. Pèse moins de 20 kg. Installation en moins de cinq minutes.", + 'Available around the clock' => 'Disponible 24h/24', + 'No range scheduling, no firearms instructor required for routine drills. Train at the start of a shift, between two patrols, or before deployment.' + => "Pas de réservation de stand, pas d'instructeur de tir requis pour les exercices courants. Entraînez-vous au début d'un service, entre deux patrouilles ou avant une mission.", + 'Multi-site' => 'Multi-sites', + 'Move it from station to station. Equip a region with a small fleet — your officers train where they are stationed.' + => "Déplacez-le d'un poste à l'autre. Équipez une région avec une petite flotte — vos agents s'entraînent là où ils sont affectés.", + 'Economical' => 'Économique', + "Ammunition costs collapse. A CO2 refill costs about \u{20AC}0.25 and powers 30 to 40 shots. A gas refill costs about \u{20AC}8 and powers 600 to 800 shots. Break-even versus live ammunition arrives in months, not years." + => "Les coûts de munitions s'effondrent. Une recharge CO2 coûte environ 0,25 \u{20AC} et permet 30 à 40 tirs. Une recharge gaz coûte environ 8 \u{20AC} et permet 600 à 800 tirs. Le retour sur investissement face aux munitions réelles se compte en mois, pas en années.", + 'Real-recoil firearm' => 'Arme à recul réel', + 'A gas-blowback or CO2 replica fitted with our Universal Weapon Digitizer. Recoil is genuine. Shot detection is dual (sound + recoil) to avoid false positives.' + => "Une réplique gas-blowback ou CO2 équipée de notre Universal Weapon Digitizer. Le recul est authentique. La détection du tir est double (son + recul) pour éviter les faux positifs.", + 'Personal AAR' => 'AAR personnel', + 'Every officer has a personal account and dashboard. Track precision, reaction time, missed shots, target identification accuracy over time. Shareable with the supervising instructor.' + => "Chaque agent dispose d'un compte personnel et d'un tableau de bord. Suivez précision, temps de réaction, tirs manqués et identification des cibles dans le temps. Partageable avec l'instructeur référent.", + + "What's in the mini-case" => 'Le contenu du mini-coffre', + 'Inside the case.' => 'Dans le coffre.', + 'VR rendering laptop (Intel Core i9-13900HX · NVIDIA RTX 4080 · 16 GB DDR5 · Wi-Fi 6E)' + => 'Portable de rendu VR (Intel Core i9-13900HX · NVIDIA RTX 4080 · 16 Go DDR5 · Wi-Fi 6E)', + "5K wireless headset (per-eye 2448\u{D7}2448 \u{B7} 120\u{B0} FOV \u{B7} Snapdragon XR2)" + => "Casque sans fil 5K (2448\u{D7}2448 par \u{153}il \u{B7} 120\u{B0} FOV \u{B7} Snapdragon XR2)", + 'Spare battery + charging dock' => 'Batterie de rechange + station de charge', + 'Wi-Fi 6E router (tri-band, AXE11000)' => 'Routeur Wi-Fi 6E (tri-bande, AXE11000)', + 'One real-recoil replica (handgun OR rifle, your choice from the catalog)' + => 'Une réplique à recul réel (pistolet OU fusil, au choix dans le catalogue)', + 'Universal Weapon Digitizer (UWeD)' => 'Universal Weapon Digitizer (UWeD)', + 'Gas / CO2 starter pack' => 'Pack de démarrage gaz / CO2', + "Custom-foam waterproof transport case (~20 kg fully loaded)" + => "Coffre de transport étanche avec mousse sur mesure (~20 kg en charge)", + + 'Suitable for' => 'Adapté à', + 'Designed for the officer, not the academy.' => "Conçu pour l'agent, pas l'académie.", + 'Police municipale and protective security agents — comply with continuous training obligations without depending on a national range network.' + => "Police municipale et agents de sécurité — respectez les obligations de formation continue sans dépendre d'un réseau national de stands.", + 'Reservists and gendarmes — train at home base between mobilizations.' + => "Réservistes et gendarmes — entraînez-vous à votre base entre deux mobilisations.", + 'Firearm-licensed officers in transport, energy, or critical infrastructure security.' + => "Agents porteurs d'arme dans la sécurité des transports, de l'énergie ou des infrastructures critiques.", + 'Trainee officers preparing for live-fire qualifications.' + => 'Élèves préparant leurs qualifications de tir réel.', + + 'Volume pricing' => 'Tarif volume', + 'Volume pricing for regional rollouts.' => 'Tarif volume pour les déploiements régionaux.', + "Equip a department, a region, or a national programme. Indicative tiers: from \u{20AC}9,000 per unit at single-piece purchase, dropping to \u{20AC}6,000 per unit at 50+ units. Quote on request." + => "Équipez un département, une région ou un programme national. Tarifs indicatifs : à partir de 9 000 \u{20AC} l'unité à l'achat unitaire, descendant à 6 000 \u{20AC} l'unité au-delà de 50 unités. Devis sur demande.", + + 'When to size up.' => "Quand passer à la taille supérieure.", + 'MY PROSERVE is the right choice for solo, repetitive marksmanship and decision drills. If you need cooperative team training (multiple trainees in the same scenario, real-time tactical coordination), step up to PROSERVE FLEX. If you run a full curriculum for 8 to 16 trainees at once, PROSERVE ACADEMY is the right fit. The software is the same — only the format scales.' + => "MY PROSERVE est le bon choix pour le tir et les drills décisionnels solo et répétés. Si vous avez besoin d'entraînement coopératif (plusieurs stagiaires dans le même scénario, coordination tactique en temps réel), montez à PROSERVE FLEX. Si vous opérez un cursus complet pour 8 à 16 stagiaires en simultané, PROSERVE ACADEMY est adapté. Le logiciel est le même — seul le format évolue.", + 'Compare with PROSERVE FLEX' => 'Comparer avec PROSERVE FLEX', + 'Compare with PROSERVE ACADEMY' => 'Comparer avec PROSERVE ACADEMY', + + /* ====================== PROSERVE FLEX ====================== */ + 'The portable team kit. One case per operator, one instructor PC, full multi-user cooperation. Deploy on a station floor, in a school gym, in a tent. The whole unit trains together — and reviews together.' + => "Le kit équipe portable. Un coffre par opérateur, un PC instructeur, coopération multi-utilisateurs complète. Déployez-le sur le sol d'un poste, dans un gymnase, sous une tente. Toute l'unité s'entraîne ensemble — et débriefe ensemble.", + + 'Why team training in VR' => "Pourquoi l'entraînement en équipe en VR", + 'Tactical performance is collective. Train it that way.' + => "La performance tactique est collective. Entraînez-la comme telle.", + 'Solo drills build muscle memory. They do not build cohesion. PROSERVE FLEX puts up to five operators in the same scenario, seeing each other as 3D avatars in roles defined by the mission — point, breacher, security, K9 handler, sniper. Communication, coordination, command-of-fire decisions: everything an instructor can rate, the system records.' + => "Les exercices solo construisent la mémoire musculaire. Pas la cohésion. PROSERVE FLEX réunit jusqu'à cinq opérateurs dans le même scénario, se voyant comme des avatars 3D dans des rôles définis par la mission — pointe, breacher, sécurité, maître-chien, tireur d'élite. Communication, coordination, décisions de tir : tout ce qu'un instructeur peut noter, le système l'enregistre.", + + 'Why FLEX wins on mobility.' => 'Pourquoi FLEX gagne sur la mobilité.', + 'Versatile' => 'Polyvalent', + 'Solo, two-by-two, or in teams of up to five. Same hardware, different scenario. The kit adapts to the schedule, not the other way around.' + => "Solo, par binôme ou en équipe jusqu'à cinq. Même matériel, scénarios différents. Le kit s'adapte au planning, pas l'inverse.", + 'Mobile' => 'Mobile', + "One case per operator — fully wireless. No fixed install, no cable management, no specialized facility. Set up on any flat indoor surface from 9 m\u{B2} (solo) to 100 m\u{B2} (full team)." + => "Un coffre par opérateur — entièrement sans fil. Pas d'installation fixe, pas de câbles, pas de local spécifique. Déployez-vous sur toute surface plane intérieure, de 9 m\u{B2} (solo) à 100 m\u{B2} (équipe complète).", + 'Fast' => 'Rapide', + 'Solo configuration ready in under five minutes. Full team configuration ready in under twenty. The instructor PC handles calibration on the fly.' + => "Configuration solo prête en moins de cinq minutes. Configuration équipe complète prête en moins de vingt. Le PC instructeur gère la calibration à la volée.", + 'Immersive' => 'Immersif', + 'Real-recoil replicas, ballistic physics powered by Unreal Engine 5, autonomous AI civilians and adversaries with vision, hearing, and personality (cautious, courageous, aggressive, panicked). No two scenarios play out the same way.' + => "Répliques à recul réel, physique balistique par Unreal Engine 5, civils et adversaires IA autonomes dotés de vision, ouïe et personnalité (prudent, courageux, agressif, paniqué). Aucun scénario ne se joue deux fois de la même façon.", + 'Instructor-led' => 'Piloté par l\'instructeur', + "A dedicated instructor PC follows the action from any angle, manipulates the scenario in real time, displays each trainee's field of view and shot trace. Statistics roll up automatically." + => "Un PC instructeur dédié suit l'action sous tous les angles, manipule le scénario en temps réel, affiche le champ de vision et la trace de tir de chaque stagiaire. Les statistiques remontent automatiquement.", + 'AAR-ready' => 'AAR-ready', + "Replay every second of the session in top-down tactical view, free camera, or any trainee's first-person view. Pause, slow down, compare. End every drill with the data, not just the impression." + => "Rejouez chaque seconde de la session en vue tactique zénithale, caméra libre ou première personne d'un stagiaire. Pause, ralenti, comparaison. Terminez chaque drill avec les données, pas seulement l'impression.", + + 'Inside the kit' => 'Le contenu du kit', + "What's in the FLEX deployment." => 'Le contenu du déploiement FLEX.', + 'One trainee case per operator (laptop, headset, batteries, weapon, UWeD)' + => 'Un coffre stagiaire par opérateur (laptop, casque, batteries, arme, UWeD)', + 'One instructor PC + display' => 'Un PC instructeur + écran', + 'Charging station + spare batteries' => 'Station de charge + batteries de rechange', + 'Optional: haptic vest, wrist tracker, holsters, additional weapon profiles (PROSERVE+)' + => 'Optionnel : gilet haptique, tracker de poignet, étuis, profils d\'armes additionnels (PROSERVE+)', + + 'Designed for tactical teams.' => 'Conçu pour les équipes tactiques.', + 'Special weapons and tactics units (RAID, BRI, GIGN equivalents)' + => 'Unités d\'intervention spéciales (RAID, BRI, équivalents GIGN)', + 'Mobile gendarmerie squadrons' => 'Escadrons de gendarmerie mobile', + 'National police rapid-response teams' => 'Équipes d\'intervention rapide de la police nationale', + 'K9 handler training pairs' => 'Binômes maître-chien', + 'Embarked teams (counter-piracy, executive protection)' + => 'Équipes embarquées (contre-piraterie, protection rapprochée)', + + 'Sample scenarios' => 'Scénarios types', + 'Sample team scenarios.' => 'Scénarios types en équipe.', + 'Tactical progression in a residential apartment' => 'Progression tactique dans un appartement', + 'Hostage rescue on an office floor' => 'Libération d\'otages dans un étage de bureaux', + 'Vehicle stop with armed driver' => 'Contrôle d\'un véhicule avec conducteur armé', + 'Active shooter in a supermarket' => 'Tireur actif dans un supermarché', + 'Crowd containment with isolated aggressor' => 'Maintien de foule avec agresseur isolé', + 'Tactical entry on a parking deck' => 'Entrée tactique sur un parking en élévation', + + /* ====================== PROSERVE ACADEMY ====================== */ + 'For training centres, national academies, and large operational units. Up to sixteen trainees in the same free-roam scenario. Up to 900 m² of indoor space. Permanent, calibrated, supervised by a dedicated instructor cockpit.' + => "Pour les centres de formation, les écoles nationales et les grandes unités opérationnelles. Jusqu'à seize stagiaires dans le même scénario en libre déambulation. Jusqu'à 900 m\u{B2} en intérieur. Installation permanente, calibrée, supervisée par un cockpit instructeur dédié.", + + 'When ACADEMY is the right fit' => 'Quand ACADEMY est adapté', + 'When you need scale, not portability.' => 'Quand vous avez besoin d\'échelle, pas de portabilité.', + "ACADEMY is the right configuration when you train cohorts of 8 to 16 trainees in the same scenario, when training is the unit's daily activity, and when you have a dedicated facility you can configure once. The hardware lives in a rack. The instructor sits in a control booth. The trainees walk in, calibrate, and start." + => "ACADEMY est la bonne configuration quand vous formez des cohortes de 8 à 16 stagiaires dans le même scénario, quand l'entraînement est l'activité quotidienne de l'unité et que vous disposez d'un local dédié configurable une fois pour toutes. Le matériel vit en baie. L'instructeur est en cabine de contrôle. Les stagiaires entrent, se calibrent et commencent.", + + 'What ACADEMY brings to a curriculum.' => "Ce qu'ACADEMY apporte à un cursus.", + 'Up to 16 trainees, same scenario' => "Jusqu'à 16 stagiaires, même scénario", + 'All trainees see each other as full-body 3D avatars whose appearance reflects their role — assault, security, breach, civilian. Communication is voice-natural and proximity-based.' + => "Tous les stagiaires se voient en avatars 3D corps entier dont l'apparence reflète leur rôle — assaut, sécurité, brèche, civil. La communication est vocale naturelle et basée sur la proximité.", + 'Modular ergonomics' => 'Ergonomie modulaire', + 'Configure for one trainee or sixteen. Self-configuring software. Less than five minutes from session-end to next-session-start.' + => "Configuration pour un ou seize stagiaires. Logiciel auto-configurable. Moins de cinq minutes entre la fin d'une session et le début de la suivante.", + 'Multi-mode training' => 'Entraînement multi-modes', + 'Procedural learning. Motor-skill acquisition. Repetitive drill (skill-and-drill). Deliberate fatigue training. Whatever your pedagogical model, the platform supports it.' + => "Apprentissage procédural. Acquisition motrice. Drill répétitif (skill-and-drill). Entraînement délibéré en fatigue. Quel que soit votre modèle pédagogique, la plateforme le supporte.", + 'Instructor cockpit, dedicated control room' => 'Cockpit instructeur, salle de contrôle dédiée', + "Real-time 360\u{B0} video monitoring, multi-angle tactical view, shot-trace overlay, individual statistics, scenario manipulation on the fly." + => "Supervision vidéo 360\u{B0} en temps réel, vue tactique multi-angles, overlay des traces de tir, statistiques individuelles, manipulation du scénario à la volée.", + 'Customizable scenarios' => 'Scénarios personnalisables', + "Add bespoke scenarios produced by our French studio. Replicate your local training environments, your unit's uniforms, your specific weapons profiles. Production lead time: 1 to 3 months per environment." + => "Ajoutez des scénarios sur mesure produits dans notre studio français. Reproduisez vos environnements locaux, les uniformes de votre unité, vos profils d'armes spécifiques. Délai de production : 1 à 3 mois par environnement.", + 'Permanent compute infrastructure' => 'Infrastructure de calcul permanente', + 'Server rack with VR rendering machines, instructor workstation, dedicated Wi-Fi 6E network. Engineered for daily operation, redundancy, and remote support.' + => "Baie serveur avec machines de rendu VR, poste instructeur, réseau Wi-Fi 6E dédié. Conçu pour l'opération quotidienne, la redondance et le support à distance.", + + 'Facility requirements' => 'Exigences du local', + 'What you need on site.' => 'Ce qu\'il vous faut sur site.', + "Indoor floor footprint: from 100 m\u{B2} to 900 m\u{B2} depending on the scenarios you train (we recommend 10\u{D7}10 m for full-team dynamic CQB)" + => "Emprise au sol : de 100 m\u{B2} à 900 m\u{B2} selon les scénarios entraînés (nous recommandons 10\u{D7}10 m pour le CQB dynamique en équipe complète)", + 'Even, non-flickering lighting (no direct sunlight)' => 'Éclairage homogène et stable (pas de lumière directe du soleil)', + 'Decorated walls (avoid uniformly colored surfaces — posters or markings improve tracking)' + => "Murs habillés (éviter les surfaces uniformes — affiches ou marquages améliorent le tracking)", + "Dedicated control room (minimum 6 m\u{B2}) for instructor cockpit" + => "Salle de contrôle dédiée (minimum 6 m\u{B2}) pour le cockpit instructeur", + "No specific HVAC requirements (unlike live-fire ranges \u{2014} no smoke, no lead, no decontamination)" + => "Aucune exigence CVC spécifique (contrairement aux stands de tir réel \u{2014} pas de fumée, pas de plomb, pas de décontamination)", + + 'Designed for academies.' => 'Conçu pour les académies.', + 'Police academies (national and regional)' => 'Écoles de police (nationales et régionales)', + 'Gendarmerie schools' => 'Écoles de gendarmerie', + 'Military training centres' => 'Centres de formation militaire', + 'Firefighter training schools' => 'Écoles de sapeurs-pompiers', + 'Joint inter-services training centres' => 'Centres interarmées de formation', + 'Distributors and integrators looking to operate a turnkey training-as-a-service offer' + => "Distributeurs et intégrateurs souhaitant opérer une offre training-as-a-service clé en main", + + /* ====================== PROSERVE+ ====================== */ + 'Your environments. Your weapons. Your uniforms. Your doctrine. Our engineers, your specifications. PROSERVE+ extends every PROSERVE configuration with bespoke hardware, environments, and software, designed and produced in our French studio.' + => "Vos environnements. Vos armes. Vos uniformes. Votre doctrine. Nos ingénieurs, vos spécifications. PROSERVE+ étend chaque configuration PROSERVE avec du matériel, des environnements et des logiciels sur mesure, conçus et produits dans notre studio français.", + + 'Hardware add-ons' => 'Extensions matérielles', + 'Hardware extensions.' => 'Extensions matérielles.', + 'Haptic vest' => 'Gilet haptique', + 'Full-torso wireless haptic vest. 32 ERM motors. Renders the impact location of incoming fire and explosive proximity. Bluetooth BLE, 13.5 hours autonomy.' + => "Gilet haptique sans fil torso complet. 32 moteurs ERM. Restitue la localisation des impacts entrants et la proximité d'explosion. Bluetooth BLE, 13,5 heures d'autonomie.", + 'Wrist trackers' => 'Trackers de poignet', + 'Six degree-of-freedom wrist tracking for precise hand placement, weapon retention, and grappling drills.' + => "Tracking de poignet à six degrés de liberté pour le placement précis des mains, la rétention de l'arme et les exercices de close-combat.", + 'Custom replicas' => 'Répliques sur mesure', + 'Sourcing and tracker integration for replicas that go beyond the standard catalog: country-specific service weapons, less-lethal options (electroshock, OC spray, beanbag launchers), shoulder-fired support weapons, and field-specific equipment.' + => "Sourcing et intégration de trackers pour des répliques qui dépassent le catalogue standard : armes de service spécifiques à un pays, options non-létales (électrochoc, gaz OC, lanceurs de sacs lestés), armes d'appui à l'épaule et équipements terrain spécifiques.", + + 'Environments and scenarios' => 'Environnements et scénarios', + 'Bespoke environments and scenarios.' => 'Environnements et scénarios sur mesure.', + 'All environment types are possible. We model and animate in our French studio, with our team of 3D designers and senior engineers. Lead time: from one to three months per environment, depending on complexity. One environment typically generates five to ten distinct scenarios.' + => "Tous les types d'environnements sont possibles. Modélisation et animation dans notre studio français, avec notre équipe de designers 3D et d'ingénieurs seniors. Délai : de un à trois mois par environnement, selon la complexité. Un environnement génère typiquement cinq à dix scénarios distincts.", + 'Specific city blocks (urban CQB on actual local geometry)' => 'Quartiers urbains spécifiques (CQB urbain sur géométrie locale réelle)', + 'Public transit infrastructure (specific airports, stations, metros)' + => 'Infrastructures de transport public (aéroports, gares, métros spécifiques)', + 'Industrial sites (oil & gas, chemical, energy)' => 'Sites industriels (oil & gas, chimie, énergie)', + 'Critical infrastructure (data centres, power plants, water treatment)' + => 'Infrastructures critiques (data centres, centrales, traitement des eaux)', + 'Government and embassy buildings' => 'Bâtiments gouvernementaux et ambassades', + 'Maritime (boats, naval vessels, oil rigs)' => 'Maritime (navires, bâtiments militaires, plateformes pétrolières)', + 'Aviation (commercial airliners, airfields)' => 'Aviation (avions de ligne, aérodromes)', + + 'Custom characters' => 'Personnages sur mesure', + 'Characters in your colors.' => 'Des personnages aux couleurs de votre unité.', + 'Virtual characters dressed in the uniforms of your forces, equipped with the gear of your unit. Hostile actors based on threat profiles relevant to your area of operations. Civilian behaviors localized to the cultural context of the scenario.' + => "Personnages virtuels en uniforme de vos forces, équipés du matériel de votre unité. Acteurs hostiles fondés sur des profils de menace pertinents pour votre zone d'opération. Comportements civils localisés au contexte culturel du scénario.", + + 'Process' => 'Processus', + 'How a PROSERVE+ project runs.' => 'Comment se déroule un projet PROSERVE+.', + '1. Discovery' => '1. Cadrage', + 'We interview your operators and instructors to capture the operational reality and the pedagogical goal.' + => "Nous interviewons vos opérateurs et instructeurs pour capter la réalité opérationnelle et l'objectif pédagogique.", + '2. Specification' => '2. Spécification', + 'Joint document defining the environment, scenarios, characters, weapons, and instructor controls.' + => "Document conjoint définissant l'environnement, les scénarios, les personnages, les armes et les contrôles instructeur.", + '3. Production' => '3. Production', + 'Modeling, animation, AI behavior tuning in our French studio. Weekly review milestones.' + => "Modélisation, animation, réglage des comportements IA dans notre studio français. Jalons de revue hebdomadaires.", + '4. Integration' => '4. Intégration', + 'Software update deploys the new content to your existing PROSERVE installation.' + => "Une mise à jour logicielle déploie le nouveau contenu sur votre installation PROSERVE existante.", + '5. Validation' => '5. Validation', + 'On-site instructor training and acceptance review.' + => "Formation des instructeurs sur site et revue de réception.", + ]; +} + +/* ----------------------------------------------------------------- + URL trigger — fires on template_redirect (NOT init) so Bricks's + element controls are loaded (happens on 'wp' hook). Without loaded + controls, Bricks's wpml_page_builder_register_strings handler skips + every element silently and NO strings reach icl_strings → ATE empty. + ----------------------------------------------------------------- */ +add_action('template_redirect', function () { + if (empty($_GET['av_generate_solutions'])) return; + if (! in_array($_SERVER['SERVER_NAME'] ?? '', ['localhost', '127.0.0.1'], true)) wp_die('localhost only'); + + $data_file = 'C:\\ASTERION\\GIT\\WP2026\\content-source\\solutions-data.php'; + if (! file_exists($data_file)) wp_die("data file not found: $data_file"); + + $solutions = include $data_file; + if (! is_array($solutions)) wp_die('solutions data not array'); + + $parent_id = av_ensure_hub_page('solutions', 'Solutions'); + $report = ["Rebuilding /solutions/ hub + child detail pages..."]; + + // Hub + $hub_elements = av_build_solutions_hub_elements($solutions); + $hub_write = av_write_bricks_page_elements($parent_id, $hub_elements); + $report[] = " HUB → page #$parent_id (rebuilt) — " . count($hub_elements) + . " elements (json={$hub_write['json_len']}, stored={$hub_write['stored_len']})"; + + // Detail pages + foreach ($solutions as $slug => $d) { + $page = av_upsert_detail_page($slug, 'solutions', $d); + $report[] = " $slug → page #{$page['id']} ({$page['action']}) — {$page['element_count']} elements (json={$page['json_len']}, stored={$page['stored_len']})"; + } + + header('Content-Type: text/plain; charset=utf-8'); + echo implode("\n", $report); + exit; +}); + +/* ----------------------------------------------------------------- + Solutions hub : hero + 4-card grid (tier comparison) + closer CTA + ----------------------------------------------------------------- */ +function av_build_solutions_hub_elements($solutions) { + $els = []; + + // HERO + $els = array_merge($els, av_build_hero('shub', [ + 'eyebrow' => 'PROSERVE platform', + 'tagline' => "Four tiers. One platform. Train where you are.", + 'hero_lead' => "From the solo officer in a 9 m\u{B2} corner to a 16-trainee free-roam academy : PROSERVE\u{2122} scales without changing software. Same physics. Same AI. Same instructor logic. Pick the configuration that fits your context — upgrade or extend later.", + 'ctas' => [ + ['label' => 'Book a demo', 'href' => '/request-demo/', 'variant' => 'primary'], + ['label' => 'Request a T&E Kit', 'href' => '/request-te-kit/', 'variant' => 'secondary'], + ], + ])); + + // 4-card grid — one card per tier + $cards_section = [ + 'eyebrow' => '', + 'title' => 'Pick the right configuration for your unit.', + 'items' => [], + ]; + foreach ($solutions as $slug => $d) { + $cards_section['items'][] = [ + 'title' => $d['title'], + 'body' => ($d['eyebrow'] ?? '') . " \u{2014} " . ($d['tagline'] ?? ''), + ]; + } + $els = array_merge($els, av_build_cards_section('shubgrid', 0, $cards_section, 'var(--color-bg-subtle)')); + + // Append "Learn more →" link in each card (post-build patch) + $i = 0; + foreach ($solutions as $slug => $d) { + $kid = "kshubgridc0x{$i}"; + $lid = "lnshubgridc0x{$i}"; + $els[] = av_text_link($lid, $kid, "Learn more \u{2192}", "/solutions/$slug/"); + foreach ($els as &$el) { + if (($el['id'] ?? '') === $kid) { + $el['children'][] = $lid; + } + } + unset($el); + $i++; + } + + // CLOSER CTA + $els = array_merge($els, av_build_closer_cta('shub')); + + return $els; +} diff --git a/tools/av-translate-industries-fr.php b/tools/av-translate-industries-fr.php new file mode 100644 index 0000000..fc35888 --- /dev/null +++ b/tools/av-translate-industries-fr.php @@ -0,0 +1,61 @@ + 'Secteurs', + 'police' => 'Police', + 'special-forces' => 'Forces spéciales', + 'military' => 'Armée', + 'firefighters' => 'Sapeurs-pompiers', + ]; + + // 1) Hub FR + $fr_id = av_translate_page_fr($hub->ID, 'industries', $fr_titles['industries']); + if ($fr_id) { + echo "HUB EN #{$hub->ID} → FR #$fr_id (slug: industries)\n"; + } else { + echo "HUB EN #{$hub->ID} : FR creation FAILED\n"; + } + + // 2) Detail pages FR + foreach (['police', 'special-forces', 'military', 'firefighters'] as $slug) { + $en = get_page_by_path("industries/$slug"); + if (! $en) { + echo " $slug : EN page not found — skipping\n"; + continue; + } + $fr_id = av_translate_page_fr($en->ID, $slug, $fr_titles[$slug]); + if ($fr_id) { + echo " $slug : EN #{$en->ID} → FR #$fr_id\n"; + } else { + echo " $slug : FR creation FAILED\n"; + } + } + + echo "\nDONE. Now run /?av_generate_industries=1 again to push translated structure to FR via auto-sync.\n"; + exit; +}); diff --git a/tools/av-translate-solutions-fr.php b/tools/av-translate-solutions-fr.php new file mode 100644 index 0000000..bbf998f --- /dev/null +++ b/tools/av-translate-solutions-fr.php @@ -0,0 +1,61 @@ + 'Solutions', + 'my-proserve' => 'MY PROSERVE', + 'proserve-flex' => 'PROSERVE FLEX', + 'proserve-academy' => 'PROSERVE ACADEMY', + 'customization' => 'PROSERVE+', + ]; + + // 1) Hub FR + $fr_id = av_translate_page_fr($hub->ID, 'solutions', $fr_titles['solutions']); + if ($fr_id) { + echo "HUB EN #{$hub->ID} → FR #$fr_id (slug: solutions)\n"; + } else { + echo "HUB EN #{$hub->ID} : FR creation FAILED\n"; + } + + // 2) Detail pages FR + foreach (['my-proserve', 'proserve-flex', 'proserve-academy', 'customization'] as $slug) { + $en = get_page_by_path("solutions/$slug"); + if (! $en) { + echo " $slug : EN page not found — skipping\n"; + continue; + } + $fr_id = av_translate_page_fr($en->ID, $slug, $fr_titles[$slug]); + if ($fr_id) { + echo " $slug : EN #{$en->ID} → FR #$fr_id\n"; + } else { + echo " $slug : FR creation FAILED\n"; + } + } + + echo "\nDONE. Now run /?av_generate_solutions=1 again to push translated structure to FR via auto-sync.\n"; + exit; +}); diff --git a/tools/av-wpml-create-jobs.php b/tools/av-wpml-create-jobs.php new file mode 100644 index 0000000..e945336 --- /dev/null +++ b/tools/av-wpml-create-jobs.php @@ -0,0 +1,185 @@ +get_results($wpdb->prepare( + "SELECT p.ID AS post_id, p.post_title AS title, t.trid, t.translation_id, ts.rid, ts.status + FROM {$wpdb->posts} p + JOIN {$wpdb->prefix}icl_translations t ON t.element_id = p.ID AND t.element_type='post_page' AND t.language_code='fr' + LEFT JOIN {$wpdb->prefix}icl_translation_status ts ON ts.translation_id = t.translation_id + WHERE p.post_name IN ($placeholders)", + ...$slugs + )); +} + +function av_wpml_create_jobs() { + global $wpdb; + header('Content-Type: text/plain; charset=utf-8'); + + $rows = av_target_fr_translations(); + if (! $rows) { echo "No FR translations found\n"; exit; } + + $batch_id = 1; // reuse Bricks #14's batch (or create new one — using 1 for simplicity) + + foreach ($rows as $r) { + if (! $r->rid) { + echo "FR #{$r->post_id} '{$r->title}' : no translation_status row, skipping\n"; + continue; + } + + // 1. Set translation_status to status=2 (ICL_TM_IN_PROGRESS) to match + // Bricks #14's pattern. This is what makes the FR column icon = gear ⚙️ + // and routes the click to the translation editor (CTE). + // Note: editing the EN page directly will show a "Translation in progress, + // wait before editing" warning dialog — informative only, click "I understand" + // to dismiss. + $uuid = wp_generate_uuid4(); + $wpdb->update( + $wpdb->prefix . 'icl_translation_status', + [ + 'status' => 2, // ICL_TM_IN_PROGRESS (= gear icon) + 'needs_update' => 0, + 'translator_id' => 1, + 'translation_service' => 'local', + 'batch_id' => $batch_id, + 'uuid' => $uuid, + 'tp_revision' => 1, + 'timestamp' => current_time('mysql'), + 'links_fixed' => 1, + ], + ['rid' => $r->rid] + ); + + // 2. Insert/update translate_job row with editor='ate' + $existing_job = $wpdb->get_var($wpdb->prepare( + "SELECT job_id FROM {$wpdb->prefix}icl_translate_job WHERE rid=%d ORDER BY job_id DESC LIMIT 1", + $r->rid + )); + + // editor='wpml' = CTE (Classic Translation Editor), works locally without + // cloud sync. translated=1 + completed_date set = the FR is "ready", + // matches Bricks #14's pattern (which shows gear icon and opens CTE). + // CRITICAL : revision MUST be NULL. WPML's filter_status_css_class() in + // class-wpml-tm-translation-status-display.php joins icl_translate_job + // with the explicit condition `translate_job.revision IS NULL`. Jobs + // with non-null revision are silently ignored → pencil icon, no CTE. + $job_data = [ + 'rid' => $r->rid, + 'translator_id' => 1, + 'translated' => 1, // FR considered done (we pre-filled via dict) + 'manager_id' => 1, + // 'revision' deliberately omitted → DB default = NULL + 'title' => $r->title, + 'deadline_date' => null, + 'completed_date' => current_time('mysql'), + 'editor' => 'wpml', // CTE (matches doc_translation_method=1) + 'editor_job_id' => null, + 'automatic' => 0, + ]; + + if ($existing_job) { + $wpdb->update($wpdb->prefix . 'icl_translate_job', $job_data, ['job_id' => $existing_job]); + // $wpdb->update can't write SQL NULL (it coerces to empty string). + // Force revision NULL via raw query for the icon's JOIN to match. + $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->prefix}icl_translate_job SET revision = NULL WHERE job_id = %d", + $existing_job + )); + echo "FR #{$r->post_id} '{$r->title}' (rid={$r->rid}) : job UPDATED #{$existing_job} (editor={$job_data['editor']}, revision=NULL)\n"; + } else { + $wpdb->insert($wpdb->prefix . 'icl_translate_job', $job_data); + $new_job_id = $wpdb->insert_id; + // Belt-and-suspenders : ensure revision is NULL even on fresh insert. + $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->prefix}icl_translate_job SET revision = NULL WHERE job_id = %d", + $new_job_id + )); + echo "FR #{$r->post_id} '{$r->title}' (rid={$r->rid}) : job CREATED #{$new_job_id} (editor={$job_data['editor']}, revision=NULL)\n"; + } + } + + echo "\nDONE. Refresh WP Admin → Pages. The FR column icon should switch from pencil ✏️ to gear ⚙️.\n"; + echo "Click the gear to open ATE for that page.\n"; + exit; +} + +function av_wpml_remove_jobs() { + global $wpdb; + header('Content-Type: text/plain; charset=utf-8'); + + $rows = av_target_fr_translations(); + foreach ($rows as $r) { + if (! $r->rid) continue; + + // 1. Remove translate_job rows + $wpdb->query($wpdb->prepare( + "DELETE FROM {$wpdb->prefix}icl_translate_job WHERE rid=%d", + $r->rid + )); + + // 2. Reset status row : ALL the cloud-sync artifacts cleared + $wpdb->update( + $wpdb->prefix . 'icl_translation_status', + [ + 'status' => 10, + 'needs_update' => 0, + 'batch_id' => 0, + 'uuid' => '', + 'tp_id' => '', + 'tp_revision' => 0, + 'ts_status' => null, + 'review_status' => null, + 'ate_comm_retry_count' => 0, + '_prevstate' => '', + 'translation_service' => 'local', + 'translator_id' => 0, + ], + ['rid' => $r->rid] + ); + + echo "FR #{$r->post_id} : job removed, status fully reset\n"; + } + + // 3. Cleanup orphan translation_batches rows that may trigger cloud-fetch + $orphans = $wpdb->query( + "DELETE FROM {$wpdb->prefix}icl_translation_batches + WHERE batch_name LIKE 'asterion%' OR id NOT IN + (SELECT DISTINCT batch_id FROM {$wpdb->prefix}icl_translation_status WHERE batch_id > 0)" + ); + echo "\nOrphan translation_batches rows cleaned : $orphans\n"; + + // 4. Cleanup orphan translation_downloads rows (background ATE sync queue) + $tbl_downloads = $wpdb->prefix . 'icl_translation_downloads'; + if ($wpdb->get_var("SHOW TABLES LIKE '$tbl_downloads'")) { + $cleared = $wpdb->query("DELETE FROM $tbl_downloads"); + echo "Cleared $cleared rows from icl_translation_downloads (pending cloud sync queue)\n"; + } + + echo "\nReverted to clean state. WPML queue + page list should be back to normal.\n"; + exit; +} diff --git a/tools/av-wpml-debug.php b/tools/av-wpml-debug.php new file mode 100644 index 0000000..3157b87 --- /dev/null +++ b/tools/av-wpml-debug.php @@ -0,0 +1,59 @@ + $l) { + echo " $lc " . ($l['display_name'] ?? '') . " url=" . ($l['url'] ?? '') . "\n"; + } + echo "\nURL strategy: " . ($setup['language_negotiation_type'] ?? '?') . " (1=lang in dir, 2=domain, 3=lang param)\n"; + echo "Hidden langs: " . implode(',', $setup['hidden_languages'] ?? []) . "\n"; + exit; +} + +function av_wpml_translations($post_id) { + header('Content-Type: text/plain; charset=utf-8'); + + $type = get_post_type($post_id); + $title = get_the_title($post_id); + $element = 'post_' . $type; + $trid = apply_filters('wpml_element_trid', null, $post_id, $element); + $translations = apply_filters('wpml_get_element_translations', null, $trid, $element); + + echo "Post #$post_id (type=$type) : \"$title\"\n"; + echo "trid: " . ($trid ?? 'null') . "\n\n"; + echo "Translations:\n"; + foreach ((array) $translations as $lc => $t) { + $tid = $t->element_id ?? '?'; + $name = $t->post_title ?? '?'; + $slug = get_post($tid)->post_name ?? '?'; + echo " [$lc] id=$tid slug=$slug title=\"$name\"\n"; + } + exit; +} diff --git a/tools/av-wpml-fix-status.php b/tools/av-wpml-fix-status.php new file mode 100644 index 0000000..4022efb --- /dev/null +++ b/tools/av-wpml-fix-status.php @@ -0,0 +1,270 @@ +get_col( + "SELECT DISTINCT t.trid + FROM {$wpdb->prefix}icl_translations t + JOIN {$wpdb->posts} p ON p.ID = t.element_id + WHERE t.element_type='post_page' + AND t.element_id > 0 + AND (p.post_name IN ('industries','solutions','police','special-forces','military','firefighters','my-proserve','proserve-flex','proserve-academy','customization'))" + ); +} + +function av_wpml_status_dump() { + global $wpdb; + header('Content-Type: text/plain; charset=utf-8'); + + // 1) Sitepress editor preference + $opts = get_option('icl_sitepress_settings'); + $editor_default = $opts['translation-management']['doc_translation_method'] ?? '(unset)'; + $editor_alt = $opts['translation_method'] ?? '(unset)'; + $ate_enabled = $opts['translation-management']['ate']['ate_enabled'] ?? '(unset)'; + echo "WPML editor preference :\n"; + echo " doc_translation_method = $editor_default\n"; + echo " translation_method = $editor_alt\n"; + echo " ate_enabled = $ate_enabled\n"; + echo "\n"; + + // 2) For each target trid, dump rows + $trids = av_wpml_target_trids(); + if (! $trids) { echo "(no target trids found)\n"; exit; } + echo "Found " . count($trids) . " target trid(s) : " . implode(', ', $trids) . "\n\n"; + + foreach ($trids as $trid) { + echo "── trid $trid ──\n"; + $tr_rows = $wpdb->get_results($wpdb->prepare( + "SELECT translation_id, element_id, language_code, source_language_code + FROM {$wpdb->prefix}icl_translations + WHERE trid=%d ORDER BY language_code", + $trid + )); + foreach ($tr_rows as $r) { + $title = get_the_title($r->element_id); + echo " [{$r->language_code}] post #{$r->element_id} '{$title}' (translation_id={$r->translation_id}, src={$r->source_language_code})\n"; + + // status row + $st = $wpdb->get_row($wpdb->prepare( + "SELECT status, needs_update, translation_service, translator_id, md5 + FROM {$wpdb->prefix}icl_translation_status + WHERE translation_id=%d", + $r->translation_id + )); + if ($st) { + echo " status_row : status=$st->status needs_update=$st->needs_update service=$st->translation_service translator=$st->translator_id md5=" . substr($st->md5 ?? '', 0, 8) . "\n"; + } else { + echo " status_row : MISSING (no row in icl_translation_status)\n"; + } + } + echo "\n"; + } + exit; +} + +/** + * Full repair pipeline : + * 1. For each known EN slug, find EN's trid. + * 2. Find any FR post with the same slug. If it's linked to a DIFFERENT trid, + * relink it to EN's trid (this fixes session-3 re-creation cases where + * set_element_language_details fell back to a fresh trid because an orphan + * empty FR row blocked the canonical (trid, fr, ...) slot). + * 3. Delete orphan FR rows (element_id NULL/0/empty) in the canonical trid. + * 4. Reset icl_translation_status (status=10, needs_update=0, md5 from EN content). + */ +function av_wpml_repair() { + global $wpdb; + header('Content-Type: text/plain; charset=utf-8'); + + $slugs = [ + // Industries + 'industries', 'police', 'special-forces', 'military', 'firefighters', + // Solutions + 'solutions', 'my-proserve', 'proserve-flex', 'proserve-academy', 'customization', + ]; + + echo "── STEP 1+2 : align FR posts to EN's trid ──\n\n"; + foreach ($slugs as $slug) { + // EN post for slug + $en = $wpdb->get_row($wpdb->prepare( + "SELECT p.ID, t.trid + FROM {$wpdb->posts} p + JOIN {$wpdb->prefix}icl_translations t ON t.element_id = p.ID AND t.element_type='post_page' + WHERE p.post_name=%s AND t.language_code='en' + LIMIT 1", + $slug + )); + if (! $en) { echo " $slug : no EN page found — skip\n"; continue; } + + // FR post for slug (regardless of current trid) + $fr = $wpdb->get_row($wpdb->prepare( + "SELECT p.ID, t.trid, t.translation_id + FROM {$wpdb->posts} p + JOIN {$wpdb->prefix}icl_translations t ON t.element_id = p.ID AND t.element_type='post_page' + WHERE p.post_name=%s AND t.language_code='fr' + LIMIT 1", + $slug + )); + if (! $fr) { echo " $slug : EN #{$en->ID} (trid={$en->trid}) — no FR yet\n"; continue; } + + if ((int) $fr->trid === (int) $en->trid) { + echo " $slug : EN #{$en->ID} ↔ FR #{$fr->ID} aligned on trid={$en->trid} ✓\n"; + } else { + // 1) Remove any orphan FR row in EN's canonical trid + $orphans = $wpdb->query($wpdb->prepare( + "DELETE FROM {$wpdb->prefix}icl_translations + WHERE trid=%d AND language_code='fr' AND element_type='post_page' + AND (element_id IS NULL OR element_id = 0 OR element_id = '')", + $en->trid + )); + // 2) Move FR translation_id to EN's trid + $wpdb->update( + $wpdb->prefix . 'icl_translations', + ['trid' => $en->trid, 'source_language_code' => 'en'], + ['translation_id' => $fr->translation_id], + ['%d', '%s'], + ['%d'] + ); + // 3) Delete the now-empty old trid row(s) of FR's previous trid (in case anything else was attached there) + // Skip — keep it conservative. The old trid row for FR is now moved, not deleted. + + echo " $slug : FR #{$fr->ID} moved from trid={$fr->trid} → {$en->trid} (deleted $orphans orphan(s))\n"; + } + } + + echo "\n── STEP 3 : remove ALL orphan FR rows across known trids ──\n\n"; + $trids = av_wpml_target_trids(); + foreach ($trids as $trid) { + $deleted = $wpdb->query($wpdb->prepare( + "DELETE FROM {$wpdb->prefix}icl_translations + WHERE trid=%d AND (element_id IS NULL OR element_id = 0 OR element_id = '')", + $trid + )); + if ($deleted > 0) echo " trid $trid : deleted $deleted orphan row(s)\n"; + } + + echo "\n── STEP 4 : write icl_translation_status (status=10 completed) ──\n\n"; + av_wpml_status_fix(false); + echo "\nDONE.\n"; + exit; +} + +function av_wpml_status_fix($exit_after = true) { + global $wpdb; + header('Content-Type: text/plain; charset=utf-8'); + + $trids = av_wpml_target_trids(); + $patched = 0; $inserted = 0; + + foreach ($trids as $trid) { + // Get EN and FR translation_id + post_id + $en = $wpdb->get_row($wpdb->prepare( + "SELECT translation_id, element_id FROM {$wpdb->prefix}icl_translations + WHERE trid=%d AND language_code='en' AND element_id > 0 LIMIT 1", + $trid + )); + $fr = $wpdb->get_row($wpdb->prepare( + "SELECT translation_id, element_id FROM {$wpdb->prefix}icl_translations + WHERE trid=%d AND language_code='fr' AND element_id > 0 LIMIT 1", + $trid + )); + if (! $en || ! $fr) { + echo "trid $trid : EN or FR missing — skip\n"; + continue; + } + + // Compute md5 of EN _bricks_page_content_2 (what WPML's translation engine would + // hash on a normal flow). When EN changes, md5 differs → WPML shows "needs update". + $en_content = get_post_meta($en->element_id, '_bricks_page_content_2', true); + if (is_array($en_content)) $en_content = wp_json_encode($en_content); + $md5 = md5((string) $en_content); + + // Status codes (WPML constants, hardcoded for portability) : + // ICL_TM_NOT_TRANSLATED = 0 + // ICL_TM_WAITING = 1 + // ICL_TM_NEEDS_UPDATE = 3 + // ICL_TM_DUPLICATE = 9 + // ICL_TM_COMPLETE = 10 + $data = [ + 'translation_id' => $fr->translation_id, + 'status' => 10, // ICL_TM_COMPLETE + 'needs_update' => 0, + 'md5' => $md5, + 'translation_service' => 'local', // not delegated to a paid service + 'translator_id' => 0, // local human (us, programmatic) + 'translated_by_id' => 0, + 'links_fixed' => 1, + ]; + + $existing = $wpdb->get_var($wpdb->prepare( + "SELECT rid FROM {$wpdb->prefix}icl_translation_status WHERE translation_id=%d", + $fr->translation_id + )); + + if ($existing) { + // Only update fields that exist (drop optional ones to be safe) + $update = $data; + unset($update['translation_id']); // not changing PK + $wpdb->update($wpdb->prefix . 'icl_translation_status', $update, ['rid' => $existing]); + echo "trid $trid : FR #{$fr->element_id} → status row UPDATED (rid=$existing, status=10, md5=" . substr($md5, 0, 8) . ")\n"; + $patched++; + } else { + // Be careful : the table may not have all columns. Try with full first, fallback. + $ok = $wpdb->insert($wpdb->prefix . 'icl_translation_status', $data); + if (! $ok) { + // Retry minimal + $minimal = [ + 'translation_id' => $fr->translation_id, + 'status' => 10, + 'needs_update' => 0, + 'md5' => $md5, + ]; + $ok = $wpdb->insert($wpdb->prefix . 'icl_translation_status', $minimal); + } + if ($ok) { + echo "trid $trid : FR #{$fr->element_id} → status row INSERTED (status=10, md5=" . substr($md5, 0, 8) . ")\n"; + $inserted++; + } else { + echo "trid $trid : FR #{$fr->element_id} → INSERT FAILED : " . $wpdb->last_error . "\n"; + } + } + } + + echo "\nDONE. patched=$patched, inserted=$inserted\n"; + echo "Refresh WP Admin → Pages — orange refresh icons should clear, pencils should\n"; + echo "route to WPML's editor (ATE if ate_enabled=1, classic editor otherwise).\n"; + if ($exit_after) exit; +} diff --git a/tools/av-wpml-plugins-check.php b/tools/av-wpml-plugins-check.php new file mode 100644 index 0000000..38f7f00 --- /dev/null +++ b/tools/av-wpml-plugins-check.php @@ -0,0 +1,50 @@ + $data) { + if (stripos($file, 'wpml') === false && stripos($file, 'sitepress') === false) continue; + $is_active = in_array($file, $active, true); + echo sprintf(" %-50s %s (v%s)\n", $file, $is_active ? '[ACTIVE]' : '[inactive]', $data['Version']); + } + + echo "\nSitepress settings (extracted) :\n"; + $opts = get_option('icl_sitepress_settings'); + $keys = [ + ['translation-management', 'doc_translation_method'], + ['translation-management', 'ate', 'ate_enabled'], + ['existing_content_language_verified'], + ['setup_complete'], + ['language_negotiation_type'], + ]; + foreach ($keys as $path) { + $val = $opts; + foreach ($path as $k) { + $val = is_array($val) && isset($val[$k]) ? $val[$k] : null; + if ($val === null) break; + } + echo " " . implode('.', $path) . " = " . (is_scalar($val) ? var_export($val, true) : '(' . gettype($val) . ')') . "\n"; + } + + // Has Translation Management features ? + echo "\nFeature detection :\n"; + echo " class TranslationManagement exists : " . (class_exists('TranslationManagement') ? 'yes' : 'no') . "\n"; + echo " class WPML_TM_Loader exists : " . (class_exists('WPML_TM_Loader') ? 'yes' : 'no') . "\n"; + echo " class WPML_String_Translation exists : " . (class_exists('WPML_String_Translation') ? 'yes' : 'no') . "\n"; + echo " WPML_TM_VERSION defined : " . (defined('WPML_TM_VERSION') ? WPML_TM_VERSION : 'no') . "\n"; + + exit; +}); diff --git a/tools/av-wpml-push-translations.php b/tools/av-wpml-push-translations.php new file mode 100644 index 0000000..a89cc37 --- /dev/null +++ b/tools/av-wpml-push-translations.php @@ -0,0 +1,122 @@ +get_results( + "SELECT context, COUNT(*) AS n FROM {$wpdb->prefix}icl_strings GROUP BY context ORDER BY n DESC" + ); + echo "Strings registered (icl_strings) by context :\n"; + foreach ($rows as $r) echo sprintf(" %-40s %d strings\n", $r->context, $r->n); + + // 1b) Sample strings whose VALUE matches our content (find which context Bricks uses for page content) + echo "\nSearching for our content strings :\n"; + foreach (['PROSERVE', 'Police training', 'Built for those', 'mini-case', 'Industries', 'Solutions'] as $needle) { + $found = $wpdb->get_results($wpdb->prepare( + "SELECT context, name, LEFT(value, 70) AS preview FROM {$wpdb->prefix}icl_strings WHERE value LIKE %s LIMIT 3", + '%' . $wpdb->esc_like($needle) . '%' + )); + echo " needle '$needle' : " . count($found) . " match(es)\n"; + foreach ($found as $f) echo " context='{$f->context}' name='{$f->name}' value='{$f->preview}'\n"; + } + + // 2) Sample a few Bricks strings + their FR (context is just 'bricks', not per-page) + echo "\nSample Bricks strings (10 of 156) :\n"; + $strings = $wpdb->get_results( + "SELECT id, name, LEFT(value, 80) AS preview, status FROM {$wpdb->prefix}icl_strings WHERE context='bricks' LIMIT 10" + ); + foreach ($strings as $s) { + echo " id={$s->id} name={$s->name} status={$s->status}\n EN: {$s->preview}\n"; + $fr = $wpdb->get_var($wpdb->prepare( + "SELECT LEFT(value, 80) FROM {$wpdb->prefix}icl_string_translations WHERE string_id=%d AND language='fr' LIMIT 1", + $s->id + )); + echo " FR: " . ($fr !== null ? $fr : '(no translation)') . "\n"; + } + + // 3) Count translations by language and status + $tr_rows = $wpdb->get_results( + "SELECT language, status, COUNT(*) AS n + FROM {$wpdb->prefix}icl_string_translations GROUP BY language, status" + ); + echo "\nString translations summary :\n"; + foreach ($tr_rows as $r) echo " lang={$r->language} status={$r->status} : {$r->n}\n"; + + exit; +} + +function av_wpml_strings_push() { + global $wpdb; + header('Content-Type: text/plain; charset=utf-8'); + + if (! function_exists('icl_add_string_translation')) { + echo "FAIL : icl_add_string_translation() not available\n"; + exit; + } + + // Trigger the dict to load (with all filter contributions from generators) + $dict = av_fr_translation_dictionary(); + echo "Loaded FR dictionary : " . count($dict) . " EN→FR mappings\n\n"; + + // All Bricks page-content strings (per-page contexts : bricks-{POST_ID}) + $strings = $wpdb->get_results( + "SELECT id, value, context FROM {$wpdb->prefix}icl_strings WHERE context LIKE 'bricks-%'" + ); + echo "Found " . count($strings) . " EN strings registered for Bricks pages\n\n"; + + $matched = 0; + $unmatched = 0; + $written = 0; + foreach ($strings as $s) { + if (! isset($dict[$s->value])) { + $unmatched++; + continue; + } + $matched++; + $fr = $dict[$s->value]; + // ICL_TM_COMPLETE = 10 + $result = icl_add_string_translation((int) $s->id, 'fr', $fr, 10); + if ($result) $written++; + } + + echo "Matched in dict : $matched\n"; + echo "Written to icl_string_translations : $written\n"; + echo "Unmatched (no FR in dict, will stay EN in ATE) : $unmatched\n"; + + if ($unmatched > 0 && $unmatched < 100) { + echo "\nSample unmatched EN strings (extend the dict to translate these) :\n"; + $shown = 0; + foreach ($strings as $s) { + if (! isset($dict[$s->value]) && $shown < 15) { + echo " - " . substr($s->value, 0, 120) . (strlen($s->value) > 120 ? '...' : '') . "\n"; + $shown++; + } + } + } + + echo "\nDONE. Refresh ATE — FR strings should be pre-filled.\n"; + exit; +} diff --git a/tools/av-wpml-rescan.php b/tools/av-wpml-rescan.php new file mode 100644 index 0000000..0c0e0f9 --- /dev/null +++ b/tools/av-wpml-rescan.php @@ -0,0 +1,61 @@ +ID, false); + foreach (['police','special-forces','military','firefighters'] as $slug) { + $p = get_page_by_path("industries/$slug"); + if ($p) av_wpml_rescan_one($p->ID, false); + } + echo "Done."; + exit; + } +}); + +function av_wpml_rescan_one($post_id, $output = true) { + $post = get_post($post_id); + if (! $post) { + if ($output) wp_die("post $post_id not found"); + return; + } + + $package_data = [ + 'kind' => 'Bricks', + 'name' => (string) $post->ID, + 'post_id' => $post->ID, + 'title' => "Bricks (ID {$post->ID})", + ]; + + do_action('wpml_page_builder_register_strings', $post, $package_data); + + if ($output) { + header('Content-Type: text/plain; charset=utf-8'); + echo "Rescan triggered : post #$post_id (\"{$post->post_title}\")\n"; + echo "WPML String Translation should now have the Bricks strings registered.\n"; + echo "Return to ATE and reopen the translation — strings should appear.\n"; + } else { + echo " rescanned #$post_id ({$post->post_title})\n"; + } +} diff --git a/tools/av-wpml-set-cte.php b/tools/av-wpml-set-cte.php new file mode 100644 index 0000000..40af2de --- /dev/null +++ b/tools/av-wpml-set-cte.php @@ -0,0 +1,44 @@ +