@estation/create-cms-site 1.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/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +91 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/list-sections.d.ts +19 -0
- package/dist/tools/list-sections.d.ts.map +1 -0
- package/dist/tools/list-sections.js +152 -0
- package/dist/tools/list-sections.js.map +1 -0
- package/dist/tools/scaffold-project.d.ts +11 -0
- package/dist/tools/scaffold-project.d.ts.map +1 -0
- package/dist/tools/scaffold-project.js +90 -0
- package/dist/tools/scaffold-project.js.map +1 -0
- package/dist/tools/validate-config.d.ts +9 -0
- package/dist/tools/validate-config.d.ts.map +1 -0
- package/dist/tools/validate-config.js +72 -0
- package/dist/tools/validate-config.js.map +1 -0
- package/dist/utils/helpers.d.ts +2 -0
- package/dist/utils/helpers.d.ts.map +1 -0
- package/dist/utils/helpers.js +5 -0
- package/dist/utils/helpers.js.map +1 -0
- package/package.json +36 -0
- package/template/.env.example +9 -0
- package/template/LIVE-PREVIEW.md +267 -0
- package/template/README.md +210 -0
- package/template/next.config.ts +14 -0
- package/template/package.json +24 -0
- package/template/postcss.config.mjs +8 -0
- package/template/src/app/[slug]/page.tsx +38 -0
- package/template/src/app/api/revalidate/route.ts +34 -0
- package/template/src/app/blog/[slug]/page.tsx +60 -0
- package/template/src/app/blog/page.tsx +68 -0
- package/template/src/app/collections/[uuid]/page.tsx +28 -0
- package/template/src/app/events/[slug]/page.tsx +62 -0
- package/template/src/app/events/page.tsx +70 -0
- package/template/src/app/layout.tsx +27 -0
- package/template/src/app/news/[slug]/page.tsx +66 -0
- package/template/src/app/news/page.tsx +68 -0
- package/template/src/app/page.tsx +40 -0
- package/template/src/app/search/page.tsx +136 -0
- package/template/src/app/sitemap.ts +48 -0
- package/template/src/components/Footer.tsx +47 -0
- package/template/src/components/Navigation.tsx +65 -0
- package/template/src/components/SectionRenderer.tsx +43 -0
- package/template/src/components/cms-preview-listener.tsx +114 -0
- package/template/src/components/sections/CTASection.tsx +32 -0
- package/template/src/components/sections/ContactSection.tsx +74 -0
- package/template/src/components/sections/FAQSection.tsx +48 -0
- package/template/src/components/sections/FeaturesSection.tsx +42 -0
- package/template/src/components/sections/GallerySection.tsx +44 -0
- package/template/src/components/sections/GenericSection.tsx +63 -0
- package/template/src/components/sections/HeroSection.tsx +27 -0
- package/template/src/components/sections/SliderSection.tsx +66 -0
- package/template/src/components/sections/TestimonialsSection.tsx +52 -0
- package/template/src/components/sections/TextSection.tsx +31 -0
- package/template/src/lib/cms-api.ts +103 -0
- package/template/src/lib/content-helpers.ts +18 -0
- package/template/src/lib/types.ts +109 -0
- package/template/src/styles/globals.css +1 -0
- package/template/tsconfig.json +23 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getBlocksByTags } from "@/lib/cms-api";
|
|
2
|
+
import { str } from "@/lib/types";
|
|
3
|
+
import type { ListItem } from "@/lib/types";
|
|
4
|
+
|
|
5
|
+
export async function Footer() {
|
|
6
|
+
let copyright = `© ${new Date().getFullYear()} All rights reserved.`;
|
|
7
|
+
let links: { label: string; href: string }[] = [];
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const footerBlocks = await getBlocksByTags(["footer"]);
|
|
11
|
+
if (footerBlocks.length > 0) {
|
|
12
|
+
const block = footerBlocks[0];
|
|
13
|
+
const c = block.content;
|
|
14
|
+
if (str(c.copyright)) copyright = str(c.copyright);
|
|
15
|
+
const items = Array.isArray(c.links?.fieldValue)
|
|
16
|
+
? (c.links.fieldValue as ListItem[])
|
|
17
|
+
: [];
|
|
18
|
+
links = items
|
|
19
|
+
.map((item) => ({
|
|
20
|
+
label: str(item.label as never),
|
|
21
|
+
href: str(item.href as never, "#"),
|
|
22
|
+
}))
|
|
23
|
+
.filter((l) => l.label);
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// footer tag not available
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<footer className="border-t mt-16">
|
|
31
|
+
<div className="max-w-6xl mx-auto px-6 py-8 flex flex-col md:flex-row items-center justify-between gap-4">
|
|
32
|
+
<p className="text-sm text-gray-500">{copyright}</p>
|
|
33
|
+
{links.length > 0 && (
|
|
34
|
+
<ul className="flex gap-6 text-sm">
|
|
35
|
+
{links.map((link) => (
|
|
36
|
+
<li key={link.href}>
|
|
37
|
+
<a href={link.href} className="text-gray-500 hover:underline">
|
|
38
|
+
{link.label}
|
|
39
|
+
</a>
|
|
40
|
+
</li>
|
|
41
|
+
))}
|
|
42
|
+
</ul>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
</footer>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getBlocksByTags, getAllPages } from "@/lib/cms-api";
|
|
2
|
+
import { str } from "@/lib/types";
|
|
3
|
+
import type { ListItem } from "@/lib/types";
|
|
4
|
+
|
|
5
|
+
interface NavLink {
|
|
6
|
+
label: string;
|
|
7
|
+
href: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function Navigation() {
|
|
11
|
+
let links: NavLink[] = [];
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const navBlocks = await getBlocksByTags(["navigation"]);
|
|
15
|
+
if (navBlocks.length > 0) {
|
|
16
|
+
const block = navBlocks[0];
|
|
17
|
+
const items = Array.isArray(block.content.items?.fieldValue)
|
|
18
|
+
? (block.content.items.fieldValue as ListItem[])
|
|
19
|
+
: [];
|
|
20
|
+
links = items
|
|
21
|
+
.map((item) => ({
|
|
22
|
+
label: str(item.label as never),
|
|
23
|
+
href: str(item.href as never, "/"),
|
|
24
|
+
}))
|
|
25
|
+
.filter((l) => l.label);
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// navigation tag not available
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (links.length === 0) {
|
|
32
|
+
try {
|
|
33
|
+
const pages = await getAllPages();
|
|
34
|
+
links = pages
|
|
35
|
+
.filter((p) => p.is_published)
|
|
36
|
+
.map((p) => ({
|
|
37
|
+
label: p.title || p.slug,
|
|
38
|
+
href: p.slug === "index" || p.slug === "" ? "/" : `/${p.slug}`,
|
|
39
|
+
}));
|
|
40
|
+
} catch {
|
|
41
|
+
// fallback if no pages
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<nav className="border-b">
|
|
47
|
+
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
48
|
+
<a href="/" className="text-xl font-bold">
|
|
49
|
+
Home
|
|
50
|
+
</a>
|
|
51
|
+
{links.length > 0 && (
|
|
52
|
+
<ul className="flex gap-6 text-sm">
|
|
53
|
+
{links.map((link) => (
|
|
54
|
+
<li key={link.href}>
|
|
55
|
+
<a href={link.href} className="hover:underline text-gray-700">
|
|
56
|
+
{link.label}
|
|
57
|
+
</a>
|
|
58
|
+
</li>
|
|
59
|
+
))}
|
|
60
|
+
</ul>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
</nav>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ContentBlock, SectionProps } from "@/lib/types";
|
|
2
|
+
import { HeroSection } from "./sections/HeroSection";
|
|
3
|
+
import { TextSection } from "./sections/TextSection";
|
|
4
|
+
import { FeaturesSection } from "./sections/FeaturesSection";
|
|
5
|
+
import { FAQSection } from "./sections/FAQSection";
|
|
6
|
+
import { TestimonialsSection } from "./sections/TestimonialsSection";
|
|
7
|
+
import { CTASection } from "./sections/CTASection";
|
|
8
|
+
import { SliderSection } from "./sections/SliderSection";
|
|
9
|
+
import { GallerySection } from "./sections/GallerySection";
|
|
10
|
+
import { ContactSection } from "./sections/ContactSection";
|
|
11
|
+
import { GenericSection } from "./sections/GenericSection";
|
|
12
|
+
|
|
13
|
+
const SECTION_MAP: Record<string, React.FC<SectionProps>> = {
|
|
14
|
+
hero: HeroSection,
|
|
15
|
+
text: TextSection,
|
|
16
|
+
features: FeaturesSection,
|
|
17
|
+
feature: FeaturesSection,
|
|
18
|
+
faq: FAQSection,
|
|
19
|
+
testimonials: TestimonialsSection,
|
|
20
|
+
testimonial: TestimonialsSection,
|
|
21
|
+
cta: CTASection,
|
|
22
|
+
slider: SliderSection,
|
|
23
|
+
sliders: SliderSection,
|
|
24
|
+
gallery: GallerySection,
|
|
25
|
+
contact: ContactSection,
|
|
26
|
+
form: ContactSection,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function SectionRenderer({ blocks }: { blocks: ContentBlock[] }) {
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
{blocks.map((block) => {
|
|
33
|
+
const tag = block.tags?.[0] || block.type;
|
|
34
|
+
const Component = SECTION_MAP[tag] || GenericSection;
|
|
35
|
+
return (
|
|
36
|
+
<section key={block.uuid} data-cms-block={tag}>
|
|
37
|
+
<Component block={block} />
|
|
38
|
+
</section>
|
|
39
|
+
);
|
|
40
|
+
})}
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { list } from "@/lib/types";
|
|
5
|
+
|
|
6
|
+
interface PreviewUpdateMessage {
|
|
7
|
+
type: "cms-preview-update";
|
|
8
|
+
blockTag: string;
|
|
9
|
+
fieldName: string;
|
|
10
|
+
fieldType: string;
|
|
11
|
+
value: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PreviewHighlightMessage {
|
|
15
|
+
type: "cms-preview-highlight";
|
|
16
|
+
blockTag: string;
|
|
17
|
+
fieldName: string;
|
|
18
|
+
active: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type CMSMessage = PreviewUpdateMessage | PreviewHighlightMessage;
|
|
22
|
+
|
|
23
|
+
const HIGHLIGHT_CLASS = "cms-preview-highlight";
|
|
24
|
+
const HIGHLIGHT_STYLE_ID = "cms-preview-highlight-style";
|
|
25
|
+
|
|
26
|
+
function ensureHighlightStyles() {
|
|
27
|
+
if (document.getElementById(HIGHLIGHT_STYLE_ID)) return;
|
|
28
|
+
const style = document.createElement("style");
|
|
29
|
+
style.id = HIGHLIGHT_STYLE_ID;
|
|
30
|
+
style.textContent = `
|
|
31
|
+
.${HIGHLIGHT_CLASS} {
|
|
32
|
+
outline: 2px solid #3b82f6 !important;
|
|
33
|
+
outline-offset: 2px;
|
|
34
|
+
transition: outline 0.15s ease;
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
document.head.appendChild(style);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findField(blockTag: string, fieldName: string): Element | null {
|
|
41
|
+
const block = document.querySelector(`[data-cms-block="${blockTag}"]`);
|
|
42
|
+
if (!block) return null;
|
|
43
|
+
return block.querySelector(`[data-cms-field="${fieldName}"]`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function handleUpdate(msg: PreviewUpdateMessage) {
|
|
47
|
+
const el = findField(msg.blockTag, msg.fieldName);
|
|
48
|
+
if (!el) return;
|
|
49
|
+
|
|
50
|
+
switch (msg.fieldType) {
|
|
51
|
+
case "text":
|
|
52
|
+
el.textContent = msg.value;
|
|
53
|
+
break;
|
|
54
|
+
case "richtext":
|
|
55
|
+
el.innerHTML = msg.value;
|
|
56
|
+
break;
|
|
57
|
+
case "image":
|
|
58
|
+
if (el instanceof HTMLImageElement) {
|
|
59
|
+
el.src = msg.value;
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
case "list":
|
|
63
|
+
try {
|
|
64
|
+
const rawItems = JSON.parse(msg.value);
|
|
65
|
+
const items = list({ fieldValue: rawItems });
|
|
66
|
+
if (Array.isArray(items)) {
|
|
67
|
+
el.innerHTML = items
|
|
68
|
+
.map(
|
|
69
|
+
(item) =>
|
|
70
|
+
`<li>${typeof item === "string" ? item : JSON.stringify(item)}</li>`
|
|
71
|
+
)
|
|
72
|
+
.join("");
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// ignore parse errors
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
default:
|
|
79
|
+
el.textContent = msg.value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function handleHighlight(msg: PreviewHighlightMessage) {
|
|
84
|
+
ensureHighlightStyles();
|
|
85
|
+
const el = findField(msg.blockTag, msg.fieldName);
|
|
86
|
+
if (!el) return;
|
|
87
|
+
|
|
88
|
+
if (msg.active) {
|
|
89
|
+
el.classList.add(HIGHLIGHT_CLASS);
|
|
90
|
+
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
91
|
+
} else {
|
|
92
|
+
el.classList.remove(HIGHLIGHT_CLASS);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function handleMessage(event: MessageEvent) {
|
|
97
|
+
const msg = event.data as CMSMessage;
|
|
98
|
+
if (!msg || typeof msg !== "object" || !msg.type) return;
|
|
99
|
+
|
|
100
|
+
if (msg.type === "cms-preview-update") {
|
|
101
|
+
handleUpdate(msg as PreviewUpdateMessage);
|
|
102
|
+
} else if (msg.type === "cms-preview-highlight") {
|
|
103
|
+
handleHighlight(msg as PreviewHighlightMessage);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function CMSPreviewListener() {
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
window.addEventListener("message", handleMessage);
|
|
110
|
+
return () => window.removeEventListener("message", handleMessage);
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SectionProps } from "@/lib/types";
|
|
2
|
+
import { str } from "@/lib/types";
|
|
3
|
+
|
|
4
|
+
export function CTASection({ block }: SectionProps) {
|
|
5
|
+
const c = block.content;
|
|
6
|
+
const description = str(c.description);
|
|
7
|
+
const buttonText = str(c.buttonText);
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="py-20 px-6 bg-gray-900 text-white text-center">
|
|
11
|
+
<div className="max-w-2xl mx-auto">
|
|
12
|
+
<h2 data-cms-field="title" className="text-3xl font-bold mb-4">
|
|
13
|
+
{str(c.title, "Ready to get started?")}
|
|
14
|
+
</h2>
|
|
15
|
+
{description && (
|
|
16
|
+
<p data-cms-field="description" className="text-lg text-gray-300 mb-8">
|
|
17
|
+
{description}
|
|
18
|
+
</p>
|
|
19
|
+
)}
|
|
20
|
+
{buttonText && (
|
|
21
|
+
<a
|
|
22
|
+
data-cms-field="buttonText"
|
|
23
|
+
href={str(c.buttonLink, "#")}
|
|
24
|
+
className="inline-block px-8 py-3 bg-white text-gray-900 rounded-lg font-medium hover:bg-gray-100 transition-colors"
|
|
25
|
+
>
|
|
26
|
+
{buttonText}
|
|
27
|
+
</a>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { SectionProps } from "@/lib/types";
|
|
2
|
+
import { str } from "@/lib/types";
|
|
3
|
+
|
|
4
|
+
export function ContactSection({ block }: SectionProps) {
|
|
5
|
+
const c = block.content;
|
|
6
|
+
const title = str(c.title, "Contact Us");
|
|
7
|
+
const description = str(c.description);
|
|
8
|
+
const email = str(c.email);
|
|
9
|
+
const phone = str(c.phone);
|
|
10
|
+
const address = str(c.address);
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="py-16 px-6">
|
|
14
|
+
<div className="max-w-4xl mx-auto">
|
|
15
|
+
<h2 data-cms-field="title" className="text-3xl font-bold text-center mb-4">
|
|
16
|
+
{title}
|
|
17
|
+
</h2>
|
|
18
|
+
{description && (
|
|
19
|
+
<p data-cms-field="description" className="text-center text-gray-600 mb-10 max-w-2xl mx-auto">
|
|
20
|
+
{description}
|
|
21
|
+
</p>
|
|
22
|
+
)}
|
|
23
|
+
<div className="grid md:grid-cols-2 gap-10">
|
|
24
|
+
<div className="space-y-4">
|
|
25
|
+
{email && (
|
|
26
|
+
<p data-cms-field="email">
|
|
27
|
+
<span className="font-medium">Email:</span>{" "}
|
|
28
|
+
<a href={`mailto:${email}`} className="text-blue-600 hover:underline">
|
|
29
|
+
{email}
|
|
30
|
+
</a>
|
|
31
|
+
</p>
|
|
32
|
+
)}
|
|
33
|
+
{phone && (
|
|
34
|
+
<p data-cms-field="phone">
|
|
35
|
+
<span className="font-medium">Phone:</span>{" "}
|
|
36
|
+
<a href={`tel:${phone}`} className="text-blue-600 hover:underline">
|
|
37
|
+
{phone}
|
|
38
|
+
</a>
|
|
39
|
+
</p>
|
|
40
|
+
)}
|
|
41
|
+
{address && (
|
|
42
|
+
<p data-cms-field="address">
|
|
43
|
+
<span className="font-medium">Address:</span> {address}
|
|
44
|
+
</p>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
<form className="space-y-4" onSubmit={(e) => e.preventDefault()}>
|
|
48
|
+
<input
|
|
49
|
+
type="text"
|
|
50
|
+
placeholder="Your Name"
|
|
51
|
+
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-300"
|
|
52
|
+
/>
|
|
53
|
+
<input
|
|
54
|
+
type="email"
|
|
55
|
+
placeholder="Your Email"
|
|
56
|
+
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-300"
|
|
57
|
+
/>
|
|
58
|
+
<textarea
|
|
59
|
+
rows={4}
|
|
60
|
+
placeholder="Your Message"
|
|
61
|
+
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-300"
|
|
62
|
+
/>
|
|
63
|
+
<button
|
|
64
|
+
type="submit"
|
|
65
|
+
className="px-6 py-2 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
|
|
66
|
+
>
|
|
67
|
+
Send Message
|
|
68
|
+
</button>
|
|
69
|
+
</form>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { SectionProps } from "@/lib/types";
|
|
2
|
+
import { str } from "@/lib/types";
|
|
3
|
+
|
|
4
|
+
export function FAQSection({ block }: SectionProps) {
|
|
5
|
+
const c = block.content;
|
|
6
|
+
const title = str(c.title);
|
|
7
|
+
|
|
8
|
+
const faqs: { question: string; answer: string }[] = [];
|
|
9
|
+
if (Array.isArray(c.items?.fieldValue)) {
|
|
10
|
+
for (const item of c.items.fieldValue as Record<string, unknown>[]) {
|
|
11
|
+
const question = (item.question as { fieldValue?: string })?.fieldValue || (item.question as string) || "";
|
|
12
|
+
const answer = (item.answer as { fieldValue?: string })?.fieldValue || (item.answer as string) || "";
|
|
13
|
+
faqs.push({ question, answer });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Fallback: treat each content key as a question/answer pair
|
|
18
|
+
if (faqs.length === 0) {
|
|
19
|
+
for (const [key, field] of Object.entries(c)) {
|
|
20
|
+
if (key === "title" || key === "description") continue;
|
|
21
|
+
if (field?.fieldValue && typeof field.fieldValue === "string") {
|
|
22
|
+
faqs.push({ question: key, answer: field.fieldValue });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="py-16 px-6">
|
|
29
|
+
<div className="max-w-3xl mx-auto">
|
|
30
|
+
{title && (
|
|
31
|
+
<h2 data-cms-field="title" className="text-3xl font-bold text-center mb-12">
|
|
32
|
+
{title}
|
|
33
|
+
</h2>
|
|
34
|
+
)}
|
|
35
|
+
<div data-cms-field="items" className="space-y-6">
|
|
36
|
+
{faqs.map((faq, i) => (
|
|
37
|
+
<details key={i} className="border border-gray-200 rounded-lg">
|
|
38
|
+
<summary className="px-6 py-4 cursor-pointer font-medium hover:bg-gray-50">
|
|
39
|
+
{faq.question}
|
|
40
|
+
</summary>
|
|
41
|
+
<div className="px-6 pb-4 text-gray-600">{faq.answer}</div>
|
|
42
|
+
</details>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SectionProps } from "@/lib/types";
|
|
2
|
+
import { str } from "@/lib/types";
|
|
3
|
+
|
|
4
|
+
export function FeaturesSection({ block }: SectionProps) {
|
|
5
|
+
const c = block.content;
|
|
6
|
+
const title = str(c.title);
|
|
7
|
+
const description = str(c.description);
|
|
8
|
+
|
|
9
|
+
const features: { title: string; description: string }[] = [];
|
|
10
|
+
if (Array.isArray(c.items?.fieldValue)) {
|
|
11
|
+
for (const item of c.items.fieldValue as Record<string, unknown>[]) {
|
|
12
|
+
const t = (item.title as { fieldValue?: string })?.fieldValue || (item.title as string) || "";
|
|
13
|
+
const d = (item.description as { fieldValue?: string })?.fieldValue || (item.description as string) || "";
|
|
14
|
+
features.push({ title: t, description: d });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="py-16 px-6">
|
|
20
|
+
<div className="max-w-5xl mx-auto">
|
|
21
|
+
{title && (
|
|
22
|
+
<h2 data-cms-field="title" className="text-3xl font-bold text-center mb-4">
|
|
23
|
+
{title}
|
|
24
|
+
</h2>
|
|
25
|
+
)}
|
|
26
|
+
{description && (
|
|
27
|
+
<p data-cms-field="description" className="text-lg text-gray-600 text-center mb-12 max-w-2xl mx-auto">
|
|
28
|
+
{description}
|
|
29
|
+
</p>
|
|
30
|
+
)}
|
|
31
|
+
<div data-cms-field="items" className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
32
|
+
{features.map((feat, i) => (
|
|
33
|
+
<div key={i} className="p-6 rounded-lg border border-gray-200">
|
|
34
|
+
<h3 className="text-xl font-semibold mb-2">{feat.title}</h3>
|
|
35
|
+
<p className="text-gray-600">{feat.description}</p>
|
|
36
|
+
</div>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { SectionProps, ListItem } from "@/lib/types";
|
|
2
|
+
import { str } from "@/lib/types";
|
|
3
|
+
|
|
4
|
+
export function GallerySection({ block }: SectionProps) {
|
|
5
|
+
const c = block.content;
|
|
6
|
+
const title = str(c.title);
|
|
7
|
+
const items = (Array.isArray(c.items?.fieldValue) ? c.items.fieldValue : []) as ListItem[];
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="py-16 px-6">
|
|
11
|
+
<div className="max-w-6xl mx-auto">
|
|
12
|
+
{title && (
|
|
13
|
+
<h2 data-cms-field="title" className="text-3xl font-bold text-center mb-10">
|
|
14
|
+
{title}
|
|
15
|
+
</h2>
|
|
16
|
+
)}
|
|
17
|
+
<div
|
|
18
|
+
data-cms-field="items"
|
|
19
|
+
className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
|
|
20
|
+
>
|
|
21
|
+
{items.map((item) => {
|
|
22
|
+
const image = str(item.image as never);
|
|
23
|
+
const caption = str(item.caption as never);
|
|
24
|
+
if (!image) return null;
|
|
25
|
+
return (
|
|
26
|
+
<figure key={item.id} className="group overflow-hidden rounded-lg">
|
|
27
|
+
<img
|
|
28
|
+
src={image}
|
|
29
|
+
alt={caption || "Gallery image"}
|
|
30
|
+
className="w-full h-64 object-cover group-hover:scale-105 transition-transform duration-300"
|
|
31
|
+
/>
|
|
32
|
+
{caption && (
|
|
33
|
+
<figcaption className="p-3 text-sm text-gray-600 text-center">
|
|
34
|
+
{caption}
|
|
35
|
+
</figcaption>
|
|
36
|
+
)}
|
|
37
|
+
</figure>
|
|
38
|
+
);
|
|
39
|
+
})}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { SectionProps } from "@/lib/types";
|
|
2
|
+
|
|
3
|
+
export function GenericSection({ block }: SectionProps) {
|
|
4
|
+
const c = block.content;
|
|
5
|
+
const entries = Object.entries(c).filter(
|
|
6
|
+
([, field]) => field?.fieldValue !== undefined && field?.fieldValue !== null && field?.fieldValue !== ""
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="py-12 px-6">
|
|
11
|
+
<div className="max-w-3xl mx-auto">
|
|
12
|
+
{entries.map(([key, field]) => {
|
|
13
|
+
const value = field.fieldValue;
|
|
14
|
+
|
|
15
|
+
if (field.fieldType === "image" && typeof value === "string") {
|
|
16
|
+
return (
|
|
17
|
+
<img
|
|
18
|
+
key={key}
|
|
19
|
+
data-cms-field={key}
|
|
20
|
+
src={value}
|
|
21
|
+
alt={key}
|
|
22
|
+
className="w-full max-w-lg mx-auto rounded-lg mb-4"
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (field.fieldType === "richtext" && typeof value === "string") {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
key={key}
|
|
31
|
+
data-cms-field={key}
|
|
32
|
+
className="prose prose-gray max-w-none mb-4"
|
|
33
|
+
dangerouslySetInnerHTML={{ __html: value }}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
return (
|
|
40
|
+
<ul key={key} data-cms-field={key} className="list-disc pl-6 mb-4 space-y-1">
|
|
41
|
+
{value.map((item, i) => (
|
|
42
|
+
<li key={i} className="text-gray-700">
|
|
43
|
+
{typeof item === "string" ? item : JSON.stringify(item)}
|
|
44
|
+
</li>
|
|
45
|
+
))}
|
|
46
|
+
</ul>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof value === "string") {
|
|
51
|
+
return (
|
|
52
|
+
<p key={key} data-cms-field={key} className="text-gray-700 mb-3">
|
|
53
|
+
{value}
|
|
54
|
+
</p>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null;
|
|
59
|
+
})}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SectionProps } from "@/lib/types";
|
|
2
|
+
import { str } from "@/lib/types";
|
|
3
|
+
|
|
4
|
+
export function HeroSection({ block }: SectionProps) {
|
|
5
|
+
const c = block.content;
|
|
6
|
+
const buttonText = str(c.buttonText);
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<div className="py-20 px-6 text-center">
|
|
10
|
+
<h1 data-cms-field="header" className="text-4xl md:text-5xl font-bold tracking-tight">
|
|
11
|
+
{str(c.header, "Hero Title")}
|
|
12
|
+
</h1>
|
|
13
|
+
<p data-cms-field="description" className="mt-4 text-lg text-gray-600 max-w-2xl mx-auto">
|
|
14
|
+
{str(c.description, "Hero description goes here.")}
|
|
15
|
+
</p>
|
|
16
|
+
{buttonText && (
|
|
17
|
+
<a
|
|
18
|
+
data-cms-field="buttonText"
|
|
19
|
+
href={str(c.buttonLink, "#")}
|
|
20
|
+
className="mt-8 inline-block px-6 py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
|
|
21
|
+
>
|
|
22
|
+
{buttonText}
|
|
23
|
+
</a>
|
|
24
|
+
)}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { SectionProps, ListItem } from "@/lib/types";
|
|
5
|
+
import { str } from "@/lib/types";
|
|
6
|
+
|
|
7
|
+
export function SliderSection({ block }: SectionProps) {
|
|
8
|
+
const c = block.content;
|
|
9
|
+
const items = (Array.isArray(c.items?.fieldValue) ? c.items.fieldValue : []) as ListItem[];
|
|
10
|
+
const [current, setCurrent] = useState(0);
|
|
11
|
+
|
|
12
|
+
if (items.length === 0) return null;
|
|
13
|
+
|
|
14
|
+
const item = items[current];
|
|
15
|
+
const title = str(item.title as never);
|
|
16
|
+
const subtitle = str(item.subtitle as never);
|
|
17
|
+
const image = str(item.image as never);
|
|
18
|
+
const linkUrl = str(item.linkUrl as never);
|
|
19
|
+
const linkText = str(item.linkText as never, "Learn more");
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="relative py-16 px-6" data-cms-field="items">
|
|
23
|
+
<div className="max-w-4xl mx-auto text-center">
|
|
24
|
+
{image && (
|
|
25
|
+
<img
|
|
26
|
+
src={image}
|
|
27
|
+
alt={title}
|
|
28
|
+
className="w-full max-h-96 object-cover rounded-lg mb-6"
|
|
29
|
+
/>
|
|
30
|
+
)}
|
|
31
|
+
{title && <h2 className="text-3xl font-bold">{title}</h2>}
|
|
32
|
+
{subtitle && <p className="mt-2 text-lg text-gray-600">{subtitle}</p>}
|
|
33
|
+
{linkUrl && (
|
|
34
|
+
<a
|
|
35
|
+
href={linkUrl}
|
|
36
|
+
className="mt-4 inline-block px-5 py-2 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
|
|
37
|
+
>
|
|
38
|
+
{linkText}
|
|
39
|
+
</a>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
{items.length > 1 && (
|
|
44
|
+
<div className="flex justify-center gap-4 mt-8">
|
|
45
|
+
<button
|
|
46
|
+
onClick={() => setCurrent((c) => (c - 1 + items.length) % items.length)}
|
|
47
|
+
className="px-4 py-2 border rounded-lg hover:bg-gray-100 transition-colors"
|
|
48
|
+
aria-label="Previous slide"
|
|
49
|
+
>
|
|
50
|
+
←
|
|
51
|
+
</button>
|
|
52
|
+
<span className="flex items-center text-sm text-gray-500">
|
|
53
|
+
{current + 1} / {items.length}
|
|
54
|
+
</span>
|
|
55
|
+
<button
|
|
56
|
+
onClick={() => setCurrent((c) => (c + 1) % items.length)}
|
|
57
|
+
className="px-4 py-2 border rounded-lg hover:bg-gray-100 transition-colors"
|
|
58
|
+
aria-label="Next slide"
|
|
59
|
+
>
|
|
60
|
+
→
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|