$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, // no "— " prefix anymore '_cssClasses' => 'av-list-item', // gold-bar accent via wp_head CSS '_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 || $card_count === 6) { // 5 cards : 3 + 2 — 6 cards : 3 + 3. Both wrap cleanly at 3-per-row. $flex_desktop = 'calc((100% - 3rem) / 3)'; } else { // default 4 cards/row (and 7+ → 4 + N 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) { // $parent_path empty/null/'' → top-level page (no parent) $page_path = $parent_path ? "$parent_path/$slug" : $slug; $existing = get_page_by_path($page_path); $parent_id = 0; if ($parent_path) { $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); }