payload-next-starter 1.0.4 → 1.0.6
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/bin/setup.js +13 -1
- package/package.json +10 -1
- package/src/collections/Media.ts +16 -0
- package/src/collections/Pages.ts +358 -0
- package/src/collections/Users.ts +13 -0
- package/src/components/BlockRenderer.tsx +39 -0
- package/src/components/RichTextContent.tsx +81 -0
- package/src/components/blocks/CTABlock.tsx +81 -0
- package/src/components/blocks/ContactFormBlock.tsx +230 -0
- package/src/components/blocks/ContentBlock.tsx +23 -0
- package/src/components/blocks/FAQBlock.tsx +91 -0
- package/src/components/blocks/FeaturesBlock.tsx +82 -0
- package/src/components/blocks/HeroBlock.tsx +99 -0
- package/src/components/blocks/ImageGalleryBlock.tsx +77 -0
- package/src/components/blocks/PricingBlock.tsx +164 -0
- package/src/components/blocks/TeamBlock.tsx +134 -0
- package/src/components/blocks/TestimonialsBlock.tsx +93 -0
- package/src/components/blocks/index.ts +10 -0
- package/src/lib/getPageBySlug.ts +25 -0
- package/templates/app/(frontend)/[slug]/page.tsx +75 -0
- package/templates/collections/Pages.ts +354 -0
- package/templates/components/BlockRenderer.tsx +39 -0
- package/templates/components/RichTextContent.tsx +81 -0
- package/templates/components/blocks/CTABlock.tsx +81 -0
- package/templates/components/blocks/ContactFormBlock.tsx +230 -0
- package/templates/components/blocks/ContentBlock.tsx +23 -0
- package/templates/components/blocks/FAQBlock.tsx +91 -0
- package/templates/components/blocks/FeaturesBlock.tsx +82 -0
- package/templates/components/blocks/HeroBlock.tsx +99 -0
- package/templates/components/blocks/ImageGalleryBlock.tsx +77 -0
- package/templates/components/blocks/PricingBlock.tsx +164 -0
- package/templates/components/blocks/TeamBlock.tsx +134 -0
- package/templates/components/blocks/TestimonialsBlock.tsx +93 -0
- package/templates/lib/getPageBySlug.ts +25 -0
package/bin/setup.js
CHANGED
|
@@ -36,6 +36,9 @@ try {
|
|
|
36
36
|
mkdirSync(join(targetDir, 'src/app/(payload)'), { recursive: true })
|
|
37
37
|
mkdirSync(join(targetDir, 'src/app/api/[...payload]'), { recursive: true })
|
|
38
38
|
mkdirSync(join(targetDir, 'src/collections'), { recursive: true })
|
|
39
|
+
mkdirSync(join(targetDir, 'src/components'), { recursive: true })
|
|
40
|
+
mkdirSync(join(targetDir, 'src/components/blocks'), { recursive: true })
|
|
41
|
+
mkdirSync(join(targetDir, 'src/lib'), { recursive: true })
|
|
39
42
|
} catch (e) {
|
|
40
43
|
// Directories might already exist
|
|
41
44
|
}
|
|
@@ -71,7 +74,16 @@ if (existsSync(join(templatesDir, 'app/(frontend)'))) {
|
|
|
71
74
|
recursive: true,
|
|
72
75
|
force: true,
|
|
73
76
|
})
|
|
74
|
-
console.log(' ✓ Frontend pages (about-us, contact-us, share)/')
|
|
77
|
+
console.log(' ✓ Frontend pages (about-us, contact-us, share, [slug])/')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Copy components
|
|
81
|
+
if (existsSync(join(templatesDir, 'components'))) {
|
|
82
|
+
cpSync(join(templatesDir, 'components'), join(targetDir, 'src/components'), {
|
|
83
|
+
recursive: true,
|
|
84
|
+
force: true,
|
|
85
|
+
})
|
|
86
|
+
console.log(' ✓ components/ (blocks, BlockRenderer, RichTextContent)/')
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
if (existsSync(join(templatesDir, 'app/api/[...payload]'))) {
|
package/package.json
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payload-next-starter",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "PayloadCMS starter for Next.js - install into any Next.js app",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": "./bin/setup.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
"./blocks": "./src/components/blocks/index.ts",
|
|
10
|
+
"./BlockRenderer": "./src/components/BlockRenderer.tsx",
|
|
11
|
+
"./RichTextContent": "./src/components/RichTextContent.tsx"
|
|
12
|
+
},
|
|
8
13
|
"files": [
|
|
9
14
|
"bin/",
|
|
10
15
|
"templates/",
|
|
16
|
+
"src/components/",
|
|
17
|
+
"src/collections/",
|
|
18
|
+
"src/lib/",
|
|
11
19
|
"README.md"
|
|
12
20
|
],
|
|
13
21
|
"keywords": [
|
|
@@ -35,6 +43,7 @@
|
|
|
35
43
|
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
|
|
36
44
|
"lint": "cross-env NODE_OPTIONS=--no-deprecation eslint .",
|
|
37
45
|
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
|
46
|
+
"seed:pages": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/seedPages.ts",
|
|
38
47
|
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
|
|
39
48
|
"test": "pnpm run test:int && pnpm run test:e2e",
|
|
40
49
|
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" playwright test --config=playwright.config.ts",
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type { CollectionConfig, Block } from 'payload'
|
|
2
|
+
|
|
3
|
+
const getPreviewPath = (slug?: string) => {
|
|
4
|
+
const baseURL = process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000'
|
|
5
|
+
const path = slug === 'home' || !slug ? '/' : `/${slug}`
|
|
6
|
+
const previewSecret = process.env.PAYLOAD_SECRET || ''
|
|
7
|
+
|
|
8
|
+
return `${baseURL}/next/preview?secret=${encodeURIComponent(previewSecret)}&slug=${encodeURIComponent(path)}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const HeroBlock: Block = {
|
|
12
|
+
slug: 'hero',
|
|
13
|
+
labels: {
|
|
14
|
+
singular: 'Hero Section',
|
|
15
|
+
plural: 'Hero Sections',
|
|
16
|
+
},
|
|
17
|
+
fields: [
|
|
18
|
+
{ name: 'title', type: 'text', required: true },
|
|
19
|
+
{ name: 'subtitle', type: 'textarea' },
|
|
20
|
+
{ name: 'backgroundImage', type: 'upload', relationTo: 'media' },
|
|
21
|
+
{ name: 'ctaText', type: 'text', admin: { description: 'Call to action button text' } },
|
|
22
|
+
{ name: 'ctaLink', type: 'text', admin: { description: 'Call to action button link' } },
|
|
23
|
+
{
|
|
24
|
+
name: 'alignment',
|
|
25
|
+
type: 'select',
|
|
26
|
+
defaultValue: 'center',
|
|
27
|
+
options: [
|
|
28
|
+
{ label: 'Left', value: 'left' },
|
|
29
|
+
{ label: 'Center', value: 'center' },
|
|
30
|
+
{ label: 'Right', value: 'right' },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ContentBlock: Block = {
|
|
37
|
+
slug: 'content',
|
|
38
|
+
labels: {
|
|
39
|
+
singular: 'Content Section',
|
|
40
|
+
plural: 'Content Sections',
|
|
41
|
+
},
|
|
42
|
+
fields: [
|
|
43
|
+
{ name: 'heading', type: 'text' },
|
|
44
|
+
{ name: 'body', type: 'richText', required: true },
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ContactFormBlock: Block = {
|
|
49
|
+
slug: 'contactForm',
|
|
50
|
+
labels: {
|
|
51
|
+
singular: 'Contact Form',
|
|
52
|
+
plural: 'Contact Forms',
|
|
53
|
+
},
|
|
54
|
+
fields: [
|
|
55
|
+
{ name: 'heading', type: 'text', defaultValue: 'Get In Touch' },
|
|
56
|
+
{ name: 'description', type: 'textarea' },
|
|
57
|
+
{ name: 'email', type: 'text', required: true },
|
|
58
|
+
{ name: 'phone', type: 'text' },
|
|
59
|
+
{ name: 'address', type: 'textarea' },
|
|
60
|
+
{
|
|
61
|
+
name: 'formFields',
|
|
62
|
+
type: 'select',
|
|
63
|
+
hasMany: true,
|
|
64
|
+
defaultValue: ['name', 'email', 'message'],
|
|
65
|
+
options: [
|
|
66
|
+
{ label: 'Name', value: 'name' },
|
|
67
|
+
{ label: 'Email', value: 'email' },
|
|
68
|
+
{ label: 'Phone', value: 'phone' },
|
|
69
|
+
{ label: 'Subject', value: 'subject' },
|
|
70
|
+
{ label: 'Message', value: 'message' },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const FeaturesBlock: Block = {
|
|
77
|
+
slug: 'features',
|
|
78
|
+
labels: {
|
|
79
|
+
singular: 'Features Section',
|
|
80
|
+
plural: 'Features Sections',
|
|
81
|
+
},
|
|
82
|
+
fields: [
|
|
83
|
+
{ name: 'heading', type: 'text' },
|
|
84
|
+
{ name: 'subheading', type: 'textarea' },
|
|
85
|
+
{
|
|
86
|
+
name: 'items',
|
|
87
|
+
type: 'array',
|
|
88
|
+
minRows: 1,
|
|
89
|
+
fields: [
|
|
90
|
+
{ name: 'icon', type: 'text', admin: { description: 'Emoji or icon name' } },
|
|
91
|
+
{ name: 'title', type: 'text', required: true },
|
|
92
|
+
{ name: 'description', type: 'textarea' },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'columns',
|
|
97
|
+
type: 'select',
|
|
98
|
+
defaultValue: '3',
|
|
99
|
+
options: [
|
|
100
|
+
{ label: '2 Columns', value: '2' },
|
|
101
|
+
{ label: '3 Columns', value: '3' },
|
|
102
|
+
{ label: '4 Columns', value: '4' },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ImageGalleryBlock: Block = {
|
|
109
|
+
slug: 'imageGallery',
|
|
110
|
+
labels: {
|
|
111
|
+
singular: 'Image Gallery',
|
|
112
|
+
plural: 'Image Galleries',
|
|
113
|
+
},
|
|
114
|
+
fields: [
|
|
115
|
+
{ name: 'heading', type: 'text' },
|
|
116
|
+
{
|
|
117
|
+
name: 'images',
|
|
118
|
+
type: 'array',
|
|
119
|
+
minRows: 1,
|
|
120
|
+
fields: [
|
|
121
|
+
{ name: 'image', type: 'upload', relationTo: 'media', required: true },
|
|
122
|
+
{ name: 'caption', type: 'text' },
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'columns',
|
|
127
|
+
type: 'select',
|
|
128
|
+
defaultValue: '3',
|
|
129
|
+
options: [
|
|
130
|
+
{ label: '2 Columns', value: '2' },
|
|
131
|
+
{ label: '3 Columns', value: '3' },
|
|
132
|
+
{ label: '4 Columns', value: '4' },
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const PricingBlock: Block = {
|
|
139
|
+
slug: 'pricing',
|
|
140
|
+
labels: {
|
|
141
|
+
singular: 'Pricing Section',
|
|
142
|
+
plural: 'Pricing Sections',
|
|
143
|
+
},
|
|
144
|
+
fields: [
|
|
145
|
+
{ name: 'heading', type: 'text', defaultValue: 'Pricing Plans' },
|
|
146
|
+
{ name: 'subheading', type: 'textarea' },
|
|
147
|
+
{
|
|
148
|
+
name: 'plans',
|
|
149
|
+
type: 'array',
|
|
150
|
+
minRows: 1,
|
|
151
|
+
fields: [
|
|
152
|
+
{ name: 'name', type: 'text', required: true },
|
|
153
|
+
{ name: 'price', type: 'text', required: true },
|
|
154
|
+
{ name: 'period', type: 'text', defaultValue: '/month' },
|
|
155
|
+
{ name: 'description', type: 'textarea' },
|
|
156
|
+
{
|
|
157
|
+
name: 'features',
|
|
158
|
+
type: 'array',
|
|
159
|
+
fields: [{ name: 'feature', type: 'text', required: true }],
|
|
160
|
+
},
|
|
161
|
+
{ name: 'ctaText', type: 'text', defaultValue: 'Get Started' },
|
|
162
|
+
{ name: 'ctaLink', type: 'text' },
|
|
163
|
+
{ name: 'highlighted', type: 'checkbox', defaultValue: false },
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const TestimonialsBlock: Block = {
|
|
170
|
+
slug: 'testimonials',
|
|
171
|
+
labels: {
|
|
172
|
+
singular: 'Testimonials Section',
|
|
173
|
+
plural: 'Testimonials Sections',
|
|
174
|
+
},
|
|
175
|
+
fields: [
|
|
176
|
+
{ name: 'heading', type: 'text', defaultValue: 'What Our Customers Say' },
|
|
177
|
+
{
|
|
178
|
+
name: 'testimonials',
|
|
179
|
+
type: 'array',
|
|
180
|
+
minRows: 1,
|
|
181
|
+
fields: [
|
|
182
|
+
{ name: 'name', type: 'text', required: true },
|
|
183
|
+
{ name: 'role', type: 'text' },
|
|
184
|
+
{ name: 'company', type: 'text' },
|
|
185
|
+
{ name: 'quote', type: 'textarea', required: true },
|
|
186
|
+
{ name: 'avatar', type: 'upload', relationTo: 'media' },
|
|
187
|
+
{
|
|
188
|
+
name: 'rating',
|
|
189
|
+
type: 'select',
|
|
190
|
+
options: [
|
|
191
|
+
{ label: '5 Stars', value: '5' },
|
|
192
|
+
{ label: '4 Stars', value: '4' },
|
|
193
|
+
{ label: '3 Stars', value: '3' },
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const CTABlock: Block = {
|
|
202
|
+
slug: 'cta',
|
|
203
|
+
labels: {
|
|
204
|
+
singular: 'Call to Action',
|
|
205
|
+
plural: 'Call to Actions',
|
|
206
|
+
},
|
|
207
|
+
fields: [
|
|
208
|
+
{ name: 'heading', type: 'text', required: true },
|
|
209
|
+
{ name: 'description', type: 'textarea' },
|
|
210
|
+
{ name: 'buttonText', type: 'text', required: true },
|
|
211
|
+
{ name: 'buttonLink', type: 'text', required: true },
|
|
212
|
+
{ name: 'secondaryButtonText', type: 'text' },
|
|
213
|
+
{ name: 'secondaryButtonLink', type: 'text' },
|
|
214
|
+
{
|
|
215
|
+
name: 'style',
|
|
216
|
+
type: 'select',
|
|
217
|
+
defaultValue: 'primary',
|
|
218
|
+
options: [
|
|
219
|
+
{ label: 'Primary (Blue)', value: 'primary' },
|
|
220
|
+
{ label: 'Secondary (Gray)', value: 'secondary' },
|
|
221
|
+
{ label: 'Accent (Gradient)', value: 'accent' },
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const TeamBlock: Block = {
|
|
228
|
+
slug: 'team',
|
|
229
|
+
labels: {
|
|
230
|
+
singular: 'Team Section',
|
|
231
|
+
plural: 'Team Sections',
|
|
232
|
+
},
|
|
233
|
+
fields: [
|
|
234
|
+
{ name: 'heading', type: 'text', defaultValue: 'Meet Our Team' },
|
|
235
|
+
{ name: 'subheading', type: 'textarea' },
|
|
236
|
+
{
|
|
237
|
+
name: 'members',
|
|
238
|
+
type: 'array',
|
|
239
|
+
minRows: 1,
|
|
240
|
+
fields: [
|
|
241
|
+
{ name: 'name', type: 'text', required: true },
|
|
242
|
+
{ name: 'role', type: 'text', required: true },
|
|
243
|
+
{ name: 'bio', type: 'textarea' },
|
|
244
|
+
{ name: 'photo', type: 'upload', relationTo: 'media' },
|
|
245
|
+
{ name: 'linkedin', type: 'text' },
|
|
246
|
+
{ name: 'twitter', type: 'text' },
|
|
247
|
+
],
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const FAQBlock: Block = {
|
|
253
|
+
slug: 'faq',
|
|
254
|
+
labels: {
|
|
255
|
+
singular: 'FAQ Section',
|
|
256
|
+
plural: 'FAQ Sections',
|
|
257
|
+
},
|
|
258
|
+
fields: [
|
|
259
|
+
{ name: 'heading', type: 'text', defaultValue: 'Frequently Asked Questions' },
|
|
260
|
+
{
|
|
261
|
+
name: 'questions',
|
|
262
|
+
type: 'array',
|
|
263
|
+
minRows: 1,
|
|
264
|
+
fields: [
|
|
265
|
+
{ name: 'question', type: 'text', required: true },
|
|
266
|
+
{ name: 'answer', type: 'textarea', required: true },
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export const Pages: CollectionConfig = {
|
|
273
|
+
slug: 'pages',
|
|
274
|
+
admin: {
|
|
275
|
+
useAsTitle: 'title',
|
|
276
|
+
defaultColumns: ['title', 'slug', '_status', 'updatedAt'],
|
|
277
|
+
livePreview: {
|
|
278
|
+
url: ({ data }) => {
|
|
279
|
+
const pageData = data as { slug?: string } | undefined
|
|
280
|
+
return getPreviewPath(pageData?.slug)
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
preview: ({ data }) => {
|
|
284
|
+
const pageData = data as { slug?: string } | undefined
|
|
285
|
+
return getPreviewPath(pageData?.slug)
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
versions: {
|
|
289
|
+
drafts: {
|
|
290
|
+
autosave: {
|
|
291
|
+
interval: 300,
|
|
292
|
+
},
|
|
293
|
+
schedulePublish: true,
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
fields: [
|
|
297
|
+
{
|
|
298
|
+
type: 'tabs',
|
|
299
|
+
tabs: [
|
|
300
|
+
{
|
|
301
|
+
label: 'Content',
|
|
302
|
+
fields: [
|
|
303
|
+
{
|
|
304
|
+
name: 'title',
|
|
305
|
+
type: 'text',
|
|
306
|
+
required: true,
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: 'slug',
|
|
310
|
+
type: 'text',
|
|
311
|
+
required: true,
|
|
312
|
+
unique: true,
|
|
313
|
+
index: true,
|
|
314
|
+
admin: {
|
|
315
|
+
description: 'URL path (e.g., "contact-us", "about", "pricing")',
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: 'layout',
|
|
320
|
+
type: 'blocks',
|
|
321
|
+
blocks: [
|
|
322
|
+
HeroBlock,
|
|
323
|
+
ContentBlock,
|
|
324
|
+
ContactFormBlock,
|
|
325
|
+
FeaturesBlock,
|
|
326
|
+
ImageGalleryBlock,
|
|
327
|
+
PricingBlock,
|
|
328
|
+
TestimonialsBlock,
|
|
329
|
+
CTABlock,
|
|
330
|
+
TeamBlock,
|
|
331
|
+
FAQBlock,
|
|
332
|
+
],
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
label: 'SEO',
|
|
338
|
+
fields: [
|
|
339
|
+
{
|
|
340
|
+
name: 'seo',
|
|
341
|
+
type: 'group',
|
|
342
|
+
fields: [
|
|
343
|
+
{
|
|
344
|
+
name: 'metaTitle',
|
|
345
|
+
type: 'text',
|
|
346
|
+
admin: { description: 'Defaults to page title if empty' },
|
|
347
|
+
},
|
|
348
|
+
{ name: 'metaDescription', type: 'textarea' },
|
|
349
|
+
{ name: 'metaImage', type: 'upload', relationTo: 'media' },
|
|
350
|
+
{ name: 'noIndex', type: 'checkbox', defaultValue: false },
|
|
351
|
+
],
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
import * as BlockComponents from './blocks'
|
|
4
|
+
|
|
5
|
+
type BlockData = {
|
|
6
|
+
blockType: string
|
|
7
|
+
[key: string]: unknown
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type Props = {
|
|
11
|
+
blocks: BlockData[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const blockComponentMap = {
|
|
15
|
+
hero: BlockComponents.HeroBlock,
|
|
16
|
+
content: BlockComponents.ContentBlock,
|
|
17
|
+
contactForm: BlockComponents.ContactFormBlock,
|
|
18
|
+
features: BlockComponents.FeaturesBlock,
|
|
19
|
+
imageGallery: BlockComponents.ImageGalleryBlock,
|
|
20
|
+
pricing: BlockComponents.PricingBlock,
|
|
21
|
+
testimonials: BlockComponents.TestimonialsBlock,
|
|
22
|
+
cta: BlockComponents.CTABlock,
|
|
23
|
+
team: BlockComponents.TeamBlock,
|
|
24
|
+
faq: BlockComponents.FAQBlock,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function BlockRenderer({ blocks }: Props) {
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
{blocks.map((block, index) => {
|
|
31
|
+
const BlockComponent = blockComponentMap[block.blockType as keyof typeof blockComponentMap]
|
|
32
|
+
if (!BlockComponent) return null
|
|
33
|
+
|
|
34
|
+
const { blockType, ...blockProps } = block as { blockType: string; [key: string]: unknown }
|
|
35
|
+
return <BlockComponent key={index} {...(blockProps as Record<string, unknown>)} />
|
|
36
|
+
})}
|
|
37
|
+
</>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
type RichTextNode = {
|
|
4
|
+
type?: string
|
|
5
|
+
text?: string
|
|
6
|
+
format?: number | string
|
|
7
|
+
tag?: string
|
|
8
|
+
url?: string
|
|
9
|
+
fields?: {
|
|
10
|
+
url?: string
|
|
11
|
+
newTab?: boolean
|
|
12
|
+
}
|
|
13
|
+
children?: RichTextNode[]
|
|
14
|
+
listType?: 'bullet' | 'number'
|
|
15
|
+
value?: RichTextNode[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Props = {
|
|
19
|
+
content: unknown
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderText(text: string | undefined, format: number | string | undefined, key: string) {
|
|
23
|
+
if (!text) return null
|
|
24
|
+
|
|
25
|
+
let node: React.ReactNode = text
|
|
26
|
+
|
|
27
|
+
if (typeof format === 'string') {
|
|
28
|
+
if (format.includes('bold')) node = <strong key={`${key}-bold`}>{node}</strong>
|
|
29
|
+
if (format.includes('italic')) node = <em key={`${key}-italic`}>{node}</em>
|
|
30
|
+
if (format.includes('underline')) node = <u key={`${key}-underline`}>{node}</u>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return <React.Fragment key={key}>{node}</React.Fragment>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderNodes(nodes: RichTextNode[] | undefined, keyPrefix = 'node'): React.ReactNode {
|
|
37
|
+
if (!nodes?.length) return null
|
|
38
|
+
|
|
39
|
+
return nodes.map((node, index) => {
|
|
40
|
+
const key = `${keyPrefix}-${index}`
|
|
41
|
+
|
|
42
|
+
if (node.text !== undefined) {
|
|
43
|
+
return renderText(node.text, node.format, key)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const children = renderNodes(node.children, key)
|
|
47
|
+
|
|
48
|
+
switch (node.type) {
|
|
49
|
+
case 'heading': {
|
|
50
|
+
const Tag = (node.tag || 'h2') as keyof React.JSX.IntrinsicElements
|
|
51
|
+
return <Tag key={key}>{children}</Tag>
|
|
52
|
+
}
|
|
53
|
+
case 'paragraph':
|
|
54
|
+
return <p key={key}>{children}</p>
|
|
55
|
+
case 'quote':
|
|
56
|
+
return <blockquote key={key}>{children}</blockquote>
|
|
57
|
+
case 'list':
|
|
58
|
+
return node.listType === 'number' ? <ol key={key}>{children}</ol> : <ul key={key}>{children}</ul>
|
|
59
|
+
case 'listitem':
|
|
60
|
+
return <li key={key}>{children}</li>
|
|
61
|
+
case 'link': {
|
|
62
|
+
const href = node.fields?.url || node.url || '#'
|
|
63
|
+
return (
|
|
64
|
+
<a key={key} href={href} target={node.fields?.newTab ? '_blank' : undefined} rel={node.fields?.newTab ? 'noreferrer noopener' : undefined}>
|
|
65
|
+
{children}
|
|
66
|
+
</a>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
case 'linebreak':
|
|
70
|
+
return <br key={key} />
|
|
71
|
+
default:
|
|
72
|
+
return <React.Fragment key={key}>{children}</React.Fragment>
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function RichTextContent({ content }: Props) {
|
|
78
|
+
const root = content as { root?: { children?: RichTextNode[] } } | null
|
|
79
|
+
|
|
80
|
+
return <div>{renderNodes(root?.root?.children)}</div>
|
|
81
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import Link from 'next/link'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
heading?: string
|
|
6
|
+
description?: string
|
|
7
|
+
buttonText?: string
|
|
8
|
+
buttonLink?: string
|
|
9
|
+
secondaryButtonText?: string
|
|
10
|
+
secondaryButtonLink?: string
|
|
11
|
+
style?: 'primary' | 'secondary' | 'accent'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function CTABlock({
|
|
15
|
+
heading,
|
|
16
|
+
description,
|
|
17
|
+
buttonText,
|
|
18
|
+
buttonLink,
|
|
19
|
+
secondaryButtonText,
|
|
20
|
+
secondaryButtonLink,
|
|
21
|
+
style = 'primary',
|
|
22
|
+
}: Props) {
|
|
23
|
+
const backgrounds = {
|
|
24
|
+
primary: 'linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%)',
|
|
25
|
+
secondary: 'linear-gradient(135deg, #475569 0%, #334155 100%)',
|
|
26
|
+
accent: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
style={{
|
|
32
|
+
padding: '80px 40px',
|
|
33
|
+
background: backgrounds[style],
|
|
34
|
+
textAlign: 'center',
|
|
35
|
+
color: '#fff',
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
|
39
|
+
<h2 style={{ fontSize: '36px', marginBottom: '16px', fontWeight: 700 }}>{heading}</h2>
|
|
40
|
+
{description && (
|
|
41
|
+
<p style={{ fontSize: '20px', marginBottom: '32px', opacity: 0.9 }}>{description}</p>
|
|
42
|
+
)}
|
|
43
|
+
<div style={{ display: 'flex', gap: '16px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
|
44
|
+
{buttonText && buttonLink && (
|
|
45
|
+
<Link
|
|
46
|
+
href={buttonLink}
|
|
47
|
+
style={{
|
|
48
|
+
padding: '16px 32px',
|
|
49
|
+
background: '#fff',
|
|
50
|
+
color: '#1e3a5f',
|
|
51
|
+
textDecoration: 'none',
|
|
52
|
+
borderRadius: '12px',
|
|
53
|
+
fontSize: '18px',
|
|
54
|
+
fontWeight: 600,
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
{buttonText}
|
|
58
|
+
</Link>
|
|
59
|
+
)}
|
|
60
|
+
{secondaryButtonText && secondaryButtonLink && (
|
|
61
|
+
<Link
|
|
62
|
+
href={secondaryButtonLink}
|
|
63
|
+
style={{
|
|
64
|
+
padding: '16px 32px',
|
|
65
|
+
background: 'transparent',
|
|
66
|
+
color: '#fff',
|
|
67
|
+
textDecoration: 'none',
|
|
68
|
+
borderRadius: '12px',
|
|
69
|
+
fontSize: '18px',
|
|
70
|
+
fontWeight: 600,
|
|
71
|
+
border: '2px solid #fff',
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
{secondaryButtonText}
|
|
75
|
+
</Link>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|