@bettercms-ai/mcp 0.83.0 → 0.83.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -86
- package/SKILL.md +160 -160
- package/dist/index.js +38 -9
- package/dist/index.js.map +1 -1
- package/dist/kit-catalog.json +1509 -1509
- package/dist/skills/bettercms/SKILL.md +206 -206
- package/dist/skills/bettercms/references/accessibility.md +171 -171
- package/dist/skills/bettercms/references/animation.md +296 -296
- package/dist/skills/bettercms/references/astro.md +148 -148
- package/dist/skills/bettercms/references/bindings.md +366 -366
- package/dist/skills/bettercms/references/componentize.md +185 -185
- package/dist/skills/bettercms/references/components.md +415 -415
- package/dist/skills/bettercms/references/content-experiments.md +62 -62
- package/dist/skills/bettercms/references/content-modeling.md +382 -382
- package/dist/skills/bettercms/references/detection.md +465 -465
- package/dist/skills/bettercms/references/dock-input.md +121 -121
- package/dist/skills/bettercms/references/editor-chrome.md +92 -92
- package/dist/skills/bettercms/references/external-content.md +78 -78
- package/dist/skills/bettercms/references/finish-site.md +377 -377
- package/dist/skills/bettercms/references/forms-and-interactions.md +245 -245
- package/dist/skills/bettercms/references/frameworks.md +159 -159
- package/dist/skills/bettercms/references/getting-started.md +156 -156
- package/dist/skills/bettercms/references/integrations.md +364 -364
- package/dist/skills/bettercms/references/kit-catalog.md +3259 -3259
- package/dist/skills/bettercms/references/kit-scenarios.md +712 -712
- package/dist/skills/bettercms/references/localization.md +85 -85
- package/dist/skills/bettercms/references/media-and-brand.md +181 -181
- package/dist/skills/bettercms/references/next.md +189 -189
- package/dist/skills/bettercms/references/other-frameworks.md +85 -85
- package/dist/skills/bettercms/references/portable-text.md +213 -213
- package/dist/skills/bettercms/references/programmatic-seo.md +98 -98
- package/dist/skills/bettercms/references/publishing.md +289 -289
- package/dist/skills/bettercms/references/seo.md +357 -357
- package/dist/skills/bettercms/references/shopify-headless.md +52 -52
- package/dist/skills/bettercms/references/site-blueprints.md +895 -895
- package/dist/skills/bettercms/references/site-review.md +74 -74
- package/dist/skills/bettercms/references/worked-examples-advanced.md +509 -509
- package/dist/skills/bettercms/references/worked-examples.md +885 -885
- package/dist/skills/bettercms/references/write-limits.md +217 -217
- package/package.json +1 -1
|
@@ -1,885 +1,885 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "Worked examples"
|
|
3
|
-
description: "Six conversions from source HTML to a production schema, a production value and a bound template: hero, feature grid, pricing with a comparison table, testimonials as a collection, FAQ with FAQPage markup, bento grid with per-cell sizes."
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Worked examples: source HTML to schema, value and bound template
|
|
7
|
-
|
|
8
|
-
Each example runs the same five steps: the source markup (trimmed), what it is and why, the
|
|
9
|
-
schema as the write tool takes it, the value as the write tool takes it, and the template with
|
|
10
|
-
its binding attributes. Copy the shape, never the customer's words. Rules behind the shapes:
|
|
11
|
-
[content-modeling.md](content-modeling.md), [components.md](components.md),
|
|
12
|
-
[bindings.md](bindings.md), [portable-text.md](portable-text.md). Examples 6 to 8 continue in
|
|
13
|
-
[worked-examples-advanced.md](worked-examples-advanced.md).
|
|
14
|
-
|
|
15
|
-
## Contents
|
|
16
|
-
|
|
17
|
-
- [Which shape each example uses](#which-shape-each-example-uses)
|
|
18
|
-
- [1. Hero with two buttons and a background image](#1-hero-with-two-buttons-and-a-background-image)
|
|
19
|
-
- [2. Feature grid with icons](#2-feature-grid-with-icons)
|
|
20
|
-
- [3. Pricing plans with a comparison table](#3-pricing-plans-with-a-comparison-table)
|
|
21
|
-
- [4. Testimonials kept as a collection](#4-testimonials-kept-as-a-collection)
|
|
22
|
-
- [5. FAQ accordion with FAQPage markup](#5-faq-accordion-with-faqpage-markup)
|
|
23
|
-
- [9. Bento grid with per-cell sizes](#9-bento-grid-with-per-cell-sizes)
|
|
24
|
-
- [Checks after any example](#checks-after-any-example)
|
|
25
|
-
- Examples 6 to 8 (blog post as an entry, mega menu in the Global Layout, tabs holding columns
|
|
26
|
-
of cards): [worked-examples-advanced.md](worked-examples-advanced.md)
|
|
27
|
-
|
|
28
|
-
## Which shape each example uses
|
|
29
|
-
|
|
30
|
-
Production stores content in four places. Pick by what the markup is, then follow that example.
|
|
31
|
-
|
|
32
|
-
| Markup | Stored as | Schema tool | Value tool | Address on the page | Example |
|
|
33
|
-
|---|---|---|---|---|---|
|
|
34
|
-
| A band one page owns | Page fields, keys prefixed by section | `create_page` `fields`, `add_field` | `set_page_content` | `data-bcms-field="<key>"` | 1, 2, 5 |
|
|
35
|
-
| A band reused or with variants | Component with props | `create_component` | `compose_pages` (an instance per page) | `<instanceId>__overrides.<key>` | 3 |
|
|
36
|
-
| Records that repeat on one route | Content model and entries | `create_content_model` | `create_content_entry` | the entry's field key | 4, 6 (advanced) |
|
|
37
|
-
| Header, footer, promo bar | Global Layout Section | `update_layout` | `update_layout` | `data-bcms-layout-field` | 7 (advanced) |
|
|
38
|
-
| A one-off band built from blocks | `section` block with child blocks | `update_page` `blockJson` | the same call | `<blockId>__<prop>` | 8 (advanced) |
|
|
39
|
-
| Cells of different kinds, each with its own size | Component with a `slot` list and `slotSettings` | `create_component` | `compose_pages` | `<instanceId>__overrides.<key>` | 9 |
|
|
40
|
-
|
|
41
|
-
Rules that hold in every example:
|
|
42
|
-
|
|
43
|
-
- A key is unique in its whole model, so a page field carries its section: `"hero_title"`,
|
|
44
|
-
`"faq_title"`. Prop keys and sub-field keys live inside one component and stay short.
|
|
45
|
-
- A field of type `text` is created as rich text. Send `richText: false` for every plain string
|
|
46
|
-
(a title, an eyebrow, a name, an icon name), then write it as a bare string.
|
|
47
|
-
- A repeater value is `{ "repeatable": [ ...rows ] }`; a row is addressed `<key>[<n>].<childKey>`.
|
|
48
|
-
- Bind the element that renders the value. Never a wrapper, never a list container.
|
|
49
|
-
- `set_page_content` merges by top-level key. `compose_pages` and `update_page` `blockJson`
|
|
50
|
-
replace the page's whole block array: read `get_page` first and send every block back.
|
|
51
|
-
|
|
52
|
-
## 1. Hero with two buttons and a background image
|
|
53
|
-
|
|
54
|
-
Source: a freight-tracking home page.
|
|
55
|
-
|
|
56
|
-
```html
|
|
57
|
-
<section class="hero relative isolate overflow-hidden" id="top">
|
|
58
|
-
<picture class="absolute inset-0 -z-10">
|
|
59
|
-
<source type="image/avif" srcset="/img/hero-harbor-1280.avif 1280w, /img/hero-harbor-2560.avif 2560w">
|
|
60
|
-
<img src="/img/hero-harbor-1280.jpg" alt="Container cranes over a quiet harbor at dusk"
|
|
61
|
-
class="h-full w-full object-cover object-[50%_35%]" width="2560" height="1440">
|
|
62
|
-
</picture>
|
|
63
|
-
<div class="absolute inset-0 -z-10 bg-slate-950/50" aria-hidden="true"></div>
|
|
64
|
-
<div class="mx-auto max-w-6xl px-6 py-32 text-white">
|
|
65
|
-
<p class="text-sm uppercase text-teal-300">Freight visibility for mid-size shippers</p>
|
|
66
|
-
<h1 class="mt-4 text-5xl font-bold">Know where every container is before your customer asks.</h1>
|
|
67
|
-
<p class="mt-6 text-lg">Live ETAs from 140 carriers, delays flagged before they cost you, and one shared view for ops and sales.</p>
|
|
68
|
-
<a href="/demo" class="btn btn-primary">Book a demo <svg class="icon-arrow-right" aria-hidden="true"/></a>
|
|
69
|
-
<a href="https://app.kestrelfreight.com/signup" class="btn btn-ghost">Start free trial</a>
|
|
70
|
-
<p class="mt-8 text-sm"><span class="stars" role="img" aria-label="Rated 4.8 out of 5">★★★★★</span> 4.8 on G2 from 310 reviews</p>
|
|
71
|
-
</div>
|
|
72
|
-
</section>
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
Reasoning:
|
|
76
|
-
|
|
77
|
-
1. One `<h1>`, a lede, two links styled as buttons, a photo behind the copy: the page's opening
|
|
78
|
-
band. Other pages write their own hero copy, so this is page fields, not chrome.
|
|
79
|
-
2. The uppercase line, the heading and the paragraph carry no links or emphasis: three plain
|
|
80
|
-
strings.
|
|
81
|
-
3. Each `<a class="btn">` is one `button` field. Label, link and look travel together; the
|
|
82
|
-
trailing arrow is the button's `arrow`, not content.
|
|
83
|
-
4. The `<picture>` is one `image` field. Store the largest source once and copy the `alt`. The CSS
|
|
84
|
-
`object-position: 50% 35%` is the focal point: divide by 100, so `{ x: 0.5, y: 0.35 }`.
|
|
85
|
-
5. The stars are drawn from the number. Store `4.8` and the label; never the glyphs.
|
|
86
|
-
6. The scrim, the padding and the teal colour are design. They stay in the site's classes.
|
|
87
|
-
|
|
88
|
-
Schema (`create_page` `fields`, or one `add_field` with `pageId` per field):
|
|
89
|
-
|
|
90
|
-
```json
|
|
91
|
-
[
|
|
92
|
-
{ "key": "hero_eyebrow", "label": "Eyebrow", "type": "text", "richText": false },
|
|
93
|
-
{ "key": "hero_title", "label": "Heading", "type": "text", "richText": false, "required": true },
|
|
94
|
-
{ "key": "hero_lede", "label": "Supporting text", "type": "text", "richText": false },
|
|
95
|
-
{ "key": "hero_primary", "label": "Primary button", "type": "button" },
|
|
96
|
-
{ "key": "hero_secondary", "label": "Secondary button", "type": "button" },
|
|
97
|
-
{ "key": "hero_image", "label": "Background image", "type": "image",
|
|
98
|
-
"helpText": "Fills the whole band behind the text. Use a photo at least 2560 px wide." },
|
|
99
|
-
{ "key": "hero_rating", "label": "Rating", "type": "number", "config": { "min": 0, "max": 5, "step": 0.1 },
|
|
100
|
-
"ui": { "control": "rating" } },
|
|
101
|
-
{ "key": "hero_rating_label", "label": "Rating source", "type": "text", "richText": false }
|
|
102
|
-
]
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
Value (`set_page_content`):
|
|
106
|
-
|
|
107
|
-
```json
|
|
108
|
-
{
|
|
109
|
-
"pageId": "<home page id>",
|
|
110
|
-
"data": {
|
|
111
|
-
"hero_eyebrow": "Freight visibility for mid-size shippers",
|
|
112
|
-
"hero_title": "Know where every container is before your customer asks.",
|
|
113
|
-
"hero_lede": "Live ETAs from 140 carriers, delays flagged before they cost you, and one shared view for ops and sales.",
|
|
114
|
-
"hero_primary": { "label": "Book a demo", "href": "/demo", "arrow": true },
|
|
115
|
-
"hero_secondary": { "label": "Start free trial", "href": "https://app.kestrelfreight.com/signup" },
|
|
116
|
-
"hero_image": {
|
|
117
|
-
"url": "/img/hero-harbor-2560.jpg",
|
|
118
|
-
"altText": "Container cranes over a quiet harbor at dusk",
|
|
119
|
-
"focal": { "x": 0.5, "y": 0.35 }
|
|
120
|
-
},
|
|
121
|
-
"hero_rating": 4.8,
|
|
122
|
-
"hero_rating_label": "on G2 from 310 reviews"
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
Bound template (classes unchanged, shortened here):
|
|
128
|
-
|
|
129
|
-
```html
|
|
130
|
-
<section class="hero ..." id="top">
|
|
131
|
-
<picture class="absolute inset-0 -z-10">
|
|
132
|
-
<img src="/img/hero-harbor-1280.jpg" alt="Container cranes over a quiet harbor at dusk"
|
|
133
|
-
data-bcms-field="hero_image" data-bcms-kind="image">
|
|
134
|
-
</picture>
|
|
135
|
-
<div class="absolute inset-0 -z-10 bg-slate-950/50" aria-hidden="true"></div>
|
|
136
|
-
<p data-bcms-field="hero_eyebrow" data-bcms-kind="text">Freight visibility for mid-size shippers</p>
|
|
137
|
-
<h1 data-bcms-field="hero_title" data-bcms-kind="text">Know where every container is before your customer asks.</h1>
|
|
138
|
-
<p data-bcms-field="hero_lede" data-bcms-kind="text">Live ETAs from 140 carriers, ...</p>
|
|
139
|
-
<a class="btn btn-primary" href="/demo" data-bcms-button="hero_primary"
|
|
140
|
-
data-bcms-props="hero_primary.href|url|href">
|
|
141
|
-
<span data-bcms-field="hero_primary.label" data-bcms-kind="text">Book a demo</span></a>
|
|
142
|
-
<a class="btn btn-ghost" href="https://app.kestrelfreight.com/signup" data-bcms-button="hero_secondary"
|
|
143
|
-
data-bcms-props="hero_secondary.href|url|href">
|
|
144
|
-
<span data-bcms-field="hero_secondary.label" data-bcms-kind="text">Start free trial</span></a>
|
|
145
|
-
<p><span class="stars" role="img" aria-label="Rated 4.8 out of 5">★★★★★</span>
|
|
146
|
-
<span data-bcms-field="hero_rating">4.8</span>
|
|
147
|
-
<span data-bcms-field="hero_rating_label" data-bcms-kind="text">on G2 from 310 reviews</span></p>
|
|
148
|
-
</section>
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
- The image binding sits on the `<img>`, not the `<picture>`. The alt text travels in the image
|
|
152
|
-
value (`altText`): a publish writes it to the bound element's `alt`. When `get_conversion_brief`
|
|
153
|
-
lists a separate alt path, declare that path in `data-bcms-props` with kind `text`.
|
|
154
|
-
- Neither button value names a `variant`: the anchors keep the site's own `btn-primary` and
|
|
155
|
-
`btn-ghost`, and the release reads those two looks into the project's Button family.
|
|
156
|
-
- An `href` is kind `url`, never `text`. The label gets its own `<span>` so a save does not erase
|
|
157
|
-
the arrow; the arrow is drawn by `BcmsButton` ([components.md](components.md)), so remove the
|
|
158
|
-
hand-written `<svg>` once the button renders through it.
|
|
159
|
-
- A number shown as text binds with no kind. `number` is a kind only inside `data-bcms-props`.
|
|
160
|
-
- The image value's `focal` frames this placement only; the asset's own focal point is untouched.
|
|
161
|
-
|
|
162
|
-
Not available today: an overlay, height or theme token on the section. The scrim and spacing stay
|
|
163
|
-
in the template's classes and change in code.
|
|
164
|
-
|
|
165
|
-
## 2. Feature grid with icons
|
|
166
|
-
|
|
167
|
-
Source: a finance SaaS product page.
|
|
168
|
-
|
|
169
|
-
```html
|
|
170
|
-
<section class="features py-24" aria-labelledby="features-title">
|
|
171
|
-
<span class="kicker">Why teams switch</span>
|
|
172
|
-
<h2 id="features-title">Everything your finance team asked for, in one close.</h2>
|
|
173
|
-
<p class="lede">Ledgerly connects to your bank, your payroll and your ERP, then does the matching for you.</p>
|
|
174
|
-
<ul class="grid gap-8 md:grid-cols-3" role="list">
|
|
175
|
-
<li class="feature-card">
|
|
176
|
-
<span class="icon-tile"><svg class="lucide lucide-zap" aria-hidden="true"><use href="#zap"/></svg></span>
|
|
177
|
-
<h3>Close in two days</h3>
|
|
178
|
-
<p>Auto-matching clears 92% of transactions before anyone opens a spreadsheet.</p>
|
|
179
|
-
<a class="text-link" href="/product/reconciliation">How matching works <span aria-hidden="true">→</span></a>
|
|
180
|
-
</li>
|
|
181
|
-
<li class="feature-card">
|
|
182
|
-
<span class="icon-tile"><svg class="lucide lucide-shield-check" aria-hidden="true"><use href="#shield-check"/></svg></span>
|
|
183
|
-
<h3>Audit-ready by default</h3>
|
|
184
|
-
<p>Every change is logged with who, when and why, so year-end takes hours.</p>
|
|
185
|
-
</li>
|
|
186
|
-
<!-- third card: plug, "40 native integrations", link to /integrations -->
|
|
187
|
-
</ul>
|
|
188
|
-
<a class="btn btn-outline mt-12" href="/product">Explore the product</a>
|
|
189
|
-
</section>
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
Reasoning:
|
|
193
|
-
|
|
194
|
-
1. A heading group, then same-shaped `<li>` cards: a repeater. The count and order are the
|
|
195
|
-
editor's; the three-column grid is the template's.
|
|
196
|
-
2. Per card: the `<h3>` and the `<p>` are plain strings. The trailing link is optional, so the
|
|
197
|
-
second card leaves it empty. Never invent a link to fill the row.
|
|
198
|
-
3. The icon is a sprite reference (`<use href="#zap"/>`), so the content is its name. Store the
|
|
199
|
-
name the site's own sprite uses (`zap`), as a plain string. An inline `<svg>` with its own
|
|
200
|
-
paths is different: that is an `image` field with `config: { "media": "icon" }`.
|
|
201
|
-
4. The band's last button is a `button` field of its own, outside the repeater.
|
|
202
|
-
|
|
203
|
-
Schema:
|
|
204
|
-
|
|
205
|
-
```json
|
|
206
|
-
[
|
|
207
|
-
{ "key": "features_eyebrow", "label": "Eyebrow", "type": "text", "richText": false },
|
|
208
|
-
{ "key": "features_title", "label": "Heading", "type": "text", "richText": false },
|
|
209
|
-
{ "key": "features_intro", "label": "Intro", "type": "text", "richText": false },
|
|
210
|
-
{ "key": "features_items", "label": "Features", "type": "repeater",
|
|
211
|
-
"ui": { "preview": { "title": "features_item_title" }, "itemLabel": "Feature" },
|
|
212
|
-
"fields": [
|
|
213
|
-
{ "key": "features_item_icon", "label": "Icon", "type": "text", "richText": false,
|
|
214
|
-
"helpText": "Icon name from the site's sprite, lowercase with hyphens: zap, shield-check, plug." },
|
|
215
|
-
{ "key": "features_item_title", "label": "Title", "type": "text", "richText": false },
|
|
216
|
-
{ "key": "features_item_body", "label": "Description", "type": "text", "richText": false },
|
|
217
|
-
{ "key": "features_item_link", "label": "Link", "type": "button" }
|
|
218
|
-
] },
|
|
219
|
-
{ "key": "features_cta", "label": "Button", "type": "button" }
|
|
220
|
-
]
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
Value (`set_page_content` `data`):
|
|
224
|
-
|
|
225
|
-
```json
|
|
226
|
-
{
|
|
227
|
-
"features_eyebrow": "Why teams switch",
|
|
228
|
-
"features_title": "Everything your finance team asked for, in one close.",
|
|
229
|
-
"features_intro": "Ledgerly connects to your bank, your payroll and your ERP, then does the matching for you.",
|
|
230
|
-
"features_items": { "repeatable": [
|
|
231
|
-
{ "features_item_icon": "zap", "features_item_title": "Close in two days",
|
|
232
|
-
"features_item_body": "Auto-matching clears 92% of transactions before anyone opens a spreadsheet.",
|
|
233
|
-
"features_item_link": { "label": "How matching works", "href": "/product/reconciliation", "variant": "link" } },
|
|
234
|
-
{ "features_item_icon": "shield-check", "features_item_title": "Audit-ready by default",
|
|
235
|
-
"features_item_body": "Every change is logged with who, when and why, so year-end takes hours." },
|
|
236
|
-
{ "features_item_icon": "plug", "features_item_title": "40 native integrations",
|
|
237
|
-
"features_item_body": "Banks, payroll and ERPs sync every hour without CSV exports.",
|
|
238
|
-
"features_item_link": { "label": "See integrations", "href": "/integrations", "variant": "link" } }
|
|
239
|
-
] },
|
|
240
|
-
"features_cta": { "label": "Explore the product", "href": "/product", "variant": "outline" }
|
|
241
|
-
}
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
Bound template (first card):
|
|
245
|
-
|
|
246
|
-
```html
|
|
247
|
-
<h2 id="features-title" data-bcms-field="features_title" data-bcms-kind="text">Everything your finance team asked for, in one close.</h2>
|
|
248
|
-
<ul class="grid gap-8 md:grid-cols-3" role="list">
|
|
249
|
-
<li class="feature-card">
|
|
250
|
-
<span class="icon-tile"><svg class="lucide lucide-zap" aria-hidden="true"><use href="#zap"/></svg></span>
|
|
251
|
-
<h3 data-bcms-field="features_items[0].features_item_title" data-bcms-kind="text">Close in two days</h3>
|
|
252
|
-
<p data-bcms-field="features_items[0].features_item_body" data-bcms-kind="text">Auto-matching clears 92% ...</p>
|
|
253
|
-
<a class="text-link" href="/product/reconciliation" data-bcms-button="features_items[0].features_item_link"
|
|
254
|
-
data-bcms-props="features_items[0].features_item_link.href|url|href">
|
|
255
|
-
<span data-bcms-field="features_items[0].features_item_link.label" data-bcms-kind="text">How matching works</span>
|
|
256
|
-
<span aria-hidden="true">→</span></a>
|
|
257
|
-
</li>
|
|
258
|
-
</ul>
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
- The `<ul>` carries no binding. Rows are added, removed and reordered in the side panel.
|
|
262
|
-
- Emit an address only for a row that exists, and only for a link the row holds.
|
|
263
|
-
- The icon `<svg>` is not bound: no field is read from text inside an SVG. The name is edited in
|
|
264
|
-
the side panel and the template turns it into the sprite reference.
|
|
265
|
-
|
|
266
|
-
To make the icon editable, add a field of type `icon`: its value is a BetterCMS icon name or an
|
|
267
|
-
uploaded icon, and a name off the set is refused. A component prop of type `icon` takes the same
|
|
268
|
-
value ([components.md](components.md) §1). An inline SVG stays an `image` with `media: "icon"`.
|
|
269
|
-
|
|
270
|
-
## 3. Pricing plans with a comparison table
|
|
271
|
-
|
|
272
|
-
Source: a social scheduling SaaS. The same band sits on the pricing page and the home page, so
|
|
273
|
-
it is a component.
|
|
274
|
-
|
|
275
|
-
```html
|
|
276
|
-
<section class="pricing" id="pricing">
|
|
277
|
-
<p class="eyebrow">Pricing</p>
|
|
278
|
-
<h2>Plans that grow with your calendar</h2>
|
|
279
|
-
<div class="billing-toggle" role="radiogroup" aria-label="Billing period">
|
|
280
|
-
<button role="radio" aria-checked="false" data-cycle="monthly">Monthly</button>
|
|
281
|
-
<button role="radio" aria-checked="true" data-cycle="yearly">Yearly <span class="pill">Save 20%</span></button>
|
|
282
|
-
</div>
|
|
283
|
-
<div class="plans grid md:grid-cols-3">
|
|
284
|
-
<article class="plan">
|
|
285
|
-
<h3>Starter</h3><p class="plan__for">For solo creators</p>
|
|
286
|
-
<p class="price"><span class="amount">$0</span></p>
|
|
287
|
-
<ul class="checks"><li>3 social accounts</li><li>30 scheduled posts a month</li></ul>
|
|
288
|
-
<a class="btn btn-outline" href="https://app.cadence.so/signup">Start free</a>
|
|
289
|
-
</article>
|
|
290
|
-
<article class="plan plan--featured">
|
|
291
|
-
<span class="ribbon">Most popular</span>
|
|
292
|
-
<h3>Growth</h3><p class="plan__for">For growing marketing teams</p>
|
|
293
|
-
<p class="price"><span class="amount" data-monthly="30" data-yearly="24">$24</span><span class="per">/mo</span></p>
|
|
294
|
-
<ul class="checks"><li>15 social accounts</li><li>Approval workflows</li><li>AI caption drafts</li></ul>
|
|
295
|
-
<a class="btn btn-primary" href="https://app.cadence.so/signup?plan=growth">Start 14-day trial</a>
|
|
296
|
-
</article>
|
|
297
|
-
<!-- Scale: "For agencies and brands", price "Let's talk", link /contact-sales -->
|
|
298
|
-
</div>
|
|
299
|
-
<table class="compare">
|
|
300
|
-
<thead><tr><th></th><th scope="col">Starter</th><th scope="col">Growth</th><th scope="col">Scale</th></tr></thead>
|
|
301
|
-
<tbody>
|
|
302
|
-
<tr class="group"><th colspan="4" scope="colgroup">Usage</th></tr>
|
|
303
|
-
<tr><th scope="row">Scheduled posts</th><td>30 a month</td><td>Unlimited</td><td>Unlimited</td></tr>
|
|
304
|
-
<tr class="group"><th colspan="4" scope="colgroup">Features</th></tr>
|
|
305
|
-
<tr><th scope="row">AI caption drafts</th><td><svg class="i-x" aria-label="Not included"/></td><td>50 a month</td><td><svg class="i-check" aria-label="Included"/></td></tr>
|
|
306
|
-
</tbody>
|
|
307
|
-
</table>
|
|
308
|
-
<p class="fineprint">Prices in USD, excluding tax. Yearly plans are billed once a year.</p>
|
|
309
|
-
</section>
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
Reasoning:
|
|
313
|
-
|
|
314
|
-
1. Plan cards and the comparison table share plan names and always show together: one section.
|
|
315
|
-
2. Plans are same-shaped rows: one `table` prop. `data-monthly` and `data-yearly` give two
|
|
316
|
-
numbers; store numbers, never `$24/mo`. A price in words ("Let's talk") is its own text key,
|
|
317
|
-
shown when the numbers are empty.
|
|
318
|
-
3. Each plan's check list is a list inside a row: a nested `table` whose rows hold one string.
|
|
319
|
-
That is the deepest a prop goes: the inner row takes scalars only.
|
|
320
|
-
4. The comparison is a second `table` prop: one row object per feature, one text key per plan,
|
|
321
|
-
plus the group it sits under. The template prints a group row when `group` changes. Read
|
|
322
|
-
"Included" and "Not included" from `aria-label`, store "Yes" and "No", and let the template
|
|
323
|
-
draw the icon.
|
|
324
|
-
5. A CTA inside a component is one `button` block that two props target (`text`, `url`).
|
|
325
|
-
|
|
326
|
-
Schema (`create_component`; lands as a draft, then `publish_component`):
|
|
327
|
-
|
|
328
|
-
```json
|
|
329
|
-
{
|
|
330
|
-
"name": "Pricing / Three plans",
|
|
331
|
-
"slug": "pricing-three-plans",
|
|
332
|
-
"category": "conversion",
|
|
333
|
-
"sectionType": "Pricing",
|
|
334
|
-
"description": "Plan cards with monthly and yearly prices, then a feature comparison.",
|
|
335
|
-
"blockJson": [
|
|
336
|
-
{ "type": "section", "id": "pr-root", "props": { "children": [
|
|
337
|
-
{ "type": "text", "id": "pr-eyebrow", "props": { "html": "" } },
|
|
338
|
-
{ "type": "heading", "id": "pr-heading", "props": { "text": "", "level": 2 } },
|
|
339
|
-
{ "type": "text", "id": "pr-saving", "props": { "html": "" } },
|
|
340
|
-
{ "type": "section", "id": "pr-plans", "label": "Plans", "props": { "children": [
|
|
341
|
-
{ "type": "heading", "id": "pr-plan-name", "props": { "text": "", "level": 3 } },
|
|
342
|
-
{ "type": "button", "id": "pr-plan-cta", "props": { "text": "", "href": "" } }
|
|
343
|
-
] } },
|
|
344
|
-
{ "type": "section", "id": "pr-compare", "label": "Comparison", "props": { "children": [
|
|
345
|
-
{ "type": "heading", "id": "pr-compare-feature", "props": { "text": "", "level": 4 } }
|
|
346
|
-
] } },
|
|
347
|
-
{ "type": "text", "id": "pr-fineprint", "props": { "html": "" } }
|
|
348
|
-
] } }
|
|
349
|
-
],
|
|
350
|
-
"props": [
|
|
351
|
-
{ "key": "eyebrow", "label": "Eyebrow", "type": "text", "target": { "blockId": "pr-eyebrow", "path": "props.html" } },
|
|
352
|
-
{ "key": "heading", "label": "Heading", "type": "text", "target": { "blockId": "pr-heading", "path": "props.text" } },
|
|
353
|
-
{ "key": "saving", "label": "Yearly saving badge", "type": "text", "target": { "blockId": "pr-saving", "path": "props.html" } },
|
|
354
|
-
{ "key": "plans", "label": "Plans", "type": "table", "target": { "blockId": "pr-plans", "path": "props.rows" },
|
|
355
|
-
"ui": { "preview": { "title": "name" }, "itemLabel": "Plan" },
|
|
356
|
-
"config": { "fields": [
|
|
357
|
-
{ "key": "name", "label": "Name", "type": "text" },
|
|
358
|
-
{ "key": "audience", "label": "Who it is for", "type": "text" },
|
|
359
|
-
{ "key": "badge", "label": "Badge", "type": "text" },
|
|
360
|
-
{ "key": "monthly", "label": "Monthly price", "type": "number", "helpText": "Per month, billed monthly. Digits only." },
|
|
361
|
-
{ "key": "yearly", "label": "Yearly price", "type": "number", "helpText": "Per month, when billed yearly. Digits only." },
|
|
362
|
-
{ "key": "priceLabel", "label": "Price in words", "type": "text", "helpText": "Shown when both prices are empty." },
|
|
363
|
-
{ "key": "features", "label": "Included", "type": "table", "fields": [
|
|
364
|
-
{ "key": "line", "label": "Feature", "type": "text" }
|
|
365
|
-
] },
|
|
366
|
-
{ "key": "ctaLabel", "label": "Button label", "type": "text" },
|
|
367
|
-
{ "key": "ctaUrl", "label": "Button link", "type": "url" }
|
|
368
|
-
] } },
|
|
369
|
-
{ "key": "comparison", "label": "Comparison rows", "type": "table", "target": { "blockId": "pr-compare", "path": "props.rows" },
|
|
370
|
-
"ui": { "preview": { "title": "feature", "subtitle": "group" }, "itemLabel": "Row" },
|
|
371
|
-
"config": { "fields": [
|
|
372
|
-
{ "key": "group", "label": "Group", "type": "text" },
|
|
373
|
-
{ "key": "feature", "label": "Feature", "type": "text" },
|
|
374
|
-
{ "key": "starter", "label": "Starter", "type": "text" },
|
|
375
|
-
{ "key": "growth", "label": "Growth", "type": "text" },
|
|
376
|
-
{ "key": "scale", "label": "Scale", "type": "text" }
|
|
377
|
-
] } },
|
|
378
|
-
{ "key": "fineprint", "label": "Fine print", "type": "text", "target": { "blockId": "pr-fineprint", "path": "props.html" } }
|
|
379
|
-
]
|
|
380
|
-
}
|
|
381
|
-
```
|
|
382
|
-
|
|
383
|
-
A `table` prop targets `props.rows` of a `section` block whose children mirror one row. On a
|
|
384
|
-
code-built site the repo file draws the band: record it with `set_component_source` as soon as it
|
|
385
|
-
exists.
|
|
386
|
-
|
|
387
|
-
Value (`compose_pages`; one instance, keyed by prop key):
|
|
388
|
-
|
|
389
|
-
```json
|
|
390
|
-
{
|
|
391
|
-
"pages": [ { "slug": "pricing", "blockJson": [
|
|
392
|
-
{ "type": "component", "id": "pricing-01", "props": {
|
|
393
|
-
"componentId": "<id returned by create_component>",
|
|
394
|
-
"overrides": {
|
|
395
|
-
"eyebrow": "Pricing",
|
|
396
|
-
"heading": "Plans that grow with your calendar",
|
|
397
|
-
"saving": "Save 20%",
|
|
398
|
-
"plans": [
|
|
399
|
-
{ "name": "Starter", "audience": "For solo creators", "monthly": 0, "yearly": 0,
|
|
400
|
-
"features": [ { "line": "3 social accounts" }, { "line": "30 scheduled posts a month" } ],
|
|
401
|
-
"ctaLabel": "Start free", "ctaUrl": "https://app.cadence.so/signup" },
|
|
402
|
-
{ "name": "Growth", "audience": "For growing marketing teams", "badge": "Most popular",
|
|
403
|
-
"monthly": 30, "yearly": 24,
|
|
404
|
-
"features": [ { "line": "15 social accounts" }, { "line": "Approval workflows" }, { "line": "AI caption drafts" } ],
|
|
405
|
-
"ctaLabel": "Start 14-day trial", "ctaUrl": "https://app.cadence.so/signup?plan=growth" },
|
|
406
|
-
{ "name": "Scale", "audience": "For agencies and brands", "priceLabel": "Let's talk",
|
|
407
|
-
"features": [ { "line": "Unlimited accounts" }, { "line": "SSO and audit log" } ],
|
|
408
|
-
"ctaLabel": "Talk to sales", "ctaUrl": "/contact-sales" }
|
|
409
|
-
],
|
|
410
|
-
"comparison": [
|
|
411
|
-
{ "group": "Usage", "feature": "Scheduled posts", "starter": "30 a month", "growth": "Unlimited", "scale": "Unlimited" },
|
|
412
|
-
{ "group": "Features", "feature": "AI caption drafts", "starter": "No", "growth": "50 a month", "scale": "Yes" }
|
|
413
|
-
],
|
|
414
|
-
"fineprint": "Prices in USD, excluding tax. Yearly plans are billed once a year."
|
|
415
|
-
} } }
|
|
416
|
-
] } ]
|
|
417
|
-
}
|
|
418
|
-
```
|
|
419
|
-
|
|
420
|
-
Bound template (the Growth card and one comparison row; `pricing-01` is the instance id):
|
|
421
|
-
|
|
422
|
-
```html
|
|
423
|
-
<section class="pricing" id="pricing" data-bcms-block="pricing-01">
|
|
424
|
-
<h2 data-bcms-field="pricing-01__overrides.heading" data-bcms-kind="text">Plans that grow with your calendar</h2>
|
|
425
|
-
<article class="plan plan--featured">
|
|
426
|
-
<span class="ribbon" data-bcms-field="pricing-01__overrides.plans[1].badge" data-bcms-kind="text">Most popular</span>
|
|
427
|
-
<h3 data-bcms-field="pricing-01__overrides.plans[1].name" data-bcms-kind="text">Growth</h3>
|
|
428
|
-
<p class="price"><span class="amount">$<span data-bcms-field="pricing-01__overrides.plans[1].yearly">24</span></span><span class="per">/mo</span></p>
|
|
429
|
-
<ul class="checks">
|
|
430
|
-
<li data-bcms-field="pricing-01__overrides.plans[1].features[0].line" data-bcms-kind="text">15 social accounts</li>
|
|
431
|
-
</ul>
|
|
432
|
-
<a class="btn btn-primary" href="https://app.cadence.so/signup?plan=growth"
|
|
433
|
-
data-bcms-props="pricing-01__overrides.plans[1].ctaUrl|url|href">
|
|
434
|
-
<span data-bcms-field="pricing-01__overrides.plans[1].ctaLabel" data-bcms-kind="text">Start 14-day trial</span></a>
|
|
435
|
-
</article>
|
|
436
|
-
<table class="compare"><tbody>
|
|
437
|
-
<tr><th scope="row" data-bcms-field="pricing-01__overrides.comparison[0].feature" data-bcms-kind="text">Scheduled posts</th>
|
|
438
|
-
<td data-bcms-field="pricing-01__overrides.comparison[0].starter" data-bcms-kind="text">30 a month</td>
|
|
439
|
-
<td data-bcms-field="pricing-01__overrides.comparison[0].growth" data-bcms-kind="text">Unlimited</td>
|
|
440
|
-
<td data-bcms-field="pricing-01__overrides.comparison[0].scale" data-bcms-kind="text">Unlimited</td></tr>
|
|
441
|
-
</tbody></table>
|
|
442
|
-
</section>
|
|
443
|
-
```
|
|
444
|
-
|
|
445
|
-
- Use the package `bind` and `bindProps` helpers inside an instance; they produce these addresses.
|
|
446
|
-
A definition block id (`pr-heading`) is shared by every instance and binds to nothing.
|
|
447
|
-
- Bind the digits, not `$24/mo`. The symbol and the period come from the template. Bind the price
|
|
448
|
-
on screen; when the switch shows monthly, the address is `.monthly`.
|
|
449
|
-
- A cell the template draws as an icon ("Yes", "No") is not bound: it is edited in the side panel.
|
|
450
|
-
- A comparison written as prose instead belongs in a `richtext` prop as a Portable Text `table`
|
|
451
|
-
([portable-text.md](portable-text.md)); then the wrapper of that one value is the bound element.
|
|
452
|
-
|
|
453
|
-
A typed table is available on both sides. On a component each `table` column is one sub-field
|
|
454
|
-
with its own type (`number`, `boolean`, `url`, ...), as `monthly` and `ctaUrl` are above. On a
|
|
455
|
-
model it is a `repeater` (stored as an `array` with `config.zones.repeatable.fields`): one field
|
|
456
|
-
per column, of any field type, `minItems` / `maxItems` on the repeatable zone, and
|
|
457
|
-
`ui.layout: "table"` to edit the rows as a grid. To key rows to plan ids, give the model a `text`
|
|
458
|
-
column with `unique: true`. A value only a developer may change is a model field with
|
|
459
|
-
`readOnly: "developer"` and a `lockReason`.
|
|
460
|
-
|
|
461
|
-
Not available today: a per-cell type (a column has one type for every row), and a caption,
|
|
462
|
-
footnotes or row groups as parts of the table (model them as fields beside it, as `group` and
|
|
463
|
-
`fineprint` are here). Offer markup: no JSON-LD is built from prices. Paste it as `schema` on
|
|
464
|
-
`update_page` when the page needs it. A fourth plan in the comparison is a new sub-field, a schema
|
|
465
|
-
change.
|
|
466
|
-
|
|
467
|
-
## 4. Testimonials kept as a collection
|
|
468
|
-
|
|
469
|
-
Source: three quote cards on a home page. The same quotes also show on the pricing and product
|
|
470
|
-
pages.
|
|
471
|
-
|
|
472
|
-
```html
|
|
473
|
-
<section class="testimonials bg-stone-50 py-24">
|
|
474
|
-
<p class="eyebrow">Customers</p>
|
|
475
|
-
<h2 class="text-3xl">Teams that stopped chasing spreadsheets</h2>
|
|
476
|
-
<div class="cards grid gap-6 md:grid-cols-3">
|
|
477
|
-
<figure class="t-card">
|
|
478
|
-
<div class="stars" role="img" aria-label="5 out of 5 stars">★★★★★</div>
|
|
479
|
-
<blockquote><p>“We closed March in a day and a half. Our auditors asked what changed.”</p></blockquote>
|
|
480
|
-
<figcaption>
|
|
481
|
-
<img class="avatar" src="/img/people/dana-ruiz.jpg" alt="" width="48" height="48">
|
|
482
|
-
<span class="name">Dana Ruiz</span><span class="role">Controller, Brightwell Foods</span>
|
|
483
|
-
</figcaption>
|
|
484
|
-
</figure>
|
|
485
|
-
<!-- Omar Haddad, VP Finance, Northgate Logistics, 5 stars; Mei Tanaka, Finance Lead, Alder Studio, 4 stars, no photo -->
|
|
486
|
-
</div>
|
|
487
|
-
<a class="btn btn-outline" href="/customers">Read all customer stories</a>
|
|
488
|
-
</section>
|
|
489
|
-
```
|
|
490
|
-
|
|
491
|
-
Reasoning:
|
|
492
|
-
|
|
493
|
-
1. Search the site before modelling rows inside the section. The quotes repeat on three pages, so
|
|
494
|
-
each quote is one entry of a content model and is written once.
|
|
495
|
-
2. Per entry: the quote without the curly quotes (the template adds them), name, role and company
|
|
496
|
-
split at the comma, photo, and the rating read from `aria-label`. Add a consent switch:
|
|
497
|
-
publishing a person's words needs their permission.
|
|
498
|
-
3. The avatar's `alt=""` is correct: the name sits beside it. Keep the alt text empty.
|
|
499
|
-
4. The section keeps its own eyebrow, heading and button. How it gets its three quotes depends on
|
|
500
|
-
where it lives (table below).
|
|
501
|
-
|
|
502
|
-
Schema (`create_content_model`), then the home page's own fields:
|
|
503
|
-
|
|
504
|
-
```json
|
|
505
|
-
{
|
|
506
|
-
"name": "Testimonial",
|
|
507
|
-
"slug": "testimonial",
|
|
508
|
-
"urlPattern": "/customers/:slug",
|
|
509
|
-
"fields": [
|
|
510
|
-
{ "key": "quote", "label": "Quote", "type": "text", "richText": false, "required": true,
|
|
511
|
-
"helpText": "Without quotation marks; the page adds them." },
|
|
512
|
-
{ "key": "name", "label": "Name", "type": "text", "richText": false, "required": true },
|
|
513
|
-
{ "key": "role", "label": "Role", "type": "text", "richText": false },
|
|
514
|
-
{ "key": "company", "label": "Company", "type": "text", "richText": false },
|
|
515
|
-
{ "key": "photo", "label": "Photo", "type": "image" },
|
|
516
|
-
{ "key": "rating", "label": "Rating", "type": "number", "config": { "min": 1, "max": 5, "step": 1 } },
|
|
517
|
-
{ "key": "consent", "label": "Permission to publish", "type": "boolean" }
|
|
518
|
-
]
|
|
519
|
-
}
|
|
520
|
-
```
|
|
521
|
-
|
|
522
|
-
```json
|
|
523
|
-
[
|
|
524
|
-
{ "key": "testimonials_eyebrow", "label": "Eyebrow", "type": "text", "richText": false },
|
|
525
|
-
{ "key": "testimonials_title", "label": "Heading", "type": "text", "richText": false },
|
|
526
|
-
{ "key": "testimonials_picks", "label": "Testimonials", "type": "multi-reference",
|
|
527
|
-
"config": { "contentModelId": "<testimonial model id>", "min": 3, "max": 9 },
|
|
528
|
-
"helpText": "Pick 3, 6 or 9 so the grid has no gaps." },
|
|
529
|
-
{ "key": "testimonials_cta", "label": "Button", "type": "button" }
|
|
530
|
-
]
|
|
531
|
-
```
|
|
532
|
-
|
|
533
|
-
Value: entries first (`create_content_entries`, drafts, ids come back), then the page:
|
|
534
|
-
|
|
535
|
-
```json
|
|
536
|
-
{
|
|
537
|
-
"contentModelId": "<testimonial model id>",
|
|
538
|
-
"entries": [
|
|
539
|
-
{ "slug": "dana-ruiz", "data": { "quote": "We closed March in a day and a half. Our auditors asked what changed.",
|
|
540
|
-
"name": "Dana Ruiz", "role": "Controller", "company": "Brightwell Foods",
|
|
541
|
-
"photo": { "url": "/img/people/dana-ruiz.jpg", "altText": "" }, "rating": 5, "consent": true } },
|
|
542
|
-
{ "slug": "omar-haddad", "data": { "quote": "Matching used to eat two people for a week. Now it is a coffee.",
|
|
543
|
-
"name": "Omar Haddad", "role": "VP Finance", "company": "Northgate Logistics",
|
|
544
|
-
"photo": { "url": "/img/people/omar-haddad.jpg", "altText": "" }, "rating": 5, "consent": true } },
|
|
545
|
-
{ "slug": "mei-tanaka", "data": { "quote": "The audit log alone paid for it.",
|
|
546
|
-
"name": "Mei Tanaka", "role": "Finance Lead", "company": "Alder Studio", "rating": 4, "consent": true } }
|
|
547
|
-
]
|
|
548
|
-
}
|
|
549
|
-
```
|
|
550
|
-
|
|
551
|
-
```json
|
|
552
|
-
{
|
|
553
|
-
"pageId": "<home page id>",
|
|
554
|
-
"data": {
|
|
555
|
-
"testimonials_eyebrow": "Customers",
|
|
556
|
-
"testimonials_title": "Teams that stopped chasing spreadsheets",
|
|
557
|
-
"testimonials_picks": ["<dana entry id>", "<omar entry id>", "<mei entry id>"],
|
|
558
|
-
"testimonials_cta": { "label": "Read all customer stories", "href": "/customers", "variant": "outline" }
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
```
|
|
562
|
-
|
|
563
|
-
A reference value is the entry id as a string; a multi-reference is a list of them. An id that
|
|
564
|
-
does not exist is refused with a 400 ("ref_missing"), so create the entries before the page value.
|
|
565
|
-
|
|
566
|
-
How a band shows the entries, by where it lives:
|
|
567
|
-
|
|
568
|
-
| Where | What production supports |
|
|
569
|
-
|---|---|
|
|
570
|
-
| A code-built page (this example) | A `multi-reference` page field holds the picked ids; the template reads the entries and draws the cards |
|
|
571
|
-
| The collection's own dynamic page (`/customers`) | A `collection` block: `cardComponentId`, `limit` (1 to 200), `order` (`newest` or `oldest`), `emptyText` |
|
|
572
|
-
| A component placed on several pages | A `table` prop filled per instance. The quotes are then copies: a change to one entry is repeated by hand on each instance |
|
|
573
|
-
|
|
574
|
-
Bound template (the section's own values; one card shown):
|
|
575
|
-
|
|
576
|
-
```html
|
|
577
|
-
<p class="eyebrow" data-bcms-field="testimonials_eyebrow" data-bcms-kind="text">Customers</p>
|
|
578
|
-
<h2 class="text-3xl" data-bcms-field="testimonials_title" data-bcms-kind="text">Teams that stopped chasing spreadsheets</h2>
|
|
579
|
-
<div class="cards grid gap-6 md:grid-cols-3">
|
|
580
|
-
<figure class="t-card">
|
|
581
|
-
<div class="stars" role="img" aria-label="5 out of 5 stars">★★★★★</div>
|
|
582
|
-
<blockquote><p>“We closed March in a day and a half. Our auditors asked what changed.”</p></blockquote>
|
|
583
|
-
<figcaption><img class="avatar" src="/img/people/dana-ruiz.jpg" alt=""><span class="name">Dana Ruiz</span></figcaption>
|
|
584
|
-
</figure>
|
|
585
|
-
</div>
|
|
586
|
-
```
|
|
587
|
-
|
|
588
|
-
- The cards carry no page binding. Their words belong to the entries and are edited there, once,
|
|
589
|
-
for every page that shows them. The picked list is changed in the side panel.
|
|
590
|
-
- Draw the stars from `rating`; never store the glyphs.
|
|
591
|
-
|
|
592
|
-
One chosen entry on a hosted page is a `collection` block, not a prop: `source: { modelId }`,
|
|
593
|
-
`pinnedIds: ["<entry id>"]`, `limit: 1` and `cardComponentId` naming the card that draws it. Pinned
|
|
594
|
-
entries come first, so the limit keeps exactly that one.
|
|
595
|
-
|
|
596
|
-
Not available today: a component prop that references entries (there is no reference prop type; it
|
|
597
|
-
would save and render nothing). A listing chosen by tag or filter on a route the repo's code
|
|
598
|
-
renders: the `collection` block with `source`, `filters` and `sort` is drawn by the hosted
|
|
599
|
-
renderer only, so there a "latest for this product" list is a query in the site's code. A
|
|
600
|
-
decorative flag on an image VALUE: an empty `altText` is the signal for one placement;
|
|
601
|
-
`update_media` `decorative: true` marks the asset itself.
|
|
602
|
-
|
|
603
|
-
## 5. FAQ accordion with FAQPage markup
|
|
604
|
-
|
|
605
|
-
Source: a help band built from `<details>`, with hand-written FAQPage JSON-LD beside it.
|
|
606
|
-
|
|
607
|
-
```html
|
|
608
|
-
<section class="faq" id="faq">
|
|
609
|
-
<h2>Questions, answered</h2>
|
|
610
|
-
<p>Straight answers about billing, data and switching tools.</p>
|
|
611
|
-
<details class="faq-item" open>
|
|
612
|
-
<summary><h3>Can I cancel at any time?</h3><svg class="chevron" aria-hidden="true"/></summary>
|
|
613
|
-
<div class="faq-item__body"><p>Yes. Cancel from <strong>Settings > Billing</strong> and you keep access until the end of the period.</p></div>
|
|
614
|
-
</details>
|
|
615
|
-
<details class="faq-item">
|
|
616
|
-
<summary><h3>Where is my data stored?</h3><svg class="chevron" aria-hidden="true"/></summary>
|
|
617
|
-
<div class="faq-item__body"><p>In the EU or the US, your choice at sign-up. Read the <a href="/security">security overview</a>.</p></div>
|
|
618
|
-
</details>
|
|
619
|
-
<details class="faq-item">
|
|
620
|
-
<summary><h3>How do I move from spreadsheets?</h3><svg class="chevron" aria-hidden="true"/></summary>
|
|
621
|
-
<div class="faq-item__body"><p>Most teams switch in an afternoon:</p>
|
|
622
|
-
<ol><li>Connect your bank.</li><li>Import last month's CSV.</li><li>Invite your team.</li></ol></div>
|
|
623
|
-
</details>
|
|
624
|
-
<p class="faq__more">Still stuck? <a href="/support">Talk to support</a></p>
|
|
625
|
-
</section>
|
|
626
|
-
<script type="application/ld+json">{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[...]}</script>
|
|
627
|
-
```
|
|
628
|
-
|
|
629
|
-
Reasoning:
|
|
630
|
-
|
|
631
|
-
1. Repeated `<details>` with a question and an answer, specific to this page: a repeater in the
|
|
632
|
-
page's fields. Move to a collection only when the same questions repeat on several pages.
|
|
633
|
-
2. The question is a plain string; the `<h3>` and the chevron belong to the template. The answer
|
|
634
|
-
carries bold text, a link and a list: rich text, narrowed to what answers use.
|
|
635
|
-
3. `open` on the first item is behaviour. It stays in the template.
|
|
636
|
-
4. "Talk to support" is a `button` field. "Still stuck?" is fixed template copy.
|
|
637
|
-
5. Delete the hand-written `<script type="application/ld+json">` and set the page's schema type
|
|
638
|
-
instead. Name the row keys so they contain `question` and `answer`: the FAQPage builder finds
|
|
639
|
-
the first list on the page whose rows have both.
|
|
640
|
-
|
|
641
|
-
Schema:
|
|
642
|
-
|
|
643
|
-
```json
|
|
644
|
-
[
|
|
645
|
-
{ "key": "faq_title", "label": "Heading", "type": "text", "richText": false },
|
|
646
|
-
{ "key": "faq_intro", "label": "Intro", "type": "text", "richText": false },
|
|
647
|
-
{ "key": "faq_items", "label": "Questions", "type": "repeater",
|
|
648
|
-
"ui": { "preview": { "title": "faq_question" }, "itemLabel": "Question" },
|
|
649
|
-
"fields": [
|
|
650
|
-
{ "key": "faq_question", "label": "Question", "type": "text", "richText": false, "required": true },
|
|
651
|
-
{ "key": "faq_answer", "label": "Answer", "type": "richtext", "required": true,
|
|
652
|
-
"config": { "accept": ["p", "b", "i", "link", "ul", "ol"] } }
|
|
653
|
-
] },
|
|
654
|
-
{ "key": "faq_support", "label": "Support link", "type": "button" }
|
|
655
|
-
]
|
|
656
|
-
```
|
|
657
|
-
|
|
658
|
-
Value (`set_page_content` `data`; each answer is a Portable Text array, shown below):
|
|
659
|
-
|
|
660
|
-
```jsonc
|
|
661
|
-
{
|
|
662
|
-
"faq_title": "Questions, answered",
|
|
663
|
-
"faq_intro": "Straight answers about billing, data and switching tools.",
|
|
664
|
-
"faq_items": { "repeatable": [
|
|
665
|
-
{ "faq_question": "Can I cancel at any time?", "faq_answer": [ /* one paragraph, "Settings > Billing" marked strong */ ] },
|
|
666
|
-
{ "faq_question": "Where is my data stored?", "faq_answer": [ /* array A */ ] },
|
|
667
|
-
{ "faq_question": "How do I move from spreadsheets?", "faq_answer": [ /* array B */ ] }
|
|
668
|
-
] },
|
|
669
|
-
"faq_support": { "label": "Talk to support", "href": "/support", "variant": "link" }
|
|
670
|
-
}
|
|
671
|
-
```
|
|
672
|
-
|
|
673
|
-
Array A, a paragraph with a link:
|
|
674
|
-
|
|
675
|
-
<!-- pt-example -->
|
|
676
|
-
```json
|
|
677
|
-
[
|
|
678
|
-
{ "_type": "block", "_key": "a2p1", "style": "normal",
|
|
679
|
-
"markDefs": [{ "_type": "link", "_key": "a2l1", "href": "/security" }],
|
|
680
|
-
"children": [
|
|
681
|
-
{ "_type": "span", "_key": "a2s1", "text": "In the EU or the US, your choice at sign-up. Read the ", "marks": [] },
|
|
682
|
-
{ "_type": "span", "_key": "a2s2", "text": "security overview", "marks": ["a2l1"] },
|
|
683
|
-
{ "_type": "span", "_key": "a2s3", "text": ".", "marks": [] }
|
|
684
|
-
] }
|
|
685
|
-
]
|
|
686
|
-
```
|
|
687
|
-
|
|
688
|
-
Array B, a paragraph then a flat numbered list:
|
|
689
|
-
|
|
690
|
-
<!-- pt-example -->
|
|
691
|
-
```json
|
|
692
|
-
[
|
|
693
|
-
{ "_type": "block", "_key": "a3p1", "style": "normal", "markDefs": [],
|
|
694
|
-
"children": [{ "_type": "span", "_key": "a3s1", "text": "Most teams switch in an afternoon:", "marks": [] }] },
|
|
695
|
-
{ "_type": "block", "_key": "a3i1", "style": "normal", "listItem": "number", "level": 1, "markDefs": [],
|
|
696
|
-
"children": [{ "_type": "span", "_key": "a3s2", "text": "Connect your bank.", "marks": [] }] },
|
|
697
|
-
{ "_type": "block", "_key": "a3i2", "style": "normal", "listItem": "number", "level": 1, "markDefs": [],
|
|
698
|
-
"children": [{ "_type": "span", "_key": "a3s3", "text": "Import last month's CSV.", "marks": [] }] },
|
|
699
|
-
{ "_type": "block", "_key": "a3i3", "style": "normal", "listItem": "number", "level": 1, "markDefs": [],
|
|
700
|
-
"children": [{ "_type": "span", "_key": "a3s4", "text": "Invite your team.", "marks": [] }] }
|
|
701
|
-
]
|
|
702
|
-
```
|
|
703
|
-
|
|
704
|
-
Structured data (`update_page`):
|
|
705
|
-
|
|
706
|
-
```json
|
|
707
|
-
{ "pageId": "<page id>", "schemaType": "FAQPage" }
|
|
708
|
-
```
|
|
709
|
-
|
|
710
|
-
Bound template (second item):
|
|
711
|
-
|
|
712
|
-
```html
|
|
713
|
-
<details class="faq-item">
|
|
714
|
-
<summary><h3 data-bcms-field="faq_items[1].faq_question" data-bcms-kind="text">Where is my data stored?</h3>
|
|
715
|
-
<svg class="chevron" aria-hidden="true"/></summary>
|
|
716
|
-
<div class="faq-item__body" data-bcms-field="faq_items[1].faq_answer" data-bcms-kind="richtext">
|
|
717
|
-
<p>In the EU or the US, your choice at sign-up. Read the <a href="/security">security overview</a>.</p>
|
|
718
|
-
</div>
|
|
719
|
-
</details>
|
|
720
|
-
```
|
|
721
|
-
|
|
722
|
-
- The question binds on the `<h3>`, not the `<summary>`: a save on the summary would erase the
|
|
723
|
-
chevron.
|
|
724
|
-
- `.faq-item__body` renders nothing but the answer, so it is the answer's element. If it also held
|
|
725
|
-
a "Was this helpful?" row, wrap the answer in its own `<div>` and bind that.
|
|
726
|
-
- A paragraph inside an answer is addressed `faq_items[1].faq_answer#a2p1`; keep `_key` values
|
|
727
|
-
stable when you rewrite an answer, and write the whole field, never one block.
|
|
728
|
-
|
|
729
|
-
Not available today: FAQPage markup that appears on its own. Without `schemaType: "FAQPage"` the
|
|
730
|
-
page gets WebPage. An "open several at once" setting has no field type of its own; if editors
|
|
731
|
-
need it, add a `boolean` field and have the template read it.
|
|
732
|
-
|
|
733
|
-
## 9. Bento grid with per-cell sizes
|
|
734
|
-
|
|
735
|
-
Source: the cookware brand's home page. The kit's `core-bento` entry is this shape, and
|
|
736
|
-
`bento-feature-showcase` is its responsive long form ([kit-scenarios.md](kit-scenarios.md)).
|
|
737
|
-
|
|
738
|
-
```html
|
|
739
|
-
<section class="bento" id="field">
|
|
740
|
-
<p class="eyebrow">In the field</p>
|
|
741
|
-
<h2>Made to be handed down.</h2>
|
|
742
|
-
<div class="bento__grid grid grid-cols-4 gap-6">
|
|
743
|
-
<figure class="cell col-span-2 row-span-2 cell--dark">
|
|
744
|
-
<img src="/img/sierra-dawn-2400.jpg" alt="Dawn over a Sierra camp, kettle steaming" width="2400" height="2400">
|
|
745
|
-
<figcaption>Three nights in the Sierra</figcaption>
|
|
746
|
-
</figure>
|
|
747
|
-
<div class="cell cell--brand"><i class="ti ti-flame" aria-hidden="true"></i>
|
|
748
|
-
<span class="stat__value">120k+</span><span class="stat__label">Fires cooked over</span></div>
|
|
749
|
-
<div class="cell"><i class="ti ti-star" aria-hidden="true"></i>
|
|
750
|
-
<span class="stat__value">4.9</span><span class="stat__label">Average review</span></div>
|
|
751
|
-
<blockquote class="cell col-span-2"><p>Our restaurant runs four Ember ovens over live fire every night.</p>
|
|
752
|
-
<cite>Chef Amara Osei, Hearth & Ash, Portland</cite></blockquote>
|
|
753
|
-
</div>
|
|
754
|
-
</section>
|
|
755
|
-
```
|
|
756
|
-
|
|
757
|
-
Reasoning:
|
|
758
|
-
|
|
759
|
-
1. The grid's children differ in shape (an image, two stats, a quote) and in size. Different
|
|
760
|
-
shapes chosen by the editor are slot children, not rows of one list.
|
|
761
|
-
2. Each cell's size and surface belong to its place in this grid, not to the cell. Reading
|
|
762
|
-
`col-span-2 row-span-2` and `cell--dark` into the image tile's own props would carry a
|
|
763
|
-
meaningless span to every other page that reuses the tile. They go in the slot's settings.
|
|
764
|
-
3. Size is a small set of presets (1 × 1, 2 × 1, 1 × 2, 2 × 2), so it is a select, never a free
|
|
765
|
-
column number. If the source's spans do not fit four presets, it is the per-cell grid
|
|
766
|
-
(`grid-cell-layout`), not a bento.
|
|
767
|
-
4. The four-column versus three-column layout is a variant: a second component sharing the
|
|
768
|
-
`sectionType`, with the same prop keys.
|
|
769
|
-
5. The icons and the gap between cells are design: they stay in the template's classes.
|
|
770
|
-
|
|
771
|
-
Schema: one component per tile kind (`create_component`, then `publish_component`). The stat tile:
|
|
772
|
-
|
|
773
|
-
```json
|
|
774
|
-
{
|
|
775
|
-
"name": "Stat tile",
|
|
776
|
-
"slug": "stat-tile",
|
|
777
|
-
"category": "content",
|
|
778
|
-
"description": "A big number with a short label.",
|
|
779
|
-
"blockJson": [
|
|
780
|
-
{ "type": "section", "id": "stat-root", "props": { "children": [
|
|
781
|
-
{ "type": "text", "id": "stat-value", "props": { "html": "" } },
|
|
782
|
-
{ "type": "text", "id": "stat-label", "props": { "html": "" } }
|
|
783
|
-
] } }
|
|
784
|
-
],
|
|
785
|
-
"props": [
|
|
786
|
-
{ "key": "value", "label": "Number", "type": "text", "target": { "blockId": "stat-value", "path": "props.html" } },
|
|
787
|
-
{ "key": "label", "label": "Label", "type": "text", "target": { "blockId": "stat-label", "path": "props.html" } }
|
|
788
|
-
]
|
|
789
|
-
}
|
|
790
|
-
```
|
|
791
|
-
|
|
792
|
-
The image tile (`image`, `caption`) and the quote tile (`quote`, `author`, `role`) are built the
|
|
793
|
-
same way. Then the grid, whose `cells` slot lists them:
|
|
794
|
-
|
|
795
|
-
```json
|
|
796
|
-
{
|
|
797
|
-
"name": "Bento grid",
|
|
798
|
-
"slug": "bento-grid",
|
|
799
|
-
"sectionType": "bento",
|
|
800
|
-
"category": "section",
|
|
801
|
-
"description": "A mosaic of image, stat and quote cells, each with its own size and surface.",
|
|
802
|
-
"blockJson": [
|
|
803
|
-
{ "type": "section", "id": "bento-root", "props": { "children": [
|
|
804
|
-
{ "type": "text", "id": "bento-eyebrow", "props": { "html": "" } },
|
|
805
|
-
{ "type": "heading", "id": "bento-title", "props": { "text": "", "level": 2 } },
|
|
806
|
-
{ "type": "section", "id": "bento-cells", "props": { "children": [] } }
|
|
807
|
-
] } }
|
|
808
|
-
],
|
|
809
|
-
"props": [
|
|
810
|
-
{ "key": "eyebrow", "label": "Eyebrow", "type": "text", "target": { "blockId": "bento-eyebrow", "path": "props.html" } },
|
|
811
|
-
{ "key": "heading", "label": "Heading", "type": "text", "target": { "blockId": "bento-title", "path": "props.text" } },
|
|
812
|
-
{ "key": "cells", "label": "Cells", "type": "slot",
|
|
813
|
-
"target": { "blockId": "bento-cells", "path": "props.children" },
|
|
814
|
-
"config": {
|
|
815
|
-
"multiple": true, "minItems": 3, "maxItems": 9,
|
|
816
|
-
"componentIds": ["<image tile id>", "<stat tile id>", "<quote tile id>"],
|
|
817
|
-
"slotSettings": [
|
|
818
|
-
{ "key": "span", "label": "Size", "type": "select", "defaultValue": "1x1",
|
|
819
|
-
"options": [
|
|
820
|
-
{ "value": "1x1", "label": "Small" }, { "value": "2x1", "label": "Wide" },
|
|
821
|
-
{ "value": "1x2", "label": "Tall" }, { "value": "2x2", "label": "Large" } ] },
|
|
822
|
-
{ "key": "tone", "label": "Surface", "type": "select", "defaultValue": "light",
|
|
823
|
-
"options": [
|
|
824
|
-
{ "value": "light", "label": "Light" }, { "value": "dark", "label": "Dark" },
|
|
825
|
-
{ "value": "brand", "label": "Brand" } ] }
|
|
826
|
-
] } }
|
|
827
|
-
]
|
|
828
|
-
}
|
|
829
|
-
```
|
|
830
|
-
|
|
831
|
-
Value: the instance, inside the page's whole `blockJson` (`update_page` with `ifMatch`, or
|
|
832
|
-
`compose_pages`). Each cell names its tile component in `_type`, holds that tile's props at its
|
|
833
|
-
root, and its place in the grid in `_settings`:
|
|
834
|
-
|
|
835
|
-
```json
|
|
836
|
-
{ "type": "component", "id": "home-bento", "props": { "componentId": "<bento grid id>", "overrides": {
|
|
837
|
-
"eyebrow": "In the field",
|
|
838
|
-
"heading": "Made to be handed down.",
|
|
839
|
-
"cells": [
|
|
840
|
-
{ "_key": "g1", "_type": "<image tile id>", "_settings": { "span": "2x2", "tone": "dark" },
|
|
841
|
-
"image": "/img/sierra-dawn-2400.jpg", "caption": "Three nights in the Sierra" },
|
|
842
|
-
{ "_key": "g2", "_type": "<stat tile id>", "_settings": { "span": "1x1", "tone": "brand" },
|
|
843
|
-
"value": "120k+", "label": "Fires cooked over" },
|
|
844
|
-
{ "_key": "g3", "_type": "<stat tile id>", "_settings": { "span": "1x1", "tone": "light" },
|
|
845
|
-
"value": "4.9", "label": "Average review" },
|
|
846
|
-
{ "_key": "g4", "_type": "<quote tile id>", "_settings": { "span": "2x1", "tone": "light" },
|
|
847
|
-
"quote": "Our restaurant runs four Ember ovens over live fire every night.",
|
|
848
|
-
"author": "Chef Amara Osei", "role": "Hearth & Ash, Portland" }
|
|
849
|
-
] } } }
|
|
850
|
-
```
|
|
851
|
-
|
|
852
|
-
Bound template: render the blocks with `BcmsBlocks`. Each cell arrives as a `component` block
|
|
853
|
-
whose `props.slotSettings` holds its `_settings`; the template turns `span` and `tone` into its own
|
|
854
|
-
grid and surface classes.
|
|
855
|
-
|
|
856
|
-
```html
|
|
857
|
-
<section data-bcms-block="home-bento">
|
|
858
|
-
<p data-bcms-field="home-bento__overrides.eyebrow" data-bcms-kind="text">In the field</p>
|
|
859
|
-
<h2 data-bcms-field="home-bento__overrides.heading" data-bcms-kind="text">Made to be handed down.</h2>
|
|
860
|
-
<div class="bento__grid grid grid-cols-4 gap-6">
|
|
861
|
-
<figure class="cell col-span-2 row-span-2 cell--dark">…</figure>
|
|
862
|
-
</div>
|
|
863
|
-
</section>
|
|
864
|
-
```
|
|
865
|
-
|
|
866
|
-
- A `_settings` value that does not fit the slot's `slotSettings` is refused, naming the cell's
|
|
867
|
-
`_key`; a slot with no `slotSettings` takes no `_settings` at all.
|
|
868
|
-
- `_key` is the cell's identity: keep it stable across reorders.
|
|
869
|
-
- Publish every tile component before the grid: a draft child renders nothing.
|
|
870
|
-
- A cell's copy is edited in the placement's `cells` list, not by clicking it on the canvas
|
|
871
|
-
([components.md](components.md) §1).
|
|
872
|
-
|
|
873
|
-
Not available today: spans that depend on the variant's column count (validate them yourself),
|
|
874
|
-
per-breakpoint spans, and a phone order per cell.
|
|
875
|
-
|
|
876
|
-
## Checks after any example
|
|
877
|
-
|
|
878
|
-
1. Read back what you wrote: `get_page`, `get_component` or `get_layout`. A richtext value comes
|
|
879
|
-
back as an envelope, an image as the resolved asset, a repeater as `{ repeatable }`.
|
|
880
|
-
2. `get_site_composition`: every placed component is published and no required prop is empty.
|
|
881
|
-
3. After the next release, `get_binding_report` for the route: no unmatched path for the fields
|
|
882
|
-
you bound, and the mode you expect.
|
|
883
|
-
4. Edit one value of each bound kind in the Visual Editor (a text, a link, an image, a body
|
|
884
|
-
block) and confirm only that element changed.
|
|
885
|
-
5. Publishing needs the user's authorization. Say what is still a draft.
|
|
1
|
+
---
|
|
2
|
+
title: "Worked examples"
|
|
3
|
+
description: "Six conversions from source HTML to a production schema, a production value and a bound template: hero, feature grid, pricing with a comparison table, testimonials as a collection, FAQ with FAQPage markup, bento grid with per-cell sizes."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Worked examples: source HTML to schema, value and bound template
|
|
7
|
+
|
|
8
|
+
Each example runs the same five steps: the source markup (trimmed), what it is and why, the
|
|
9
|
+
schema as the write tool takes it, the value as the write tool takes it, and the template with
|
|
10
|
+
its binding attributes. Copy the shape, never the customer's words. Rules behind the shapes:
|
|
11
|
+
[content-modeling.md](content-modeling.md), [components.md](components.md),
|
|
12
|
+
[bindings.md](bindings.md), [portable-text.md](portable-text.md). Examples 6 to 8 continue in
|
|
13
|
+
[worked-examples-advanced.md](worked-examples-advanced.md).
|
|
14
|
+
|
|
15
|
+
## Contents
|
|
16
|
+
|
|
17
|
+
- [Which shape each example uses](#which-shape-each-example-uses)
|
|
18
|
+
- [1. Hero with two buttons and a background image](#1-hero-with-two-buttons-and-a-background-image)
|
|
19
|
+
- [2. Feature grid with icons](#2-feature-grid-with-icons)
|
|
20
|
+
- [3. Pricing plans with a comparison table](#3-pricing-plans-with-a-comparison-table)
|
|
21
|
+
- [4. Testimonials kept as a collection](#4-testimonials-kept-as-a-collection)
|
|
22
|
+
- [5. FAQ accordion with FAQPage markup](#5-faq-accordion-with-faqpage-markup)
|
|
23
|
+
- [9. Bento grid with per-cell sizes](#9-bento-grid-with-per-cell-sizes)
|
|
24
|
+
- [Checks after any example](#checks-after-any-example)
|
|
25
|
+
- Examples 6 to 8 (blog post as an entry, mega menu in the Global Layout, tabs holding columns
|
|
26
|
+
of cards): [worked-examples-advanced.md](worked-examples-advanced.md)
|
|
27
|
+
|
|
28
|
+
## Which shape each example uses
|
|
29
|
+
|
|
30
|
+
Production stores content in four places. Pick by what the markup is, then follow that example.
|
|
31
|
+
|
|
32
|
+
| Markup | Stored as | Schema tool | Value tool | Address on the page | Example |
|
|
33
|
+
|---|---|---|---|---|---|
|
|
34
|
+
| A band one page owns | Page fields, keys prefixed by section | `create_page` `fields`, `add_field` | `set_page_content` | `data-bcms-field="<key>"` | 1, 2, 5 |
|
|
35
|
+
| A band reused or with variants | Component with props | `create_component` | `compose_pages` (an instance per page) | `<instanceId>__overrides.<key>` | 3 |
|
|
36
|
+
| Records that repeat on one route | Content model and entries | `create_content_model` | `create_content_entry` | the entry's field key | 4, 6 (advanced) |
|
|
37
|
+
| Header, footer, promo bar | Global Layout Section | `update_layout` | `update_layout` | `data-bcms-layout-field` | 7 (advanced) |
|
|
38
|
+
| A one-off band built from blocks | `section` block with child blocks | `update_page` `blockJson` | the same call | `<blockId>__<prop>` | 8 (advanced) |
|
|
39
|
+
| Cells of different kinds, each with its own size | Component with a `slot` list and `slotSettings` | `create_component` | `compose_pages` | `<instanceId>__overrides.<key>` | 9 |
|
|
40
|
+
|
|
41
|
+
Rules that hold in every example:
|
|
42
|
+
|
|
43
|
+
- A key is unique in its whole model, so a page field carries its section: `"hero_title"`,
|
|
44
|
+
`"faq_title"`. Prop keys and sub-field keys live inside one component and stay short.
|
|
45
|
+
- A field of type `text` is created as rich text. Send `richText: false` for every plain string
|
|
46
|
+
(a title, an eyebrow, a name, an icon name), then write it as a bare string.
|
|
47
|
+
- A repeater value is `{ "repeatable": [ ...rows ] }`; a row is addressed `<key>[<n>].<childKey>`.
|
|
48
|
+
- Bind the element that renders the value. Never a wrapper, never a list container.
|
|
49
|
+
- `set_page_content` merges by top-level key. `compose_pages` and `update_page` `blockJson`
|
|
50
|
+
replace the page's whole block array: read `get_page` first and send every block back.
|
|
51
|
+
|
|
52
|
+
## 1. Hero with two buttons and a background image
|
|
53
|
+
|
|
54
|
+
Source: a freight-tracking home page.
|
|
55
|
+
|
|
56
|
+
```html
|
|
57
|
+
<section class="hero relative isolate overflow-hidden" id="top">
|
|
58
|
+
<picture class="absolute inset-0 -z-10">
|
|
59
|
+
<source type="image/avif" srcset="/img/hero-harbor-1280.avif 1280w, /img/hero-harbor-2560.avif 2560w">
|
|
60
|
+
<img src="/img/hero-harbor-1280.jpg" alt="Container cranes over a quiet harbor at dusk"
|
|
61
|
+
class="h-full w-full object-cover object-[50%_35%]" width="2560" height="1440">
|
|
62
|
+
</picture>
|
|
63
|
+
<div class="absolute inset-0 -z-10 bg-slate-950/50" aria-hidden="true"></div>
|
|
64
|
+
<div class="mx-auto max-w-6xl px-6 py-32 text-white">
|
|
65
|
+
<p class="text-sm uppercase text-teal-300">Freight visibility for mid-size shippers</p>
|
|
66
|
+
<h1 class="mt-4 text-5xl font-bold">Know where every container is before your customer asks.</h1>
|
|
67
|
+
<p class="mt-6 text-lg">Live ETAs from 140 carriers, delays flagged before they cost you, and one shared view for ops and sales.</p>
|
|
68
|
+
<a href="/demo" class="btn btn-primary">Book a demo <svg class="icon-arrow-right" aria-hidden="true"/></a>
|
|
69
|
+
<a href="https://app.kestrelfreight.com/signup" class="btn btn-ghost">Start free trial</a>
|
|
70
|
+
<p class="mt-8 text-sm"><span class="stars" role="img" aria-label="Rated 4.8 out of 5">★★★★★</span> 4.8 on G2 from 310 reviews</p>
|
|
71
|
+
</div>
|
|
72
|
+
</section>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Reasoning:
|
|
76
|
+
|
|
77
|
+
1. One `<h1>`, a lede, two links styled as buttons, a photo behind the copy: the page's opening
|
|
78
|
+
band. Other pages write their own hero copy, so this is page fields, not chrome.
|
|
79
|
+
2. The uppercase line, the heading and the paragraph carry no links or emphasis: three plain
|
|
80
|
+
strings.
|
|
81
|
+
3. Each `<a class="btn">` is one `button` field. Label, link and look travel together; the
|
|
82
|
+
trailing arrow is the button's `arrow`, not content.
|
|
83
|
+
4. The `<picture>` is one `image` field. Store the largest source once and copy the `alt`. The CSS
|
|
84
|
+
`object-position: 50% 35%` is the focal point: divide by 100, so `{ x: 0.5, y: 0.35 }`.
|
|
85
|
+
5. The stars are drawn from the number. Store `4.8` and the label; never the glyphs.
|
|
86
|
+
6. The scrim, the padding and the teal colour are design. They stay in the site's classes.
|
|
87
|
+
|
|
88
|
+
Schema (`create_page` `fields`, or one `add_field` with `pageId` per field):
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
[
|
|
92
|
+
{ "key": "hero_eyebrow", "label": "Eyebrow", "type": "text", "richText": false },
|
|
93
|
+
{ "key": "hero_title", "label": "Heading", "type": "text", "richText": false, "required": true },
|
|
94
|
+
{ "key": "hero_lede", "label": "Supporting text", "type": "text", "richText": false },
|
|
95
|
+
{ "key": "hero_primary", "label": "Primary button", "type": "button" },
|
|
96
|
+
{ "key": "hero_secondary", "label": "Secondary button", "type": "button" },
|
|
97
|
+
{ "key": "hero_image", "label": "Background image", "type": "image",
|
|
98
|
+
"helpText": "Fills the whole band behind the text. Use a photo at least 2560 px wide." },
|
|
99
|
+
{ "key": "hero_rating", "label": "Rating", "type": "number", "config": { "min": 0, "max": 5, "step": 0.1 },
|
|
100
|
+
"ui": { "control": "rating" } },
|
|
101
|
+
{ "key": "hero_rating_label", "label": "Rating source", "type": "text", "richText": false }
|
|
102
|
+
]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Value (`set_page_content`):
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"pageId": "<home page id>",
|
|
110
|
+
"data": {
|
|
111
|
+
"hero_eyebrow": "Freight visibility for mid-size shippers",
|
|
112
|
+
"hero_title": "Know where every container is before your customer asks.",
|
|
113
|
+
"hero_lede": "Live ETAs from 140 carriers, delays flagged before they cost you, and one shared view for ops and sales.",
|
|
114
|
+
"hero_primary": { "label": "Book a demo", "href": "/demo", "arrow": true },
|
|
115
|
+
"hero_secondary": { "label": "Start free trial", "href": "https://app.kestrelfreight.com/signup" },
|
|
116
|
+
"hero_image": {
|
|
117
|
+
"url": "/img/hero-harbor-2560.jpg",
|
|
118
|
+
"altText": "Container cranes over a quiet harbor at dusk",
|
|
119
|
+
"focal": { "x": 0.5, "y": 0.35 }
|
|
120
|
+
},
|
|
121
|
+
"hero_rating": 4.8,
|
|
122
|
+
"hero_rating_label": "on G2 from 310 reviews"
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Bound template (classes unchanged, shortened here):
|
|
128
|
+
|
|
129
|
+
```html
|
|
130
|
+
<section class="hero ..." id="top">
|
|
131
|
+
<picture class="absolute inset-0 -z-10">
|
|
132
|
+
<img src="/img/hero-harbor-1280.jpg" alt="Container cranes over a quiet harbor at dusk"
|
|
133
|
+
data-bcms-field="hero_image" data-bcms-kind="image">
|
|
134
|
+
</picture>
|
|
135
|
+
<div class="absolute inset-0 -z-10 bg-slate-950/50" aria-hidden="true"></div>
|
|
136
|
+
<p data-bcms-field="hero_eyebrow" data-bcms-kind="text">Freight visibility for mid-size shippers</p>
|
|
137
|
+
<h1 data-bcms-field="hero_title" data-bcms-kind="text">Know where every container is before your customer asks.</h1>
|
|
138
|
+
<p data-bcms-field="hero_lede" data-bcms-kind="text">Live ETAs from 140 carriers, ...</p>
|
|
139
|
+
<a class="btn btn-primary" href="/demo" data-bcms-button="hero_primary"
|
|
140
|
+
data-bcms-props="hero_primary.href|url|href">
|
|
141
|
+
<span data-bcms-field="hero_primary.label" data-bcms-kind="text">Book a demo</span></a>
|
|
142
|
+
<a class="btn btn-ghost" href="https://app.kestrelfreight.com/signup" data-bcms-button="hero_secondary"
|
|
143
|
+
data-bcms-props="hero_secondary.href|url|href">
|
|
144
|
+
<span data-bcms-field="hero_secondary.label" data-bcms-kind="text">Start free trial</span></a>
|
|
145
|
+
<p><span class="stars" role="img" aria-label="Rated 4.8 out of 5">★★★★★</span>
|
|
146
|
+
<span data-bcms-field="hero_rating">4.8</span>
|
|
147
|
+
<span data-bcms-field="hero_rating_label" data-bcms-kind="text">on G2 from 310 reviews</span></p>
|
|
148
|
+
</section>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- The image binding sits on the `<img>`, not the `<picture>`. The alt text travels in the image
|
|
152
|
+
value (`altText`): a publish writes it to the bound element's `alt`. When `get_conversion_brief`
|
|
153
|
+
lists a separate alt path, declare that path in `data-bcms-props` with kind `text`.
|
|
154
|
+
- Neither button value names a `variant`: the anchors keep the site's own `btn-primary` and
|
|
155
|
+
`btn-ghost`, and the release reads those two looks into the project's Button family.
|
|
156
|
+
- An `href` is kind `url`, never `text`. The label gets its own `<span>` so a save does not erase
|
|
157
|
+
the arrow; the arrow is drawn by `BcmsButton` ([components.md](components.md)), so remove the
|
|
158
|
+
hand-written `<svg>` once the button renders through it.
|
|
159
|
+
- A number shown as text binds with no kind. `number` is a kind only inside `data-bcms-props`.
|
|
160
|
+
- The image value's `focal` frames this placement only; the asset's own focal point is untouched.
|
|
161
|
+
|
|
162
|
+
Not available today: an overlay, height or theme token on the section. The scrim and spacing stay
|
|
163
|
+
in the template's classes and change in code.
|
|
164
|
+
|
|
165
|
+
## 2. Feature grid with icons
|
|
166
|
+
|
|
167
|
+
Source: a finance SaaS product page.
|
|
168
|
+
|
|
169
|
+
```html
|
|
170
|
+
<section class="features py-24" aria-labelledby="features-title">
|
|
171
|
+
<span class="kicker">Why teams switch</span>
|
|
172
|
+
<h2 id="features-title">Everything your finance team asked for, in one close.</h2>
|
|
173
|
+
<p class="lede">Ledgerly connects to your bank, your payroll and your ERP, then does the matching for you.</p>
|
|
174
|
+
<ul class="grid gap-8 md:grid-cols-3" role="list">
|
|
175
|
+
<li class="feature-card">
|
|
176
|
+
<span class="icon-tile"><svg class="lucide lucide-zap" aria-hidden="true"><use href="#zap"/></svg></span>
|
|
177
|
+
<h3>Close in two days</h3>
|
|
178
|
+
<p>Auto-matching clears 92% of transactions before anyone opens a spreadsheet.</p>
|
|
179
|
+
<a class="text-link" href="/product/reconciliation">How matching works <span aria-hidden="true">→</span></a>
|
|
180
|
+
</li>
|
|
181
|
+
<li class="feature-card">
|
|
182
|
+
<span class="icon-tile"><svg class="lucide lucide-shield-check" aria-hidden="true"><use href="#shield-check"/></svg></span>
|
|
183
|
+
<h3>Audit-ready by default</h3>
|
|
184
|
+
<p>Every change is logged with who, when and why, so year-end takes hours.</p>
|
|
185
|
+
</li>
|
|
186
|
+
<!-- third card: plug, "40 native integrations", link to /integrations -->
|
|
187
|
+
</ul>
|
|
188
|
+
<a class="btn btn-outline mt-12" href="/product">Explore the product</a>
|
|
189
|
+
</section>
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Reasoning:
|
|
193
|
+
|
|
194
|
+
1. A heading group, then same-shaped `<li>` cards: a repeater. The count and order are the
|
|
195
|
+
editor's; the three-column grid is the template's.
|
|
196
|
+
2. Per card: the `<h3>` and the `<p>` are plain strings. The trailing link is optional, so the
|
|
197
|
+
second card leaves it empty. Never invent a link to fill the row.
|
|
198
|
+
3. The icon is a sprite reference (`<use href="#zap"/>`), so the content is its name. Store the
|
|
199
|
+
name the site's own sprite uses (`zap`), as a plain string. An inline `<svg>` with its own
|
|
200
|
+
paths is different: that is an `image` field with `config: { "media": "icon" }`.
|
|
201
|
+
4. The band's last button is a `button` field of its own, outside the repeater.
|
|
202
|
+
|
|
203
|
+
Schema:
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
[
|
|
207
|
+
{ "key": "features_eyebrow", "label": "Eyebrow", "type": "text", "richText": false },
|
|
208
|
+
{ "key": "features_title", "label": "Heading", "type": "text", "richText": false },
|
|
209
|
+
{ "key": "features_intro", "label": "Intro", "type": "text", "richText": false },
|
|
210
|
+
{ "key": "features_items", "label": "Features", "type": "repeater",
|
|
211
|
+
"ui": { "preview": { "title": "features_item_title" }, "itemLabel": "Feature" },
|
|
212
|
+
"fields": [
|
|
213
|
+
{ "key": "features_item_icon", "label": "Icon", "type": "text", "richText": false,
|
|
214
|
+
"helpText": "Icon name from the site's sprite, lowercase with hyphens: zap, shield-check, plug." },
|
|
215
|
+
{ "key": "features_item_title", "label": "Title", "type": "text", "richText": false },
|
|
216
|
+
{ "key": "features_item_body", "label": "Description", "type": "text", "richText": false },
|
|
217
|
+
{ "key": "features_item_link", "label": "Link", "type": "button" }
|
|
218
|
+
] },
|
|
219
|
+
{ "key": "features_cta", "label": "Button", "type": "button" }
|
|
220
|
+
]
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Value (`set_page_content` `data`):
|
|
224
|
+
|
|
225
|
+
```json
|
|
226
|
+
{
|
|
227
|
+
"features_eyebrow": "Why teams switch",
|
|
228
|
+
"features_title": "Everything your finance team asked for, in one close.",
|
|
229
|
+
"features_intro": "Ledgerly connects to your bank, your payroll and your ERP, then does the matching for you.",
|
|
230
|
+
"features_items": { "repeatable": [
|
|
231
|
+
{ "features_item_icon": "zap", "features_item_title": "Close in two days",
|
|
232
|
+
"features_item_body": "Auto-matching clears 92% of transactions before anyone opens a spreadsheet.",
|
|
233
|
+
"features_item_link": { "label": "How matching works", "href": "/product/reconciliation", "variant": "link" } },
|
|
234
|
+
{ "features_item_icon": "shield-check", "features_item_title": "Audit-ready by default",
|
|
235
|
+
"features_item_body": "Every change is logged with who, when and why, so year-end takes hours." },
|
|
236
|
+
{ "features_item_icon": "plug", "features_item_title": "40 native integrations",
|
|
237
|
+
"features_item_body": "Banks, payroll and ERPs sync every hour without CSV exports.",
|
|
238
|
+
"features_item_link": { "label": "See integrations", "href": "/integrations", "variant": "link" } }
|
|
239
|
+
] },
|
|
240
|
+
"features_cta": { "label": "Explore the product", "href": "/product", "variant": "outline" }
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Bound template (first card):
|
|
245
|
+
|
|
246
|
+
```html
|
|
247
|
+
<h2 id="features-title" data-bcms-field="features_title" data-bcms-kind="text">Everything your finance team asked for, in one close.</h2>
|
|
248
|
+
<ul class="grid gap-8 md:grid-cols-3" role="list">
|
|
249
|
+
<li class="feature-card">
|
|
250
|
+
<span class="icon-tile"><svg class="lucide lucide-zap" aria-hidden="true"><use href="#zap"/></svg></span>
|
|
251
|
+
<h3 data-bcms-field="features_items[0].features_item_title" data-bcms-kind="text">Close in two days</h3>
|
|
252
|
+
<p data-bcms-field="features_items[0].features_item_body" data-bcms-kind="text">Auto-matching clears 92% ...</p>
|
|
253
|
+
<a class="text-link" href="/product/reconciliation" data-bcms-button="features_items[0].features_item_link"
|
|
254
|
+
data-bcms-props="features_items[0].features_item_link.href|url|href">
|
|
255
|
+
<span data-bcms-field="features_items[0].features_item_link.label" data-bcms-kind="text">How matching works</span>
|
|
256
|
+
<span aria-hidden="true">→</span></a>
|
|
257
|
+
</li>
|
|
258
|
+
</ul>
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
- The `<ul>` carries no binding. Rows are added, removed and reordered in the side panel.
|
|
262
|
+
- Emit an address only for a row that exists, and only for a link the row holds.
|
|
263
|
+
- The icon `<svg>` is not bound: no field is read from text inside an SVG. The name is edited in
|
|
264
|
+
the side panel and the template turns it into the sprite reference.
|
|
265
|
+
|
|
266
|
+
To make the icon editable, add a field of type `icon`: its value is a BetterCMS icon name or an
|
|
267
|
+
uploaded icon, and a name off the set is refused. A component prop of type `icon` takes the same
|
|
268
|
+
value ([components.md](components.md) §1). An inline SVG stays an `image` with `media: "icon"`.
|
|
269
|
+
|
|
270
|
+
## 3. Pricing plans with a comparison table
|
|
271
|
+
|
|
272
|
+
Source: a social scheduling SaaS. The same band sits on the pricing page and the home page, so
|
|
273
|
+
it is a component.
|
|
274
|
+
|
|
275
|
+
```html
|
|
276
|
+
<section class="pricing" id="pricing">
|
|
277
|
+
<p class="eyebrow">Pricing</p>
|
|
278
|
+
<h2>Plans that grow with your calendar</h2>
|
|
279
|
+
<div class="billing-toggle" role="radiogroup" aria-label="Billing period">
|
|
280
|
+
<button role="radio" aria-checked="false" data-cycle="monthly">Monthly</button>
|
|
281
|
+
<button role="radio" aria-checked="true" data-cycle="yearly">Yearly <span class="pill">Save 20%</span></button>
|
|
282
|
+
</div>
|
|
283
|
+
<div class="plans grid md:grid-cols-3">
|
|
284
|
+
<article class="plan">
|
|
285
|
+
<h3>Starter</h3><p class="plan__for">For solo creators</p>
|
|
286
|
+
<p class="price"><span class="amount">$0</span></p>
|
|
287
|
+
<ul class="checks"><li>3 social accounts</li><li>30 scheduled posts a month</li></ul>
|
|
288
|
+
<a class="btn btn-outline" href="https://app.cadence.so/signup">Start free</a>
|
|
289
|
+
</article>
|
|
290
|
+
<article class="plan plan--featured">
|
|
291
|
+
<span class="ribbon">Most popular</span>
|
|
292
|
+
<h3>Growth</h3><p class="plan__for">For growing marketing teams</p>
|
|
293
|
+
<p class="price"><span class="amount" data-monthly="30" data-yearly="24">$24</span><span class="per">/mo</span></p>
|
|
294
|
+
<ul class="checks"><li>15 social accounts</li><li>Approval workflows</li><li>AI caption drafts</li></ul>
|
|
295
|
+
<a class="btn btn-primary" href="https://app.cadence.so/signup?plan=growth">Start 14-day trial</a>
|
|
296
|
+
</article>
|
|
297
|
+
<!-- Scale: "For agencies and brands", price "Let's talk", link /contact-sales -->
|
|
298
|
+
</div>
|
|
299
|
+
<table class="compare">
|
|
300
|
+
<thead><tr><th></th><th scope="col">Starter</th><th scope="col">Growth</th><th scope="col">Scale</th></tr></thead>
|
|
301
|
+
<tbody>
|
|
302
|
+
<tr class="group"><th colspan="4" scope="colgroup">Usage</th></tr>
|
|
303
|
+
<tr><th scope="row">Scheduled posts</th><td>30 a month</td><td>Unlimited</td><td>Unlimited</td></tr>
|
|
304
|
+
<tr class="group"><th colspan="4" scope="colgroup">Features</th></tr>
|
|
305
|
+
<tr><th scope="row">AI caption drafts</th><td><svg class="i-x" aria-label="Not included"/></td><td>50 a month</td><td><svg class="i-check" aria-label="Included"/></td></tr>
|
|
306
|
+
</tbody>
|
|
307
|
+
</table>
|
|
308
|
+
<p class="fineprint">Prices in USD, excluding tax. Yearly plans are billed once a year.</p>
|
|
309
|
+
</section>
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Reasoning:
|
|
313
|
+
|
|
314
|
+
1. Plan cards and the comparison table share plan names and always show together: one section.
|
|
315
|
+
2. Plans are same-shaped rows: one `table` prop. `data-monthly` and `data-yearly` give two
|
|
316
|
+
numbers; store numbers, never `$24/mo`. A price in words ("Let's talk") is its own text key,
|
|
317
|
+
shown when the numbers are empty.
|
|
318
|
+
3. Each plan's check list is a list inside a row: a nested `table` whose rows hold one string.
|
|
319
|
+
That is the deepest a prop goes: the inner row takes scalars only.
|
|
320
|
+
4. The comparison is a second `table` prop: one row object per feature, one text key per plan,
|
|
321
|
+
plus the group it sits under. The template prints a group row when `group` changes. Read
|
|
322
|
+
"Included" and "Not included" from `aria-label`, store "Yes" and "No", and let the template
|
|
323
|
+
draw the icon.
|
|
324
|
+
5. A CTA inside a component is one `button` block that two props target (`text`, `url`).
|
|
325
|
+
|
|
326
|
+
Schema (`create_component`; lands as a draft, then `publish_component`):
|
|
327
|
+
|
|
328
|
+
```json
|
|
329
|
+
{
|
|
330
|
+
"name": "Pricing / Three plans",
|
|
331
|
+
"slug": "pricing-three-plans",
|
|
332
|
+
"category": "conversion",
|
|
333
|
+
"sectionType": "Pricing",
|
|
334
|
+
"description": "Plan cards with monthly and yearly prices, then a feature comparison.",
|
|
335
|
+
"blockJson": [
|
|
336
|
+
{ "type": "section", "id": "pr-root", "props": { "children": [
|
|
337
|
+
{ "type": "text", "id": "pr-eyebrow", "props": { "html": "" } },
|
|
338
|
+
{ "type": "heading", "id": "pr-heading", "props": { "text": "", "level": 2 } },
|
|
339
|
+
{ "type": "text", "id": "pr-saving", "props": { "html": "" } },
|
|
340
|
+
{ "type": "section", "id": "pr-plans", "label": "Plans", "props": { "children": [
|
|
341
|
+
{ "type": "heading", "id": "pr-plan-name", "props": { "text": "", "level": 3 } },
|
|
342
|
+
{ "type": "button", "id": "pr-plan-cta", "props": { "text": "", "href": "" } }
|
|
343
|
+
] } },
|
|
344
|
+
{ "type": "section", "id": "pr-compare", "label": "Comparison", "props": { "children": [
|
|
345
|
+
{ "type": "heading", "id": "pr-compare-feature", "props": { "text": "", "level": 4 } }
|
|
346
|
+
] } },
|
|
347
|
+
{ "type": "text", "id": "pr-fineprint", "props": { "html": "" } }
|
|
348
|
+
] } }
|
|
349
|
+
],
|
|
350
|
+
"props": [
|
|
351
|
+
{ "key": "eyebrow", "label": "Eyebrow", "type": "text", "target": { "blockId": "pr-eyebrow", "path": "props.html" } },
|
|
352
|
+
{ "key": "heading", "label": "Heading", "type": "text", "target": { "blockId": "pr-heading", "path": "props.text" } },
|
|
353
|
+
{ "key": "saving", "label": "Yearly saving badge", "type": "text", "target": { "blockId": "pr-saving", "path": "props.html" } },
|
|
354
|
+
{ "key": "plans", "label": "Plans", "type": "table", "target": { "blockId": "pr-plans", "path": "props.rows" },
|
|
355
|
+
"ui": { "preview": { "title": "name" }, "itemLabel": "Plan" },
|
|
356
|
+
"config": { "fields": [
|
|
357
|
+
{ "key": "name", "label": "Name", "type": "text" },
|
|
358
|
+
{ "key": "audience", "label": "Who it is for", "type": "text" },
|
|
359
|
+
{ "key": "badge", "label": "Badge", "type": "text" },
|
|
360
|
+
{ "key": "monthly", "label": "Monthly price", "type": "number", "helpText": "Per month, billed monthly. Digits only." },
|
|
361
|
+
{ "key": "yearly", "label": "Yearly price", "type": "number", "helpText": "Per month, when billed yearly. Digits only." },
|
|
362
|
+
{ "key": "priceLabel", "label": "Price in words", "type": "text", "helpText": "Shown when both prices are empty." },
|
|
363
|
+
{ "key": "features", "label": "Included", "type": "table", "fields": [
|
|
364
|
+
{ "key": "line", "label": "Feature", "type": "text" }
|
|
365
|
+
] },
|
|
366
|
+
{ "key": "ctaLabel", "label": "Button label", "type": "text" },
|
|
367
|
+
{ "key": "ctaUrl", "label": "Button link", "type": "url" }
|
|
368
|
+
] } },
|
|
369
|
+
{ "key": "comparison", "label": "Comparison rows", "type": "table", "target": { "blockId": "pr-compare", "path": "props.rows" },
|
|
370
|
+
"ui": { "preview": { "title": "feature", "subtitle": "group" }, "itemLabel": "Row" },
|
|
371
|
+
"config": { "fields": [
|
|
372
|
+
{ "key": "group", "label": "Group", "type": "text" },
|
|
373
|
+
{ "key": "feature", "label": "Feature", "type": "text" },
|
|
374
|
+
{ "key": "starter", "label": "Starter", "type": "text" },
|
|
375
|
+
{ "key": "growth", "label": "Growth", "type": "text" },
|
|
376
|
+
{ "key": "scale", "label": "Scale", "type": "text" }
|
|
377
|
+
] } },
|
|
378
|
+
{ "key": "fineprint", "label": "Fine print", "type": "text", "target": { "blockId": "pr-fineprint", "path": "props.html" } }
|
|
379
|
+
]
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
A `table` prop targets `props.rows` of a `section` block whose children mirror one row. On a
|
|
384
|
+
code-built site the repo file draws the band: record it with `set_component_source` as soon as it
|
|
385
|
+
exists.
|
|
386
|
+
|
|
387
|
+
Value (`compose_pages`; one instance, keyed by prop key):
|
|
388
|
+
|
|
389
|
+
```json
|
|
390
|
+
{
|
|
391
|
+
"pages": [ { "slug": "pricing", "blockJson": [
|
|
392
|
+
{ "type": "component", "id": "pricing-01", "props": {
|
|
393
|
+
"componentId": "<id returned by create_component>",
|
|
394
|
+
"overrides": {
|
|
395
|
+
"eyebrow": "Pricing",
|
|
396
|
+
"heading": "Plans that grow with your calendar",
|
|
397
|
+
"saving": "Save 20%",
|
|
398
|
+
"plans": [
|
|
399
|
+
{ "name": "Starter", "audience": "For solo creators", "monthly": 0, "yearly": 0,
|
|
400
|
+
"features": [ { "line": "3 social accounts" }, { "line": "30 scheduled posts a month" } ],
|
|
401
|
+
"ctaLabel": "Start free", "ctaUrl": "https://app.cadence.so/signup" },
|
|
402
|
+
{ "name": "Growth", "audience": "For growing marketing teams", "badge": "Most popular",
|
|
403
|
+
"monthly": 30, "yearly": 24,
|
|
404
|
+
"features": [ { "line": "15 social accounts" }, { "line": "Approval workflows" }, { "line": "AI caption drafts" } ],
|
|
405
|
+
"ctaLabel": "Start 14-day trial", "ctaUrl": "https://app.cadence.so/signup?plan=growth" },
|
|
406
|
+
{ "name": "Scale", "audience": "For agencies and brands", "priceLabel": "Let's talk",
|
|
407
|
+
"features": [ { "line": "Unlimited accounts" }, { "line": "SSO and audit log" } ],
|
|
408
|
+
"ctaLabel": "Talk to sales", "ctaUrl": "/contact-sales" }
|
|
409
|
+
],
|
|
410
|
+
"comparison": [
|
|
411
|
+
{ "group": "Usage", "feature": "Scheduled posts", "starter": "30 a month", "growth": "Unlimited", "scale": "Unlimited" },
|
|
412
|
+
{ "group": "Features", "feature": "AI caption drafts", "starter": "No", "growth": "50 a month", "scale": "Yes" }
|
|
413
|
+
],
|
|
414
|
+
"fineprint": "Prices in USD, excluding tax. Yearly plans are billed once a year."
|
|
415
|
+
} } }
|
|
416
|
+
] } ]
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Bound template (the Growth card and one comparison row; `pricing-01` is the instance id):
|
|
421
|
+
|
|
422
|
+
```html
|
|
423
|
+
<section class="pricing" id="pricing" data-bcms-block="pricing-01">
|
|
424
|
+
<h2 data-bcms-field="pricing-01__overrides.heading" data-bcms-kind="text">Plans that grow with your calendar</h2>
|
|
425
|
+
<article class="plan plan--featured">
|
|
426
|
+
<span class="ribbon" data-bcms-field="pricing-01__overrides.plans[1].badge" data-bcms-kind="text">Most popular</span>
|
|
427
|
+
<h3 data-bcms-field="pricing-01__overrides.plans[1].name" data-bcms-kind="text">Growth</h3>
|
|
428
|
+
<p class="price"><span class="amount">$<span data-bcms-field="pricing-01__overrides.plans[1].yearly">24</span></span><span class="per">/mo</span></p>
|
|
429
|
+
<ul class="checks">
|
|
430
|
+
<li data-bcms-field="pricing-01__overrides.plans[1].features[0].line" data-bcms-kind="text">15 social accounts</li>
|
|
431
|
+
</ul>
|
|
432
|
+
<a class="btn btn-primary" href="https://app.cadence.so/signup?plan=growth"
|
|
433
|
+
data-bcms-props="pricing-01__overrides.plans[1].ctaUrl|url|href">
|
|
434
|
+
<span data-bcms-field="pricing-01__overrides.plans[1].ctaLabel" data-bcms-kind="text">Start 14-day trial</span></a>
|
|
435
|
+
</article>
|
|
436
|
+
<table class="compare"><tbody>
|
|
437
|
+
<tr><th scope="row" data-bcms-field="pricing-01__overrides.comparison[0].feature" data-bcms-kind="text">Scheduled posts</th>
|
|
438
|
+
<td data-bcms-field="pricing-01__overrides.comparison[0].starter" data-bcms-kind="text">30 a month</td>
|
|
439
|
+
<td data-bcms-field="pricing-01__overrides.comparison[0].growth" data-bcms-kind="text">Unlimited</td>
|
|
440
|
+
<td data-bcms-field="pricing-01__overrides.comparison[0].scale" data-bcms-kind="text">Unlimited</td></tr>
|
|
441
|
+
</tbody></table>
|
|
442
|
+
</section>
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
- Use the package `bind` and `bindProps` helpers inside an instance; they produce these addresses.
|
|
446
|
+
A definition block id (`pr-heading`) is shared by every instance and binds to nothing.
|
|
447
|
+
- Bind the digits, not `$24/mo`. The symbol and the period come from the template. Bind the price
|
|
448
|
+
on screen; when the switch shows monthly, the address is `.monthly`.
|
|
449
|
+
- A cell the template draws as an icon ("Yes", "No") is not bound: it is edited in the side panel.
|
|
450
|
+
- A comparison written as prose instead belongs in a `richtext` prop as a Portable Text `table`
|
|
451
|
+
([portable-text.md](portable-text.md)); then the wrapper of that one value is the bound element.
|
|
452
|
+
|
|
453
|
+
A typed table is available on both sides. On a component each `table` column is one sub-field
|
|
454
|
+
with its own type (`number`, `boolean`, `url`, ...), as `monthly` and `ctaUrl` are above. On a
|
|
455
|
+
model it is a `repeater` (stored as an `array` with `config.zones.repeatable.fields`): one field
|
|
456
|
+
per column, of any field type, `minItems` / `maxItems` on the repeatable zone, and
|
|
457
|
+
`ui.layout: "table"` to edit the rows as a grid. To key rows to plan ids, give the model a `text`
|
|
458
|
+
column with `unique: true`. A value only a developer may change is a model field with
|
|
459
|
+
`readOnly: "developer"` and a `lockReason`.
|
|
460
|
+
|
|
461
|
+
Not available today: a per-cell type (a column has one type for every row), and a caption,
|
|
462
|
+
footnotes or row groups as parts of the table (model them as fields beside it, as `group` and
|
|
463
|
+
`fineprint` are here). Offer markup: no JSON-LD is built from prices. Paste it as `schema` on
|
|
464
|
+
`update_page` when the page needs it. A fourth plan in the comparison is a new sub-field, a schema
|
|
465
|
+
change.
|
|
466
|
+
|
|
467
|
+
## 4. Testimonials kept as a collection
|
|
468
|
+
|
|
469
|
+
Source: three quote cards on a home page. The same quotes also show on the pricing and product
|
|
470
|
+
pages.
|
|
471
|
+
|
|
472
|
+
```html
|
|
473
|
+
<section class="testimonials bg-stone-50 py-24">
|
|
474
|
+
<p class="eyebrow">Customers</p>
|
|
475
|
+
<h2 class="text-3xl">Teams that stopped chasing spreadsheets</h2>
|
|
476
|
+
<div class="cards grid gap-6 md:grid-cols-3">
|
|
477
|
+
<figure class="t-card">
|
|
478
|
+
<div class="stars" role="img" aria-label="5 out of 5 stars">★★★★★</div>
|
|
479
|
+
<blockquote><p>“We closed March in a day and a half. Our auditors asked what changed.”</p></blockquote>
|
|
480
|
+
<figcaption>
|
|
481
|
+
<img class="avatar" src="/img/people/dana-ruiz.jpg" alt="" width="48" height="48">
|
|
482
|
+
<span class="name">Dana Ruiz</span><span class="role">Controller, Brightwell Foods</span>
|
|
483
|
+
</figcaption>
|
|
484
|
+
</figure>
|
|
485
|
+
<!-- Omar Haddad, VP Finance, Northgate Logistics, 5 stars; Mei Tanaka, Finance Lead, Alder Studio, 4 stars, no photo -->
|
|
486
|
+
</div>
|
|
487
|
+
<a class="btn btn-outline" href="/customers">Read all customer stories</a>
|
|
488
|
+
</section>
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Reasoning:
|
|
492
|
+
|
|
493
|
+
1. Search the site before modelling rows inside the section. The quotes repeat on three pages, so
|
|
494
|
+
each quote is one entry of a content model and is written once.
|
|
495
|
+
2. Per entry: the quote without the curly quotes (the template adds them), name, role and company
|
|
496
|
+
split at the comma, photo, and the rating read from `aria-label`. Add a consent switch:
|
|
497
|
+
publishing a person's words needs their permission.
|
|
498
|
+
3. The avatar's `alt=""` is correct: the name sits beside it. Keep the alt text empty.
|
|
499
|
+
4. The section keeps its own eyebrow, heading and button. How it gets its three quotes depends on
|
|
500
|
+
where it lives (table below).
|
|
501
|
+
|
|
502
|
+
Schema (`create_content_model`), then the home page's own fields:
|
|
503
|
+
|
|
504
|
+
```json
|
|
505
|
+
{
|
|
506
|
+
"name": "Testimonial",
|
|
507
|
+
"slug": "testimonial",
|
|
508
|
+
"urlPattern": "/customers/:slug",
|
|
509
|
+
"fields": [
|
|
510
|
+
{ "key": "quote", "label": "Quote", "type": "text", "richText": false, "required": true,
|
|
511
|
+
"helpText": "Without quotation marks; the page adds them." },
|
|
512
|
+
{ "key": "name", "label": "Name", "type": "text", "richText": false, "required": true },
|
|
513
|
+
{ "key": "role", "label": "Role", "type": "text", "richText": false },
|
|
514
|
+
{ "key": "company", "label": "Company", "type": "text", "richText": false },
|
|
515
|
+
{ "key": "photo", "label": "Photo", "type": "image" },
|
|
516
|
+
{ "key": "rating", "label": "Rating", "type": "number", "config": { "min": 1, "max": 5, "step": 1 } },
|
|
517
|
+
{ "key": "consent", "label": "Permission to publish", "type": "boolean" }
|
|
518
|
+
]
|
|
519
|
+
}
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
```json
|
|
523
|
+
[
|
|
524
|
+
{ "key": "testimonials_eyebrow", "label": "Eyebrow", "type": "text", "richText": false },
|
|
525
|
+
{ "key": "testimonials_title", "label": "Heading", "type": "text", "richText": false },
|
|
526
|
+
{ "key": "testimonials_picks", "label": "Testimonials", "type": "multi-reference",
|
|
527
|
+
"config": { "contentModelId": "<testimonial model id>", "min": 3, "max": 9 },
|
|
528
|
+
"helpText": "Pick 3, 6 or 9 so the grid has no gaps." },
|
|
529
|
+
{ "key": "testimonials_cta", "label": "Button", "type": "button" }
|
|
530
|
+
]
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Value: entries first (`create_content_entries`, drafts, ids come back), then the page:
|
|
534
|
+
|
|
535
|
+
```json
|
|
536
|
+
{
|
|
537
|
+
"contentModelId": "<testimonial model id>",
|
|
538
|
+
"entries": [
|
|
539
|
+
{ "slug": "dana-ruiz", "data": { "quote": "We closed March in a day and a half. Our auditors asked what changed.",
|
|
540
|
+
"name": "Dana Ruiz", "role": "Controller", "company": "Brightwell Foods",
|
|
541
|
+
"photo": { "url": "/img/people/dana-ruiz.jpg", "altText": "" }, "rating": 5, "consent": true } },
|
|
542
|
+
{ "slug": "omar-haddad", "data": { "quote": "Matching used to eat two people for a week. Now it is a coffee.",
|
|
543
|
+
"name": "Omar Haddad", "role": "VP Finance", "company": "Northgate Logistics",
|
|
544
|
+
"photo": { "url": "/img/people/omar-haddad.jpg", "altText": "" }, "rating": 5, "consent": true } },
|
|
545
|
+
{ "slug": "mei-tanaka", "data": { "quote": "The audit log alone paid for it.",
|
|
546
|
+
"name": "Mei Tanaka", "role": "Finance Lead", "company": "Alder Studio", "rating": 4, "consent": true } }
|
|
547
|
+
]
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
```json
|
|
552
|
+
{
|
|
553
|
+
"pageId": "<home page id>",
|
|
554
|
+
"data": {
|
|
555
|
+
"testimonials_eyebrow": "Customers",
|
|
556
|
+
"testimonials_title": "Teams that stopped chasing spreadsheets",
|
|
557
|
+
"testimonials_picks": ["<dana entry id>", "<omar entry id>", "<mei entry id>"],
|
|
558
|
+
"testimonials_cta": { "label": "Read all customer stories", "href": "/customers", "variant": "outline" }
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
A reference value is the entry id as a string; a multi-reference is a list of them. An id that
|
|
564
|
+
does not exist is refused with a 400 ("ref_missing"), so create the entries before the page value.
|
|
565
|
+
|
|
566
|
+
How a band shows the entries, by where it lives:
|
|
567
|
+
|
|
568
|
+
| Where | What production supports |
|
|
569
|
+
|---|---|
|
|
570
|
+
| A code-built page (this example) | A `multi-reference` page field holds the picked ids; the template reads the entries and draws the cards |
|
|
571
|
+
| The collection's own dynamic page (`/customers`) | A `collection` block: `cardComponentId`, `limit` (1 to 200), `order` (`newest` or `oldest`), `emptyText` |
|
|
572
|
+
| A component placed on several pages | A `table` prop filled per instance. The quotes are then copies: a change to one entry is repeated by hand on each instance |
|
|
573
|
+
|
|
574
|
+
Bound template (the section's own values; one card shown):
|
|
575
|
+
|
|
576
|
+
```html
|
|
577
|
+
<p class="eyebrow" data-bcms-field="testimonials_eyebrow" data-bcms-kind="text">Customers</p>
|
|
578
|
+
<h2 class="text-3xl" data-bcms-field="testimonials_title" data-bcms-kind="text">Teams that stopped chasing spreadsheets</h2>
|
|
579
|
+
<div class="cards grid gap-6 md:grid-cols-3">
|
|
580
|
+
<figure class="t-card">
|
|
581
|
+
<div class="stars" role="img" aria-label="5 out of 5 stars">★★★★★</div>
|
|
582
|
+
<blockquote><p>“We closed March in a day and a half. Our auditors asked what changed.”</p></blockquote>
|
|
583
|
+
<figcaption><img class="avatar" src="/img/people/dana-ruiz.jpg" alt=""><span class="name">Dana Ruiz</span></figcaption>
|
|
584
|
+
</figure>
|
|
585
|
+
</div>
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
- The cards carry no page binding. Their words belong to the entries and are edited there, once,
|
|
589
|
+
for every page that shows them. The picked list is changed in the side panel.
|
|
590
|
+
- Draw the stars from `rating`; never store the glyphs.
|
|
591
|
+
|
|
592
|
+
One chosen entry on a hosted page is a `collection` block, not a prop: `source: { modelId }`,
|
|
593
|
+
`pinnedIds: ["<entry id>"]`, `limit: 1` and `cardComponentId` naming the card that draws it. Pinned
|
|
594
|
+
entries come first, so the limit keeps exactly that one.
|
|
595
|
+
|
|
596
|
+
Not available today: a component prop that references entries (there is no reference prop type; it
|
|
597
|
+
would save and render nothing). A listing chosen by tag or filter on a route the repo's code
|
|
598
|
+
renders: the `collection` block with `source`, `filters` and `sort` is drawn by the hosted
|
|
599
|
+
renderer only, so there a "latest for this product" list is a query in the site's code. A
|
|
600
|
+
decorative flag on an image VALUE: an empty `altText` is the signal for one placement;
|
|
601
|
+
`update_media` `decorative: true` marks the asset itself.
|
|
602
|
+
|
|
603
|
+
## 5. FAQ accordion with FAQPage markup
|
|
604
|
+
|
|
605
|
+
Source: a help band built from `<details>`, with hand-written FAQPage JSON-LD beside it.
|
|
606
|
+
|
|
607
|
+
```html
|
|
608
|
+
<section class="faq" id="faq">
|
|
609
|
+
<h2>Questions, answered</h2>
|
|
610
|
+
<p>Straight answers about billing, data and switching tools.</p>
|
|
611
|
+
<details class="faq-item" open>
|
|
612
|
+
<summary><h3>Can I cancel at any time?</h3><svg class="chevron" aria-hidden="true"/></summary>
|
|
613
|
+
<div class="faq-item__body"><p>Yes. Cancel from <strong>Settings > Billing</strong> and you keep access until the end of the period.</p></div>
|
|
614
|
+
</details>
|
|
615
|
+
<details class="faq-item">
|
|
616
|
+
<summary><h3>Where is my data stored?</h3><svg class="chevron" aria-hidden="true"/></summary>
|
|
617
|
+
<div class="faq-item__body"><p>In the EU or the US, your choice at sign-up. Read the <a href="/security">security overview</a>.</p></div>
|
|
618
|
+
</details>
|
|
619
|
+
<details class="faq-item">
|
|
620
|
+
<summary><h3>How do I move from spreadsheets?</h3><svg class="chevron" aria-hidden="true"/></summary>
|
|
621
|
+
<div class="faq-item__body"><p>Most teams switch in an afternoon:</p>
|
|
622
|
+
<ol><li>Connect your bank.</li><li>Import last month's CSV.</li><li>Invite your team.</li></ol></div>
|
|
623
|
+
</details>
|
|
624
|
+
<p class="faq__more">Still stuck? <a href="/support">Talk to support</a></p>
|
|
625
|
+
</section>
|
|
626
|
+
<script type="application/ld+json">{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[...]}</script>
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
Reasoning:
|
|
630
|
+
|
|
631
|
+
1. Repeated `<details>` with a question and an answer, specific to this page: a repeater in the
|
|
632
|
+
page's fields. Move to a collection only when the same questions repeat on several pages.
|
|
633
|
+
2. The question is a plain string; the `<h3>` and the chevron belong to the template. The answer
|
|
634
|
+
carries bold text, a link and a list: rich text, narrowed to what answers use.
|
|
635
|
+
3. `open` on the first item is behaviour. It stays in the template.
|
|
636
|
+
4. "Talk to support" is a `button` field. "Still stuck?" is fixed template copy.
|
|
637
|
+
5. Delete the hand-written `<script type="application/ld+json">` and set the page's schema type
|
|
638
|
+
instead. Name the row keys so they contain `question` and `answer`: the FAQPage builder finds
|
|
639
|
+
the first list on the page whose rows have both.
|
|
640
|
+
|
|
641
|
+
Schema:
|
|
642
|
+
|
|
643
|
+
```json
|
|
644
|
+
[
|
|
645
|
+
{ "key": "faq_title", "label": "Heading", "type": "text", "richText": false },
|
|
646
|
+
{ "key": "faq_intro", "label": "Intro", "type": "text", "richText": false },
|
|
647
|
+
{ "key": "faq_items", "label": "Questions", "type": "repeater",
|
|
648
|
+
"ui": { "preview": { "title": "faq_question" }, "itemLabel": "Question" },
|
|
649
|
+
"fields": [
|
|
650
|
+
{ "key": "faq_question", "label": "Question", "type": "text", "richText": false, "required": true },
|
|
651
|
+
{ "key": "faq_answer", "label": "Answer", "type": "richtext", "required": true,
|
|
652
|
+
"config": { "accept": ["p", "b", "i", "link", "ul", "ol"] } }
|
|
653
|
+
] },
|
|
654
|
+
{ "key": "faq_support", "label": "Support link", "type": "button" }
|
|
655
|
+
]
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
Value (`set_page_content` `data`; each answer is a Portable Text array, shown below):
|
|
659
|
+
|
|
660
|
+
```jsonc
|
|
661
|
+
{
|
|
662
|
+
"faq_title": "Questions, answered",
|
|
663
|
+
"faq_intro": "Straight answers about billing, data and switching tools.",
|
|
664
|
+
"faq_items": { "repeatable": [
|
|
665
|
+
{ "faq_question": "Can I cancel at any time?", "faq_answer": [ /* one paragraph, "Settings > Billing" marked strong */ ] },
|
|
666
|
+
{ "faq_question": "Where is my data stored?", "faq_answer": [ /* array A */ ] },
|
|
667
|
+
{ "faq_question": "How do I move from spreadsheets?", "faq_answer": [ /* array B */ ] }
|
|
668
|
+
] },
|
|
669
|
+
"faq_support": { "label": "Talk to support", "href": "/support", "variant": "link" }
|
|
670
|
+
}
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
Array A, a paragraph with a link:
|
|
674
|
+
|
|
675
|
+
<!-- pt-example -->
|
|
676
|
+
```json
|
|
677
|
+
[
|
|
678
|
+
{ "_type": "block", "_key": "a2p1", "style": "normal",
|
|
679
|
+
"markDefs": [{ "_type": "link", "_key": "a2l1", "href": "/security" }],
|
|
680
|
+
"children": [
|
|
681
|
+
{ "_type": "span", "_key": "a2s1", "text": "In the EU or the US, your choice at sign-up. Read the ", "marks": [] },
|
|
682
|
+
{ "_type": "span", "_key": "a2s2", "text": "security overview", "marks": ["a2l1"] },
|
|
683
|
+
{ "_type": "span", "_key": "a2s3", "text": ".", "marks": [] }
|
|
684
|
+
] }
|
|
685
|
+
]
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
Array B, a paragraph then a flat numbered list:
|
|
689
|
+
|
|
690
|
+
<!-- pt-example -->
|
|
691
|
+
```json
|
|
692
|
+
[
|
|
693
|
+
{ "_type": "block", "_key": "a3p1", "style": "normal", "markDefs": [],
|
|
694
|
+
"children": [{ "_type": "span", "_key": "a3s1", "text": "Most teams switch in an afternoon:", "marks": [] }] },
|
|
695
|
+
{ "_type": "block", "_key": "a3i1", "style": "normal", "listItem": "number", "level": 1, "markDefs": [],
|
|
696
|
+
"children": [{ "_type": "span", "_key": "a3s2", "text": "Connect your bank.", "marks": [] }] },
|
|
697
|
+
{ "_type": "block", "_key": "a3i2", "style": "normal", "listItem": "number", "level": 1, "markDefs": [],
|
|
698
|
+
"children": [{ "_type": "span", "_key": "a3s3", "text": "Import last month's CSV.", "marks": [] }] },
|
|
699
|
+
{ "_type": "block", "_key": "a3i3", "style": "normal", "listItem": "number", "level": 1, "markDefs": [],
|
|
700
|
+
"children": [{ "_type": "span", "_key": "a3s4", "text": "Invite your team.", "marks": [] }] }
|
|
701
|
+
]
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
Structured data (`update_page`):
|
|
705
|
+
|
|
706
|
+
```json
|
|
707
|
+
{ "pageId": "<page id>", "schemaType": "FAQPage" }
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
Bound template (second item):
|
|
711
|
+
|
|
712
|
+
```html
|
|
713
|
+
<details class="faq-item">
|
|
714
|
+
<summary><h3 data-bcms-field="faq_items[1].faq_question" data-bcms-kind="text">Where is my data stored?</h3>
|
|
715
|
+
<svg class="chevron" aria-hidden="true"/></summary>
|
|
716
|
+
<div class="faq-item__body" data-bcms-field="faq_items[1].faq_answer" data-bcms-kind="richtext">
|
|
717
|
+
<p>In the EU or the US, your choice at sign-up. Read the <a href="/security">security overview</a>.</p>
|
|
718
|
+
</div>
|
|
719
|
+
</details>
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
- The question binds on the `<h3>`, not the `<summary>`: a save on the summary would erase the
|
|
723
|
+
chevron.
|
|
724
|
+
- `.faq-item__body` renders nothing but the answer, so it is the answer's element. If it also held
|
|
725
|
+
a "Was this helpful?" row, wrap the answer in its own `<div>` and bind that.
|
|
726
|
+
- A paragraph inside an answer is addressed `faq_items[1].faq_answer#a2p1`; keep `_key` values
|
|
727
|
+
stable when you rewrite an answer, and write the whole field, never one block.
|
|
728
|
+
|
|
729
|
+
Not available today: FAQPage markup that appears on its own. Without `schemaType: "FAQPage"` the
|
|
730
|
+
page gets WebPage. An "open several at once" setting has no field type of its own; if editors
|
|
731
|
+
need it, add a `boolean` field and have the template read it.
|
|
732
|
+
|
|
733
|
+
## 9. Bento grid with per-cell sizes
|
|
734
|
+
|
|
735
|
+
Source: the cookware brand's home page. The kit's `core-bento` entry is this shape, and
|
|
736
|
+
`bento-feature-showcase` is its responsive long form ([kit-scenarios.md](kit-scenarios.md)).
|
|
737
|
+
|
|
738
|
+
```html
|
|
739
|
+
<section class="bento" id="field">
|
|
740
|
+
<p class="eyebrow">In the field</p>
|
|
741
|
+
<h2>Made to be handed down.</h2>
|
|
742
|
+
<div class="bento__grid grid grid-cols-4 gap-6">
|
|
743
|
+
<figure class="cell col-span-2 row-span-2 cell--dark">
|
|
744
|
+
<img src="/img/sierra-dawn-2400.jpg" alt="Dawn over a Sierra camp, kettle steaming" width="2400" height="2400">
|
|
745
|
+
<figcaption>Three nights in the Sierra</figcaption>
|
|
746
|
+
</figure>
|
|
747
|
+
<div class="cell cell--brand"><i class="ti ti-flame" aria-hidden="true"></i>
|
|
748
|
+
<span class="stat__value">120k+</span><span class="stat__label">Fires cooked over</span></div>
|
|
749
|
+
<div class="cell"><i class="ti ti-star" aria-hidden="true"></i>
|
|
750
|
+
<span class="stat__value">4.9</span><span class="stat__label">Average review</span></div>
|
|
751
|
+
<blockquote class="cell col-span-2"><p>Our restaurant runs four Ember ovens over live fire every night.</p>
|
|
752
|
+
<cite>Chef Amara Osei, Hearth & Ash, Portland</cite></blockquote>
|
|
753
|
+
</div>
|
|
754
|
+
</section>
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
Reasoning:
|
|
758
|
+
|
|
759
|
+
1. The grid's children differ in shape (an image, two stats, a quote) and in size. Different
|
|
760
|
+
shapes chosen by the editor are slot children, not rows of one list.
|
|
761
|
+
2. Each cell's size and surface belong to its place in this grid, not to the cell. Reading
|
|
762
|
+
`col-span-2 row-span-2` and `cell--dark` into the image tile's own props would carry a
|
|
763
|
+
meaningless span to every other page that reuses the tile. They go in the slot's settings.
|
|
764
|
+
3. Size is a small set of presets (1 × 1, 2 × 1, 1 × 2, 2 × 2), so it is a select, never a free
|
|
765
|
+
column number. If the source's spans do not fit four presets, it is the per-cell grid
|
|
766
|
+
(`grid-cell-layout`), not a bento.
|
|
767
|
+
4. The four-column versus three-column layout is a variant: a second component sharing the
|
|
768
|
+
`sectionType`, with the same prop keys.
|
|
769
|
+
5. The icons and the gap between cells are design: they stay in the template's classes.
|
|
770
|
+
|
|
771
|
+
Schema: one component per tile kind (`create_component`, then `publish_component`). The stat tile:
|
|
772
|
+
|
|
773
|
+
```json
|
|
774
|
+
{
|
|
775
|
+
"name": "Stat tile",
|
|
776
|
+
"slug": "stat-tile",
|
|
777
|
+
"category": "content",
|
|
778
|
+
"description": "A big number with a short label.",
|
|
779
|
+
"blockJson": [
|
|
780
|
+
{ "type": "section", "id": "stat-root", "props": { "children": [
|
|
781
|
+
{ "type": "text", "id": "stat-value", "props": { "html": "" } },
|
|
782
|
+
{ "type": "text", "id": "stat-label", "props": { "html": "" } }
|
|
783
|
+
] } }
|
|
784
|
+
],
|
|
785
|
+
"props": [
|
|
786
|
+
{ "key": "value", "label": "Number", "type": "text", "target": { "blockId": "stat-value", "path": "props.html" } },
|
|
787
|
+
{ "key": "label", "label": "Label", "type": "text", "target": { "blockId": "stat-label", "path": "props.html" } }
|
|
788
|
+
]
|
|
789
|
+
}
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
The image tile (`image`, `caption`) and the quote tile (`quote`, `author`, `role`) are built the
|
|
793
|
+
same way. Then the grid, whose `cells` slot lists them:
|
|
794
|
+
|
|
795
|
+
```json
|
|
796
|
+
{
|
|
797
|
+
"name": "Bento grid",
|
|
798
|
+
"slug": "bento-grid",
|
|
799
|
+
"sectionType": "bento",
|
|
800
|
+
"category": "section",
|
|
801
|
+
"description": "A mosaic of image, stat and quote cells, each with its own size and surface.",
|
|
802
|
+
"blockJson": [
|
|
803
|
+
{ "type": "section", "id": "bento-root", "props": { "children": [
|
|
804
|
+
{ "type": "text", "id": "bento-eyebrow", "props": { "html": "" } },
|
|
805
|
+
{ "type": "heading", "id": "bento-title", "props": { "text": "", "level": 2 } },
|
|
806
|
+
{ "type": "section", "id": "bento-cells", "props": { "children": [] } }
|
|
807
|
+
] } }
|
|
808
|
+
],
|
|
809
|
+
"props": [
|
|
810
|
+
{ "key": "eyebrow", "label": "Eyebrow", "type": "text", "target": { "blockId": "bento-eyebrow", "path": "props.html" } },
|
|
811
|
+
{ "key": "heading", "label": "Heading", "type": "text", "target": { "blockId": "bento-title", "path": "props.text" } },
|
|
812
|
+
{ "key": "cells", "label": "Cells", "type": "slot",
|
|
813
|
+
"target": { "blockId": "bento-cells", "path": "props.children" },
|
|
814
|
+
"config": {
|
|
815
|
+
"multiple": true, "minItems": 3, "maxItems": 9,
|
|
816
|
+
"componentIds": ["<image tile id>", "<stat tile id>", "<quote tile id>"],
|
|
817
|
+
"slotSettings": [
|
|
818
|
+
{ "key": "span", "label": "Size", "type": "select", "defaultValue": "1x1",
|
|
819
|
+
"options": [
|
|
820
|
+
{ "value": "1x1", "label": "Small" }, { "value": "2x1", "label": "Wide" },
|
|
821
|
+
{ "value": "1x2", "label": "Tall" }, { "value": "2x2", "label": "Large" } ] },
|
|
822
|
+
{ "key": "tone", "label": "Surface", "type": "select", "defaultValue": "light",
|
|
823
|
+
"options": [
|
|
824
|
+
{ "value": "light", "label": "Light" }, { "value": "dark", "label": "Dark" },
|
|
825
|
+
{ "value": "brand", "label": "Brand" } ] }
|
|
826
|
+
] } }
|
|
827
|
+
]
|
|
828
|
+
}
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
Value: the instance, inside the page's whole `blockJson` (`update_page` with `ifMatch`, or
|
|
832
|
+
`compose_pages`). Each cell names its tile component in `_type`, holds that tile's props at its
|
|
833
|
+
root, and its place in the grid in `_settings`:
|
|
834
|
+
|
|
835
|
+
```json
|
|
836
|
+
{ "type": "component", "id": "home-bento", "props": { "componentId": "<bento grid id>", "overrides": {
|
|
837
|
+
"eyebrow": "In the field",
|
|
838
|
+
"heading": "Made to be handed down.",
|
|
839
|
+
"cells": [
|
|
840
|
+
{ "_key": "g1", "_type": "<image tile id>", "_settings": { "span": "2x2", "tone": "dark" },
|
|
841
|
+
"image": "/img/sierra-dawn-2400.jpg", "caption": "Three nights in the Sierra" },
|
|
842
|
+
{ "_key": "g2", "_type": "<stat tile id>", "_settings": { "span": "1x1", "tone": "brand" },
|
|
843
|
+
"value": "120k+", "label": "Fires cooked over" },
|
|
844
|
+
{ "_key": "g3", "_type": "<stat tile id>", "_settings": { "span": "1x1", "tone": "light" },
|
|
845
|
+
"value": "4.9", "label": "Average review" },
|
|
846
|
+
{ "_key": "g4", "_type": "<quote tile id>", "_settings": { "span": "2x1", "tone": "light" },
|
|
847
|
+
"quote": "Our restaurant runs four Ember ovens over live fire every night.",
|
|
848
|
+
"author": "Chef Amara Osei", "role": "Hearth & Ash, Portland" }
|
|
849
|
+
] } } }
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
Bound template: render the blocks with `BcmsBlocks`. Each cell arrives as a `component` block
|
|
853
|
+
whose `props.slotSettings` holds its `_settings`; the template turns `span` and `tone` into its own
|
|
854
|
+
grid and surface classes.
|
|
855
|
+
|
|
856
|
+
```html
|
|
857
|
+
<section data-bcms-block="home-bento">
|
|
858
|
+
<p data-bcms-field="home-bento__overrides.eyebrow" data-bcms-kind="text">In the field</p>
|
|
859
|
+
<h2 data-bcms-field="home-bento__overrides.heading" data-bcms-kind="text">Made to be handed down.</h2>
|
|
860
|
+
<div class="bento__grid grid grid-cols-4 gap-6">
|
|
861
|
+
<figure class="cell col-span-2 row-span-2 cell--dark">…</figure>
|
|
862
|
+
</div>
|
|
863
|
+
</section>
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
- A `_settings` value that does not fit the slot's `slotSettings` is refused, naming the cell's
|
|
867
|
+
`_key`; a slot with no `slotSettings` takes no `_settings` at all.
|
|
868
|
+
- `_key` is the cell's identity: keep it stable across reorders.
|
|
869
|
+
- Publish every tile component before the grid: a draft child renders nothing.
|
|
870
|
+
- A cell's copy is edited in the placement's `cells` list, not by clicking it on the canvas
|
|
871
|
+
([components.md](components.md) §1).
|
|
872
|
+
|
|
873
|
+
Not available today: spans that depend on the variant's column count (validate them yourself),
|
|
874
|
+
per-breakpoint spans, and a phone order per cell.
|
|
875
|
+
|
|
876
|
+
## Checks after any example
|
|
877
|
+
|
|
878
|
+
1. Read back what you wrote: `get_page`, `get_component` or `get_layout`. A richtext value comes
|
|
879
|
+
back as an envelope, an image as the resolved asset, a repeater as `{ repeatable }`.
|
|
880
|
+
2. `get_site_composition`: every placed component is published and no required prop is empty.
|
|
881
|
+
3. After the next release, `get_binding_report` for the route: no unmatched path for the fields
|
|
882
|
+
you bound, and the mode you expect.
|
|
883
|
+
4. Edit one value of each bound kind in the Visual Editor (a text, a link, an image, a body
|
|
884
|
+
block) and confirm only that element changed.
|
|
885
|
+
5. Publishing needs the user's authorization. Say what is still a draft.
|