@bison-lab/payload-blocks 2.0.0 → 3.1.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 CHANGED
@@ -108,7 +108,7 @@ const HeroBlock = {
108
108
  value: "dark"
109
109
  },
110
110
  {
111
- label: "Light (page background)",
111
+ label: "Plain (page surface)",
112
112
  value: "light"
113
113
  }
114
114
  ],
@@ -144,6 +144,31 @@ const RichTextBlock = {
144
144
  }]
145
145
  };
146
146
  //#endregion
147
+ //#region src/fields/min-rows.ts
148
+ /**
149
+ * The import-map path of the array field that refuses to go below `minRows`.
150
+ *
151
+ * Payload gates *Add* on `maxRows` but never gates *Remove* on `minRows`: an
152
+ * editor can delete the third showcase panel, see a "requires 3 panels"
153
+ * note, and only learn on publish that the section is invalid. Payload has no
154
+ * option for this, so it is an admin component (`@bison-lab/payload-blocks/admin`)
155
+ * referenced here by package specifier. Every array in this package with a
156
+ * `minRows` uses it, and a site can put it on its own arrays:
157
+ *
158
+ * ```ts
159
+ * { name: 'items', type: 'array', minRows: 2,
160
+ * admin: { components: { Field: MIN_ROWS_ARRAY_FIELD } }, fields: [...] }
161
+ * ```
162
+ *
163
+ * Consuming sites pick it up by re-running `payload generate:importmap`. Until
164
+ * they do, Payload logs the missing entry and falls back to its stock field.
165
+ */
166
+ const MIN_ROWS_ARRAY_FIELD = "@bison-lab/payload-blocks/admin#MinRowsArrayField";
167
+ /** Empty rows for an array's `defaultValue`, so a block opens at its minimum. */
168
+ function emptyRows(count) {
169
+ return Array.from({ length: count }, () => ({}));
170
+ }
171
+ //#endregion
147
172
  //#region src/blocks/showcase-panels/config.ts
148
173
  /** Expanding panels, one per service or product line. */
149
174
  const ShowcasePanelsBlock = {
@@ -160,10 +185,12 @@ const ShowcasePanelsBlock = {
160
185
  required: true,
161
186
  minRows: 3,
162
187
  maxRows: 6,
188
+ defaultValue: emptyRows(3),
163
189
  labels: {
164
190
  singular: "Panel",
165
191
  plural: "Panels"
166
192
  },
193
+ admin: { components: { Field: MIN_ROWS_ARRAY_FIELD } },
167
194
  fields: [
168
195
  {
169
196
  name: "title",
@@ -219,23 +246,6 @@ const ShowcasePanelsBlock = {
219
246
  defaultValue: 0,
220
247
  min: 0,
221
248
  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
249
  }
240
250
  ]
241
251
  };
@@ -256,10 +266,12 @@ const ProcessStepsBlock = {
256
266
  required: true,
257
267
  minRows: 3,
258
268
  maxRows: 6,
269
+ defaultValue: emptyRows(3),
259
270
  labels: {
260
271
  singular: "Step",
261
272
  plural: "Steps"
262
273
  },
274
+ admin: { components: { Field: MIN_ROWS_ARRAY_FIELD } },
263
275
  fields: [
264
276
  {
265
277
  name: "title",
@@ -271,13 +283,7 @@ const ProcessStepsBlock = {
271
283
  type: "textarea",
272
284
  required: true
273
285
  },
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
- }
286
+ imageField({ required: true })
281
287
  ]
282
288
  },
283
289
  {
@@ -304,23 +310,6 @@ const ProcessStepsBlock = {
304
310
  defaultValue: 0,
305
311
  min: 0,
306
312
  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
313
  }
325
314
  ]
326
315
  };
@@ -369,10 +358,12 @@ const FaqColumnsBlock = {
369
358
  type: "array",
370
359
  required: true,
371
360
  minRows: 1,
361
+ defaultValue: emptyRows(1),
372
362
  labels: {
373
363
  singular: "Question",
374
364
  plural: "Questions"
375
365
  },
366
+ admin: { components: { Field: MIN_ROWS_ARRAY_FIELD } },
376
367
  fields: [{
377
368
  name: "question",
378
369
  type: "text",
@@ -453,10 +444,12 @@ const TestimonialMasonryBlock = {
453
444
  type: "array",
454
445
  required: true,
455
446
  minRows: 1,
447
+ defaultValue: emptyRows(1),
456
448
  labels: {
457
449
  singular: "Testimonial",
458
450
  plural: "Testimonials"
459
451
  },
452
+ admin: { components: { Field: MIN_ROWS_ARRAY_FIELD } },
460
453
  fields: [{
461
454
  name: "content",
462
455
  type: "textarea",
@@ -572,11 +565,15 @@ const NapBlock = {
572
565
  type: "array",
573
566
  required: true,
574
567
  minRows: 1,
568
+ defaultValue: emptyRows(1),
575
569
  labels: {
576
570
  singular: "Department",
577
571
  plural: "Departments"
578
572
  },
579
- admin: { description: "A single-location business has one entry, usually named after the business." },
573
+ admin: {
574
+ components: { Field: MIN_ROWS_ARRAY_FIELD },
575
+ description: "A single-location business has one entry, usually named after the business."
576
+ },
580
577
  fields: [
581
578
  {
582
579
  name: "name",
@@ -672,6 +669,438 @@ function resolveMedia(value) {
672
669
  };
673
670
  }
674
671
  //#endregion
675
- export { FaqColumnsBlock, HeroBlock, NapBlock, ProcessStepsBlock, RichTextBlock, ShowcasePanelsBlock, TestimonialMasonryBlock, headingFields, imageField, linkField, linkFields, resolveMedia };
672
+ //#region src/blocks/faq-columns/sample.ts
673
+ const faqColumnsSample = {
674
+ id: "sample-faq-columns",
675
+ blockType: "faqColumns",
676
+ eyebrow: "Good to know",
677
+ title: "Questions patients ask before their first visit",
678
+ description: "If yours is not here, the front desk answers the phone between eight and six on weekdays.",
679
+ items: [
680
+ {
681
+ id: "sample-faq-referral",
682
+ question: "Do I need a referral?",
683
+ answer: "No. You can book directly. Some insurers ask for one before they reimburse, so check your policy if you plan to claim."
684
+ },
685
+ {
686
+ id: "sample-faq-sessions",
687
+ question: "How many sessions will I need?",
688
+ answer: "Most plans finish in six to eight sessions. You will get a written estimate after your assessment, and we revise it with you as you progress."
689
+ },
690
+ {
691
+ id: "sample-faq-wear",
692
+ question: "What should I wear?",
693
+ answer: "Anything you can move in. Shorts for a knee or hip, a vest or loose top for a shoulder or neck.\n\nThere are changing rooms if you are coming from work."
694
+ },
695
+ {
696
+ id: "sample-faq-insurance",
697
+ question: "Is treatment covered by insurance?",
698
+ answer: "Usually, once conservative treatment has been recommended. We invoice you directly and give you everything the insurer needs to reimburse you."
699
+ },
700
+ {
701
+ id: "sample-faq-cancel",
702
+ question: "What is the cancellation policy?",
703
+ answer: "Twenty-four hours' notice, by phone or through the booking link in your confirmation email. Later than that and the session is charged."
704
+ }
705
+ ],
706
+ cta: {
707
+ title: "Still have questions?",
708
+ linkText: "Call the front desk",
709
+ href: "tel:+13035550142",
710
+ newTab: false
711
+ },
712
+ sticky: true,
713
+ type: "single",
714
+ collapsible: true
715
+ };
716
+ //#endregion
717
+ //#region src/sample-image.ts
718
+ function escapeXml(value) {
719
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
720
+ }
721
+ /**
722
+ * A placeholder upload document for a block sample.
723
+ *
724
+ * A preview has no site media behind it, so the image has to travel with the
725
+ * sample. This is an inline SVG as a `data:` URL, in the `MediaDoc` shape
726
+ * `resolveMedia` already narrows, with `width` and `height` set so an image
727
+ * adapter can reserve the box. The scheme is what tells an adapter which kind
728
+ * of source it has: `next/image` treats a `data:` src as `unoptimized` on its
729
+ * own, so a site's adapter needs no special case for samples.
730
+ *
731
+ * Neutral greys rather than theme tokens: the SVG is a standalone document
732
+ * and cannot see the page's custom properties.
733
+ */
734
+ function sampleImage({ label, width, height, alt }) {
735
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="100%" height="100%" fill="#d4d4d8"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="system-ui, sans-serif" font-size="${Math.max(12, Math.round(Math.min(width, height) / 12))}" fill="#52525b">${escapeXml(label)}</text></svg>`;
736
+ return {
737
+ url: `data:image/svg+xml,${encodeURIComponent(svg)}`,
738
+ alt: alt ?? label,
739
+ width,
740
+ height
741
+ };
742
+ }
743
+ //#endregion
744
+ //#region src/blocks/hero/sample.ts
745
+ /**
746
+ * The placeholder hero's sample. The hero family (BIS-45) ships a sample per
747
+ * hero as each one is added; this is the one for the plain renderer.
748
+ */
749
+ const heroSample = {
750
+ id: "sample-hero",
751
+ blockType: "hero",
752
+ eyebrow: "Now booking spring appointments",
753
+ heading: "Move well again, without the long wait",
754
+ body: "Same-week assessments with a physiotherapist who stays with you from the first visit to the last. Most treatment plans finish in six to eight sessions.",
755
+ tone: "sweep",
756
+ image: sampleImage({
757
+ label: "Hero background · 2000 × 1000",
758
+ width: 2e3,
759
+ height: 1e3,
760
+ alt: "A bright, open treatment room"
761
+ }),
762
+ links: [{
763
+ label: "Book an assessment",
764
+ href: "/book",
765
+ newTab: false
766
+ }, {
767
+ label: "See our approach",
768
+ href: "https://example.com/approach",
769
+ newTab: true
770
+ }]
771
+ };
772
+ //#endregion
773
+ //#region src/blocks/nap/sample.ts
774
+ /**
775
+ * A fictional clinic. `emitJsonLd` is off: the block emits schema.org
776
+ * `LocalBusiness` by default, and a preview must not put a business that does
777
+ * not exist into a site's structured data. The phone numbers are in the
778
+ * 555-01xx range reserved for fiction.
779
+ */
780
+ const napSample = {
781
+ id: "sample-nap",
782
+ blockType: "nap",
783
+ businessName: "Larkspur Physiotherapy",
784
+ businessType: "Physiotherapy",
785
+ address: {
786
+ streetAddress: "400 Larkspur Lane, Suite 120",
787
+ addressLocality: "Boulder",
788
+ addressRegion: "CO",
789
+ postalCode: "80302",
790
+ addressCountry: "US"
791
+ },
792
+ departments: [{
793
+ id: "sample-dept-front-desk",
794
+ name: "Front desk",
795
+ phoneE164: "+13035550142",
796
+ phoneDisplay: "(303) 555-0142"
797
+ }, {
798
+ id: "sample-dept-billing",
799
+ name: "Billing and insurance",
800
+ departmentType: "AccountingService",
801
+ phoneE164: "+13035550143"
802
+ }],
803
+ url: "https://example.com",
804
+ showName: true,
805
+ headingLevel: "h2",
806
+ emitJsonLd: false
807
+ };
808
+ //#endregion
809
+ //#region src/blocks/process-steps/sample.ts
810
+ /** Four steps: enough to show the rail advancing through a real sequence. */
811
+ const processStepsSample = {
812
+ id: "sample-process-steps",
813
+ blockType: "processSteps",
814
+ items: [
815
+ {
816
+ id: "sample-step-assess",
817
+ title: "Assessment",
818
+ description: "A fifty-minute first visit: your history, a movement screen, and a clear explanation of what we found.",
819
+ image: sampleImage({
820
+ label: "Step 1 · 1600 × 1000",
821
+ width: 1600,
822
+ height: 1e3,
823
+ alt: "A physiotherapist taking notes during an assessment"
824
+ })
825
+ },
826
+ {
827
+ id: "sample-step-plan",
828
+ title: "Your plan",
829
+ description: "A written plan with the number of sessions we expect, what each one is for, and the exercises between them.",
830
+ image: sampleImage({
831
+ label: "Step 2 · 1600 × 1000",
832
+ width: 1600,
833
+ height: 1e3,
834
+ alt: "A printed treatment plan on a desk"
835
+ })
836
+ },
837
+ {
838
+ id: "sample-step-treat",
839
+ title: "Treatment",
840
+ description: "Hands-on work where it helps, and progressive loading where it matters. You leave every session knowing what to do next.",
841
+ image: sampleImage({
842
+ label: "Step 3 · 1600 × 1000",
843
+ width: 1600,
844
+ height: 1e3,
845
+ alt: "A patient lifting a light kettlebell under supervision"
846
+ })
847
+ },
848
+ {
849
+ id: "sample-step-discharge",
850
+ title: "Discharge and beyond",
851
+ description: "A final review, a maintenance programme, and an open door if anything flares up later.",
852
+ image: sampleImage({
853
+ label: "Step 4 · 1600 × 1000",
854
+ width: 1600,
855
+ height: 1e3,
856
+ alt: "A patient walking out of the clinic"
857
+ })
858
+ }
859
+ ],
860
+ autoAdvance: true,
861
+ autoAdvanceDuration: 5e3,
862
+ pauseOnHover: true,
863
+ defaultActiveIndex: 0
864
+ };
865
+ //#endregion
866
+ //#region src/blocks/rich-text/sample.ts
867
+ /**
868
+ * A serialized Lexical document, written by hand in the shapes the default
869
+ * JSX converters read (`heading`, `paragraph`, `text`, `list`, `link`).
870
+ * `version` is on every serialized Lexical node, and `direction`, `format` and
871
+ * `indent` are what `RichTextContent` requires on the root; the converters
872
+ * read none of them, and the sample carries them so it is shaped like a row
873
+ * the editor saved.
874
+ */
875
+ const block = {
876
+ direction: "ltr",
877
+ format: "",
878
+ indent: 0,
879
+ version: 1
880
+ };
881
+ function text(value, format = 0) {
882
+ return {
883
+ type: "text",
884
+ text: value,
885
+ format,
886
+ detail: 0,
887
+ mode: "normal",
888
+ style: "",
889
+ version: 1
890
+ };
891
+ }
892
+ function paragraph(children) {
893
+ return {
894
+ type: "paragraph",
895
+ children,
896
+ textFormat: 0,
897
+ textStyle: "",
898
+ ...block
899
+ };
900
+ }
901
+ const BOLD = 1;
902
+ const richTextSample = {
903
+ id: "sample-rich-text",
904
+ blockType: "richText",
905
+ content: { root: {
906
+ type: "root",
907
+ ...block,
908
+ children: [
909
+ {
910
+ type: "heading",
911
+ tag: "h2",
912
+ children: [text("What to expect at your first visit")],
913
+ ...block
914
+ },
915
+ paragraph([
916
+ text("Your first appointment runs about "),
917
+ text("fifty minutes", BOLD),
918
+ text(". We start with a conversation about what brought you in, then a movement assessment, and you leave with a written plan and the first two exercises.")
919
+ ]),
920
+ paragraph([
921
+ text("Bring comfortable clothes and any recent imaging. If you have questions before you arrive, "),
922
+ {
923
+ type: "link",
924
+ fields: {
925
+ url: "https://example.com/contact",
926
+ newTab: true,
927
+ linkType: "custom"
928
+ },
929
+ children: [text("get in touch")],
930
+ ...block,
931
+ version: 3
932
+ },
933
+ text(".")
934
+ ]),
935
+ {
936
+ type: "list",
937
+ listType: "bullet",
938
+ tag: "ul",
939
+ start: 1,
940
+ children: [
941
+ "Assessment and written plan",
942
+ "Hands-on treatment where it helps",
943
+ "Exercises you can do at home, with video"
944
+ ].map((item, i) => ({
945
+ type: "listitem",
946
+ value: i + 1,
947
+ children: [text(item)],
948
+ ...block
949
+ })),
950
+ ...block
951
+ }
952
+ ]
953
+ } }
954
+ };
955
+ //#endregion
956
+ //#region src/blocks/showcase-panels/sample.ts
957
+ /** Three panels: the minimum the layout is designed around. */
958
+ const showcasePanelsSample = {
959
+ id: "sample-showcase-panels",
960
+ blockType: "showcasePanels",
961
+ items: [
962
+ {
963
+ id: "sample-panel-back",
964
+ title: "Back and neck pain",
965
+ summary: "Most back pain settles with the right movement, not rest. We find what is driving yours and build a plan around your week, not ours.",
966
+ image: sampleImage({
967
+ label: "Panel 1 · 1400 × 1000",
968
+ width: 1400,
969
+ height: 1e3,
970
+ alt: "A physiotherapist guiding a patient through a stretch"
971
+ }),
972
+ href: "/services/back-and-neck",
973
+ numeral: "01"
974
+ },
975
+ {
976
+ id: "sample-panel-sport",
977
+ title: "Sports injuries",
978
+ summary: "From a rolled ankle to a post-surgical knee: a return-to-play plan with clear milestones, so you know when you are ready.",
979
+ image: sampleImage({
980
+ label: "Panel 2 · 1400 × 1000",
981
+ width: 1400,
982
+ height: 1e3,
983
+ alt: "A runner mid-stride on a track"
984
+ }),
985
+ href: "/services/sports-injuries"
986
+ },
987
+ {
988
+ id: "sample-panel-post-op",
989
+ title: "Post-operative rehab",
990
+ summary: "We work from your surgeon's protocol and keep them in the loop, so every stage of recovery is signed off before the next begins.",
991
+ image: sampleImage({
992
+ label: "Panel 3 · 1400 × 1000",
993
+ width: 1400,
994
+ height: 1e3,
995
+ alt: "A patient on a rehabilitation bike"
996
+ })
997
+ }
998
+ ],
999
+ spineVariant: "numbered",
1000
+ watermark: "LARKSPUR",
1001
+ defaultActiveIndex: 0
1002
+ };
1003
+ //#endregion
1004
+ //#region src/blocks/testimonial-masonry/sample.ts
1005
+ function avatar(name) {
1006
+ return sampleImage({
1007
+ label: name.split(" ").map((part) => part[0]).join(""),
1008
+ width: 160,
1009
+ height: 160,
1010
+ alt: `Portrait of ${name}`
1011
+ });
1012
+ }
1013
+ //#endregion
1014
+ //#region src/samples.ts
1015
+ /**
1016
+ * Every block the package ships, rendered without a CMS row.
1017
+ *
1018
+ * A Block library page (BIS-52) renders these live so an admin can see each
1019
+ * block before enabling it. Each sample lives beside its block's `config.ts`
1020
+ * and `component.tsx` as `sample.ts`, and `src/__tests__/samples.test.tsx`
1021
+ * locks the contract: one entry per slug, `blockType` matching the key, every
1022
+ * config field exercised at least once, and every image self-contained.
1023
+ *
1024
+ * React-free on purpose: it is read from a Global's field config, which
1025
+ * Payload loads in plain Node like the rest of this entry, as well as from a
1026
+ * route handler. Only what this package ships is here; a site supplies samples
1027
+ * for its own blocks through the Block library factory, whose option BIS-52
1028
+ * names.
1029
+ */
1030
+ const blockSamples = {
1031
+ hero: heroSample,
1032
+ richText: richTextSample,
1033
+ showcasePanels: showcasePanelsSample,
1034
+ processSteps: processStepsSample,
1035
+ faqColumns: faqColumnsSample,
1036
+ testimonialMasonry: {
1037
+ id: "sample-testimonial-masonry",
1038
+ blockType: "testimonialMasonry",
1039
+ eyebrow: "From our patients",
1040
+ title: "What people say after they finish",
1041
+ description: "Every review here was left by a patient we discharged. We do not edit them.",
1042
+ items: [
1043
+ {
1044
+ id: "sample-quote-1",
1045
+ content: "I had written off running. Eight weeks later I did a parkrun, and the plan I was given actually fitted around a job and two kids.",
1046
+ author: {
1047
+ name: "Priya Natarajan",
1048
+ title: "Recovered from a hamstring tear",
1049
+ avatar: avatar("Priya Natarajan")
1050
+ }
1051
+ },
1052
+ {
1053
+ id: "sample-quote-2",
1054
+ content: "The first physio who explained what was wrong in words I understood.",
1055
+ author: {
1056
+ name: "Tom Ferreira",
1057
+ title: "Lower back pain",
1058
+ avatar: avatar("Tom Ferreira")
1059
+ }
1060
+ },
1061
+ {
1062
+ id: "sample-quote-3",
1063
+ content: "My surgeon said my knee was ahead of schedule at every check-in. That was the rehab, not me.",
1064
+ author: {
1065
+ name: "Aisha Bello",
1066
+ title: "ACL reconstruction"
1067
+ }
1068
+ },
1069
+ {
1070
+ id: "sample-quote-4",
1071
+ content: "Booked on a Tuesday, seen on the Thursday. The shoulder I had put up with for a year was sorted in six visits.",
1072
+ author: {
1073
+ name: "Marcus Whitfield",
1074
+ title: "Frozen shoulder",
1075
+ avatar: avatar("Marcus Whitfield")
1076
+ }
1077
+ },
1078
+ {
1079
+ id: "sample-quote-5",
1080
+ content: "They kept my consultant in the loop the whole way through, which nobody else had bothered to do.",
1081
+ author: { name: "Helen Ostrowski" }
1082
+ },
1083
+ {
1084
+ id: "sample-quote-6",
1085
+ content: "Honest about what would take time and what would not. I would send my parents here.",
1086
+ author: {
1087
+ name: "Daniel Kim",
1088
+ title: "Ankle sprain",
1089
+ avatar: avatar("Daniel Kim")
1090
+ }
1091
+ }
1092
+ ],
1093
+ link: {
1094
+ label: "Read every review",
1095
+ href: "https://example.com/reviews",
1096
+ newTab: true
1097
+ },
1098
+ minItemsForFade: 6,
1099
+ maxVisibleRows: 2
1100
+ },
1101
+ nap: napSample
1102
+ };
1103
+ //#endregion
1104
+ export { FaqColumnsBlock, HeroBlock, MIN_ROWS_ARRAY_FIELD, NapBlock, ProcessStepsBlock, RichTextBlock, ShowcasePanelsBlock, TestimonialMasonryBlock, blockSamples, emptyRows, headingFields, imageField, linkField, linkFields, resolveMedia, sampleImage };
676
1105
 
677
1106
  //# sourceMappingURL=index.mjs.map