@bison-lab/payload-blocks 2.0.0

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/dist/index.mjs ADDED
@@ -0,0 +1,677 @@
1
+ //#region src/fields/image.ts
2
+ /**
3
+ * A single image from the site's upload collection, for any block with a
4
+ * picture in it.
5
+ *
6
+ * Optional unless the block says otherwise: `imageField({ name: 'portrait',
7
+ * required: true })`. `admin` is replaced, not merged, so a block that passes
8
+ * its own description writes the whole thing.
9
+ */
10
+ function imageField(overrides = {}) {
11
+ return {
12
+ name: "image",
13
+ type: "upload",
14
+ relationTo: "media",
15
+ ...overrides.required ? {} : { admin: { description: "Optional." } },
16
+ ...overrides
17
+ };
18
+ }
19
+ //#endregion
20
+ //#region src/fields/link.ts
21
+ /**
22
+ * The three fields a call to action is made of, unwrapped.
23
+ *
24
+ * `href` is plain text rather than a relationship to a `pages` row: a block in
25
+ * this package has to work on a site whose page collection is named something
26
+ * else, or which links out more often than in. Resolving an internal reference
27
+ * is the site's job.
28
+ *
29
+ * `newTab` drives `target="_blank"` *and* the matching `rel`, which is why no
30
+ * renderer takes one without the other.
31
+ */
32
+ function linkFields({ required = false, labelFieldLabel = "Label" } = {}) {
33
+ return [
34
+ {
35
+ name: "label",
36
+ type: "text",
37
+ label: labelFieldLabel,
38
+ required
39
+ },
40
+ {
41
+ name: "href",
42
+ type: "text",
43
+ required,
44
+ admin: { description: "A site path (\"/contact\") or a full URL (\"https://example.com\")." }
45
+ },
46
+ {
47
+ name: "newTab",
48
+ type: "checkbox",
49
+ label: "Open in a new tab",
50
+ defaultValue: false
51
+ }
52
+ ];
53
+ }
54
+ /** `linkFields()` as one named group, for a block with a single CTA. */
55
+ function linkField({ name = "link", label, admin, ...rest } = {}) {
56
+ return {
57
+ name,
58
+ type: "group",
59
+ fields: linkFields(rest),
60
+ ...label === void 0 ? {} : { label },
61
+ ...admin === void 0 ? {} : { admin }
62
+ };
63
+ }
64
+ //#endregion
65
+ //#region src/blocks/hero/config.ts
66
+ /**
67
+ * The band a page opens with.
68
+ *
69
+ * Unlike every other block here this one has no `@bison-lab/ui` counterpart:
70
+ * a hero is where a site's brand is loudest, and the shipped renderer is
71
+ * deliberately plain markup so a site can swap in its own by overriding this
72
+ * one registry entry. The *fields* are the shared part — that is what makes a
73
+ * page portable between sites.
74
+ */
75
+ const HeroBlock = {
76
+ slug: "hero",
77
+ interfaceName: "HeroBlock",
78
+ labels: {
79
+ singular: "Hero",
80
+ plural: "Heroes"
81
+ },
82
+ fields: [
83
+ {
84
+ name: "eyebrow",
85
+ type: "text",
86
+ admin: { description: "Small label above the heading. Optional." }
87
+ },
88
+ {
89
+ name: "heading",
90
+ type: "text",
91
+ required: true
92
+ },
93
+ {
94
+ name: "body",
95
+ type: "textarea"
96
+ },
97
+ {
98
+ name: "tone",
99
+ type: "select",
100
+ defaultValue: "sweep",
101
+ options: [
102
+ {
103
+ label: "Sweep (brand gradient)",
104
+ value: "sweep"
105
+ },
106
+ {
107
+ label: "Dark (flat brand band)",
108
+ value: "dark"
109
+ },
110
+ {
111
+ label: "Light (page background)",
112
+ value: "light"
113
+ }
114
+ ],
115
+ admin: { description: "How the band is painted. Sites map these to their own surfaces." }
116
+ },
117
+ imageField({ admin: { description: "Optional background or lead image." } }),
118
+ {
119
+ name: "links",
120
+ type: "array",
121
+ maxRows: 2,
122
+ labels: {
123
+ singular: "Call to action",
124
+ plural: "Calls to action"
125
+ },
126
+ fields: linkFields({ required: true })
127
+ }
128
+ ]
129
+ };
130
+ //#endregion
131
+ //#region src/blocks/rich-text/config.ts
132
+ /** A prose section: one Lexical document at reading measure. */
133
+ const RichTextBlock = {
134
+ slug: "richText",
135
+ interfaceName: "RichTextBlock",
136
+ labels: {
137
+ singular: "Rich Text",
138
+ plural: "Rich Text"
139
+ },
140
+ fields: [{
141
+ name: "content",
142
+ type: "richText",
143
+ required: true
144
+ }]
145
+ };
146
+ //#endregion
147
+ //#region src/blocks/showcase-panels/config.ts
148
+ /** Expanding panels, one per service or product line. */
149
+ const ShowcasePanelsBlock = {
150
+ slug: "showcasePanels",
151
+ interfaceName: "ShowcasePanelsBlock",
152
+ labels: {
153
+ singular: "Showcase Panels",
154
+ plural: "Showcase Panels"
155
+ },
156
+ fields: [
157
+ {
158
+ name: "items",
159
+ type: "array",
160
+ required: true,
161
+ minRows: 3,
162
+ maxRows: 6,
163
+ labels: {
164
+ singular: "Panel",
165
+ plural: "Panels"
166
+ },
167
+ fields: [
168
+ {
169
+ name: "title",
170
+ type: "text",
171
+ required: true
172
+ },
173
+ {
174
+ name: "summary",
175
+ type: "textarea",
176
+ required: true,
177
+ admin: { description: "Revealed when the panel expands." }
178
+ },
179
+ imageField({ required: true }),
180
+ {
181
+ name: "href",
182
+ type: "text",
183
+ admin: { description: "Optional \"read more\" destination for this panel." }
184
+ },
185
+ {
186
+ name: "numeral",
187
+ type: "text",
188
+ admin: { description: "Overrides the spine numeral (\"01\", \"II\"). Leave empty to number automatically." }
189
+ }
190
+ ]
191
+ },
192
+ {
193
+ name: "spineVariant",
194
+ type: "select",
195
+ defaultValue: "numbered",
196
+ options: [
197
+ {
198
+ label: "Numbered (01, 02)",
199
+ value: "numbered"
200
+ },
201
+ {
202
+ label: "Volume (I, II)",
203
+ value: "volume"
204
+ },
205
+ {
206
+ label: "Minimal (no numeral)",
207
+ value: "minimal"
208
+ }
209
+ ]
210
+ },
211
+ {
212
+ name: "watermark",
213
+ type: "text",
214
+ admin: { description: "Faint mark behind the panels, e.g. a brand word." }
215
+ },
216
+ {
217
+ name: "defaultActiveIndex",
218
+ type: "number",
219
+ defaultValue: 0,
220
+ min: 0,
221
+ admin: { description: "Which panel is open on load, counting from 0." }
222
+ },
223
+ {
224
+ name: "ariaLabel",
225
+ type: "text",
226
+ admin: { description: "Names the region for screen readers. Defaults to \"Showcase panels\"." }
227
+ },
228
+ {
229
+ name: "labels",
230
+ type: "group",
231
+ admin: { description: "Overrides for the panel state text." },
232
+ fields: [{
233
+ name: "selected",
234
+ type: "text"
235
+ }, {
236
+ name: "preview",
237
+ type: "text"
238
+ }]
239
+ }
240
+ ]
241
+ };
242
+ //#endregion
243
+ //#region src/blocks/process-steps/config.ts
244
+ /** An ordered walkthrough that advances on its own. */
245
+ const ProcessStepsBlock = {
246
+ slug: "processSteps",
247
+ interfaceName: "ProcessStepsBlock",
248
+ labels: {
249
+ singular: "Process Steps",
250
+ plural: "Process Steps"
251
+ },
252
+ fields: [
253
+ {
254
+ name: "items",
255
+ type: "array",
256
+ required: true,
257
+ minRows: 3,
258
+ maxRows: 6,
259
+ labels: {
260
+ singular: "Step",
261
+ plural: "Steps"
262
+ },
263
+ fields: [
264
+ {
265
+ name: "title",
266
+ type: "text",
267
+ required: true
268
+ },
269
+ {
270
+ name: "description",
271
+ type: "textarea",
272
+ required: true
273
+ },
274
+ imageField({ required: true }),
275
+ {
276
+ name: "step",
277
+ type: "number",
278
+ min: 1,
279
+ admin: { description: "Overrides the displayed number. Leave empty to number by position." }
280
+ }
281
+ ]
282
+ },
283
+ {
284
+ name: "autoAdvance",
285
+ type: "checkbox",
286
+ defaultValue: true,
287
+ admin: { description: "Advance on a timer. Always off for visitors who prefer reduced motion." }
288
+ },
289
+ {
290
+ name: "autoAdvanceDuration",
291
+ type: "number",
292
+ defaultValue: 6e3,
293
+ min: 1e3,
294
+ admin: { description: "Milliseconds each step holds before advancing." }
295
+ },
296
+ {
297
+ name: "pauseOnHover",
298
+ type: "checkbox",
299
+ defaultValue: true
300
+ },
301
+ {
302
+ name: "defaultActiveIndex",
303
+ type: "number",
304
+ defaultValue: 0,
305
+ min: 0,
306
+ admin: { description: "Which step is active on load, counting from 0." }
307
+ },
308
+ {
309
+ name: "ariaLabel",
310
+ type: "text",
311
+ admin: { description: "Names the region for screen readers. Defaults to \"Process steps\"." }
312
+ },
313
+ {
314
+ name: "labels",
315
+ type: "group",
316
+ admin: { description: "Overrides for the play/pause control text." },
317
+ fields: [{
318
+ name: "pause",
319
+ type: "text"
320
+ }, {
321
+ name: "resume",
322
+ type: "text"
323
+ }]
324
+ }
325
+ ]
326
+ };
327
+ //#endregion
328
+ //#region src/fields/heading.ts
329
+ /**
330
+ * The eyebrow / title / description trio every marketing band opens with.
331
+ *
332
+ * They are three top-level fields rather than a group: editors read a section
333
+ * heading as the first thing in the block, and burying it one collapse deep
334
+ * puts the least-optional copy behind a click. The field names match the
335
+ * `@bison-lab/ui` prop names so the renderers stay a pass-through.
336
+ */
337
+ function headingFields({ required = true, eyebrowDescription = "Small label above the heading. Leave empty to hide the row." } = {}) {
338
+ return [
339
+ {
340
+ name: "eyebrow",
341
+ type: "text",
342
+ admin: { description: eyebrowDescription }
343
+ },
344
+ {
345
+ name: "title",
346
+ type: "text",
347
+ required
348
+ },
349
+ {
350
+ name: "description",
351
+ type: "textarea"
352
+ }
353
+ ];
354
+ }
355
+ //#endregion
356
+ //#region src/blocks/faq-columns/config.ts
357
+ /** A sticky intro beside a column of questions. */
358
+ const FaqColumnsBlock = {
359
+ slug: "faqColumns",
360
+ interfaceName: "FaqColumnsBlock",
361
+ labels: {
362
+ singular: "FAQ Columns",
363
+ plural: "FAQ Columns"
364
+ },
365
+ fields: [
366
+ ...headingFields(),
367
+ {
368
+ name: "items",
369
+ type: "array",
370
+ required: true,
371
+ minRows: 1,
372
+ labels: {
373
+ singular: "Question",
374
+ plural: "Questions"
375
+ },
376
+ fields: [{
377
+ name: "question",
378
+ type: "text",
379
+ required: true
380
+ }, {
381
+ name: "answer",
382
+ type: "textarea",
383
+ required: true
384
+ }]
385
+ },
386
+ {
387
+ name: "cta",
388
+ type: "group",
389
+ label: "Call to action",
390
+ admin: { description: "Shown under the intro. Leave the link empty to hide it." },
391
+ fields: [
392
+ {
393
+ name: "title",
394
+ type: "text",
395
+ admin: { description: "Lead-in line, e.g. \"Still have questions?\"." }
396
+ },
397
+ {
398
+ name: "linkText",
399
+ type: "text"
400
+ },
401
+ {
402
+ name: "href",
403
+ type: "text"
404
+ },
405
+ {
406
+ name: "newTab",
407
+ type: "checkbox",
408
+ label: "Open in a new tab",
409
+ defaultValue: false
410
+ }
411
+ ]
412
+ },
413
+ {
414
+ name: "sticky",
415
+ type: "checkbox",
416
+ defaultValue: true,
417
+ admin: { description: "Pin the intro column while the answers scroll." }
418
+ },
419
+ {
420
+ name: "type",
421
+ type: "select",
422
+ defaultValue: "single",
423
+ options: [{
424
+ label: "One answer open at a time",
425
+ value: "single"
426
+ }, {
427
+ label: "Several answers open at once",
428
+ value: "multiple"
429
+ }]
430
+ },
431
+ {
432
+ name: "collapsible",
433
+ type: "checkbox",
434
+ defaultValue: true,
435
+ admin: { description: "Allow the open answer to be closed again. Single mode only." }
436
+ }
437
+ ]
438
+ };
439
+ //#endregion
440
+ //#region src/blocks/testimonial-masonry/config.ts
441
+ /** Quotes in a masonry grid, clipped behind a fade once there are enough. */
442
+ const TestimonialMasonryBlock = {
443
+ slug: "testimonialMasonry",
444
+ interfaceName: "TestimonialMasonryBlock",
445
+ labels: {
446
+ singular: "Testimonial Masonry",
447
+ plural: "Testimonial Masonry"
448
+ },
449
+ fields: [
450
+ ...headingFields(),
451
+ {
452
+ name: "items",
453
+ type: "array",
454
+ required: true,
455
+ minRows: 1,
456
+ labels: {
457
+ singular: "Testimonial",
458
+ plural: "Testimonials"
459
+ },
460
+ fields: [{
461
+ name: "content",
462
+ type: "textarea",
463
+ required: true,
464
+ label: "Quote"
465
+ }, {
466
+ name: "author",
467
+ type: "group",
468
+ fields: [
469
+ {
470
+ name: "name",
471
+ type: "text",
472
+ required: true
473
+ },
474
+ {
475
+ name: "title",
476
+ type: "text",
477
+ admin: { description: "Role or organisation, e.g. \"Orthopaedic surgeon\"." }
478
+ },
479
+ imageField({
480
+ name: "avatar",
481
+ admin: { description: "Optional. Initials from the name are used when empty." }
482
+ })
483
+ ]
484
+ }]
485
+ },
486
+ linkField({
487
+ label: "Link",
488
+ admin: { description: "Shown under the fade, once there are enough quotes to clip." }
489
+ }),
490
+ {
491
+ name: "minItemsForFade",
492
+ type: "number",
493
+ defaultValue: 7,
494
+ min: 1,
495
+ admin: { description: "How many quotes before the grid clips and the bottom fade appears." }
496
+ },
497
+ {
498
+ name: "maxVisibleRows",
499
+ type: "number",
500
+ min: 1,
501
+ admin: { description: "Rows shown before the fade. Optional." }
502
+ }
503
+ ]
504
+ };
505
+ //#endregion
506
+ //#region src/blocks/nap/config.ts
507
+ /**
508
+ * Name, address, phone — the block local SEO is graded on.
509
+ *
510
+ * The `e164` phone number is a separate field from the displayed one because
511
+ * `NapBlock` uses it, and only it, for the `tel:` href and the JSON-LD
512
+ * `telephone`. A prettified string can never silently become an unreachable
513
+ * link.
514
+ */
515
+ const NapBlock = {
516
+ slug: "nap",
517
+ interfaceName: "NapBlock",
518
+ labels: {
519
+ singular: "Name, Address, Phone",
520
+ plural: "Name, Address, Phone"
521
+ },
522
+ fields: [
523
+ {
524
+ name: "businessName",
525
+ type: "text",
526
+ required: true,
527
+ label: "Business name"
528
+ },
529
+ {
530
+ name: "businessType",
531
+ type: "text",
532
+ required: true,
533
+ defaultValue: "LocalBusiness",
534
+ admin: { description: "schema.org type, e.g. \"MedicalBusiness\", \"Chiropractor\", \"LocalBusiness\"." }
535
+ },
536
+ {
537
+ name: "address",
538
+ type: "group",
539
+ fields: [
540
+ {
541
+ name: "streetAddress",
542
+ type: "text",
543
+ required: true
544
+ },
545
+ {
546
+ name: "addressLocality",
547
+ type: "text",
548
+ required: true,
549
+ label: "City"
550
+ },
551
+ {
552
+ name: "addressRegion",
553
+ type: "text",
554
+ required: true,
555
+ label: "State or region"
556
+ },
557
+ {
558
+ name: "postalCode",
559
+ type: "text",
560
+ required: true
561
+ },
562
+ {
563
+ name: "addressCountry",
564
+ type: "text",
565
+ defaultValue: "US",
566
+ admin: { description: "ISO 3166-1 alpha-2, e.g. US." }
567
+ }
568
+ ]
569
+ },
570
+ {
571
+ name: "departments",
572
+ type: "array",
573
+ required: true,
574
+ minRows: 1,
575
+ labels: {
576
+ singular: "Department",
577
+ plural: "Departments"
578
+ },
579
+ admin: { description: "A single-location business has one entry, usually named after the business." },
580
+ fields: [
581
+ {
582
+ name: "name",
583
+ type: "text",
584
+ required: true
585
+ },
586
+ {
587
+ name: "departmentType",
588
+ type: "text",
589
+ admin: { description: "schema.org type for this department. Falls back to the business type." }
590
+ },
591
+ {
592
+ name: "phoneE164",
593
+ type: "text",
594
+ required: true,
595
+ label: "Phone (E.164)",
596
+ admin: { description: "Dialable form, e.g. \"+15551234567\". This is what the tel: link and the structured data use." }
597
+ },
598
+ {
599
+ name: "phoneDisplay",
600
+ type: "text",
601
+ label: "Phone (as displayed)",
602
+ admin: { description: "Optional. US numbers are formatted automatically when this is empty." }
603
+ }
604
+ ]
605
+ },
606
+ {
607
+ name: "url",
608
+ type: "text",
609
+ admin: { description: "Canonical URL for the business. Optional." }
610
+ },
611
+ {
612
+ name: "showName",
613
+ type: "checkbox",
614
+ defaultValue: true,
615
+ admin: { description: "Turn off when a heading above the block already names the business." }
616
+ },
617
+ {
618
+ name: "headingLevel",
619
+ type: "select",
620
+ defaultValue: "h2",
621
+ options: [
622
+ {
623
+ label: "H2",
624
+ value: "h2"
625
+ },
626
+ {
627
+ label: "H3",
628
+ value: "h3"
629
+ },
630
+ {
631
+ label: "H4",
632
+ value: "h4"
633
+ }
634
+ ],
635
+ admin: { description: "Keeps the page's heading order intact." }
636
+ },
637
+ {
638
+ name: "emitJsonLd",
639
+ type: "checkbox",
640
+ defaultValue: true,
641
+ label: "Emit structured data",
642
+ admin: { description: "Adds a schema.org LocalBusiness script. Turn off if the page emits its own." }
643
+ }
644
+ ]
645
+ };
646
+ //#endregion
647
+ //#region src/media.ts
648
+ function isMediaDoc(value) {
649
+ return typeof value === "object" && value !== null;
650
+ }
651
+ /**
652
+ * Narrows an upload value to something renderable, or `undefined`.
653
+ *
654
+ * `undefined` covers three cases a renderer must treat identically: the field
655
+ * is empty, `depth: 0` left it as a bare id, or the document exists but has no
656
+ * `url` yet (an upload mid-flight). A renderer that gets `undefined` omits the
657
+ * image rather than emitting a broken `<img>`.
658
+ *
659
+ * `alt` is always a string: the media collection requires it, but a draft saved
660
+ * before that validation ran can still reach a renderer, and an `alt` of
661
+ * `undefined` would silently drop the attribute entirely.
662
+ */
663
+ function resolveMedia(value) {
664
+ if (!isMediaDoc(value)) return void 0;
665
+ const { url, alt, width, height } = value;
666
+ if (typeof url !== "string" || url.length === 0) return void 0;
667
+ return {
668
+ src: url,
669
+ alt: typeof alt === "string" ? alt : "",
670
+ ...typeof width === "number" ? { width } : {},
671
+ ...typeof height === "number" ? { height } : {}
672
+ };
673
+ }
674
+ //#endregion
675
+ export { FaqColumnsBlock, HeroBlock, NapBlock, ProcessStepsBlock, RichTextBlock, ShowcasePanelsBlock, TestimonialMasonryBlock, headingFields, imageField, linkField, linkFields, resolveMedia };
676
+
677
+ //# sourceMappingURL=index.mjs.map