This playbook documents how to create new service pages for callbrightside.com using native Oxygen Builder elements instead of raw HTML code blocks. Pages built this way are fully editable in the Oxygen UI, support drag-and-drop, and inherit responsive breakpoints automatically.
Instead of rebuilding every element from scratch, we clone an existing working page's JSON structure, replace selectors and content, then insert the modified JSON directly into the WordPress database. This takes ~15 minutes per page vs. 2-3 hours of manual Oxygen UI work.
| Feature | Native Oxygen | Code Block (raw HTML) |
|---|---|---|
| Editable in UI | Yes | No |
| Drag and drop | Yes | No |
| Responsive built-in | Yes | Manual CSS |
| Reusable components | Yes | No |
| Team maintainable | Yes | Dev only |
| Build speed (with playbook) | ~15 min | ~30 min |
| Table | Key | Value | Purpose |
|---|---|---|---|
| ukueq_postmeta | ct_builder_json | Full JSON blob (~14K chars) | Entire Oxygen page structure |
| ukueq_postmeta | ct_other_template | 10 | Header/footer template reference |
| ukueq_postmeta | ct_builder_shortcodes | (empty) | Shortcode placeholder |
| ukueq_posts | post_content | (empty) | Oxygen stores structure in meta, not content |
post.php?post=XXXX). Set the title, slug, and permalink. For sewer camera, this was Page ID 1313 at /sewer-camera-inspection/.
ukueq_.
Run this SQL query to get the Oxygen JSON from the source page:
SELECT meta_value
FROM ukueq_postmeta
WHERE post_id = 137
AND meta_key = 'ct_builder_json';
Copy the entire meta_value result. This is the raw Oxygen JSON (will be heavily escaped if exported via SQL dump).
If copied directly from phpMyAdmin results tab, it should be valid JSON. If from a SQL dump file, you need to unescape SQL backslash encoding first:
import json, re
# If from SQL dump, unescape backslashes
raw = open('source_dump.sql').read()
# Extract the JSON value between quotes
match = re.search(r"'(\{.*?\})'", raw, re.DOTALL)
if match:
escaped = match.group(1)
# Unescape SQL-style escaping
unescaped = escaped.replace("\\'", "'").replace('\\"', '"').replace('\\\\', '\\')
data = json.loads(unescaped)
# Save clean version
with open('source_page_oxy.json', 'w') as f:
json.dump(data, f, indent=2)
print(f"Parsed OK. Root children: {len(data.get('children', []))}")
Confirm the structure is intact before proceeding:
# Must have root with children
assert data['name'] == 'root'
assert len(data['children']) >= 5 # sections + reusables + modal
# Must have selectors with source page ID
json_str = json.dumps(data)
count = json_str.count(f'-137') # Source page ID
print(f"Found {count} selectors with page ID 137")
import copy
source_id = 137 # Page we're cloning FROM
target_id = 1313 # Page we're building FOR
# Deep copy to avoid mutating original
page = copy.deepcopy(data)
Every Oxygen element has a selector field that includes the page ID (e.g., div_block-2-137). These MUST be updated to the new page ID or styles will collide.
def replace_selectors(obj, old_id, new_id):
"""Recursively replace page ID in all selector strings."""
if isinstance(obj, dict):
for key, val in obj.items():
if key == 'selector' and isinstance(val, str):
obj[key] = val.replace(f'-{old_id}', f'-{new_id}')
elif key == 'activeselector' and isinstance(val, str):
obj[key] = val.replace(f'-{old_id}', f'-{new_id}')
else:
replace_selectors(val, old_id, new_id)
elif isinstance(obj, list):
for item in obj:
replace_selectors(item, old_id, new_id)
replace_selectors(page, source_id, target_id)
json_str = json.dumps(page)
old_count = json_str.count(f'-{source_id}')
new_count = json_str.count(f'-{target_id}')
print(f"Old selectors remaining: {old_count}") # MUST be 0
print(f"New selectors created: {new_count}") # Should be 30+
assert old_count == 0, "STOP: Old selectors still present!"
Every element in the Oxygen tree has a unique ct_id. Use this helper to locate any element:
def find_by_ct_id(obj, target_id):
"""Find an element in the Oxygen tree by its ct_id."""
if isinstance(obj, dict):
opts = obj.get('options', {})
if opts.get('ct_id') == target_id:
return obj
for child in obj.get('children', []):
result = find_by_ct_id(child, target_id)
if result:
return result
return None
Replace text, images, and links for the new service. Target elements by ct_id:
# H1 - Page Title
el = find_by_ct_id(page, 8)
el['options']['ct_content'] = 'Sewer Camera Inspection in Kansas City<br>'
# Intro paragraph
el = find_by_ct_id(page, 9)
el['options']['ct_content'] = 'See inside your pipes before you spend a dollar...'
# H2 sections (repeat for each)
el = find_by_ct_id(page, 13)
el['options']['ct_content'] = 'What Is a Sewer Camera Inspection?'
# Rich text (HTML content)
el = find_by_ct_id(page, 14)
el['options']['ct_content'] = '''<p>A sewer camera inspection uses a...</p>
<p>Our plumber inserts a flexible rod...</p>'''
The image element references a WordPress attachment by ID, URL, and dimensions:
# Hero image (ct_id: 6)
el = find_by_ct_id(page, 6)
opts = el['options']['original']
opts['attachment_id'] = 1202 # WordPress media ID
opts['attachment_url'] = 'https://www.callbrightside.com/wp-content/uploads/2024/sewer-camera-hero.jpg'
opts['attachment_size'] = 'image-640'
opts['attachment_height'] = 427
opts['attachment_width'] = 640
opts['alt'] = 'Bright Side Plumbing technician performing sewer camera inspection'
post=XXXX in media editor), then reference it here. Wrong attachment_id = broken image.
# Modal heading (ct_id: 51)
el = find_by_ct_id(page, 51)
el['options']['ct_content'] = 'Free Sewer Camera Inspection'
# Modal body text (ct_id: 52 - code block)
el = find_by_ct_id(page, 52)
el['options']['original']['code-php'] = '''<p style="color:#555;font-size:15px;">
Get a free HD camera inspection with any sewer repair estimate.
See exactly what is happening inside your pipes.
</p>'''
The dynamic monthly special (ct_id: 3) uses JavaScript to display the current month:
# Specials notice (ct_id: 3 - code block)
el = find_by_ct_id(page, 3)
el['options']['original']['code-js'] = '''
var months = ["JANUARY","FEBRUARY","MARCH","APRIL","MAY","JUNE",
"JULY","AUGUST","SEPTEMBER","OCTOBER","NOVEMBER","DECEMBER"];
var currentMonth = months[new Date().getMonth()];
document.getElementById("special-notice").innerHTML =
"<strong>" + currentMonth + " SPECIAL:</strong> " +
"Free sewer camera inspection with any repair estimate";
'''
| ct_id | Element Type | Content |
|---|---|---|
| 3 | ct_code_block | Specials notice (JS) |
| 6 | ct_image | Hero image |
| 8 | ct_headline | H1 - Page title |
| 9 | ct_text_block | Intro paragraph |
| 13 | ct_headline | H2 - "What Is..." |
| 14 | oxy_rich_text | Answer paragraph |
| 15 | ct_headline | H2 - "When Do You Need..." |
| 16 | oxy_rich_text | Answer paragraph |
| 17 | ct_headline | H2 - "What Happens..." |
| 18 | oxy_rich_text | Answer paragraph |
| 39 | ct_headline | H2 - "Why Choose..." |
| 40 | oxy_rich_text | Answer paragraph |
| 51 | ct_headline | Modal heading |
| 52 | ct_code_block | Modal body text |
# Pretty version (for human review)
with open('sewer_camera_inspection_oxy.json', 'w') as f:
json.dump(page, f, indent=2)
# Compact version (for database)
compact = json.dumps(page, separators=(',', ':'))
with open('sewer_camera_inspection_oxy_compact.json', 'w') as f:
f.write(compact)
print(f"Pretty: {os.path.getsize('sewer_camera_inspection_oxy.json')} bytes")
print(f"Compact: {len(compact)} chars")
target_id = 1313
# Escape single quotes for SQL
sql_safe = compact.replace("'", "\\'")
sql = f"""-- Sewer Camera Inspection Page (ID: {target_id})
-- Generated by build_camera_page.py
INSERT INTO ukueq_postmeta (post_id, meta_key, meta_value)
VALUES ({target_id}, 'ct_builder_json', '{sql_safe}');
INSERT INTO ukueq_postmeta (post_id, meta_key, meta_value)
VALUES ({target_id}, 'ct_other_template', '10');
INSERT INTO ukueq_postmeta (post_id, meta_key, meta_value)
VALUES ({target_id}, 'ct_builder_shortcodes', '');
"""
with open('sewer_camera_inspection_insert.sql', 'w') as f:
f.write(sql)
1. Open phpMyAdmin for the callbrightside.com database
2. Go to SQL tab
3. Check if meta already exists (to avoid duplicates):
SELECT meta_id FROM ukueq_postmeta
WHERE post_id = 1313 AND meta_key = 'ct_builder_json';
4. If exists, UPDATE instead of INSERT:
UPDATE ukueq_postmeta
SET meta_value = '[COMPACT JSON]'
WHERE post_id = 1313 AND meta_key = 'ct_builder_json';
5. If not exists, run the INSERT statements from the SQL file
Navigate to: wp-admin/post.php?post=1313&action=edit
Click "Edit with Oxygen". The builder should load all elements in the visual editor. If you see a blank canvas, the JSON was malformed or not inserted correctly.
In Oxygen's structure panel (left sidebar), confirm:
Use Oxygen's responsive preview to check tablet and mobile views. Since we preserved all media query settings from the source page, responsive behavior should work identically.
1. Save in Oxygen editor
2. Publish the page (change from draft to published)
3. Purge Cloudflare cache for the URL
4. Purge WP Rocket / LiteSpeed cache
5. Visit the live URL in incognito to verify
{
"id": 0,
"name": "root",
"depth": 0,
"children": [...] // All page elements
}
{
"id": 2,
"name": "ct_div_block", // Element type
"options": {
"ct_id": 2, // Component tree ID (unique per page)
"ct_parent": 0, // Parent ct_id (0 = root)
"selector": "div_block-2-1313", // CSS selector (includes page ID)
"nicename": "specials notice", // Label in Oxygen UI
"classes": ["special-notice"], // CSS classes
"activeselector": "special-notice",
"ct_depth": 1, // Depth in tree
"ct_content": "", // Text/HTML content
"original": { // Styling properties
"background-color": "#fff",
"padding-top": "20",
"padding-top-unit": "px"
},
"media": { // Responsive overrides
"phone-portrait": {
"original": { "font-size": "14" }
}
}
},
"children": [...], // Child elements
"depth": 1
}
{
"name": "ct_image",
"options": {
"original": {
"attachment_id": 1202,
"attachment_url": "https://...",
"attachment_size": "image-640",
"attachment_height": 427,
"attachment_width": 640,
"alt": "Description text",
"src": "https://...",
"object-fit": "cover"
}
}
}
{
"name": "ct_code_block",
"options": {
"original": {
"code-php": "<div id='container'></div>",
"code-js": "document.getElementById('container').innerHTML = 'Hello';"
}
}
}
{
"name": "ct_reusable",
"options": {
"view_id": 52, // WordPress post/view ID to embed
"selector": "reusable-10-1313" // Selector with page ID
}
}
| Type | Purpose | Content Field | Notes |
|---|---|---|---|
ct_section | Major page section | None (container) | Use for top-level blocks |
ct_div_block | Generic container/wrapper | None (container) | Grids, columns, wrappers |
ct_headline | Heading (H1-H6) | ct_content | Set tag in original.tag |
ct_text_block | Plain text paragraph | ct_content | No HTML formatting |
oxy_rich_text | Rich HTML text | ct_content | Supports <p>, <ul>, <a>, etc. |
ct_image | Image | original.* | Uses attachment_id + URL |
ct_code_block | PHP/JS code | original.code-php/js | Executes on page load |
ct_link_button | CTA button | ct_content + url | Styled link element |
ct_modal | Modal/popup | Children elements | Has trigger settings |
ct_reusable | Embed another post | view_id | Shared components |
oxy-shape-divider | SVG section divider | SVG settings | Wavy/angled borders |
These components are shared across all service pages. Edit once, updates everywhere.
| view_id | Component | Used For |
|---|---|---|
| 52 | CTA Button Widget | Call-to-action in hero section |
| 46 | Guarantees Section | Trust badges, warranty info |
| 36 | Services Grid | List of all plumbing services |
| 264 | Service Areas | Sidebar city/area list |
| 47 | Members / Reviews | Team + review cards |
\' which becomes \\' in the dump. If json.loads() fails on the extracted text, try progressively unescaping: first \\\\ → \\, then \\' → ', then \\" → ". Three decode scripts exist for this reason.
ct_builder_json already exists. Running INSERT creates a duplicate. Always check with SELECT first. Use UPDATE if the row exists. Duplicates cause Oxygen to load the wrong version or break entirely.
attachment_id must match an existing WordPress media library item. If you use the source page's attachment_id, it will show the source page's image (which might be fine for shared images, but wrong for hero images). Always upload new images first and get the correct ID.
ct_content for rich text elements uses HTML. Special characters must be properly encoded. Ampersands as &, quotes as ". Malformed HTML in ct_content can break the entire Oxygen editor for that page.
Use this checklist every time you create a new service page:
| Page | Source Template | Status | Priority |
|---|---|---|---|
| /sewer-camera-inspection/ | Page 137 | DONE (ID: 1313) | P1 |
| /sewer-line-replacement/ | Page 1313 or 137 | Next | P1 |
| /emergency-sewer-repair/ | Page 1313 or 137 | Planned | P1 |
| /trenchless-sewer-repair/ | Page 1313 or 137 | Planned | P1 |
| /sewer-line-cleaning/ | Page 1313 or 137 | Planned | P2 |
| /sewer-line-repair/ | Page 1313 or 137 | Planned | P2 |